Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/CODEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions packages/protocol/src/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,24 @@ 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;
/** How writes are handled for this site — the user's per-origin trust setting. Default "ask". */
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. */
Expand All @@ -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[];
Expand Down
2 changes: 2 additions & 0 deletions packages/protocol/src/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
13 changes: 13 additions & 0 deletions packages/sidekick/src/backends/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)!;
Expand All @@ -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),
};
});
}
Expand Down
47 changes: 47 additions & 0 deletions packages/sidekick/src/backends/routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -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: [] }));
}
});

23 changes: 18 additions & 5 deletions packages/sidekick/src/security/grant-store.ts
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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();
Expand Down Expand Up @@ -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 {
Expand Down
22 changes: 19 additions & 3 deletions packages/sidekick/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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]!);
Expand Down Expand Up @@ -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];
Expand Down
Loading