diff --git a/Dockerfile b/Dockerfile index cb71442..e04a3fb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,19 @@ 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 \ ripgrep \ git \ 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/* WORKDIR /app @@ -22,12 +31,8 @@ RUN npm run build # Prune dev deps after build RUN npm prune --omit=dev -# Create workspace dir and non-root user -RUN useradd -r -m -s /bin/bash agent && \ - mkdir -p /workspaces && \ - chown -R agent:agent /workspaces /app - -USER agent +# Create workspace dir +RUN mkdir -p /workspaces # Git identity for commits made by agents RUN git config --global user.email "agent@vibnai.com" && \ diff --git a/src/agent-session-runner.ts b/src/agent-session-runner.ts index 9b0061b..c197f30 100644 --- a/src/agent-session-runner.ts +++ b/src/agent-session-runner.ts @@ -17,6 +17,7 @@ import { createLLM, toOAITools, LLMMessage } from './llm'; import { AgentConfig } from './agents'; import { executeTool, ToolContext } from './tools'; import { resolvePrompt } from './prompts/loader'; +import { isTheiaAvailable, theiaExec, syncToTheia, getTheiaContainer } from './theia-exec'; const MAX_TURNS = 60; @@ -39,6 +40,8 @@ export interface SessionRunOptions { coolifyAppUuid?: string; coolifyApiUrl?: string; coolifyApiToken?: string; + // Theia integration + theiaWorkspaceSubdir?: string; // e.g. "mark_sportsy" — subdir inside /home/node/workspace } // ── VIBN DB bridge ──────────────────────────────────────────────────────────── @@ -113,6 +116,17 @@ async function autoCommitAndDeploy( const giteaToken = process.env.GITEA_API_TOKEN || ''; 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 { execSync('git config user.email "agent@vibnai.com"', 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; 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) { result = { error: err instanceof Error ? err.message : String(err) }; } diff --git a/src/server.ts b/src/server.ts index 6c738bb..428897b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -13,6 +13,7 @@ import { PROTECTED_GITEA_REPOS } from './tools/security'; import { orchestratorChat, listSessions, clearSession } from './orchestrator'; import { atlasChat, listAtlasSessions, clearAtlasSession } from './atlas'; import { LLMMessage, createLLM } from './llm'; +import { getTheiaContainer } from './theia-exec'; const app = express(); app.use(cors()); @@ -433,6 +434,13 @@ app.post('/agent/execute', async (req: Request, res: Response) => { ? `Original task: ${task}\n\nFollow-up instruction: ${continueTask}` : 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) runSessionAgent(agentConfig, effectiveTask, ctx, { sessionId, @@ -446,6 +454,7 @@ app.post('/agent/execute', async (req: Request, res: Response) => { coolifyAppUuid, coolifyApiUrl: process.env.COOLIFY_API_URL, coolifyApiToken: process.env.COOLIFY_API_TOKEN, + theiaWorkspaceSubdir, }) .catch(err => { const msg = err instanceof Error ? err.message : String(err); @@ -604,4 +613,11 @@ app.listen(PORT, () => { if (!process.env.GOOGLE_API_KEY) { 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.'); + } }); diff --git a/src/theia-exec.ts b/src/theia-exec.ts new file mode 100644 index 0000000..ff737e7 --- /dev/null +++ b/src/theia-exec.ts @@ -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 }; + } +}