diff --git a/docs/CODEX.md b/docs/CODEX.md index 8142883..aeba009 100644 --- a/docs/CODEX.md +++ b/docs/CODEX.md @@ -62,6 +62,12 @@ disables discovery. `RELAY_DIR` relocates Switchboard state for an isolated test or pick a Codex model in the app's card in the menu-bar panel: Switchboard raises the same connect card pre-filled with the current grant plus that model, and one tap widens it (Deny changes nothing). New connects pre-select every signed-in provider's default, grouped by provider; untick to refuse. +- **Class grants (opt-in):** an app may ask for a capability class instead of ids — + `claude_connect({ requirements: [{ class: "cloud-coding" }] })`. The user still approves concrete models on + the card; the providers behind them become the grant's allowed providers, and a new conversation resolves to + any enabled model of that class on those providers. That keeps a grant working when a provider's catalog + moves (gpt-5.5 → gpt-6-astra) or one provider is offline. Legacy `scope.models` grants are unchanged and + never widened. `claude_capabilities().modelInfo[].classes` lists each model's classes. - **Global default vs. an app's grant:** if the global `defaultModel` is not granted to an app, that app's effective default is the first enabled model in its own grant — discovery and routing agree, and the grant is never widened. An explicit request for an ungranted model is still refused. diff --git a/packages/protocol/src/permissions.ts b/packages/protocol/src/permissions.ts index 1e2c20c..c5ed49a 100644 --- a/packages/protocol/src/permissions.ts +++ b/packages/protocol/src/permissions.ts @@ -52,6 +52,11 @@ export interface Budgets { } /** The grant object stored (authoritatively) in the daemon and mirrored to the extension UI. */ +/** CAPABILITY CLASS of a model — what an app can ask for instead of a provider's model id, so a grant + * survives a provider's catalog moving under it (2026-09-07: Codex gpt-5.5 → gpt-6-astra) and works on + * whichever granted provider is online. A model may satisfy several classes. */ +export type ModelClass = "cloud-coding" | "cloud-vision" | "local-text"; + export interface OriginGrant { /** Browser-verified origin, e.g. "https://shop.example". The permission key. */ origin: string; @@ -59,6 +64,12 @@ export interface OriginGrant { mode: ConsentMode; /** Model ids this origin may request. Empty = none. Maps to daemon model backends. */ models: string[]; + /** CLASS grant (additive; set only when the app opted in via ScopeRequest.requirements): when present, + * ANY enabled, online model of one of these classes on one of `providers` is also allowed — resolved at + * call time. `models` stays the explicit-id set. Absent on legacy grants ⇒ behaviour unchanged. */ + classes?: ModelClass[]; + /** Provider ids the user allowed at consent (derived from the approved models' backends). Never widened. */ + providers?: string[]; /** USER-chosen default for new conversations, overriding the app's requested model. * Existing conversation IDs keep their starting model. Must be one of `models`; * cleared if it falls out of grant. undefined = honor the app's request/global preference. */ @@ -78,6 +89,9 @@ export interface OriginGrant { * in the consent popup — the granted scope may be smaller than requested, never larger. */ export interface ScopeRequest { models?: string[]; + /** Ask for a CLASS of model instead of ids (e.g. [{ class: "cloud-coding" }]). Opting in makes the grant + * class-based (OriginGrant.classes/providers) so it is not bound to one provider's model ids. */ + requirements?: Array<{ class: ModelClass }>; /** Tool names the site wants; the daemon resolves each to an access class and the user * approves per-tool. Omit to request "read-only tools only". */ tools?: string[]; diff --git a/packages/protocol/src/rpc.ts b/packages/protocol/src/rpc.ts index 2cb133f..cc84d7d 100644 --- a/packages/protocol/src/rpc.ts +++ b/packages/protocol/src/rpc.ts @@ -46,6 +46,8 @@ export interface ModelInfo { capabilities: { vision: boolean; agentic: boolean; warmSessions: boolean }; /** Codex uses broker MCP tools; Claude Code may also use its runtime's tools. */ toolSource: "broker-mcp" | "claude-code" | "none"; + /** Capability classes this model satisfies (see permissions ModelClass). */ + classes?: Array<"cloud-coding" | "cloud-vision" | "local-text">; } /** Provider capabilities, returned by claude_capabilities for feature detection. */ diff --git a/packages/sidekick/src/backends/registry.ts b/packages/sidekick/src/backends/registry.ts index 61470eb..119cdec 100644 --- a/packages/sidekick/src/backends/registry.ts +++ b/packages/sidekick/src/backends/registry.ts @@ -72,6 +72,18 @@ export class BackendRegistry { return [...this.modelToBackend.keys()]; } + /** Capability classes a model satisfies (protocol ModelClass). Local runners are text-only; a cloud model + * with an agentic tool loop is "cloud-coding"; a vision-capable cloud model is also "cloud-vision". */ + classesOf(model: string): Array<"cloud-coding" | "cloud-vision" | "local-text"> { + const backend = this.modelToBackend.get(model); + if (!backend) return []; + if (backend.id === "ollama" || /[:/]/.test(model)) return ["local-text"]; + const out: Array<"cloud-coding" | "cloud-vision" | "local-text"> = []; + if (backend.capabilities?.agentic) out.push("cloud-coding"); + if (backend.capabilities?.vision) out.push("cloud-vision"); + return out; + } + modelInfo(): ModelInfo[] { return this.allowedModels().map((id) => { const backend = this.modelToBackend.get(id)!; @@ -83,6 +95,7 @@ export class BackendRegistry { warmSessions: backend.capabilities?.warmSessions === true, }, toolSource: backend.id === "claude-code" ? "claude-code" : backend.capabilities?.agentic ? "broker-mcp" : "none", + classes: this.classesOf(id), }; }); } diff --git a/packages/sidekick/src/backends/routing.test.ts b/packages/sidekick/src/backends/routing.test.ts index cf33225..2049cd9 100644 --- a/packages/sidekick/src/backends/routing.test.ts +++ b/packages/sidekick/src/backends/routing.test.ts @@ -117,6 +117,7 @@ test("apps discover backend features and their own granted default", async () => assert.deepEqual(a.modelInfo.find((m: any) => m.id === "model-a"), { id: "model-a", backend: "codex", hosted: false, capabilities: { vision: true, agentic: true, warmSessions: true }, toolSource: "broker-mcp", + classes: ["cloud-coding", "cloud-vision"], // slice 5a: discovery advertises capability classes }); assert.deepEqual(a.modelInfo.find((m: any) => m.id === "local-text").capabilities, { vision: false, agentic: false, warmSessions: false }); @@ -438,3 +439,49 @@ test("mixed providers: picking an ungranted provider's model raises a one-tap re assert.equal(asked, null); assert.equal(r.ok, true); assert.equal(grants.get(app)?.modelOverride, "claude-a"); }); +test("class grants: resolve per granted provider, never to an excluded one, and survive catalog drift", async () => { + const dir = mkdtempSync(join(tmpdir(), "sb-class-")); + const backends = new BackendRegistry(); + const codexModels = ["codex-a"]; + const mk = (id: string, models: () => string[]): ModelBackend => ({ id, capabilities: { vision: true, agentic: true }, healthy: async () => true, listModels: async () => models(), run: async (params) => ({ text: params.model!, usage: { inputTokens: 1, outputTokens: 1 } }) }); + backends.register(mk("claude-code", () => ["claude-a"])); backends.register(mk("codex", () => codexModels)); + await backends.refreshModels(); + const grants = new GrantStore(dir); + const mcp = new McpRegistry(); + const gate = new Gate(grants, new BudgetLedger(), new AuditLog(dir), { requestWriteConsent: async () => false } as any, mcp); + const broker = new Broker({ config: { stateDir: dir }, backends, grants, budgets: new BudgetLedger(), audit: new AuditLog(dir), gate, mcp, storage: new StorageStore(dir), sessions: { end() {} } } as any) as any; + const b = { maxCallsPerMin: 100, maxTokensPerDay: 100000 }; + const classy = "https://classy.test", excl = "https://claude-only-class.test", legacy = "https://legacy-ids.test"; + grants.upsert(classy, { models: ["claude-a", "codex-a"], tools: [], budgets: b, classes: ["cloud-coding"], providers: ["claude-code", "codex"] }); + grants.upsert(excl, { models: ["claude-a"], tools: [], budgets: b, classes: ["cloud-coding"], providers: ["claude-code"] }); + grants.upsert(legacy, { models: ["claude-a", "codex-a"], tools: [], budgets: b }); + try { + // default on Codex: the class-granted app honours it; the provider-excluded app never leaves Claude + writeFileSync(join(process.env.RELAY_DIR!, "models.json"), JSON.stringify({ disabled: [], defaultModel: "codex-a" })); + assert.equal((await broker.complete(classy, { prompt: "x", sessionId: "c1" })).model, "codex-a"); + assert.equal((await broker.complete(excl, { prompt: "x", sessionId: "e1" })).model, "claude-a"); + await assert.rejects(broker.complete(excl, { prompt: "x", model: "codex-a", sessionId: "e2" }), /grant|scope|not granted/i); + // CATALOG DRIFT: Codex retires codex-a and ships codex-b (2026-09-07: gpt-5.5 → gpt-6-astra) + codexModels.splice(0, 1, "codex-b"); await backends.refreshModels(); + writeFileSync(join(process.env.RELAY_DIR!, "models.json"), JSON.stringify({ disabled: [], defaultModel: "codex-b" })); + // the CLASS-granted app follows the provider's new model (class × provider still granted) … + assert.equal((await broker.complete(classy, { prompt: "x", sessionId: "c2" })).model, "codex-b"); + assert.equal(grants.allowsModel(classy, "codex-b"), true); + // … the LEGACY id-only app is never widened to it: falls back inside its explicit ids + assert.equal((await broker.complete(legacy, { prompt: "x", sessionId: "l1" })).model, "claude-a"); + assert.equal(grants.allowsModel(legacy, "codex-b"), false); + await assert.rejects(broker.complete(legacy, { prompt: "x", model: "codex-b", sessionId: "l2" }), /grant|scope|not granted/i); + // a user override may now point at a class-allowed model + assert.equal(grants.setModelOverride(classy, "codex-b")?.modelOverride, "codex-b"); + assert.equal(grants.setModelOverride(legacy, "codex-b"), null); + // connect(): an app that asks for a CLASS gets classes + providers derived from what the user approved + broker.requestConnectConsent = async (_o: string, body: any) => ({ models: body.models.available, tools: [], budgets: body.budgets }); + const g = await broker.connect("https://asks-class.test", { reason: "t", tools: [], requirements: [{ class: "cloud-coding" }] }); + assert.deepEqual(g.classes, ["cloud-coding"]); + assert.deepEqual([...g.providers].sort(), ["claude-code", "codex"]); + assert.ok((await broker.capabilities("https://asks-class.test")).modelInfo.every((m: any) => Array.isArray(m.classes))); + } finally { + writeFileSync(join(process.env.RELAY_DIR!, "models.json"), JSON.stringify({ disabled: [] })); + } +}); + diff --git a/packages/sidekick/src/security/grant-store.ts b/packages/sidekick/src/security/grant-store.ts index fca521b..20881cc 100644 --- a/packages/sidekick/src/security/grant-store.ts +++ b/packages/sidekick/src/security/grant-store.ts @@ -1,6 +1,6 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import type { OriginGrant, ScopeRequest, ToolGrant } from "@relay/protocol"; +import type { OriginGrant, ScopeRequest, ToolGrant, ModelClass } from "@relay/protocol"; import { DEFAULT_BUDGETS } from "@relay/protocol"; /** @@ -35,6 +35,17 @@ export class GrantStore { this.load(); } + /** CLASS grants (slice 5a) need to know a model's classes + provider; the registry owns that, so the + * broker injects a resolver rather than this store importing backends. Without one, class grants are inert. */ + private classResolver: ((model: string) => { classes: string[]; provider: string } | null) | null = null; + setClassResolver(fn: (model: string) => { classes: string[]; provider: string } | null) { this.classResolver = fn; } + /** Is this model allowed by the grant's CLASS × PROVIDER set (ignoring the explicit id list)? */ + classAllows(g: OriginGrant, model: string): boolean { + if (!g.classes?.length || !g.providers?.length || !this.classResolver) return false; + const r = this.classResolver(model); + return !!r && g.providers.includes(r.provider) && r.classes.some((c) => (g.classes as string[]).includes(c)); + } + private load() { if (!existsSync(this.file)) return; try { @@ -66,13 +77,15 @@ export class GrantStore { /** Create/replace a grant from the scope the USER approved (already narrowed by the consent * UI). `approvedTools` carries each tool with its daemon-assigned access class. */ - upsert(origin: string, approved: { models: string[]; tools: ToolGrant[]; budgets: ScopeRequest["budgets"]; contextKinds?: string[]; expiresAt?: number }): OriginGrant { + upsert(origin: string, approved: { models: string[]; tools: ToolGrant[]; budgets: ScopeRequest["budgets"]; contextKinds?: string[]; expiresAt?: number; classes?: ModelClass[]; providers?: string[] }): OriginGrant { const now = Date.now(); const prev = this.grants.get(origin); const grant: OriginGrant = { origin, mode: prev?.mode ?? "ask", // preserve the user's chosen trust mode across re-consents; default ask models: approved.models, + classes: approved.classes?.length ? approved.classes : undefined, + providers: approved.providers?.length ? approved.providers : undefined, // Preserve the user's model override across re-consents, but only if it's still granted. modelOverride: prev?.modelOverride && approved.models.includes(prev.modelOverride) ? prev.modelOverride : undefined, tools: approved.tools, @@ -92,7 +105,7 @@ export class GrantStore { setModelOverride(origin: string, model: string | null): OriginGrant | null { const g = this.grants.get(origin); if (!g) return null; - if (model && !g.models.includes(model)) return null; // can only override to a granted model + if (model && !this.allowsModel(origin, model)) return null; // can only override to a granted model (id or class×provider) g.modelOverride = model ?? undefined; g.updatedAt = Date.now(); this.persist(); @@ -146,9 +159,9 @@ export class GrantStore { allowsModel(origin: string, model: string | undefined): boolean { const g = this.get(origin); if (!g) return false; - if (!model) return g.models.length > 0; + if (!model) return g.models.length > 0 || !!g.classes?.length; const want = canonicalModel(model); - return g.models.some((m) => canonicalModel(m) === want); + return g.models.some((m) => canonicalModel(m) === want) || this.classAllows(g, model); } toolGrant(origin: string, name: string): ToolGrant | null { diff --git a/packages/sidekick/src/server.ts b/packages/sidekick/src/server.ts index 02603a8..1ef43b4 100644 --- a/packages/sidekick/src/server.ts +++ b/packages/sidekick/src/server.ts @@ -27,6 +27,7 @@ import type { SbBrandResult, GuideRunParams, GuideResult, + ModelClass, } from "@relay/protocol"; import { BYOP_VERSION, BYOPErrorCode, ProviderError, isTabPrincipal, hostOfTabPrincipal, nativePrincipal } from "@relay/protocol"; import { CONNECTOR_META, connectorIdOf, connectorsInClass, type ConnectorClass } from "@relay/protocol"; @@ -157,7 +158,9 @@ export class Broker implements ConsentPrompter, NativeHandler { private modelStateSignature: string | undefined; private sessionRoutes: SessionRoutes; - constructor(private deps: BrokerDeps) { this.sessionRoutes = new SessionRoutes(deps.config.stateDir); } + constructor(private deps: BrokerDeps) { + // CLASS grants (slice 5a): the grant store checks class×provider through the registry's view of a model. + this.deps.grants?.setClassResolver?.((m) => { const b = this.deps.backends.backendFor(m); return b ? { classes: this.deps.backends.classesOf(m), provider: b.id } : null; }); this.sessionRoutes = new SessionRoutes(deps.config.stateDir); } start() { const { host, port, pairingToken } = this.deps.config; @@ -1058,7 +1061,13 @@ export class Broker implements ConsentPrompter, NativeHandler { // = the UI never asked (a scope-upgrade re-consent may ask later). const approvedKinds = (approved as unknown as { contextKinds?: unknown }).contextKinds; const contextKinds = Array.isArray(approvedKinds) ? approvedKinds.map((k) => String(k)).filter(Boolean) : undefined; - const grant = this.deps.grants.upsert(origin, { models: approved.models, tools, budgets: approved.budgets, contextKinds, expiresAt: approved.expiresAt }); + // CLASS grant (slice 5a) — only when the app opted in with `requirements`. The user still approved concrete + // models on the card; the providers behind those become the allowed provider set, and the requested classes + // let a NEW conversation resolve to any enabled model of that class on those providers — so the grant is + // not bound to one provider's model ids (catalog drift, provider offline). Legacy apps: unchanged. + const classes = (requested.requirements ?? []).map((r) => r.class).filter((c): c is ModelClass => c === "cloud-coding" || c === "cloud-vision" || c === "local-text"); + const providers = classes.length ? [...new Set(approved.models.map((m) => this.deps.backends.backendFor(m)?.id).filter((id): id is string => !!id))] : []; + const grant = this.deps.grants.upsert(origin, { models: approved.models, tools, budgets: approved.budgets, contextKinds, expiresAt: approved.expiresAt, classes, providers }); const selectedModel = (approved as unknown as { modelOverride?: string }).modelOverride; if (selectedModel && approved.models.includes(selectedModel)) this.deps.grants.setModelOverride(origin, selectedModel); else if (approved.models.length === 1) this.deps.grants.setModelOverride(origin, approved.models[0]!); @@ -1431,7 +1440,14 @@ export class Broker implements ConsentPrompter, NativeHandler { if (fit.ok) return { ...selected, model: candidate }; if (explicit) throw this.toolsUnservable(candidate, fit.unservable); } - const eligible = (this.deps.grants.get(origin)?.models ?? []).filter((m) => this.deps.backends.isAllowed(m) && this.deps.backends.capabilityModels().includes(m)); + const grantNow = this.deps.grants.get(origin); + const idEligible = (grantNow?.models ?? []).filter((m) => this.deps.backends.isAllowed(m) && this.deps.backends.capabilityModels().includes(m)); + // CLASS grant (slice 5a): any enabled, online model of a granted class on a granted provider is eligible + // too — this is what lets a grant survive a provider's catalog moving under it. + const classEligible = grantNow?.classes?.length + ? this.deps.backends.allowedModels().filter((m) => this.deps.backends.capabilityModels().includes(m) && !idEligible.includes(m) && this.deps.grants.classAllows(grantNow, m)) + : []; + const eligible = [...idEligible, ...classEligible]; const fitModel = eligible.find((m) => this.toolFit(origin, m, params).ok); if (fitModel) return { ...selected, model: fitModel }; const blamed = candidate ?? eligible[0];