diff --git a/AGENTS.md b/AGENTS.md index f8b3559..6fadcce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,9 @@ `spoon-agent-job` images to `git.gbrown.org/gib`. In production, `SPOON_AGENT_JOB_IMAGE` should point to `git.gbrown.org/gib/spoon-agent-job:latest`, and the worker service requires - access to the host Docker socket. + access to the host Docker socket. API-key provider jobs run through OpenCode; + Codex ChatGPT login profiles run through the Codex CLI with an injected + `CODEX_HOME/.codex/auth.json` inside the isolated job workspace. ## Protected and generated files diff --git a/README.md b/README.md index 611dcc2..3bd2ab4 100644 --- a/README.md +++ b/README.md @@ -200,9 +200,10 @@ curl -H "Authorization: Bearer $SPOON_AGENT_WORKER_INTERNAL_TOKEN" \ http://spoon-agent-worker:3921/health ``` -For the first production run, use an API-key based AI provider profile. Stored -OpenCode/Codex `auth.json` profiles are supported in settings, but worker-side -auth-file injection is still a follow-up before they can execute jobs. +API-key based AI provider profiles run through OpenCode. Codex ChatGPT login +profiles run through the Codex CLI: Spoon writes the encrypted `auth.json` into +the isolated job workspace as `CODEX_HOME/.codex/auth.json` before execution. +Treat that saved auth file like a password and only use it on trusted workers. @@ -294,7 +295,8 @@ The mobile app currently supports: - thread list/detail, message composer, resolve/cancel actions, and workspace review links - GitHub integration status and repository listing -- AI provider profile management, including Codex/OpenCode auth JSON +- AI provider profile management, including Codex auth JSON and API-key + providers - read-only workspace review for job status, messages, diffs, events, artifacts, and draft PR links @@ -466,11 +468,12 @@ not call Infisical. - GitHub drift refresh, commit cache, PR cache, and sync-run history - Effective drift and ignored upstream change records - Global Threads page and Spoon-scoped Threads tab -- OpenCode-oriented agent worker and browser workspace foundation +- OpenCode/Codex-oriented agent worker and browser workspace foundation - Monaco editor with optional Vim mode - Diff viewer, command panel, worker logs, and artifacts - Encrypted Spoon secrets and bulk `.env` import -- Encrypted AI provider profiles, including Codex/OpenCode auth support +- Encrypted AI provider profiles, including Codex auth JSON and API-key + provider support - Authentik, GitHub, and password auth through Convex Auth - Self-hosted Convex/Postgres deployment model diff --git a/apps/agent-worker/src/worker.ts b/apps/agent-worker/src/worker.ts index b4a448b..1c74236 100644 --- a/apps/agent-worker/src/worker.ts +++ b/apps/agent-worker/src/worker.ts @@ -94,6 +94,7 @@ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const client = new ConvexHttpClient(env.convexUrl); const activeWorkspaces = new Map(); +const jobContainerWorkspace = '/workspace'; const appendEvent = async ( jobId: Id<'agentJobs'>, @@ -239,7 +240,50 @@ const recordWorkspaceChange = async (args: { const commandToShell = (command: string) => ['bash', '-lc', command]; -const providerEnvironment = (claim: Claim): Record => { +const isCodexLoginProfile = (claim: Claim) => + claim.aiProviderProfile?.provider === 'opencode_openai_login' || + claim.aiProviderProfile?.authType === 'opencode_auth_json'; + +const collectJsonStringValues = (value?: string): string[] => { + if (!value) return []; + try { + const parsed = JSON.parse(value) as unknown; + const values: string[] = []; + const visit = (item: unknown) => { + if (typeof item === 'string') { + if (item.length >= 12) values.push(item); + return; + } + if (Array.isArray(item)) { + item.forEach(visit); + return; + } + if (item && typeof item === 'object') { + Object.values(item).forEach(visit); + } + }; + visit(parsed); + return values; + } catch { + return []; + } +}; + +const providerEnvironment = ( + claim: Claim, + workspaceRoot?: string, +): Record => { + if (isCodexLoginProfile(claim)) { + if (!workspaceRoot) { + throw new Error('Codex auth profiles require a prepared workspace.'); + } + return { + CODEX_HOME: path.join(workspaceRoot, '.codex'), + HOME: workspaceRoot, + XDG_DATA_HOME: path.join(workspaceRoot, '.local', 'share'), + XDG_CONFIG_HOME: path.join(workspaceRoot, '.config'), + }; + } const profile = claim.aiProviderProfile; const secret = profile?.secret ?? claim.openai.apiKey; if (!secret) { @@ -268,9 +312,7 @@ const providerEnvironment = (claim: Claim): Record => { ) { return { OPENAI_API_KEY: secret, ...baseUrl }; } - throw new Error( - 'OpenCode login profiles are saved but need auth-file injection before execution.', - ); + throw new Error('Unsupported AI provider profile.'); }; const opencodeModel = (claim: Claim) => { @@ -288,6 +330,63 @@ const opencodeModel = (claim: Claim) => { return `${profile.provider}/${model}`; }; +const codexModel = (claim: Claim) => { + const model = claim.aiProviderProfile?.model ?? claim.openai.model; + return model.includes('/') ? model.split('/').at(-1) ?? model : model; +}; + +const writeJsonFile = async (filePath: string, content: string) => { + let normalized = content.trim(); + try { + normalized = `${JSON.stringify(JSON.parse(normalized), null, 2)}\n`; + } catch { + throw new Error('Codex auth JSON is not valid JSON.'); + } + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, normalized, { mode: 0o600 }); +}; + +const prepareCodexAuth = async (workspace: ActiveWorkspace) => { + const secret = workspace.claim.aiProviderProfile?.secret; + if (!secret) { + throw new Error('Codex auth profile is missing auth.json contents.'); + } + const codexAuthPath = path.join(workspace.workdir, '.codex', 'auth.json'); + await writeJsonFile(codexAuthPath, secret); + + // Also seed OpenCode's auth location with the saved JSON for forward + // compatibility if this profile later runs through OpenCode directly. + const openCodeAuthPath = path.join( + workspace.workdir, + '.local', + 'share', + 'opencode', + 'auth.json', + ); + await writeJsonFile(openCodeAuthPath, secret); + + await appendEvent( + workspace.claim.job._id, + 'info', + 'clone', + 'Prepared Codex auth JSON for the isolated workspace.', + ); +}; + +const agentCommand = (claim: Claim, prompt: string) => { + if (isCodexLoginProfile(claim)) { + return commandToShell( + `codex exec --model ${quoteShell(codexModel(claim))} --sandbox workspace-write ${quoteShell(prompt)}`, + ); + } + return commandToShell( + `opencode run --model ${quoteShell(opencodeModel(claim))} ${quoteShell(prompt)}`, + ); +}; + +const agentFailurePrefix = (claim: Claim) => + isCodexLoginProfile(claim) ? 'codex failed' : 'opencode failed'; + const systemPromptForJob = (claim: Claim) => { const base = [ `Spoon: ${claim.spoon.name}`, @@ -605,6 +704,7 @@ const runClaim = async (claim: Claim) => { const secretValues = [ claim.openai.apiKey ?? '', claim.aiProviderProfile?.secret ?? '', + ...collectJsonStringValues(claim.aiProviderProfile?.secret), ...claim.secrets.map((secret) => secret.value), ].filter(Boolean); const redact = createRedactor(secretValues); @@ -632,6 +732,9 @@ const runClaim = async (claim: Claim) => { githubToken, redact, }; + if (isCodexLoginProfile(claim)) { + await prepareCodexAuth(workspace); + } await materializeEnvFile(workspace); const detected = await detectPackageCommands(repoDir); await addArtifact({ @@ -800,18 +903,19 @@ export const sendWorkspaceMessage = async (jobId: string, prompt: string) => { if ((claim.job.runtime ?? 'opencode') !== 'opencode') { throw new Error('Legacy OpenAI direct jobs are no longer supported.'); } - const model = opencodeModel(claim); - const aiEnv = providerEnvironment(claim); + const aiEnv = providerEnvironment( + claim, + env.runtime === 'docker' ? jobContainerWorkspace : workdir, + ); const secretEnv = Object.fromEntries( claim.secrets.map((secret) => [secret.name, secret.value]), ); + const command = agentCommand(claim, prompt); const result = env.runtime === 'docker' ? await runInJobContainer({ workdir, - command: commandToShell( - `opencode run --model ${quoteShell(model)} ${quoteShell(prompt)}`, - ), + command, environment: { ...aiEnv, ...secretEnv, @@ -821,10 +925,7 @@ export const sendWorkspaceMessage = async (jobId: string, prompt: string) => { }) : await run( 'bash', - [ - '-lc', - `opencode run --model ${quoteShell(model)} ${quoteShell(prompt)}`, - ], + command.slice(1), { cwd: repoDir, env: { @@ -842,7 +943,7 @@ export const sendWorkspaceMessage = async (jobId: string, prompt: string) => { content: truncate(result.output, 40_000), }); if (result.exitCode !== 0) { - throw new Error(`opencode failed:\n${result.output}`); + throw new Error(`${agentFailurePrefix(claim)}:\n${result.output}`); } if (claim.job.jobType === 'maintenance_review') { const decision = parseMaintenanceDecision(result.output); diff --git a/apps/expo/src/components/settings/ai-provider-profile-form.tsx b/apps/expo/src/components/settings/ai-provider-profile-form.tsx index 28160e3..1454b7a 100644 --- a/apps/expo/src/components/settings/ai-provider-profile-form.tsx +++ b/apps/expo/src/components/settings/ai-provider-profile-form.tsx @@ -153,7 +153,7 @@ export const AiProviderProfileForm = ({ {authType === 'opencode_auth_json' ? ( - Copy auth.json from your Codex/OpenCode auth folder, for example - ~/.codex/auth.json, and paste it here. + Copy auth.json from your Codex auth folder, for example + ~/.codex/auth.json, and paste it here. Spoon writes it into isolated + agent workspaces for Codex CLI runs. ) : null} {authType !== 'none' ? ( diff --git a/apps/next/src/components/agent-workspace/agent-workspace-shell.tsx b/apps/next/src/components/agent-workspace/agent-workspace-shell.tsx index c80856e..1de588f 100644 --- a/apps/next/src/components/agent-workspace/agent-workspace-shell.tsx +++ b/apps/next/src/components/agent-workspace/agent-workspace-shell.tsx @@ -95,13 +95,13 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => { }; return ( -
+
-
-