feat(backend): queue-time runtime/profile validation; job.runtime authoritative in worker

This commit is contained in:
Gabriel Brown
2026-07-11 10:58:05 -04:00
parent 70396feccc
commit c0f107c4cf
7 changed files with 250 additions and 32 deletions
+3 -3
View File
@@ -38,7 +38,7 @@ export type Claim = {
| 'cloudflare_ai_gateway' | 'cloudflare_ai_gateway'
| 'custom_openai_compatible' | 'custom_openai_compatible'
| 'opencode_openai_login'; | 'opencode_openai_login';
authType: 'api_key' | 'opencode_auth_json' | 'none'; authType: 'api_key' | 'opencode_auth_json' | 'anthropic_oauth_json' | 'none';
secret?: string; secret?: string;
baseUrl?: string; baseUrl?: string;
model: 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 Code OAuth profiles store their credentials as a JSON blob written to
// `~/.claude/.credentials.json`; API-key profiles authenticate via // `~/.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) => 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`), // The `claude --model` flag expects a bare model id (e.g. `claude-sonnet-4`),
// so strip any `provider/` prefix like codexModel does. // so strip any `provider/` prefix like codexModel does.
+9 -22
View File
@@ -16,6 +16,7 @@ import { api } from '@spoon/backend/convex/_generated/api.js';
import type { NormalizedAgentEvent } from './agent-events'; import type { NormalizedAgentEvent } from './agent-events';
import type { AdapterWorkspace, Claim } from './runtime/provider'; import type { AdapterWorkspace, Claim } from './runtime/provider';
import type { BoxHandle } from './user-container'; import type { BoxHandle } from './user-container';
import type { AgentRuntimeName } from './runtime/agent-runtime';
import { getAdapter } from './runtime/agent-runtime'; import { getAdapter } from './runtime/agent-runtime';
import { resolveTurnOutcome } from './runtime/turn-outcome'; import { resolveTurnOutcome } from './runtime/turn-outcome';
import './runtime/register'; import './runtime/register';
@@ -35,10 +36,7 @@ import {
listWorkspaceContainerNames, listWorkspaceContainerNames,
runExecInContainer, runExecInContainer,
} from './runtime/docker'; } from './runtime/docker';
import { import { collectJsonStringValues } from './runtime/provider';
collectJsonStringValues,
isCodexLoginProfile,
} from './runtime/provider';
import { acquireUserBox, runningBoxUsernames } from './user-container'; import { acquireUserBox, runningBoxUsernames } from './user-container';
import { fetchUserEnvironment, materializeUserHome } from './user-environment'; import { fetchUserEnvironment, materializeUserHome } from './user-environment';
import { teardownWorkspace } from './workspace-teardown'; import { teardownWorkspace } from './workspace-teardown';
@@ -876,10 +874,9 @@ const runClaim = async (claim: Claim) => {
].filter(Boolean); ].filter(Boolean);
const redact = createRedactor(secretValues); const redact = createRedactor(secretValues);
let acquiredBoxHandle: BoxHandle | undefined; let acquiredBoxHandle: BoxHandle | undefined;
// job.runtime is authoritative (validated against the profile at queue time).
const runtime = (claim.job.runtime ?? 'opencode') as AgentRuntimeName;
try { try {
if ((claim.job.runtime ?? 'opencode') !== 'opencode') {
throw new Error('Legacy OpenAI direct jobs are no longer supported.');
}
await updateStatus(jobId, 'preparing'); await updateStatus(jobId, 'preparing');
await appendEvent(jobId, 'info', 'clone', 'Creating installation token.'); await appendEvent(jobId, 'info', 'clone', 'Creating installation token.');
if (!claim.github.installationId) { if (!claim.github.installationId) {
@@ -937,9 +934,7 @@ const runClaim = async (claim: Claim) => {
boxHandle, boxHandle,
githubToken, githubToken,
redact, redact,
// job.runtime becomes authoritative in Task 8; until then resolve from runtime,
// the profile so dispatch stays byte-identical to today.
runtime: isCodexLoginProfile(claim) ? 'codex' : 'opencode',
}; };
if (userEnv) { if (userEnv) {
await appendEvent( await appendEvent(
@@ -956,15 +951,7 @@ const runClaim = async (claim: Claim) => {
redact, redact,
}); });
} }
if (isCodexLoginProfile(claim)) { await getAdapter(runtime).prepareAuth(workspace);
await getAdapter(workspace.runtime).prepareAuth(workspace);
await appendEvent(
jobId,
'info',
'clone',
'Prepared Codex auth JSON for the isolated workspace.',
);
}
await materializeEnvFile(workspace); await materializeEnvFile(workspace);
const detected = await detectPackageCommands(repoDir); const detected = await detectPackageCommands(repoDir);
await addArtifact({ await addArtifact({
@@ -1617,9 +1604,9 @@ export const startWorker = async () => {
await sleep(env.pollMs); await sleep(env.pollMs);
continue; continue;
} }
// The Convex claim still carries the legacy `openai_direct` runtime // The generated `Doc<'agentJobs'>.runtime` still includes the legacy
// literal, which `runClaim` rejects. Narrow to the shared `Claim` shape // `openai_direct` literal (queue-time validation now excludes it), so
// (widened to the codex/opencode/claude union) at this boundary. // narrow to the worker's codex/opencode/claude `Claim` shape here.
await runClaim(claim as Claim); await runClaim(claim as Claim);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@@ -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 { NormalizedAgentEvent } from '../../src/agent-events';
import type { ExecStreamFn } from '../../src/runtime/agent-runtime'; import type { ExecStreamFn } from '../../src/runtime/agent-runtime';
@@ -51,6 +55,69 @@ const makeWorkspace = (): AdapterWorkspace => ({
runtime: 'claude', 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', () => { describe('ClaudeCodeAdapter', () => {
test('streams normalized events and marks the turn via buildMarkedCommand', async () => { test('streams normalized events and marks the turn via buildMarkedCommand', async () => {
const { createClaudeAdapter } = await loadAdapter(); const { createClaudeAdapter } = await loadAdapter();
+31 -6
View File
@@ -4,6 +4,8 @@ import type { Doc, Id } from './_generated/dataModel';
import type { MutationCtx } from './_generated/server'; import type { MutationCtx } from './_generated/server';
import { internalMutation, mutation, query } from './_generated/server'; import { internalMutation, mutation, query } from './_generated/server';
import { getOwnedSpoon, getRequiredUserId, optionalText } from './model'; import { getOwnedSpoon, getRequiredUserId, optionalText } from './model';
import type { AgentRuntimeName } from './runtimeSupport';
import { runtimesForProfile } from './runtimeSupport';
import { assertWorkerToken } from './workerAuth'; import { assertWorkerToken } from './workerAuth';
const jobStatus = v.union( const jobStatus = v.union(
@@ -19,7 +21,11 @@ const jobStatus = v.union(
v.literal('timed_out'), 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( const jobType = v.union(
v.literal('user_change'), v.literal('user_change'),
@@ -141,7 +147,6 @@ const maintenanceRisk = v.union(
const defaultAgentSettings = { const defaultAgentSettings = {
enabled: true, enabled: true,
runtime: 'opencode' as const,
branchPrefix: 'spoon/agent', branchPrefix: 'spoon/agent',
agentModel: '', agentModel: '',
reasoningEffort: 'medium' as const, reasoningEffort: 'medium' as const,
@@ -440,7 +445,7 @@ const insertJob = async (
| 'conflict_resolution'; | 'conflict_resolution';
baseBranch?: string; baseBranch?: string;
requestedBranchName?: string; requestedBranchName?: string;
requestedRuntime?: 'opencode'; requestedRuntime?: AgentRuntimeName;
materializeEnvFile?: boolean; materializeEnvFile?: boolean;
requestedEnvFilePath?: string; requestedEnvFilePath?: string;
requestedProfileId?: Id<'aiProviderProfiles'>; requestedProfileId?: Id<'aiProviderProfiles'>;
@@ -464,7 +469,23 @@ const insertJob = async (
const now = Date.now(); const now = Date.now();
const resolvedBaseBranch = const resolvedBaseBranch =
optionalText(baseBranch) ?? settings.defaultBaseBranch; 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 = const shouldMaterializeEnvFile =
materializeEnvFile ?? settings.materializeEnvFileByDefault; materializeEnvFile ?? settings.materializeEnvFileByDefault;
const envFilePath = const envFilePath =
@@ -488,7 +509,7 @@ const insertJob = async (
jobType: requestedJobType, jobType: requestedJobType,
status: 'queued', status: 'queued',
prompt, prompt,
runtime: jobRuntime, runtime: resolvedRuntime,
workspaceStatus: 'not_started', workspaceStatus: 'not_started',
baseBranch: resolvedBaseBranch, baseBranch: resolvedBaseBranch,
workBranch, workBranch,
@@ -596,6 +617,7 @@ export const createForThread = mutation({
jobType, jobType,
baseBranch: v.optional(v.string()), baseBranch: v.optional(v.string()),
requestedBranchName: v.optional(v.string()), requestedBranchName: v.optional(v.string()),
runtime: v.optional(runtime),
materializeEnvFile: v.optional(v.boolean()), materializeEnvFile: v.optional(v.boolean()),
envFilePath: v.optional(v.string()), envFilePath: v.optional(v.string()),
aiProviderProfileId: v.optional(v.id('aiProviderProfiles')), aiProviderProfileId: v.optional(v.id('aiProviderProfiles')),
@@ -649,6 +671,7 @@ export const createForThread = mutation({
requestedJobType: args.jobType, requestedJobType: args.jobType,
baseBranch: args.baseBranch, baseBranch: args.baseBranch,
requestedBranchName: args.requestedBranchName, requestedBranchName: args.requestedBranchName,
requestedRuntime: args.runtime,
materializeEnvFile: args.materializeEnvFile, materializeEnvFile: args.materializeEnvFile,
requestedEnvFilePath: args.envFilePath, requestedEnvFilePath: args.envFilePath,
requestedProfileId: args.aiProviderProfileId, requestedProfileId: args.aiProviderProfileId,
@@ -664,6 +687,7 @@ export const createForThreadInternal = internalMutation({
jobType, jobType,
baseBranch: v.optional(v.string()), baseBranch: v.optional(v.string()),
requestedBranchName: v.optional(v.string()), requestedBranchName: v.optional(v.string()),
runtime: v.optional(runtime),
materializeEnvFile: v.optional(v.boolean()), materializeEnvFile: v.optional(v.boolean()),
envFilePath: v.optional(v.string()), envFilePath: v.optional(v.string()),
aiProviderProfileId: v.optional(v.id('aiProviderProfiles')), aiProviderProfileId: v.optional(v.id('aiProviderProfiles')),
@@ -719,6 +743,7 @@ export const createForThreadInternal = internalMutation({
requestedJobType: args.jobType, requestedJobType: args.jobType,
baseBranch: args.baseBranch, baseBranch: args.baseBranch,
requestedBranchName: args.requestedBranchName, requestedBranchName: args.requestedBranchName,
requestedRuntime: args.runtime,
materializeEnvFile: args.materializeEnvFile, materializeEnvFile: args.materializeEnvFile,
requestedEnvFilePath: args.envFilePath, requestedEnvFilePath: args.envFilePath,
requestedProfileId: args.aiProviderProfileId, requestedProfileId: args.aiProviderProfileId,
@@ -1140,7 +1165,7 @@ export const claimNextInternal = internalMutation({
await failJobClaim(ctx, job, 'Agent jobs are disabled for this Spoon.'); await failJobClaim(ctx, job, 'Agent jobs are disabled for this Spoon.');
return null; return null;
} }
if ((job.runtime ?? 'opencode') !== 'opencode') { if (job.runtime === 'openai_direct') {
await failJobClaim( await failJobClaim(
ctx, ctx,
job, job,
@@ -4,6 +4,7 @@ import type { Doc, Id } from './_generated/dataModel';
import type { MutationCtx } from './_generated/server'; import type { MutationCtx } from './_generated/server';
import { internalMutation, mutation, query } from './_generated/server'; import { internalMutation, mutation, query } from './_generated/server';
import { getRequiredUserId, optionalText } from './model'; import { getRequiredUserId, optionalText } from './model';
import { runtimesForProfile } from './runtimeSupport';
type AiProviderProfileWithDefault = Doc<'aiProviderProfiles'> & { type AiProviderProfileWithDefault = Doc<'aiProviderProfiles'> & {
isDefault?: boolean; isDefault?: boolean;
@@ -59,6 +60,7 @@ const publicProfile = (
reasoningEffort: profile.reasoningEffort, reasoningEffort: profile.reasoningEffort,
enabled: profile.enabled, enabled: profile.enabled,
configured: isConfigured(profile), configured: isConfigured(profile),
supportedRuntimes: runtimesForProfile(profile),
isDefault: profile._id === defaultProfileId, isDefault: profile._id === defaultProfileId,
createdAt: profile.createdAt, createdAt: profile.createdAt,
updatedAt: profile.updatedAt, updatedAt: profile.updatedAt,
+4
View File
@@ -245,6 +245,9 @@ export const createUserThread = mutation({
prompt: v.string(), prompt: v.string(),
baseBranch: v.optional(v.string()), baseBranch: v.optional(v.string()),
requestedBranchName: 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()), materializeEnvFile: v.optional(v.boolean()),
envFilePath: v.optional(v.string()), envFilePath: v.optional(v.string()),
aiProviderProfileId: v.optional(v.id('aiProviderProfiles')), aiProviderProfileId: v.optional(v.id('aiProviderProfiles')),
@@ -284,6 +287,7 @@ export const createUserThread = mutation({
jobType: 'user_change', jobType: 'user_change',
baseBranch: args.baseBranch, baseBranch: args.baseBranch,
requestedBranchName: args.requestedBranchName, requestedBranchName: args.requestedBranchName,
runtime: args.runtime,
materializeEnvFile: args.materializeEnvFile, materializeEnvFile: args.materializeEnvFile,
envFilePath: args.envFilePath, envFilePath: args.envFilePath,
aiProviderProfileId: args.aiProviderProfileId, aiProviderProfileId: args.aiProviderProfileId,
@@ -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<typeof convexTest>, email: string) =>
(await t.mutation(async (ctx) => {
return await ctx.db.insert('users', { email, name: email });
})) as Id<'users'>;
const authed = (t: ReturnType<typeof convexTest>, 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<typeof convexTest>,
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');
});
});