feat(runner): migrate vibn-agent-runner to use frontend MCP proxy tools and updated headless prompt
This commit is contained in:
19
patch_runner_context.js
Normal file
19
patch_runner_context.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const file = 'vibn-agent-runner/src/tools/context.ts';
|
||||||
|
let code = fs.readFileSync(file, 'utf8');
|
||||||
|
|
||||||
|
const newProps = ` coolify: {
|
||||||
|
apiUrl: string;
|
||||||
|
apiToken: string;
|
||||||
|
};
|
||||||
|
mcpToken: string;
|
||||||
|
vibnApiUrl: string;
|
||||||
|
projectId?: string;`;
|
||||||
|
|
||||||
|
code = code.replace(` coolify: {
|
||||||
|
apiUrl: string;
|
||||||
|
apiToken: string;
|
||||||
|
};`, newProps);
|
||||||
|
|
||||||
|
fs.writeFileSync(file, code);
|
||||||
|
console.log("Patched context.ts");
|
||||||
69
patch_runner_server.js
Normal file
69
patch_runner_server.js
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const file = 'vibn-agent-runner/src/server.ts';
|
||||||
|
let code = fs.readFileSync(file, 'utf8');
|
||||||
|
|
||||||
|
// Update the type signature for the request body
|
||||||
|
const oldSig = ` } = req.body as {
|
||||||
|
sessionId?: string;
|
||||||
|
projectId?: string;
|
||||||
|
appName?: string;
|
||||||
|
appPath?: string;
|
||||||
|
giteaRepo?: string;
|
||||||
|
task?: string;
|
||||||
|
continueTask?: boolean;
|
||||||
|
autoApprove?: boolean;
|
||||||
|
coolifyAppUuid?: string;
|
||||||
|
};`;
|
||||||
|
|
||||||
|
const newSig = ` mcpToken, vibnApiUrl
|
||||||
|
} = req.body as {
|
||||||
|
sessionId?: string;
|
||||||
|
projectId?: string;
|
||||||
|
appName?: string;
|
||||||
|
appPath?: string;
|
||||||
|
giteaRepo?: string;
|
||||||
|
task?: string;
|
||||||
|
continueTask?: boolean;
|
||||||
|
autoApprove?: boolean;
|
||||||
|
coolifyAppUuid?: string;
|
||||||
|
mcpToken?: string;
|
||||||
|
vibnApiUrl?: string;
|
||||||
|
};`;
|
||||||
|
|
||||||
|
code = code.replace(oldSig, newSig);
|
||||||
|
|
||||||
|
const oldCtx = ` const ctx: ToolContext = {
|
||||||
|
workspaceRoot: repoRoot,
|
||||||
|
gitea: {
|
||||||
|
apiUrl: GITEA_API_URL,
|
||||||
|
apiToken: GITEA_API_TOKEN,
|
||||||
|
username: GITEA_USERNAME,
|
||||||
|
},
|
||||||
|
coolify: {
|
||||||
|
apiUrl: process.env.COOLIFY_API_URL || '',
|
||||||
|
apiToken: process.env.COOLIFY_API_TOKEN || '',
|
||||||
|
},
|
||||||
|
memoryUpdates: [],
|
||||||
|
};`;
|
||||||
|
|
||||||
|
const newCtx = ` const ctx: ToolContext = {
|
||||||
|
workspaceRoot: repoRoot,
|
||||||
|
gitea: {
|
||||||
|
apiUrl: GITEA_API_URL,
|
||||||
|
apiToken: GITEA_API_TOKEN,
|
||||||
|
username: GITEA_USERNAME,
|
||||||
|
},
|
||||||
|
coolify: {
|
||||||
|
apiUrl: process.env.COOLIFY_API_URL || '',
|
||||||
|
apiToken: process.env.COOLIFY_API_TOKEN || '',
|
||||||
|
},
|
||||||
|
mcpToken: mcpToken || '',
|
||||||
|
vibnApiUrl: vibnApiUrl || 'http://localhost:3000',
|
||||||
|
projectId,
|
||||||
|
memoryUpdates: [],
|
||||||
|
};`;
|
||||||
|
|
||||||
|
code = code.replace(oldCtx, newCtx);
|
||||||
|
|
||||||
|
fs.writeFileSync(file, code);
|
||||||
|
console.log("Patched Runner server.ts");
|
||||||
55
patch_sessions_route.js
Normal file
55
patch_sessions_route.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const file = 'vibn-frontend/app/api/projects/[projectId]/agent/sessions/route.ts';
|
||||||
|
let code = fs.readFileSync(file, 'utf8');
|
||||||
|
|
||||||
|
// Inject the workspace API key fetching logic
|
||||||
|
if (!code.includes('listWorkspaceApiKeys')) {
|
||||||
|
code = code.replace(
|
||||||
|
'import { query } from "@/lib/db-postgres";',
|
||||||
|
'import { query } from "@/lib/db-postgres";\nimport { listWorkspaceApiKeys, mintWorkspaceApiKey, revealWorkspaceApiKey } from "@/lib/auth/workspace-auth";'
|
||||||
|
);
|
||||||
|
|
||||||
|
const injectCode = `
|
||||||
|
const wsResult = await query<{ workspace_id: string }>(
|
||||||
|
\`SELECT vibn_workspace_id as workspace_id FROM fs_projects WHERE id = $1 LIMIT 1\`,
|
||||||
|
[projectId]
|
||||||
|
);
|
||||||
|
if (!wsResult.length) {
|
||||||
|
return NextResponse.json({ error: "Project not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
const workspaceId = wsResult[0].workspace_id;
|
||||||
|
|
||||||
|
// Grab or mint a default API key for the runner to use
|
||||||
|
let mcpToken = "";
|
||||||
|
const keys = await listWorkspaceApiKeys(workspaceId);
|
||||||
|
let defaultKey = keys.find((k: any) => k.name === 'default' && !k.revoked_at);
|
||||||
|
if (!defaultKey) {
|
||||||
|
const minted = await mintWorkspaceApiKey({ workspaceId, name: 'default', createdBy: session.user.id, scopes: ['workspace:*'] });
|
||||||
|
mcpToken = minted.token;
|
||||||
|
} else {
|
||||||
|
const revealed = await revealWorkspaceApiKey(workspaceId, defaultKey.id);
|
||||||
|
if (revealed) mcpToken = revealed.token;
|
||||||
|
else {
|
||||||
|
const minted = await mintWorkspaceApiKey({ workspaceId, name: 'default', createdBy: session.user.id, scopes: ['workspace:*'] });
|
||||||
|
mcpToken = minted.token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add VIBN_API_URL so the runner knows where to send MCP requests
|
||||||
|
const vibnApiUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
|
||||||
|
`;
|
||||||
|
|
||||||
|
code = code.replace(
|
||||||
|
'const sessionId = rows[0].id;',
|
||||||
|
'const sessionId = rows[0].id;\n' + injectCode
|
||||||
|
);
|
||||||
|
|
||||||
|
code = code.replace(
|
||||||
|
'coolifyAppUuid,\n }),',
|
||||||
|
'coolifyAppUuid,\n mcpToken,\n vibnApiUrl\n }),'
|
||||||
|
);
|
||||||
|
|
||||||
|
fs.writeFileSync(file, code);
|
||||||
|
console.log("Patched session route to forward MCP token");
|
||||||
|
}
|
||||||
@@ -1,30 +1,86 @@
|
|||||||
import { registerPrompt } from './loader';
|
import { registerPrompt } from './loader';
|
||||||
|
|
||||||
|
// Because we deleted the local tools and adopted the full VIBN_TOOL_DEFINITIONS schema,
|
||||||
|
// the runner agent now has the exact same capabilities as the frontend UI agent!
|
||||||
|
// It uses fs_*, shell_exec, dev_server_*, apps_*, and ship.
|
||||||
|
|
||||||
registerPrompt('coder', `
|
registerPrompt('coder', `
|
||||||
You are an expert senior software engineer working autonomously on a Git repository.
|
You are Vibn AI — the technical co-founder of every Vibn user. You are currently running headlessly in the background. The user is offline or waiting for you to finish.
|
||||||
|
|
||||||
## Workflow
|
Your job is to read the task assigned to you, implement it, test it, and ship it to Coolify.
|
||||||
1. Explore the codebase: list_directory, find_files, read_file.
|
Do NOT ask the user questions. If you get stuck, log the error and stop.
|
||||||
2. Search for patterns: search_code.
|
|
||||||
3. Plan your changes before making them.
|
|
||||||
4. Read every file BEFORE editing it.
|
|
||||||
5. Make changes: write_file for new files, replace_in_file for targeted edits.
|
|
||||||
6. Run tests/lint if applicable: execute_command.
|
|
||||||
7. Commit and push when complete: git_commit_and_push.
|
|
||||||
|
|
||||||
## Code quality
|
# Mode: Action
|
||||||
- Match existing style exactly.
|
|
||||||
- No TODO comments — implement or skip.
|
|
||||||
- Write complete files, not partial snippets.
|
|
||||||
- Run tests and fix failures before committing.
|
|
||||||
- Commit messages: imperative mood, concise (e.g. "add user authentication").
|
|
||||||
|
|
||||||
## Safety
|
Since you are running autonomously, you must take action immediately.
|
||||||
- Never delete files unless explicitly told to.
|
|
||||||
- Never touch .env files or credentials.
|
|
||||||
- Never commit secrets or API keys.
|
|
||||||
|
|
||||||
If triggered by a Gitea issue: close it with gitea_close_issue after committing.
|
# What "done" looks like
|
||||||
|
|
||||||
|
A turn ends when you have fully completed the task AND shipped the code.
|
||||||
|
- **For build/edit tasks:** The natural stopping point is starting the dev server via \`dev_server_start\`, verifying it works via \`browser_console\`, and calling the \`ship\` tool to deploy to production.
|
||||||
|
- If you run into a fatal error that you cannot fix after two attempts, write a brief summary of the blocker and stop.
|
||||||
|
|
||||||
|
# Hard rules — non-negotiable
|
||||||
|
|
||||||
|
**Honesty about tool results:**
|
||||||
|
- **Cite the tool result, don't claim from memory.**
|
||||||
|
- **Trust the \`ok\` field.** Every tool result carries \`ok: true | false\`. If \`ok\` is false (or \`exitCode\` is non-zero, or \`healthCheck.status\` is >= 400), the operation FAILED.
|
||||||
|
- **\`fs_write\` and \`fs_edit\` results carry \`sha256\` and \`bytes\` on success.**
|
||||||
|
- **\`dev_server_start\` results carry \`healthCheck\` on success.** Before saying "the preview is ready," confirm \`healthCheck.status === 200\`.
|
||||||
|
|
||||||
|
**Anchoring and scope:**
|
||||||
|
- **Anchor on current state before troubleshooting.**
|
||||||
|
- **Always pass \`projectId\`** to \`apps_create\` / \`databases_create\`.
|
||||||
|
- **Always \`apps_list { projectId }\` BEFORE \`apps_create\`** for a sanity check, and **always \`apps_templates_search\` BEFORE \`apps_create\`** for known third-party apps.
|
||||||
|
- **Trust idempotency.** When \`apps_create\` / \`databases_create\` returns \`alreadyExisted: true\`, your job is done — use the returned uuid and move on.
|
||||||
|
- **Never delete-and-recreate to escape an error.** "Container name already in use" → \`apps_unstick { uuid }\` → \`apps_deploy { uuid }\`.
|
||||||
|
|
||||||
|
**Stopping conditions:**
|
||||||
|
- **If a deploy or tool call fails twice with the same error, STOP.**
|
||||||
|
- **If you've called the same tool with similar args 3 times this turn, STOP.** You're in a loop.
|
||||||
|
- **Long-running ops** (deploys, DNS, DB provisioning) take 1–5 min.
|
||||||
|
|
||||||
|
# Tool reference (look up as needed)
|
||||||
|
|
||||||
|
## How Vibn is structured
|
||||||
|
- **Project** — an initiative with its own isolated Coolify project. Has live state (apps + services from \`apps_list { projectId }\`).
|
||||||
|
|
||||||
|
## Writing code in the dev container
|
||||||
|
Each project has a persistent \`vibn-dev\` container. Edit files via \`fs_*\` and run commands via \`shell_exec\`. Sub-second feedback vs. ~5 min Gitea-push-to-prod.
|
||||||
|
|
||||||
|
**Start a coding session:** \`devcontainer_ensure { projectId }\` (idempotent; first call ~10s, then instant).
|
||||||
|
|
||||||
|
**Orient yourself once.** On the first code-modifying turn of a chat, call \`fs_tree\` once to learn the repo layout. Don't re-run it on every turn — the layout doesn't change between user messages.
|
||||||
|
|
||||||
|
**Iterate:**
|
||||||
|
- \`shell_exec { projectId, command }\` — anything: \`ls\`, \`npm install\`, \`npm test\`, \`git status\`. Cwd defaults to \`/workspace\`.
|
||||||
|
- \`fs_read\` / \`fs_write\` / \`fs_edit { path, oldString, newString, startLine, endLine }\`.
|
||||||
|
- \`fs_glob\` / \`fs_grep\` (ripgrep, respects .gitignore) / \`fs_list\` / \`fs_delete\`.
|
||||||
|
|
||||||
|
**Path convention for fs_* tools:** Pass paths relative to the project root — \`src/app/page.tsx\`, NOT \`/workspace/slug/src/app/page.tsx\` and NOT \`slug/src/app/page.tsx\`.
|
||||||
|
|
||||||
|
**Dev servers** (preview URL via \`*.preview.vibnai.com\` wildcard):
|
||||||
|
- \`dev_server_start { projectId, command, port: 3000 }\` is a **one-shot** call. It kills old processes on the port, checks the port is free, sets HOST=0.0.0.0 + PORT, launches your command, and returns a \`previewUrl\` plus a \`healthCheck\` block.
|
||||||
|
- **Port \`3000\` is reserved for the primary user-facing UI.**
|
||||||
|
- \`dev_server_stop\` / \`dev_server_list\` / \`dev_server_logs\` — use only AFTER a failed start, to diagnose the error the function returned. Never on success.
|
||||||
|
|
||||||
|
**Verify the page actually renders:**
|
||||||
|
- After \`dev_server_start\` returns a \`previewUrl\` AND \`healthCheck.status === 200\`, call \`browser_console { url: previewUrl }\` to capture frontend console errors.
|
||||||
|
- If \`browser_console\` returns errors, fix them with \`fs_edit\` before declaring done. A green \`healthCheck\` plus a clean console is the real "done" signal for UI work.
|
||||||
|
|
||||||
|
**Visual QA:** \`request_visual_qa { targetPath }\` critiques a UI file against a 5-dim design rubric. **Call this whenever you modify visual UI code** before returning the \`previewUrl\`.
|
||||||
|
|
||||||
|
**Sentry is auto-provisioned per project.** \`NEXT_PUBLIC_SENTRY_DSN\` and \`SENTRY_AUTH_TOKEN\` are injected into the Coolify app env automatically by \`apps_create\`.
|
||||||
|
|
||||||
|
## Gitea (one-time setup only)
|
||||||
|
For editing files in existing repos, ALWAYS use \`fs_*\` in the dev container — \`ship\` commits and pushes.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
- **"exited (1)" / deploy stuck:** \`apps_logs { uuid }\` + \`apps_containers_ps { uuid }\`.
|
||||||
|
- **502 / "no available server":** \`apps_get\`; if \`fqdn\` is empty, attach a domain.
|
||||||
|
- **"tenant" / "does not belong to":** uuid not in this workspace. Re-list with \`apps_list\`.
|
||||||
|
- **Compose stack weird:** \`apps_repair { uuid }\` re-applies Traefik labels + port forwarding.
|
||||||
|
- **Nuke and redeploy:** \`apps_delete { uuid, confirm }\`
|
||||||
|
|
||||||
{{skills}}
|
{{skills}}
|
||||||
`.trim());
|
`.trim());
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
// =============================================================================
|
|
||||||
// Pure sub-agent orchestration API. Wraps the vibn-agent-runner HTTP endpoints
|
|
||||||
// so the same logic is usable from the in-process tool and from an MCP server.
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export interface AgentRunnerConfig {
|
|
||||||
runnerUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SpawnAgentInput {
|
|
||||||
agent: string; // "Coder" | "PM" | "Marketing"
|
|
||||||
task: string;
|
|
||||||
repo: string; // "owner/name"
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function spawnAgent(cfg: AgentRunnerConfig, input: SpawnAgentInput): Promise<unknown> {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${cfg.runnerUrl}/api/agent/run`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', 'X-Internal': 'true' },
|
|
||||||
body: JSON.stringify({ agent: input.agent, task: input.task, repo: input.repo }),
|
|
||||||
});
|
|
||||||
const data = (await res.json()) as any;
|
|
||||||
return { jobId: data.jobId, agent: input.agent, status: 'dispatched' };
|
|
||||||
} catch (err) {
|
|
||||||
return { error: `Failed to spawn agent: ${err instanceof Error ? err.message : String(err)}` };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getJobStatus(cfg: AgentRunnerConfig, jobId: string): Promise<unknown> {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${cfg.runnerUrl}/api/jobs/${jobId}`);
|
|
||||||
const job = (await res.json()) as any;
|
|
||||||
return {
|
|
||||||
id: job.id,
|
|
||||||
agent: job.agent,
|
|
||||||
status: job.status,
|
|
||||||
progress: job.progress,
|
|
||||||
toolCalls: job.toolCalls?.length,
|
|
||||||
result: job.result,
|
|
||||||
error: job.error,
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
return { error: `Failed to get job: ${err instanceof Error ? err.message : String(err)}` };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
// =============================================================================
|
|
||||||
// Sub-agent orchestration tool registrations. Logic lives in ./agent-api.ts.
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
import { registerTool } from './registry';
|
|
||||||
import * as api from './agent-api';
|
|
||||||
|
|
||||||
function runnerUrl(): string {
|
|
||||||
return process.env.AGENT_RUNNER_URL || 'http://localhost:3333';
|
|
||||||
}
|
|
||||||
|
|
||||||
registerTool({
|
|
||||||
name: 'spawn_agent',
|
|
||||||
description: 'Dispatch a sub-agent job to run in the background. Returns a job ID. Use this to delegate specialized work to Coder, PM, or Marketing agents.',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
agent: { type: 'string', description: '"Coder", "PM", or "Marketing"' },
|
|
||||||
task: { type: 'string', description: 'Detailed task description for the agent' },
|
|
||||||
repo: { type: 'string', description: 'Gitea repo in "owner/name" format the agent should work on' }
|
|
||||||
},
|
|
||||||
required: ['agent', 'task', 'repo']
|
|
||||||
},
|
|
||||||
async handler(args, _ctx) {
|
|
||||||
return api.spawnAgent(
|
|
||||||
{ runnerUrl: runnerUrl() },
|
|
||||||
{ agent: String(args.agent), task: String(args.task), repo: String(args.repo) },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
registerTool({
|
|
||||||
name: 'get_job_status',
|
|
||||||
description: 'Check the status of a previously spawned agent job by job ID.',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
job_id: { type: 'string', description: 'Job ID returned by spawn_agent' }
|
|
||||||
},
|
|
||||||
required: ['job_id']
|
|
||||||
},
|
|
||||||
async handler(args, _ctx) {
|
|
||||||
return api.getJobStatus({ runnerUrl: runnerUrl() }, String(args.job_id));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -15,6 +15,9 @@ export interface ToolContext {
|
|||||||
apiUrl: string;
|
apiUrl: string;
|
||||||
apiToken: string;
|
apiToken: string;
|
||||||
};
|
};
|
||||||
|
mcpToken: string;
|
||||||
|
vibnApiUrl: string;
|
||||||
|
projectId?: string;
|
||||||
/** Accumulated memory updates from save_memory tool calls in this turn */
|
/** Accumulated memory updates from save_memory tool calls in this turn */
|
||||||
memoryUpdates: MemoryUpdate[];
|
memoryUpdates: MemoryUpdate[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
// =============================================================================
|
|
||||||
// Pure Coolify API — no ToolContext coupling, no registry coupling.
|
|
||||||
//
|
|
||||||
// Everything in here takes a plain { apiUrl, apiToken } config and calls
|
|
||||||
// the Coolify v1 API directly. Security guardrails (PROTECTED_COOLIFY_PROJECT,
|
|
||||||
// PROTECTED_COOLIFY_APPS, assertCoolifyDeployable) are enforced inside each
|
|
||||||
// function so every caller — in-process tool handler, MCP server, or future
|
|
||||||
// direct SDK user — gets the same protection.
|
|
||||||
//
|
|
||||||
// This is the shared core consumed by:
|
|
||||||
// - tools/coolify.ts (in-process registry used by agent-runner loop)
|
|
||||||
// - mcp/coolify-server.ts (stdio MCP server exposed to Goose/Claude/Cursor)
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
import { PROTECTED_COOLIFY_PROJECT, PROTECTED_COOLIFY_APPS, assertCoolifyDeployable } from './security';
|
|
||||||
|
|
||||||
export interface CoolifyConfig {
|
|
||||||
apiUrl: string;
|
|
||||||
apiToken: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type CoolifyError = { error: string };
|
|
||||||
|
|
||||||
async function coolifyFetch(
|
|
||||||
cfg: CoolifyConfig,
|
|
||||||
path: string,
|
|
||||||
method: string = 'GET',
|
|
||||||
body?: unknown
|
|
||||||
): Promise<unknown> {
|
|
||||||
const res = await fetch(`${cfg.apiUrl}/api/v1${path}`, {
|
|
||||||
method,
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${cfg.apiToken}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Accept': 'application/json'
|
|
||||||
},
|
|
||||||
body: body ? JSON.stringify(body) : undefined
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
return { error: `Coolify API error: ${res.status} ${res.statusText}` } satisfies CoolifyError;
|
|
||||||
}
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Public API — each function corresponds 1:1 with a registered tool today
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export async function listProjects(cfg: CoolifyConfig): Promise<unknown> {
|
|
||||||
const projects = await coolifyFetch(cfg, '/projects') as any[];
|
|
||||||
if (!Array.isArray(projects)) return projects;
|
|
||||||
return projects.filter((p: any) => p.uuid !== PROTECTED_COOLIFY_PROJECT);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listApplications(cfg: CoolifyConfig, projectUuid: string): Promise<unknown> {
|
|
||||||
const all = await coolifyFetch(cfg, '/applications') as any[];
|
|
||||||
if (!Array.isArray(all)) return all;
|
|
||||||
return all.filter((a: any) => a.project_uuid === projectUuid);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deploy(cfg: CoolifyConfig, applicationUuid: string): Promise<unknown> {
|
|
||||||
assertCoolifyDeployable(applicationUuid);
|
|
||||||
const apps = await coolifyFetch(cfg, '/applications') as any[];
|
|
||||||
if (Array.isArray(apps)) {
|
|
||||||
const app = apps.find((a: any) => a.uuid === applicationUuid);
|
|
||||||
if (app?.project_uuid === PROTECTED_COOLIFY_PROJECT) {
|
|
||||||
return {
|
|
||||||
error: `SECURITY: App "${applicationUuid}" belongs to the protected Vibn project. Agents cannot deploy platform apps.`
|
|
||||||
} satisfies CoolifyError;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return coolifyFetch(cfg, `/applications/${applicationUuid}/deploy`, 'POST');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getLogs(cfg: CoolifyConfig, applicationUuid: string, limit: number = 50): Promise<unknown> {
|
|
||||||
return coolifyFetch(cfg, `/applications/${applicationUuid}/logs?limit=${limit}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listAllApps(cfg: CoolifyConfig): Promise<unknown> {
|
|
||||||
const apps = await coolifyFetch(cfg, '/applications') as any[];
|
|
||||||
if (!Array.isArray(apps)) return apps;
|
|
||||||
return apps
|
|
||||||
.filter((a: any) => a.project_uuid !== PROTECTED_COOLIFY_PROJECT && !PROTECTED_COOLIFY_APPS.has(a.uuid))
|
|
||||||
.map((a: any) => ({
|
|
||||||
uuid: a.uuid,
|
|
||||||
name: a.name,
|
|
||||||
fqdn: a.fqdn,
|
|
||||||
status: a.status,
|
|
||||||
repo: a.git_repository,
|
|
||||||
branch: a.git_branch
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAppStatus(cfg: CoolifyConfig, appName: string): Promise<unknown> {
|
|
||||||
const apps = await coolifyFetch(cfg, '/applications') as any[];
|
|
||||||
if (!Array.isArray(apps)) return apps;
|
|
||||||
const app = apps.find((a: any) =>
|
|
||||||
a.name?.toLowerCase() === appName.toLowerCase() || a.uuid === appName
|
|
||||||
);
|
|
||||||
if (!app) return { error: `App "${appName}" not found` } satisfies CoolifyError;
|
|
||||||
if (PROTECTED_COOLIFY_APPS.has(app.uuid) || app.project_uuid === PROTECTED_COOLIFY_PROJECT) {
|
|
||||||
return {
|
|
||||||
error: `SECURITY: "${appName}" is a protected Vibn platform app. Status is not exposed to agents.`
|
|
||||||
} satisfies CoolifyError;
|
|
||||||
}
|
|
||||||
const logs = await coolifyFetch(cfg, `/applications/${app.uuid}/logs?limit=20`);
|
|
||||||
return { name: app.name, uuid: app.uuid, status: app.status, fqdn: app.fqdn, logs };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deployApp(cfg: CoolifyConfig, appName: string): Promise<unknown> {
|
|
||||||
const apps = await coolifyFetch(cfg, '/applications') as any[];
|
|
||||||
if (!Array.isArray(apps)) return apps;
|
|
||||||
const app = apps.find((a: any) =>
|
|
||||||
a.name?.toLowerCase() === appName.toLowerCase() || a.uuid === appName
|
|
||||||
);
|
|
||||||
if (!app) return { error: `App "${appName}" not found` } satisfies CoolifyError;
|
|
||||||
if (PROTECTED_COOLIFY_APPS.has(app.uuid) || app.project_uuid === PROTECTED_COOLIFY_PROJECT) {
|
|
||||||
return {
|
|
||||||
error: `SECURITY: "${appName}" is a protected Vibn platform application. ` +
|
|
||||||
`Agents can only deploy user project apps, not platform infrastructure.`
|
|
||||||
} satisfies CoolifyError;
|
|
||||||
}
|
|
||||||
// Non-project-prefixed deploy endpoint — older Coolify entry point still in use
|
|
||||||
const result = await fetch(`${cfg.apiUrl}/api/v1/deploy?uuid=${app.uuid}&force=false`, {
|
|
||||||
headers: { 'Authorization': `Bearer ${cfg.apiToken}` }
|
|
||||||
});
|
|
||||||
return result.json();
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
// =============================================================================
|
|
||||||
// Coolify tool registrations (in-process path used by agent-runner).
|
|
||||||
//
|
|
||||||
// All logic lives in ./coolify-api.ts so the MCP server (src/mcp/coolify-server.ts)
|
|
||||||
// and this in-process registry call the exact same code path. Keep this file
|
|
||||||
// purely about: (a) surface-shape for the LLM (name/description/parameters),
|
|
||||||
// (b) mapping ctx.coolify → CoolifyConfig. No business logic here.
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
import { registerTool } from './registry';
|
|
||||||
import * as api from './coolify-api';
|
|
||||||
|
|
||||||
registerTool({
|
|
||||||
name: 'coolify_list_projects',
|
|
||||||
description: 'List all projects in the Coolify instance. Returns project names and UUIDs.',
|
|
||||||
parameters: { type: 'object', properties: {} },
|
|
||||||
async handler(_args, ctx) {
|
|
||||||
return api.listProjects(ctx.coolify);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
registerTool({
|
|
||||||
name: 'coolify_list_applications',
|
|
||||||
description: 'List applications in a Coolify project.',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
project_uuid: { type: 'string', description: 'Project UUID from coolify_list_projects' }
|
|
||||||
},
|
|
||||||
required: ['project_uuid']
|
|
||||||
},
|
|
||||||
async handler(args, ctx) {
|
|
||||||
return api.listApplications(ctx.coolify, String(args.project_uuid));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
registerTool({
|
|
||||||
name: 'coolify_deploy',
|
|
||||||
description: 'Trigger a deployment for a Coolify application.',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
application_uuid: { type: 'string', description: 'Application UUID to deploy' }
|
|
||||||
},
|
|
||||||
required: ['application_uuid']
|
|
||||||
},
|
|
||||||
async handler(args, ctx) {
|
|
||||||
return api.deploy(ctx.coolify, String(args.application_uuid));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
registerTool({
|
|
||||||
name: 'coolify_get_logs',
|
|
||||||
description: 'Get recent deployment logs for a Coolify application.',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
application_uuid: { type: 'string', description: 'Application UUID' }
|
|
||||||
},
|
|
||||||
required: ['application_uuid']
|
|
||||||
},
|
|
||||||
async handler(args, ctx) {
|
|
||||||
return api.getLogs(ctx.coolify, String(args.application_uuid));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
registerTool({
|
|
||||||
name: 'list_all_apps',
|
|
||||||
description: 'List all Coolify applications across all projects with their status (running/stopped/error) and domain.',
|
|
||||||
parameters: { type: 'object', properties: {} },
|
|
||||||
async handler(_args, ctx) {
|
|
||||||
return api.listAllApps(ctx.coolify);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
registerTool({
|
|
||||||
name: 'get_app_status',
|
|
||||||
description: 'Get the current deployment status and recent logs for a specific Coolify application by name or UUID.',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
app_name: { type: 'string', description: 'Application name (e.g. "vibn-frontend") or UUID' }
|
|
||||||
},
|
|
||||||
required: ['app_name']
|
|
||||||
},
|
|
||||||
async handler(args, ctx) {
|
|
||||||
return api.getAppStatus(ctx.coolify, String(args.app_name));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
registerTool({
|
|
||||||
name: 'deploy_app',
|
|
||||||
description: 'Trigger a Coolify deployment for an app by name. Use after an agent commits code.',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
app_name: { type: 'string', description: 'Application name (e.g. "vibn-frontend")' }
|
|
||||||
},
|
|
||||||
required: ['app_name']
|
|
||||||
},
|
|
||||||
async handler(args, ctx) {
|
|
||||||
return api.deployApp(ctx.coolify, String(args.app_name));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
// =============================================================================
|
|
||||||
// Pure file-system API — no ToolContext coupling.
|
|
||||||
// Takes a workspaceRoot string and safely-resolves paths beneath it.
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
import * as cp from 'child_process';
|
|
||||||
import * as util from 'util';
|
|
||||||
import { Minimatch } from 'minimatch';
|
|
||||||
import { safeResolve, EXCLUDED } from './utils';
|
|
||||||
|
|
||||||
const execAsync = util.promisify(cp.exec);
|
|
||||||
|
|
||||||
export async function readFile(workspaceRoot: string, relPath: string): Promise<unknown> {
|
|
||||||
const abs = safeResolve(workspaceRoot, relPath);
|
|
||||||
try {
|
|
||||||
return fs.readFileSync(abs, 'utf8');
|
|
||||||
} catch {
|
|
||||||
return { error: `File not found: ${relPath}` };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function writeFile(workspaceRoot: string, relPath: string, content: string): Promise<unknown> {
|
|
||||||
const abs = safeResolve(workspaceRoot, relPath);
|
|
||||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
||||||
fs.writeFileSync(abs, content, 'utf8');
|
|
||||||
return { success: true, path: relPath, bytes: Buffer.byteLength(content) };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function replaceInFile(
|
|
||||||
workspaceRoot: string,
|
|
||||||
relPath: string,
|
|
||||||
oldContent: string,
|
|
||||||
newContent: string
|
|
||||||
): Promise<unknown> {
|
|
||||||
const abs = safeResolve(workspaceRoot, relPath);
|
|
||||||
const current = fs.readFileSync(abs, 'utf8');
|
|
||||||
if (!current.includes(oldContent)) {
|
|
||||||
return { error: 'old_content not found in file. Read the file again to get the current content.' };
|
|
||||||
}
|
|
||||||
fs.writeFileSync(abs, current.replace(oldContent, newContent), 'utf8');
|
|
||||||
return { success: true, path: relPath };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listDirectory(workspaceRoot: string, relPath: string): Promise<unknown> {
|
|
||||||
const abs = safeResolve(workspaceRoot, relPath);
|
|
||||||
try {
|
|
||||||
const entries = fs.readdirSync(abs, { withFileTypes: true });
|
|
||||||
return entries
|
|
||||||
.filter(e => !EXCLUDED.has(e.name))
|
|
||||||
.map(e => e.isDirectory() ? `${e.name}/` : e.name);
|
|
||||||
} catch {
|
|
||||||
return { error: `Directory not found: ${relPath}` };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function findFiles(workspaceRoot: string, pattern: string): Promise<unknown> {
|
|
||||||
const matcher = new Minimatch(pattern, { dot: false });
|
|
||||||
const results: string[] = [];
|
|
||||||
|
|
||||||
function walk(dir: string): void {
|
|
||||||
if (results.length >= 200) return;
|
|
||||||
let entries: fs.Dirent[];
|
|
||||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
||||||
for (const e of entries) {
|
|
||||||
if (EXCLUDED.has(e.name)) continue;
|
|
||||||
const abs = path.join(dir, e.name);
|
|
||||||
const rel = path.relative(workspaceRoot, abs).split(path.sep).join('/');
|
|
||||||
if (e.isDirectory()) {
|
|
||||||
walk(abs);
|
|
||||||
} else if (matcher.match(rel)) {
|
|
||||||
results.push(rel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
walk(workspaceRoot);
|
|
||||||
return { files: results, truncated: results.length >= 200 };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function searchCode(
|
|
||||||
workspaceRoot: string,
|
|
||||||
query: string,
|
|
||||||
fileExtensions?: string[]
|
|
||||||
): Promise<unknown> {
|
|
||||||
const globPatterns = fileExtensions?.map(e => `*.${e}`) || [];
|
|
||||||
const rgArgs = ['--line-number', '--no-heading', '--color=never', '--max-count=30'];
|
|
||||||
for (const ex of EXCLUDED) { rgArgs.push('--glob', `!${ex}`); }
|
|
||||||
if (globPatterns.length > 0) { for (const g of globPatterns) rgArgs.push('--glob', g); }
|
|
||||||
rgArgs.push('--fixed-strings', query, workspaceRoot);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { stdout } = await execAsync(`rg ${rgArgs.map(a => `'${a}'`).join(' ')}`, {
|
|
||||||
cwd: workspaceRoot, timeout: 15000,
|
|
||||||
});
|
|
||||||
return stdout.trim().split('\n').filter(Boolean).map(line => {
|
|
||||||
const m = line.match(/^(.+?):(\d+):(.*)$/);
|
|
||||||
if (!m) return null;
|
|
||||||
return {
|
|
||||||
file: path.relative(workspaceRoot, m[1]).split(path.sep).join('/'),
|
|
||||||
line: parseInt(m[2]),
|
|
||||||
content: m[3].trim(),
|
|
||||||
};
|
|
||||||
}).filter(Boolean);
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err.code === 1) return []; // ripgrep exit 1 = no matches
|
|
||||||
return { error: `Search failed: ${err.message}` };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
// =============================================================================
|
|
||||||
// File-system tool registrations (in-process path used by agent-runner).
|
|
||||||
// All logic lives in ./file-api.ts.
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
import { registerTool } from './registry';
|
|
||||||
import * as api from './file-api';
|
|
||||||
|
|
||||||
registerTool({
|
|
||||||
name: 'read_file',
|
|
||||||
description: 'Read the complete content of a file in the workspace. Always read before editing.',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
path: { type: 'string', description: 'Relative path from workspace root (e.g. "src/index.ts")' }
|
|
||||||
},
|
|
||||||
required: ['path']
|
|
||||||
},
|
|
||||||
async handler(args, ctx) {
|
|
||||||
return api.readFile(ctx.workspaceRoot, String(args.path));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
registerTool({
|
|
||||||
name: 'write_file',
|
|
||||||
description: 'Write complete content to a file. Creates parent directories if needed. Overwrites existing files.',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
path: { type: 'string', description: 'Relative path from workspace root' },
|
|
||||||
content: { type: 'string', description: 'Complete new file content' }
|
|
||||||
},
|
|
||||||
required: ['path', 'content']
|
|
||||||
},
|
|
||||||
async handler(args, ctx) {
|
|
||||||
return api.writeFile(ctx.workspaceRoot, String(args.path), String(args.content));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
registerTool({
|
|
||||||
name: 'replace_in_file',
|
|
||||||
description: 'Replace an exact string in a file. The old_content must match character-for-character. Read the file first.',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
path: { type: 'string', description: 'Relative path from workspace root' },
|
|
||||||
old_content: { type: 'string', description: 'Exact text to replace' },
|
|
||||||
new_content: { type: 'string', description: 'Replacement text' }
|
|
||||||
},
|
|
||||||
required: ['path', 'old_content', 'new_content']
|
|
||||||
},
|
|
||||||
async handler(args, ctx) {
|
|
||||||
return api.replaceInFile(
|
|
||||||
ctx.workspaceRoot,
|
|
||||||
String(args.path),
|
|
||||||
String(args.old_content),
|
|
||||||
String(args.new_content)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
registerTool({
|
|
||||||
name: 'list_directory',
|
|
||||||
description: 'List files and subdirectories in a directory. Directories have trailing "/".',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
path: { type: 'string', description: 'Relative path from workspace root. Use "." for root.' }
|
|
||||||
},
|
|
||||||
required: ['path']
|
|
||||||
},
|
|
||||||
async handler(args, ctx) {
|
|
||||||
return api.listDirectory(ctx.workspaceRoot, String(args.path));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
registerTool({
|
|
||||||
name: 'find_files',
|
|
||||||
description: 'Find files matching a glob pattern in the workspace. Returns up to 200 relative paths.',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
pattern: { type: 'string', description: 'Glob pattern e.g. "**/*.ts", "src/**/*.test.js"' }
|
|
||||||
},
|
|
||||||
required: ['pattern']
|
|
||||||
},
|
|
||||||
async handler(args, ctx) {
|
|
||||||
return api.findFiles(ctx.workspaceRoot, String(args.pattern));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
registerTool({
|
|
||||||
name: 'search_code',
|
|
||||||
description: 'Search file contents for a string or regex pattern. Returns file path, line number, and matching line.',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
query: { type: 'string', description: 'Search term or regex' },
|
|
||||||
file_extensions: {
|
|
||||||
type: 'array',
|
|
||||||
items: { type: 'string' },
|
|
||||||
description: 'Optional: limit to these extensions e.g. ["ts","js"]'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
required: ['query']
|
|
||||||
},
|
|
||||||
async handler(args, ctx) {
|
|
||||||
const exts = Array.isArray(args.file_extensions) ? (args.file_extensions as string[]) : undefined;
|
|
||||||
return api.searchCode(ctx.workspaceRoot, String(args.query), exts);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
// =============================================================================
|
|
||||||
// Pure git API — no ToolContext coupling.
|
|
||||||
// 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";
|
|
||||||
|
|
||||||
const execAsync = util.promisify(cp.exec);
|
|
||||||
|
|
||||||
import fs from "fs";
|
|
||||||
import path from "path";
|
|
||||||
|
|
||||||
export interface GitPushConfig {
|
|
||||||
apiUrl: string;
|
|
||||||
apiToken: string;
|
|
||||||
username: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function gitCommitAndPush(
|
|
||||||
workspaceRoot: string,
|
|
||||||
message: string,
|
|
||||||
cfg: GitPushConfig,
|
|
||||||
): Promise<unknown> {
|
|
||||||
const cwd = workspaceRoot;
|
|
||||||
const { apiUrl, apiToken, username } = cfg;
|
|
||||||
|
|
||||||
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.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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}` };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
// =============================================================================
|
|
||||||
// Git commit-and-push tool registration. Logic lives in ./git-api.ts.
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
import { registerTool } from './registry';
|
|
||||||
import * as api from './git-api';
|
|
||||||
|
|
||||||
registerTool({
|
|
||||||
name: 'git_commit_and_push',
|
|
||||||
description: 'Stage all changes, commit with a message, and push to the remote. Call this when work is complete.',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
message: { type: 'string', description: 'Commit message describing the changes made' }
|
|
||||||
},
|
|
||||||
required: ['message']
|
|
||||||
},
|
|
||||||
async handler(args, ctx) {
|
|
||||||
return api.gitCommitAndPush(ctx.workspaceRoot, String(args.message), {
|
|
||||||
apiUrl: ctx.gitea.apiUrl,
|
|
||||||
apiToken: ctx.gitea.apiToken,
|
|
||||||
username: ctx.gitea.username,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
// =============================================================================
|
|
||||||
// Pure Gitea API — no ToolContext coupling, no registry coupling.
|
|
||||||
//
|
|
||||||
// Takes a plain { apiUrl, apiToken, username } config. Security guardrails
|
|
||||||
// (PROTECTED_GITEA_REPOS, assertGiteaWritable) are enforced inside each
|
|
||||||
// function so every caller gets the same protection.
|
|
||||||
//
|
|
||||||
// Consumed by:
|
|
||||||
// - tools/gitea.ts (in-process registry used by agent-runner loop)
|
|
||||||
// - mcp/gitea-server.ts (stdio MCP server exposed to any MCP client)
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
import { PROTECTED_GITEA_REPOS, assertGiteaWritable } from './security';
|
|
||||||
|
|
||||||
export interface GiteaConfig {
|
|
||||||
apiUrl: string;
|
|
||||||
apiToken: string;
|
|
||||||
username?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type GiteaError = { error: string };
|
|
||||||
|
|
||||||
async function giteaFetch(
|
|
||||||
cfg: GiteaConfig,
|
|
||||||
path: string,
|
|
||||||
method: string = 'GET',
|
|
||||||
body?: unknown
|
|
||||||
): Promise<unknown> {
|
|
||||||
const res = await fetch(`${cfg.apiUrl}/api/v1${path}`, {
|
|
||||||
method,
|
|
||||||
headers: {
|
|
||||||
'Authorization': `token ${cfg.apiToken}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Accept': 'application/json'
|
|
||||||
},
|
|
||||||
body: body ? JSON.stringify(body) : undefined
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
return { error: `Gitea API error: ${res.status} ${res.statusText}` } satisfies GiteaError;
|
|
||||||
}
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Public API — 1:1 with tool surface
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface CreateIssueInput {
|
|
||||||
repo: string;
|
|
||||||
title: string;
|
|
||||||
body: string;
|
|
||||||
labels?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createIssue(cfg: GiteaConfig, input: CreateIssueInput): Promise<unknown> {
|
|
||||||
assertGiteaWritable(input.repo);
|
|
||||||
return giteaFetch(cfg, `/repos/${input.repo}/issues`, 'POST', {
|
|
||||||
title: input.title,
|
|
||||||
body: input.body,
|
|
||||||
labels: input.labels,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listIssues(cfg: GiteaConfig, repo: string, state: string = 'open'): Promise<unknown> {
|
|
||||||
return giteaFetch(cfg, `/repos/${repo}/issues?state=${state}&limit=20`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function closeIssue(cfg: GiteaConfig, repo: string, issueNumber: number): Promise<unknown> {
|
|
||||||
assertGiteaWritable(repo);
|
|
||||||
return giteaFetch(cfg, `/repos/${repo}/issues/${issueNumber}`, 'PATCH', { state: 'closed' });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listRepos(cfg: GiteaConfig): Promise<unknown> {
|
|
||||||
const res = await fetch(`${cfg.apiUrl}/api/v1/repos/search?limit=50`, {
|
|
||||||
headers: { 'Authorization': `token ${cfg.apiToken}` }
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
return { error: `Gitea API error: ${res.status} ${res.statusText}` } satisfies GiteaError;
|
|
||||||
}
|
|
||||||
const data = await res.json() as any;
|
|
||||||
return (data.data || [])
|
|
||||||
.filter((r: any) => !PROTECTED_GITEA_REPOS.has(r.full_name))
|
|
||||||
.map((r: any) => ({
|
|
||||||
name: r.full_name,
|
|
||||||
description: r.description,
|
|
||||||
default_branch: r.default_branch,
|
|
||||||
updated: r.updated,
|
|
||||||
stars: r.stars_count,
|
|
||||||
open_issues: r.open_issues_count,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listAllIssues(
|
|
||||||
cfg: GiteaConfig,
|
|
||||||
opts: { repo?: string; state?: string } = {}
|
|
||||||
): Promise<unknown> {
|
|
||||||
const state = opts.state || 'open';
|
|
||||||
|
|
||||||
if (opts.repo) {
|
|
||||||
if (PROTECTED_GITEA_REPOS.has(opts.repo)) {
|
|
||||||
return {
|
|
||||||
error: `SECURITY: "${opts.repo}" is a protected Vibn platform repo. Agents cannot access its issues.`
|
|
||||||
} satisfies GiteaError;
|
|
||||||
}
|
|
||||||
return giteaFetch(cfg, `/repos/${opts.repo}/issues?state=${state}&limit=20`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch across all non-protected repos (cap at 10 repos to bound request count)
|
|
||||||
const reposRes = await fetch(`${cfg.apiUrl}/api/v1/repos/search?limit=50`, {
|
|
||||||
headers: { 'Authorization': `token ${cfg.apiToken}` }
|
|
||||||
});
|
|
||||||
if (!reposRes.ok) {
|
|
||||||
return { error: `Gitea API error: ${reposRes.status} ${reposRes.statusText}` } satisfies GiteaError;
|
|
||||||
}
|
|
||||||
const reposData = await reposRes.json() as any;
|
|
||||||
const repos = (reposData.data || []).filter((r: any) => !PROTECTED_GITEA_REPOS.has(r.full_name));
|
|
||||||
|
|
||||||
const allIssues: unknown[] = [];
|
|
||||||
for (const r of repos.slice(0, 10)) {
|
|
||||||
const issues = await giteaFetch(cfg, `/repos/${r.full_name}/issues?state=${state}&limit=10`) as any[];
|
|
||||||
if (Array.isArray(issues)) {
|
|
||||||
allIssues.push(...issues.map((i: any) => ({
|
|
||||||
repo: r.full_name,
|
|
||||||
number: i.number,
|
|
||||||
title: i.title,
|
|
||||||
state: i.state,
|
|
||||||
labels: i.labels?.map((l: any) => l.name),
|
|
||||||
created: i.created_at,
|
|
||||||
})));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return allIssues;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function readRepoFile(cfg: GiteaConfig, repo: string, filePath: string): Promise<unknown> {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${cfg.apiUrl}/api/v1/repos/${repo}/contents/${filePath}`, {
|
|
||||||
headers: { 'Authorization': `token ${cfg.apiToken}` }
|
|
||||||
});
|
|
||||||
if (!res.ok) return { error: `File not found: ${filePath} in ${repo}` } satisfies GiteaError;
|
|
||||||
const data = await res.json() as any;
|
|
||||||
const content = Buffer.from(data.content, 'base64').toString('utf8');
|
|
||||||
return { repo, path: filePath, content };
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
error: `Failed to read ${filePath}: ${err instanceof Error ? err.message : String(err)}`
|
|
||||||
} satisfies GiteaError;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
// =============================================================================
|
|
||||||
// Gitea tool registrations (in-process path used by agent-runner).
|
|
||||||
//
|
|
||||||
// All logic lives in ./gitea-api.ts so the MCP server (src/mcp/gitea-server.ts)
|
|
||||||
// and this in-process registry call the exact same code path. Keep this file
|
|
||||||
// purely about: (a) surface-shape for the LLM (name/description/parameters),
|
|
||||||
// (b) mapping ctx.gitea → GiteaConfig. No business logic here.
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
import { registerTool } from './registry';
|
|
||||||
import * as api from './gitea-api';
|
|
||||||
|
|
||||||
registerTool({
|
|
||||||
name: 'gitea_create_issue',
|
|
||||||
description: 'Create a new issue in a Gitea repository.',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
repo: { type: 'string', description: 'Repository in "owner/name" format' },
|
|
||||||
title: { type: 'string', description: 'Issue title' },
|
|
||||||
body: { type: 'string', description: 'Issue body (markdown)' },
|
|
||||||
labels: { type: 'array', items: { type: 'string' }, description: 'Optional label names' }
|
|
||||||
},
|
|
||||||
required: ['repo', 'title', 'body']
|
|
||||||
},
|
|
||||||
async handler(args, ctx) {
|
|
||||||
return api.createIssue(ctx.gitea, {
|
|
||||||
repo: String(args.repo),
|
|
||||||
title: String(args.title),
|
|
||||||
body: String(args.body),
|
|
||||||
labels: Array.isArray(args.labels) ? (args.labels as string[]) : undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
registerTool({
|
|
||||||
name: 'gitea_list_issues',
|
|
||||||
description: 'List open issues in a Gitea repository.',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
repo: { type: 'string', description: 'Repository in "owner/name" format' },
|
|
||||||
state: { type: 'string', description: '"open", "closed", or "all". Default: "open"' }
|
|
||||||
},
|
|
||||||
required: ['repo']
|
|
||||||
},
|
|
||||||
async handler(args, ctx) {
|
|
||||||
return api.listIssues(ctx.gitea, String(args.repo), String(args.state || 'open'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
registerTool({
|
|
||||||
name: 'gitea_close_issue',
|
|
||||||
description: 'Close an issue in a Gitea repository.',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
repo: { type: 'string', description: 'Repository in "owner/name" format' },
|
|
||||||
issue_number: { type: 'number', description: 'Issue number to close' }
|
|
||||||
},
|
|
||||||
required: ['repo', 'issue_number']
|
|
||||||
},
|
|
||||||
async handler(args, ctx) {
|
|
||||||
return api.closeIssue(ctx.gitea, String(args.repo), Number(args.issue_number));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
registerTool({
|
|
||||||
name: 'list_repos',
|
|
||||||
description: 'List all Git repositories in the Gitea organization. Returns repo names, descriptions, and last update time.',
|
|
||||||
parameters: { type: 'object', properties: {} },
|
|
||||||
async handler(_args, ctx) {
|
|
||||||
return api.listRepos(ctx.gitea);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
registerTool({
|
|
||||||
name: 'list_all_issues',
|
|
||||||
description: 'List open issues across all repos or a specific repo. Use this to understand what work is queued or in progress.',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
repo: { type: 'string', description: 'Optional: "owner/name" to scope to one repo. Omit for all repos.' },
|
|
||||||
state: { type: 'string', description: '"open", "closed", or "all". Default: "open"' }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async handler(args, ctx) {
|
|
||||||
return api.listAllIssues(ctx.gitea, {
|
|
||||||
repo: args.repo ? String(args.repo) : undefined,
|
|
||||||
state: args.state ? String(args.state) : undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
registerTool({
|
|
||||||
name: 'read_repo_file',
|
|
||||||
description: 'Read a file from any Gitea repository without cloning it. Useful for understanding project structure.',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
repo: { type: 'string', description: 'Repo in "owner/name" format' },
|
|
||||||
path: { type: 'string', description: 'File path within the repo (e.g. "src/app/page.tsx")' }
|
|
||||||
},
|
|
||||||
required: ['repo', 'path']
|
|
||||||
},
|
|
||||||
async handler(args, ctx) {
|
|
||||||
return api.readRepoFile(ctx.gitea, String(args.repo), String(args.path));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,17 +1,3 @@
|
|||||||
// Import domain files first — side effects register each tool into the registry.
|
export * from './context';
|
||||||
// Order determines ALL_TOOLS array order (informational only).
|
export * from './registry';
|
||||||
import './file';
|
export * from './mcp-client';
|
||||||
import './shell';
|
|
||||||
import './git';
|
|
||||||
import './gitea';
|
|
||||||
import './coolify';
|
|
||||||
import './agent';
|
|
||||||
import './memory';
|
|
||||||
import './skills';
|
|
||||||
import './prd';
|
|
||||||
import './search';
|
|
||||||
|
|
||||||
// Re-export the public API — identical surface to the old tools.ts
|
|
||||||
export { ALL_TOOLS, executeTool, ToolDefinition } from './registry';
|
|
||||||
export { ToolContext, MemoryUpdate } from './context';
|
|
||||||
export { PROTECTED_GITEA_REPOS, PROTECTED_COOLIFY_PROJECT, PROTECTED_COOLIFY_APPS, assertGiteaWritable, assertCoolifyDeployable } from './security';
|
|
||||||
|
|||||||
38
vibn-agent-runner/src/tools/mcp-client.ts
Normal file
38
vibn-agent-runner/src/tools/mcp-client.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { ToolContext } from './context';
|
||||||
|
import { VIBN_TOOL_DEFINITIONS } from './vibn-tools';
|
||||||
|
|
||||||
|
export const ALL_TOOLS = VIBN_TOOL_DEFINITIONS;
|
||||||
|
|
||||||
|
export async function executeTool(
|
||||||
|
name: string,
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
ctx: ToolContext
|
||||||
|
): Promise<unknown> {
|
||||||
|
// Some tools might just be executed locally by the Runner in the future,
|
||||||
|
// but right now we forward all non-github/http tools to the frontend MCP.
|
||||||
|
|
||||||
|
// Convert underscore to dot format as expected by MCP
|
||||||
|
const action = name.replace(/_/g, ".");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${ctx.vibnApiUrl}/api/mcp`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": `Bearer ${ctx.mcpToken}`,
|
||||||
|
...(ctx.projectId ? { "X-Vibn-Project-Id": ctx.projectId } : {})
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ action, params: args }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return { error: data.error || `HTTP ${response.status}: ${response.statusText}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.result || data;
|
||||||
|
} catch (e) {
|
||||||
|
return { error: `Failed to execute tool ${name} via MCP: ${e instanceof Error ? e.message : String(e)}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
// =============================================================================
|
|
||||||
// Pure memory API. The in-process agent-runner collects memory updates into an
|
|
||||||
// array on the ToolContext (ctx.memoryUpdates) so the supervisor loop can
|
|
||||||
// persist them at end-of-turn. MCP clients don't share that array, so the MCP
|
|
||||||
// server keeps its own module-level store keyed by an optional sessionKey.
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export interface MemoryEntry {
|
|
||||||
key: string;
|
|
||||||
type: string; // "tech_stack" | "decision" | "feature" | "goal" | "constraint" | "note"
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MemoryInput {
|
|
||||||
key: string;
|
|
||||||
type: string;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toEntry(input: MemoryInput): MemoryEntry {
|
|
||||||
return { key: input.key, type: input.type, value: input.value };
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------
|
|
||||||
// In-memory store used by the MCP server path (the in-process path
|
|
||||||
// appends directly to ctx.memoryUpdates and ignores this store).
|
|
||||||
// -------------------------------------------------------------------
|
|
||||||
|
|
||||||
const memoryStore = new Map<string, MemoryEntry[]>();
|
|
||||||
|
|
||||||
export function saveMemoryToStore(sessionKey: string, input: MemoryInput): { saved: true; entry: MemoryEntry } {
|
|
||||||
const entry = toEntry(input);
|
|
||||||
const list = memoryStore.get(sessionKey) ?? [];
|
|
||||||
list.push(entry);
|
|
||||||
memoryStore.set(sessionKey, list);
|
|
||||||
return { saved: true, entry };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listMemoryFromStore(sessionKey: string): MemoryEntry[] {
|
|
||||||
return [...(memoryStore.get(sessionKey) ?? [])];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearMemoryStore(sessionKey: string): void {
|
|
||||||
memoryStore.delete(sessionKey);
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
// =============================================================================
|
|
||||||
// save_memory tool registration. Logic lives in ./memory-api.ts.
|
|
||||||
// In-process: appends to ctx.memoryUpdates so the supervisor loop can persist
|
|
||||||
// at end-of-turn. MCP server path uses memory-api's internal store.
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
import { registerTool } from './registry';
|
|
||||||
import * as api from './memory-api';
|
|
||||||
|
|
||||||
registerTool({
|
|
||||||
name: 'save_memory',
|
|
||||||
description: 'Persist an important fact about this project to long-term memory. Use this to save decisions, tech stack choices, feature descriptions, constraints, or goals so they are remembered across conversations.',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
key: { type: 'string', description: 'Short unique label (e.g. "primary_language", "auth_strategy", "deploy_target")' },
|
|
||||||
type: {
|
|
||||||
type: 'string',
|
|
||||||
enum: ['tech_stack', 'decision', 'feature', 'goal', 'constraint', 'note'],
|
|
||||||
description: 'Category of the memory item'
|
|
||||||
},
|
|
||||||
value: { type: 'string', description: 'The fact to remember (1-3 sentences)' }
|
|
||||||
},
|
|
||||||
required: ['key', 'type', 'value']
|
|
||||||
},
|
|
||||||
async handler(args, ctx) {
|
|
||||||
const entry = api.toEntry({
|
|
||||||
key: String(args.key),
|
|
||||||
type: String(args.type),
|
|
||||||
value: String(args.value),
|
|
||||||
});
|
|
||||||
ctx.memoryUpdates.push(entry);
|
|
||||||
return { saved: true, key: entry.key, type: entry.type };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
// =============================================================================
|
|
||||||
// Pure PRD API. The store is module-level so atlas.ts can inspect it after each
|
|
||||||
// turn (it imports `prdStore` from prd.ts which re-exports from here). Keep
|
|
||||||
// this module side-effect-free otherwise.
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/** sessionKey (workspaceRoot) → PRD markdown */
|
|
||||||
export const prdStore = new Map<string, string>();
|
|
||||||
|
|
||||||
export function finalizePrd(sessionKey: string, content: string): { saved: true; message: string } {
|
|
||||||
prdStore.set(sessionKey, content);
|
|
||||||
return {
|
|
||||||
saved: true,
|
|
||||||
message: 'PRD saved. Let the user know their product requirements document is ready and the platform will now architect the technical solution.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPrd(sessionKey: string): string | null {
|
|
||||||
return prdStore.get(sessionKey) ?? null;
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
// =============================================================================
|
|
||||||
// finalize_prd tool registration. Logic + store live in ./prd-api.ts.
|
|
||||||
// We re-export `prdStore` so existing imports (atlas.ts) continue to work.
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
import { registerTool } from './registry';
|
|
||||||
import * as api from './prd-api';
|
|
||||||
|
|
||||||
export { prdStore } from './prd-api';
|
|
||||||
|
|
||||||
registerTool({
|
|
||||||
name: 'finalize_prd',
|
|
||||||
description: 'Call this when you have finished writing the complete PRD document. Pass the full PRD markdown as content. This saves the document and signals to the user that discovery is complete.',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
content: {
|
|
||||||
type: 'string',
|
|
||||||
description: 'The complete PRD document in markdown format'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
required: ['content']
|
|
||||||
},
|
|
||||||
async handler(args, ctx) {
|
|
||||||
// Store against workspaceRoot as a unique key (each project has its own workspace)
|
|
||||||
return api.finalizePrd(ctx.workspaceRoot, String(args.content));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,34 +1,7 @@
|
|||||||
import { ToolContext } from './context';
|
import { ToolContext } from './context';
|
||||||
|
import { ALL_TOOLS } from './mcp-client';
|
||||||
|
|
||||||
export interface ToolDefinition {
|
export { ALL_TOOLS };
|
||||||
name: string;
|
// Legacy exports to satisfy imports in agent-runner
|
||||||
description: string;
|
export const executeTool = require('./mcp-client').executeTool;
|
||||||
parameters: Record<string, unknown>;
|
export type ToolDefinition = any;
|
||||||
/** Implementation — called by executeTool(). Not sent to the LLM. */
|
|
||||||
handler: (args: Record<string, unknown>, ctx: ToolContext) => Promise<unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Live registry — grows as domain files are imported. */
|
|
||||||
const _registry = new Map<string, ToolDefinition>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mutable array kept in sync with the registry.
|
|
||||||
* Used by agents.ts to pick tool subsets by name (backwards-compatible with ALL_TOOLS).
|
|
||||||
*/
|
|
||||||
export const ALL_TOOLS: ToolDefinition[] = [];
|
|
||||||
|
|
||||||
export function registerTool(tool: ToolDefinition): void {
|
|
||||||
_registry.set(tool.name, tool);
|
|
||||||
ALL_TOOLS.push(tool);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Dispatch a tool call by name — O(1) map lookup, no switch needed. */
|
|
||||||
export async function executeTool(
|
|
||||||
name: string,
|
|
||||||
args: Record<string, unknown>,
|
|
||||||
ctx: ToolContext
|
|
||||||
): Promise<unknown> {
|
|
||||||
const tool = _registry.get(name);
|
|
||||||
if (!tool) return { error: `Unknown tool: ${name}` };
|
|
||||||
return tool.handler(args, ctx);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
// =============================================================================
|
|
||||||
// Pure web-search API via DuckDuckGo HTML endpoint. No API key required.
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export async function webSearch(query: string): Promise<unknown> {
|
|
||||||
const trimmed = query.trim();
|
|
||||||
if (!trimmed) return { error: 'No query provided' };
|
|
||||||
|
|
||||||
const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(trimmed)}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(url, {
|
|
||||||
headers: {
|
|
||||||
'User-Agent': 'Mozilla/5.0 (compatible; VIBN-Atlas/1.0)',
|
|
||||||
Accept: 'text/html',
|
|
||||||
},
|
|
||||||
signal: AbortSignal.timeout(15_000),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
return { error: `Search failed with status ${res.status}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
const html = await res.text();
|
|
||||||
|
|
||||||
const titles: string[] = [];
|
|
||||||
for (const m of html.matchAll(/class="result__a"[^>]*href="[^"]*"[^>]*>(.*?)<\/a>/gs)) {
|
|
||||||
const title = m[1].replace(/<[^>]+>/g, '').trim();
|
|
||||||
if (title) titles.push(title);
|
|
||||||
}
|
|
||||||
|
|
||||||
const snippets: string[] = [];
|
|
||||||
for (const m of html.matchAll(/class="result__snippet"[^>]*>(.*?)<\/a>/gs)) {
|
|
||||||
const snippet = m[1].replace(/<[^>]+>/g, '').trim();
|
|
||||||
if (snippet) snippets.push(snippet);
|
|
||||||
}
|
|
||||||
|
|
||||||
const count = Math.min(6, Math.max(titles.length, snippets.length));
|
|
||||||
const results: string[] = [];
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
const title = titles[i] || '';
|
|
||||||
const snippet = snippets[i] || '';
|
|
||||||
if (title || snippet) results.push(`**${title}**\n${snippet}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (results.length === 0) return { error: 'No results found' };
|
|
||||||
|
|
||||||
const text = results.join('\n\n');
|
|
||||||
const truncated = text.length > 5000 ? text.slice(0, 5000) + '\n\n[...results truncated]' : text;
|
|
||||||
return { query: trimmed, results: truncated };
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
return { error: `Search request failed: ${message}` };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
// =============================================================================
|
|
||||||
// web_search tool registration. Logic lives in ./search-api.ts.
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
import { registerTool } from './registry';
|
|
||||||
import * as api from './search-api';
|
|
||||||
|
|
||||||
registerTool({
|
|
||||||
name: 'web_search',
|
|
||||||
description: 'Search the web for current information. Use this to research competitors, market trends, pricing models, existing solutions, technology choices, or any topic the user mentions that would benefit from real-world context. Returns a summary of top search results.',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
query: {
|
|
||||||
type: 'string',
|
|
||||||
description: 'The search query. Be specific — e.g. "SaaS project management tools pricing 2024" rather than just "project management".'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
required: ['query']
|
|
||||||
},
|
|
||||||
async handler(args) {
|
|
||||||
return api.webSearch(String(args.query));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
// =============================================================================
|
|
||||||
// SECURITY GUARDRAILS — Protected VIBN Platform Resources
|
|
||||||
//
|
|
||||||
// These repos and Coolify resources belong to the Vibn platform itself.
|
|
||||||
// Agents must never be allowed to push code or trigger deployments here.
|
|
||||||
// Read-only operations (list, read file, get status) are still permitted
|
|
||||||
// so agents can observe platform state, but all mutations are blocked.
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/** Gitea repos agents can NEVER push to, commit to, or write issues on. */
|
|
||||||
export const PROTECTED_GITEA_REPOS = new Set([
|
|
||||||
'mark/vibn-frontend',
|
|
||||||
'mark/vibn-agent-runner',
|
|
||||||
'mark/vibn-api',
|
|
||||||
'mark/master-ai',
|
|
||||||
]);
|
|
||||||
|
|
||||||
/** Coolify project UUID for the VIBN platform — agents cannot deploy here. */
|
|
||||||
export const PROTECTED_COOLIFY_PROJECT = 'f4owwggokksgw0ogo0844os0';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Specific Coolify app UUIDs that must never be deployed by an agent.
|
|
||||||
* Belt-and-suspenders check in case the project UUID filter is bypassed.
|
|
||||||
*/
|
|
||||||
export const PROTECTED_COOLIFY_APPS = new Set([
|
|
||||||
'y4cscsc8s08c8808go0448s0', // vibn-frontend
|
|
||||||
'kggs4ogckc0w8ggwkkk88kck', // vibn-postgres
|
|
||||||
'o4wwck0g0c04wgoo4g4s0004', // gitea
|
|
||||||
]);
|
|
||||||
|
|
||||||
export function assertGiteaWritable(repo: string): void {
|
|
||||||
if (PROTECTED_GITEA_REPOS.has(repo)) {
|
|
||||||
throw new Error(
|
|
||||||
`SECURITY: Repo "${repo}" is a protected Vibn platform repo. ` +
|
|
||||||
`Agents cannot push code or modify issues in this repository.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function assertCoolifyDeployable(appUuid: string): void {
|
|
||||||
if (PROTECTED_COOLIFY_APPS.has(appUuid)) {
|
|
||||||
throw new Error(
|
|
||||||
`SECURITY: App "${appUuid}" is a protected Vibn platform application. ` +
|
|
||||||
`Agents cannot trigger deployments for this application.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
// =============================================================================
|
|
||||||
// Pure shell execution API — no ToolContext coupling.
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
import * as cp from 'child_process';
|
|
||||||
import * as util from 'util';
|
|
||||||
import { safeResolve } from './utils';
|
|
||||||
|
|
||||||
const execAsync = util.promisify(cp.exec);
|
|
||||||
|
|
||||||
const BLOCKED_COMMANDS = ['rm -rf /', 'mkfs', ':(){:|:&};:'];
|
|
||||||
|
|
||||||
export async function executeCommand(
|
|
||||||
workspaceRoot: string,
|
|
||||||
command: string,
|
|
||||||
workingDirectory?: string
|
|
||||||
): Promise<unknown> {
|
|
||||||
if (BLOCKED_COMMANDS.some(b => command.includes(b))) {
|
|
||||||
return { error: 'Command blocked for safety.' };
|
|
||||||
}
|
|
||||||
const cwd = workingDirectory ? safeResolve(workspaceRoot, workingDirectory) : workspaceRoot;
|
|
||||||
try {
|
|
||||||
const { stdout, stderr } = await execAsync(command, {
|
|
||||||
cwd, timeout: 120_000, maxBuffer: 1024 * 1024,
|
|
||||||
});
|
|
||||||
return { exitCode: 0, stdout: stdout.trim(), stderr: stderr.trim() };
|
|
||||||
} catch (err: any) {
|
|
||||||
return {
|
|
||||||
exitCode: err.code,
|
|
||||||
stdout: (err.stdout || '').trim(),
|
|
||||||
stderr: (err.stderr || '').trim(),
|
|
||||||
error: err.message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
// =============================================================================
|
|
||||||
// Shell execution tool registration. Logic lives in ./shell-api.ts.
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
import { registerTool } from './registry';
|
|
||||||
import * as api from './shell-api';
|
|
||||||
|
|
||||||
registerTool({
|
|
||||||
name: 'execute_command',
|
|
||||||
description: 'Run a shell command in the workspace and return stdout + stderr. 120s timeout. Use for: npm install, npm test, git status, building, etc.',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
command: { type: 'string', description: 'Shell command to run' },
|
|
||||||
working_directory: { type: 'string', description: 'Optional: relative subdirectory to run in' }
|
|
||||||
},
|
|
||||||
required: ['command']
|
|
||||||
},
|
|
||||||
async handler(args, ctx) {
|
|
||||||
return api.executeCommand(
|
|
||||||
ctx.workspaceRoot,
|
|
||||||
String(args.command),
|
|
||||||
args.working_directory ? String(args.working_directory) : undefined
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
// =============================================================================
|
|
||||||
// Pure skills API. Skills live in a Gitea repo at .skills/<name>/SKILL.md.
|
|
||||||
// Takes a GiteaReadConfig so it can read from any Gitea instance (in-process
|
|
||||||
// agent passes ctx.gitea, MCP server loads from env).
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
const SKILL_FILE = 'SKILL.md';
|
|
||||||
const SKILLS_DIR = '.skills';
|
|
||||||
|
|
||||||
export interface GiteaReadConfig {
|
|
||||||
apiUrl: string;
|
|
||||||
apiToken: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function giteaGetContents(
|
|
||||||
cfg: GiteaReadConfig,
|
|
||||||
repo: string,
|
|
||||||
filePath: string
|
|
||||||
): Promise<any> {
|
|
||||||
const res = await fetch(`${cfg.apiUrl}/api/v1/repos/${repo}/contents/${filePath}`, {
|
|
||||||
headers: { Authorization: `token ${cfg.apiToken}` },
|
|
||||||
});
|
|
||||||
if (!res.ok) return null;
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listSkills(cfg: GiteaReadConfig, repo: string): Promise<unknown> {
|
|
||||||
const contents = await giteaGetContents(cfg, repo, SKILLS_DIR);
|
|
||||||
if (!contents || !Array.isArray(contents)) {
|
|
||||||
return { skills: [], message: `No .skills/ directory found in ${repo}` };
|
|
||||||
}
|
|
||||||
const skills = contents
|
|
||||||
.filter((entry: any) => entry.type === 'dir')
|
|
||||||
.map((entry: any) => ({ name: entry.name, path: entry.path }));
|
|
||||||
return { repo, skills };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getSkill(cfg: GiteaReadConfig, repo: string, skillName: string): Promise<unknown> {
|
|
||||||
const filePath = `${SKILLS_DIR}/${skillName}/${SKILL_FILE}`;
|
|
||||||
const file = await giteaGetContents(cfg, repo, filePath);
|
|
||||||
if (!file || !file.content) {
|
|
||||||
return { error: `Skill "${skillName}" not found in ${repo}. Try list_skills to see available skills.` };
|
|
||||||
}
|
|
||||||
const content = Buffer.from(file.content, 'base64').toString('utf8');
|
|
||||||
// Strip YAML frontmatter if present, return just the markdown body
|
|
||||||
const body = content.replace(/^---[\s\S]*?---\s*/m, '').trim();
|
|
||||||
return { repo, skill: skillName, content: body };
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
// =============================================================================
|
|
||||||
// Skills tool registrations. Logic lives in ./skills-api.ts.
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
import { registerTool } from './registry';
|
|
||||||
import * as api from './skills-api';
|
|
||||||
|
|
||||||
registerTool({
|
|
||||||
name: 'list_skills',
|
|
||||||
description: `List available skills for a project repo. Skills are stored in .skills/<name>/SKILL.md and provide reusable instructions the agent should follow (e.g. deploy process, test commands, code conventions).`,
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
repo: { type: 'string', description: 'Repo in "owner/name" format' }
|
|
||||||
},
|
|
||||||
required: ['repo']
|
|
||||||
},
|
|
||||||
async handler(args, ctx) {
|
|
||||||
return api.listSkills(
|
|
||||||
{ apiUrl: ctx.gitea.apiUrl, apiToken: ctx.gitea.apiToken },
|
|
||||||
String(args.repo),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
registerTool({
|
|
||||||
name: 'get_skill',
|
|
||||||
description: `Read the full content of a specific skill from a project repo. Call list_skills first to see what's available. Use this before spawning agents so they have the relevant project-specific instructions.`,
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
repo: { type: 'string', description: 'Repo in "owner/name" format' },
|
|
||||||
skill_name: { type: 'string', description: 'Skill name (directory name inside .skills/)' }
|
|
||||||
},
|
|
||||||
required: ['repo', 'skill_name']
|
|
||||||
},
|
|
||||||
async handler(args, ctx) {
|
|
||||||
return api.getSkill(
|
|
||||||
{ apiUrl: ctx.gitea.apiUrl, apiToken: ctx.gitea.apiToken },
|
|
||||||
String(args.repo),
|
|
||||||
String(args.skill_name),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
/** Directory names to skip when walking or listing workspaces. */
|
|
||||||
export const EXCLUDED = new Set(['node_modules', '.git', 'dist', 'build', 'lib', '.cache', 'coverage']);
|
|
||||||
|
|
||||||
/** Resolve a relative path safely within a workspace root — throws if it tries to escape. */
|
|
||||||
export function safeResolve(root: string, rel: string): string {
|
|
||||||
const resolved = path.resolve(root, rel);
|
|
||||||
if (!resolved.startsWith(path.resolve(root))) {
|
|
||||||
throw new Error(`Path escapes workspace: ${rel}`);
|
|
||||||
}
|
|
||||||
return resolved;
|
|
||||||
}
|
|
||||||
1975
vibn-agent-runner/src/tools/vibn-tools.ts
Normal file
1975
vibn-agent-runner/src/tools/vibn-tools.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { authSession } from "@/lib/auth/session-server";
|
import { authSession } from "@/lib/auth/session-server";
|
||||||
import { query } from "@/lib/db-postgres";
|
import { query } from "@/lib/db-postgres";
|
||||||
|
import { listWorkspaceApiKeys, mintWorkspaceApiKey, revealWorkspaceApiKey } from "@/lib/auth/workspace-auth";
|
||||||
|
|
||||||
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333";
|
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333";
|
||||||
|
|
||||||
@@ -78,6 +79,35 @@ export async function POST(
|
|||||||
);
|
);
|
||||||
const sessionId = rows[0].id;
|
const sessionId = rows[0].id;
|
||||||
|
|
||||||
|
const wsResult = await query<{ workspace_id: string }>(
|
||||||
|
`SELECT vibn_workspace_id as workspace_id FROM fs_projects WHERE id = $1 LIMIT 1`,
|
||||||
|
[projectId]
|
||||||
|
);
|
||||||
|
if (!wsResult.length) {
|
||||||
|
return NextResponse.json({ error: "Project not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
const workspaceId = wsResult[0].workspace_id;
|
||||||
|
|
||||||
|
// Grab or mint a default API key for the runner to use
|
||||||
|
let mcpToken = "";
|
||||||
|
const keys = await listWorkspaceApiKeys(workspaceId);
|
||||||
|
let defaultKey = keys.find((k: any) => k.name === 'default' && !k.revoked_at);
|
||||||
|
if (!defaultKey) {
|
||||||
|
const minted = await mintWorkspaceApiKey({ workspaceId, name: 'default', createdBy: session.user.id, scopes: ['workspace:*'] });
|
||||||
|
mcpToken = minted.token;
|
||||||
|
} else {
|
||||||
|
const revealed = await revealWorkspaceApiKey(workspaceId, defaultKey.id);
|
||||||
|
if (revealed) mcpToken = revealed.token;
|
||||||
|
else {
|
||||||
|
const minted = await mintWorkspaceApiKey({ workspaceId, name: 'default', createdBy: session.user.id, scopes: ['workspace:*'] });
|
||||||
|
mcpToken = minted.token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add VIBN_API_URL so the runner knows where to send MCP requests
|
||||||
|
const vibnApiUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
|
||||||
|
|
||||||
|
|
||||||
// Fire-and-forget: call agent-runner to start the execution loop.
|
// Fire-and-forget: call agent-runner to start the execution loop.
|
||||||
// autoApprove: true — agent commits + deploys automatically on completion.
|
// autoApprove: true — agent commits + deploys automatically on completion.
|
||||||
fetch(`${AGENT_RUNNER_URL}/agent/execute`, {
|
fetch(`${AGENT_RUNNER_URL}/agent/execute`, {
|
||||||
@@ -92,6 +122,8 @@ export async function POST(
|
|||||||
task: task.trim(),
|
task: task.trim(),
|
||||||
autoApprove: true,
|
autoApprove: true,
|
||||||
coolifyAppUuid,
|
coolifyAppUuid,
|
||||||
|
mcpToken,
|
||||||
|
vibnApiUrl
|
||||||
}),
|
}),
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
// Agent runner may not be wired yet — log but don't fail
|
// Agent runner may not be wired yet — log but don't fail
|
||||||
|
|||||||
Reference in New Issue
Block a user