feat(devcontainer): auto-clone Gitea repo + auto-commit on each AI turn

The smoke test caught the biggest beta-blocker yet: everything the AI
writes inside the dev container was invisible in the UI because the
Product/Hosting/Infrastructure tabs all read from Gitea + Coolify, not
from the dev container's volume. Plan tab worked; nothing else did.

Two-part fix:

1. lib/dev-container-git.ts — new module with two helpers:
   - ensureProjectRepoCloned(): clones the project's Gitea repo into
     /workspace/<slug>/ using the AI's gitea token, embedding the auth
     into the remote URL so subsequent pushes work without prompts.
     Idempotent: tri-state probe handles 'git' (real repo, no-op),
     'dir' (path exists from pre-fix AI work, init in place), and
     'absent' (full clone). Has an empty-repo fallback for fresh Gitea
     repos where 'git clone' warns and produces nothing checked out.
   - commitAndPushIfDirty(): stages all changes under /workspace/<slug>,
     commits with a one-line message + pushes to origin. Bails fast
     with reason='clean' when there's nothing to commit. Never throws.

2. app/api/chat/route.ts wiring:
   - Pre-loop: fire-and-forget ensureProjectRepoCloned so the repo is
     on disk before the AI's first filesystem-mutating tool call.
   - Post-loop: fire-and-forget commitAndPushIfDirty after the assistant
     message is persisted; commit message is the assistant's first
     sentence (≤180 chars) or 'AI checkpoint' fallback.
   - System prompt now tells the AI: project repo is at /workspace/<slug>,
     write everything you want in the UI under that path, and don't
     manually commit (harness handles it).

Cred plumbing: GITEA_API_URL/GITEA_USERNAME/GITEA_API_TOKEN are read
from process.env in the harness; the dev container never sees the
token outside of the embedded URL. Same blast radius as shell.exec.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-01 14:02:16 -07:00
parent 8c5fbad782
commit 836733536e
2 changed files with 408 additions and 1 deletions

View File

