142 lines
4.7 KiB
TypeScript
142 lines
4.7 KiB
TypeScript
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 };
|
|
};
|