← vibesdk
/**
* Git Protocol Handler
* Handles git clone/fetch operations via HTTP protocol
* Route: /apps/:id.git/info/refs and /apps/:id.git/git-upload-pack
*
* Architecture: Export git objects from DO, build repo in worker to save DO memory
*/
import { getAgentStub, getSpaceGitStub, isThinkApp } from '../../agents';
import { createLogger } from '../../logger';
import { GitCloneService } from '../../agents/git/git-clone-service';
import type { MemFS } from '../../agents/git/memfs';
import type { TemplateDetails } from '../../services/sandbox/sandboxTypes';
import { AppService } from '../../database/services/AppService';
import { JWTUtils } from '../../utils/jwtUtils';
import { GitCache } from './git-cache';
const logger = createLogger('GitProtocol');
interface ResolvedGitObjects {
gitObjects: Array<{ path: string; data: Uint8Array }>;
query: string;
hasCommits: boolean;
templateDetails: TemplateDetails | null;
isThink: boolean;
}
/**
* Resolve the app's git objects for clone. Think apps' repositories live in
* SpaceDO/Artifacts (exported verbatim); other apps use the agent's own git
* (rebased on the template).
*/
async function resolveGitObjectsForApp(env: Env, appId: string): Promise<ResolvedGitObjects> {
if (await isThinkApp(env, appId)) {
const spaceStub = getSpaceGitStub(env, appId);
const gitObjects = spaceStub ? await spaceStub.exportGitObjects() : [];
return { gitObjects, query: '', hasCommits: gitObjects.length > 0, templateDetails: null, isThink: true };
}
const agentStub = await getAgentStub(env, appId);
const exported = await agentStub.exportGitObjects();
return { ...exported, isThink: false };
}
/** Build the in-memory repo, verbatim for think apps and template-rebased otherwise. */
async function buildRepoFor(resolved: ResolvedGitObjects, appQuery: string, appCreatedAt?: Date): Promise<MemFS> {
return resolved.isThink
? GitCloneService.buildRepositoryFromObjects(resolved.gitObjects)
: GitCloneService.buildRepository({
gitObjects: resolved.gitObjects,
templateDetails: resolved.templateDetails,
appQuery,
appCreatedAt,
});
}
/**
* Git protocol route patterns
*/
const GIT_INFO_REFS_PATTERN = /^\/apps\/([a-f0-9-]+)\.git\/info\/refs$/;
const GIT_UPLOAD_PACK_PATTERN = /^\/apps\/([a-f0-9-]+)\.git\/git-upload-pack$/;
/**
* Check if request is a Git protocol request
*/
export function isGitProtocolRequest(pathname: string): boolean {
return GIT_INFO_REFS_PATTERN.test(pathname) || GIT_UPLOAD_PACK_PATTERN.test(pathname);
}
/**
* Extract app ID from Git protocol URL
*/
function extractAppId(pathname: string): string | null {
const infoRefsMatch = pathname.match(GIT_INFO_REFS_PATTERN);
if (infoRefsMatch) return infoRefsMatch[1];
const uploadPackMatch = pathname.match(GIT_UPLOAD_PACK_PATTERN);
if (uploadPackMatch) return uploadPackMatch[1];
return null;
}
/**
* Extract agent HEAD OID from git objects
*/
function extractAgentHeadOid(gitObjects: Array<{ path: string; data: Uint8Array }>): string | null {
const headFile = gitObjects.find(obj => obj.path === '.git/HEAD');
if (!headFile) return null;
const headContent = new TextDecoder().decode(headFile.data).trim();
if (headContent.startsWith('ref: ')) {
const refPath = headContent.slice(5).trim();
const refFile = gitObjects.find(obj => obj.path === `.git/${refPath}`);
return refFile ? new TextDecoder().decode(refFile.data).trim() : null;
}
return null;
}
/**
* Verify git access (public apps or owner with valid token)
*/
async function verifyGitAccess(
request: Request,
env: Env,
appId: string
): Promise<{ hasAccess: boolean; appCreatedAt?: Date }> {
logger.info('Verifying git access', { appId });
const appService = new AppService(env);
const app = await appService.getAppDetails(appId);
logger.info('App details retrieved', { appId, found: !!app, visibility: app?.visibility });
if (!app) {
logger.warn('App not found in database', { appId });
return { hasAccess: false };
}
// Public apps: anyone can clone
if (app.visibility === 'public') {
return { hasAccess: true, appCreatedAt: app.createdAt || undefined };
}
// Private apps: require authentication
const authHeader = request.headers.get('Authorization');
logger.info('Private app - checking auth header', { hasAuthHeader: !!authHeader, authType: authHeader?.split(' ')[0] });
let token: string | null = null;
if (authHeader?.startsWith('Bearer ')) {
token = authHeader.slice(7);
logger.info('Extracted Bearer token', { tokenLength: token.length });
} else if (authHeader?.startsWith('Basic ')) {
// Git sends credentials as Basic auth
const decoded = atob(authHeader.slice(6));
const [username, password] = decoded.split(':');
token = password || username;
logger.info('Extracted Basic auth token', { tokenLength: token?.length, hasUsername: !!username, hasPassword: !!password });
}
if (!token) {
logger.warn('No token found in auth header - will return 401 to prompt git for credentials');
return { hasAccess: false };
}
// Verify token using JWTUtils
const jwtUtils = JWTUtils.getInstance(env);
const payload = await jwtUtils.verifyToken(token);
if (!payload) {
logger.warn('Token verification failed - invalid or expired token');
return { hasAccess: false };
}
logger.info('Token verified', { userId: payload.sub, appOwnerId: app.userId });
// Check if user owns the app
const hasAccess = payload.sub === app.userId;
if (!hasAccess) {
logger.warn('Access denied - user does not own this app', { userId: payload.sub, appOwnerId: app.userId });
} else {
logger.info('Access granted - user owns this app');
}
return { hasAccess, appCreatedAt: hasAccess ? (app.createdAt || undefined) : undefined };
}
/**
* Handle Git info/refs request
*/
async function handleInfoRefs(
request: Request,
env: Env,
ctx: ExecutionContext,
appId: string
): Promise<Response> {
try {
// Verify access first
const { hasAccess, appCreatedAt } = await verifyGitAccess(request, env, appId);
if (!hasAccess) {
// Return 401 with WWW-Authenticate to prompt git for credentials
return new Response('Authentication required', {
status: 401,
headers: {
'WWW-Authenticate': 'Basic realm="Git"'
}
});
}
const resolved = await resolveGitObjectsForApp(env, appId);
const { gitObjects, query, hasCommits, templateDetails } = resolved;
if (!hasCommits) {
// Return empty advertisement for repos with no commits
return new Response('001e# service=git-upload-pack\n0000', {
status: 200,
headers: {
'Content-Type': 'application/x-git-upload-pack-advertisement',
'Cache-Control': 'no-cache'
}
});
}
// Extract HEAD OID for cache validation
const agentHeadOid = extractAgentHeadOid(gitObjects);
if (!agentHeadOid) {
throw new Error('Could not determine agent HEAD OID');
}
// Try memory cache first
const cache = new GitCache();
const { repo } = await cache.getRepository(appId, agentHeadOid, templateDetails);
if (repo) {
logger.info('Cache HIT (memory): info/refs', { appId, agentHeadOid });
const response = await GitCloneService.handleInfoRefs(repo);
return new Response(response, {
status: 200,
headers: {
'Content-Type': 'application/x-git-upload-pack-advertisement',
'Cache-Control': 'no-cache',
'X-Cache': 'HIT-MEMORY'
}
});
}
logger.info('Cache MISS: building repository', { appId, agentHeadOid });
// Build repository in worker
const repoFS = await buildRepoFor(resolved, query, appCreatedAt);
// Store in memory for subsequent upload-pack request
cache.storeRepository(appId, repoFS, agentHeadOid, templateDetails);
// Use waitUntil to keep Worker alive for upload-pack request (typically arrives within seconds)
ctx.waitUntil(
new Promise(resolve => setTimeout(resolve, 5000))
);
// Generate info/refs response
const response = await GitCloneService.handleInfoRefs(repoFS);
return new Response(response, {
status: 200,
headers: {
'Content-Type': 'application/x-git-upload-pack-advertisement',
'Cache-Control': 'no-cache',
'X-Cache': 'MISS'
}
});
} catch (error) {
logger.error('Git info/refs error:', error);
return new Response('Internal server error', { status: 500 });
}
}
/**
* Handle Git upload-pack request
*/
async function handleUploadPack(
request: Request,
env: Env,
_ctx: ExecutionContext,
appId: string
): Promise<Response> {
try {
// Verify access first
const { hasAccess, appCreatedAt } = await verifyGitAccess(request, env, appId);
if (!hasAccess) {
// Return 401 with WWW-Authenticate to prompt git for credentials
return new Response('Authentication required', {
status: 401,
headers: {
'WWW-Authenticate': 'Basic realm="Git"'
}
});
}
const resolved = await resolveGitObjectsForApp(env, appId);
const { gitObjects, query, hasCommits, templateDetails } = resolved;
if (!hasCommits) {
return new Response('No commits to pack', { status: 404 });
}
// Extract HEAD OID for cache validation
const agentHeadOid = extractAgentHeadOid(gitObjects);
if (!agentHeadOid) {
throw new Error('Could not determine agent HEAD OID');
}
// Try memory cache first (HOT PATH - usually hits after info/refs!)
const cache = new GitCache();
const { repo, source } = await cache.getRepository(appId, agentHeadOid, templateDetails);
if (repo) {
logger.info(`Cache HIT (${source}): upload-pack`, { appId, agentHeadOid });
const packfile = await GitCloneService.handleUploadPack(repo);
return new Response(packfile, {
status: 200,
headers: {
'Content-Type': 'application/x-git-upload-pack-result',
'Cache-Control': 'no-cache',
'X-Cache': `HIT-${source.toUpperCase()}`
}
});
}
logger.info('Cache MISS: building repository', { appId, agentHeadOid });
// Build repository in worker (cold path - different Worker or timeout)
const repoFS = await buildRepoFor(resolved, query, appCreatedAt);
// Store in memory for potential retries
cache.storeRepository(appId, repoFS, agentHeadOid, templateDetails);
// Generate packfile with full commit history
const packfile = await GitCloneService.handleUploadPack(repoFS);
return new Response(packfile, {
status: 200,
headers: {
'Content-Type': 'application/x-git-upload-pack-result',
'Cache-Control': 'no-cache',
'X-Cache': 'MISS'
}
});
} catch (error) {
logger.error('Git upload-pack error:', error);
return new Response('Internal server error', { status: 500 });
}
}
/**
* Main handler for Git protocol requests
*/
export async function handleGitProtocolRequest(
request: Request,
env: Env,
ctx: ExecutionContext
): Promise<Response> {
const url = new URL(request.url);
const pathname = url.pathname;
// Extract app ID
const appId = extractAppId(pathname);
if (!appId) {
return new Response('Invalid Git URL', { status: 400 });
}
// Route to appropriate handler
if (GIT_INFO_REFS_PATTERN.test(pathname)) {
return handleInfoRefs(request, env, ctx, appId);
} else if (GIT_UPLOAD_PACK_PATTERN.test(pathname)) {
return handleUploadPack(request, env, ctx, appId);
}
return new Response('Not found', { status: 404 });
}