|
|
|
@@ -0,0 +1,667 @@
|
|
|
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
|
|
import { dirname } from "node:path";
|
|
|
|
|
import { createHourlyJsonlWriter, logRetentionBytesForService } from "../../../shared/src/rotating-jsonl";
|
|
|
|
|
|
|
|
|
|
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
|
|
|
|
|
type JsonRecord = Record<string, JsonValue>;
|
|
|
|
|
type SandboxMode = "full-isolation" | "half-isolation" | "bridge";
|
|
|
|
|
type AdapterName = "codex" | "opencode" | "future-agent";
|
|
|
|
|
type AgentStatus = "idle" | "starting" | "attached" | "prompting" | "steering" | "interrupted" | "resumed" | "forked" | "finished" | "failed";
|
|
|
|
|
type TerminalStatus = "completed" | "interrupted" | "failed" | "unknown";
|
|
|
|
|
|
|
|
|
|
interface RuntimeConfig {
|
|
|
|
|
host: string;
|
|
|
|
|
port: number;
|
|
|
|
|
dataDir: string;
|
|
|
|
|
logFile: string;
|
|
|
|
|
stateFile: string;
|
|
|
|
|
adapterSummaryFile: string;
|
|
|
|
|
envFilePath: string;
|
|
|
|
|
configTomlPath: string;
|
|
|
|
|
authJsonPath: string;
|
|
|
|
|
sandboxMode: SandboxMode;
|
|
|
|
|
tokenProvider: string;
|
|
|
|
|
tokenProviderSwitchAllowed: boolean;
|
|
|
|
|
configWritable: boolean;
|
|
|
|
|
authWritable: boolean;
|
|
|
|
|
envWritable: boolean;
|
|
|
|
|
adapterNames: AdapterName[];
|
|
|
|
|
defaultAdapter: AdapterName;
|
|
|
|
|
maxPromptChars: number;
|
|
|
|
|
maxTraceItems: number;
|
|
|
|
|
deployServiceId: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface AdapterState {
|
|
|
|
|
name: AdapterName;
|
|
|
|
|
status: AgentStatus;
|
|
|
|
|
terminalStatus: TerminalStatus;
|
|
|
|
|
startedAt: string | null;
|
|
|
|
|
updatedAt: string | null;
|
|
|
|
|
threadId: string | null;
|
|
|
|
|
turnId: string | null;
|
|
|
|
|
promptCount: number;
|
|
|
|
|
steerCount: number;
|
|
|
|
|
interruptCount: number;
|
|
|
|
|
resumeCount: number;
|
|
|
|
|
forkCount: number;
|
|
|
|
|
lastPrompt: string | null;
|
|
|
|
|
lastSteer: string | null;
|
|
|
|
|
lastArtifactSummary: JsonRecord | null;
|
|
|
|
|
notes: string[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface SandboxSnapshot {
|
|
|
|
|
serviceId: string;
|
|
|
|
|
serviceName: string;
|
|
|
|
|
mode: SandboxMode;
|
|
|
|
|
tokenProvider: string;
|
|
|
|
|
tokenProviderSwitchAllowed: boolean;
|
|
|
|
|
configWritable: boolean;
|
|
|
|
|
authWritable: boolean;
|
|
|
|
|
envWritable: boolean;
|
|
|
|
|
configTomlText: string;
|
|
|
|
|
authJsonText: string;
|
|
|
|
|
envVars: Record<string, string>;
|
|
|
|
|
adapters: Record<string, AdapterState>;
|
|
|
|
|
createdAt: string;
|
|
|
|
|
updatedAt: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const startedAt = new Date().toISOString();
|
|
|
|
|
const recentLogs: JsonRecord[] = [];
|
|
|
|
|
|
|
|
|
|
function envString(name: string, fallback: string): string {
|
|
|
|
|
const value = process.env[name];
|
|
|
|
|
return value === undefined || value.length === 0 ? fallback : value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function envNumber(name: string, fallback: number): number {
|
|
|
|
|
const raw = process.env[name];
|
|
|
|
|
if (raw === undefined || raw.trim().length === 0) return fallback;
|
|
|
|
|
const parsed = Number(raw);
|
|
|
|
|
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
|
|
|
|
return Math.floor(parsed);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function envBoolean(name: string, fallback: boolean): boolean {
|
|
|
|
|
const raw = process.env[name];
|
|
|
|
|
if (raw === undefined || raw.trim().length === 0) return fallback;
|
|
|
|
|
return ["1", "true", "yes", "on"].includes(raw.trim().toLowerCase());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseSandboxMode(value: string): SandboxMode {
|
|
|
|
|
const normalized = value.trim().toLowerCase();
|
|
|
|
|
if (normalized === "full-isolation" || normalized === "full" || normalized === "isolated") return "full-isolation";
|
|
|
|
|
if (normalized === "half-isolation" || normalized === "half" || normalized === "semi-isolated") return "half-isolation";
|
|
|
|
|
if (normalized === "bridge" || normalized === "bridged") return "bridge";
|
|
|
|
|
throw new Error(`unsupported CODE_AGENT_SANDBOX_MODE: ${value}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseAdapterNames(value: string): AdapterName[] {
|
|
|
|
|
const names = value.split(",").map((item) => item.trim().toLowerCase()).filter(Boolean);
|
|
|
|
|
const allowed: AdapterName[] = [];
|
|
|
|
|
for (const name of names) {
|
|
|
|
|
if (name === "codex" || name === "opencode" || name === "future-agent") {
|
|
|
|
|
allowed.push(name);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
throw new Error(`unsupported adapter name: ${name}`);
|
|
|
|
|
}
|
|
|
|
|
return Array.from(new Set(allowed));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildConfig(): RuntimeConfig {
|
|
|
|
|
const port = envNumber("PORT", 4260);
|
|
|
|
|
const adapterNames = parseAdapterNames(envString("CODE_AGENT_SANDBOX_ADAPTERS", "codex,opencode,future-agent"));
|
|
|
|
|
const defaultAdapter = envString("CODE_AGENT_SANDBOX_DEFAULT_ADAPTER", "codex").trim().toLowerCase() as AdapterName;
|
|
|
|
|
if (!adapterNames.includes(defaultAdapter)) {
|
|
|
|
|
throw new Error(`CODE_AGENT_SANDBOX_DEFAULT_ADAPTER must be one of: ${adapterNames.join(", ")}`);
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
host: envString("HOST", "0.0.0.0"),
|
|
|
|
|
port,
|
|
|
|
|
dataDir: envString("CODE_AGENT_SANDBOX_DATA_DIR", "/var/lib/unidesk/code-agent-sandbox"),
|
|
|
|
|
logFile: envString("LOG_FILE", "/var/log/unidesk/code-agent-sandbox.jsonl"),
|
|
|
|
|
stateFile: envString("CODE_AGENT_SANDBOX_STATE_FILE", "/var/lib/unidesk/code-agent-sandbox/state.json"),
|
|
|
|
|
adapterSummaryFile: envString("CODE_AGENT_SANDBOX_ADAPTER_SUMMARY_FILE", "/var/lib/unidesk/code-agent-sandbox/adapters.json"),
|
|
|
|
|
envFilePath: envString("CODE_AGENT_SANDBOX_ENV_FILE_PATH", "/var/lib/unidesk/code-agent-sandbox/env.json"),
|
|
|
|
|
configTomlPath: envString("CODE_AGENT_SANDBOX_CONFIG_TOML_PATH", "/var/lib/unidesk/code-agent-sandbox/config.toml"),
|
|
|
|
|
authJsonPath: envString("CODE_AGENT_SANDBOX_AUTH_JSON_PATH", "/var/lib/unidesk/code-agent-sandbox/auth.json"),
|
|
|
|
|
sandboxMode: parseSandboxMode(envString("CODE_AGENT_SANDBOX_MODE", "half-isolation")),
|
|
|
|
|
tokenProvider: envString("CODE_AGENT_SANDBOX_TOKEN_PROVIDER", "codex"),
|
|
|
|
|
tokenProviderSwitchAllowed: envBoolean("CODE_AGENT_SANDBOX_TOKEN_PROVIDER_SWITCH_ALLOWED", true),
|
|
|
|
|
configWritable: envBoolean("CODE_AGENT_SANDBOX_CONFIG_WRITABLE", true),
|
|
|
|
|
authWritable: envBoolean("CODE_AGENT_SANDBOX_AUTH_WRITABLE", true),
|
|
|
|
|
envWritable: envBoolean("CODE_AGENT_SANDBOX_ENV_WRITABLE", true),
|
|
|
|
|
adapterNames,
|
|
|
|
|
defaultAdapter,
|
|
|
|
|
maxPromptChars: envNumber("CODE_AGENT_SANDBOX_MAX_PROMPT_CHARS", 12_000),
|
|
|
|
|
maxTraceItems: envNumber("CODE_AGENT_SANDBOX_MAX_TRACE_ITEMS", 200),
|
|
|
|
|
deployServiceId: envString("UNIDESK_DEPLOY_SERVICE_ID", "code-agent-sandbox"),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const config = buildConfig();
|
|
|
|
|
let logWriter: ReturnType<typeof createHourlyJsonlWriter> | null = null;
|
|
|
|
|
|
|
|
|
|
function getLogWriter(): ReturnType<typeof createHourlyJsonlWriter> {
|
|
|
|
|
if (logWriter === null) {
|
|
|
|
|
mkdirSync(config.dataDir, { recursive: true });
|
|
|
|
|
logWriter = createHourlyJsonlWriter({
|
|
|
|
|
baseLogFile: config.logFile,
|
|
|
|
|
service: "code-agent-sandbox",
|
|
|
|
|
maxBytes: logRetentionBytesForService("code-agent-sandbox"),
|
|
|
|
|
});
|
|
|
|
|
logWriter.prune();
|
|
|
|
|
}
|
|
|
|
|
return logWriter;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function log(event: string, detail: JsonRecord = {}): void {
|
|
|
|
|
const record: JsonRecord = { at: new Date().toISOString(), service: "code-agent-sandbox", event, ...detail };
|
|
|
|
|
recentLogs.push(record);
|
|
|
|
|
while (recentLogs.length > 500) recentLogs.shift();
|
|
|
|
|
try {
|
|
|
|
|
getLogWriter().appendJson(record, new Date(String(record.at)));
|
|
|
|
|
} catch {
|
|
|
|
|
// Logging must not break the sandbox control plane.
|
|
|
|
|
}
|
|
|
|
|
console.log(JSON.stringify(record));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function jsonResponse(body: unknown, status = 200, headers: Record<string, string> = {}): Response {
|
|
|
|
|
return new Response(JSON.stringify(body), {
|
|
|
|
|
status,
|
|
|
|
|
headers: { "content-type": "application/json; charset=utf-8", ...headers },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function errorToJson(error: unknown): JsonRecord {
|
|
|
|
|
if (error instanceof Error) return { name: error.name, message: error.message, stack: error.stack ?? "" };
|
|
|
|
|
return { message: String(error) };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function errorResponse(error: unknown, status = 500): Response {
|
|
|
|
|
log("request_failed", { status, error: errorToJson(error) });
|
|
|
|
|
return jsonResponse({ ok: false, error: error instanceof Error ? error.message : String(error) }, status);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clampText(value: string, maxChars: number): string {
|
|
|
|
|
const compact = value.replace(/\s+/gu, " ").trim();
|
|
|
|
|
return compact.length <= maxChars ? compact : `${compact.slice(0, Math.max(0, maxChars - 12))}…<truncated>`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function nowIso(): string {
|
|
|
|
|
return new Date().toISOString();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readTextOrEmpty(path: string): string {
|
|
|
|
|
if (!existsSync(path)) return "";
|
|
|
|
|
try {
|
|
|
|
|
return readFileSync(path, "utf8");
|
|
|
|
|
} catch {
|
|
|
|
|
return "";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readJsonObjectOrEmpty(path: string): Record<string, string> {
|
|
|
|
|
const raw = readTextOrEmpty(path).trim();
|
|
|
|
|
if (raw.length === 0) return {};
|
|
|
|
|
try {
|
|
|
|
|
const parsed = JSON.parse(raw) as unknown;
|
|
|
|
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
|
|
|
|
|
return Object.fromEntries(Object.entries(parsed).flatMap(([key, value]) => typeof value === "string" ? [[key, value]] : []));
|
|
|
|
|
} catch {
|
|
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function loadState(): SandboxSnapshot {
|
|
|
|
|
try {
|
|
|
|
|
if (!existsSync(config.stateFile)) throw new Error("state file missing");
|
|
|
|
|
const parsed = JSON.parse(readFileSync(config.stateFile, "utf8")) as unknown;
|
|
|
|
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("state file is not an object");
|
|
|
|
|
const record = parsed as Record<string, unknown>;
|
|
|
|
|
const adapters = typeof record.adapters === "object" && record.adapters !== null && !Array.isArray(record.adapters)
|
|
|
|
|
? record.adapters as Record<string, unknown>
|
|
|
|
|
: {};
|
|
|
|
|
const fresh = freshSnapshot();
|
|
|
|
|
return {
|
|
|
|
|
serviceId: String(record.serviceId ?? fresh.serviceId),
|
|
|
|
|
serviceName: String(record.serviceName ?? fresh.serviceName),
|
|
|
|
|
mode: parseSandboxMode(String(record.mode ?? fresh.mode)),
|
|
|
|
|
tokenProvider: String(record.tokenProvider ?? fresh.tokenProvider),
|
|
|
|
|
tokenProviderSwitchAllowed: typeof record.tokenProviderSwitchAllowed === "boolean"
|
|
|
|
|
? record.tokenProviderSwitchAllowed
|
|
|
|
|
: fresh.tokenProviderSwitchAllowed,
|
|
|
|
|
configWritable: typeof record.configWritable === "boolean" ? record.configWritable : fresh.configWritable,
|
|
|
|
|
authWritable: typeof record.authWritable === "boolean" ? record.authWritable : fresh.authWritable,
|
|
|
|
|
envWritable: typeof record.envWritable === "boolean" ? record.envWritable : fresh.envWritable,
|
|
|
|
|
configTomlText: typeof record.configTomlText === "string" ? record.configTomlText : readTextOrEmpty(config.configTomlPath),
|
|
|
|
|
authJsonText: typeof record.authJsonText === "string" ? record.authJsonText : readTextOrEmpty(config.authJsonPath),
|
|
|
|
|
envVars: normalizeEnvVars(record.envVars, readJsonObjectOrEmpty(config.envFilePath)),
|
|
|
|
|
adapters: Object.fromEntries(config.adapterNames.map((name) => [name, normalizeAdapterState(name, adapters[name] ?? fresh.adapters[name])])),
|
|
|
|
|
createdAt: String(record.createdAt ?? fresh.createdAt),
|
|
|
|
|
updatedAt: String(record.updatedAt ?? fresh.updatedAt),
|
|
|
|
|
};
|
|
|
|
|
} catch {
|
|
|
|
|
return freshSnapshot();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeAdapterState(name: string, value: unknown): AdapterState {
|
|
|
|
|
const record = typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
|
|
|
return {
|
|
|
|
|
name: name as AdapterName,
|
|
|
|
|
status: (record.status as AgentStatus) ?? "idle",
|
|
|
|
|
terminalStatus: (record.terminalStatus as TerminalStatus) ?? "unknown",
|
|
|
|
|
startedAt: typeof record.startedAt === "string" ? record.startedAt : null,
|
|
|
|
|
updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : null,
|
|
|
|
|
threadId: typeof record.threadId === "string" ? record.threadId : null,
|
|
|
|
|
turnId: typeof record.turnId === "string" ? record.turnId : null,
|
|
|
|
|
promptCount: Number(record.promptCount ?? 0) || 0,
|
|
|
|
|
steerCount: Number(record.steerCount ?? 0) || 0,
|
|
|
|
|
interruptCount: Number(record.interruptCount ?? 0) || 0,
|
|
|
|
|
resumeCount: Number(record.resumeCount ?? 0) || 0,
|
|
|
|
|
forkCount: Number(record.forkCount ?? 0) || 0,
|
|
|
|
|
lastPrompt: typeof record.lastPrompt === "string" ? record.lastPrompt : null,
|
|
|
|
|
lastSteer: typeof record.lastSteer === "string" ? record.lastSteer : null,
|
|
|
|
|
lastArtifactSummary: typeof record.lastArtifactSummary === "object" && record.lastArtifactSummary !== null && !Array.isArray(record.lastArtifactSummary)
|
|
|
|
|
? record.lastArtifactSummary as JsonRecord
|
|
|
|
|
: null,
|
|
|
|
|
notes: Array.isArray(record.notes) ? record.notes.filter((item): item is string => typeof item === "string") : [],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function freshSnapshot(): SandboxSnapshot {
|
|
|
|
|
const adapters = Object.fromEntries(config.adapterNames.map((name) => [name, freshAdapterState(name)]));
|
|
|
|
|
return {
|
|
|
|
|
serviceId: config.deployServiceId || "code-agent-sandbox",
|
|
|
|
|
serviceName: "Code Agent Sandbox",
|
|
|
|
|
mode: config.sandboxMode,
|
|
|
|
|
tokenProvider: config.tokenProvider,
|
|
|
|
|
tokenProviderSwitchAllowed: config.tokenProviderSwitchAllowed,
|
|
|
|
|
configWritable: config.configWritable,
|
|
|
|
|
authWritable: config.authWritable,
|
|
|
|
|
envWritable: config.envWritable,
|
|
|
|
|
configTomlText: "",
|
|
|
|
|
authJsonText: "{}",
|
|
|
|
|
envVars: {},
|
|
|
|
|
adapters,
|
|
|
|
|
createdAt: startedAt,
|
|
|
|
|
updatedAt: startedAt,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function freshAdapterState(name: AdapterName): AdapterState {
|
|
|
|
|
return {
|
|
|
|
|
name,
|
|
|
|
|
status: "idle",
|
|
|
|
|
terminalStatus: "unknown",
|
|
|
|
|
startedAt: null,
|
|
|
|
|
updatedAt: null,
|
|
|
|
|
threadId: null,
|
|
|
|
|
turnId: null,
|
|
|
|
|
promptCount: 0,
|
|
|
|
|
steerCount: 0,
|
|
|
|
|
interruptCount: 0,
|
|
|
|
|
resumeCount: 0,
|
|
|
|
|
forkCount: 0,
|
|
|
|
|
lastPrompt: null,
|
|
|
|
|
lastSteer: null,
|
|
|
|
|
lastArtifactSummary: null,
|
|
|
|
|
notes: [],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let state = freshSnapshot();
|
|
|
|
|
|
|
|
|
|
function persistState(): void {
|
|
|
|
|
state.updatedAt = nowIso();
|
|
|
|
|
mkdirSync(dirname(config.stateFile), { recursive: true });
|
|
|
|
|
mkdirSync(dirname(config.adapterSummaryFile), { recursive: true });
|
|
|
|
|
writeFileSync(config.stateFile, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
|
|
|
writeFileSync(config.adapterSummaryFile, `${JSON.stringify({ updatedAt: state.updatedAt, adapters: state.adapters }, null, 2)}\n`, "utf8");
|
|
|
|
|
if (state.mode !== "bridge" && state.configWritable) {
|
|
|
|
|
mkdirSync(dirname(config.configTomlPath), { recursive: true });
|
|
|
|
|
writeFileSync(config.configTomlPath, state.configTomlText, "utf8");
|
|
|
|
|
}
|
|
|
|
|
if (state.mode !== "bridge" && state.authWritable) {
|
|
|
|
|
mkdirSync(dirname(config.authJsonPath), { recursive: true });
|
|
|
|
|
writeFileSync(config.authJsonPath, `${state.authJsonText}\n`, "utf8");
|
|
|
|
|
}
|
|
|
|
|
if (state.mode !== "bridge" && state.envWritable) {
|
|
|
|
|
mkdirSync(dirname(config.envFilePath), { recursive: true });
|
|
|
|
|
writeFileSync(config.envFilePath, `${JSON.stringify(state.envVars, null, 2)}\n`, "utf8");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function selectAdapter(name: string | null | undefined): AdapterState {
|
|
|
|
|
const chosen = (name ?? config.defaultAdapter) as AdapterName;
|
|
|
|
|
if (!config.adapterNames.includes(chosen)) throw new Error(`adapter not enabled: ${chosen}`);
|
|
|
|
|
const adapter = state.adapters[chosen];
|
|
|
|
|
if (adapter === undefined) throw new Error(`adapter state missing: ${chosen}`);
|
|
|
|
|
return adapter;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function adapterSummary(): JsonRecord {
|
|
|
|
|
return {
|
|
|
|
|
ok: true,
|
|
|
|
|
serviceId: state.serviceId,
|
|
|
|
|
mode: state.mode,
|
|
|
|
|
tokenProvider: state.tokenProvider,
|
|
|
|
|
tokenProviderSwitchAllowed: state.tokenProviderSwitchAllowed,
|
|
|
|
|
configWritable: state.configWritable,
|
|
|
|
|
authWritable: state.authWritable,
|
|
|
|
|
envWritable: state.envWritable,
|
|
|
|
|
adapterNames: config.adapterNames,
|
|
|
|
|
defaultAdapter: config.defaultAdapter,
|
|
|
|
|
adapters: Object.values(state.adapters).map((adapter) => ({
|
|
|
|
|
name: adapter.name,
|
|
|
|
|
status: adapter.status,
|
|
|
|
|
terminalStatus: adapter.terminalStatus,
|
|
|
|
|
threadId: adapter.threadId,
|
|
|
|
|
turnId: adapter.turnId,
|
|
|
|
|
promptCount: adapter.promptCount,
|
|
|
|
|
steerCount: adapter.steerCount,
|
|
|
|
|
interruptCount: adapter.interruptCount,
|
|
|
|
|
resumeCount: adapter.resumeCount,
|
|
|
|
|
forkCount: adapter.forkCount,
|
|
|
|
|
lastPromptPreview: adapter.lastPrompt ? clampText(adapter.lastPrompt, 240) : null,
|
|
|
|
|
lastSteerPreview: adapter.lastSteer ? clampText(adapter.lastSteer, 240) : null,
|
|
|
|
|
notes: adapter.notes.slice(-5),
|
|
|
|
|
lastArtifactSummary: adapter.lastArtifactSummary,
|
|
|
|
|
})),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function serviceHealth(): JsonRecord {
|
|
|
|
|
return {
|
|
|
|
|
ok: true,
|
|
|
|
|
status: "healthy",
|
|
|
|
|
serviceId: state.serviceId,
|
|
|
|
|
serviceName: state.serviceName,
|
|
|
|
|
mode: state.mode,
|
|
|
|
|
startedAt,
|
|
|
|
|
updatedAt: state.updatedAt,
|
|
|
|
|
tokenProvider: state.tokenProvider,
|
|
|
|
|
tokenProviderSwitchAllowed: state.tokenProviderSwitchAllowed,
|
|
|
|
|
credentialBoundary: {
|
|
|
|
|
fullIsolation: {
|
|
|
|
|
configTomlWritable: true,
|
|
|
|
|
authJsonWritable: true,
|
|
|
|
|
envWritable: true,
|
|
|
|
|
tokenProviderSwitchAllowed: true,
|
|
|
|
|
},
|
|
|
|
|
halfIsolation: {
|
|
|
|
|
configTomlWritable: state.configWritable,
|
|
|
|
|
authJsonWritable: state.authWritable,
|
|
|
|
|
envWritable: state.envWritable,
|
|
|
|
|
tokenProviderSwitchAllowed: state.tokenProviderSwitchAllowed,
|
|
|
|
|
},
|
|
|
|
|
bridge: {
|
|
|
|
|
configTomlWritable: false,
|
|
|
|
|
authJsonWritable: false,
|
|
|
|
|
envWritable: false,
|
|
|
|
|
tokenProviderSwitchAllowed: false,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function diagnostics(): JsonRecord {
|
|
|
|
|
return {
|
|
|
|
|
ok: true,
|
|
|
|
|
serviceId: state.serviceId,
|
|
|
|
|
mode: state.mode,
|
|
|
|
|
adapterNames: config.adapterNames,
|
|
|
|
|
defaultAdapter: config.defaultAdapter,
|
|
|
|
|
credentialBoundary: {
|
|
|
|
|
configTomlPath: config.configTomlPath,
|
|
|
|
|
authJsonPath: config.authJsonPath,
|
|
|
|
|
configTomlWritable: state.mode === "bridge" ? false : state.configWritable,
|
|
|
|
|
authJsonWritable: state.mode === "bridge" ? false : state.authWritable,
|
|
|
|
|
envWritable: state.mode === "bridge" ? false : state.envWritable,
|
|
|
|
|
tokenProvider: state.tokenProvider,
|
|
|
|
|
tokenProviderSwitchAllowed: state.mode === "bridge" ? false : state.tokenProviderSwitchAllowed,
|
|
|
|
|
},
|
|
|
|
|
recoveryBoundary: {
|
|
|
|
|
statusRestoreSupported: true,
|
|
|
|
|
queuedTaskRerouteSupported: true,
|
|
|
|
|
interruptedAttemptRetrySupported: true,
|
|
|
|
|
liveMigrationSupported: false,
|
|
|
|
|
resultLossTolerance: "none",
|
|
|
|
|
},
|
|
|
|
|
adapterSummary: adapterSummary().adapters,
|
|
|
|
|
notes: [
|
|
|
|
|
"Code Queue is not a dependency of this sandbox service.",
|
|
|
|
|
"Adapters are contract-first stubs; integration is delegated to follow-up tasks.",
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function trace(): JsonRecord {
|
|
|
|
|
return {
|
|
|
|
|
ok: true,
|
|
|
|
|
serviceId: state.serviceId,
|
|
|
|
|
generatedAt: nowIso(),
|
|
|
|
|
limit: config.maxTraceItems,
|
|
|
|
|
terminalStatus: Object.values(state.adapters).reduce<Record<string, TerminalStatus>>((acc, adapter) => {
|
|
|
|
|
acc[adapter.name] = adapter.terminalStatus;
|
|
|
|
|
return acc;
|
|
|
|
|
}, {}),
|
|
|
|
|
adapters: Object.values(state.adapters).slice(0, config.maxTraceItems).map((adapter) => ({
|
|
|
|
|
name: adapter.name,
|
|
|
|
|
status: adapter.status,
|
|
|
|
|
terminalStatus: adapter.terminalStatus,
|
|
|
|
|
startedAt: adapter.startedAt,
|
|
|
|
|
updatedAt: adapter.updatedAt,
|
|
|
|
|
threadId: adapter.threadId,
|
|
|
|
|
turnId: adapter.turnId,
|
|
|
|
|
promptCount: adapter.promptCount,
|
|
|
|
|
steerCount: adapter.steerCount,
|
|
|
|
|
interruptCount: adapter.interruptCount,
|
|
|
|
|
resumeCount: adapter.resumeCount,
|
|
|
|
|
forkCount: adapter.forkCount,
|
|
|
|
|
lastPromptPreview: adapter.lastPrompt ? clampText(adapter.lastPrompt, 120) : null,
|
|
|
|
|
lastSteerPreview: adapter.lastSteer ? clampText(adapter.lastSteer, 120) : null,
|
|
|
|
|
lastArtifactSummary: adapter.lastArtifactSummary,
|
|
|
|
|
notes: adapter.notes.slice(-10),
|
|
|
|
|
})),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function recordAdapterMutation(adapter: AdapterState, status: AgentStatus, note: string): void {
|
|
|
|
|
adapter.status = status;
|
|
|
|
|
adapter.updatedAt = nowIso();
|
|
|
|
|
adapter.notes.push(note);
|
|
|
|
|
if (adapter.notes.length > 50) adapter.notes.splice(0, adapter.notes.length - 50);
|
|
|
|
|
persistState();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseBody(body: unknown): Record<string, unknown> {
|
|
|
|
|
if (typeof body === "object" && body !== null && !Array.isArray(body)) return body as Record<string, unknown>;
|
|
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeEnvVars(value: unknown, fallback: Record<string, string>): Record<string, string> {
|
|
|
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return { ...fallback };
|
|
|
|
|
const result: Record<string, string> = {};
|
|
|
|
|
for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
|
|
|
|
|
if (typeof raw === "string") result[key] = raw;
|
|
|
|
|
}
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function adapterAction(action: string, body: Record<string, unknown>): JsonRecord {
|
|
|
|
|
const adapter = selectAdapter(typeof body.adapter === "string" ? body.adapter : null);
|
|
|
|
|
const mode: SandboxMode = state.mode;
|
|
|
|
|
const prompt = typeof body.prompt === "string" ? body.prompt : null;
|
|
|
|
|
const steerPrompt = typeof body.steerPrompt === "string" ? body.steerPrompt : null;
|
|
|
|
|
const artifact = typeof body.artifactSummary === "object" && body.artifactSummary !== null && !Array.isArray(body.artifactSummary)
|
|
|
|
|
? body.artifactSummary as JsonRecord
|
|
|
|
|
: null;
|
|
|
|
|
|
|
|
|
|
switch (action) {
|
|
|
|
|
case "start":
|
|
|
|
|
adapter.startedAt = nowIso();
|
|
|
|
|
adapter.threadId = adapter.threadId ?? `sandbox-${adapter.name}-${Date.now()}`;
|
|
|
|
|
adapter.turnId = null;
|
|
|
|
|
adapter.promptCount += 1;
|
|
|
|
|
adapter.lastPrompt = prompt;
|
|
|
|
|
recordAdapterMutation(adapter, "starting", `start:${adapter.threadId}`);
|
|
|
|
|
return { ok: true, action, adapter: adapter.name, threadId: adapter.threadId, terminalStatus: adapter.terminalStatus };
|
|
|
|
|
case "attach":
|
|
|
|
|
adapter.threadId = adapter.threadId ?? `attached-${adapter.name}-${Date.now()}`;
|
|
|
|
|
adapter.turnId = adapter.turnId ?? `turn-${Date.now()}`;
|
|
|
|
|
recordAdapterMutation(adapter, "attached", `attach:${adapter.threadId}`);
|
|
|
|
|
return { ok: true, action, adapter: adapter.name, threadId: adapter.threadId, turnId: adapter.turnId };
|
|
|
|
|
case "prompt":
|
|
|
|
|
adapter.promptCount += 1;
|
|
|
|
|
adapter.lastPrompt = prompt;
|
|
|
|
|
adapter.turnId = adapter.turnId ?? `turn-${Date.now()}`;
|
|
|
|
|
recordAdapterMutation(adapter, "prompting", `prompt:${clampText(prompt ?? "", 180)}`);
|
|
|
|
|
return { ok: true, action, adapter: adapter.name, turnId: adapter.turnId, promptPreview: clampText(prompt ?? "", config.maxPromptChars) };
|
|
|
|
|
case "steer":
|
|
|
|
|
adapter.steerCount += 1;
|
|
|
|
|
adapter.lastSteer = steerPrompt;
|
|
|
|
|
recordAdapterMutation(adapter, "steering", `steer:${clampText(steerPrompt ?? "", 180)}`);
|
|
|
|
|
return { ok: true, action, adapter: adapter.name, turnId: adapter.turnId, steerPreview: clampText(steerPrompt ?? "", config.maxPromptChars) };
|
|
|
|
|
case "interrupt":
|
|
|
|
|
adapter.interruptCount += 1;
|
|
|
|
|
recordAdapterMutation(adapter, "interrupted", "interrupt");
|
|
|
|
|
adapter.terminalStatus = "interrupted";
|
|
|
|
|
persistState();
|
|
|
|
|
return { ok: true, action, adapter: adapter.name, terminalStatus: adapter.terminalStatus };
|
|
|
|
|
case "resume":
|
|
|
|
|
adapter.resumeCount += 1;
|
|
|
|
|
recordAdapterMutation(adapter, "resumed", "resume");
|
|
|
|
|
adapter.terminalStatus = "unknown";
|
|
|
|
|
persistState();
|
|
|
|
|
return { ok: true, action, adapter: adapter.name, terminalStatus: adapter.terminalStatus };
|
|
|
|
|
case "fork":
|
|
|
|
|
adapter.forkCount += 1;
|
|
|
|
|
recordAdapterMutation(adapter, "forked", "fork");
|
|
|
|
|
return { ok: true, action, adapter: adapter.name, forkCount: adapter.forkCount };
|
|
|
|
|
case "terminal-status":
|
|
|
|
|
return {
|
|
|
|
|
ok: true,
|
|
|
|
|
action,
|
|
|
|
|
adapter: adapter.name,
|
|
|
|
|
terminalStatus: adapter.terminalStatus,
|
|
|
|
|
status: adapter.status,
|
|
|
|
|
};
|
|
|
|
|
case "artifact-summary":
|
|
|
|
|
adapter.lastArtifactSummary = artifact ?? {
|
|
|
|
|
summary: "placeholder",
|
|
|
|
|
source: "code-agent-sandbox",
|
|
|
|
|
generatedAt: nowIso(),
|
|
|
|
|
};
|
|
|
|
|
recordAdapterMutation(adapter, "finished", "artifact-summary");
|
|
|
|
|
return { ok: true, action, adapter: adapter.name, artifactSummary: adapter.lastArtifactSummary };
|
|
|
|
|
case "config-toml":
|
|
|
|
|
if (mode === "bridge") throw new Error("bridge mode is read-only for config.toml");
|
|
|
|
|
if (!state.configWritable) throw new Error("config.toml writes are disabled");
|
|
|
|
|
if (typeof body.text === "string") state.configTomlText = body.text;
|
|
|
|
|
persistState();
|
|
|
|
|
return { ok: true, action, path: config.configTomlPath, writable: state.configWritable, chars: state.configTomlText.length };
|
|
|
|
|
case "auth-json":
|
|
|
|
|
if (mode === "bridge") throw new Error("bridge mode is read-only for auth.json");
|
|
|
|
|
if (!state.authWritable) throw new Error("auth.json writes are disabled");
|
|
|
|
|
if (typeof body.text === "string") state.authJsonText = body.text;
|
|
|
|
|
persistState();
|
|
|
|
|
return { ok: true, action, path: config.authJsonPath, writable: state.authWritable, chars: state.authJsonText.length };
|
|
|
|
|
case "env":
|
|
|
|
|
if (mode === "bridge") throw new Error("bridge mode is read-only for env files");
|
|
|
|
|
if (!state.envWritable) throw new Error("env writes are disabled");
|
|
|
|
|
state.envVars = normalizeEnvVars(body.env, state.envVars);
|
|
|
|
|
persistState();
|
|
|
|
|
return { ok: true, action, path: config.envFilePath, writable: state.envWritable, keys: Object.keys(state.envVars).sort() };
|
|
|
|
|
case "token-provider":
|
|
|
|
|
if (mode === "bridge") throw new Error("bridge mode is read-only for token provider switching");
|
|
|
|
|
if (!state.tokenProviderSwitchAllowed) throw new Error("token provider switching is disabled");
|
|
|
|
|
state.tokenProvider = typeof body.provider === "string" && body.provider.length > 0 ? body.provider : state.tokenProvider;
|
|
|
|
|
persistState();
|
|
|
|
|
return { ok: true, action, tokenProvider: state.tokenProvider };
|
|
|
|
|
case "trace":
|
|
|
|
|
return trace();
|
|
|
|
|
default:
|
|
|
|
|
throw new Error(`unsupported adapter action: ${action}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function readJson(req: Request): Promise<Record<string, unknown>> {
|
|
|
|
|
const text = await req.text();
|
|
|
|
|
if (text.trim().length === 0) return {};
|
|
|
|
|
const parsed = JSON.parse(text) as unknown;
|
|
|
|
|
return parseBody(parsed);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function route(req: Request): Promise<Response> {
|
|
|
|
|
const url = new URL(req.url);
|
|
|
|
|
if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/health")) return jsonResponse(serviceHealth());
|
|
|
|
|
if (req.method === "GET" && url.pathname === "/diagnostics") return jsonResponse(diagnostics());
|
|
|
|
|
if (req.method === "GET" && url.pathname === "/trace") return jsonResponse(trace());
|
|
|
|
|
if (req.method === "GET" && url.pathname === "/artifacts") return jsonResponse({ ok: true, serviceId: state.serviceId, summary: adapterSummary() });
|
|
|
|
|
if (req.method === "GET" && url.pathname === "/api/config/toml") return jsonResponse({ ok: true, path: config.configTomlPath, writable: state.mode !== "bridge" && state.configWritable, text: state.configTomlText, chars: state.configTomlText.length });
|
|
|
|
|
if (req.method === "GET" && url.pathname === "/api/auth/json") return jsonResponse({ ok: true, path: config.authJsonPath, writable: state.mode !== "bridge" && state.authWritable, text: state.authJsonText, chars: state.authJsonText.length });
|
|
|
|
|
if (req.method === "GET" && url.pathname === "/api/env") return jsonResponse({ ok: true, path: config.envFilePath, writable: state.mode !== "bridge" && state.envWritable, env: state.envVars, keys: Object.keys(state.envVars).sort() });
|
|
|
|
|
if (req.method === "GET" && url.pathname === "/api/token-provider") return jsonResponse({ ok: true, tokenProvider: state.tokenProvider, switchAllowed: state.mode !== "bridge" && state.tokenProviderSwitchAllowed });
|
|
|
|
|
if (req.method === "GET" && url.pathname === "/logs") {
|
|
|
|
|
return jsonResponse({ ok: true, logs: recentLogs.slice(-200), logFile: config.logFile, currentLogFile: getLogWriter().currentPath(), startedAt });
|
|
|
|
|
}
|
|
|
|
|
if (req.method === "GET" && url.pathname === "/status") return jsonResponse({ ok: true, serviceId: state.serviceId, state, config: { mode: state.mode, adapterNames: config.adapterNames, defaultAdapter: config.defaultAdapter } });
|
|
|
|
|
if (req.method === "POST" && url.pathname.startsWith("/api/adapters/")) {
|
|
|
|
|
const rest = url.pathname.slice("/api/adapters/".length);
|
|
|
|
|
const [adapterName, action] = rest.split("/", 2);
|
|
|
|
|
if (!adapterName || !action) return jsonResponse({ ok: false, error: "adapter name and action are required" }, 400);
|
|
|
|
|
const body = await readJson(req);
|
|
|
|
|
if (typeof body.adapter !== "string") body.adapter = adapterName;
|
|
|
|
|
const result = adapterAction(action, body);
|
|
|
|
|
log("adapter_action", { adapterName, action, bodyPreview: clampText(JSON.stringify(body), 600) });
|
|
|
|
|
return jsonResponse(result);
|
|
|
|
|
}
|
|
|
|
|
if (req.method === "POST" && url.pathname === "/api/state/reload") {
|
|
|
|
|
state = loadState();
|
|
|
|
|
persistState();
|
|
|
|
|
return jsonResponse({ ok: true, state });
|
|
|
|
|
}
|
|
|
|
|
if (req.method === "POST" && url.pathname === "/api/state/seed") {
|
|
|
|
|
const body = await readJson(req);
|
|
|
|
|
const nextMode = typeof body.mode === "string" ? parseSandboxMode(body.mode) : state.mode;
|
|
|
|
|
state.mode = nextMode;
|
|
|
|
|
state.tokenProvider = typeof body.tokenProvider === "string" ? body.tokenProvider : state.tokenProvider;
|
|
|
|
|
state.tokenProviderSwitchAllowed = typeof body.tokenProviderSwitchAllowed === "boolean" ? body.tokenProviderSwitchAllowed : state.tokenProviderSwitchAllowed;
|
|
|
|
|
state.configWritable = typeof body.configWritable === "boolean" ? body.configWritable : state.configWritable;
|
|
|
|
|
state.authWritable = typeof body.authWritable === "boolean" ? body.authWritable : state.authWritable;
|
|
|
|
|
state.envWritable = typeof body.envWritable === "boolean" ? body.envWritable : state.envWritable;
|
|
|
|
|
if (typeof body.configTomlText === "string") state.configTomlText = body.configTomlText;
|
|
|
|
|
if (typeof body.authJsonText === "string") state.authJsonText = body.authJsonText;
|
|
|
|
|
if (typeof body.env === "object" && body.env !== null && !Array.isArray(body.env)) state.envVars = normalizeEnvVars(body.env, state.envVars);
|
|
|
|
|
persistState();
|
|
|
|
|
return jsonResponse({ ok: true, state });
|
|
|
|
|
}
|
|
|
|
|
if (req.method === "GET" && url.pathname === "/api/adapter-summary") return jsonResponse({ ok: true, summary: adapterSummary() });
|
|
|
|
|
return jsonResponse({
|
|
|
|
|
ok: false,
|
|
|
|
|
error: "not found",
|
|
|
|
|
serviceId: state.serviceId,
|
|
|
|
|
available: ["/health", "/diagnostics", "/trace", "/logs", "/status", "/api/config/toml", "/api/auth/json", "/api/env", "/api/token-provider", "/api/adapters/:adapter/:action", "/api/state/reload", "/api/state/seed"],
|
|
|
|
|
}, 404);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (import.meta.main) {
|
|
|
|
|
state = loadState();
|
|
|
|
|
persistState();
|
|
|
|
|
log("service_started", { startedAt, serviceId: state.serviceId, mode: state.mode, adapterNames: config.adapterNames });
|
|
|
|
|
|
|
|
|
|
Bun.serve({
|
|
|
|
|
hostname: config.host,
|
|
|
|
|
port: config.port,
|
|
|
|
|
idleTimeout: 120,
|
|
|
|
|
fetch(req) {
|
|
|
|
|
return route(req).catch((error) => errorResponse(error));
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|