190 lines
6.6 KiB
JavaScript
190 lines
6.6 KiB
JavaScript
'use strict';
|
|
|
|
const vscode = require('vscode');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
const { spawn } = require('child_process');
|
|
|
|
const MAX_HOOK_OUTPUT = 1024 * 1024;
|
|
|
|
function projectRoot() {
|
|
const folders = vscode.workspace.workspaceFolders || [];
|
|
for (const folder of folders) {
|
|
const root = folder.uri.fsPath;
|
|
if (fs.existsSync(path.join(root, '.casan', 'config.json'))) {
|
|
return root;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function pythonCommand() {
|
|
const configured = vscode.workspace.getConfiguration('casan').get('pythonPath', '').trim();
|
|
if (configured) {
|
|
return { command: configured, prefix: [] };
|
|
}
|
|
return process.platform === 'win32'
|
|
? { command: 'py', prefix: ['-3'] }
|
|
: { command: 'python3', prefix: [] };
|
|
}
|
|
|
|
function runHook(root, event, payload, token) {
|
|
const bootstrap = path.join(root, '.casan', 'casan-hook.py');
|
|
if (!fs.existsSync(bootstrap)) {
|
|
return Promise.reject(new Error('Missing .casan/casan-hook.py. Run `casan init` again.'));
|
|
}
|
|
const runtime = pythonCommand();
|
|
const timeoutMs = vscode.workspace.getConfiguration('casan').get('hookTimeoutMs', 20000);
|
|
const args = [...runtime.prefix, bootstrap, '--client', 'vscode-copilot', '--event', event];
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(runtime.command, args, {
|
|
cwd: root,
|
|
env: { ...process.env, CASAN_APP_ROOT: root },
|
|
windowsHide: true,
|
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
});
|
|
let stdout = '';
|
|
let stderr = '';
|
|
let settled = false;
|
|
const finish = (error, value) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
cancellation.dispose();
|
|
if (error) reject(error); else resolve(value);
|
|
};
|
|
const timer = setTimeout(() => {
|
|
child.kill();
|
|
finish(new Error(`CASAN ${event} timed out after ${timeoutMs} ms`));
|
|
}, timeoutMs);
|
|
const cancellation = token.onCancellationRequested(() => {
|
|
child.kill();
|
|
finish(new vscode.CancellationError());
|
|
});
|
|
child.stdout.on('data', chunk => {
|
|
stdout += chunk.toString('utf8');
|
|
if (stdout.length > MAX_HOOK_OUTPUT) {
|
|
child.kill();
|
|
finish(new Error('CASAN hook output exceeded the safety limit'));
|
|
}
|
|
});
|
|
child.stderr.on('data', chunk => {
|
|
stderr += chunk.toString('utf8');
|
|
if (stderr.length > MAX_HOOK_OUTPUT) stderr = stderr.slice(-MAX_HOOK_OUTPUT);
|
|
});
|
|
child.on('error', error => finish(error));
|
|
child.on('close', code => {
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(stdout.trim() || '{}');
|
|
} catch (_error) {
|
|
finish(new Error(`CASAN ${event} returned invalid JSON${stderr ? `: ${stderr.trim()}` : ''}`));
|
|
return;
|
|
}
|
|
if (code !== 0 && parsed.decision !== 'block') {
|
|
finish(new Error(parsed.reason || stderr.trim() || `CASAN ${event} failed (${code})`));
|
|
return;
|
|
}
|
|
finish(undefined, parsed);
|
|
});
|
|
child.stdin.end(JSON.stringify(payload));
|
|
});
|
|
}
|
|
|
|
async function abortQuietly(root, admissionId, reason) {
|
|
if (!admissionId) return;
|
|
const source = new vscode.CancellationTokenSource();
|
|
try {
|
|
await runHook(root, 'Abort', { admission_id: admissionId, reason }, source.token);
|
|
} catch (_error) {
|
|
// The primary error is more useful to the user; abort remains best-effort.
|
|
} finally {
|
|
source.dispose();
|
|
}
|
|
}
|
|
|
|
async function handler(request, _context, stream, token) {
|
|
const root = projectRoot();
|
|
if (!root) {
|
|
stream.markdown('CASAN is not initialized in this workspace. Run `casan init` at the project root.');
|
|
return { metadata: { certified: false, reason: 'not_initialized' } };
|
|
}
|
|
if (!vscode.workspace.isTrusted) {
|
|
stream.markdown('CASAN requires a trusted VS Code workspace before it can run project governance.');
|
|
return { metadata: { certified: false, reason: 'workspace_untrusted' } };
|
|
}
|
|
if (request.command === 'status') {
|
|
stream.markdown('CASAN is initialized. Prompts sent explicitly to `@casan` use the governed route. Built-in Copilot chat is not globally intercepted.');
|
|
return { metadata: { certified: false, reason: 'status_only' } };
|
|
}
|
|
|
|
const sessionId = crypto.randomUUID();
|
|
const turnId = crypto.randomUUID();
|
|
const started = Date.now();
|
|
let admissionId;
|
|
try {
|
|
const begin = await runHook(root, 'Begin', {
|
|
project: root,
|
|
session_id: sessionId,
|
|
turn_id: turnId,
|
|
prompt: request.prompt,
|
|
client_version: vscode.version
|
|
}, token);
|
|
if (begin.decision === 'block' || !begin.admission_id) {
|
|
stream.markdown(`CASAN blocked this prompt: ${begin.reason || 'admission denied'}`);
|
|
return { metadata: { certified: false, traceId: begin.trace_id } };
|
|
}
|
|
admissionId = begin.admission_id;
|
|
stream.progress(`CASAN admission ${begin.trace_id || 'opened'}`);
|
|
|
|
const messages = [
|
|
vscode.LanguageModelChatMessage.User(
|
|
'You are operating through the CASAN governed chat route. Follow the user request, do not claim to have executed tools or changed files, and clearly state when a requested side effect requires a supported agentic client.'
|
|
),
|
|
vscode.LanguageModelChatMessage.User(request.prompt)
|
|
];
|
|
const response = await request.model.sendRequest(messages, {}, token);
|
|
let summary = '';
|
|
for await (const fragment of response.text) {
|
|
summary += fragment;
|
|
stream.markdown(fragment);
|
|
}
|
|
await runHook(root, 'Telemetry', {
|
|
admission_id: admissionId,
|
|
model: request.model.id,
|
|
runtime_ms: Date.now() - started,
|
|
cost_source: 'unavailable'
|
|
}, token);
|
|
const finalized = await runHook(root, 'Finalize', {
|
|
admission_id: admissionId,
|
|
stop_reason: 'completed',
|
|
assistant_summary: summary.slice(0, 4000)
|
|
}, token);
|
|
return {
|
|
metadata: {
|
|
certified: finalized.decision === 'certified',
|
|
traceId: finalized.trace_id,
|
|
certificationStrength: finalized.certification_strength
|
|
}
|
|
};
|
|
} catch (error) {
|
|
await abortQuietly(root, admissionId, token.isCancellationRequested
|
|
? 'vscode_cancelled'
|
|
: `vscode_error:${error instanceof Error ? error.message : String(error)}`);
|
|
if (error instanceof vscode.CancellationError) throw error;
|
|
stream.markdown(`CASAN governed chat failed safely: ${error instanceof Error ? error.message : String(error)}`);
|
|
return { metadata: { certified: false, reason: 'runtime_error' } };
|
|
}
|
|
}
|
|
|
|
function activate(context) {
|
|
const participant = vscode.chat.createChatParticipant('fpt-casan.casan', handler);
|
|
context.subscriptions.push(participant);
|
|
}
|
|
|
|
function deactivate() {}
|
|
|
|
module.exports = { activate, deactivate };
|