Skip to content

Commit 452d118

Browse files
committed
feat(user-foundation): profile, onboarding, per-model toggles, agent defaults, theme
Completes the user-foundation cluster (design gaps #4,#6,#8,#13,#14): - agent-worker: StartRunInput personalization fields; run-create personalization load + disabled-model 400 gate (run-personalization.ts); startAgentRun plumbing; mastra-stream context threading; fallback suppression when GPT-5.4 Mini disabled; BYOK direct-vs-OpenRouter transport rule - gateway: GET/PATCH /v1/me/profile + OpenAPI; authenticate.ts extraction (index.ts now 689 lines); @cheatcode/auth updateClerkUserPublicMetadata wrapper; synchronous Clerk onboarding-claim mirror - web: profile API + hooks, ProfileModelSync, Personalization panel (name/memory/two-row agent defaults), per-model toggles, theme unlock (system/light/dark), 5-step onboarding flow + middleware gate (clean impl, no backfill) Full repo gate green (61/61 typecheck+lint+build).
1 parent b409916 commit 452d118

29 files changed

Lines changed: 1847 additions & 243 deletions

.claude/last-session.json

Lines changed: 0 additions & 7 deletions
This file was deleted.

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,6 @@ plans/
2828
# Local debugging artifacts (agent-browser screenshots, clipboard scratch)
2929
/*.png
3030
.gemini-clipboard/
31+
32+
# Claude Code transient session state
33+
.claude/last-session.json

apps/agent-worker/src/agent-routing.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
getProjectWriteState,
99
getThread,
1010
getUserDailyUsageCostUsd,
11+
type RunPersonalization,
1112
withUserContext,
1213
} from "@cheatcode/db";
1314
import { APIError, emitUserEvent } from "@cheatcode/observability";
@@ -122,6 +123,7 @@ export async function startAgentRun(
122123
body: CreateRun,
123124
sandboxName: string,
124125
policy: RunEntitlementPolicy,
126+
personalization: RunPersonalization,
125127
): Promise<Response> {
126128
const messageText = extractRunMessageText(body);
127129
const response = await fetchAgentRun(
@@ -137,6 +139,11 @@ export async function startAgentRun(
137139
? {}
138140
: { dailyCostCapUsd: policy.dailyCostCapUsd }),
139141
...(run.masterInstructions ? { masterInstructions: run.masterInstructions } : {}),
142+
...(personalization.agentDisplayName
143+
? { agentDisplayName: personalization.agentDisplayName }
144+
: {}),
145+
...(personalization.globalMemory ? { globalMemory: personalization.globalMemory } : {}),
146+
disabledModels: personalization.disabledModels,
140147
messageText,
141148
model: body.model ?? run.modelId,
142149
projectId: run.projectId,
@@ -215,6 +222,7 @@ export async function startLegacyThreadRun(
215222
...(policy.dailyCostCapUsd === undefined
216223
? {}
217224
: { dailyCostCapUsd: policy.dailyCostCapUsd }),
225+
disabledModels: [],
218226
messageText: extractRunMessageText(body),
219227
model: body.model,
220228
projectId,

apps/agent-worker/src/durable-objects/agent-run-mastra-stream.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export async function runMastraStream(options: MastraStreamOptions): Promise<voi
5757
requestContext: createCodeRequestContext(
5858
{ artifacts: options.artifactRuntime, sandbox },
5959
{
60+
agentDisplayName: input.agentDisplayName,
6061
anthropicApiKey: credential.provider === "anthropic" ? credential.apiKey : undefined,
6162
composioApiKey: toolCredentials.composioApiKey,
6263
composioConnectedAccounts: toolCredentials.composioConnectedAccounts,
@@ -66,6 +67,7 @@ export async function runMastraStream(options: MastraStreamOptions): Promise<voi
6667
exaApiKey: toolCredentials.exaApiKey,
6768
falApiKey: toolCredentials.falApiKey,
6869
firecrawlApiKey: toolCredentials.firecrawlApiKey,
70+
globalMemory: input.globalMemory,
6971
googleApiKey: credential.provider === "google" ? credential.apiKey : undefined,
7072
llmProvider: credential.provider,
7173
masterInstructions: input.masterInstructions,

apps/agent-worker/src/durable-objects/agent-run-schemas.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ export const StartRunInputSchema = z
1313
isFirstRun: z.boolean().default(false),
1414
researchFanoutSubagentLimit: z.number().int().positive().max(25).default(3),
1515
masterInstructions: z.string().trim().min(1).max(20_000).optional(),
16+
agentDisplayName: z.string().trim().min(1).max(80).optional(),
17+
globalMemory: z.string().trim().min(1).max(8_000).optional(),
18+
disabledModels: z.array(z.string().trim().min(1).max(200)).max(16).default([]),
1619
budgetCapUsd: z.number().positive().max(50).optional(),
1720
dailyCostCapUsd: z.number().positive().optional(),
1821
dailyCostUsdAtRunStart: z.number().nonnegative().default(0),

apps/agent-worker/src/durable-objects/agent-run.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { DurableObject } from "cloudflare:workers";
22
import { executeRunCodeTool, mastra } from "@cheatcode/agent-core";
33
import { APIError, createLogger, emitUserEvent } from "@cheatcode/observability";
44
import type { ArtifactRuntime, CodeRuntimeContext } from "@cheatcode/tools-code";
5+
import { FALLBACK_MODEL_ID } from "@cheatcode/types";
56
import type { UIMessageChunk } from "ai";
67
import {
78
createAgentStreamResponse,
@@ -531,6 +532,10 @@ export class AgentRun extends DurableObject<AgentRunEnv> {
531532
if (!shouldFallbackToOpenAI(input.model, primaryCredential, error)) {
532533
throw error;
533534
}
535+
if (input.disabledModels.includes(FALLBACK_MODEL_ID)) {
536+
logger.warn("llm_fallback_suppressed_by_user", { fallbackModel: FALLBACK_MODEL_ID });
537+
throw error;
538+
}
534539
const fallbackCredential = await resolveOpenAiFallbackCredential(this.env, input, logger);
535540
if (!fallbackCredential) {
536541
throw error;

apps/agent-worker/src/durable-objects/llm-provider.ts

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
resolveRequestedLlmModel,
66
} from "@cheatcode/agent-core";
77
import { getProviderKey } from "@cheatcode/byok";
8-
import { createDb, type DatabaseHandle, withUserContext } from "@cheatcode/db";
8+
import { createDb, type Database, type DatabaseHandle, withUserContext } from "@cheatcode/db";
99
import { APIError, type createLogger } from "@cheatcode/observability";
1010
import { UserId } from "@cheatcode/types";
1111
import { closeDatabaseBestEffort } from "./db-close";
@@ -94,22 +94,50 @@ async function resolveProviderKey(
9494
): Promise<LlmCredential> {
9595
const dbHandle = createDb(env.HYPERDRIVE);
9696
try {
97-
const apiKey = await withUserContext(dbHandle.db, UserId(userId), (db) =>
98-
getProviderKey(db, selection.provider),
97+
const resolved = await withUserContext(dbHandle.db, UserId(userId), (db) =>
98+
resolveTransportKey(db, selection),
9999
);
100-
if (!apiKey) {
101-
throw missingProviderKey(selection.provider);
102-
}
103100
logger.info("byok_provider_key_resolved", {
104-
modelId: selection.modelId,
105-
provider: selection.provider,
101+
modelId: resolved.selection.modelId,
102+
provider: resolved.selection.provider,
106103
});
107-
return { ...selection, apiKey };
104+
return { ...resolved.selection, apiKey: resolved.apiKey };
108105
} finally {
109106
await closeDatabase(dbHandle, logger);
110107
}
111108
}
112109

110+
/**
111+
* D9 transport rule: prefer the user's direct provider key; otherwise route a
112+
* non-OpenRouter selection through OpenRouter (using the full `provider/model`
113+
* slug) when an OpenRouter key is present; otherwise the model is unavailable.
114+
* Runs inside the caller's already-open withUserContext connection — at most one
115+
* extra indexed get_provider_key call on the direct-key miss path.
116+
*/
117+
async function resolveTransportKey(
118+
db: Database,
119+
selection: LlmModelSelection,
120+
): Promise<{ apiKey: string; selection: LlmModelSelection }> {
121+
const directKey = await getProviderKey(db, selection.provider);
122+
if (directKey) {
123+
return { apiKey: directKey, selection };
124+
}
125+
if (selection.provider !== "openrouter") {
126+
const openrouterKey = await getProviderKey(db, "openrouter");
127+
if (openrouterKey) {
128+
return {
129+
apiKey: openrouterKey,
130+
selection: { modelId: openRouterSlug(selection), provider: "openrouter" },
131+
};
132+
}
133+
}
134+
throw missingProviderKey(selection.provider);
135+
}
136+
137+
function openRouterSlug(selection: LlmModelSelection): string {
138+
return `${selection.provider}/${selection.modelId}`;
139+
}
140+
113141
function missingProviderKey(provider: LlmProvider): APIError {
114142
const label = providerLabel(provider);
115143
return new APIError(400, "byok_key_missing", `Add a ${label} BYOK key before starting a run.`, {

apps/agent-worker/src/index.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import {
5757
OutputIdSchema,
5858
verifySignedOutputDownload,
5959
} from "./output-download";
60+
import { loadRunPersonalization } from "./run-personalization";
6061
import { parseCreateRunRequestBody } from "./run-request";
6162
import {
6263
GatewayUserIdSchema,
@@ -306,9 +307,14 @@ agentApp.post("/v1/threads/:threadId/runs", async (c) => {
306307
try {
307308
const parsedUserId = UserId(userId);
308309
const parsedThreadId = ThreadId(threadId);
309-
const thread = await withUserContext(db, parsedUserId, (tx) =>
310-
getThread(tx, { threadId: parsedThreadId, userId: parsedUserId }),
311-
);
310+
const { personalization, thread } = await withUserContext(db, parsedUserId, async (tx) => {
311+
const loadedThread = await getThread(tx, {
312+
threadId: parsedThreadId,
313+
userId: parsedUserId,
314+
});
315+
const loadedPersonalization = await loadRunPersonalization(tx, parsedUserId, body.model);
316+
return { personalization: loadedPersonalization, thread: loadedThread };
317+
});
312318
if (!thread) {
313319
throw new APIError(404, "not_found_thread", "Thread not found", { retriable: false });
314320
}
@@ -318,6 +324,7 @@ agentApp.post("/v1/threads/:threadId/runs", async (c) => {
318324
createAgentRunForThread(tx, {
319325
agentName: body.agentName ?? DEFAULT_AGENT_NAME,
320326
maxConcurrentSandboxes: policy.maxConcurrentSandboxes,
327+
personalization,
321328
sandboxId: sandboxName,
322329
source: "web",
323330
threadId: parsedThreadId,
@@ -363,7 +370,15 @@ agentApp.post("/v1/threads/:threadId/runs", async (c) => {
363370
}),
364371
);
365372
await sandboxForProject(c.env, userId, result.run.projectId);
366-
const response = await startAgentRun(c.env, userId, result.run, body, sandboxName, policy);
373+
const response = await startAgentRun(
374+
c.env,
375+
userId,
376+
result.run,
377+
body,
378+
sandboxName,
379+
policy,
380+
personalization,
381+
);
367382
return withRunLocation(response, result.run.runId);
368383
} finally {
369384
c.executionCtx.waitUntil(close());
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { type Database, getRunPersonalization, type RunPersonalization } from "@cheatcode/db";
2+
import { APIError } from "@cheatcode/observability";
3+
import type { UserId } from "@cheatcode/types";
4+
5+
/**
6+
* Loads the user's run personalization on the run-create hot path and enforces the
7+
* explicit-model gate: an explicitly requested model that the user has turned off in
8+
* their Models settings is rejected synchronously with a 400.
9+
*
10+
* Runs inside the caller's already-open Hyperdrive transaction (one extra indexed PK select).
11+
*/
12+
export async function loadRunPersonalization(
13+
tx: Database,
14+
userId: UserId,
15+
requestedModel: string | undefined,
16+
): Promise<RunPersonalization> {
17+
const personalization = await getRunPersonalization(tx, userId);
18+
if (requestedModel && personalization.disabledModels.includes(requestedModel)) {
19+
throw new APIError(
20+
400,
21+
"validation_model_unavailable",
22+
"This model is turned off in your Models settings.",
23+
{
24+
details: { model: requestedModel },
25+
hint: "Re-enable it under Settings → Agents, or pick another model.",
26+
retriable: false,
27+
},
28+
);
29+
}
30+
return personalization;
31+
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import {
2+
fetchClerkUserPrimaryEmail,
3+
fetchClerkUserPrimaryEmailStatus,
4+
verifyClerkBearerToken,
5+
} from "@cheatcode/auth";
6+
import { createDb, resolveInternalUserId, upsertClerkUser } from "@cheatcode/db";
7+
import { resolveWorkerSecret, type WorkerSecret } from "@cheatcode/env";
8+
import { APIError } from "@cheatcode/observability";
9+
import type { UserId } from "@cheatcode/types";
10+
11+
/**
12+
* Narrow env surface the auth helpers depend on. `GatewayEnv` structurally
13+
* satisfies it, so route handlers keep passing their full `c.env`.
14+
*/
15+
export interface AuthEnv {
16+
CLERK_JWT_KEY?: WorkerSecret;
17+
CLERK_SECRET_KEY?: WorkerSecret;
18+
HYPERDRIVE: Hyperdrive;
19+
}
20+
21+
export async function authenticate(
22+
request: Request,
23+
env: AuthEnv,
24+
ctx: ExecutionContext,
25+
): Promise<UserId> {
26+
const jwtKey = await readOptionalSecret(env.CLERK_JWT_KEY, "CLERK_JWT_KEY");
27+
const secretKey = await readOptionalSecret(env.CLERK_SECRET_KEY, "CLERK_SECRET_KEY");
28+
if (!jwtKey && !secretKey) {
29+
throw new APIError(503, "unavailable_maintenance", "Clerk verification is not configured", {
30+
hint: "Set CLERK_JWT_KEY or CLERK_SECRET_KEY in the gateway Worker environment.",
31+
retriable: false,
32+
});
33+
}
34+
const verificationOptions: { jwtKey?: string; secretKey?: string } = {};
35+
if (jwtKey) {
36+
verificationOptions.jwtKey = jwtKey;
37+
}
38+
if (secretKey) {
39+
verificationOptions.secretKey = secretKey;
40+
}
41+
const session = await verifyClerkBearerToken(request, verificationOptions);
42+
const { db, close } = createDb(env.HYPERDRIVE);
43+
try {
44+
const userId = await resolveInternalUserId(db, session.clerkUserId);
45+
if (userId) {
46+
return userId;
47+
}
48+
if (!secretKey) {
49+
throw new APIError(404, "not_found_user", "Authenticated user is not synced", {
50+
hint: "Wait for the Clerk user.created webhook to finish, then retry.",
51+
retriable: true,
52+
});
53+
}
54+
const email = await fetchClerkUserEmail(session.clerkUserId, secretKey);
55+
if (!email) {
56+
throw new APIError(404, "not_found_user", "Authenticated user is missing an email", {
57+
hint: "Add a primary email address to the Clerk user, then retry.",
58+
retriable: false,
59+
});
60+
}
61+
const syncedUser = await upsertClerkUser(db, { clerkId: session.clerkUserId, email });
62+
return syncedUser.userId;
63+
} finally {
64+
ctx.waitUntil(close());
65+
}
66+
}
67+
68+
async function fetchClerkUserEmail(clerkUserId: string, secretKey: string): Promise<string | null> {
69+
try {
70+
return await fetchClerkUserPrimaryEmail({ clerkUserId, secretKey });
71+
} catch {
72+
throw new APIError(503, "unavailable_maintenance", "Unable to sync Clerk user", {
73+
hint: "Verify CLERK_SECRET_KEY and Clerk Backend API availability.",
74+
retriable: true,
75+
});
76+
}
77+
}
78+
79+
export async function requireVerifiedClerkEmail(request: Request, env: AuthEnv): Promise<void> {
80+
const secretKey = await readRequiredSecret(env.CLERK_SECRET_KEY, "CLERK_SECRET_KEY");
81+
const session = await verifyClerkBearerToken(request, { secretKey });
82+
const emailStatus = await fetchClerkEmailStatus(session.clerkUserId, secretKey);
83+
if (emailStatus.verified) {
84+
return;
85+
}
86+
throw new APIError(403, "permission_denied", "Verify your email before starting a sandbox run", {
87+
details: { email: emailStatus.email },
88+
hint: "Complete Clerk email verification, refresh the app, and start the run again.",
89+
retriable: false,
90+
});
91+
}
92+
93+
async function fetchClerkEmailStatus(clerkUserId: string, secretKey: string) {
94+
try {
95+
return await fetchClerkUserPrimaryEmailStatus({ clerkUserId, secretKey });
96+
} catch {
97+
throw new APIError(503, "unavailable_maintenance", "Unable to verify Clerk email status", {
98+
hint: "Verify CLERK_SECRET_KEY and Clerk Backend API availability.",
99+
retriable: true,
100+
});
101+
}
102+
}
103+
104+
export async function readOptionalSecret(
105+
secret: WorkerSecret | undefined,
106+
name: string,
107+
): Promise<string | undefined> {
108+
try {
109+
return await resolveWorkerSecret(secret);
110+
} catch {
111+
throw new APIError(503, "unavailable_maintenance", `${name} is unavailable`, {
112+
hint: `Verify the ${name} Cloudflare Secrets Store binding and secret value.`,
113+
retriable: false,
114+
});
115+
}
116+
}
117+
118+
export async function readRequiredSecret(
119+
secret: WorkerSecret | undefined,
120+
name: string,
121+
): Promise<string> {
122+
const value = await readOptionalSecret(secret, name);
123+
if (!value) {
124+
throw new APIError(503, "unavailable_maintenance", `${name} is not configured`, {
125+
hint: `Set ${name} in the gateway Worker environment.`,
126+
retriable: false,
127+
});
128+
}
129+
return value;
130+
}

0 commit comments

Comments
 (0)