sandbox-agent/foundry/packages/backend/src/index.ts
Nathan Flurry 70d31f819c
chore(foundry): improve sandbox impl + status pill (#252)
* Improve Daytona sandbox provisioning and frontend UI

Refactor git clone script in Daytona provider to use cleaner shell logic for GitHub token authentication and branch checkout. Add support for private repository clones with token-based auth. Improve Daytona provider error handling and git configuration setup.

Frontend improvements include enhanced dev panel, workspace dashboard, sidebar navigation, and UI components for better task/session management. Update interest manager and backend client to support improved session state handling.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* Add header status pill showing task/session/sandbox state

Surface aggregate status (error, provisioning, running, ready, no sandbox)
as a colored pill in the transcript panel header. Integrates task runtime
status, session status, and sandbox availability via the sandboxProcesses
interest topic so the pill accurately reflects unreachable sandboxes.

Includes mock tasks demonstrating error, provisioning, and running states,
unit tests for deriveHeaderStatus, and workspace-dashboard integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-14 12:14:06 -07:00

362 lines
11 KiB
TypeScript

import { Hono } from "hono";
import { cors } from "hono/cors";
import { randomUUID } from "node:crypto";
import { initActorRuntimeContext } from "./actors/context.js";
import { registry } from "./actors/index.js";
import { workspaceKey } from "./actors/keys.js";
import { loadConfig } from "./config/backend.js";
import { createBackends, createNotificationService } from "./notifications/index.js";
import { createDefaultDriver } from "./driver.js";
import { createClient } from "rivetkit/client";
import { initBetterAuthService } from "./services/better-auth.js";
import { createDefaultAppShellServices } from "./services/app-shell-runtime.js";
import { APP_SHELL_WORKSPACE_ID } from "./actors/workspace/app-shell.js";
import { logger } from "./logging.js";
export interface BackendStartOptions {
host?: string;
port?: number;
}
interface AppWorkspaceLogContext {
action?: string;
cfConnectingIp?: string;
cfRay?: string;
forwardedFor?: string;
forwardedHost?: string;
forwardedProto?: string;
method?: string;
path?: string;
requestId?: string;
referer?: string;
secFetchDest?: string;
secFetchMode?: string;
secFetchSite?: string;
secFetchUser?: string;
sessionId?: string;
userAgent?: string;
xRealIp?: string;
}
function stripTrailingSlash(value: string): string {
return value.replace(/\/$/, "");
}
function isRivetRequest(request: Request): boolean {
const { pathname } = new URL(request.url);
return pathname === "/v1/rivet" || pathname.startsWith("/v1/rivet/");
}
export async function startBackend(options: BackendStartOptions = {}): Promise<void> {
// sandbox-agent agent plugins vary on which env var they read for OpenAI/Codex auth.
// Normalize to keep local dev + docker-compose simple.
if (!process.env.CODEX_API_KEY && process.env.OPENAI_API_KEY) {
process.env.CODEX_API_KEY = process.env.OPENAI_API_KEY;
}
const config = loadConfig();
config.backend.host = options.host ?? config.backend.host;
config.backend.port = options.port ?? config.backend.port;
// Allow docker-compose/dev environments to supply provider config via env vars
// instead of writing into the container's config.toml.
const envFirst = (...keys: string[]): string | undefined => {
for (const key of keys) {
const raw = process.env[key];
if (raw && raw.trim().length > 0) return raw.trim();
}
return undefined;
};
config.providers.e2b.apiKey = envFirst("E2B_API_KEY") ?? config.providers.e2b.apiKey;
config.providers.e2b.template = envFirst("HF_E2B_TEMPLATE", "E2B_TEMPLATE") ?? config.providers.e2b.template;
const driver = createDefaultDriver();
const backends = await createBackends(config.notify);
const notifications = createNotificationService(backends);
const appShellServices = createDefaultAppShellServices();
initActorRuntimeContext(config, notifications, driver, appShellServices);
const actorClient = createClient({
endpoint: `http://127.0.0.1:${config.backend.port}/v1/rivet`,
}) as any;
const betterAuth = initBetterAuthService(actorClient, {
apiUrl: appShellServices.apiUrl,
appUrl: appShellServices.appUrl,
});
const requestHeaderContext = (c: any): AppWorkspaceLogContext => ({
cfConnectingIp: c.req.header("cf-connecting-ip") ?? undefined,
cfRay: c.req.header("cf-ray") ?? undefined,
forwardedFor: c.req.header("x-forwarded-for") ?? undefined,
forwardedHost: c.req.header("x-forwarded-host") ?? undefined,
forwardedProto: c.req.header("x-forwarded-proto") ?? undefined,
referer: c.req.header("referer") ?? undefined,
secFetchDest: c.req.header("sec-fetch-dest") ?? undefined,
secFetchMode: c.req.header("sec-fetch-mode") ?? undefined,
secFetchSite: c.req.header("sec-fetch-site") ?? undefined,
secFetchUser: c.req.header("sec-fetch-user") ?? undefined,
userAgent: c.req.header("user-agent") ?? undefined,
xRealIp: c.req.header("x-real-ip") ?? undefined,
});
// Serve custom Foundry HTTP APIs alongside the RivetKit registry.
const app = new Hono<{ Variables: { requestId: string } }>();
const allowHeaders = [
"Content-Type",
"Authorization",
"x-rivet-token",
"x-rivet-encoding",
"x-rivet-query",
"x-rivet-conn-params",
"x-rivet-actor",
"x-rivet-target",
"x-rivet-namespace",
"x-rivet-endpoint",
"x-rivet-total-slots",
"x-rivet-runner-name",
"x-rivet-namespace-name",
];
const exposeHeaders = ["Content-Type", "x-rivet-ray-id"];
const allowedOrigins = new Set([stripTrailingSlash(appShellServices.appUrl), stripTrailingSlash(appShellServices.apiUrl)]);
const corsConfig = {
origin: (origin: string) => (allowedOrigins.has(origin) ? origin : null) as string | undefined | null,
credentials: true,
allowHeaders,
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
exposeHeaders,
};
app.use("/v1/*", cors(corsConfig));
app.use("/v1", cors(corsConfig));
app.use("*", async (c, next) => {
const requestId = c.req.header("x-request-id")?.trim() || randomUUID();
const start = performance.now();
c.set("requestId", requestId);
c.header("x-request-id", requestId);
try {
await next();
} catch (error) {
logger.error(
{
...requestHeaderContext(c),
requestId,
method: c.req.method,
path: c.req.path,
errorMessage: error instanceof Error ? error.message : String(error),
errorStack: error instanceof Error ? error.stack : undefined,
},
"http_request_failed",
);
throw error;
}
logger.info(
{
...requestHeaderContext(c),
requestId,
method: c.req.method,
path: c.req.path,
status: c.res.status,
durationMs: Math.round((performance.now() - start) * 100) / 100,
},
"http_request",
);
});
// Cache the app workspace actor handle for the lifetime of this backend process.
// The "app" workspace is a singleton coordinator for auth indexes, org state, and
// billing. Caching avoids repeated getOrCreate round-trips on every HTTP request.
let cachedAppWorkspace: any | null = null;
const appWorkspace = async (context: AppWorkspaceLogContext = {}) => {
if (cachedAppWorkspace) return cachedAppWorkspace;
const start = performance.now();
try {
const handle = await actorClient.workspace.getOrCreate(workspaceKey(APP_SHELL_WORKSPACE_ID), {
createWithInput: APP_SHELL_WORKSPACE_ID,
});
cachedAppWorkspace = handle;
logger.info(
{
...context,
cache: "miss",
durationMs: Math.round((performance.now() - start) * 100) / 100,
},
"app_workspace_resolve",
);
return handle;
} catch (error) {
logger.error(
{
...context,
cache: "miss",
durationMs: Math.round((performance.now() - start) * 100) / 100,
errorMessage: error instanceof Error ? error.message : String(error),
errorStack: error instanceof Error ? error.stack : undefined,
},
"app_workspace_resolve_failed",
);
throw error;
}
};
const requestLogContext = (c: any, sessionId?: string): AppWorkspaceLogContext => ({
...requestHeaderContext(c),
method: c.req.method,
path: c.req.path,
requestId: c.get("requestId"),
sessionId,
});
const resolveSessionId = async (c: any): Promise<string | null> => {
const session = await betterAuth.resolveSession(c.req.raw.headers);
return session?.session?.id ?? null;
};
app.all("/v1/auth/*", async (c) => {
return await betterAuth.auth.handler(c.req.raw);
});
app.post("/v1/app/sign-out", async (c) => {
const sessionId = await resolveSessionId(c);
if (sessionId) {
const signOutResponse = await betterAuth.signOut(c.req.raw.headers);
const setCookie = signOutResponse.headers.get("set-cookie");
if (setCookie) {
c.header("set-cookie", setCookie);
}
}
return c.json({
auth: { status: "signed_out", currentUserId: null },
activeOrganizationId: null,
onboarding: {
starterRepo: {
repoFullName: "rivet-dev/sandbox-agent",
repoUrl: "https://github.com/rivet-dev/sandbox-agent",
status: "pending",
starredAt: null,
skippedAt: null,
},
},
users: [],
organizations: [],
});
});
app.get("/v1/billing/checkout/complete", async (c) => {
const organizationId = c.req.query("organizationId");
const checkoutSessionId = c.req.query("session_id");
if (!organizationId || !checkoutSessionId) {
return c.text("Missing Stripe checkout completion parameters", 400);
}
const sessionId = await resolveSessionId(c);
if (!sessionId) {
return c.text("Unauthorized", 401);
}
const result = await (await appWorkspace(requestLogContext(c, sessionId))).finalizeAppCheckoutSession({
organizationId,
sessionId,
checkoutSessionId,
});
return Response.redirect(result.redirectTo, 302);
});
const handleStripeWebhook = async (c: any) => {
const payload = await c.req.text();
await (await appWorkspace(requestLogContext(c))).handleAppStripeWebhook({
payload,
signatureHeader: c.req.header("stripe-signature") ?? null,
});
return c.json({ ok: true });
};
app.post("/v1/webhooks/stripe", handleStripeWebhook);
app.post("/v1/webhooks/github", async (c) => {
const payload = await c.req.text();
await (await appWorkspace(requestLogContext(c))).handleAppGithubWebhook({
payload,
signatureHeader: c.req.header("x-hub-signature-256") ?? null,
eventHeader: c.req.header("x-github-event") ?? null,
});
return c.json({ ok: true });
});
const server = Bun.serve({
fetch: (request) => {
if (isRivetRequest(request)) {
return registry.handler(request);
}
return app.fetch(request);
},
hostname: config.backend.host,
port: config.backend.port,
});
logger.info(
{
host: config.backend.host,
port: config.backend.port,
},
"backend_started",
);
process.on("SIGINT", async () => {
server.stop();
process.exit(0);
});
process.on("SIGTERM", async () => {
server.stop();
process.exit(0);
});
// Keep process alive.
await new Promise<void>(() => undefined);
}
function parseArg(flag: string): string | undefined {
const idx = process.argv.indexOf(flag);
if (idx < 0) return undefined;
return process.argv[idx + 1];
}
function parseEnvPort(value: string | undefined): number | undefined {
if (!value) {
return undefined;
}
const port = Number(value);
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
return undefined;
}
return port;
}
async function main(): Promise<void> {
const cmd = process.argv[2] ?? "start";
if (cmd !== "start") {
throw new Error(`Unsupported backend command: ${cmd}`);
}
const host = parseArg("--host") ?? process.env.HOST ?? process.env.HF_BACKEND_HOST;
const port = parseArg("--port") ?? process.env.PORT ?? process.env.HF_BACKEND_PORT;
await startBackend({
host,
port: parseEnvPort(port),
});
}
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err: unknown) => {
logger.fatal(
{
errorMessage: err instanceof Error ? err.message : String(err),
errorStack: err instanceof Error ? err.stack : undefined,
},
"backend_start_failed",
);
process.exit(1);
});
}