From c0f107c4cf4c52700d916d10dda73d5611ddfb64 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Sat, 11 Jul 2026 10:52:49 -0400 Subject: [PATCH] feat(backend): queue-time runtime/profile validation; job.runtime authoritative in worker --- apps/agent-worker/src/runtime/provider.ts | 6 +- apps/agent-worker/src/worker.ts | 31 ++-- .../tests/unit/claude-adapter.test.ts | 69 ++++++++- packages/backend/convex/agentJobs.ts | 37 ++++- packages/backend/convex/aiProviderProfiles.ts | 2 + packages/backend/convex/threads.ts | 4 + .../tests/unit/runtime-validation.test.ts | 133 ++++++++++++++++++ 7 files changed, 250 insertions(+), 32 deletions(-) create mode 100644 packages/backend/tests/unit/runtime-validation.test.ts diff --git a/apps/agent-worker/src/runtime/provider.ts b/apps/agent-worker/src/runtime/provider.ts index 4b4c705..2dd3ddf 100644 --- a/apps/agent-worker/src/runtime/provider.ts +++ b/apps/agent-worker/src/runtime/provider.ts @@ -38,7 +38,7 @@ export type Claim = { | 'cloudflare_ai_gateway' | 'custom_openai_compatible' | 'opencode_openai_login'; - authType: 'api_key' | 'opencode_auth_json' | 'none'; + authType: 'api_key' | 'opencode_auth_json' | 'anthropic_oauth_json' | 'none'; secret?: string; baseUrl?: string; model: string; @@ -172,9 +172,9 @@ export const codexModelArgs = (claim: Claim) => // Claude Code OAuth profiles store their credentials as a JSON blob written to // `~/.claude/.credentials.json`; API-key profiles authenticate via -// ANTHROPIC_API_KEY. Mirrors the OpenCode adapter's `opencode_auth_json` signal. +// ANTHROPIC_API_KEY. Keyed on the Anthropic OAuth snapshot type. export const isClaudeOAuthProfile = (claim: Claim) => - claim.aiProviderProfile?.authType === 'opencode_auth_json'; + claim.aiProviderProfile?.authType === 'anthropic_oauth_json'; // The `claude --model` flag expects a bare model id (e.g. `claude-sonnet-4`), // so strip any `provider/` prefix like codexModel does. diff --git a/apps/agent-worker/src/worker.ts b/apps/agent-worker/src/worker.ts index 6fee82c..f07e6cf 100644 --- a/apps/agent-worker/src/worker.ts +++ b/apps/agent-worker/src/worker.ts @@ -16,6 +16,7 @@ import { api } from '@spoon/backend/convex/_generated/api.js'; import type { NormalizedAgentEvent } from './agent-events'; import type { AdapterWorkspace, Claim } from './runtime/provider'; import type { BoxHandle } from './user-container'; +import type { AgentRuntimeName } from './runtime/agent-runtime'; import { getAdapter } from './runtime/agent-runtime'; import { resolveTurnOutcome } from './runtime/turn-outcome'; import './runtime/register'; @@ -35,10 +36,7 @@ import { listWorkspaceContainerNames, runExecInContainer, } from './runtime/docker'; -import { - collectJsonStringValues, - isCodexLoginProfile, -} from './runtime/provider'; +import { collectJsonStringValues } from './runtime/provider'; import { acquireUserBox, runningBoxUsernames } from './user-container'; import { fetchUserEnvironment, materializeUserHome } from './user-environment'; import { teardownWorkspace } from './workspace-teardown'; @@ -876,10 +874,9 @@ const runClaim = async (claim: Claim) => { ].filter(Boolean); const redact = createRedactor(secretValues); let acquiredBoxHandle: BoxHandle | undefined; + // job.runtime is authoritative (validated against the profile at queue time). + const runtime = (claim.job.runtime ?? 'opencode') as AgentRuntimeName; try { - if ((claim.job.runtime ?? 'opencode') !== 'opencode') { - throw new Error('Legacy OpenAI direct jobs are no longer supported.'); - } await updateStatus(jobId, 'preparing'); await appendEvent(jobId, 'info', 'clone', 'Creating installation token.'); if (!claim.github.installationId) { @@ -937,9 +934,7 @@ const runClaim = async (claim: Claim) => { boxHandle, githubToken, redact, - // job.runtime becomes authoritative in Task 8; until then resolve from - // the profile so dispatch stays byte-identical to today. - runtime: isCodexLoginProfile(claim) ? 'codex' : 'opencode', + runtime, }; if (userEnv) { await appendEvent( @@ -956,15 +951,7 @@ const runClaim = async (claim: Claim) => { redact, }); } - if (isCodexLoginProfile(claim)) { - await getAdapter(workspace.runtime).prepareAuth(workspace); - await appendEvent( - jobId, - 'info', - 'clone', - 'Prepared Codex auth JSON for the isolated workspace.', - ); - } + await getAdapter(runtime).prepareAuth(workspace); await materializeEnvFile(workspace); const detected = await detectPackageCommands(repoDir); await addArtifact({ @@ -1617,9 +1604,9 @@ export const startWorker = async () => { await sleep(env.pollMs); continue; } - // The Convex claim still carries the legacy `openai_direct` runtime - // literal, which `runClaim` rejects. Narrow to the shared `Claim` shape - // (widened to the codex/opencode/claude union) at this boundary. + // The generated `Doc<'agentJobs'>.runtime` still includes the legacy + // `openai_direct` literal (queue-time validation now excludes it), so + // narrow to the worker's codex/opencode/claude `Claim` shape here. await runClaim(claim as Claim); } catch (error) { console.error(error); diff --git a/apps/agent-worker/tests/unit/claude-adapter.test.ts b/apps/agent-worker/tests/unit/claude-adapter.test.ts index fd8f6d2..c60d938 100644 --- a/apps/agent-worker/tests/unit/claude-adapter.test.ts +++ b/apps/agent-worker/tests/unit/claude-adapter.test.ts @@ -1,4 +1,8 @@ -import { describe, expect, test, vi } from 'vitest'; +import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { afterEach, describe, expect, test, vi } from 'vitest'; import type { NormalizedAgentEvent } from '../../src/agent-events'; import type { ExecStreamFn } from '../../src/runtime/agent-runtime'; @@ -51,6 +55,69 @@ const makeWorkspace = (): AdapterWorkspace => ({ runtime: 'claude', }); +const makeOAuthWorkspace = ( + homeDir: string, + authType: 'anthropic_oauth_json' | 'opencode_auth_json', +): AdapterWorkspace => ({ + claim: { + ...claim, + aiProviderProfile: { + id: 'p', + name: 'Anthropic', + provider: 'anthropic', + authType, + secret: JSON.stringify({ access_token: 'oauth-token' }), + model: 'claude-sonnet-4', + reasoningEffort: 'medium', + }, + } as unknown as Claim, + workdir: homeDir, + homeDir, + username: 'tester', + containerHome: '/home/tester', + containerRepo: '/home/tester/Code/spoon', + repoDir: path.join(homeDir, 'Code/spoon'), + boxName: 'spoon-box-tester', + redact: (value: string) => value, + runtime: 'claude', +}); + +const tempDirs: string[] = []; + +afterEach(async () => { + while (tempDirs.length) { + const dir = tempDirs.pop(); + if (dir) await rm(dir, { recursive: true, force: true }); + } +}); + +describe('ClaudeCodeAdapter prepareAuth', () => { + test('writes credentials.json for an anthropic_oauth_json profile', async () => { + const { createClaudeAdapter } = await loadAdapter(); + const homeDir = await mkdtemp(path.join(tmpdir(), 'spoon-claude-oauth-')); + tempDirs.push(homeDir); + + const adapter = createClaudeAdapter(); + await adapter.prepareAuth(makeOAuthWorkspace(homeDir, 'anthropic_oauth_json')); + + const credentialsPath = path.join(homeDir, '.claude', '.credentials.json'); + const contents = await readFile(credentialsPath, 'utf8'); + expect(contents).toContain('oauth-token'); + }); + + test('does not write credentials.json for an opencode_auth_json profile', async () => { + const { createClaudeAdapter } = await loadAdapter(); + const homeDir = await mkdtemp(path.join(tmpdir(), 'spoon-claude-oauth-')); + tempDirs.push(homeDir); + + const adapter = createClaudeAdapter(); + await adapter.prepareAuth(makeOAuthWorkspace(homeDir, 'opencode_auth_json')); + + const credentialsPath = path.join(homeDir, '.claude', '.credentials.json'); + await expect(stat(credentialsPath)).rejects.toThrow(); + }); +}); + describe('ClaudeCodeAdapter', () => { test('streams normalized events and marks the turn via buildMarkedCommand', async () => { const { createClaudeAdapter } = await loadAdapter(); diff --git a/packages/backend/convex/agentJobs.ts b/packages/backend/convex/agentJobs.ts index 4cdbdce..091f13d 100644 --- a/packages/backend/convex/agentJobs.ts +++ b/packages/backend/convex/agentJobs.ts @@ -4,6 +4,8 @@ import type { Doc, Id } from './_generated/dataModel'; import type { MutationCtx } from './_generated/server'; import { internalMutation, mutation, query } from './_generated/server'; import { getOwnedSpoon, getRequiredUserId, optionalText } from './model'; +import type { AgentRuntimeName } from './runtimeSupport'; +import { runtimesForProfile } from './runtimeSupport'; import { assertWorkerToken } from './workerAuth'; const jobStatus = v.union( @@ -19,7 +21,11 @@ const jobStatus = v.union( v.literal('timed_out'), ); -const runtime = v.literal('opencode'); +const runtime = v.union( + v.literal('codex'), + v.literal('opencode'), + v.literal('claude'), +); const jobType = v.union( v.literal('user_change'), @@ -141,7 +147,6 @@ const maintenanceRisk = v.union( const defaultAgentSettings = { enabled: true, - runtime: 'opencode' as const, branchPrefix: 'spoon/agent', agentModel: '', reasoningEffort: 'medium' as const, @@ -440,7 +445,7 @@ const insertJob = async ( | 'conflict_resolution'; baseBranch?: string; requestedBranchName?: string; - requestedRuntime?: 'opencode'; + requestedRuntime?: AgentRuntimeName; materializeEnvFile?: boolean; requestedEnvFilePath?: string; requestedProfileId?: Id<'aiProviderProfiles'>; @@ -464,7 +469,23 @@ const insertJob = async ( const now = Date.now(); const resolvedBaseBranch = optionalText(baseBranch) ?? settings.defaultBaseBranch; - const jobRuntime = requestedRuntime ?? 'opencode'; + const supported = runtimesForProfile(profile); + // An explicit request is authoritative and must be supported. A persisted + // spoon runtime (seeded to 'opencode' for every Spoon) is only honored when + // the profile can actually drive it; otherwise fall back to the profile's + // primary runtime so ChatGPT-login profiles still resolve to codex. + const settingsRuntime = settings.runtime as AgentRuntimeName | undefined; + const primaryRuntime = supported[0] ?? 'opencode'; + const resolvedRuntime = + requestedRuntime ?? + (settingsRuntime && supported.includes(settingsRuntime) + ? settingsRuntime + : primaryRuntime); + if (!supported.includes(resolvedRuntime)) { + throw new ConvexError( + `Provider "${profile.name}" cannot run the "${resolvedRuntime}" runtime. Supported: ${supported.join(', ')}.`, + ); + } const shouldMaterializeEnvFile = materializeEnvFile ?? settings.materializeEnvFileByDefault; const envFilePath = @@ -488,7 +509,7 @@ const insertJob = async ( jobType: requestedJobType, status: 'queued', prompt, - runtime: jobRuntime, + runtime: resolvedRuntime, workspaceStatus: 'not_started', baseBranch: resolvedBaseBranch, workBranch, @@ -596,6 +617,7 @@ export const createForThread = mutation({ jobType, baseBranch: v.optional(v.string()), requestedBranchName: v.optional(v.string()), + runtime: v.optional(runtime), materializeEnvFile: v.optional(v.boolean()), envFilePath: v.optional(v.string()), aiProviderProfileId: v.optional(v.id('aiProviderProfiles')), @@ -649,6 +671,7 @@ export const createForThread = mutation({ requestedJobType: args.jobType, baseBranch: args.baseBranch, requestedBranchName: args.requestedBranchName, + requestedRuntime: args.runtime, materializeEnvFile: args.materializeEnvFile, requestedEnvFilePath: args.envFilePath, requestedProfileId: args.aiProviderProfileId, @@ -664,6 +687,7 @@ export const createForThreadInternal = internalMutation({ jobType, baseBranch: v.optional(v.string()), requestedBranchName: v.optional(v.string()), + runtime: v.optional(runtime), materializeEnvFile: v.optional(v.boolean()), envFilePath: v.optional(v.string()), aiProviderProfileId: v.optional(v.id('aiProviderProfiles')), @@ -719,6 +743,7 @@ export const createForThreadInternal = internalMutation({ requestedJobType: args.jobType, baseBranch: args.baseBranch, requestedBranchName: args.requestedBranchName, + requestedRuntime: args.runtime, materializeEnvFile: args.materializeEnvFile, requestedEnvFilePath: args.envFilePath, requestedProfileId: args.aiProviderProfileId, @@ -1140,7 +1165,7 @@ export const claimNextInternal = internalMutation({ await failJobClaim(ctx, job, 'Agent jobs are disabled for this Spoon.'); return null; } - if ((job.runtime ?? 'opencode') !== 'opencode') { + if (job.runtime === 'openai_direct') { await failJobClaim( ctx, job, diff --git a/packages/backend/convex/aiProviderProfiles.ts b/packages/backend/convex/aiProviderProfiles.ts index 0e2b6d7..37728e5 100644 --- a/packages/backend/convex/aiProviderProfiles.ts +++ b/packages/backend/convex/aiProviderProfiles.ts @@ -4,6 +4,7 @@ import type { Doc, Id } from './_generated/dataModel'; import type { MutationCtx } from './_generated/server'; import { internalMutation, mutation, query } from './_generated/server'; import { getRequiredUserId, optionalText } from './model'; +import { runtimesForProfile } from './runtimeSupport'; type AiProviderProfileWithDefault = Doc<'aiProviderProfiles'> & { isDefault?: boolean; @@ -59,6 +60,7 @@ const publicProfile = ( reasoningEffort: profile.reasoningEffort, enabled: profile.enabled, configured: isConfigured(profile), + supportedRuntimes: runtimesForProfile(profile), isDefault: profile._id === defaultProfileId, createdAt: profile.createdAt, updatedAt: profile.updatedAt, diff --git a/packages/backend/convex/threads.ts b/packages/backend/convex/threads.ts index 5525fa2..a88a6c2 100644 --- a/packages/backend/convex/threads.ts +++ b/packages/backend/convex/threads.ts @@ -245,6 +245,9 @@ export const createUserThread = mutation({ prompt: v.string(), baseBranch: v.optional(v.string()), requestedBranchName: v.optional(v.string()), + runtime: v.optional( + v.union(v.literal('codex'), v.literal('opencode'), v.literal('claude')), + ), materializeEnvFile: v.optional(v.boolean()), envFilePath: v.optional(v.string()), aiProviderProfileId: v.optional(v.id('aiProviderProfiles')), @@ -284,6 +287,7 @@ export const createUserThread = mutation({ jobType: 'user_change', baseBranch: args.baseBranch, requestedBranchName: args.requestedBranchName, + runtime: args.runtime, materializeEnvFile: args.materializeEnvFile, envFilePath: args.envFilePath, aiProviderProfileId: args.aiProviderProfileId, diff --git a/packages/backend/tests/unit/runtime-validation.test.ts b/packages/backend/tests/unit/runtime-validation.test.ts new file mode 100644 index 0000000..af92ca8 --- /dev/null +++ b/packages/backend/tests/unit/runtime-validation.test.ts @@ -0,0 +1,133 @@ +import { convexTest } from 'convex-test'; +import { describe, expect, test } from 'vitest'; + +import type { Id } from '../../convex/_generated/dataModel.js'; +import { api } from '../../convex/_generated/api.js'; +import schema from '../../convex/schema'; + +const modules = import.meta.glob('../../convex/**/*.*s'); + +const createUser = async (t: ReturnType, email: string) => + (await t.mutation(async (ctx) => { + return await ctx.db.insert('users', { email, name: email }); + })) as Id<'users'>; + +const authed = (t: ReturnType, userId: string) => + t.withIdentity({ + subject: `${userId}|session`, + issuer: 'https://convex.test', + }); + +const githubSpoonInput = { + name: 'Editor Spoon', + provider: 'github' as const, + upstreamOwner: 'upstream', + upstreamRepo: 'editor', + upstreamDefaultBranch: 'main', + upstreamUrl: 'https://github.com/upstream/editor', + forkOwner: 'team', + forkRepo: 'editor-spoon', + forkUrl: 'https://github.com/team/editor-spoon', + visibility: 'private' as const, + maintenanceMode: 'watch' as const, + syncCadence: 'daily' as const, + productionRefStrategy: 'default_branch' as const, +}; + +const setup = async ( + t: ReturnType, + profile: { + provider: 'anthropic' | 'opencode_openai_login'; + authType: 'api_key' | 'opencode_auth_json'; + }, +) => { + const ownerId = await createUser(t, 'owner@example.com'); + const spoonId = await authed(t, ownerId).mutation( + api.spoons.createManual, + githubSpoonInput, + ); + const threadId = await t.mutation(async (ctx) => { + const now = Date.now(); + await ctx.db.insert('aiProviderProfiles', { + ownerId, + name: 'Primary provider', + provider: profile.provider, + authType: profile.authType, + encryptedSecret: 'encrypted-secret', + secretPreview: 'sk-...abcd', + defaultModel: 'claude-sonnet-4', + modelOptions: ['claude-sonnet-4'], + reasoningEffort: 'medium', + enabled: true, + createdAt: now, + updatedAt: now, + }); + return await ctx.db.insert('threads', { + ownerId, + spoonId, + title: 'Runtime thread', + summary: 'do the work', + source: 'user_request', + status: 'open', + priority: 'normal', + createdAt: now, + updatedAt: now, + }); + }); + return { ownerId, spoonId, threadId }; +}; + +describe('queue-time runtime validation', () => { + test('rejects a runtime the provider profile cannot drive', async () => { + const t = convexTest(schema, modules); + const { ownerId, threadId } = await setup(t, { + provider: 'anthropic', + authType: 'api_key', + }); + + await expect( + authed(t, ownerId).mutation(api.agentJobs.createForThread, { + threadId, + jobType: 'user_change', + runtime: 'codex', + }), + ).rejects.toThrow(/cannot run the "codex" runtime/); + }); + + test('resolves a supported requested runtime onto the job', async () => { + const t = convexTest(schema, modules); + const { ownerId, threadId } = await setup(t, { + provider: 'anthropic', + authType: 'api_key', + }); + + const jobId = await authed(t, ownerId).mutation( + api.agentJobs.createForThread, + { + threadId, + jobType: 'user_change', + runtime: 'claude', + }, + ); + const job = await t.run(async (ctx) => await ctx.db.get(jobId)); + expect(job?.runtime).toBe('claude'); + }); + + test('defaults a ChatGPT-login profile job to the codex runtime', async () => { + const t = convexTest(schema, modules); + const { ownerId, threadId } = await setup(t, { + provider: 'opencode_openai_login', + authType: 'opencode_auth_json', + }); + + const jobId = await authed(t, ownerId).mutation( + api.agentJobs.createForThread, + { + threadId, + jobType: 'user_change', + }, + ); + const job = await t.run(async (ctx) => await ctx.db.get(jobId)); + expect(job?.runtime).toBe('codex'); + }); +});