Try to fix workers and workspace
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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's auth cache. It is stored encrypted and should
|
||||||
|
be treated like a password.
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
Executable
+26
@@ -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'
|
||||||
Reference in New Issue
Block a user