@owasp-aghast/aghast 0.8.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +127 -0
- package/assets/img/aghastowaspcaption.png +0 -0
- package/assets/txt/logo.txt +52 -0
- package/config/pricing.json +42 -0
- package/config/prompts/false-positive-validation.md +78 -0
- package/config/prompts/general-vuln-discovery.md +99 -0
- package/config/prompts/generic-instructions.md +59 -0
- package/config/prompts/test-cheaper-instructions.md +57 -0
- package/dist/budget.d.ts +62 -0
- package/dist/budget.d.ts.map +1 -0
- package/dist/budget.js +137 -0
- package/dist/budget.js.map +1 -0
- package/dist/build-config.d.ts +15 -0
- package/dist/build-config.d.ts.map +1 -0
- package/dist/build-config.js +568 -0
- package/dist/build-config.js.map +1 -0
- package/dist/check-library.d.ts +88 -0
- package/dist/check-library.d.ts.map +1 -0
- package/dist/check-library.js +461 -0
- package/dist/check-library.js.map +1 -0
- package/dist/check-types.d.ts +35 -0
- package/dist/check-types.d.ts.map +1 -0
- package/dist/check-types.js +66 -0
- package/dist/check-types.js.map +1 -0
- package/dist/claude-code-provider.d.ts +29 -0
- package/dist/claude-code-provider.d.ts.map +1 -0
- package/dist/claude-code-provider.js +347 -0
- package/dist/claude-code-provider.js.map +1 -0
- package/dist/cli.d.ts +13 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +109 -0
- package/dist/cli.js.map +1 -0
- package/dist/colors.d.ts +7 -0
- package/dist/colors.d.ts.map +1 -0
- package/dist/colors.js +18 -0
- package/dist/colors.js.map +1 -0
- package/dist/cost-calculator.d.ts +80 -0
- package/dist/cost-calculator.d.ts.map +1 -0
- package/dist/cost-calculator.js +226 -0
- package/dist/cost-calculator.js.map +1 -0
- package/dist/defaults.d.ts +21 -0
- package/dist/defaults.d.ts.map +1 -0
- package/dist/defaults.js +21 -0
- package/dist/defaults.js.map +1 -0
- package/dist/diff-filter.d.ts +50 -0
- package/dist/diff-filter.d.ts.map +1 -0
- package/dist/diff-filter.js +130 -0
- package/dist/diff-filter.js.map +1 -0
- package/dist/diff-parser.d.ts +40 -0
- package/dist/diff-parser.d.ts.map +1 -0
- package/dist/diff-parser.js +112 -0
- package/dist/diff-parser.js.map +1 -0
- package/dist/diff-unit-matcher.d.ts +53 -0
- package/dist/diff-unit-matcher.d.ts.map +1 -0
- package/dist/diff-unit-matcher.js +156 -0
- package/dist/diff-unit-matcher.js.map +1 -0
- package/dist/discoveries/openant-discovery.d.ts +10 -0
- package/dist/discoveries/openant-discovery.d.ts.map +1 -0
- package/dist/discoveries/openant-discovery.js +56 -0
- package/dist/discoveries/openant-discovery.js.map +1 -0
- package/dist/discoveries/sarif-discovery.d.ts +9 -0
- package/dist/discoveries/sarif-discovery.d.ts.map +1 -0
- package/dist/discoveries/sarif-discovery.js +56 -0
- package/dist/discoveries/sarif-discovery.js.map +1 -0
- package/dist/discoveries/semgrep-discovery.d.ts +9 -0
- package/dist/discoveries/semgrep-discovery.d.ts.map +1 -0
- package/dist/discoveries/semgrep-discovery.js +61 -0
- package/dist/discoveries/semgrep-discovery.js.map +1 -0
- package/dist/discovery.d.ts +100 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +49 -0
- package/dist/discovery.js.map +1 -0
- package/dist/error-codes.d.ts +46 -0
- package/dist/error-codes.d.ts.map +1 -0
- package/dist/error-codes.js +70 -0
- package/dist/error-codes.js.map +1 -0
- package/dist/formatters/index.d.ts +10 -0
- package/dist/formatters/index.d.ts.map +1 -0
- package/dist/formatters/index.js +23 -0
- package/dist/formatters/index.js.map +1 -0
- package/dist/formatters/json-formatter.d.ts +11 -0
- package/dist/formatters/json-formatter.d.ts.map +1 -0
- package/dist/formatters/json-formatter.js +11 -0
- package/dist/formatters/json-formatter.js.map +1 -0
- package/dist/formatters/sarif-formatter.d.ts +18 -0
- package/dist/formatters/sarif-formatter.d.ts.map +1 -0
- package/dist/formatters/sarif-formatter.js +103 -0
- package/dist/formatters/sarif-formatter.js.map +1 -0
- package/dist/formatters/types.d.ts +11 -0
- package/dist/formatters/types.d.ts.map +1 -0
- package/dist/formatters/types.js +6 -0
- package/dist/formatters/types.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +754 -0
- package/dist/index.js.map +1 -0
- package/dist/logging.d.ts +140 -0
- package/dist/logging.d.ts.map +1 -0
- package/dist/logging.js +344 -0
- package/dist/logging.js.map +1 -0
- package/dist/mock-agent-provider.d.ts +24 -0
- package/dist/mock-agent-provider.d.ts.map +1 -0
- package/dist/mock-agent-provider.js +38 -0
- package/dist/mock-agent-provider.js.map +1 -0
- package/dist/mock-ai-provider.d.ts +21 -0
- package/dist/mock-ai-provider.d.ts.map +1 -0
- package/dist/mock-ai-provider.js +31 -0
- package/dist/mock-ai-provider.js.map +1 -0
- package/dist/new-check.d.ts +13 -0
- package/dist/new-check.d.ts.map +1 -0
- package/dist/new-check.js +515 -0
- package/dist/new-check.js.map +1 -0
- package/dist/openant-loader.d.ts +105 -0
- package/dist/openant-loader.d.ts.map +1 -0
- package/dist/openant-loader.js +135 -0
- package/dist/openant-loader.js.map +1 -0
- package/dist/openant-runner.d.ts +30 -0
- package/dist/openant-runner.d.ts.map +1 -0
- package/dist/openant-runner.js +113 -0
- package/dist/openant-runner.js.map +1 -0
- package/dist/opencode-provider.d.ts +63 -0
- package/dist/opencode-provider.d.ts.map +1 -0
- package/dist/opencode-provider.js +719 -0
- package/dist/opencode-provider.js.map +1 -0
- package/dist/prompt-template.d.ts +12 -0
- package/dist/prompt-template.d.ts.map +1 -0
- package/dist/prompt-template.js +36 -0
- package/dist/prompt-template.js.map +1 -0
- package/dist/provider-registry.d.ts +15 -0
- package/dist/provider-registry.d.ts.map +1 -0
- package/dist/provider-registry.js +29 -0
- package/dist/provider-registry.js.map +1 -0
- package/dist/provider-utils.d.ts +52 -0
- package/dist/provider-utils.d.ts.map +1 -0
- package/dist/provider-utils.js +40 -0
- package/dist/provider-utils.js.map +1 -0
- package/dist/repository-analyzer.d.ts +68 -0
- package/dist/repository-analyzer.d.ts.map +1 -0
- package/dist/repository-analyzer.js +238 -0
- package/dist/repository-analyzer.js.map +1 -0
- package/dist/response-parser.d.ts +14 -0
- package/dist/response-parser.d.ts.map +1 -0
- package/dist/response-parser.js +111 -0
- package/dist/response-parser.js.map +1 -0
- package/dist/runtime-config.d.ts +15 -0
- package/dist/runtime-config.d.ts.map +1 -0
- package/dist/runtime-config.js +187 -0
- package/dist/runtime-config.js.map +1 -0
- package/dist/sarif-parser.d.ts +20 -0
- package/dist/sarif-parser.d.ts.map +1 -0
- package/dist/sarif-parser.js +76 -0
- package/dist/sarif-parser.js.map +1 -0
- package/dist/scan-history.d.ts +82 -0
- package/dist/scan-history.d.ts.map +1 -0
- package/dist/scan-history.js +127 -0
- package/dist/scan-history.js.map +1 -0
- package/dist/scan-runner.d.ts +122 -0
- package/dist/scan-runner.d.ts.map +1 -0
- package/dist/scan-runner.js +928 -0
- package/dist/scan-runner.js.map +1 -0
- package/dist/semgrep-runner.d.ts +25 -0
- package/dist/semgrep-runner.d.ts.map +1 -0
- package/dist/semgrep-runner.js +100 -0
- package/dist/semgrep-runner.js.map +1 -0
- package/dist/snippet-extractor.d.ts +25 -0
- package/dist/snippet-extractor.d.ts.map +1 -0
- package/dist/snippet-extractor.js +56 -0
- package/dist/snippet-extractor.js.map +1 -0
- package/dist/stats.d.ts +11 -0
- package/dist/stats.d.ts.map +1 -0
- package/dist/stats.js +197 -0
- package/dist/stats.js.map +1 -0
- package/dist/types.d.ts +321 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +19 -0
- package/dist/types.js.map +1 -0
- package/package.json +61 -0
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode agent provider implementation.
|
|
3
|
+
* Uses @opencode-ai/sdk v2 API to delegate to any LLM provider supported by OpenCode.
|
|
4
|
+
*
|
|
5
|
+
* Progress logging: at debug/trace level, subscribes to the SSE event stream to
|
|
6
|
+
* log tool calls and session errors in real-time while session.prompt() blocks.
|
|
7
|
+
*/
|
|
8
|
+
import { exec, spawn, spawnSync } from 'node:child_process';
|
|
9
|
+
import { existsSync, rmSync } from 'node:fs';
|
|
10
|
+
import { rm as rmAsync } from 'node:fs/promises';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { promisify } from 'node:util';
|
|
13
|
+
import { FatalProviderError } from './types.js';
|
|
14
|
+
import { parseAgentResponse } from './response-parser.js';
|
|
15
|
+
import { OUTPUT_SCHEMA } from './provider-utils.js';
|
|
16
|
+
import { logProgress, logDebug, logDebugFull, logTrace, logWarn, createTimer, isDebugEnabled, isTraceEnabled } from './logging.js';
|
|
17
|
+
const execAsync = promisify(exec);
|
|
18
|
+
const TAG = 'opencode-provider';
|
|
19
|
+
const DEFAULT_OPENCODE_MODEL = 'opencode/hy3-preview-free';
|
|
20
|
+
// Tools the agent is permitted to use — everything else is denied.
|
|
21
|
+
const ALLOWED_TOOL_PERMISSIONS = new Set(['read', 'glob', 'grep', 'list']);
|
|
22
|
+
const HEARTBEAT_INTERVAL_MS = 15000;
|
|
23
|
+
const CLOSE_TIMEOUT_MS = 5000;
|
|
24
|
+
/**
|
|
25
|
+
* Parse a "providerID/modelID" string into its components.
|
|
26
|
+
* Falls back to the default if not provided.
|
|
27
|
+
*/
|
|
28
|
+
function parseModelString(model) {
|
|
29
|
+
const raw = model ?? DEFAULT_OPENCODE_MODEL;
|
|
30
|
+
const slashIdx = raw.indexOf('/');
|
|
31
|
+
if (slashIdx === -1) {
|
|
32
|
+
throw new Error(`Invalid model format "${raw}" for opencode provider. Expected "providerID/modelID" (e.g. "anthropic/claude-sonnet-4-20250514").`);
|
|
33
|
+
}
|
|
34
|
+
return { providerID: raw.slice(0, slashIdx), modelID: raw.slice(slashIdx + 1) };
|
|
35
|
+
}
|
|
36
|
+
// Regex for stderr lines worth forwarding — drops service=bus, tool.registry, snapshot,
|
|
37
|
+
// config, file.watcher, lsp, plugin, etc. Keeps LLM calls (including 429 retries),
|
|
38
|
+
// provider routing, permission decisions, and session loop steps.
|
|
39
|
+
const USEFUL_SERVER_LOG = /service=(llm|permission|provider|session\.prompt|session\.processor)\b/;
|
|
40
|
+
// Lines matching USEFUL_SERVER_LOG that also contain an error indicator are promoted to debug.
|
|
41
|
+
const SERVER_LOG_ERROR = /\berror\b/i;
|
|
42
|
+
/** Extract a compact summary from a raw opencode server error log line. */
|
|
43
|
+
function summariseServerError(line) {
|
|
44
|
+
const providerID = line.match(/providerID=(\S+)/)?.[1] ?? '';
|
|
45
|
+
const modelID = line.match(/modelID=(\S+)/)?.[1] ?? '';
|
|
46
|
+
const statusCode = line.match(/"statusCode":(\d+)/)?.[1];
|
|
47
|
+
const isRetryable = /"isRetryable":true/.test(line);
|
|
48
|
+
const errorName = line.match(/"name":"([^"]+)"/)?.[1] ?? 'LLM error';
|
|
49
|
+
const model = [providerID, modelID].filter(Boolean).join('/');
|
|
50
|
+
const status = statusCode ? `HTTP ${statusCode}` : errorName;
|
|
51
|
+
const suffix = isRetryable ? ' (retrying)' : '';
|
|
52
|
+
return `[opencode-server] ${status}${model ? ` — ${model}` : ''}${suffix}`;
|
|
53
|
+
}
|
|
54
|
+
// Inlined from @opencode-ai/sdk/dist/process.js — that path is not in the package exports map.
|
|
55
|
+
// See docs/opencode-provider-internals.md.
|
|
56
|
+
function stopProcess(proc) {
|
|
57
|
+
if (proc.exitCode !== null || proc.signalCode !== null)
|
|
58
|
+
return;
|
|
59
|
+
if (process.platform === 'win32' && proc.pid) {
|
|
60
|
+
const out = spawnSync('taskkill', ['/pid', String(proc.pid), '/T', '/F'], { windowsHide: true });
|
|
61
|
+
if (!out.error && out.status === 0)
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
proc.kill();
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Spawn `opencode serve` directly so we can forward filtered server logs.
|
|
68
|
+
* Returns the same `{ url, close() }` shape as the SDK's createOpencode().
|
|
69
|
+
* At debug/trace log level, useful stderr lines (LLM calls, permission decisions,
|
|
70
|
+
* provider routing) are forwarded to logDebug — surfacing 429 retries and auth errors
|
|
71
|
+
* that would otherwise be invisible while session.prompt() blocks.
|
|
72
|
+
*/
|
|
73
|
+
async function spawnOpencodeServer() {
|
|
74
|
+
// Always pass --print-logs so opencode writes server logs to stderr instead of disk.
|
|
75
|
+
// We capture and forward those logs via logDebug, which routes to whichever handlers
|
|
76
|
+
// are active (file handler at debug level sees them even if console is at info).
|
|
77
|
+
// Shell is required on Windows for the .cmd wrapper (CVE-2024-27980 mitigation).
|
|
78
|
+
const cmd = 'opencode serve --hostname=127.0.0.1 --port=0 --print-logs';
|
|
79
|
+
const proc = spawn(cmd, { shell: true });
|
|
80
|
+
const forwardStderr = (chunk) => {
|
|
81
|
+
for (const line of chunk.toString().split('\n')) {
|
|
82
|
+
if (!line.trim())
|
|
83
|
+
continue;
|
|
84
|
+
if (USEFUL_SERVER_LOG.test(line)) {
|
|
85
|
+
// Log error lines (e.g. 429 retries) at info so they're visible without --debug.
|
|
86
|
+
if (SERVER_LOG_ERROR.test(line)) {
|
|
87
|
+
logProgress(TAG, summariseServerError(line));
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
logTrace(TAG, `[opencode-server] ${line.trim()}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
let stdoutBuf = '';
|
|
96
|
+
const url = await new Promise((resolve, reject) => {
|
|
97
|
+
const timeout = setTimeout(() => {
|
|
98
|
+
stopProcess(proc);
|
|
99
|
+
reject(new Error('Timeout: opencode server did not start within 30s'));
|
|
100
|
+
}, 30_000);
|
|
101
|
+
proc.stdout?.on('data', (chunk) => {
|
|
102
|
+
stdoutBuf += chunk.toString();
|
|
103
|
+
const lines = stdoutBuf.split('\n');
|
|
104
|
+
stdoutBuf = lines.pop() ?? '';
|
|
105
|
+
for (const line of lines) {
|
|
106
|
+
const m = line.match(/opencode server listening on\s+(https?:\/\/[^\s]+)/);
|
|
107
|
+
if (m) {
|
|
108
|
+
clearTimeout(timeout);
|
|
109
|
+
resolve(m[1]);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
proc.stderr?.on('data', forwardStderr);
|
|
114
|
+
proc.on('exit', (code) => {
|
|
115
|
+
clearTimeout(timeout);
|
|
116
|
+
reject(new Error(`opencode server exited with code ${code} before becoming ready`));
|
|
117
|
+
});
|
|
118
|
+
proc.on('error', (err) => {
|
|
119
|
+
clearTimeout(timeout);
|
|
120
|
+
reject(new Error(`opencode server spawn error: ${err.message}`));
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
// Continue forwarding stderr after server is ready.
|
|
124
|
+
proc.stderr?.off('data', forwardStderr);
|
|
125
|
+
proc.stderr?.on('data', forwardStderr);
|
|
126
|
+
return {
|
|
127
|
+
url,
|
|
128
|
+
close: () => stopProcess(proc),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/** Verify that the opencode binary is installed and runnable. */
|
|
132
|
+
function verifyOpenCodeInstalled() {
|
|
133
|
+
// Use `exec` (single command string, always uses shell) instead of `execFile` with
|
|
134
|
+
// shell:true + args array. The latter triggers DEP0190 on Node 22+; the former does not.
|
|
135
|
+
// Shell is required on Windows to invoke opencode's .cmd wrapper — spawning .cmd
|
|
136
|
+
// directly is blocked by the CVE-2024-27980 mitigation. No user input is interpolated
|
|
137
|
+
// into the command, so there is no injection surface.
|
|
138
|
+
return new Promise((resolve, reject) => {
|
|
139
|
+
exec('opencode --version', (error, stdout, stderr) => {
|
|
140
|
+
if (error) {
|
|
141
|
+
// `exec` routes both "spawn failed" (binary not on PATH) and "ran but exited
|
|
142
|
+
// non-zero" (e.g. corrupt config, permission issue) through the same error
|
|
143
|
+
// callback. We can't tell the two apart reliably cross-platform, so the
|
|
144
|
+
// message has to cover both possibilities — otherwise a user with a broken
|
|
145
|
+
// install is sent on a wild goose chase reinstalling an already-present binary.
|
|
146
|
+
const detail = (stderr || stdout || error.message).toString().trim();
|
|
147
|
+
const suffix = detail ? ` Details: ${detail}` : '';
|
|
148
|
+
reject(new Error(`OpenCode is required for the 'opencode' agent provider but \`opencode --version\` failed. ` +
|
|
149
|
+
`Either OpenCode is not installed (get it from https://opencode.ai) or the installed binary returned an error.${suffix}`));
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
resolve();
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
export class OpenCodeProvider {
|
|
157
|
+
providerID = '';
|
|
158
|
+
modelID = '';
|
|
159
|
+
_client;
|
|
160
|
+
_server;
|
|
161
|
+
cleanedUp = false;
|
|
162
|
+
signalHandler;
|
|
163
|
+
/** Refcount of project markers we created, keyed by absolute repositoryPath.
|
|
164
|
+
* An entry exists ONLY when we created the marker — we never track pre-existing `.git`. */
|
|
165
|
+
createdMarkers = new Map();
|
|
166
|
+
/** FIFO async mutex serializing mutations to createdMarkers. Guards against races
|
|
167
|
+
* between the N parallel executeCheck calls that share one repositoryPath. */
|
|
168
|
+
markerMutex = Promise.resolve();
|
|
169
|
+
/** Skip project-marker logic when a mock client was injected (tests use fake paths). */
|
|
170
|
+
skipProjectMarker;
|
|
171
|
+
constructor(options) {
|
|
172
|
+
if (options?._client) {
|
|
173
|
+
this._client = options._client;
|
|
174
|
+
}
|
|
175
|
+
this.skipProjectMarker = !!options?._client;
|
|
176
|
+
}
|
|
177
|
+
checkPrerequisites() {
|
|
178
|
+
// OpenCode manages its own credentials — no env var prerequisites to check.
|
|
179
|
+
}
|
|
180
|
+
async initialize(config) {
|
|
181
|
+
const parsed = parseModelString(config.model);
|
|
182
|
+
this.providerID = parsed.providerID;
|
|
183
|
+
this.modelID = parsed.modelID;
|
|
184
|
+
// Skip server startup if a mock client was injected via constructor
|
|
185
|
+
if (this._client) {
|
|
186
|
+
logDebug(TAG, 'Using injected client (test mode)');
|
|
187
|
+
await this.validateModel();
|
|
188
|
+
logDebug(TAG, `Provider initialized with model ${this.providerID}/${this.modelID}`);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
// Verify opencode binary is installed
|
|
192
|
+
await verifyOpenCodeInstalled();
|
|
193
|
+
const { createOpencodeClient } = await import('@opencode-ai/sdk/v2/client');
|
|
194
|
+
logProgress(TAG, 'Starting OpenCode server...');
|
|
195
|
+
this._server = await spawnOpencodeServer();
|
|
196
|
+
this._client = createOpencodeClient({ baseUrl: this._server.url });
|
|
197
|
+
logProgress(TAG, `OpenCode server started at ${this._server.url}`);
|
|
198
|
+
// Register signal handlers for cleanup on unexpected exit.
|
|
199
|
+
// SIGHUP catches terminal-window-close (sent as SIGHUP on Unix; Node maps
|
|
200
|
+
// Windows CTRL_CLOSE_EVENT to the same event) — critical for cleaning up the
|
|
201
|
+
// transient .git marker when a user closes their terminal mid-scan.
|
|
202
|
+
this.signalHandler = () => {
|
|
203
|
+
this.cleanupSync();
|
|
204
|
+
process.exit(1);
|
|
205
|
+
};
|
|
206
|
+
process.on('SIGINT', this.signalHandler);
|
|
207
|
+
process.on('SIGTERM', this.signalHandler);
|
|
208
|
+
process.on('SIGHUP', this.signalHandler);
|
|
209
|
+
if (process.platform === 'win32') {
|
|
210
|
+
process.on('SIGBREAK', this.signalHandler);
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
await this.validateModel();
|
|
214
|
+
}
|
|
215
|
+
catch (err) {
|
|
216
|
+
this.cleanupSync();
|
|
217
|
+
throw err;
|
|
218
|
+
}
|
|
219
|
+
logDebug(TAG, `Provider initialized with model ${this.providerID}/${this.modelID}`);
|
|
220
|
+
}
|
|
221
|
+
async validateModel() {
|
|
222
|
+
const client = this._client;
|
|
223
|
+
const result = await client.config.providers();
|
|
224
|
+
const data = result.data;
|
|
225
|
+
const providers = data?.providers ?? [];
|
|
226
|
+
const provider = providers.find(p => p.id === this.providerID);
|
|
227
|
+
if (!provider) {
|
|
228
|
+
const available = providers.map(p => p.id).join(', ') || '(none)';
|
|
229
|
+
throw new FatalProviderError(`OpenCode provider "${this.providerID}" not found. Available providers: ${available}. Run 'opencode' and use /connect to configure providers.`);
|
|
230
|
+
}
|
|
231
|
+
const models = provider.models ? Object.keys(provider.models) : [];
|
|
232
|
+
if (models.length > 0 && !models.includes(this.modelID)) {
|
|
233
|
+
const availableModels = models.map(m => `${this.providerID}/${m}`).join(', ');
|
|
234
|
+
const availableProviders = providers.map(p => p.id).join(', ') || '(none)';
|
|
235
|
+
throw new FatalProviderError(`Model "${this.modelID}" not found for provider "${this.providerID}". Available models: ${availableModels}. Available providers: ${availableProviders}.`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
async listModels() {
|
|
239
|
+
if (!this._client) {
|
|
240
|
+
throw new Error('OpenCode provider not initialized — call initialize() first');
|
|
241
|
+
}
|
|
242
|
+
const result = await this._client.config.providers();
|
|
243
|
+
const data = result.data;
|
|
244
|
+
const providers = data?.providers ?? [];
|
|
245
|
+
const out = [];
|
|
246
|
+
for (const provider of providers) {
|
|
247
|
+
const models = provider.models ?? {};
|
|
248
|
+
for (const [modelID, model] of Object.entries(models)) {
|
|
249
|
+
out.push({
|
|
250
|
+
id: `${provider.id}/${modelID}`,
|
|
251
|
+
label: model.name ?? modelID,
|
|
252
|
+
description: provider.name,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
258
|
+
getModelName() {
|
|
259
|
+
return `${this.providerID}/${this.modelID}`;
|
|
260
|
+
}
|
|
261
|
+
setModel(model) {
|
|
262
|
+
const parsed = parseModelString(model);
|
|
263
|
+
this.providerID = parsed.providerID;
|
|
264
|
+
this.modelID = parsed.modelID;
|
|
265
|
+
}
|
|
266
|
+
/** Run `fn` under an async FIFO mutex so refcount mutations and fs ops don't race. */
|
|
267
|
+
async withMarkerLock(fn) {
|
|
268
|
+
const prev = this.markerMutex;
|
|
269
|
+
let release;
|
|
270
|
+
this.markerMutex = new Promise((r) => { release = r; });
|
|
271
|
+
await prev;
|
|
272
|
+
try {
|
|
273
|
+
return await fn();
|
|
274
|
+
}
|
|
275
|
+
finally {
|
|
276
|
+
release();
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/** Ensure `.git` exists at repositoryPath so OpenCode treats it as the project root.
|
|
280
|
+
* No-op if a `.git` already exists (whether directory or file) — we never touch
|
|
281
|
+
* pre-existing markers. If we create it, track it via refcount so concurrent
|
|
282
|
+
* targets with the same path coordinate correctly. Non-fatal on failure:
|
|
283
|
+
* falls through to whatever OpenCode does by default (walk up to parent repo),
|
|
284
|
+
* which is what the user saw before this fix. */
|
|
285
|
+
async ensureProjectMarker(repositoryPath) {
|
|
286
|
+
if (this.skipProjectMarker)
|
|
287
|
+
return;
|
|
288
|
+
await this.withMarkerLock(async () => {
|
|
289
|
+
const existing = this.createdMarkers.get(repositoryPath);
|
|
290
|
+
if (existing !== undefined) {
|
|
291
|
+
this.createdMarkers.set(repositoryPath, existing + 1);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (existsSync(join(repositoryPath, '.git'))) {
|
|
295
|
+
// Pre-existing — not ours, no refcount. OpenCode will use this naturally.
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const gitPath = join(repositoryPath, '.git');
|
|
299
|
+
// Record BEFORE running git init. If SIGINT/SIGHUP fires while git init is
|
|
300
|
+
// running — or in the race window between init completing and the set() call
|
|
301
|
+
// — the sync cleanup handler iterates createdMarkers and rmSync's whatever's
|
|
302
|
+
// at gitPath. rmSync with { force: true } is lenient on ENOENT, so recording
|
|
303
|
+
// before a would-be-nonexistent path is safe.
|
|
304
|
+
this.createdMarkers.set(repositoryPath, 1);
|
|
305
|
+
try {
|
|
306
|
+
await execAsync('git init -q', { cwd: repositoryPath });
|
|
307
|
+
// Info-level: we are touching the user's filesystem. They should see it without --debug.
|
|
308
|
+
logProgress(TAG, `Created transient ${gitPath} so OpenCode treats this directory as its project root. ` +
|
|
309
|
+
`Will be removed when the scan finishes.`);
|
|
310
|
+
}
|
|
311
|
+
catch (err) {
|
|
312
|
+
// Init failed — remove the phantom refcount so release doesn't try to rm a
|
|
313
|
+
// non-existent .git (harmless but produces a confusing warning log).
|
|
314
|
+
this.createdMarkers.delete(repositoryPath);
|
|
315
|
+
logProgress(TAG, `Warning: could not create project marker at ${gitPath}: ${err instanceof Error ? err.message : String(err)}. ` +
|
|
316
|
+
`File reads may resolve against the nearest ancestor .git instead, which can cause ENOENT errors.`);
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
/** Release one reference to a project marker. When refcount hits zero, remove it.
|
|
321
|
+
* Pre-existing (non-ours) markers are ignored. */
|
|
322
|
+
async releaseProjectMarker(repositoryPath) {
|
|
323
|
+
if (this.skipProjectMarker)
|
|
324
|
+
return;
|
|
325
|
+
await this.withMarkerLock(async () => {
|
|
326
|
+
const count = this.createdMarkers.get(repositoryPath);
|
|
327
|
+
if (count === undefined)
|
|
328
|
+
return;
|
|
329
|
+
if (count > 1) {
|
|
330
|
+
this.createdMarkers.set(repositoryPath, count - 1);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
this.createdMarkers.delete(repositoryPath);
|
|
334
|
+
const gitPath = join(repositoryPath, '.git');
|
|
335
|
+
try {
|
|
336
|
+
await rmAsync(gitPath, { recursive: true, force: true });
|
|
337
|
+
// Removal is the symmetric cleanup — debug is fine, users don't need to see it at info.
|
|
338
|
+
logDebug(TAG, `Removed transient project marker at ${gitPath}`);
|
|
339
|
+
}
|
|
340
|
+
catch (err) {
|
|
341
|
+
// Failure to remove leaves state on the filesystem — surface at info.
|
|
342
|
+
logProgress(TAG, `Warning: could not remove transient project marker at ${gitPath}: ${err instanceof Error ? err.message : String(err)}. ` +
|
|
343
|
+
`You may need to remove it manually.`);
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
/** Synchronously wipe all markers we created. Used by signal handlers where
|
|
348
|
+
* async cleanup is unsafe. Does NOT acquire the mutex — signal handlers run
|
|
349
|
+
* between event loop ticks, so we observe a consistent map snapshot. */
|
|
350
|
+
cleanupMarkersSync() {
|
|
351
|
+
for (const [path] of this.createdMarkers) {
|
|
352
|
+
try {
|
|
353
|
+
rmSync(join(path, '.git'), { recursive: true, force: true });
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
// Best-effort in signal handler — process is about to exit anyway.
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
this.createdMarkers.clear();
|
|
360
|
+
}
|
|
361
|
+
async executeCheck(instructions, repositoryPath, logPrefix, _options) {
|
|
362
|
+
if (!this._client) {
|
|
363
|
+
throw new Error('OpenCode provider not initialized — call initialize() first');
|
|
364
|
+
}
|
|
365
|
+
// Ensure OpenCode treats repositoryPath as its project root. Without this, when
|
|
366
|
+
// repositoryPath is a subdirectory of some other git repo, OpenCode walks up to
|
|
367
|
+
// the ancestor .git and resolves all Read-tool paths relative to THAT directory,
|
|
368
|
+
// causing ENOENT for every target file. This creates a transient `.git` marker
|
|
369
|
+
// we remove when the scan finishes (refcounted across parallel targets).
|
|
370
|
+
await this.ensureProjectMarker(repositoryPath);
|
|
371
|
+
try {
|
|
372
|
+
return await this.executeCheckInner(instructions, repositoryPath, logPrefix);
|
|
373
|
+
}
|
|
374
|
+
finally {
|
|
375
|
+
await this.releaseProjectMarker(repositoryPath);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
async executeCheckInner(instructions, repositoryPath, logPrefix) {
|
|
379
|
+
const client = this._client;
|
|
380
|
+
const timer = createTimer();
|
|
381
|
+
const prefix = logPrefix ? `${logPrefix} ` : '';
|
|
382
|
+
// Build a virtual allowlist: fetch all available tool IDs and deny everything
|
|
383
|
+
// not in ALLOWED_TOOL_PERMISSIONS. This adapts automatically to new or MCP tools
|
|
384
|
+
// rather than relying on a static blocklist that could miss future additions.
|
|
385
|
+
const permission = [];
|
|
386
|
+
try {
|
|
387
|
+
const toolIdsResult = await client.tool.ids({ directory: repositoryPath });
|
|
388
|
+
const allToolIds = toolIdsResult.data ?? [];
|
|
389
|
+
for (const id of allToolIds) {
|
|
390
|
+
if (!ALLOWED_TOOL_PERMISSIONS.has(id.toLowerCase())) {
|
|
391
|
+
permission.push({ permission: id, pattern: '*', action: 'deny' });
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
logDebug(TAG, `${prefix}Permission ruleset: allowing ${[...ALLOWED_TOOL_PERMISSIONS].join(', ')}; denying ${permission.length} other tools`);
|
|
395
|
+
}
|
|
396
|
+
catch (err) {
|
|
397
|
+
logDebug(TAG, `${prefix}Could not fetch tool IDs, skipping permission ruleset: ${err instanceof Error ? err.message : String(err)}`);
|
|
398
|
+
}
|
|
399
|
+
// Create an isolated session for this check
|
|
400
|
+
logDebug(TAG, `${prefix}Creating session for check (cwd=${repositoryPath})`);
|
|
401
|
+
const sessionResult = await client.session.create({
|
|
402
|
+
title: 'aghast security check',
|
|
403
|
+
directory: repositoryPath,
|
|
404
|
+
...(permission.length > 0 ? { permission } : {}),
|
|
405
|
+
});
|
|
406
|
+
const sessionId = sessionResult.data?.id;
|
|
407
|
+
if (!sessionId) {
|
|
408
|
+
throw new Error('Failed to create OpenCode session — no session ID returned');
|
|
409
|
+
}
|
|
410
|
+
logDebug(TAG, `${prefix}Session created: ${sessionId}`);
|
|
411
|
+
logDebug(TAG, `${prefix}Starting query: model=${this.providerID}/${this.modelID}, promptLen=${instructions.length}`);
|
|
412
|
+
logDebugFull(TAG, `${prefix}Full prompt sent to AI`, instructions);
|
|
413
|
+
let toolCallCount = 0;
|
|
414
|
+
let lastActivityTime = Date.now();
|
|
415
|
+
const debugEnabled = isDebugEnabled();
|
|
416
|
+
const trace = isTraceEnabled();
|
|
417
|
+
// Subscribe to the SSE event stream before calling session.prompt() so we
|
|
418
|
+
// catch events from the very first step. Filter to our sessionId so concurrent
|
|
419
|
+
// checks sharing the same server don't see each other's events.
|
|
420
|
+
// session.error is always surfaced at warn regardless of log level.
|
|
421
|
+
// Tool-progress events (message.part.updated) are only logged at debug/trace.
|
|
422
|
+
// See docs/opencode-provider-internals.md for why message.part.updated is used
|
|
423
|
+
// instead of session.next.tool.* and the --print-logs stderr capture.
|
|
424
|
+
const sseAbort = new AbortController();
|
|
425
|
+
const seenPartStatuses = new Map(); // partId → last logged status
|
|
426
|
+
const sseTask = (async () => {
|
|
427
|
+
try {
|
|
428
|
+
const sseResult = await client.event.subscribe({ directory: repositoryPath }, { signal: sseAbort.signal });
|
|
429
|
+
for await (const evt of sseResult.stream) {
|
|
430
|
+
const e = evt;
|
|
431
|
+
const evtType = e.type ?? '';
|
|
432
|
+
const props = (e.properties ?? {});
|
|
433
|
+
if (props['sessionID'] !== sessionId)
|
|
434
|
+
continue;
|
|
435
|
+
lastActivityTime = Date.now();
|
|
436
|
+
if (evtType === 'message.part.updated' && debugEnabled) {
|
|
437
|
+
const part = props['part'];
|
|
438
|
+
if (!part)
|
|
439
|
+
continue;
|
|
440
|
+
const partId = part['id'];
|
|
441
|
+
const partType = part['type'];
|
|
442
|
+
const state = part['state'];
|
|
443
|
+
const status = state?.['status'] ?? '';
|
|
444
|
+
if (partType === 'tool') {
|
|
445
|
+
if (seenPartStatuses.get(partId) === status)
|
|
446
|
+
continue;
|
|
447
|
+
// Record the status before the switch so that any unhandled status
|
|
448
|
+
// is still deduplicated on the next duplicate event, preventing
|
|
449
|
+
// re-admission after a status transition (e.g. spurious running
|
|
450
|
+
// after completed). Terminal statuses are deleted afterward to
|
|
451
|
+
// bound map size — once deleted, no further events are expected.
|
|
452
|
+
seenPartStatuses.set(partId, status);
|
|
453
|
+
const toolName = part['tool'] ?? 'unknown';
|
|
454
|
+
const input = state?.['input'];
|
|
455
|
+
const inputPreview = previewJSON(input, 200);
|
|
456
|
+
if (status === 'running') {
|
|
457
|
+
toolCallCount++;
|
|
458
|
+
logDebug(TAG, `${prefix}Tool[${toolCallCount}]: ${toolName} ${inputPreview} (${timer.elapsedStr()})`);
|
|
459
|
+
if (trace && JSON.stringify(input).length > 200) {
|
|
460
|
+
logDebugFull(TAG, `${prefix}Full tool call input`, JSON.stringify(input));
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
else if (status === 'completed') {
|
|
464
|
+
seenPartStatuses.delete(partId); // terminal — no further events expected
|
|
465
|
+
logDebug(TAG, `${prefix}Tool done: ${toolName} (${timer.elapsedStr()})`);
|
|
466
|
+
const toolOutput = state?.['output'] ?? '';
|
|
467
|
+
if (trace && toolOutput.length > 200)
|
|
468
|
+
logDebugFull(TAG, `${prefix}Full tool output (${toolOutput.length} chars)`, toolOutput);
|
|
469
|
+
}
|
|
470
|
+
else if (status === 'error') {
|
|
471
|
+
seenPartStatuses.delete(partId); // terminal
|
|
472
|
+
const errorMsg = state?.['error'] ?? '(no error message)';
|
|
473
|
+
const errorPreview = errorMsg.length > 300 ? errorMsg.slice(0, 300) + '...' : errorMsg;
|
|
474
|
+
logDebug(TAG, `${prefix}Tool error: ${toolName} ${inputPreview} → ${errorPreview} (${timer.elapsedStr()})`);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
else if (evtType === 'session.error') {
|
|
479
|
+
const error = props['error'];
|
|
480
|
+
const errData = error?.['data'];
|
|
481
|
+
const message = errData?.['message'] ?? error?.['name'] ?? 'unknown error';
|
|
482
|
+
logWarn(TAG, `${prefix}OpenCode session error: ${message}`);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
catch {
|
|
487
|
+
// SSE stream error (including AbortError on teardown) is non-fatal
|
|
488
|
+
}
|
|
489
|
+
})();
|
|
490
|
+
// Heartbeat timer (all log levels)
|
|
491
|
+
const heartbeatInterval = setInterval(() => {
|
|
492
|
+
const silentSeconds = Math.round((Date.now() - lastActivityTime) / 1000);
|
|
493
|
+
if (silentSeconds >= HEARTBEAT_INTERVAL_MS / 1000) {
|
|
494
|
+
logDebug(TAG, `${prefix}Still waiting... (${timer.elapsedStr()})`);
|
|
495
|
+
}
|
|
496
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
497
|
+
let promptResult;
|
|
498
|
+
try {
|
|
499
|
+
promptResult = await client.session.prompt({
|
|
500
|
+
sessionID: sessionId,
|
|
501
|
+
model: { providerID: this.providerID, modelID: this.modelID },
|
|
502
|
+
parts: [{ type: 'text', text: instructions }],
|
|
503
|
+
format: {
|
|
504
|
+
type: 'json_schema',
|
|
505
|
+
schema: OUTPUT_SCHEMA,
|
|
506
|
+
},
|
|
507
|
+
directory: repositoryPath,
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
finally {
|
|
511
|
+
sseAbort.abort();
|
|
512
|
+
await sseTask;
|
|
513
|
+
clearInterval(heartbeatInterval);
|
|
514
|
+
}
|
|
515
|
+
let info = promptResult.data?.info;
|
|
516
|
+
let parts = promptResult.data?.parts;
|
|
517
|
+
// Some providers (e.g. OpenRouter routing) reject requests when tool_choice is set,
|
|
518
|
+
// which is how OpenCode enforces the permission denylist. Detect this and retry once
|
|
519
|
+
// without restrictions so the scan can still complete (security mitigation won't apply).
|
|
520
|
+
if (permission.length > 0 &&
|
|
521
|
+
info?.error?.name === 'APIError') {
|
|
522
|
+
const rawErrMsg = 'data' in info.error && info.error.data && typeof info.error.data === 'object' && 'message' in info.error.data
|
|
523
|
+
? String(info.error.data.message)
|
|
524
|
+
: info.error.name;
|
|
525
|
+
if (/tool.?choice/i.test(rawErrMsg)) {
|
|
526
|
+
logProgress(TAG, `${prefix}Warning: provider does not support tool restrictions (tool_choice unsupported) — retrying without permission ruleset. The prompt injection security mitigation will not apply for this check.`);
|
|
527
|
+
const retrySession = await client.session.create({
|
|
528
|
+
title: 'aghast security check',
|
|
529
|
+
directory: repositoryPath,
|
|
530
|
+
});
|
|
531
|
+
const retrySessionId = retrySession.data?.id;
|
|
532
|
+
if (!retrySessionId) {
|
|
533
|
+
throw new Error('Failed to create OpenCode retry session — no session ID returned');
|
|
534
|
+
}
|
|
535
|
+
// Omit json_schema format — OpenCode also uses tool_choice to enforce structured
|
|
536
|
+
// output, which would trigger the same error. Text parsing handles the response instead.
|
|
537
|
+
const retryResult = await client.session.prompt({
|
|
538
|
+
sessionID: retrySessionId,
|
|
539
|
+
model: { providerID: this.providerID, modelID: this.modelID },
|
|
540
|
+
parts: [{ type: 'text', text: instructions }],
|
|
541
|
+
directory: repositoryPath,
|
|
542
|
+
});
|
|
543
|
+
info = retryResult.data?.info;
|
|
544
|
+
parts = retryResult.data?.parts;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
// Check for errors on the assistant message
|
|
548
|
+
if (info?.error) {
|
|
549
|
+
const err = info.error;
|
|
550
|
+
const errName = err.name;
|
|
551
|
+
const errMsg = 'data' in err && err.data && typeof err.data === 'object' && 'message' in err.data
|
|
552
|
+
? String(err.data.message)
|
|
553
|
+
: errName;
|
|
554
|
+
// StructuredOutputError: model doesn't support JSON schema output.
|
|
555
|
+
// Fall through to text-based parsing instead of failing.
|
|
556
|
+
if (errName === 'StructuredOutputError') {
|
|
557
|
+
logDebug(TAG, `${prefix}Model does not support structured output — falling back to text parsing`);
|
|
558
|
+
}
|
|
559
|
+
else {
|
|
560
|
+
if (errName === 'ProviderAuthError') {
|
|
561
|
+
throw new FatalProviderError(`OpenCode authentication failed: ${errMsg}. Run 'opencode' and use /connect to configure credentials.`);
|
|
562
|
+
}
|
|
563
|
+
if (errName === 'APIError' && /rate.?limit|429/i.test(errMsg)) {
|
|
564
|
+
throw new FatalProviderError(`OpenCode rate limit reached: ${errMsg}`);
|
|
565
|
+
}
|
|
566
|
+
throw new Error(`OpenCode AI error (${errName}): ${errMsg}`);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
// Extract token usage
|
|
570
|
+
let tokenUsage;
|
|
571
|
+
if (!info?.tokens) {
|
|
572
|
+
logDebug(TAG, `${prefix}Token usage not reported by provider`);
|
|
573
|
+
}
|
|
574
|
+
else {
|
|
575
|
+
const tokens = info.tokens;
|
|
576
|
+
const inputTokens = tokens.input ?? 0;
|
|
577
|
+
const outputTokens = tokens.output ?? 0;
|
|
578
|
+
const reasoningTokens = tokens.reasoning !== undefined && tokens.reasoning > 0
|
|
579
|
+
? tokens.reasoning
|
|
580
|
+
: undefined;
|
|
581
|
+
const cacheReadInputTokens = tokens.cache?.read !== undefined && tokens.cache.read > 0
|
|
582
|
+
? tokens.cache.read
|
|
583
|
+
: undefined;
|
|
584
|
+
const cacheCreationInputTokens = tokens.cache?.write !== undefined && tokens.cache.write > 0
|
|
585
|
+
? tokens.cache.write
|
|
586
|
+
: undefined;
|
|
587
|
+
const reportedCost = typeof info.cost === 'number'
|
|
588
|
+
? { amountUsd: info.cost, source: 'opencode' }
|
|
589
|
+
: undefined;
|
|
590
|
+
tokenUsage = {
|
|
591
|
+
inputTokens,
|
|
592
|
+
outputTokens,
|
|
593
|
+
...(reasoningTokens !== undefined ? { reasoningTokens } : {}),
|
|
594
|
+
...(cacheReadInputTokens !== undefined ? { cacheReadInputTokens } : {}),
|
|
595
|
+
...(cacheCreationInputTokens !== undefined ? { cacheCreationInputTokens } : {}),
|
|
596
|
+
totalTokens: inputTokens + outputTokens,
|
|
597
|
+
...(reportedCost !== undefined ? { reportedCost } : {}),
|
|
598
|
+
};
|
|
599
|
+
logDebug(TAG, `${prefix}Token usage: ${inputTokens} in, ${outputTokens} out${reasoningTokens !== undefined ? `, ${reasoningTokens} reasoning` : ''}${cacheReadInputTokens !== undefined ? `, ${cacheReadInputTokens} cache-read` : ''}${cacheCreationInputTokens !== undefined ? `, ${cacheCreationInputTokens} cache-write` : ''}${reportedCost !== undefined ? `, $${reportedCost.amountUsd} reported` : ''}`);
|
|
600
|
+
}
|
|
601
|
+
// Try structured output first (v2 API: info.structured)
|
|
602
|
+
if (info?.structured) {
|
|
603
|
+
const structuredOutput = info.structured;
|
|
604
|
+
logDebug(TAG, `${prefix}Structured output: ${structuredOutput.issues?.length ?? 0} issues`);
|
|
605
|
+
logDebugFull(TAG, `${prefix}Full AI response (structured)`, JSON.stringify(structuredOutput, null, 2));
|
|
606
|
+
logProgress(TAG, `${prefix}Completed in ${timer.elapsedStr()} (${toolCallCount} tool calls)`);
|
|
607
|
+
const rawText = extractTextFromParts(parts);
|
|
608
|
+
return { raw: rawText, parsed: structuredOutput, tokenUsage };
|
|
609
|
+
}
|
|
610
|
+
// Fallback: extract text from response parts and parse with response-parser
|
|
611
|
+
const rawText = extractTextFromParts(parts);
|
|
612
|
+
logDebug(TAG, `${prefix}No structured output — falling back to text parsing (${rawText.length} chars)`);
|
|
613
|
+
logDebugFull(TAG, `${prefix}Full AI response`, rawText);
|
|
614
|
+
if (!rawText) {
|
|
615
|
+
throw new Error('OpenCode AI returned no text response');
|
|
616
|
+
}
|
|
617
|
+
const parsed = parseAgentResponse(rawText);
|
|
618
|
+
if (parsed) {
|
|
619
|
+
logDebug(TAG, `${prefix}Parsed ${parsed.issues.length} issues from text response`);
|
|
620
|
+
}
|
|
621
|
+
logProgress(TAG, `${prefix}Completed in ${timer.elapsedStr()} (${toolCallCount} tool calls)`);
|
|
622
|
+
return { raw: rawText, parsed: parsed ?? undefined, tokenUsage };
|
|
623
|
+
}
|
|
624
|
+
async validateConfig() {
|
|
625
|
+
return !!this._client;
|
|
626
|
+
}
|
|
627
|
+
/** Synchronous cleanup for signal handlers (best-effort). */
|
|
628
|
+
cleanupSync() {
|
|
629
|
+
if (this.cleanedUp)
|
|
630
|
+
return;
|
|
631
|
+
this.cleanedUp = true;
|
|
632
|
+
// Remove signal handlers so cleanup() (called later via build-config's finally,
|
|
633
|
+
// or by a caller after initialize() throws) doesn't no-op and leave them installed.
|
|
634
|
+
if (this.signalHandler) {
|
|
635
|
+
process.removeListener('SIGINT', this.signalHandler);
|
|
636
|
+
process.removeListener('SIGTERM', this.signalHandler);
|
|
637
|
+
process.removeListener('SIGHUP', this.signalHandler);
|
|
638
|
+
if (process.platform === 'win32') {
|
|
639
|
+
process.removeListener('SIGBREAK', this.signalHandler);
|
|
640
|
+
}
|
|
641
|
+
this.signalHandler = undefined;
|
|
642
|
+
}
|
|
643
|
+
// Wipe any transient .git markers we created before exiting.
|
|
644
|
+
this.cleanupMarkersSync();
|
|
645
|
+
if (this._server) {
|
|
646
|
+
try {
|
|
647
|
+
this._server.close();
|
|
648
|
+
}
|
|
649
|
+
catch {
|
|
650
|
+
// Best-effort in signal handler
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
async cleanup() {
|
|
655
|
+
if (this.cleanedUp)
|
|
656
|
+
return;
|
|
657
|
+
this.cleanedUp = true;
|
|
658
|
+
// Remove signal handlers
|
|
659
|
+
if (this.signalHandler) {
|
|
660
|
+
process.removeListener('SIGINT', this.signalHandler);
|
|
661
|
+
process.removeListener('SIGTERM', this.signalHandler);
|
|
662
|
+
process.removeListener('SIGHUP', this.signalHandler);
|
|
663
|
+
if (process.platform === 'win32') {
|
|
664
|
+
process.removeListener('SIGBREAK', this.signalHandler);
|
|
665
|
+
}
|
|
666
|
+
this.signalHandler = undefined;
|
|
667
|
+
}
|
|
668
|
+
// Force-remove any markers still tracked (normally refcount already brought them
|
|
669
|
+
// to zero via releaseProjectMarker; this covers the case where executeCheck threw
|
|
670
|
+
// before reaching its finally block).
|
|
671
|
+
for (const [path] of this.createdMarkers) {
|
|
672
|
+
try {
|
|
673
|
+
await rmAsync(join(path, '.git'), { recursive: true, force: true });
|
|
674
|
+
}
|
|
675
|
+
catch (err) {
|
|
676
|
+
logDebug(TAG, `Failed to remove marker at ${path} during cleanup: ${err instanceof Error ? err.message : String(err)}`);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
this.createdMarkers.clear();
|
|
680
|
+
if (this._server) {
|
|
681
|
+
logProgress(TAG, 'Stopping OpenCode server...');
|
|
682
|
+
try {
|
|
683
|
+
await Promise.race([
|
|
684
|
+
Promise.resolve(this._server.close()),
|
|
685
|
+
new Promise((resolve) => setTimeout(() => {
|
|
686
|
+
logDebug(TAG, `Server close timed out after ${CLOSE_TIMEOUT_MS}ms`);
|
|
687
|
+
resolve();
|
|
688
|
+
}, CLOSE_TIMEOUT_MS)),
|
|
689
|
+
]);
|
|
690
|
+
}
|
|
691
|
+
catch (err) {
|
|
692
|
+
logDebug(TAG, `Error stopping OpenCode server: ${err}`);
|
|
693
|
+
}
|
|
694
|
+
this._server = undefined;
|
|
695
|
+
this._client = undefined;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* Extract text content from an array of response parts.
|
|
701
|
+
*/
|
|
702
|
+
/** JSON-stringify a value and truncate to `maxLen` characters for log output. */
|
|
703
|
+
function previewJSON(value, maxLen) {
|
|
704
|
+
if (value === undefined)
|
|
705
|
+
return '';
|
|
706
|
+
const str = JSON.stringify(value);
|
|
707
|
+
if (str === undefined)
|
|
708
|
+
return '';
|
|
709
|
+
return str.length > maxLen ? str.slice(0, maxLen) + '...' : str;
|
|
710
|
+
}
|
|
711
|
+
function extractTextFromParts(parts) {
|
|
712
|
+
if (!parts)
|
|
713
|
+
return '';
|
|
714
|
+
return parts
|
|
715
|
+
.filter((p) => p.type === 'text' && typeof p.text === 'string')
|
|
716
|
+
.map((p) => p.text)
|
|
717
|
+
.join('\n');
|
|
718
|
+
}
|
|
719
|
+
//# sourceMappingURL=opencode-provider.js.map
|