← vibesdk
import { Agent, AgentContext, ConnectionContext, getAgentByName } from "agents";
import { AgentInitArgs, AgentSummary, DeployOptions, DeployResult, ExportOptions, ExportResult, DeploymentTarget, BehaviorType } from "./types";
import { AgenticState, AgentState, BaseProjectState, CurrentDevState, MAX_PHASES, ThinkState, PhasicState } from "./state";
import { Blueprint } from "../schemas";
import { BaseCodingBehavior } from "./behaviors/base";
import { ThinkCodingBehavior } from './behaviors/think';
import { getBehaviorTypeForProject } from './features';
import { createObjectLogger, StructuredLogger } from '../../logger';
import { InferenceMetadata } from "../inferutils/config.types";
import { getMimeType } from 'hono/utils/mime';
import { normalizePath, isPathSafe } from '../../utils/pathUtils';
import { FileManager } from '../services/implementations/FileManager';
import { DeploymentManager } from '../services/implementations/DeploymentManager';
import { GitVersionControl } from '../git';
import { StateManager } from '../services/implementations/StateManager';
import { PhasicCodingBehavior } from './behaviors/phasic';
import { AgenticCodingBehavior } from './behaviors/agentic';
import { SqlExecutor } from '../git';
import { AgentInfrastructure } from "./AgentCore";
import { ProjectType } from './types';
import { Connection } from 'agents';
import { handleWebSocketMessage, handleWebSocketClose, broadcastToConnections, sendToConnection } from './websocket';
import { WebSocketMessageData, WebSocketMessageType } from "worker/api/websocketTypes";
import { PreviewType, TemplateDetails } from "worker/services/sandbox/sandboxTypes";
import { WebSocketMessageResponses } from "../constants";
import { AppService, ModelConfigService } from "worker/database";
import { ConversationMessage, ConversationState } from "../inferutils/common";
import { ImageAttachment } from "worker/types/image-attachment";
import { RateLimitExceededError } from "shared/types/errors";
import { ProjectObjective } from "./objectives/base";
import { FileOutputType } from "../schemas";
import { SecretsClient, type UserSecretsStoreStub } from '../../services/secrets/SecretsClient';
import { StateMigration } from './stateMigration';
import {
ConversationMessageLoader,
LocalConversationMessageLoader,
ThinkMessageLoader,
} from './conversation/MessageLoader';
import { PendingWsTicket, TicketConsumptionResult } from '../../types/auth-types';
import { WsTicketManager } from '../../utils/wsTicketManager';
import { readTokenCookie } from '../../utils/oauthCookie';
const DEFAULT_CONVERSATION_SESSION_ID = 'default';
interface AgentBootstrapProps {
behaviorType?: BehaviorType;
projectType?: ProjectType;
}
export class CodeGeneratorAgent extends Agent<Env, AgentState> implements AgentInfrastructure<AgentState> {
public _logger: StructuredLogger | undefined;
private behavior!: BaseCodingBehavior<AgentState>;
private objective!: ProjectObjective<BaseProjectState>;
private secretsClient: SecretsClient | null = null;
protected static readonly PROJECT_NAME_PREFIX_MAX_LENGTH = 20;
/** Ticket manager for WebSocket authentication */
private ticketManager = new WsTicketManager();
// Services
readonly fileManager: FileManager;
readonly deploymentManager: DeploymentManager;
readonly git: GitVersionControl;
// Redeclare as public to satisfy AgentInfrastructure interface
declare public readonly env: Env;
declare public readonly sql: SqlExecutor;
// ==========================================
// Initialization
// ==========================================
initialState = {
behaviorType: 'unknown' as BehaviorType,
projectType: 'unknown' as ProjectType,
projectName: "",
query: "",
sessionId: '',
hostname: '',
blueprint: {} as unknown as Blueprint,
templateName: '',
generatedFilesMap: {},
conversationMessages: [],
metadata: {} as InferenceMetadata,
shouldBeGenerating: false,
sandboxInstanceId: undefined,
commandsHistory: [],
lastPackageJson: '',
pendingUserInputs: [],
projectUpdatesAccumulator: [],
lastDeepDebugTranscript: null,
mvpGenerated: false,
reviewingInitiated: false,
generatedPhases: [],
currentDevState: CurrentDevState.IDLE,
phasesCounter: MAX_PHASES,
} as AgentState;
constructor(ctx: AgentContext, env: Env) {
super(ctx, env);
void this.sql`CREATE TABLE IF NOT EXISTS full_conversations (id TEXT PRIMARY KEY, messages TEXT)`;
void this.sql`CREATE TABLE IF NOT EXISTS compact_conversations (id TEXT PRIMARY KEY, messages TEXT)`;
// Create StateManager
const stateManager = new StateManager(
() => this.state,
(s) => this.setState(s)
);
this.git = new GitVersionControl(this.sql.bind(this));
this.fileManager = new FileManager(
stateManager,
() => this.behavior?.getTemplateDetails?.() || null,
this.git
);
this.deploymentManager = new DeploymentManager(
{
stateManager,
fileManager: this.fileManager,
getLogger: () => this.logger(),
env: this.env
},
10, // MAX_COMMANDS_HISTORY
);
}
private createObjective(projectType: ProjectType): ProjectObjective<BaseProjectState> {
return new ProjectObjective(this as AgentInfrastructure<BaseProjectState>, projectType);
}
/**
* Initialize the agent with project blueprint and template
* Only called once in an app's lifecycle
*/
async initialize(
initArgs: AgentInitArgs<AgentState>,
..._args: unknown[]
): Promise<AgentState> {
const { inferenceContext } = initArgs;
const sandboxSessionId = DeploymentManager.generateNewSessionId();
this.initLogger(inferenceContext.metadata.agentId, inferenceContext.metadata.userId, sandboxSessionId);
// Infrastructure setup
await this.gitInit();
// Let behavior handle all state initialization (blueprint, projectName, etc.)
await this.behavior.initialize({
...initArgs,
sandboxSessionId // Pass generated session ID to behavior
});
await this.saveToDatabase();
return this.state;
}
async isInitialized() {
return this.getAgentId() ? true : false
}
/**
* Called evertime when agent is started or re-started
* @param props - Optional props
*/
async onStart(props?: Record<string, unknown> | undefined): Promise<void> {
// Run common migration FIRST, before any state access
const migratedState = StateMigration.migrateCommon(this.state);
if (migratedState) {
this.setState(migratedState);
}
this.logger().info(`Agent ${this.getAgentId()} session: ${this.state.sessionId} onStart`, { props });
this.logger().info('Bootstrapping CodeGeneratorAgent', { props });
const agentProps = props as AgentBootstrapProps;
// ProjectType: prefer props → existing state → 'app'. Treat 'unknown'
// sentinel from initialState as missing.
const projectType =
agentProps?.projectType ??
(this.state.projectType && this.state.projectType !== ('unknown' as unknown as typeof this.state.projectType)
? this.state.projectType
: 'app');
// BehaviorType resolution order:
// 1. explicit props.behaviorType (controller-supplied)
// 2. previously persisted state.behaviorType (only if valid)
// 3. feature-registry default for projectType
// The 'unknown' sentinel and any invalid value falls through to (3).
const persistedBehavior = this.state.behaviorType;
const isValidPersisted =
persistedBehavior === 'phasic' ||
persistedBehavior === 'agentic' ||
persistedBehavior === 'think';
const behaviorType: BehaviorType =
agentProps?.behaviorType ??
(isValidPersisted ? persistedBehavior : getBehaviorTypeForProject(projectType));
if (behaviorType === 'phasic') {
this.behavior = new PhasicCodingBehavior(this as AgentInfrastructure<PhasicState>, projectType);
} else if (behaviorType === 'think') {
this.behavior = new ThinkCodingBehavior(
this as AgentInfrastructure<ThinkState>,
projectType,
) as unknown as BaseCodingBehavior<AgentState>;
} else {
this.behavior = new AgenticCodingBehavior(this as AgentInfrastructure<AgenticState>, projectType);
}
// Create objective based on project type
this.objective = this.createObjective(projectType);
this.behavior.onStart(props);
// Ignore if agent not initialized
if (!this.state.query) {
this.logger().warn(`Agent ${this.getAgentId()} session: ${this.state.sessionId} onStart ignored, agent not initialized`);
return;
}
// Ensure state is migrated for any previous versions
this.behavior.migrateStateIfNeeded();
// Check if this is a read-only operation
const readOnlyMode = props?.readOnlyMode === true;
if (readOnlyMode) {
this.logger().info(`Agent ${this.getAgentId()} starting in READ-ONLY mode - skipping expensive initialization`);
return;
}
// Just in case
await this.gitInit();
await this.behavior.ensureTemplateDetails();
this.logger().info(`Agent ${this.getAgentId()} session: ${this.state.sessionId} onStart processed successfully`);
// Load the latest user configs
const modelConfigService = new ModelConfigService(this.env);
const userConfigsRecord = await modelConfigService.getUserModelConfigs(this.state.metadata.userId);
this.behavior.setUserModelConfigs(userConfigsRecord);
this.logger().info(`Agent ${this.getAgentId()} session: ${this.state.sessionId} onStart: User configs loaded successfully`, {userConfigsRecord});
}
onConnect(connection: Connection, ctx: ConnectionContext) {
this.logger().info(`Agent connected for agent ${this.getAgentId()}`, { connection, ctx });
// Capture the encrypted Cloudflare OAuth blob from the WS upgrade request's
// HttpOnly cookie and stash it on the DO state. The browser cannot read this
// cookie, and messages over the WS do not carry cookies per-frame, so this
// is the one chance we get to pick it up per connection.
try {
const blob = readTokenCookie(ctx.request, this.env);
const origin = new URL(ctx.request.url).origin;
const needsUpdate =
(blob && blob !== this.state.cloudflareToken) ||
(origin && origin !== this.state.wsOrigin);
if (needsUpdate) {
this.setState({
...this.state,
cloudflareToken: blob || this.state.cloudflareToken,
wsOrigin: origin || this.state.wsOrigin,
});
}
} catch (error) {
this.logger().warn('Failed to capture CF token cookie on WS connect', { error });
}
void (async () => {
let previewUrl = '';
try {
// The think behavior exposes its preview via the
// `/space/.../preview/<branch>/` worker route. Only surface it
// once a real deploy has happened (`lastDeployedCommit` is set
// by `deployCurrentBranch`); the URL is derived purely from the
// space name/branch, so without this gate the iframe would
// point at an empty, undeployed space before the first deploy.
const renderMode = this.behavior.getTemplateDetails().renderMode;
const isThink = this.state.behaviorType === 'think';
const thinkHasDeployed = isThink && Boolean(this.state.lastDeployedCommit);
if (renderMode === 'browser' || thinkHasDeployed) {
previewUrl = await this.behavior.getBrowserPreviewURL();
}
} catch (error) {
this.logger().error('Error getting preview URL:', error);
}
sendToConnection(connection, WebSocketMessageResponses.AGENT_CONNECTED, {
state: this.state,
templateDetails: this.behavior.getTemplateDetails(),
previewUrl: previewUrl
});
})();
}
private initLogger(agentId: string, userId: string, sessionId?: string) {
this._logger = createObjectLogger(this, 'CodeGeneratorAgent');
this._logger.setObjectId(agentId);
this._logger.setFields({
agentId,
userId,
projectType: this.state.projectType,
behaviorType: this.state.behaviorType
});
if (sessionId) {
this._logger.setField('sessionId', sessionId);
}
return this._logger;
}
// ==========================================
// Utilities
// ==========================================
logger(): StructuredLogger {
if (!this._logger) {
this._logger = this.initLogger(this.getAgentId(), this.state.metadata.userId, this.state.sessionId);
}
return this._logger;
}
getAgentId() {
return this.state.metadata.agentId;
}
getWebSockets(): WebSocket[] {
return this.ctx.getWebSockets();
}
handleVaultUnlocked(): void {
this.secretsClient?.notifyUnlocked();
this.logger().info('Vault unlocked notification received', {});
}
handleVaultLocked(): void {
this.secretsClient?.notifyUnlockFailed('Vault locked');
this.secretsClient = null;
this.logger().info('Vault locked', {});
}
private getSecretsClient(): SecretsClient {
if (!this.secretsClient) {
const userId = this.state.metadata.userId;
const stub = this.env.UserSecretsStore.get(
this.env.UserSecretsStore.idFromName(userId)
) as unknown as UserSecretsStoreStub;
this.secretsClient = new SecretsClient(stub, (type, data) => {
if (type === 'vault_required') {
const vaultData = data as { reason: string; provider?: string; envVarName?: string; secretId?: string };
broadcastToConnections(this, 'vault_required', vaultData);
}
});
}
return this.secretsClient;
}
async getDecryptedSecret(query: { provider?: string; envVarName?: string; secretId?: string }): Promise<string | null> {
try {
return await this.getSecretsClient().get(query);
} catch (error) {
this.logger().info('Secret request failed', { query, error: String(error) });
return null;
}
}
/**
* Get the project objective (defines what is being built)
*/
getObjective(): ProjectObjective<BaseProjectState> {
return this.objective;
}
/**
* Get the behavior (defines how code is generated)
*/
getBehavior(): BaseCodingBehavior<AgentState> {
return this.behavior;
}
async getFullState(): Promise<AgentState> {
return await this.behavior.getFullState();
}
async getSummary(): Promise<AgentSummary> {
return this.behavior.getSummary();
}
getBehaviorType(): BehaviorType {
return this.state.behaviorType;
}
/**
* Public RPC entrypoint for the think agent's tools (and any other caller
* holding a `CodeGenObject` stub) to capture browser console
* output from the current preview app. Delegates straight to the
* active behavior, which routes between the BROWSER binding (prod)
* and the local dev sidecar.
*/
async captureBrowserConsoleLogs(opts?: {
url?: string;
waitSeconds?: number;
viewport?: { width: number; height: number };
interactScript?: string;
}) {
return this.behavior.captureBrowserConsoleLogs(opts ?? {});
}
getPreviewUrlCache(): string {
return ''; // Unimplemented
}
deployToSandbox(
files: FileOutputType[] = [],
redeploy: boolean = false,
commitMessage?: string,
clearLogs: boolean = false
): Promise<PreviewType | null> {
return this.behavior.deployToSandbox(files, redeploy, commitMessage, clearLogs);
}
deployToCloudflare(target?: DeploymentTarget): Promise<{ deploymentUrl?: string; workersUrl?: string } | null> {
return this.behavior.deployToCloudflare(target);
}
deployProject(options?: DeployOptions): Promise<DeployResult> {
return this.objective.deploy(options);
}
exportProject(options: ExportOptions): Promise<ExportResult> {
return this.objective.export(options);
}
importTemplate(templateName: string): Promise<{ templateName: string; filesImported: number }> {
return this.behavior.importTemplate(templateName);
}
protected async saveToDatabase() {
this.logger().info(`Saving agent ${this.getAgentId()} to database`);
// Save the app to database (authenticated users only)
const appService = new AppService(this.env);
await appService.createApp({
id: this.state.metadata.agentId,
userId: this.state.metadata.userId,
sessionToken: null,
title: this.state.blueprint.title || this.state.query.substring(0, 100),
description: this.state.blueprint.description,
originalPrompt: this.state.query,
finalPrompt: this.state.query,
framework: this.state.blueprint.frameworks.join(','),
visibility: 'private',
status: 'generating',
createdAt: new Date(),
updatedAt: new Date()
});
this.logger().info(`App saved successfully to database for agent ${this.state.metadata.agentId}`, {
agentId: this.state.metadata.agentId,
userId: this.state.metadata.userId,
visibility: 'private'
});
this.logger().info(`Agent initialized successfully for agent ${this.state.metadata.agentId}`);
}
// ==========================================
// Conversation Management
// ==========================================
/*
* Each DO has 10 gb of sqlite storage. However, the way agents sdk works, it stores the 'state' object of the agent as a single row
* in the cf_agents_state table. And row size has a much smaller limit in sqlite. Thus, we only keep current compactified conversation
* in the agent's core state and store the full conversation in a separate DO table.
*/
getConversationState(id: string = DEFAULT_CONVERSATION_SESSION_ID): ConversationState {
const rows = this.sql<{ messages: string, id: string }>`SELECT * FROM full_conversations WHERE id = ${id}`;
let fullHistory: ConversationMessage[] = [];
if (rows.length > 0 && rows[0].messages) {
try {
const parsed = JSON.parse(rows[0].messages);
if (Array.isArray(parsed)) {
fullHistory = parsed as ConversationMessage[];
}
} catch (_e) {
this.logger().warn('Failed to parse full conversation history', _e);
}
}
// Load compact (running) history from sqlite with fallback to in-memory state for migration
const compactRows = this.sql<{ messages: string, id: string }>`SELECT * FROM compact_conversations WHERE id = ${id}`;
let runningHistory: ConversationMessage[] = [];
if (compactRows.length > 0 && compactRows[0].messages) {
try {
const parsed = JSON.parse(compactRows[0].messages);
if (Array.isArray(parsed)) {
runningHistory = parsed as ConversationMessage[];
}
} catch (_e) {
this.logger().warn('Failed to parse compact conversation history', _e);
}
}
if (runningHistory.length === 0) {
runningHistory = fullHistory;
}
// Remove duplicates
const deduplicateMessages = (messages: ConversationMessage[]): ConversationMessage[] => {
const seen = new Set<string>();
return messages.filter(msg => {
const key = `${msg.conversationId}-${msg.role}-${msg.tool_call_id || ''}`;
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
};
runningHistory = deduplicateMessages(runningHistory);
fullHistory = deduplicateMessages(fullHistory);
this.logger().info(`Loaded conversation state ${id}, full_length: ${fullHistory.length}, compact_length: ${runningHistory.length}`, fullHistory);
return {
id: id,
runningHistory,
fullHistory,
};
}
setConversationState(conversations: ConversationState) {
const serializedFull = JSON.stringify(conversations.fullHistory);
const serializedCompact = JSON.stringify(conversations.runningHistory);
try {
this.logger().info(`Saving conversation state ${conversations.id}, full_length: ${serializedFull.length}, compact_length: ${serializedCompact.length}`, serializedFull);
void this.sql`INSERT OR REPLACE INTO compact_conversations (id, messages) VALUES (${conversations.id}, ${serializedCompact})`;
void this.sql`INSERT OR REPLACE INTO full_conversations (id, messages) VALUES (${conversations.id}, ${serializedFull})`;
} catch (error) {
this.logger().error(`Failed to save conversation state ${conversations.id}`, error);
}
}
addConversationMessage(message: ConversationMessage) {
const conversationState = this.getConversationState();
if (!conversationState.runningHistory.find(msg => msg.conversationId === message.conversationId)) {
this.logger().info('Adding conversation message', {
message,
conversationId: message.conversationId,
runningHistoryLength: conversationState.runningHistory.length,
fullHistoryLength: conversationState.fullHistory.length
});
conversationState.runningHistory.push(message);
} else {
conversationState.runningHistory = conversationState.runningHistory.map(msg => {
if (msg.conversationId === message.conversationId) {
return message;
}
return msg;
});
}
if (!conversationState.fullHistory.find(msg => msg.conversationId === message.conversationId)) {
conversationState.fullHistory.push(message);
} else {
conversationState.fullHistory = conversationState.fullHistory.map(msg => {
if (msg.conversationId === message.conversationId) {
return message;
}
return msg;
});
}
this.setConversationState(conversationState);
}
/**
* Pick a conversation message loader based on the active behavior.
*
* - `think` → reads from the companion `ThinkAgent` DO (the source of
* truth for think runs; populated by the Think runtime itself).
* - everything else → reads from this agent's local SQLite tables.
*
* Used by the WebSocket boundary (`GET_CONVERSATION_STATE`) so the
* frontend sees the same history the backend actually persists.
*/
public getConversationMessageLoader(): ConversationMessageLoader {
const behavior = this.state.behaviorType;
if (behavior === 'think') {
const thinkState = this.state as ThinkState;
const ns = (this.env as unknown as {
THINK_DO?: DurableObjectNamespace;
}).THINK_DO;
const name = thinkState.thinkAgentName || thinkState.metadata?.agentId;
if (ns && name) {
// Resolve the ThinkAgent via the agents framework helper so its
// `_init` runs before we read messages (a raw stub would throw).
return new ThinkMessageLoader(async () => {
const stub = await getAgentByName(ns as never, name);
return stub as unknown as Awaited<ReturnType<ConstructorParameters<typeof ThinkMessageLoader>[0]>>;
});
}
}
return new LocalConversationMessageLoader(this);
}
/**
* Clear conversation history
*/
public clearConversation(): void {
try {
this.logger().info('Clearing conversation history');
// Clear SQL tables for default conversation session
void this.sql`DELETE FROM full_conversations WHERE id = ${DEFAULT_CONVERSATION_SESSION_ID}`;
void this.sql`DELETE FROM compact_conversations WHERE id = ${DEFAULT_CONVERSATION_SESSION_ID}`;
this.logger().info('Conversation history cleared successfully');
this.broadcast(WebSocketMessageResponses.CONVERSATION_CLEARED, {
message: 'Conversation history cleared',
});
} catch (error) {
this.logger().error('Error clearing conversation history:', error);
this.broadcastError('Failed to clear conversation history', error);
}
}
/**
* Handle user input during conversational code generation
* Processes user messages and updates pendingUserInputs state
*/
async handleUserInput(userMessage: string, images?: ImageAttachment[]): Promise<void> {
try {
this.logger().info('Processing user input message', {
messageLength: userMessage.length,
pendingInputsCount: this.state.pendingUserInputs.length,
hasImages: !!images && images.length > 0,
imageCount: images?.length || 0
});
await this.behavior.handleUserInput(userMessage, images);
if (!this.behavior.isCodeGenerating()) {
// If idle, start generation process
this.logger().info('User input during IDLE state, starting generation');
this.behavior.generateAllFiles().catch(error => {
this.logger().error('Error starting generation from user input:', error);
});
}
} catch (error) {
if (error instanceof RateLimitExceededError) {
this.logger().error('Rate limit exceeded:', error);
this.broadcast(WebSocketMessageResponses.RATE_LIMIT_ERROR, {
error
});
return;
}
this.broadcastError('Error processing user input', error);
}
}
// ==========================================
// WebSocket Management
// ==========================================
/**
* Handle WebSocket message - Agent owns WebSocket lifecycle
* Delegates to centralized handler which can access both behavior and objective
*/
async onMessage(connection: Connection, message: string): Promise<void> {
await handleWebSocketMessage(this, connection, message);
}
/**
* Handle WebSocket close - Agent owns WebSocket lifecycle
*/
async onClose(connection: Connection): Promise<void> {
handleWebSocketClose(this, connection);
}
/**
* Broadcast message to all connected WebSocket clients
* Type-safe version using proper WebSocket message types
*/
public broadcast<T extends WebSocketMessageType>(
type: T,
data?: WebSocketMessageData<T>
): void {
broadcastToConnections(this, type, data || {} as WebSocketMessageData<T>);
}
protected broadcastError(context: string, error: unknown): void {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger().error(`${context}:`, error);
this.broadcast(WebSocketMessageResponses.ERROR, {
error: `${context}: ${errorMessage}`
});
}
// ==========================================
// Git Management
// ==========================================
protected async gitInit() {
try {
await this.git.init();
this.logger().info("Git initialized successfully");
// Check if there is any commit
const head = await this.git.getHead();
if (!head) {
this.logger().info("No commits found, creating initial commit");
// get all generated files and commit them
const generatedFiles = this.fileManager.getGeneratedFiles();
if (generatedFiles.length === 0) {
this.logger().info("No generated files found, skipping initial commit");
return;
}
await this.git.commit(generatedFiles, "Initial commit");
this.logger().info("Initial commit created successfully");
}
} catch (error) {
this.logger().error("Error during git init:", error);
}
}
/**
* Export git objects
* The route handler will build the repo with template rebasing
*/
async exportGitObjects(): Promise<{
gitObjects: Array<{ path: string; data: Uint8Array }>;
query: string;
hasCommits: boolean;
templateDetails: TemplateDetails | null;
}> {
try {
// Export git objects efficiently (minimal DO memory usage)
const gitObjects = this.git.fs.exportGitObjects();
await this.gitInit();
// Ensure template details are available
await this.behavior.ensureTemplateDetails();
const templateDetails = this.behavior.getTemplateDetails();
return {
gitObjects,
query: this.state.query || 'N/A',
hasCommits: gitObjects.length > 0,
templateDetails
};
} catch (error) {
this.logger().error('exportGitObjects failed', error);
throw error;
}
}
/**
* Handle browser file serving requests
*/
async handleBrowserFileServing(request: Request): Promise<Response> {
const url = new URL(request.url);
this.logger().info('[BROWSER SERVING] Request received', {
hostname: url.hostname,
pathname: url.pathname,
method: request.method
});
// Handle CORS preflight
if (request.method === 'OPTIONS') {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': '*',
'Access-Control-Max-Age': '86400'
}
});
}
// Extract token from hostname
// Pattern: b-{agentid}-{token}.{previewDomain}/{filepath}
// Token is always 16 characters after the LAST hyphen (after removing 'b-' prefix)
const subdomain = url.hostname.split('.')[0];
if (!subdomain.startsWith('b-')) {
this.logger().warn('[BROWSER SERVING] Invalid hostname pattern - missing b- prefix', { hostname: url.hostname });
return new Response('Invalid request', {
status: 400,
headers: { 'Content-Type': 'text/plain' }
});
}
const withoutPrefix = subdomain.substring(2); // Remove 'b-'
const lastHyphenIndex = withoutPrefix.lastIndexOf('-');
if (lastHyphenIndex === -1) {
this.logger().warn('[BROWSER SERVING] Invalid hostname pattern - no hyphen after prefix', { hostname: url.hostname });
return new Response('Invalid request', {
status: 400,
headers: { 'Content-Type': 'text/plain' }
});
}
const providedToken = withoutPrefix.substring(lastHyphenIndex + 1);
// Extract file path from pathname
const filePath = url.pathname === '/' || url.pathname === ''
? 'public/index.html'
: url.pathname.replace(/^\//, ''); // Remove leading slash
this.logger().info('[BROWSER SERVING] Extracted', { providedToken, filePath });
// Validate token
const storedToken = this.state.fileServingToken?.token;
if (!storedToken || providedToken !== storedToken.toLowerCase()) {
this.logger().warn('[BROWSER SERVING] Token mismatch', { providedToken, storedToken });
return new Response('Unauthorized', {
status: 403,
headers: { 'Content-Type': 'text/plain' }
});
}
if (!isPathSafe(filePath)) {
return new Response('Invalid path', {
status: 400,
headers: { 'Content-Type': 'text/plain' }
});
}
const normalized = normalizePath(filePath);
let file = this.fileManager.getFile(normalized);
// Try with public/ prefix if not found
if (!file && !normalized.startsWith('public/')) {
file = this.fileManager.getFile(`public/${normalized}`);
}
if (!file) {
this.logger().warn('[BROWSER SERVING] File not found', { normalized });
return new Response('File not found', {
status: 404,
headers: { 'Content-Type': 'text/plain' }
});
}
// Serve file with correct Content-Type
const contentType = getMimeType(normalized) || 'application/octet-stream';
this.logger().info('[BROWSER SERVING] Serving file', {
path: normalized,
contentType
});
let content = file.fileContents;
// For HTML files, inject base tag
if (normalized.endsWith('.html') || contentType.includes('text/html')) {
const baseTag = `<base href="/">`;
// Inject base tag after <head> tag if present
if (content.includes('<head>')) {
content = content.replace(/<head>/i, `<head>\n ${baseTag}`);
} else {
// Fallback: inject at the beginning
content = baseTag + '\n' + content;
}
this.logger().info('[BROWSER SERVING] Injected base tag');
}
return new Response(content, {
status: 200,
headers: {
'Content-Type': contentType,
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': '*',
'X-Sandbox-Type': 'browser-native'
}
});
}
/**
* Cache GitHub OAuth token in memory for subsequent exports
* Token is ephemeral - lost on DO eviction
*/
setGitHubToken(token: string, username: string, ttl: number = 3600000): void {
this.objective.setGitHubToken(token, username, ttl);
}
/**
* Get cached GitHub token if available and not expired
*/
getGitHubToken(): { token: string; username: string } | null {
return this.objective.getGitHubToken();
}
/**
* Clear cached GitHub token
*/
clearGitHubToken(): void {
this.objective.clearGitHubToken();
}
// ==========================================
// WebSocket Ticket Management
// ==========================================
/**
* Store a WebSocket ticket for later consumption
* Called by controller after ownership is verified via JWT
*/
storeWsTicket(ticket: PendingWsTicket): void {
this.ticketManager.store(ticket);
this.logger().info('WebSocket ticket stored', {
userId: ticket.user.id,
expiresIn: Math.floor((ticket.expiresAt - Date.now()) / 1000),
});
}
/**
* Consume a WebSocket ticket (one-time use)
* Returns user session if valid, null otherwise
*/
consumeWsTicket(token: string): TicketConsumptionResult | null {
const result = this.ticketManager.consume(token);
if (result) {
this.logger().info('Ticket consumed successfully', { userId: result.user.id });
} else {
this.logger().warn('Ticket consumption failed', { tokenPrefix: token.slice(0, 10) });
}
return result;
}
}