feat: dev env bootstrap, deferred sessions, and mock agent fallback

- Add dotenv loader for NODE_ENV=development with .env.development.example
- Add factory self-hosting deployment docs
- Defer agent session creation in handoff init (create on first prompt)
- Fall back to mock agent when Claude/Codex credentials are missing
- Replace stub mock agent launcher with real ACP JSON-RPC Node.js script
- Fix React useEffect dependency array in org settings
- Clean up tracked .context cache/temp files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nathan Flurry 2026-03-09 22:50:26 -07:00
parent e08d1b4dca
commit 3022bce2ad
317 changed files with 496 additions and 167352 deletions

View file

@ -7,7 +7,6 @@ import {
getOrCreateHistory,
getOrCreateProject,
getOrCreateSandboxInstance,
getSandboxInstance,
selfHandoff
} from "../../handles.js";
import { logActorWarning, resolveErrorMessage } from "../../logging.js";
@ -15,7 +14,6 @@ import { handoff as handoffTable, handoffRuntime, handoffSandboxes } from "../db
import {
HANDOFF_ROW_ID,
appendHistory,
buildAgentPrompt,
collectErrorMessages,
resolveErrorDetail,
setHandoffState
@ -394,7 +392,7 @@ export async function initCreateSessionActivity(
sandbox: any,
sandboxInstanceReady: any
): Promise<any> {
await setHandoffState(loopCtx, "init_create_session", "creating agent session");
await setHandoffState(loopCtx, "init_create_session", "deferring agent session creation");
if (!sandboxInstanceReady.ok) {
return {
id: null,
@ -403,20 +401,11 @@ export async function initCreateSessionActivity(
} as const;
}
const { config } = getActorRuntimeContext();
const providerId = body?.providerId ?? loopCtx.state.providerId;
const sandboxInstance = getSandboxInstance(loopCtx, loopCtx.state.workspaceId, providerId, sandbox.sandboxId);
const cwd =
sandbox.metadata && typeof (sandbox.metadata as any).cwd === "string"
? ((sandbox.metadata as any).cwd as string)
: undefined;
return await sandboxInstance.createSession({
prompt: buildAgentPrompt(loopCtx.state.task),
cwd,
agent: (loopCtx.state.agentType ?? config.default_agent) as any
});
return {
id: null,
status: "idle",
deferred: true,
} as const;
}
export async function initWriteDbActivity(
@ -432,10 +421,13 @@ export async function initWriteDbActivity(
const now = Date.now();
const db = loopCtx.db;
const sessionId = session?.id ?? null;
const sessionHealthy = Boolean(sessionId) && session?.status !== "error";
const activeSessionId = sessionHealthy ? sessionId : null;
const sessionDeferred = !sessionId;
const sessionHealthy = sessionDeferred || session?.status !== "error";
const activeSessionId = sessionDeferred ? null : sessionHealthy ? sessionId : null;
const statusMessage =
sessionHealthy
sessionDeferred
? "ready"
: sessionHealthy
? "session created"
: session?.status === "error"
? (session.error ?? "session create failed")
@ -454,7 +446,7 @@ export async function initWriteDbActivity(
.update(handoffTable)
.set({
providerId,
status: sessionHealthy ? "running" : "error",
status: sessionHealthy ? "idle" : "error",
agentType: loopCtx.state.agentType ?? config.default_agent,
updatedAt: now
})
@ -549,7 +541,7 @@ export async function initStartStatusSyncActivity(
export async function initCompleteActivity(loopCtx: any, body: any, sandbox: any, session: any): Promise<void> {
const providerId = body?.providerId ?? loopCtx.state.providerId;
const sessionId = session?.id ?? null;
const sessionHealthy = Boolean(sessionId) && session?.status !== "error";
const sessionHealthy = !sessionId || session?.status !== "error";
if (sessionHealthy) {
await setHandoffState(loopCtx, "init_complete", "handoff initialized");
@ -558,7 +550,7 @@ export async function initCompleteActivity(loopCtx: any, body: any, sandbox: any
kind: "handoff.initialized",
handoffId: loopCtx.state.handoffId,
branchName: loopCtx.state.branchName,
payload: { providerId, sandboxId: sandbox.sandboxId, sessionId }
payload: { providerId, sandboxId: sandbox.sandboxId, sessionId: sessionId ?? null }
});
loopCtx.state.initialized = true;

View file

@ -0,0 +1,79 @@
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
const DEVELOPMENT_ENV_FILES = [".env.development.local", ".env.development"] as const;
const LOCAL_DEV_BETTER_AUTH_SECRET = "sandbox-agent-factory-development-only-change-me";
const LOCAL_DEV_APP_URL = "http://localhost:4173";
function loadEnvFile(path: string): void {
const source = readFileSync(path, "utf8");
for (const line of source.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) {
continue;
}
const normalized = trimmed.startsWith("export ") ? trimmed.slice("export ".length).trim() : trimmed;
const separatorIndex = normalized.indexOf("=");
if (separatorIndex <= 0) {
continue;
}
const key = normalized.slice(0, separatorIndex).trim();
if (!key || process.env[key] != null) {
continue;
}
let value = normalized.slice(separatorIndex + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
process.env[key] = value;
}
}
export function isDevelopmentEnv(): boolean {
return process.env.NODE_ENV === "development";
}
export function loadDevelopmentEnvFiles(cwd = process.cwd()): string[] {
if (!isDevelopmentEnv()) {
return [];
}
const loaded: string[] = [];
for (const fileName of DEVELOPMENT_ENV_FILES) {
const path = resolve(cwd, fileName);
if (!existsSync(path)) {
continue;
}
loadEnvFile(path);
loaded.push(path);
}
return loaded;
}
export function applyDevelopmentEnvDefaults(): void {
if (!isDevelopmentEnv()) {
return;
}
if (!process.env.APP_URL) {
process.env.APP_URL = LOCAL_DEV_APP_URL;
}
if (!process.env.BETTER_AUTH_URL) {
process.env.BETTER_AUTH_URL = process.env.APP_URL;
}
if (!process.env.BETTER_AUTH_SECRET) {
process.env.BETTER_AUTH_SECRET = LOCAL_DEV_BETTER_AUTH_SECRET;
}
if (!process.env.GITHUB_REDIRECT_URI && process.env.APP_URL) {
process.env.GITHUB_REDIRECT_URI = `${process.env.APP_URL.replace(/\/$/, "")}/api/rivet/app/auth/github/callback`;
}
}

View file

@ -4,6 +4,7 @@ import { initActorRuntimeContext } from "./actors/context.js";
import { registry, resolveManagerPort } from "./actors/index.js";
import { workspaceKey } from "./actors/keys.js";
import { loadConfig } from "./config/backend.js";
import { applyDevelopmentEnvDefaults, loadDevelopmentEnvFiles } from "./config/env.js";
import { createBackends, createNotificationService } from "./notifications/index.js";
import { createDefaultDriver } from "./driver.js";
import { createProviderRegistry } from "./providers/index.js";
@ -17,6 +18,9 @@ export interface BackendStartOptions {
}
export async function startBackend(options: BackendStartOptions = {}): Promise<void> {
loadDevelopmentEnvFiles();
applyDevelopmentEnvDefaults();
// 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) {

View file

@ -9,7 +9,7 @@ import type {
} from "sandbox-agent";
import { SandboxAgent } from "sandbox-agent";
export type AgentId = AgentType | "opencode";
export type AgentId = AgentType | "opencode" | "mock";
export interface SandboxSession {
id: string;
@ -37,6 +37,24 @@ export interface SandboxAgentClientOptions {
const DEFAULT_AGENT: AgentId = "codex";
function hasClaudeCredentials(): boolean {
return Boolean(process.env.ANTHROPIC_API_KEY || process.env.CLAUDE_API_KEY);
}
function hasCodexCredentials(): boolean {
return Boolean(process.env.CODEX_API_KEY || process.env.OPENAI_API_KEY);
}
function resolveAvailableAgent(requested: AgentId): AgentId {
if (requested === "claude") {
return hasClaudeCredentials() ? requested : "mock";
}
if (requested === "codex") {
return hasCodexCredentials() ? requested : "mock";
}
return requested;
}
function modeIdForAgent(agent: AgentId): string | null {
switch (agent) {
case "codex":
@ -130,18 +148,19 @@ export class SandboxAgentClient {
typeof request === "string"
? { prompt: request }
: request;
const resolvedAgent = resolveAvailableAgent(normalized.agent ?? this.agent);
const sdk = await this.sdk();
// Do not wrap createSession in a local Promise.race timeout. The underlying SDK
// call is not abortable, so local timeout races create overlapping ACP requests and
// can produce duplicate/orphaned sessions while the original request is still running.
const session = await sdk.createSession({
agent: normalized.agent ?? this.agent,
agent: resolvedAgent,
sessionInit: {
cwd: normalized.cwd ?? "/",
mcpServers: [],
},
});
const modeId = modeIdForAgent(normalized.agent ?? this.agent);
const modeId = modeIdForAgent(resolvedAgent);
// Codex defaults to a restrictive "read-only" preset in some environments.
// For Sandbox Agent Factory automation we need to allow edits + command execution + network
@ -359,7 +378,7 @@ export class SandboxAgentClient {
const sdk = await this.sdk();
const session = await sdk.createSession({
agent: this.agent,
agent: resolveAvailableAgent(this.agent),
sessionInit: {
cwd: dir,
mcpServers: [],