Files
ci/src/backends/opencode.ts
T
Jon Chery 5fb285cf46 fix(P01): add Zod BackendResult validation and fix opencode silent success
---ci---
project: ci
phase: 1
milestone: v0.8
status: in_progress
decisions:
  - id: D-022
    decision: Validate BackendResult at boundary with Zod schema
    rationale: External backend output is untrusted; runtime validation prevents corrupt commit streams
    confidence: 0.92
  - id: D-023
    decision: opencode parseResult returns success:false on malformed JSON
    rationale: Silent success:true on parse failure masks backend errors; fail loudly instead
    confidence: 0.95
requirements:
  covered: [FIX-02, FIX-03]
---/ci---

FIX-02: Add Zod BackendResultSchema and validateBackendResult() in
backends/types.ts. backendResultToAgentResult() in base.ts now validates
before passing through. Invalid results produce success:false with error
detail. Path traversal protection: artifact paths with '..' or leading '/'
are rejected.

FIX-03: opencode.ts parseResult() no longer defaults to success:true when
JSON parsing fails entirely. Both the inner parse error and the no-JSON
match case now return emptyBackendResult() with descriptive error messages.
2026-05-29 19:52:51 +00:00

180 lines
6.6 KiB
TypeScript

import { execSync, spawn } from "node:child_process";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import {
IntelligenceBackend,
BackendRequest,
BackendResult,
BackendType,
OpencodeBackendConfig,
emptyTokenUsage,
emptyBackendResult,
} from "./types.js";
export class OpencodeBackend implements IntelligenceBackend {
readonly name = "opencode";
readonly type: BackendType = "agent";
private config: OpencodeBackendConfig;
constructor(config?: OpencodeBackendConfig) {
this.config = config || { enabled: true };
}
async isAvailable(): Promise<boolean> {
const executable = this.config.executable || "opencode";
try {
const result = execSync(`${executable} --version`, {
encoding: "utf-8",
timeout: 5000,
stdio: "pipe",
});
return !!result;
} catch {
return false;
}
}
async execute(request: BackendRequest): Promise<BackendResult> {
const executable = this.config.executable || "opencode";
const startTime = Date.now();
try {
const serializedRequest = this.serializeRequest(request);
const tempFile = path.join(
os.tmpdir(),
`ci-request-${request.persona}-${Date.now()}.json`
);
fs.writeFileSync(tempFile, serializedRequest, "utf-8");
const command = `${executable} --non-interactive "/ci-${request.workflow} ${request.task}"`;
const contextEnv = {
...process.env,
CI_BACKEND_REQUEST: tempFile,
CI_PROJECT_PATH: request.context.project_path,
CI_PHASE: String(request.context.phase),
CI_STAGE: request.context.stage,
CI_AUTONOMY: request.autonomy,
};
const result = execSync(command, {
cwd: request.context.project_path,
encoding: "utf-8",
timeout: 600000,
env: contextEnv,
stdio: ["pipe", "pipe", "pipe"],
maxBuffer: 10 * 1024 * 1024,
});
try {
fs.unlinkSync(tempFile);
} catch {}
return this.parseResult(result, Date.now() - startTime);
} catch (err) {
const execErr = err as { stderr?: string; status?: number };
try {
const tempFile = path.join(
os.tmpdir(),
`ci-request-${request.persona}-${startTime}.json`
);
if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile);
} catch {}
if (execErr.stderr) {
return emptyBackendResult(
`opencode execution failed (exit ${execErr.status || "unknown"}): ${execErr.stderr}`
);
}
return emptyBackendResult(
`opencode backend error: ${err instanceof Error ? err.message : String(err)}`
);
}
}
private serializeRequest(request: BackendRequest): string {
return JSON.stringify({
persona: request.persona,
workflow: request.workflow,
task: request.task,
context: {
project_path: request.context.project_path,
phase: request.context.phase,
stage: request.context.stage,
specification: request.context.specification,
config_path: request.context.config_path,
},
autonomy: request.autonomy,
}, null, 2);
}
private parseResult(output: string, durationMs: number): BackendResult {
const jsonMatch = output.match(/\{[\s\S]*"success"[\s\S]*\}/);
if (jsonMatch) {
try {
const parsed = JSON.parse(jsonMatch[0]);
if (typeof parsed.success !== "boolean") {
return emptyBackendResult(`Backend returned non-boolean success field: ${typeof parsed.success}`);
}
if (parsed.success === false && !parsed.error && !parsed.output) {
return emptyBackendResult("Backend returned failure with no error or output");
}
return {
success: parsed.success,
output: parsed.output || output,
artifacts: Array.isArray(parsed.artifacts)
? parsed.artifacts.filter((a: unknown) => !!a).map((a: Record<string, unknown>) => ({
path: String(a.path || ""),
content: String(a.content || ""),
operation: (a.operation as "create" | "update" | "delete") || "create",
}))
: [],
decisions: Array.isArray(parsed.decisions)
? parsed.decisions.filter((d: unknown) => !!d).map((d: Record<string, unknown>) => ({
id: String(d.id || "D-000"),
decision: String(d.decision || ""),
rationale: String(d.rationale || ""),
confidence: Number(d.confidence || 0.5),
category: (d.category as "implementation_approach" | "technology_choice" | "architecture" | "scope" | "verification" | "security" | "deployment" | "general") || "general",
alternatives_considered: Array.isArray(d.alternatives_considered)
? d.alternatives_considered.map((a: unknown) =>
typeof a === "string"
? { option: a, rejected_reason: "" }
: (a as { option: string; rejected_reason: string })
)
: [],
human_override: d.human_override ? String(d.human_override) : null,
timestamp: String(d.timestamp || new Date().toISOString()),
}))
: [],
escalations: Array.isArray(parsed.escalations)
? parsed.escalations.filter((e: unknown) => !!e).map((e: Record<string, unknown>) => ({
id: String(e.id || "E-000"),
timestamp: String(e.timestamp || new Date().toISOString()),
type: (e.type as "irreversible_action" | "verification_failure" | "low_confidence_decision" | "security_escalation" | "specification_ambiguity") || "specification_ambiguity",
phase: String(e.phase || ""),
description: String(e.description || ""),
context: String(e.context || ""),
options: Array.isArray(e.options) ? e.options : [],
default_option_id: String(e.default_option_id || ""),
resolution: (e.resolution as "approved" | "rejected" | "modified" | "pending" | "timeout_auto_proceed") || "pending",
commit_hash: String(e.commit_hash || ""),
}))
: [],
usage: parsed.usage || {
...emptyTokenUsage(),
total_tokens: Math.ceil(output.length / 4),
},
};
} catch {
return emptyBackendResult(`Backend output contained JSON-like structure but failed to parse: ${output.slice(0, 200)}`);
}
}
return emptyBackendResult(`Backend output did not contain valid JSON result: ${output.slice(0, 200)}`);
}
}