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'
| '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.
+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 { 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);
@@ -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();