feat: require explicit opt-in for Claude Code use

This commit is contained in:
amosblomqvist
2026-10-28 21:37:03 +02:00
parent ab76e4ff50
commit c9c8cead57
16 changed files with 417 additions and 88 deletions
+12 -2
View File
@@ -29,6 +29,7 @@
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import { AUTH_PROVIDER_KEYS } from "./auth-keys.ts";
import { CLAUDE_CODE_PROVIDER } from "./contracts/claude-code.ts";
import type { ModelRole } from "../../config.ts";
import type { ChatSettings } from "./contracts/chat.ts";
import type { SearchEngineId } from "./search.ts";
@@ -156,10 +157,11 @@ export interface ProviderKeyMissing {
/** The refusal wording's invariant tail — the matcher below keys on it
* (the label prefix varies by provider). */
const PROVIDER_KEY_MISSING_TAIL = "key is set — add one in Settings → Keys";
const CLAUDE_CODE_UNAVAILABLE = "Claude Code is unavailable. Enable and set it up in Settings → Providers.";
export function providerKeyMissing(provider: string, slots: ModelSlotName[]): ProviderKeyMissing {
const label = AUTH_PROVIDER_KEYS.find((p) => p.id === provider)?.label ?? provider;
return {
error: `No ${label} ${PROVIDER_KEY_MISSING_TAIL}`,
error: provider === CLAUDE_CODE_PROVIDER ? CLAUDE_CODE_UNAVAILABLE : `No ${label} ${PROVIDER_KEY_MISSING_TAIL}`,
code: PROVIDER_KEY_MISSING_CODE,
provider,
slots,
@@ -170,7 +172,7 @@ export function providerKeyMissing(provider: string, slots: ModelSlotName[]): Pr
* scary-error) — the envelope's home module owns the string so the
* writer and the matcher can never drift. */
export function isProviderKeyRefusal(error: string | undefined): boolean {
return !!error && error.endsWith(PROVIDER_KEY_MISSING_TAIL);
return !!error && (error.endsWith(PROVIDER_KEY_MISSING_TAIL) || error === CLAUDE_CODE_UNAVAILABLE);
}
/** The typed throw for in-process seams (extraction pipeline) — the
* ProviderKeyMissing sibling of ModelsNotConfiguredError: callers catch
@@ -368,6 +370,8 @@ export interface UserSettings {
version: number;
general?: GeneralSettings;
dictation?: DictationSettings;
/** Explicit app-wide opt-in; absent/invalid is off, including older records. */
claudeCode?: { enabled?: boolean };
agent?: AgentSettings;
/** The strong/cheap/vision model slots (model slots wave, 2026-09-20). */
models?: ModelsSettings;
@@ -631,6 +635,12 @@ export function sanitizeUserSettings(raw: unknown): UserSettings | null {
? { language: (raw as DictationSettings).language }
: {};
}
if (r.claudeCode !== undefined) {
const raw = r.claudeCode;
out.claudeCode = raw !== null && typeof raw === "object" && !Array.isArray(raw)
&& typeof (raw as Record<string, unknown>).enabled === "boolean"
? { enabled: (raw as { enabled: boolean }).enabled } : {};
}
if (r.agent !== null && typeof r.agent === "object" && !Array.isArray(r.agent)) {
out.agent = sanitizeAgent(r.agent as Record<string, unknown>);
}
+8 -1
View File
@@ -7,7 +7,7 @@ import {
type TeachingAgent,
} from "../agent/teacher-agent.ts";
import { piEngineForSpec, type PiEngine } from "../agent/models.ts";
import { connectionBackgroundAllowed, connectionScopeCurrent, isSubscriptionProvider } from "../agent/subscription-connections.ts";
import { connectionBackgroundAllowed as subscriptionBackgroundAllowed, connectionScopeCurrent, isSubscriptionProvider } from "../agent/subscription-connections.ts";
import { nativeSubscriptionError } from "../agent/subscription-stream.ts";
import { chatGPTStreamError } from "../agent/chatgpt-stream.ts";
import { SubscriptionRequestCycle, type SubscriptionDispatchLifecycle } from "../agent/subscription-request-cycle.ts";
@@ -33,6 +33,7 @@ import { providerEventNote } from "../agent/provider-events.ts";
import { effectiveSettings, stores } from "./stores.ts";
import { searchAvailable, searchProvider } from "./search.ts";
import { providerKeyPresent } from "./keys.ts";
import { CLAUDE_CODE_PROVIDER } from "../core/contracts/claude-code.ts";
import {
extractionEnabled,
graphToolsEnabled,
@@ -80,6 +81,12 @@ import type {CallUsage} from "../core/contracts.ts";
* for write-ahead persistence, turn liveness, and the turn services.
*/
/** Fresh check only at new background-run admission; never cancel a run
* already in progress when the opt-in is switched off. */
function connectionBackgroundAllowed(provider: string): boolean {
return subscriptionBackgroundAllowed(provider) && (provider !== CLAUDE_CODE_PROVIDER || providerKeyPresent(provider));
}
function settlementErrorText(error: unknown): string {
return error instanceof TurnPersistenceError ? error.message : "An error occurred.";
}
+13
View File
@@ -0,0 +1,13 @@
import { getClaudeBinary } from "../agent/claude-code/binary.ts";
import { settingsStore } from "../store/settings.ts";
/** Fresh admission state, not a process-wide cache. Existing runs retain
* their engines; only new chat/job/extraction/compaction admissions consult it. */
export function claudeCodeEnabled(): boolean {
return settingsStore.readSettings()?.claudeCode?.enabled === true;
}
/** Installation/opt-in availability only, never authenticated readiness. */
export function claudeCodeAvailable(): boolean {
return claudeCodeEnabled() && getClaudeBinary() !== null;
}
+3
View File
@@ -1,6 +1,7 @@
import { inspectClaudeInstallation } from "../agent/claude-code/installation.ts";
import { initAuthEnv } from "./keys.ts";
import { subscriptionRequestOrigin } from "./web-subscription-http.ts";
import { claudeCodeEnabled } from "./claude-code-config.ts";
/** The Settings status seam: initialize provenance before any child env is copied. */
export async function claudeCodeInstallationStatus() {
@@ -13,6 +14,7 @@ export async function claudeCodeInstallationStatus() {
export async function claudeCodeInstallationResponse(
request: Request,
inspect = claudeCodeInstallationStatus,
enabled = claudeCodeEnabled,
): Promise<Response> {
const origin = subscriptionRequestOrigin(request);
const supplied = request.headers.get("origin");
@@ -23,5 +25,6 @@ export async function claudeCodeInstallationResponse(
|| (supplied !== null && supplied !== origin)) {
return Response.json({ error: "Claude Code checks require a same-origin LearnHarness request." }, { status: 403, headers });
}
if (!enabled()) return Response.json({ error: "Claude Code is disabled." }, { status: 403, headers });
return Response.json(await inspect(), { headers });
}
+4 -3
View File
@@ -53,7 +53,7 @@ import {
} from "../core/auth-keys.ts";
import { authStore } from "../store/auth.ts";
import { isSubscriptionProvider, subscriptionConnected } from "../agent/subscription-connections.ts";
import { getClaudeBinary } from "../agent/claude-code/binary.ts";
import { claudeCodeAvailable } from "./claude-code-config.ts";
import { CLAUDE_CODE_PROVIDER } from "../agent/claude-code/constants.ts";
// Shared HMR-stable provenance lets external CLIs exclude file-only injections
@@ -189,8 +189,9 @@ export function keyValue(id: string): string | undefined {
* (gate by what would run). Providers absent from AUTH_PROVIDER_KEYS take
* no key at all: nothing to gate, present by definition. */
export function providerKeyPresent(provider: string): boolean {
// Presence only, not authentication readiness. Launch performs the version gate.
if (provider === CLAUDE_CODE_PROVIDER) return getClaudeBinary() !== null;
// Explicit opt-in + installation only, never authentication readiness.
// Consulted at admission, not inside already-running agent loops.
if (provider === CLAUDE_CODE_PROVIDER) return claudeCodeAvailable();
if (isSubscriptionProvider(provider)) return subscriptionConnected(provider);
const info = AUTH_PROVIDER_KEYS.find((p) => p.id === provider);
return info ? keyValue(info.envVar) !== undefined : true;
+3 -2
View File
@@ -31,6 +31,7 @@ export async function refreshConnectionCatalog(provider?: string): Promise<void>
]);
}
function connectionModel(provider: string, id: string): Model<Api> | undefined {
if (provider === CLAUDE_CODE_PROVIDER && !claudeCodeAvailable()) return undefined;
return provider === "chatgpt" ? chatGPTModel(id) : nativeSubscriptionId(provider) ? nativeSubscriptionModel(provider, id) : piModels().getModel(provider, id);
}
function connectionProviderIds(): string[] { return [...piModels().getProviders().map((p) => p.id), "chatgpt", ...NATIVE_SUBSCRIPTIONS.map((p) => p.logicalId)]; }
@@ -48,7 +49,7 @@ import type {
} from "../core/contracts/models.ts";
import type { EffectiveModelSpec, EffectiveSlot } from "../core/settings-resolution.ts";
import { AUTH_PROVIDER_KEYS } from "../core/auth-keys.ts";
import { getClaudeBinary } from "../agent/claude-code/binary.ts";
import { claudeCodeAvailable } from "./claude-code-config.ts";
import { CLAUDE_CODE_PROVIDER } from "../agent/claude-code/constants.ts";
import { BACKGROUND_MODEL_ROLES, MODEL_SLOTS, type BackgroundModelRole, type ModelSlotName, type ModelsSettings } from "../core/settings.ts";
@@ -143,7 +144,7 @@ export function catalogProviders(): CatalogProvider[] {
keyPresent: keyValue(p.envVar) !== undefined,
})).concat([
{ id: "chatgpt", envVar: "", keyPresent: chatGPTConnected() },
{ id: CLAUDE_CODE_PROVIDER, envVar: "", keyPresent: getClaudeBinary() !== null },
{ id: CLAUDE_CODE_PROVIDER, envVar: "", keyPresent: claudeCodeAvailable() },
...NATIVE_SUBSCRIPTIONS.map((p) => ({ id: p.logicalId, envVar: "", keyPresent: nativeSubscriptionConnected(p.providerId) })),
]).sort((a, b) => a.id.localeCompare(b.id));
}
+10
View File
@@ -91,6 +91,15 @@ export function parseGeneralSettingsBody(body: unknown): SettingsBodyResult<Gene
return { ok: true, settings };
}
/** claudeCode: explicit app-wide opt-in. Empty section resets to off. */
export function parseClaudeCodeSettingsBody(body: unknown): SettingsBodyResult<{ enabled?: boolean }> {
const raw = asRecord(body);
if (!raw) return { ok: false, error: "Invalid JSON body" };
if (raw.enabled === undefined) return { ok: true, settings: {} };
if (typeof raw.enabled !== "boolean") return { ok: false, error: "enabled must be a boolean" };
return { ok: true, settings: { enabled: raw.enabled } };
}
/** agent.models: only the six known background roles; each override's
* fields mirror ChatSettings (model pair + independent thinkingLevel). */
function parseRoleOverride(value: unknown, role: string): { ok: true; override: RoleModelOverride } | { ok: false; error: string } {
@@ -397,6 +406,7 @@ export const SETTINGS_SECTIONS = {
compaction: parseCompactionSettingsBody,
models: parseModelsSettingsBody,
dictation: parseDictationSettingsBody,
claudeCode: parseClaudeCodeSettingsBody,
} as const;
export type SettingsSectionName = keyof typeof SETTINGS_SECTIONS;