fix(ai): strip deepseek xml tags from chat history & secure git tools

This commit addresses the issue where DeepSeek's raw XML markup (like <tool_calls> and <think>) was leaking into chat history, causing hallucinations in subsequent turns. It also patches a vulnerability in the git commit tool where arbitrary shell injection was possible.

Additionally, it includes UX copy and color contrast adjustments for the marketing homepage breadcrumbs.
This commit is contained in:
2026-05-14 11:34:42 -07:00
parent 5968b98aa7
commit c51c3c21b3
22 changed files with 4559 additions and 667 deletions

View File

@@ -12,189 +12,243 @@
* - Calls vibn-frontend's PATCH /api/projects/[id]/agent/sessions/[sid]
*/
import { execSync } from 'child_process';
import { createLLM, toOAITools, LLMMessage } from './llm';
import { AgentConfig } from './agents';
import { executeTool, ToolContext } from './tools';
import { resolvePrompt } from './prompts/loader';
import { ingestSessionEvents } from './vibn-events-ingest';
import { execSync } from "child_process";
import { createLLM, toOAITools, LLMMessage } from "./llm";
import { AgentConfig } from "./agents";
import { executeTool, ToolContext } from "./tools";
import { resolvePrompt } from "./prompts/loader";
import { ingestSessionEvents } from "./vibn-events-ingest";
const MAX_TURNS = 60;
export interface OutputLine {
ts: string;
type: 'step' | 'stdout' | 'stderr' | 'info' | 'error' | 'done';
text: string;
ts: string;
type: "step" | "stdout" | "stderr" | "info" | "error" | "done";
text: string;
}
export interface SessionRunOptions {
sessionId: string;
projectId: string;
vibnApiUrl: string; // e.g. https://vibnai.com
appPath: string; // relative path within repo, e.g. "apps/admin"
repoRoot?: string; // absolute path to the git repo root (for auto-commit)
isStopped: () => boolean;
// Auto-approve: commit + push + deploy without user confirmation
autoApprove?: boolean;
giteaRepo?: string; // e.g. "mark/sportsy"
coolifyAppUuid?: string;
coolifyApiUrl?: string;
coolifyApiToken?: string;
sessionId: string;
projectId: string;
vibnApiUrl: string; // e.g. https://vibnai.com
appPath: string; // relative path within repo, e.g. "apps/admin"
repoRoot?: string; // absolute path to the git repo root (for auto-commit)
isStopped: () => boolean;
// Auto-approve: commit + push + deploy without user confirmation
autoApprove?: boolean;
giteaRepo?: string; // e.g. "mark/sportsy"
coolifyAppUuid?: string;
coolifyApiUrl?: string;
coolifyApiToken?: string;
}
// ── VIBN DB bridge ────────────────────────────────────────────────────────────
async function patchSession(
opts: SessionRunOptions,
payload: {
status?: string;
outputLine?: OutputLine;
changedFile?: { path: string; status: string };
error?: string;
}
opts: SessionRunOptions,
payload: {
status?: string;
outputLine?: OutputLine;
changedFile?: { path: string; status: string };
error?: string;
},
): Promise<void> {
const url = `${opts.vibnApiUrl}/api/projects/${opts.projectId}/agent/sessions/${opts.sessionId}`;
try {
await fetch(url, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', 'x-agent-runner-secret': process.env.AGENT_RUNNER_SECRET ?? '' },
body: JSON.stringify(payload),
});
} catch (err) {
// Log but don't crash — output will be lost for this line but loop continues
console.warn('[session-runner] PATCH failed:', err instanceof Error ? err.message : err);
}
const url = `${opts.vibnApiUrl}/api/projects/${opts.projectId}/agent/sessions/${opts.sessionId}`;
try {
await fetch(url, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
"x-agent-runner-secret": process.env.AGENT_RUNNER_SECRET ?? "",
},
body: JSON.stringify(payload),
});
} catch (err) {
// Log but don't crash — output will be lost for this line but loop continues
console.warn(
"[session-runner] PATCH failed:",
err instanceof Error ? err.message : err,
);
}
}
function now(): string {
return new Date().toISOString();
return new Date().toISOString();
}
// ── File change tracking ──────────────────────────────────────────────────────
const FILE_WRITE_TOOLS = new Set(['write_file', 'replace_in_file', 'create_file']);
const FILE_WRITE_TOOLS = new Set([
"write_file",
"replace_in_file",
"create_file",
]);
function extractChangedFile(
toolName: string,
args: Record<string, unknown>,
workspaceRoot: string,
appPath: string
toolName: string,
args: Record<string, unknown>,
workspaceRoot: string,
appPath: string,
): { path: string; status: string } | null {
if (!FILE_WRITE_TOOLS.has(toolName)) return null;
const rawPath = String(args.path ?? args.file_path ?? '');
if (!rawPath) return null;
if (!FILE_WRITE_TOOLS.has(toolName)) return null;
const rawPath = String(args.path ?? args.file_path ?? "");
if (!rawPath) return null;
// Make path relative to appPath for display
const fullPrefix = `${workspaceRoot}/${appPath}/`;
const appPrefix = `${appPath}/`;
let displayPath = rawPath
.replace(fullPrefix, '')
.replace(appPrefix, '');
// Make path relative to appPath for display
const fullPrefix = `${workspaceRoot}/${appPath}/`;
const appPrefix = `${appPath}/`;
let displayPath = rawPath.replace(fullPrefix, "").replace(appPrefix, "");
const fileStatus = toolName === 'write_file' ? 'added' : 'modified';
return { path: displayPath, status: fileStatus };
const fileStatus = toolName === "write_file" ? "added" : "modified";
return { path: displayPath, status: fileStatus };
}
// ── Auto-commit helper ────────────────────────────────────────────────────────
async function autoCommitAndDeploy(
opts: SessionRunOptions,
task: string,
emit: (line: OutputLine) => Promise<void>
opts: SessionRunOptions,
task: string,
emit: (line: OutputLine) => Promise<void>,
): Promise<void> {
const repoRoot = opts.repoRoot;
if (!repoRoot || !opts.giteaRepo) {
await emit({ ts: now(), type: 'info', text: 'Auto-approve skipped — no repo root available.' });
return;
}
const repoRoot = opts.repoRoot;
if (!repoRoot || !opts.giteaRepo) {
await emit({
ts: now(),
type: "info",
text: "Auto-approve skipped — no repo root available.",
});
return;
}
const gitOpts = { cwd: repoRoot, stdio: 'pipe' as const };
const giteaApiUrl = process.env.GITEA_API_URL || '';
const giteaUsername = process.env.GITEA_USERNAME || 'agent';
const giteaToken = process.env.GITEA_API_TOKEN || '';
const gitOpts = { cwd: repoRoot, stdio: "pipe" as const };
const giteaApiUrl = process.env.GITEA_API_URL || "";
const giteaUsername = process.env.GITEA_USERNAME || "agent";
const giteaToken = process.env.GITEA_API_TOKEN || "";
try {
try {
try {
execSync('git config user.email "agent@vibnai.com"', gitOpts);
execSync('git config user.name "VIBN Agent"', gitOpts);
} catch { /* already set */ }
execSync('git add -A', gitOpts);
const status = execSync('git status --porcelain', gitOpts).toString().trim();
if (!status) {
await emit({ ts: now(), type: 'info', text: '✓ No file changes to commit.' });
await patchSession(opts, { status: 'approved' });
return;
}
const commitMsg = `agent: ${task.slice(0, 72)}`;
execSync(`git commit -m ${JSON.stringify(commitMsg)}`, gitOpts);
await emit({ ts: now(), type: 'info', text: `✓ Committed: "${commitMsg}"` });
const authedUrl = `${giteaApiUrl}/${opts.giteaRepo}.git`
.replace('https://', `https://${giteaUsername}:${giteaToken}@`);
execSync(`git push "${authedUrl}" HEAD:main`, gitOpts);
await emit({ ts: now(), type: 'info', text: '✓ Pushed to Gitea.' });
// Optional Coolify deploy
let deployed = false;
if (opts.coolifyApiUrl && opts.coolifyApiToken && opts.coolifyAppUuid) {
try {
const deployRes = await fetch(
`${opts.coolifyApiUrl}/api/v1/applications/${opts.coolifyAppUuid}/start`,
{ method: 'POST', headers: { Authorization: `Bearer ${opts.coolifyApiToken}` } }
);
deployed = deployRes.ok;
if (deployed) await emit({ ts: now(), type: 'info', text: '✓ Deployment triggered.' });
} catch { /* best-effort */ }
}
await patchSession(opts, {
status: 'approved',
outputLine: {
ts: now(), type: 'done',
text: `✓ Auto-committed & ${deployed ? 'deployed' : 'pushed'}. No approval needed.`,
},
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
await emit({ ts: now(), type: 'error', text: `Auto-commit failed: ${msg}` });
// Fall back to done so user can manually approve
await patchSession(opts, { status: 'done' });
execSync('git config user.email "agent@vibnai.com"', gitOpts);
execSync('git config user.name "VIBN Agent"', gitOpts);
} catch {
/* already set */
}
execSync("git add -A", gitOpts);
const status = execSync("git status --porcelain", gitOpts)
.toString()
.trim();
if (!status) {
await emit({
ts: now(),
type: "info",
text: "✓ No file changes to commit.",
});
await patchSession(opts, { status: "approved" });
return;
}
const commitMsg = `agent: ${task.slice(0, 72)}`;
const msgFile = require("path").join(
opts.workspaceRoot,
".git",
"COMMIT_EDITMSG",
);
require("fs").writeFileSync(msgFile, commitMsg, "utf8");
execSync("git commit -F .git/COMMIT_EDITMSG", gitOpts);
try {
require("fs").unlinkSync(msgFile);
} catch {}
await emit({
ts: now(),
type: "info",
text: `✓ Committed: "${commitMsg}"`,
});
const authedUrl = `${giteaApiUrl}/${opts.giteaRepo}.git`.replace(
"https://",
`https://${giteaUsername}:${giteaToken}@`,
);
execSync(`git push "${authedUrl}" HEAD:main`, gitOpts);
await emit({ ts: now(), type: "info", text: "✓ Pushed to Gitea." });
// Optional Coolify deploy
let deployed = false;
if (opts.coolifyApiUrl && opts.coolifyApiToken && opts.coolifyAppUuid) {
try {
const deployRes = await fetch(
`${opts.coolifyApiUrl}/api/v1/applications/${opts.coolifyAppUuid}/start`,
{
method: "POST",
headers: { Authorization: `Bearer ${opts.coolifyApiToken}` },
},
);
deployed = deployRes.ok;
if (deployed)
await emit({
ts: now(),
type: "info",
text: "✓ Deployment triggered.",
});
} catch {
/* best-effort */
}
}
await patchSession(opts, {
status: "approved",
outputLine: {
ts: now(),
type: "done",
text: `✓ Auto-committed & ${deployed ? "deployed" : "pushed"}. No approval needed.`,
},
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
await emit({
ts: now(),
type: "error",
text: `Auto-commit failed: ${msg}`,
});
// Fall back to done so user can manually approve
await patchSession(opts, { status: "done" });
}
}
// ── Main streaming execution loop ─────────────────────────────────────────────
export async function runSessionAgent(
config: AgentConfig,
task: string,
ctx: ToolContext,
opts: SessionRunOptions
config: AgentConfig,
task: string,
ctx: ToolContext,
opts: SessionRunOptions,
): Promise<void> {
const llm = createLLM(config.model, { temperature: 0.2 });
const oaiTools = toOAITools(config.tools);
const llm = createLLM(config.model, { temperature: 0.2 });
const oaiTools = toOAITools(config.tools);
const emit = async (line: OutputLine) => {
console.log(`[session ${opts.sessionId}] ${line.type}: ${line.text}`);
await Promise.all([
patchSession(opts, { outputLine: line }),
ingestSessionEvents(opts.vibnApiUrl, opts.projectId, opts.sessionId, [
{
type: `output.${line.type}`,
payload: { text: line.text },
ts: line.ts,
},
]),
]);
};
const emit = async (line: OutputLine) => {
console.log(`[session ${opts.sessionId}] ${line.type}: ${line.text}`);
await Promise.all([
patchSession(opts, { outputLine: line }),
ingestSessionEvents(opts.vibnApiUrl, opts.projectId, opts.sessionId, [
{
type: `output.${line.type}`,
payload: { text: line.text },
ts: line.ts,
},
]),
]);
};
await emit({ ts: now(), type: 'info', text: `Agent starting (${llm.modelId}) — working in ${opts.appPath}` });
await emit({
ts: now(),
type: "info",
text: `Agent starting (${llm.modelId}) — working in ${opts.appPath}`,
});
// Scope the system prompt to the specific app within the monorepo
const basePrompt = resolvePrompt(config.promptId);
const scopedPrompt = `${basePrompt}
// Scope the system prompt to the specific app within the monorepo
const basePrompt = resolvePrompt(config.promptId);
const scopedPrompt = `${basePrompt}
## Active context
You are working inside the monorepo directory: ${opts.appPath}
@@ -203,135 +257,166 @@ When running commands, always cd into ${opts.appPath} first unless already there
Do NOT run git commit or git push — the platform handles committing after you finish.
`;
const history: LLMMessage[] = [
{ role: 'user', content: task }
const history: LLMMessage[] = [{ role: "user", content: task }];
let turn = 0;
let finalText = "";
const trackedFiles = new Map<string, string>(); // path → status
while (turn < MAX_TURNS) {
// Check for stop signal between turns
if (opts.isStopped()) {
await emit({ ts: now(), type: "info", text: "Stopped by user." });
await patchSession(opts, { status: "stopped" });
return;
}
turn++;
await emit({ ts: now(), type: "info", text: `Turn ${turn} — thinking…` });
const messages: LLMMessage[] = [
{ role: "system", content: scopedPrompt },
...history,
];
let turn = 0;
let finalText = '';
const trackedFiles = new Map<string, string>(); // path → status
while (turn < MAX_TURNS) {
// Check for stop signal between turns
if (opts.isStopped()) {
await emit({ ts: now(), type: 'info', text: 'Stopped by user.' });
await patchSession(opts, { status: 'stopped' });
return;
}
turn++;
await emit({ ts: now(), type: 'info', text: `Turn ${turn} — thinking…` });
const messages: LLMMessage[] = [
{ role: 'system', content: scopedPrompt },
...history
];
let response: Awaited<ReturnType<typeof llm.chat>>;
try {
response = await llm.chat(messages, oaiTools, 8192);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
await emit({ ts: now(), type: 'error', text: `LLM error: ${msg}` });
await patchSession(opts, { status: 'failed', error: msg });
return;
}
const assistantMsg: LLMMessage = {
role: 'assistant',
content: response.content,
tool_calls: response.tool_calls.length > 0 ? response.tool_calls : undefined
};
history.push(assistantMsg);
// Agent finished — no more tool calls
if (response.tool_calls.length === 0) {
finalText = response.content ?? 'Task complete.';
break;
}
// Execute each tool call
for (const tc of response.tool_calls) {
if (opts.isStopped()) break;
const fnName = tc.function.name;
let fnArgs: Record<string, unknown> = {};
try { fnArgs = JSON.parse(tc.function.arguments || '{}'); } catch { /* bad JSON */ }
// Human-readable step label
const stepLabel = buildStepLabel(fnName, fnArgs);
await emit({ ts: now(), type: 'step', text: stepLabel });
let result: unknown;
try {
result = await executeTool(fnName, fnArgs, ctx);
} catch (err) {
result = { error: err instanceof Error ? err.message : String(err) };
}
// Stream stdout/stderr if present
if (result && typeof result === 'object') {
const r = result as Record<string, unknown>;
if (r.stdout && String(r.stdout).trim()) {
for (const line of String(r.stdout).split('\n').filter(Boolean).slice(0, 40)) {
await emit({ ts: now(), type: 'stdout', text: line });
}
}
if (r.stderr && String(r.stderr).trim()) {
for (const line of String(r.stderr).split('\n').filter(Boolean).slice(0, 20)) {
await emit({ ts: now(), type: 'stderr', text: line });
}
}
if (r.error) {
await emit({ ts: now(), type: 'error', text: String(r.error) });
}
}
// Track file changes
const changed = extractChangedFile(fnName, fnArgs, ctx.workspaceRoot, opts.appPath);
if (changed && !trackedFiles.has(changed.path)) {
trackedFiles.set(changed.path, changed.status);
await patchSession(opts, { changedFile: changed });
await emit({ ts: now(), type: 'info', text: `${changed.status === 'added' ? '+ Created' : '~ Modified'} ${changed.path}` });
}
history.push({
role: 'tool',
tool_call_id: tc.id,
name: fnName,
content: typeof result === 'string' ? result : JSON.stringify(result)
});
}
let response: Awaited<ReturnType<typeof llm.chat>>;
try {
response = await llm.chat(messages, oaiTools, 8192);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
await emit({ ts: now(), type: "error", text: `LLM error: ${msg}` });
await patchSession(opts, { status: "failed", error: msg });
return;
}
if (turn >= MAX_TURNS && !finalText) {
finalText = `Hit the ${MAX_TURNS}-turn limit. Stopping.`;
const assistantMsg: LLMMessage = {
role: "assistant",
content: response.content,
tool_calls:
response.tool_calls.length > 0 ? response.tool_calls : undefined,
};
history.push(assistantMsg);
// Agent finished — no more tool calls
if (response.tool_calls.length === 0) {
finalText = response.content ?? "Task complete.";
break;
}
await emit({ ts: now(), type: 'done', text: finalText });
// Execute each tool call
for (const tc of response.tool_calls) {
if (opts.isStopped()) break;
if (opts.autoApprove) {
await autoCommitAndDeploy(opts, task, emit);
} else {
await patchSession(opts, {
status: 'done',
outputLine: { ts: now(), type: 'done', text: '✓ Complete — review changes and approve to commit.' },
const fnName = tc.function.name;
let fnArgs: Record<string, unknown> = {};
try {
fnArgs = JSON.parse(tc.function.arguments || "{}");
} catch {
/* bad JSON */
}
// Human-readable step label
const stepLabel = buildStepLabel(fnName, fnArgs);
await emit({ ts: now(), type: "step", text: stepLabel });
let result: unknown;
try {
result = await executeTool(fnName, fnArgs, ctx);
} catch (err) {
result = { error: err instanceof Error ? err.message : String(err) };
}
// Stream stdout/stderr if present
if (result && typeof result === "object") {
const r = result as Record<string, unknown>;
if (r.stdout && String(r.stdout).trim()) {
for (const line of String(r.stdout)
.split("\n")
.filter(Boolean)
.slice(0, 40)) {
await emit({ ts: now(), type: "stdout", text: line });
}
}
if (r.stderr && String(r.stderr).trim()) {
for (const line of String(r.stderr)
.split("\n")
.filter(Boolean)
.slice(0, 20)) {
await emit({ ts: now(), type: "stderr", text: line });
}
}
if (r.error) {
await emit({ ts: now(), type: "error", text: String(r.error) });
}
}
// Track file changes
const changed = extractChangedFile(
fnName,
fnArgs,
ctx.workspaceRoot,
opts.appPath,
);
if (changed && !trackedFiles.has(changed.path)) {
trackedFiles.set(changed.path, changed.status);
await patchSession(opts, { changedFile: changed });
await emit({
ts: now(),
type: "info",
text: `${changed.status === "added" ? "+ Created" : "~ Modified"} ${changed.path}`,
});
}
history.push({
role: "tool",
tool_call_id: tc.id,
name: fnName,
content: typeof result === "string" ? result : JSON.stringify(result),
});
}
}
if (turn >= MAX_TURNS && !finalText) {
finalText = `Hit the ${MAX_TURNS}-turn limit. Stopping.`;
}
await emit({ ts: now(), type: "done", text: finalText });
if (opts.autoApprove) {
await autoCommitAndDeploy(opts, task, emit);
} else {
await patchSession(opts, {
status: "done",
outputLine: {
ts: now(),
type: "done",
text: "✓ Complete — review changes and approve to commit.",
},
});
}
}
// ── Step label helpers ────────────────────────────────────────────────────────
function buildStepLabel(tool: string, args: Record<string, unknown>): string {
switch (tool) {
case 'read_file': return `Read ${args.path ?? args.file_path}`;
case 'write_file': return `Write ${args.path ?? args.file_path}`;
case 'replace_in_file': return `Edit ${args.path ?? args.file_path}`;
case 'list_directory': return `List ${args.path ?? '.'}`;
case 'find_files': return `Find files: ${args.pattern}`;
case 'search_code': return `Search: ${args.query}`;
case 'execute_command': return `Run: ${String(args.command ?? '').slice(0, 80)}`;
case 'git_commit_and_push': return `Git commit: "${args.message}"`;
default: return `${tool}(${JSON.stringify(args).slice(0, 60)})`;
}
switch (tool) {
case "read_file":
return `Read ${args.path ?? args.file_path}`;
case "write_file":
return `Write ${args.path ?? args.file_path}`;
case "replace_in_file":
return `Edit ${args.path ?? args.file_path}`;
case "list_directory":
return `List ${args.path ?? "."}`;
case "find_files":
return `Find files: ${args.pattern}`;
case "search_code":
return `Search: ${args.query}`;
case "execute_command":
return `Run: ${String(args.command ?? "").slice(0, 80)}`;
case "git_commit_and_push":
return `Git commit: "${args.message}"`;
default:
return `${tool}(${JSON.stringify(args).slice(0, 60)})`;
}
}

View File

@@ -1,7 +1,7 @@
import { GoogleAuth } from 'google-auth-library';
import { GoogleGenAI } from '@google/genai';
import AnthropicVertex from '@anthropic-ai/vertex-sdk';
import { v4 as uuidv4 } from 'uuid';
import { GoogleAuth } from "google-auth-library";
import { GoogleGenAI } from "@google/genai";
import AnthropicVertex from "@anthropic-ai/vertex-sdk";
import { v4 as uuidv4 } from "uuid";
// =============================================================================
// Unified LLM client — OpenAI-compatible message format throughout
@@ -22,46 +22,64 @@ import { v4 as uuidv4 } from 'uuid';
// ---------------------------------------------------------------------------
export interface LLMMessage {
role: 'system' | 'user' | 'assistant' | 'tool';
content: string | null;
tool_calls?: LLMToolCall[];
tool_call_id?: string; // set on role=tool messages
name?: string; // function name on role=tool messages
role: "system" | "user" | "assistant" | "tool";
content: string | null;
tool_calls?: LLMToolCall[];
tool_call_id?: string; // set on role=tool messages
name?: string; // function name on role=tool messages
}
export interface LLMToolCall {
id: string;
type: 'function';
function: {
name: string;
arguments: string; // JSON-encoded string
};
id: string;
type: "function";
function: {
name: string;
arguments: string; // JSON-encoded string
};
}
export interface LLMTool {
type: 'function';
function: {
name: string;
description: string;
parameters: Record<string, unknown>;
};
type: "function";
function: {
name: string;
description: string;
parameters: Record<string, unknown>;
};
}
export interface LLMResponse {
content: string | null;
reasoning: string | null; // GLM-5 chain-of-thought
tool_calls: LLMToolCall[];
finish_reason: string;
usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
content: string | null;
reasoning: string | null; // GLM-5 chain-of-thought
tool_calls: LLMToolCall[];
finish_reason: string;
usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
}
/**
* Strips DeepSeek-specific XML tags like <tool_calls> and <think> from content
* so it doesn't leak into the model's history and cause subsequent hallucinations.
*/
function stripModelMarkup(text: string | null | undefined): string | null {
if (!text) return null;
return (
text
.replace(/<tool_calls>[\s\S]*?<\/tool_calls>/g, "")
.replace(/<think>[\s\S]*?<\/think>/g, "")
.trim() || null
);
}
export interface LLMClient {
modelId: string;
chat(messages: LLMMessage[], tools?: LLMTool[], maxTokens?: number): Promise<LLMResponse>;
modelId: string;
chat(
messages: LLMMessage[],
tools?: LLMTool[],
maxTokens?: number,
): Promise<LLMResponse>;
}
// ---------------------------------------------------------------------------
@@ -69,7 +87,7 @@ export interface LLMClient {
// Used for: zai-org/glm-5-maas, anthropic/claude-sonnet-4-6, etc.
// ---------------------------------------------------------------------------
let _cachedToken = '';
let _cachedToken = "";
let _tokenExpiry = 0;
// Build GoogleAuth with explicit service account credentials when available.
@@ -77,113 +95,131 @@ let _tokenExpiry = 0;
// an env var since it contains no newlines or special shell characters.
// Falls back to the GCP metadata server (works on VMs with correct scopes).
function buildGoogleAuth(): GoogleAuth {
const b64Key = process.env.GCP_SA_KEY_BASE64;
if (b64Key) {
try {
const jsonStr = Buffer.from(b64Key, 'base64').toString('utf8');
const credentials = JSON.parse(jsonStr);
return new GoogleAuth({ credentials, scopes: ['https://www.googleapis.com/auth/cloud-platform'] });
} catch {
console.warn('[llm] GCP_SA_KEY_BASE64 is set but failed to decode/parse — falling back to metadata server');
}
const b64Key = process.env.GCP_SA_KEY_BASE64;
if (b64Key) {
try {
const jsonStr = Buffer.from(b64Key, "base64").toString("utf8");
const credentials = JSON.parse(jsonStr);
return new GoogleAuth({
credentials,
scopes: ["https://www.googleapis.com/auth/cloud-platform"],
});
} catch {
console.warn(
"[llm] GCP_SA_KEY_BASE64 is set but failed to decode/parse — falling back to metadata server",
);
}
return new GoogleAuth({ scopes: ['https://www.googleapis.com/auth/cloud-platform'] });
}
return new GoogleAuth({
scopes: ["https://www.googleapis.com/auth/cloud-platform"],
});
}
const _googleAuth = buildGoogleAuth();
async function getVertexToken(): Promise<string> {
const now = Date.now();
if (_cachedToken && now < _tokenExpiry) return _cachedToken;
const client = await _googleAuth.getClient();
const tokenResponse = await client.getAccessToken();
_cachedToken = tokenResponse.token!;
_tokenExpiry = now + 55 * 60 * 1000; // tokens last 1hr, refresh at 55min
return _cachedToken;
const now = Date.now();
if (_cachedToken && now < _tokenExpiry) return _cachedToken;
const client = await _googleAuth.getClient();
const tokenResponse = await client.getAccessToken();
_cachedToken = tokenResponse.token!;
_tokenExpiry = now + 55 * 60 * 1000; // tokens last 1hr, refresh at 55min
return _cachedToken;
}
export class VertexOpenAIClient implements LLMClient {
modelId: string;
private projectId: string;
private region: string;
private temperature: number;
modelId: string;
private projectId: string;
private region: string;
private temperature: number;
constructor(modelId: string, opts?: { projectId?: string; region?: string; temperature?: number }) {
this.modelId = modelId;
this.projectId = opts?.projectId ?? process.env.GCP_PROJECT_ID ?? 'master-ai-484822';
this.region = opts?.region ?? 'global';
this.temperature = opts?.temperature ?? 0.3;
constructor(
modelId: string,
opts?: { projectId?: string; region?: string; temperature?: number },
) {
this.modelId = modelId;
this.projectId =
opts?.projectId ?? process.env.GCP_PROJECT_ID ?? "master-ai-484822";
this.region = opts?.region ?? "global";
this.temperature = opts?.temperature ?? 0.3;
}
async chat(
messages: LLMMessage[],
tools?: LLMTool[],
maxTokens = 4096,
): Promise<LLMResponse> {
const base =
this.region === "global"
? "https://aiplatform.googleapis.com"
: `https://${this.region}-aiplatform.googleapis.com`;
const url = `${base}/v1/projects/${this.projectId}/locations/${this.region}/endpoints/openapi/chat/completions`;
const body: Record<string, unknown> = {
model: this.modelId,
messages,
max_tokens: maxTokens,
temperature: this.temperature,
stream: false,
};
if (tools && tools.length > 0) {
body.tools = tools;
body.tool_choice = "auto";
}
async chat(messages: LLMMessage[], tools?: LLMTool[], maxTokens = 4096): Promise<LLMResponse> {
const base = this.region === 'global'
? 'https://aiplatform.googleapis.com'
: `https://${this.region}-aiplatform.googleapis.com`;
const url = `${base}/v1/projects/${this.projectId}/locations/${this.region}/endpoints/openapi/chat/completions`;
// Retry with exponential backoff on 429 / 503 (rate limit / overload)
const MAX_RETRIES = 4;
const RETRY_STATUSES = new Set([429, 503]);
const body: Record<string, unknown> = {
model: this.modelId,
messages,
max_tokens: maxTokens,
temperature: this.temperature,
stream: false
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
const token = await getVertexToken();
const res = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (res.ok) {
const data = (await res.json()) as any;
const choice = data.choices?.[0];
const message = choice?.message ?? {};
return {
content: stripModelMarkup(message.content),
reasoning: stripModelMarkup(message.reasoning_content),
tool_calls: message.tool_calls ?? [],
finish_reason: choice?.finish_reason ?? "stop",
usage: data.usage,
};
}
if (tools && tools.length > 0) {
body.tools = tools;
body.tool_choice = 'auto';
}
const errText = await res.text();
// Retry with exponential backoff on 429 / 503 (rate limit / overload)
const MAX_RETRIES = 4;
const RETRY_STATUSES = new Set([429, 503]);
// Force token refresh on 401
if (res.status === 401) _tokenExpiry = 0;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
const token = await getVertexToken();
const res = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
if (RETRY_STATUSES.has(res.status) && attempt < MAX_RETRIES) {
// Check for Retry-After header, otherwise use exponential backoff
const retryAfter = res.headers.get("retry-after");
const waitMs = retryAfter
? Math.min(parseInt(retryAfter, 10) * 1000, 60_000)
: Math.min(2 ** attempt * 2000 + Math.random() * 500, 30_000);
console.warn(
`[llm] Vertex ${res.status} on attempt ${attempt + 1}/${MAX_RETRIES + 1} — retrying in ${Math.round(waitMs / 1000)}s`,
);
await new Promise((r) => setTimeout(r, waitMs));
continue;
}
if (res.ok) {
const data = await res.json() as any;
const choice = data.choices?.[0];
const message = choice?.message ?? {};
return {
content: message.content ?? null,
reasoning: message.reasoning_content ?? null,
tool_calls: message.tool_calls ?? [],
finish_reason: choice?.finish_reason ?? 'stop',
usage: data.usage
};
}
const errText = await res.text();
// Force token refresh on 401
if (res.status === 401) _tokenExpiry = 0;
if (RETRY_STATUSES.has(res.status) && attempt < MAX_RETRIES) {
// Check for Retry-After header, otherwise use exponential backoff
const retryAfter = res.headers.get('retry-after');
const waitMs = retryAfter
? Math.min(parseInt(retryAfter, 10) * 1000, 60_000)
: Math.min(2 ** attempt * 2000 + Math.random() * 500, 30_000);
console.warn(`[llm] Vertex ${res.status} on attempt ${attempt + 1}/${MAX_RETRIES + 1} — retrying in ${Math.round(waitMs / 1000)}s`);
await new Promise(r => setTimeout(r, waitMs));
continue;
}
throw new Error(`Vertex API ${res.status}: ${errText.slice(0, 400)}`);
}
// TypeScript requires an explicit throw after the loop (unreachable in practice)
throw new Error('Vertex API: exceeded max retries');
throw new Error(`Vertex API ${res.status}: ${errText.slice(0, 400)}`);
}
// TypeScript requires an explicit throw after the loop (unreachable in practice)
throw new Error("Vertex API: exceeded max retries");
}
}
// ---------------------------------------------------------------------------
@@ -193,99 +229,116 @@ export class VertexOpenAIClient implements LLMClient {
// ---------------------------------------------------------------------------
export class GeminiClient implements LLMClient {
modelId: string;
private temperature: number;
modelId: string;
private temperature: number;
constructor(modelId = 'gemini-2.5-flash', opts?: { temperature?: number }) {
this.modelId = modelId;
this.temperature = opts?.temperature ?? 0.2;
}
constructor(modelId = "gemini-2.5-flash", opts?: { temperature?: number }) {
this.modelId = modelId;
this.temperature = opts?.temperature ?? 0.2;
}
async chat(messages: LLMMessage[], tools?: LLMTool[], maxTokens = 8192): Promise<LLMResponse> {
const apiKey = process.env.GOOGLE_API_KEY;
if (!apiKey) throw new Error('GOOGLE_API_KEY not set');
async chat(
messages: LLMMessage[],
tools?: LLMTool[],
maxTokens = 8192,
): Promise<LLMResponse> {
const apiKey = process.env.GOOGLE_API_KEY;
if (!apiKey) throw new Error("GOOGLE_API_KEY not set");
const genai = new GoogleGenAI({ apiKey });
const genai = new GoogleGenAI({ apiKey });
const systemMsg = messages.find(m => m.role === 'system');
const nonSystem = messages.filter(m => m.role !== 'system');
const systemMsg = messages.find((m) => m.role === "system");
const nonSystem = messages.filter((m) => m.role !== "system");
const functionDeclarations = (tools ?? []).map(t => ({
name: t.function.name,
description: t.function.description,
parameters: t.function.parameters as any
}));
const functionDeclarations = (tools ?? []).map((t) => ({
name: t.function.name,
description: t.function.description,
parameters: t.function.parameters as any,
}));
const response = await genai.models.generateContent({
model: this.modelId,
contents: toGeminiContents(nonSystem),
config: {
systemInstruction: systemMsg?.content ?? undefined,
tools: functionDeclarations.length > 0 ? [{ functionDeclarations }] : undefined,
temperature: this.temperature,
maxOutputTokens: maxTokens
}
});
const response = await genai.models.generateContent({
model: this.modelId,
contents: toGeminiContents(nonSystem),
config: {
systemInstruction: systemMsg?.content ?? undefined,
tools:
functionDeclarations.length > 0
? [{ functionDeclarations }]
: undefined,
temperature: this.temperature,
maxOutputTokens: maxTokens,
},
});
const candidate = response.candidates?.[0];
if (!candidate) throw new Error('No response from Gemini');
const candidate = response.candidates?.[0];
if (!candidate) throw new Error("No response from Gemini");
const parts = candidate.content?.parts ?? [];
const textContent = parts.filter(p => p.text).map(p => p.text).join('') || null;
const fnCalls = parts.filter(p => p.functionCall);
const parts = candidate.content?.parts ?? [];
const textContent =
parts
.filter((p) => p.text)
.map((p) => p.text)
.join("") || null;
const fnCalls = parts.filter((p) => p.functionCall);
const tool_calls: LLMToolCall[] = fnCalls.map(p => ({
id: `call_${uuidv4().replace(/-/g, '').slice(0, 12)}`,
type: 'function' as const,
function: {
name: p.functionCall!.name ?? '',
arguments: JSON.stringify(p.functionCall!.args ?? {})
}
}));
const tool_calls: LLMToolCall[] = fnCalls.map((p) => ({
id: `call_${uuidv4().replace(/-/g, "").slice(0, 12)}`,
type: "function" as const,
function: {
name: p.functionCall!.name ?? "",
arguments: JSON.stringify(p.functionCall!.args ?? {}),
},
}));
return {
content: textContent,
reasoning: null,
tool_calls,
finish_reason: fnCalls.length > 0 ? 'tool_calls' : 'stop'
};
}
return {
content: stripModelMarkup(textContent),
reasoning: null,
tool_calls,
finish_reason: fnCalls.length > 0 ? "tool_calls" : "stop",
};
}
}
/** Convert OpenAI message format → Gemini Content[] format */
function toGeminiContents(messages: LLMMessage[]): any[] {
const contents: any[] = [];
for (const msg of messages) {
if (msg.role === 'assistant') {
const parts: any[] = [];
if (msg.content) parts.push({ text: msg.content });
for (const tc of msg.tool_calls ?? []) {
parts.push({
functionCall: {
name: tc.function.name,
args: JSON.parse(tc.function.arguments || '{}')
}
});
}
contents.push({ role: 'model', parts });
} else if (msg.role === 'tool') {
// Parse content back — could be JSON or plain text
let resultValue: unknown = msg.content;
try { resultValue = JSON.parse(msg.content ?? 'null'); } catch { /* keep as string */ }
contents.push({
role: 'user',
parts: [{
functionResponse: {
name: msg.name ?? 'tool',
response: { result: resultValue }
}
}]
});
} else {
contents.push({ role: 'user', parts: [{ text: msg.content ?? '' }] });
}
const contents: any[] = [];
for (const msg of messages) {
if (msg.role === "assistant") {
const parts: any[] = [];
if (msg.content) parts.push({ text: msg.content });
for (const tc of msg.tool_calls ?? []) {
parts.push({
functionCall: {
name: tc.function.name,
args: JSON.parse(tc.function.arguments || "{}"),
},
});
}
contents.push({ role: "model", parts });
} else if (msg.role === "tool") {
// Parse content back — could be JSON or plain text
let resultValue: unknown = msg.content;
try {
resultValue = JSON.parse(msg.content ?? "null");
} catch {
/* keep as string */
}
contents.push({
role: "user",
parts: [
{
functionResponse: {
name: msg.name ?? "tool",
response: { result: resultValue },
},
},
],
});
} else {
contents.push({ role: "user", parts: [{ text: msg.content ?? "" }] });
}
return contents;
}
return contents;
}
// ---------------------------------------------------------------------------
@@ -295,147 +348,196 @@ function toGeminiContents(messages: LLMMessage[]): any[] {
// ---------------------------------------------------------------------------
export class AnthropicVertexClient implements LLMClient {
modelId: string;
private projectId: string;
private region: string;
modelId: string;
private projectId: string;
private region: string;
constructor(modelId: string, opts?: { projectId?: string; region?: string }) {
// Strip the "anthropic/" prefix if present — the SDK uses bare model names
this.modelId = modelId.startsWith('anthropic/') ? modelId.slice(10) : modelId;
this.projectId = opts?.projectId ?? process.env.GCP_PROJECT_ID ?? 'master-ai-484822';
this.region = opts?.region ?? process.env.CLAUDE_REGION ?? 'us-east5';
constructor(modelId: string, opts?: { projectId?: string; region?: string }) {
// Strip the "anthropic/" prefix if present — the SDK uses bare model names
this.modelId = modelId.startsWith("anthropic/")
? modelId.slice(10)
: modelId;
this.projectId =
opts?.projectId ?? process.env.GCP_PROJECT_ID ?? "master-ai-484822";
this.region = opts?.region ?? process.env.CLAUDE_REGION ?? "us-east5";
}
private buildClient(): AnthropicVertex {
const b64Key = process.env.GCP_SA_KEY_BASE64;
if (b64Key) {
try {
const jsonStr = Buffer.from(b64Key, "base64").toString("utf8");
const credentials = JSON.parse(jsonStr);
return new AnthropicVertex({
projectId: this.projectId,
region: this.region,
googleAuth: new GoogleAuth({
credentials,
scopes: ["https://www.googleapis.com/auth/cloud-platform"],
}) as any,
});
} catch {
console.warn(
"[llm] AnthropicVertex: SA key decode failed, falling back to metadata server",
);
}
}
return new AnthropicVertex({
projectId: this.projectId,
region: this.region,
});
}
private buildClient(): AnthropicVertex {
const b64Key = process.env.GCP_SA_KEY_BASE64;
if (b64Key) {
try {
const jsonStr = Buffer.from(b64Key, 'base64').toString('utf8');
const credentials = JSON.parse(jsonStr);
return new AnthropicVertex({
projectId: this.projectId,
region: this.region,
googleAuth: new GoogleAuth({ credentials, scopes: ['https://www.googleapis.com/auth/cloud-platform'] }) as any,
});
} catch {
console.warn('[llm] AnthropicVertex: SA key decode failed, falling back to metadata server');
}
async chat(
messages: LLMMessage[],
tools?: LLMTool[],
maxTokens = 8192,
): Promise<LLMResponse> {
const client = this.buildClient();
const system =
messages.find((m) => m.role === "system")?.content ?? undefined;
const nonSystem = messages.filter((m) => m.role !== "system");
// Convert OpenAI message format → Anthropic format
const anthropicMessages: any[] = nonSystem.map((m) => {
if (m.role === "assistant") {
const parts: any[] = [];
if (m.content) parts.push({ type: "text", text: m.content });
for (const tc of m.tool_calls ?? []) {
parts.push({
type: "tool_use",
id: tc.id,
name: tc.function.name,
input: JSON.parse(tc.function.arguments || "{}"),
});
}
return new AnthropicVertex({ projectId: this.projectId, region: this.region });
}
return {
role: "assistant",
content:
parts.length === 1 && parts[0].type === "text"
? parts[0].text
: parts,
};
}
if (m.role === "tool") {
return {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: m.tool_call_id,
content: m.content ?? "",
},
],
};
}
return { role: "user", content: m.content ?? "" };
});
async chat(messages: LLMMessage[], tools?: LLMTool[], maxTokens = 8192): Promise<LLMResponse> {
const client = this.buildClient();
const anthropicTools = (tools ?? []).map((t) => ({
name: t.function.name,
description: t.function.description,
input_schema: t.function.parameters,
}));
const system = messages.find(m => m.role === 'system')?.content ?? undefined;
const nonSystem = messages.filter(m => m.role !== 'system');
const MAX_RETRIES = 4;
const RETRY_STATUSES = new Set([429, 503]);
// Convert OpenAI message format → Anthropic format
const anthropicMessages: any[] = nonSystem.map(m => {
if (m.role === 'assistant') {
const parts: any[] = [];
if (m.content) parts.push({ type: 'text', text: m.content });
for (const tc of m.tool_calls ?? []) {
parts.push({
type: 'tool_use',
id: tc.id,
name: tc.function.name,
input: JSON.parse(tc.function.arguments || '{}'),
});
}
return { role: 'assistant', content: parts.length === 1 && parts[0].type === 'text' ? parts[0].text : parts };
}
if (m.role === 'tool') {
return {
role: 'user',
content: [{ type: 'tool_result', tool_use_id: m.tool_call_id, content: m.content ?? '' }],
};
}
return { role: 'user', content: m.content ?? '' };
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
const response = await (client.messages.create as Function)({
model: this.modelId,
max_tokens: maxTokens,
system: system ?? undefined,
messages: anthropicMessages,
tools: anthropicTools.length > 0 ? anthropicTools : undefined,
});
const anthropicTools = (tools ?? []).map(t => ({
name: t.function.name,
description: t.function.description,
input_schema: t.function.parameters,
}));
const textContent =
response.content
.filter((b: any) => b.type === "text")
.map((b: any) => b.text)
.join("") || null;
const MAX_RETRIES = 4;
const RETRY_STATUSES = new Set([429, 503]);
const tool_calls: LLMToolCall[] = response.content
.filter((b: any) => b.type === "tool_use")
.map((b: any) => ({
id: b.id,
type: "function" as const,
function: {
name: b.name,
arguments: JSON.stringify(b.input ?? {}),
},
}));
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
const response = await (client.messages.create as Function)({
model: this.modelId,
max_tokens: maxTokens,
system: system ?? undefined,
messages: anthropicMessages,
tools: anthropicTools.length > 0 ? anthropicTools : undefined,
});
const textContent = response.content
.filter((b: any) => b.type === 'text')
.map((b: any) => b.text)
.join('') || null;
const tool_calls: LLMToolCall[] = response.content
.filter((b: any) => b.type === 'tool_use')
.map((b: any) => ({
id: b.id,
type: 'function' as const,
function: { name: b.name, arguments: JSON.stringify(b.input ?? {}) },
}));
return {
content: textContent,
reasoning: null,
tool_calls,
finish_reason: response.stop_reason === 'tool_use' ? 'tool_calls' : 'stop',
usage: response.usage
? { prompt_tokens: response.usage.input_tokens, completion_tokens: response.usage.output_tokens, total_tokens: response.usage.input_tokens + response.usage.output_tokens }
: undefined,
};
} catch (err: any) {
const status = err?.status ?? err?.statusCode ?? 0;
if (RETRY_STATUSES.has(status) && attempt < MAX_RETRIES) {
const waitMs = Math.min(2 ** attempt * 2000 + Math.random() * 500, 30_000);
console.warn(`[llm] Anthropic Vertex ${status} on attempt ${attempt + 1}/${MAX_RETRIES + 1} — retrying in ${Math.round(waitMs / 1000)}s`);
await new Promise(r => setTimeout(r, waitMs));
continue;
}
throw new Error(`Anthropic Vertex error: ${err?.message ?? String(err)}`);
}
return {
content: stripModelMarkup(textContent),
reasoning: null,
tool_calls,
finish_reason:
response.stop_reason === "tool_use" ? "tool_calls" : "stop",
usage: response.usage
? {
prompt_tokens: response.usage.input_tokens,
completion_tokens: response.usage.output_tokens,
total_tokens:
response.usage.input_tokens + response.usage.output_tokens,
}
: undefined,
};
} catch (err: any) {
const status = err?.status ?? err?.statusCode ?? 0;
if (RETRY_STATUSES.has(status) && attempt < MAX_RETRIES) {
const waitMs = Math.min(
2 ** attempt * 2000 + Math.random() * 500,
30_000,
);
console.warn(
`[llm] Anthropic Vertex ${status} on attempt ${attempt + 1}/${MAX_RETRIES + 1} — retrying in ${Math.round(waitMs / 1000)}s`,
);
await new Promise((r) => setTimeout(r, waitMs));
continue;
}
throw new Error('Anthropic Vertex: exceeded max retries');
throw new Error(
`Anthropic Vertex error: ${err?.message ?? String(err)}`,
);
}
}
throw new Error("Anthropic Vertex: exceeded max retries");
}
}
// ---------------------------------------------------------------------------
// Factory — createLLM(modelId | tier)
// ---------------------------------------------------------------------------
export type ModelTier = 'A' | 'B' | 'C';
export type ModelTier = "A" | "B" | "C";
const TIER_MODELS: Record<ModelTier, string> = {
A: process.env.TIER_A_MODEL ?? 'gemini-2.5-flash',
B: process.env.TIER_B_MODEL ?? 'claude-sonnet-4-6',
C: process.env.TIER_C_MODEL ?? 'claude-sonnet-4-6'
A: process.env.TIER_A_MODEL ?? "gemini-2.5-flash",
B: process.env.TIER_B_MODEL ?? "claude-sonnet-4-6",
C: process.env.TIER_C_MODEL ?? "claude-sonnet-4-6",
};
export function createLLM(modelOrTier: string | ModelTier, opts?: { temperature?: number }): LLMClient {
const modelId = (modelOrTier === 'A' || modelOrTier === 'B' || modelOrTier === 'C')
? TIER_MODELS[modelOrTier]
: modelOrTier;
export function createLLM(
modelOrTier: string | ModelTier,
opts?: { temperature?: number },
): LLMClient {
const modelId =
modelOrTier === "A" || modelOrTier === "B" || modelOrTier === "C"
? TIER_MODELS[modelOrTier]
: modelOrTier;
if (modelId.startsWith('gemini-')) {
return new GeminiClient(modelId, opts);
}
if (modelId.startsWith("gemini-")) {
return new GeminiClient(modelId, opts);
}
if (modelId.startsWith('anthropic/') || modelId.startsWith('claude-')) {
return new AnthropicVertexClient(modelId);
}
if (modelId.startsWith("anthropic/") || modelId.startsWith("claude-")) {
return new AnthropicVertexClient(modelId);
}
return new VertexOpenAIClient(modelId, { temperature: opts?.temperature });
return new VertexOpenAIClient(modelId, { temperature: opts?.temperature });
}
// ---------------------------------------------------------------------------
@@ -443,14 +545,18 @@ export function createLLM(modelOrTier: string | ModelTier, opts?: { temperature?
// ---------------------------------------------------------------------------
export function toOAITools(
tools: Array<{ name: string; description: string; parameters: Record<string, unknown> }>
tools: Array<{
name: string;
description: string;
parameters: Record<string, unknown>;
}>,
): LLMTool[] {
return tools.map(t => ({
type: 'function',
function: {
name: t.name,
description: t.description,
parameters: t.parameters
}
}));
return tools.map((t) => ({
type: "function",
function: {
name: t.name,
description: t.description,
parameters: t.parameters,
},
}));
}

View File

@@ -3,66 +3,100 @@
// Requires a GitPushConfig with Gitea credentials for authenticated push.
// =============================================================================
import * as cp from 'child_process';
import * as util from 'util';
import { PROTECTED_GITEA_REPOS } from './security';
import * as cp from "child_process";
import * as util from "util";
import { PROTECTED_GITEA_REPOS } from "./security";
const execAsync = util.promisify(cp.exec);
import fs from "fs";
import path from "path";
export interface GitPushConfig {
apiUrl: string;
apiToken: string;
username: string;
apiUrl: string;
apiToken: string;
username: string;
}
export async function gitCommitAndPush(
workspaceRoot: string,
message: string,
cfg: GitPushConfig
workspaceRoot: string,
message: string,
cfg: GitPushConfig,
): Promise<unknown> {
const cwd = workspaceRoot;
const { apiUrl, apiToken, username } = cfg;
const cwd = workspaceRoot;
const { apiUrl, apiToken, username } = cfg;
try {
// Check remote URL before committing — block pushes to protected repos
let remoteCheck = "";
try {
// Check remote URL before committing — block pushes to protected repos
let remoteCheck = '';
try {
remoteCheck = (await execAsync('git remote get-url origin', { cwd })).stdout.trim();
} catch { /* no remote yet */ }
for (const protectedRepo of PROTECTED_GITEA_REPOS) {
const repoPath = protectedRepo.replace('mark/', '');
if (remoteCheck.includes(`/${repoPath}`) || remoteCheck.includes(`/${repoPath}.git`)) {
return {
error: `SECURITY: This workspace is linked to a protected Vibn platform repo (${protectedRepo}). ` +
`Agents cannot push to platform repos. Only user project repos are writable.`,
};
}
}
await execAsync('git add -A', { cwd });
await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { cwd });
// Strip any existing credentials from remote URL and re-inject cleanly
let remoteUrl = '';
try {
remoteUrl = (await execAsync('git remote get-url origin', { cwd })).stdout.trim();
} catch { /* no remote */ }
const cleanUrl = remoteUrl.replace(/https:\/\/[^@]+@/, 'https://');
const baseUrl = cleanUrl || apiUrl;
const authedUrl = baseUrl.replace('https://', `https://${username}:${apiToken}@`);
await execAsync(`git remote set-url origin "${authedUrl}"`, { cwd }).catch(async () => {
await execAsync(`git remote add origin "${authedUrl}"`, { cwd });
});
const branch = (await execAsync('git rev-parse --abbrev-ref HEAD', { cwd })).stdout.trim();
await execAsync(`git push -u origin "${branch}"`, { cwd, timeout: 60_000 });
return { success: true, message, branch };
} catch (err: any) {
const cleaned = (err.message || '').replace(new RegExp(apiToken, 'g'), '***');
return { error: `Git operation failed: ${cleaned}` };
remoteCheck = (
await execAsync("git remote get-url origin", { cwd })
).stdout.trim();
} catch {
/* no remote yet */
}
for (const protectedRepo of PROTECTED_GITEA_REPOS) {
const repoPath = protectedRepo.replace("mark/", "");
if (
remoteCheck.includes(`/${repoPath}`) ||
remoteCheck.includes(`/${repoPath}.git`)
) {
return {
error:
`SECURITY: This workspace is linked to a protected Vibn platform repo (${protectedRepo}). ` +
`Agents cannot push to platform repos. Only user project repos are writable.`,
};
}
}
// Write commit message to a temporary file to avoid shell injection
const msgFile = path.join(cwd, ".git", "COMMIT_EDITMSG");
fs.writeFileSync(msgFile, message, "utf8");
await execAsync("git add -A", { cwd });
await execAsync("git commit -F .git/COMMIT_EDITMSG", { cwd });
try {
fs.unlinkSync(msgFile);
} catch {
/* ignore */
}
// Strip any existing credentials from remote URL and re-inject cleanly
let remoteUrl = "";
try {
remoteUrl = (
await execAsync("git remote get-url origin", { cwd })
).stdout.trim();
} catch {
/* no remote */
}
const cleanUrl = remoteUrl.replace(/https:\/\/[^@]+@/, "https://");
const baseUrl = cleanUrl || apiUrl;
const authedUrl = baseUrl.replace(
"https://",
`https://${username}:${apiToken}@`,
);
await execAsync(`git remote set-url origin "${authedUrl}"`, { cwd }).catch(
async () => {
await execAsync(`git remote add origin "${authedUrl}"`, { cwd });
},
);
const branch = (
await execAsync("git rev-parse --abbrev-ref HEAD", { cwd })
).stdout.trim();
await execAsync(`git push -u origin "${branch}"`, { cwd, timeout: 60_000 });
return { success: true, message, branch };
} catch (err: any) {
const cleaned = (err.message || "").replace(
new RegExp(apiToken, "g"),
"***",
);
return { error: `Git operation failed: ${cleaned}` };
}
}