@@ -21,6 +21,10 @@ import { callGeminiChat } from '@/lib/ai/gemini-chat';
import { VIBN_TOOL_DEFINITIONS, executeMcpTool } from '@/lib/ai/vibn-tools'; import { VIBN_TOOL_DEFINITIONS, executeMcpTool } from '@/lib/ai/vibn-tools';
import { detectKnownError, formatRecoveryMessage } from '@/lib/ai/error-recovery'; import { detectKnownError, formatRecoveryMessage } from '@/lib/ai/error-recovery';
import { listRecentSentryIssues } from '@/lib/integrations/sentry'; import { listRecentSentryIssues } from '@/lib/integrations/sentry';
import {
ensureProjectRepoCloned,
commitAndPushIfDirty,
} from '@/lib/dev-container-git';
import type { ChatMessage, ToolCall } from '@/lib/ai/gemini-chat'; import type { ChatMessage, ToolCall } from '@/lib/ai/gemini-chat';
// Bumped from 6 to 12 because Path B chains (devcontainer.ensure → // Bumped from 6 to 12 because Path B chains (devcontainer.ensure →
@@ -113,7 +117,11 @@ The user is currently looking at:
- Vision: ${activeProject.productVision ? activeProject.productVision.slice(0, 240) : '(not yet captured)'} - Vision: ${activeProject.productVision ? activeProject.productVision.slice(0, 240) : '(not yet captured)'}
${activeProject.kickoff ? `- Created via: ${activeProject.kickoff.mode} (${JSON.stringify(activeProject.kickoff.sourceData).slice(0, 200)})` : ''} ${activeProject.kickoff ? `- Created via: ${activeProject.kickoff.mode} (${JSON.stringify(activeProject.kickoff.sourceData).slice(0, 200)})` : ''}
${decisionsBlock}${tasksBlock}${ideasBlock} ${decisionsBlock}${tasksBlock}${ideasBlock}
When you call tools that take a \`projectId\`, USE this id (\`${activeProject.id}\`) without asking. When the user says "this project" / "the app" / "deploy it" — they mean THIS project. Switch to a different project only if the user names one explicitly.\n` When you call tools that take a \`projectId\`, USE this id (\`${activeProject.id}\`) without asking. When the user says "this project" / "the app" / "deploy it" — they mean THIS project. Switch to a different project only if the user names one explicitly.
**Project repo is auto-cloned at \`/workspace/${activeProject.slug ?? '<slug>'}/\` inside the dev container.** That path is the project's Gitea repo. ALL code, docs, configs, and other artifacts you intend the user to see in the Product tab MUST live under that path. Anything you write outside it (e.g. \`/workspace/scratch\`, \`/workspace/some-cloned-other-repo\`) is treated as scratch and is invisible in the UI.
After every assistant turn, the harness automatically runs \`git add -A && git commit && git push\` against \`/workspace/${activeProject.slug ?? '<slug>'}/\`. You do NOT need to commit manually unless the user asks for a specific commit message or you want to checkpoint mid-turn. Don't apologize for "forgetting to commit" — the harness handles it.\n`
: ''; : '';
return `You are Vibn AI — the technical co-founder of every Vibn user. You turn ideas into shipped software. Treat their projects like they're your own. return `You are Vibn AI — the technical co-founder of every Vibn user. You turn ideas into shipped software. Treat their projects like they're your own.
@@ -343,6 +351,33 @@ export async function POST(request: Request) {
} }
} }
// Make sure the project's Gitea repo is cloned into the dev
// container at /workspace/<slug>/ before the AI runs any
// filesystem-mutating tools. Without this, anything the AI writes
// gets stranded in a scratch volume and is invisible in the
// Product/Hosting/Infrastructure tabs (those tabs read from Gitea
// and Coolify, not from the dev container's volume).
//
// We fire-and-forget on existing projects (the clone is a fast
// no-op when present) and only await on projects that don't have
// a dev container yet — there the AI is about to call
// ensureDevContainer + shell.exec, and we need the repo on disk
// before that exec lands so the AI's writes go into the project
// repo instead of an empty /workspace.
if (
activeProject?.id &&
activeProject?.slug &&
typeof activeProject?.giteaCloneUrl === 'string'
) {
void ensureProjectRepoCloned({
projectId: activeProject.id,
projectSlug: activeProject.slug,
giteaCloneUrl: activeProject.giteaCloneUrl,
}).catch((err) => {
console.warn('[chat] pre-loop ensureProjectRepoCloned failed (non-fatal)', err);
});
}
// Base URL for internal MCP calls // Base URL for internal MCP calls
const host = request.headers.get('host') || 'vibnai.com'; const host = request.headers.get('host') || 'vibnai.com';
const proto = host.startsWith('localhost') ? 'http' : 'https'; const proto = host.startsWith('localhost') ? 'http' : 'https';
@@ -594,6 +629,59 @@ export async function POST(request: Request) {
[thread_id, email, JSON.stringify(finalMsg)], [thread_id, email, JSON.stringify(finalMsg)],
); );
// Fire-and-forget: commit any AI-made filesystem changes to
// the project's Gitea repo and push to origin. This is what
// makes the AI's work appear in the Product tab's Codebases
// view — without it, every fs.write / shell.exec mutation
// stays trapped in the dev container's volume.
//
// Run AFTER the assistant message is persisted because the
// user already saw the reply; a slow push shouldn't block
// the chat. If there's nothing to commit, the helper short-
// circuits with reason='clean' in <1s.
if (
activeProject?.id &&
activeProject?.slug &&
typeof activeProject?.giteaCloneUrl === 'string'
) {
(async () => {
try {
// Best-effort clone in case the pre-loop kick-off was
// racing with container provisioning and never landed.
await ensureProjectRepoCloned({
projectId: activeProject.id,
projectSlug: activeProject.slug,
giteaCloneUrl: activeProject.giteaCloneUrl,
}).catch(() => null);
// Commit message: prefer the assistant's own first
// sentence (one line, ≤200 chars). Falls back to a
// generic checkpoint when the assistant only made
// tool calls without prose.
const firstSentence = (assistantText || '')
.split(/(?<=[.!?])\s+/)[0]
?.trim()
?.slice(0, 180);
const message = firstSentence || 'AI checkpoint';
const result = await commitAndPushIfDirty({
projectId: activeProject.id,
projectSlug: activeProject.slug,
message,
});
if (result.committed) {
console.log(
`[chat] auto-commit project=${activeProject.slug} sha=${result.sha} pushed=${result.pushed}`,
);
} else if (result.reason && result.reason !== 'clean' && result.reason !== 'no_repo') {
console.warn(
`[chat] auto-commit failed project=${activeProject.slug} reason=${result.reason}`,
);
}
} catch (err) {
console.warn('[chat] auto-commit fire-and-forget failed', err);
}
})();
}
// Fire-and-forget: ask Gemini for a 1-2 sentence "what got done" // Fire-and-forget: ask Gemini for a 1-2 sentence "what got done"
// summary of the conversation so far, persist it on the thread, // summary of the conversation so far, persist it on the thread,
// and use the first user message (truncated) as a stable title // and use the first user message (truncated) as a stable title

