feat(phase-2): Theia container bridge via docker exec
- Dockerfile: install docker-ce-cli, run as root for socket access - theia-exec.ts: container discovery (env > label > name), theiaExec(), syncToTheia() via docker cp - agent-session-runner: execute_command → docker exec into Theia (fallback to local) - agent-session-runner: syncToTheia() before auto-commit so "Open in Theia" shows agent files immediately after session completes - server.ts: compute theiaWorkspaceSubdir from giteaRepo slug, log bridge status at startup - custom_docker_run_options already set to mount /var/run/docker.sock Made-with: Cursor
This commit is contained in:
19
Dockerfile
19
Dockerfile
@@ -1,10 +1,19 @@
|
|||||||
FROM node:20-slim
|
FROM node:20-slim
|
||||||
|
|
||||||
# Install ripgrep (used by search_code tool) and git
|
# Install ripgrep, git, and docker CLI (for docker exec into Theia container)
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
ripgrep \
|
ripgrep \
|
||||||
git \
|
git \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
|
curl \
|
||||||
|
gnupg \
|
||||||
|
&& install -m 0755 -d /etc/apt/keyrings \
|
||||||
|
&& curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg \
|
||||||
|
&& chmod a+r /etc/apt/keyrings/docker.gpg \
|
||||||
|
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
|
||||||
|
> /etc/apt/sources.list.d/docker.list \
|
||||||
|
&& apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends docker-ce-cli \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -22,12 +31,8 @@ RUN npm run build
|
|||||||
# Prune dev deps after build
|
# Prune dev deps after build
|
||||||
RUN npm prune --omit=dev
|
RUN npm prune --omit=dev
|
||||||
|
|
||||||
# Create workspace dir and non-root user
|
# Create workspace dir
|
||||||
RUN useradd -r -m -s /bin/bash agent && \
|
RUN mkdir -p /workspaces
|
||||||
mkdir -p /workspaces && \
|
|
||||||
chown -R agent:agent /workspaces /app
|
|
||||||
|
|
||||||
USER agent
|
|
||||||
|
|
||||||
# Git identity for commits made by agents
|
# Git identity for commits made by agents
|
||||||
RUN git config --global user.email "agent@vibnai.com" && \
|
RUN git config --global user.email "agent@vibnai.com" && \
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { createLLM, toOAITools, LLMMessage } from './llm';
|
|||||||
import { AgentConfig } from './agents';
|
import { AgentConfig } from './agents';
|
||||||
import { executeTool, ToolContext } from './tools';
|
import { executeTool, ToolContext } from './tools';
|
||||||
import { resolvePrompt } from './prompts/loader';
|
import { resolvePrompt } from './prompts/loader';
|
||||||
|
import { isTheiaAvailable, theiaExec, syncToTheia, getTheiaContainer } from './theia-exec';
|
||||||
|
|
||||||
const MAX_TURNS = 60;
|
const MAX_TURNS = 60;
|
||||||
|
|
||||||
@@ -39,6 +40,8 @@ export interface SessionRunOptions {
|
|||||||
coolifyAppUuid?: string;
|
coolifyAppUuid?: string;
|
||||||
coolifyApiUrl?: string;
|
coolifyApiUrl?: string;
|
||||||
coolifyApiToken?: string;
|
coolifyApiToken?: string;
|
||||||
|
// Theia integration
|
||||||
|
theiaWorkspaceSubdir?: string; // e.g. "mark_sportsy" — subdir inside /home/node/workspace
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── VIBN DB bridge ────────────────────────────────────────────────────────────
|
// ── VIBN DB bridge ────────────────────────────────────────────────────────────
|
||||||
@@ -113,6 +116,17 @@ async function autoCommitAndDeploy(
|
|||||||
const giteaToken = process.env.GITEA_API_TOKEN || '';
|
const giteaToken = process.env.GITEA_API_TOKEN || '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Sync files into Theia so "Open in Theia" shows the agent's work
|
||||||
|
if (isTheiaAvailable() && opts.theiaWorkspaceSubdir) {
|
||||||
|
await emit({ ts: now(), type: 'info', text: `Syncing files to Theia…` });
|
||||||
|
const syncResult = await syncToTheia(repoRoot, opts.theiaWorkspaceSubdir);
|
||||||
|
if (syncResult.ok) {
|
||||||
|
await emit({ ts: now(), type: 'info', text: '✓ Files available in Theia — open theia.vibnai.com to inspect.' });
|
||||||
|
} else {
|
||||||
|
console.warn('[session-runner] Theia sync failed:', syncResult.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
execSync('git config user.email "agent@vibnai.com"', gitOpts);
|
execSync('git config user.email "agent@vibnai.com"', gitOpts);
|
||||||
execSync('git config user.name "VIBN Agent"', gitOpts);
|
execSync('git config user.name "VIBN Agent"', gitOpts);
|
||||||
@@ -254,7 +268,22 @@ Do NOT run git commit or git push — the platform handles committing after you
|
|||||||
|
|
||||||
let result: unknown;
|
let result: unknown;
|
||||||
try {
|
try {
|
||||||
result = await executeTool(fnName, fnArgs, ctx);
|
// Route execute_command through Theia when available so npm/node
|
||||||
|
// commands run inside Theia's persistent dev environment
|
||||||
|
if (fnName === 'execute_command' && isTheiaAvailable()) {
|
||||||
|
const command = String(fnArgs.command ?? '');
|
||||||
|
const subCwd = fnArgs.working_directory
|
||||||
|
? `${opts.theiaWorkspaceSubdir ?? ''}/${fnArgs.working_directory}`.replace(/\/+/g, '/')
|
||||||
|
: opts.theiaWorkspaceSubdir ?? undefined;
|
||||||
|
result = await theiaExec(command, subCwd ? `${process.env.THEIA_WORKSPACE ?? '/home/node/workspace'}/${subCwd}` : undefined);
|
||||||
|
if ((result as any)?.error && (result as any)?.exitCode !== 0) {
|
||||||
|
// Fallback to local execution if Theia exec fails
|
||||||
|
console.warn('[session-runner] Theia exec failed, falling back to local:', (result as any).error);
|
||||||
|
result = await executeTool(fnName, fnArgs, ctx);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result = await executeTool(fnName, fnArgs, ctx);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
result = { error: err instanceof Error ? err.message : String(err) };
|
result = { error: err instanceof Error ? err.message : String(err) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { PROTECTED_GITEA_REPOS } from './tools/security';
|
|||||||
import { orchestratorChat, listSessions, clearSession } from './orchestrator';
|
import { orchestratorChat, listSessions, clearSession } from './orchestrator';
|
||||||
import { atlasChat, listAtlasSessions, clearAtlasSession } from './atlas';
|
import { atlasChat, listAtlasSessions, clearAtlasSession } from './atlas';
|
||||||
import { LLMMessage, createLLM } from './llm';
|
import { LLMMessage, createLLM } from './llm';
|
||||||
|
import { getTheiaContainer } from './theia-exec';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
@@ -433,6 +434,13 @@ app.post('/agent/execute', async (req: Request, res: Response) => {
|
|||||||
? `Original task: ${task}\n\nFollow-up instruction: ${continueTask}`
|
? `Original task: ${task}\n\nFollow-up instruction: ${continueTask}`
|
||||||
: task!;
|
: task!;
|
||||||
|
|
||||||
|
// Derive the Theia workspace subdir from the giteaRepo slug
|
||||||
|
// e.g. "mark/sportsy" → "mark_sportsy", then append appPath
|
||||||
|
// So files land at /home/node/workspace/mark_sportsy/apps/admin inside Theia
|
||||||
|
const theiaWorkspaceSubdir = giteaRepo
|
||||||
|
? giteaRepo.replace('/', '_')
|
||||||
|
: undefined;
|
||||||
|
|
||||||
// Run the streaming agent loop (fire and forget)
|
// Run the streaming agent loop (fire and forget)
|
||||||
runSessionAgent(agentConfig, effectiveTask, ctx, {
|
runSessionAgent(agentConfig, effectiveTask, ctx, {
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -446,6 +454,7 @@ app.post('/agent/execute', async (req: Request, res: Response) => {
|
|||||||
coolifyAppUuid,
|
coolifyAppUuid,
|
||||||
coolifyApiUrl: process.env.COOLIFY_API_URL,
|
coolifyApiUrl: process.env.COOLIFY_API_URL,
|
||||||
coolifyApiToken: process.env.COOLIFY_API_TOKEN,
|
coolifyApiToken: process.env.COOLIFY_API_TOKEN,
|
||||||
|
theiaWorkspaceSubdir,
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
@@ -604,4 +613,11 @@ app.listen(PORT, () => {
|
|||||||
if (!process.env.GOOGLE_API_KEY) {
|
if (!process.env.GOOGLE_API_KEY) {
|
||||||
console.warn('WARNING: GOOGLE_API_KEY is not set — agents will fail');
|
console.warn('WARNING: GOOGLE_API_KEY is not set — agents will fail');
|
||||||
}
|
}
|
||||||
|
// Theia bridge status
|
||||||
|
const theiaContainer = getTheiaContainer();
|
||||||
|
if (theiaContainer) {
|
||||||
|
console.log(`Theia bridge: active (container: ${theiaContainer})`);
|
||||||
|
} else {
|
||||||
|
console.warn('Theia bridge: not available — docker socket not mounted or container not found. Commands will run locally.');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
143
src/theia-exec.ts
Normal file
143
src/theia-exec.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* theia-exec.ts
|
||||||
|
*
|
||||||
|
* Bridge between vibn-agent-runner and the Theia container.
|
||||||
|
*
|
||||||
|
* The agent runner and Theia run as separate Docker containers on the same
|
||||||
|
* host. With /var/run/docker.sock mounted in the runner, this module:
|
||||||
|
*
|
||||||
|
* 1. Discovers the Theia container at startup (by label or explicit name)
|
||||||
|
* 2. theiaExec(cmd, workdir) — runs a command inside Theia via docker exec
|
||||||
|
* 3. syncToTheia(localDir, theiaWorkspacePath) — copies files from the
|
||||||
|
* runner's local workspace into Theia so "Open in Theia" shows them
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* import { theiaExec, syncToTheia, getTheiaContainer } from './theia-exec';
|
||||||
|
*
|
||||||
|
* Environment variables:
|
||||||
|
* THEIA_CONTAINER — explicit container name/id (fastest)
|
||||||
|
* THEIA_COOLIFY_UUID — Coolify app UUID to search by label (fallback)
|
||||||
|
* default: b8wswk0ggcg88k0c8osk4c4c
|
||||||
|
* THEIA_WORKSPACE — workspace path inside Theia (default: /home/node/workspace)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execSync, execFileSync } from 'child_process';
|
||||||
|
|
||||||
|
const THEIA_WORKSPACE = process.env.THEIA_WORKSPACE ?? '/home/node/workspace';
|
||||||
|
const THEIA_COOLIFY_UUID = process.env.THEIA_COOLIFY_UUID ?? 'b8wswk0ggcg88k0c8osk4c4c';
|
||||||
|
|
||||||
|
let _cachedContainer: string | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the running Theia container. Returns the container name/id or null.
|
||||||
|
* Result is cached after first successful lookup.
|
||||||
|
*/
|
||||||
|
export function getTheiaContainer(): string | null {
|
||||||
|
if (_cachedContainer) return _cachedContainer;
|
||||||
|
|
||||||
|
// 1. Explicit env var — fastest
|
||||||
|
if (process.env.THEIA_CONTAINER) {
|
||||||
|
_cachedContainer = process.env.THEIA_CONTAINER.trim();
|
||||||
|
return _cachedContainer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Search by Coolify application UUID label
|
||||||
|
try {
|
||||||
|
const byAppId = execSync(
|
||||||
|
`docker ps --filter "label=coolify.applicationId=${THEIA_COOLIFY_UUID}" --format "{{.Names}}" 2>/dev/null`,
|
||||||
|
{ timeout: 5000 }
|
||||||
|
).toString().trim();
|
||||||
|
if (byAppId) { _cachedContainer = byAppId.split('\n')[0].trim(); return _cachedContainer; }
|
||||||
|
} catch { /* docker not available or socket not mounted */ }
|
||||||
|
|
||||||
|
// 3. Search by partial name match
|
||||||
|
try {
|
||||||
|
const byName = execSync(
|
||||||
|
`docker ps --filter "name=theia" --format "{{.Names}}" 2>/dev/null`,
|
||||||
|
{ timeout: 5000 }
|
||||||
|
).toString().trim();
|
||||||
|
if (byName) { _cachedContainer = byName.split('\n')[0].trim(); return _cachedContainer; }
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true if docker exec into Theia is available */
|
||||||
|
export function isTheiaAvailable(): boolean {
|
||||||
|
return getTheiaContainer() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a shell command inside the Theia container.
|
||||||
|
* Returns { exitCode, stdout, stderr }.
|
||||||
|
*/
|
||||||
|
export async function theiaExec(
|
||||||
|
command: string,
|
||||||
|
workdir?: string
|
||||||
|
): Promise<{ exitCode: number; stdout: string; stderr: string; error?: string }> {
|
||||||
|
const container = getTheiaContainer();
|
||||||
|
if (!container) {
|
||||||
|
return { exitCode: 1, stdout: '', stderr: '', error: 'Theia container not available — running locally' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const cwd = workdir ?? THEIA_WORKSPACE;
|
||||||
|
// Wrap in bash so the command runs in a shell with proper cd
|
||||||
|
const wrapped = `cd ${JSON.stringify(cwd)} && ${command}`;
|
||||||
|
|
||||||
|
return new Promise(resolve => {
|
||||||
|
try {
|
||||||
|
const { execFile } = require('child_process') as typeof import('child_process');
|
||||||
|
execFile(
|
||||||
|
'docker', ['exec', container, 'bash', '-c', wrapped],
|
||||||
|
{ timeout: 120_000, maxBuffer: 4 * 1024 * 1024 },
|
||||||
|
(err, stdout, stderr) => {
|
||||||
|
resolve({
|
||||||
|
exitCode: typeof err?.code === 'number' ? err.code : 0,
|
||||||
|
stdout: stdout.trim(),
|
||||||
|
stderr: stderr.trim(),
|
||||||
|
error: err ? err.message : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
resolve({ exitCode: 1, stdout: '', stderr: '', error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy files from the runner's local workspace directory into Theia's workspace.
|
||||||
|
* Creates the destination directory if it doesn't exist.
|
||||||
|
*
|
||||||
|
* @param localDir Absolute path in the runner container, e.g. /workspaces/mark_sportsy
|
||||||
|
* @param theiaPath Relative path inside THEIA_WORKSPACE, e.g. "mark_sportsy"
|
||||||
|
* or pass null to copy into the workspace root
|
||||||
|
*/
|
||||||
|
export async function syncToTheia(
|
||||||
|
localDir: string,
|
||||||
|
theiaPath: string | null
|
||||||
|
): Promise<{ ok: boolean; error?: string }> {
|
||||||
|
const container = getTheiaContainer();
|
||||||
|
if (!container) {
|
||||||
|
return { ok: false, error: 'Theia container not available — sync skipped' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const destDir = theiaPath
|
||||||
|
? `${THEIA_WORKSPACE}/${theiaPath}`
|
||||||
|
: THEIA_WORKSPACE;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Ensure destination directory exists
|
||||||
|
execSync(`docker exec ${container} mkdir -p ${JSON.stringify(destDir)}`, { timeout: 10_000 });
|
||||||
|
|
||||||
|
// docker cp copies the contents of localDir into destDir
|
||||||
|
// Trailing /. means "copy contents, not the directory itself"
|
||||||
|
execSync(`docker cp "${localDir}/." "${container}:${destDir}/"`, { timeout: 60_000 });
|
||||||
|
|
||||||
|
console.log(`[theia-exec] Synced ${localDir} → ${container}:${destDir}`);
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`[theia-exec] Sync failed:`, err.message);
|
||||||
|
return { ok: false, error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user