Try to fix workers and workspace
Build and Push Spoon Images / quality (push) Successful in 1m40s
Build and Push Spoon Images / build-images (push) Successful in 7m0s

This commit is contained in:
Gabriel Brown
2026-06-22 23:17:27 -04:00
parent f33f76d874
commit 930fbf5965
11 changed files with 208 additions and 48 deletions
+3 -1
View File
@@ -20,7 +20,9 @@
`spoon-agent-job` images to `git.gbrown.org/gib`. In production, `spoon-agent-job` images to `git.gbrown.org/gib`. In production,
`SPOON_AGENT_JOB_IMAGE` should point to `SPOON_AGENT_JOB_IMAGE` should point to
`git.gbrown.org/gib/spoon-agent-job:latest`, and the worker service requires `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 ## Protected and generated files
+9 -6
View File
@@ -200,9 +200,10 @@ curl -H "Authorization: Bearer $SPOON_AGENT_WORKER_INTERNAL_TOKEN" \
http://spoon-agent-worker:3921/health http://spoon-agent-worker:3921/health
``` ```
For the first production run, use an API-key based AI provider profile. Stored API-key based AI provider profiles run through OpenCode. Codex ChatGPT login
OpenCode/Codex `auth.json` profiles are supported in settings, but worker-side profiles run through the Codex CLI: Spoon writes the encrypted `auth.json` into
auth-file injection is still a follow-up before they can execute jobs. 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.
</details> </details>
@@ -294,7 +295,8 @@ The mobile app currently supports:
- thread list/detail, message composer, resolve/cancel actions, and workspace - thread list/detail, message composer, resolve/cancel actions, and workspace
review links review links
- GitHub integration status and repository listing - 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, - read-only workspace review for job status, messages, diffs, events,
artifacts, and draft PR links artifacts, and draft PR links
@@ -466,11 +468,12 @@ not call Infisical.
- GitHub drift refresh, commit cache, PR cache, and sync-run history - GitHub drift refresh, commit cache, PR cache, and sync-run history
- Effective drift and ignored upstream change records - Effective drift and ignored upstream change records
- Global Threads page and Spoon-scoped Threads tab - 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 - Monaco editor with optional Vim mode
- Diff viewer, command panel, worker logs, and artifacts - Diff viewer, command panel, worker logs, and artifacts
- Encrypted Spoon secrets and bulk `.env` import - 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 - Authentik, GitHub, and password auth through Convex Auth
- Self-hosted Convex/Postgres deployment model - Self-hosted Convex/Postgres deployment model
+115 -14
View File
@@ -94,6 +94,7 @@ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const client = new ConvexHttpClient(env.convexUrl); const client = new ConvexHttpClient(env.convexUrl);
const activeWorkspaces = new Map<string, ActiveWorkspace>(); const activeWorkspaces = new Map<string, ActiveWorkspace>();
const jobContainerWorkspace = '/workspace';
const appendEvent = async ( const appendEvent = async (
jobId: Id<'agentJobs'>, jobId: Id<'agentJobs'>,
@@ -239,7 +240,50 @@ const recordWorkspaceChange = async (args: {
const commandToShell = (command: string) => ['bash', '-lc', command]; const commandToShell = (command: string) => ['bash', '-lc', command];
const providerEnvironment = (claim: Claim): Record<string, string> => { 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<string, string> => {
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 profile = claim.aiProviderProfile;
const secret = profile?.secret ?? claim.openai.apiKey; const secret = profile?.secret ?? claim.openai.apiKey;
if (!secret) { if (!secret) {
@@ -268,9 +312,7 @@ const providerEnvironment = (claim: Claim): Record<string, string> => {
) { ) {
return { OPENAI_API_KEY: secret, ...baseUrl }; return { OPENAI_API_KEY: secret, ...baseUrl };
} }
throw new Error( throw new Error('Unsupported AI provider profile.');
'OpenCode login profiles are saved but need auth-file injection before execution.',
);
}; };
const opencodeModel = (claim: Claim) => { const opencodeModel = (claim: Claim) => {
@@ -288,6 +330,63 @@ const opencodeModel = (claim: Claim) => {
return `${profile.provider}/${model}`; 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 systemPromptForJob = (claim: Claim) => {
const base = [ const base = [
`Spoon: ${claim.spoon.name}`, `Spoon: ${claim.spoon.name}`,
@@ -605,6 +704,7 @@ const runClaim = async (claim: Claim) => {
const secretValues = [ const secretValues = [
claim.openai.apiKey ?? '', claim.openai.apiKey ?? '',
claim.aiProviderProfile?.secret ?? '', claim.aiProviderProfile?.secret ?? '',
...collectJsonStringValues(claim.aiProviderProfile?.secret),
...claim.secrets.map((secret) => secret.value), ...claim.secrets.map((secret) => secret.value),
].filter(Boolean); ].filter(Boolean);
const redact = createRedactor(secretValues); const redact = createRedactor(secretValues);
@@ -632,6 +732,9 @@ const runClaim = async (claim: Claim) => {
githubToken, githubToken,
redact, redact,
}; };
if (isCodexLoginProfile(claim)) {
await prepareCodexAuth(workspace);
}
await materializeEnvFile(workspace); await materializeEnvFile(workspace);
const detected = await detectPackageCommands(repoDir); const detected = await detectPackageCommands(repoDir);
await addArtifact({ await addArtifact({
@@ -800,18 +903,19 @@ export const sendWorkspaceMessage = async (jobId: string, prompt: string) => {
if ((claim.job.runtime ?? 'opencode') !== 'opencode') { if ((claim.job.runtime ?? 'opencode') !== 'opencode') {
throw new Error('Legacy OpenAI direct jobs are no longer supported.'); throw new Error('Legacy OpenAI direct jobs are no longer supported.');
} }
const model = opencodeModel(claim); const aiEnv = providerEnvironment(
const aiEnv = providerEnvironment(claim); claim,
env.runtime === 'docker' ? jobContainerWorkspace : workdir,
);
const secretEnv = Object.fromEntries( const secretEnv = Object.fromEntries(
claim.secrets.map((secret) => [secret.name, secret.value]), claim.secrets.map((secret) => [secret.name, secret.value]),
); );
const command = agentCommand(claim, prompt);
const result = const result =
env.runtime === 'docker' env.runtime === 'docker'
? await runInJobContainer({ ? await runInJobContainer({
workdir, workdir,
command: commandToShell( command,
`opencode run --model ${quoteShell(model)} ${quoteShell(prompt)}`,
),
environment: { environment: {
...aiEnv, ...aiEnv,
...secretEnv, ...secretEnv,
@@ -821,10 +925,7 @@ export const sendWorkspaceMessage = async (jobId: string, prompt: string) => {
}) })
: await run( : await run(
'bash', 'bash',
[ command.slice(1),
'-lc',
`opencode run --model ${quoteShell(model)} ${quoteShell(prompt)}`,
],
{ {
cwd: repoDir, cwd: repoDir,
env: { env: {
@@ -842,7 +943,7 @@ export const sendWorkspaceMessage = async (jobId: string, prompt: string) => {
content: truncate(result.output, 40_000), content: truncate(result.output, 40_000),
}); });
if (result.exitCode !== 0) { 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') { if (claim.job.jobType === 'maintenance_review') {
const decision = parseMaintenanceDecision(result.output); const decision = parseMaintenanceDecision(result.output);
@@ -153,7 +153,7 @@ export const AiProviderProfileForm = ({
<SheetSelect <SheetSelect
label='Provider' label='Provider'
options={[ options={[
{ label: 'OpenCode OpenAI login', value: 'opencode_openai_login' }, { label: 'Codex ChatGPT login', value: 'opencode_openai_login' },
{ label: 'OpenAI', value: 'openai' }, { label: 'OpenAI', value: 'openai' },
{ label: 'Anthropic', value: 'anthropic' }, { label: 'Anthropic', value: 'anthropic' },
{ label: 'Google', value: 'google' }, { label: 'Google', value: 'google' },
@@ -173,7 +173,7 @@ export const AiProviderProfileForm = ({
label='Auth type' label='Auth type'
options={[ options={[
{ label: 'API key', value: 'api_key' }, { label: 'API key', value: 'api_key' },
{ label: 'OpenCode auth JSON', value: 'opencode_auth_json' }, { label: 'Codex auth JSON', value: 'opencode_auth_json' },
{ label: 'None', value: 'none' }, { label: 'None', value: 'none' },
]} ]}
value={authType} value={authType}
@@ -181,8 +181,9 @@ export const AiProviderProfileForm = ({
/> />
{authType === 'opencode_auth_json' ? ( {authType === 'opencode_auth_json' ? (
<Text className='text-muted-foreground text-sm leading-5'> <Text className='text-muted-foreground text-sm leading-5'>
Copy auth.json from your Codex/OpenCode auth folder, for example Copy auth.json from your Codex auth folder, for example
~/.codex/auth.json, and paste it here. ~/.codex/auth.json, and paste it here. Spoon writes it into isolated
agent workspaces for Codex CLI runs.
</Text> </Text>
) : null} ) : null}
{authType !== 'none' ? ( {authType !== 'none' ? (
@@ -95,13 +95,13 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
}; };
return ( return (
<main className='border-border bg-muted/20 min-h-[calc(100vh-5rem)] overflow-hidden rounded-md border'> <main className='border-border bg-muted/20 flex h-[calc(100vh-8.5rem)] min-h-[720px] flex-col overflow-hidden rounded-md border'>
<JobStatusBar job={job} /> <JobStatusBar job={job} />
<div className='border-border bg-background flex items-center justify-end border-b px-4 py-2'> <div className='border-border bg-background flex items-center justify-end border-b px-4 py-2'>
<WorkspaceActions job={job} disabled={workspaceDisabled} /> <WorkspaceActions job={job} disabled={workspaceDisabled} />
</div> </div>
<div className='grid min-h-[680px] grid-cols-1 xl:grid-cols-[260px_minmax(0,1fr)_360px]'> <div className='grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[280px_minmax(0,1fr)] 2xl:grid-cols-[300px_minmax(0,1fr)_420px]'>
<aside className='border-border bg-background min-h-[260px] border-r'> <aside className='border-border bg-background min-h-0 border-r'>
<div className='border-border border-b p-3'> <div className='border-border border-b p-3'>
<h2 className='text-sm font-semibold'>Files</h2> <h2 className='text-sm font-semibold'>Files</h2>
<p className='text-muted-foreground text-xs'>Current workspace</p> <p className='text-muted-foreground text-xs'>Current workspace</p>
@@ -117,16 +117,19 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
}} }}
/> />
</aside> </aside>
<section className='bg-background min-w-0'> <section className='bg-background flex min-w-0 flex-col'>
<Tabs defaultValue='editor' className='h-full'> <Tabs defaultValue='editor' className='flex min-h-0 flex-1 flex-col'>
<TabsList <TabsList
variant='line' variant='line'
className='border-border h-11 w-full justify-start rounded-none border-b px-3' className='border-border h-11 flex-none justify-start rounded-none border-b px-3'
> >
<TabsTrigger value='editor'>Editor</TabsTrigger> <TabsTrigger value='editor'>Editor</TabsTrigger>
<TabsTrigger value='diff'>Diff</TabsTrigger> <TabsTrigger value='diff'>Diff</TabsTrigger>
<TabsTrigger value='thread' className='2xl:hidden'>
Thread
</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value='editor' className='m-0'> <TabsContent value='editor' className='m-0 min-h-0 flex-1'>
<CodeEditor <CodeEditor
path={selectedPath} path={selectedPath}
content={fileContent} content={fileContent}
@@ -134,13 +137,23 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
onSave={saveFile} onSave={saveFile}
/> />
</TabsContent> </TabsContent>
<TabsContent value='diff' className='m-0'> <TabsContent value='diff' className='m-0 min-h-0 flex-1'>
<DiffViewer diff={diff} onRefresh={loadDiff} /> <DiffViewer diff={diff} onRefresh={loadDiff} />
</TabsContent> </TabsContent>
<TabsContent
value='thread'
className='m-0 min-h-0 flex-1 2xl:hidden'
>
<AgentThread
jobId={jobId}
messages={messages}
disabled={workspaceDisabled}
/>
</TabsContent>
</Tabs> </Tabs>
<CommandPanel jobId={jobId} disabled={workspaceDisabled} /> <CommandPanel jobId={jobId} disabled={workspaceDisabled} />
</section> </section>
<aside className='border-border bg-muted/20 min-w-0 border-l'> <aside className='border-border bg-muted/20 hidden min-w-0 border-l 2xl:block'>
<AgentThread <AgentThread
jobId={jobId} jobId={jobId}
messages={messages} messages={messages}
@@ -79,7 +79,7 @@ export const CodeEditor = ({
}; };
return ( return (
<div className='flex h-full min-h-[520px] flex-col'> <div className='flex h-full min-h-0 flex-col'>
<div className='border-border flex h-11 items-center justify-between gap-3 border-b px-3'> <div className='border-border flex h-11 items-center justify-between gap-3 border-b px-3'>
<div className='min-w-0'> <div className='min-w-0'>
<p className='truncate font-mono text-xs'>{path}</p> <p className='truncate font-mono text-xs'>{path}</p>
@@ -104,7 +104,8 @@ export const CodeEditor = ({
</div> </div>
<div className='min-h-0 flex-1'> <div className='min-h-0 flex-1'>
<MonacoEditor <MonacoEditor
height='520px' height='100%'
width='100%'
path={path} path={path}
value={value} value={value}
theme='vs-dark' theme='vs-dark'
@@ -114,6 +115,7 @@ export const CodeEditor = ({
fontSize: 13, fontSize: 13,
scrollBeyondLastLine: false, scrollBeyondLastLine: false,
wordWrap: 'on', wordWrap: 'on',
automaticLayout: true,
}} }}
onMount={(editor) => { onMount={(editor) => {
editorRef.current = editor as MonacoEditorInstance; editorRef.current = editor as MonacoEditorInstance;
@@ -15,7 +15,7 @@ export const DiffViewer = ({
diff: string; diff: string;
onRefresh: () => Promise<void>; onRefresh: () => Promise<void>;
}) => ( }) => (
<div className='flex h-full min-h-[520px] flex-col'> <div className='flex h-full min-h-0 flex-col'>
<div className='border-border flex h-11 items-center justify-between border-b px-3'> <div className='border-border flex h-11 items-center justify-between border-b px-3'>
<div> <div>
<p className='text-sm font-medium'>Workspace diff</p> <p className='text-sm font-medium'>Workspace diff</p>
@@ -27,7 +27,8 @@ export const DiffViewer = ({
</div> </div>
{diff.trim() ? ( {diff.trim() ? (
<MonacoEditor <MonacoEditor
height='520px' height='100%'
width='100%'
language='diff' language='diff'
theme='vs-dark' theme='vs-dark'
value={diff} value={diff}
@@ -36,6 +37,7 @@ export const DiffViewer = ({
minimap: { enabled: false }, minimap: { enabled: false },
fontSize: 13, fontSize: 13,
scrollBeyondLastLine: false, scrollBeyondLastLine: false,
automaticLayout: true,
}} }}
/> />
) : ( ) : (
@@ -1,11 +1,21 @@
'use client'; 'use client';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { usePathname } from 'next/navigation';
export const AppShell = ({ children }: { children: ReactNode }) => { export const AppShell = ({ children }: { children: ReactNode }) => {
const pathname = usePathname();
const isWorkspace = /\/spoons\/[^/]+\/agent\/[^/]+/.test(pathname);
return ( return (
<div className='bg-muted/20 flex-1 border-t'> <div className='bg-muted/20 flex-1 border-t'>
<div className='container mx-auto min-w-0 px-4 py-6 md:px-6'> <div
className={
isWorkspace
? 'min-w-0 px-3 py-3 md:px-4'
: 'container mx-auto min-w-0 px-4 py-6 md:px-6'
}
>
{children} {children}
</div> </div>
</div> </div>
@@ -85,7 +85,7 @@ const providerOptions: {
}, },
{ {
value: 'opencode_openai_login', value: 'opencode_openai_login',
label: 'OpenCode OpenAI login', label: 'Codex ChatGPT login',
authType: 'opencode_auth_json', authType: 'opencode_auth_json',
}, },
]; ];
@@ -282,8 +282,8 @@ export const AiProviderProfilesPanel = () => {
)) ))
) : ( ) : (
<p className='text-muted-foreground text-sm'> <p className='text-muted-foreground text-sm'>
Add API-key providers for OpenCode, or store an OpenCode OpenAI Add API-key providers for OpenCode, or store a Codex ChatGPT login
login profile for the next auth-file injection pass. profile for runs that should use your Codex plan.
</p> </p>
)} )}
</CardContent> </CardContent>
@@ -332,7 +332,7 @@ export const AiProviderProfilesPanel = () => {
<div className='grid gap-2'> <div className='grid gap-2'>
<Label> <Label>
{selectedProvider.authType === 'opencode_auth_json' {selectedProvider.authType === 'opencode_auth_json'
? 'OpenCode auth JSON' ? 'Codex auth JSON'
: 'API key'} : 'API key'}
</Label> </Label>
{selectedProvider.authType === 'opencode_auth_json' ? ( {selectedProvider.authType === 'opencode_auth_json' ? (
@@ -347,8 +347,9 @@ export const AiProviderProfilesPanel = () => {
<code className='bg-muted rounded px-1 py-0.5'> <code className='bg-muted rounded px-1 py-0.5'>
~/.codex/auth.json ~/.codex/auth.json
</code> </code>
. It is stored encrypted and should be treated like a . Spoon writes it into the isolated job workspace as
password. Codex&apos;s auth cache. It is stored encrypted and should
be treated like a password.
</p> </p>
</> </>
) : ( ) : (
+2 -3
View File
@@ -5,6 +5,7 @@ ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends \ && apt-get install -y --no-install-recommends \
bash \ bash \
bubblewrap \
build-essential \ build-essential \
ca-certificates \ ca-certificates \
curl \ curl \
@@ -14,11 +15,9 @@ RUN apt-get update \
python3 \ python3 \
ripgrep \ ripgrep \
&& corepack enable \ && corepack enable \
&& npm install -g bun@1.3.10 opencode-ai@latest \ && npm install -g bun@1.3.10 opencode-ai@latest @openai/codex@latest \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN useradd --create-home --shell /bin/bash agent
USER agent
WORKDIR /workspace WORKDIR /workspace
CMD ["bash"] CMD ["bash"]
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
printf 'usage: pem-to-env VARIABLE_NAME path/to/key.pem\n' >&2
exit 2
}
[[ "$#" -eq 2 ]] || usage
name="$1"
file="$2"
case "$name" in
[A-Za-z_][A-Za-z0-9_]*) ;;
*) printf 'pem-to-env: invalid environment variable name: %s\n' "$name" >&2; exit 2 ;;
esac
[[ -f "$file" ]] || {
printf 'pem-to-env: file not found: %s\n' "$file" >&2
exit 1
}
printf '%s="' "$name"
awk 'NF { gsub(/\r/, ""); gsub(/\\/,"\\\\"); gsub(/"/,"\\\""); printf "%s\\n", $0 }' "$file"
printf '"\n'