319
lib/dev-container-git.ts Normal file
View File

@@ -0,0 +1,319 @@
/**
* Per-project dev-container git plumbing.
*
* The dev container's /workspace is a scratch volume by default. Without
* this module, anything the AI writes there is invisible to the user —
* the Product/Hosting/Infrastructure tabs all read from Gitea + Coolify,
* not from the dev container's filesystem.
*
* Two responsibilities:
*
* 1. ensureProjectRepoCloned()
* Clones the project's Gitea repo into /workspace/<slug>/ on first
* use. Configures the AI's git author identity and embeds Gitea
* credentials into the remote URL so subsequent pushes work without
* another credential exchange. Idempotent — if the repo already
* exists at the expected path, it's a no-op.
*
* 2. commitAndPushIfDirty()
* Run at the end of each AI assistant turn. If there are changes
* under /workspace/<slug>/, stages them, makes a commit with a
* one-line summary, and pushes to origin. Failures are logged but
* don't fail the chat turn — the user already saw their reply.
*
* Why we shell out vs. using a JS git lib: the dev container has a real
* git binary, the credentials are simple (HTTPS+token), and isomorphic
* git would mean shipping a ~2MB JS dep just to mirror what `git` already
* does. Cost outweighs benefit.
*
* Security note: the Gitea token is embedded in the remote URL, which
* lives in `.git/config` inside the dev container's volume. The volume
* is per-project and only reachable through our own SSH-bridge exec, so
* the blast radius is the same as `shell.exec` itself — anything the AI
* can do can read this token. That's already true today; this doesn't
* widen it.
*/
import { execInDevContainer } from '@/lib/dev-container';
const GITEA_API_URL = process.env.GITEA_API_URL ?? '';
const GITEA_USERNAME = process.env.GITEA_USERNAME ?? '';
const GITEA_API_TOKEN = process.env.GITEA_API_TOKEN ?? '';
const AI_GIT_AUTHOR_NAME = 'Vibn AI';
const AI_GIT_AUTHOR_EMAIL = 'ai@vibnai.com';
/** Where each project's repo lives inside the dev container. */
export function projectRepoPath(projectSlug: string): string {
return `/workspace/${projectSlug}`;
}
function isGiteaConfigured(): boolean {
return Boolean(GITEA_API_URL && GITEA_USERNAME && GITEA_API_TOKEN);
}
/**
* Take a Gitea HTTPS URL and embed the AI's credentials inline so
* `git clone` / `git push` work without an interactive prompt.
*
* Input: https://git.vibnai.com/vibn-mark/manifest.git
* Output: https://mark:<token>@git.vibnai.com/vibn-mark/manifest.git
*/
function authedCloneUrl(plainCloneUrl: string): string {
if (!isGiteaConfigured()) return plainCloneUrl;
try {
const u = new URL(plainCloneUrl);
u.username = GITEA_USERNAME;
u.password = GITEA_API_TOKEN;
return u.toString();
} catch {
return plainCloneUrl;
}
}
export interface EnsureRepoClonedOpts {
projectId: string;
projectSlug: string;
giteaCloneUrl: string;
}
export interface EnsureRepoClonedResult {
cloned: boolean;
alreadyPresent: boolean;
reason?: string;
}
/**
* Make sure the project's Gitea repo is checked out at /workspace/<slug>/.
*
* Skips silently if the repo is already present (detected by
* `git rev-parse --is-inside-work-tree`). Logs and returns a reason
* string if Gitea isn't configured or the clone fails — callers should
* treat clone failure as non-fatal: the dev container is still usable,
* the AI just won't be able to surface its work in the UI until the
* repo is fixed.
*/
export async function ensureProjectRepoCloned(
opts: EnsureRepoClonedOpts,
): Promise<EnsureRepoClonedResult> {
if (!isGiteaConfigured()) {
return { cloned: false, alreadyPresent: false, reason: 'gitea_not_configured' };
}
if (!opts.giteaCloneUrl) {
return { cloned: false, alreadyPresent: false, reason: 'no_clone_url' };
}
const repoDir = projectRepoPath(opts.projectSlug);
const authed = authedCloneUrl(opts.giteaCloneUrl);
// Tri-state probe:
// "git" → /workspace/<slug>/.git exists (proper repo, no-op)
// "dir" → /workspace/<slug>/ exists but isn't a repo (init in place)
// "absent" → nothing there yet (full clone)
//
// The "dir" case happens when an earlier AI turn (pre-this-fix)
// created files under /workspace/<slug>/ before we had auto-clone.
// We init-git in place so those files become the first commit
// instead of being clobbered by a clone-and-rename.
const probe = await execInDevContainer({
projectId: opts.projectId,
command:
`if [ -d ${shellQ(repoDir + '/.git')} ]; then echo git; ` +
`elif [ -d ${shellQ(repoDir)} ]; then echo dir; ` +
`else echo absent; fi`,
timeoutMs: 5_000,
});
const probeState = probe.stdout.trim();
if (probeState === 'git') {
return { cloned: false, alreadyPresent: true };
}
if (probeState === 'dir') {
// Init in place, hook up the remote, fetch, set tracking. We
// don't try to merge — the directory's contents become whatever
// the next auto-commit-and-push picks up. If the remote has
// existing commits that conflict, the push will fail loudly
// and the AI can resolve via shell.exec.
const initCmd = [
`cd ${shellQ(repoDir)}`,
`git init -b main`,
`git config user.name ${shellQ(AI_GIT_AUTHOR_NAME)}`,
`git config user.email ${shellQ(AI_GIT_AUTHOR_EMAIL)}`,
`git remote add origin ${shellQ(authed)}`,
// Best-effort fetch; if remote is empty this errors out and
// we proceed anyway. The `|| true` keeps the chain going.
`(git fetch origin main 2>/dev/null && git reset --soft origin/main 2>/dev/null) || true`,
].join(' && ');
const result = await execInDevContainer({
projectId: opts.projectId,
command: initCmd,
timeoutMs: 30_000,
});
if (result.exitCode !== 0) {
return {
cloned: false,
alreadyPresent: false,
reason: `init_in_place_failed:${result.stderr.slice(0, 200)}`,
};
}
return { cloned: true, alreadyPresent: false };
}
// probeState === 'absent' → full clone path.
// Clone into a temp path then atomic-rename, so a clone interrupted
// halfway through doesn't leave a half-checked-out tree at the real
// path that the next attempt would mistake for "already present".
const tmpDir = `${repoDir}.cloning.${Date.now()}`;
const cloneCmd = [
`mkdir -p /workspace`,
// --depth 50 keeps the dev container small; we don't need full
// history, and `--no-single-branch` would let the AI fetch other
// branches if needed. For empty repos this errors with "remote
// HEAD refers to nonexistent ref" — handle that with the dir
// fallback below.
`git clone --depth 50 ${shellQ(authed)} ${shellQ(tmpDir)}`,
`cd ${shellQ(tmpDir)}`,
`git config user.name ${shellQ(AI_GIT_AUTHOR_NAME)}`,
`git config user.email ${shellQ(AI_GIT_AUTHOR_EMAIL)}`,
`git remote set-url origin ${shellQ(authed)}`,
`cd /workspace && mv ${shellQ(tmpDir)} ${shellQ(repoDir)}`,
].join(' && ');
const result = await execInDevContainer({
projectId: opts.projectId,
command: cloneCmd,
timeoutMs: 60_000,
});
if (result.exitCode !== 0) {
await execInDevContainer({
projectId: opts.projectId,
command: `rm -rf ${shellQ(tmpDir)}`,
timeoutMs: 5_000,
}).catch(() => {});
// Empty-repo fallback: if Gitea returned the repo is empty
// (warning: "remote HEAD refers to nonexistent ref"), clone
// didn't actually fail — just there's no history to check
// out. Make an empty dir + init so subsequent commits land.
if (/empty|warning: You appear to have cloned an empty/i.test(result.stderr + result.stdout)) {
const initEmpty = [
`mkdir -p ${shellQ(repoDir)}`,
`cd ${shellQ(repoDir)}`,
`git init -b main`,
`git config user.name ${shellQ(AI_GIT_AUTHOR_NAME)}`,
`git config user.email ${shellQ(AI_GIT_AUTHOR_EMAIL)}`,
`git remote add origin ${shellQ(authed)}`,
].join(' && ');
const initR = await execInDevContainer({
projectId: opts.projectId,
command: initEmpty,
timeoutMs: 15_000,
}).catch(() => null);
if (initR && initR.exitCode === 0) {
return { cloned: true, alreadyPresent: false };
}
}
return {
cloned: false,
alreadyPresent: false,
reason: `clone_failed:${result.stderr.slice(0, 200)}`,
};
}
return { cloned: true, alreadyPresent: false };
}
export interface CommitAndPushOpts {
projectId: string;
projectSlug: string;
/** One-line summary; defaults to a generic checkpoint message. */
message?: string;
}
export interface CommitAndPushResult {
committed: boolean;
pushed: boolean;
/** Short reason ('clean', 'no_repo', 'commit_failed:<err>', etc.). */
reason?: string;
/** Commit hash if a commit was made. */
sha?: string;
}
/**
* Stage every change under /workspace/<slug>/, commit if there's a diff,
* and push to origin.
*
* Designed to be called at the end of each assistant turn. Cheap when
* there are no changes (one `git status --porcelain` and we're out).
* Never throws — the chat reply has already been streamed to the user
* by the time this runs, and a failed push shouldn't surface as a
* user-visible error in the conversation.
*/
export async function commitAndPushIfDirty(
opts: CommitAndPushOpts,
): Promise<CommitAndPushResult> {
const repoDir = projectRepoPath(opts.projectSlug);
const message = (opts.message ?? '').trim() || 'AI checkpoint';
// Sanitize: keep messages single-line and bounded so we can't be
// tricked into shell-escape or commit-message injection.
const safeMessage = message.replace(/[\r\n]+/g, ' ').slice(0, 200);
// Bail fast if there's no repo to commit against. Don't treat as
// an error — projects without a clone (e.g. cloning failed earlier)
// should still be able to chat.
const probe = await execInDevContainer({
projectId: opts.projectId,
command: `test -d ${shellQ(repoDir + '/.git')} && echo present || echo absent`,
timeoutMs: 5_000,
}).catch(() => null);
if (!probe || probe.stdout.trim() !== 'present') {
return { committed: false, pushed: false, reason: 'no_repo' };
}
const cmd = [
`cd ${shellQ(repoDir)}`,
`git add -A`,
// If there's nothing staged, `diff --cached --quiet` exits 0 and
// we short-circuit with a clean status. Otherwise commit + push.
`if git diff --cached --quiet; then echo CLEAN && exit 0; fi`,
`git commit -m ${shellQ(safeMessage)}`,
`SHA=$(git rev-parse --short HEAD)`,
`git push -u origin HEAD 2>&1 | tail -n 5`,
`echo COMMITTED $SHA`,
].join(' && ');
const result = await execInDevContainer({
projectId: opts.projectId,
command: cmd,
timeoutMs: 30_000,
}).catch((err: unknown) => ({
stdout: '',
stderr: err instanceof Error ? err.message : String(err),
exitCode: -1,
} satisfies { stdout: string; stderr: string; exitCode: number }));
const out = result.stdout || '';
if (out.includes('CLEAN')) {
return { committed: false, pushed: false, reason: 'clean' };
}
const m = /COMMITTED\s+([0-9a-f]+)/.exec(out);
if (m) {
return { committed: true, pushed: true, sha: m[1] };
}
// We staged something but didn't see the COMMITTED marker — likely
// commit succeeded but push failed (offline, auth rotated, etc.).
// Surface the stderr tail so debugging is possible.
return {
committed: false,
pushed: false,
reason: `commit_or_push_failed:${(result.stderr || out).slice(-200)}`,
};
}
function shellQ(s: string): string {
return `'${s.replace(/'/g, `'\\''`)}'`;
}