5fb285cf46
---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.
180 lines
6.6 KiB
TypeScript
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)}`);
|
|
}
|
|
} |