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:
319
lib/dev-container-git.ts
Normal file
319
lib/dev-container-git.ts
Normal 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, `'\\''`)}'`;
|
||||
}
|
||||
Reference in New Issue
Block a user