feat(worker): OpenCodeAdapter runs opencode in the per-user box (no side container)
This commit is contained in:
@@ -464,3 +464,56 @@ export const normalizeOpenCodeEvent = (
|
|||||||
}
|
}
|
||||||
return events;
|
return events;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// `opencode run --format json` emits newline-delimited JSON: streamed events share
|
||||||
|
// the server's `{ type, properties }` SSE envelope, plus a final result object that
|
||||||
|
// carries the session id and the assistant's completed text.
|
||||||
|
export const normalizeOpenCodeRunLine = (
|
||||||
|
line: string,
|
||||||
|
): NormalizedAgentEvent[] => {
|
||||||
|
if (!line.trim()) return [];
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(line) as unknown;
|
||||||
|
} catch {
|
||||||
|
return [{ kind: 'status', status: line }];
|
||||||
|
}
|
||||||
|
const record = asRecord(parsed);
|
||||||
|
if (!record) return [{ kind: 'status', status: line }];
|
||||||
|
if ('type' in record && 'properties' in record) {
|
||||||
|
return normalizeOpenCodeEvent(record);
|
||||||
|
}
|
||||||
|
const events: NormalizedAgentEvent[] = [];
|
||||||
|
const sessionId = record.sessionID ?? record.sessionId;
|
||||||
|
if (typeof sessionId === 'string') {
|
||||||
|
events.push({ kind: 'session', sessionId });
|
||||||
|
}
|
||||||
|
const parts = record.parts;
|
||||||
|
let text = '';
|
||||||
|
if (Array.isArray(parts)) {
|
||||||
|
text = parts
|
||||||
|
.map((part) => {
|
||||||
|
const record = asRecord(part);
|
||||||
|
return record ? textFromPart(record) : '';
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
if (!text) {
|
||||||
|
const message = asRecord(record.message);
|
||||||
|
text =
|
||||||
|
textFromPart(record) ||
|
||||||
|
(message ? stringify(message.content ?? message.text) : '');
|
||||||
|
}
|
||||||
|
if (text) {
|
||||||
|
const externalMessageId = stringify(record.messageID ?? record.id);
|
||||||
|
events.push({
|
||||||
|
kind: 'assistant_completed',
|
||||||
|
content: text,
|
||||||
|
...(externalMessageId ? { externalMessageId } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (events.length === 0) {
|
||||||
|
events.push({ kind: 'status', status: line });
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { mkdir, writeFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
import type { NormalizedAgentEvent } from '../agent-events';
|
||||||
|
import { normalizeOpenCodeRunLine } from '../agent-events';
|
||||||
|
import { env } from '../env';
|
||||||
|
import type { AdapterFactory, TurnResult } from './agent-runtime';
|
||||||
|
import {
|
||||||
|
buildMarkedCommand,
|
||||||
|
killBoxProcessesByMarker,
|
||||||
|
streamExecInContainer,
|
||||||
|
} from './docker';
|
||||||
|
import type { AdapterWorkspace } from './provider';
|
||||||
|
import { opencodeModel, providerEnvironment } from './provider';
|
||||||
|
|
||||||
|
// Normalize + pretty-print an auth JSON blob before writing it with owner-only
|
||||||
|
// permissions (mirrors the CodexAdapter helper).
|
||||||
|
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('OpenCode auth JSON is not valid JSON.');
|
||||||
|
}
|
||||||
|
await mkdir(path.dirname(filePath), { recursive: true });
|
||||||
|
await writeFile(filePath, normalized, { mode: 0o600 });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createOpenCodeAdapter: AdapterFactory = (deps) => {
|
||||||
|
const execStream = deps?.execStream ?? streamExecInContainer;
|
||||||
|
|
||||||
|
const prepareAuth = async (workspace: AdapterWorkspace) => {
|
||||||
|
// API-key profiles authenticate purely via ANTHROPIC_API_KEY/OPENAI_API_KEY
|
||||||
|
// in the environment; only `opencode_auth_json` profiles need auth.json on disk.
|
||||||
|
const profile = workspace.claim.aiProviderProfile;
|
||||||
|
if (profile?.authType !== 'opencode_auth_json') return;
|
||||||
|
if (!profile.secret) {
|
||||||
|
throw new Error('OpenCode auth profile is missing auth.json contents.');
|
||||||
|
}
|
||||||
|
const authPath = path.join(
|
||||||
|
workspace.workdir,
|
||||||
|
'.local',
|
||||||
|
'share',
|
||||||
|
'opencode',
|
||||||
|
'auth.json',
|
||||||
|
);
|
||||||
|
await writeJsonFile(authPath, profile.secret);
|
||||||
|
};
|
||||||
|
|
||||||
|
const runTurn = async (
|
||||||
|
workspace: AdapterWorkspace,
|
||||||
|
prompt: string,
|
||||||
|
onEvent: (event: NormalizedAgentEvent) => Promise<void>,
|
||||||
|
): Promise<TurnResult> => {
|
||||||
|
workspace.runtime = 'opencode';
|
||||||
|
workspace.turnMarker = `spoon-turn-${randomUUID()}`;
|
||||||
|
const marker = workspace.turnMarker;
|
||||||
|
|
||||||
|
// NOTE: `--session` continuity is UNVERIFIED against `opencode run --help` and
|
||||||
|
// may be dropped in Task 11's smoke; if unsupported, OpenCode turns are stateless
|
||||||
|
// (acceptable — Codex/Claude carry continuity).
|
||||||
|
const argv = [
|
||||||
|
'opencode',
|
||||||
|
'run',
|
||||||
|
'--format',
|
||||||
|
'json',
|
||||||
|
'--model',
|
||||||
|
opencodeModel(workspace.claim),
|
||||||
|
...(workspace.opencodeSessionId
|
||||||
|
? ['--session', workspace.opencodeSessionId]
|
||||||
|
: []),
|
||||||
|
'--',
|
||||||
|
prompt,
|
||||||
|
];
|
||||||
|
const command = buildMarkedCommand(marker, argv);
|
||||||
|
|
||||||
|
const aiEnv = providerEnvironment(workspace.claim, workspace.containerHome);
|
||||||
|
const secretEnv = Object.fromEntries(
|
||||||
|
workspace.claim.secrets.map((secret) => [secret.name, secret.value]),
|
||||||
|
);
|
||||||
|
|
||||||
|
let capturedSessionId: string | undefined;
|
||||||
|
let capturedError: string | undefined;
|
||||||
|
let finalMessage: string | undefined;
|
||||||
|
|
||||||
|
// execa's own `timeout` only kills the local docker-exec client, not the
|
||||||
|
// in-box process group. Schedule an explicit marker kill on timeout and clear
|
||||||
|
// it once the exec resolves.
|
||||||
|
const killTimer = setTimeout(() => {
|
||||||
|
void killBoxProcessesByMarker({
|
||||||
|
containerName: workspace.boxName,
|
||||||
|
marker,
|
||||||
|
});
|
||||||
|
}, env.jobTimeoutMs);
|
||||||
|
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await execStream({
|
||||||
|
containerName: workspace.boxName,
|
||||||
|
containerCwd: workspace.containerRepo,
|
||||||
|
command,
|
||||||
|
environment: { ...aiEnv, ...secretEnv },
|
||||||
|
redact: workspace.redact,
|
||||||
|
timeoutMs: env.jobTimeoutMs,
|
||||||
|
onStdoutLine: async (line) => {
|
||||||
|
for (const event of normalizeOpenCodeRunLine(line)) {
|
||||||
|
if (event.kind === 'session') capturedSessionId = event.sessionId;
|
||||||
|
if (event.kind === 'error') capturedError = event.message;
|
||||||
|
if (event.kind === 'assistant_completed' && event.content) {
|
||||||
|
finalMessage = event.content;
|
||||||
|
}
|
||||||
|
await onEvent(event);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onStderrLine: async (line) => {
|
||||||
|
if (!line.trim()) return;
|
||||||
|
await onEvent({ kind: 'status', status: line });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
clearTimeout(killTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
capturedError = result.output;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { finalMessage, sessionId: capturedSessionId, error: capturedError };
|
||||||
|
};
|
||||||
|
|
||||||
|
const abort = async (workspace: AdapterWorkspace) => {
|
||||||
|
if (!workspace.turnMarker) return;
|
||||||
|
await killBoxProcessesByMarker({
|
||||||
|
containerName: workspace.boxName,
|
||||||
|
marker: workspace.turnMarker,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return { name: 'opencode', prepareAuth, runTurn, abort };
|
||||||
|
};
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { registerAdapter } from './agent-runtime';
|
import { registerAdapter } from './agent-runtime';
|
||||||
import { createCodexAdapter } from './codex-adapter';
|
import { createCodexAdapter } from './codex-adapter';
|
||||||
|
import { createOpenCodeAdapter } from './opencode-adapter';
|
||||||
|
|
||||||
registerAdapter('codex', createCodexAdapter);
|
registerAdapter('codex', createCodexAdapter);
|
||||||
|
registerAdapter('opencode', createOpenCodeAdapter);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describe, expect, test } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
normalizeCodexJsonLine,
|
normalizeCodexJsonLine,
|
||||||
normalizeOpenCodeEvent,
|
normalizeOpenCodeEvent,
|
||||||
|
normalizeOpenCodeRunLine,
|
||||||
} from '../../src/agent-events';
|
} from '../../src/agent-events';
|
||||||
|
|
||||||
describe('agent event normalization', () => {
|
describe('agent event normalization', () => {
|
||||||
@@ -291,4 +292,41 @@ describe('agent event normalization', () => {
|
|||||||
externalMessageId: 'message-2',
|
externalMessageId: 'message-2',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('normalizes opencode run --format json output lines', () => {
|
||||||
|
expect(
|
||||||
|
normalizeOpenCodeRunLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'message.part.delta',
|
||||||
|
properties: { part: { text: 'hi' }, messageID: 'm1' },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({
|
||||||
|
kind: 'assistant_delta',
|
||||||
|
content: 'hi',
|
||||||
|
externalMessageId: 'm1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeOpenCodeRunLine(
|
||||||
|
JSON.stringify({
|
||||||
|
sessionID: 'ses_abc',
|
||||||
|
parts: [{ text: 'final answer' }],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
{ kind: 'session', sessionId: 'ses_abc' },
|
||||||
|
expect.objectContaining({
|
||||||
|
kind: 'assistant_completed',
|
||||||
|
content: 'final answer',
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(normalizeOpenCodeRunLine('not json at all')).toContainEqual({
|
||||||
|
kind: 'status',
|
||||||
|
status: 'not json at all',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { NormalizedAgentEvent } from '../../src/agent-events';
|
||||||
|
import type { ExecStreamFn } from '../../src/runtime/agent-runtime';
|
||||||
|
import type { AdapterWorkspace, Claim } from '../../src/runtime/provider';
|
||||||
|
|
||||||
|
const loadAdapter = async () => {
|
||||||
|
process.env.SPOON_WORKER_TOKEN = 'test-worker-token';
|
||||||
|
process.env.GITHUB_APP_ID = '123';
|
||||||
|
process.env.GITHUB_APP_PRIVATE_KEY =
|
||||||
|
'-----BEGIN PRIVATE KEY-----\\ntest\\n-----END PRIVATE KEY-----';
|
||||||
|
return await import('../../src/runtime/opencode-adapter');
|
||||||
|
};
|
||||||
|
|
||||||
|
const claim = {
|
||||||
|
job: {
|
||||||
|
_id: 'job-1',
|
||||||
|
prompt: 'hi',
|
||||||
|
baseBranch: 'main',
|
||||||
|
workBranch: 'spoon/x',
|
||||||
|
forkOwner: 'o',
|
||||||
|
forkRepo: 'r',
|
||||||
|
upstreamOwner: 'u',
|
||||||
|
upstreamRepo: 'r',
|
||||||
|
},
|
||||||
|
spoon: { name: 'Spoon' },
|
||||||
|
openai: { model: 'gpt-5', reasoningEffort: 'medium' },
|
||||||
|
aiProviderProfile: {
|
||||||
|
id: 'p',
|
||||||
|
name: 'OpenAI',
|
||||||
|
provider: 'openai',
|
||||||
|
authType: 'api_key',
|
||||||
|
secret: 'sk-test',
|
||||||
|
model: 'gpt-5',
|
||||||
|
reasoningEffort: 'medium',
|
||||||
|
},
|
||||||
|
github: {},
|
||||||
|
secrets: [],
|
||||||
|
} as unknown as Claim;
|
||||||
|
|
||||||
|
const makeWorkspace = (): AdapterWorkspace => ({
|
||||||
|
claim,
|
||||||
|
workdir: '/tmp/spoon-opencode-adapter-test-missing',
|
||||||
|
homeDir: '/tmp/spoon-opencode-adapter-test-missing',
|
||||||
|
username: 'tester',
|
||||||
|
containerHome: '/home/tester',
|
||||||
|
containerRepo: '/home/tester/Code/spoon',
|
||||||
|
repoDir: '/tmp/spoon-opencode-adapter-test-missing/Code/spoon',
|
||||||
|
boxName: 'spoon-box-tester',
|
||||||
|
redact: (value: string) => value,
|
||||||
|
runtime: 'opencode',
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('OpenCodeAdapter', () => {
|
||||||
|
test('streams normalized events and marks the turn via buildMarkedCommand', async () => {
|
||||||
|
const { createOpenCodeAdapter } = await loadAdapter();
|
||||||
|
const captured: unknown[] = [];
|
||||||
|
const execStream = vi.fn(async (args) => {
|
||||||
|
captured.push(args.command);
|
||||||
|
await args.onStdoutLine?.(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'message.part.delta',
|
||||||
|
properties: { part: { text: 'hi' }, messageID: 'm1' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await args.onStdoutLine?.(
|
||||||
|
JSON.stringify({
|
||||||
|
sessionID: 'ses_abc',
|
||||||
|
parts: [{ text: 'final answer' }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return { exitCode: 0, output: '' };
|
||||||
|
}) as unknown as ExecStreamFn;
|
||||||
|
|
||||||
|
const adapter = createOpenCodeAdapter({ execStream });
|
||||||
|
const events: NormalizedAgentEvent[] = [];
|
||||||
|
const onEvent = (event: NormalizedAgentEvent): Promise<void> => {
|
||||||
|
events.push(event);
|
||||||
|
return Promise.resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await adapter.runTurn(makeWorkspace(), 'do it', onEvent);
|
||||||
|
|
||||||
|
expect(events).toContainEqual({
|
||||||
|
kind: 'assistant_delta',
|
||||||
|
content: 'hi',
|
||||||
|
externalMessageId: 'm1',
|
||||||
|
});
|
||||||
|
expect(result).toBeTypeOf('object');
|
||||||
|
expect(result.sessionId).toBe('ses_abc');
|
||||||
|
expect(result.finalMessage).toBe('final answer');
|
||||||
|
|
||||||
|
// The turn ran through buildMarkedCommand (setsid bash session) carrying the
|
||||||
|
// opencode run argv in the script.
|
||||||
|
const command = captured[0] as string[];
|
||||||
|
expect(command[0]).toBe('setsid');
|
||||||
|
expect(command[1]).toBe('bash');
|
||||||
|
expect(command[3]).toContain("'opencode' 'run' '--format' 'json'");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user