From bbda3e87a91befaa740340e4c036ae35a913ec53 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 20 May 2026 06:50:25 +0000 Subject: [PATCH] feat: add code agent sandbox skeleton --- AGENTS.md | 1 + config.json | 49 ++ docker-compose.yml | 44 ++ docs/reference/code-agent-sandbox.md | 34 + docs/reference/microservices.md | 10 + scripts/src/check.ts | 23 +- scripts/src/help.ts | 1 + .../code-agent-sandbox/Dockerfile | 11 + .../code-agent-sandbox/package.json | 15 + .../code-agent-sandbox/src/index.ts | 667 ++++++++++++++++++ .../code-agent-sandbox/tsconfig.json | 18 + src/tsconfig.base.json | 1 + 12 files changed, 869 insertions(+), 5 deletions(-) create mode 100644 docs/reference/code-agent-sandbox.md create mode 100644 src/components/microservices/code-agent-sandbox/Dockerfile create mode 100644 src/components/microservices/code-agent-sandbox/package.json create mode 100644 src/components/microservices/code-agent-sandbox/src/index.ts create mode 100644 src/components/microservices/code-agent-sandbox/tsconfig.json diff --git a/AGENTS.md b/AGENTS.md index c8f9150c..39c7d15d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 - `bun scripts/cli.ts provider attach [--master-server URL] [--up] [--force]`:在新增计算节点上生成两项配置的 provider-gateway 挂载包;默认只需要主 server URL(默认 `http://74.48.78.17/`)和唯一 Provider ID,生成的 Compose 固定 Docker socket、`pid: "host"`、`restart: always`、只读 `/workspace`、SSH 维护私钥挂载和 loopback egress proxy 端口,规则见 `docs/reference/provider-gateway.md`。 - `bun scripts/cli.ts ssh [ssh-like args...]`:通过 provider-gateway 的 Host SSH / WSL SSH 维护桥打开近似原生 ssh 的交互会话或远端命令,并在远端 PATH 注入 `apply_patch`、`glob` 与 `skill-discover`;`apply-patch`、`py`、`skills`、结构化 `find`、`glob` 和 `argv` 子命令用于避免远端补丁、Python stdin、skill 发现与常用只读命令的嵌套转义问题,使用规则见 `docs/reference/cli.md` 和 `docs/reference/provider-gateway.md`。 - `bun scripts/cli.ts microservice list/status/health/diagnostics/tunnel-self-test/proxy`:管理和验证挂载在主 server、计算节点 Docker 或 k3s 控制面上的用户服务,`proxy` 支持受控 JSON body,OA Event Flow/Todo Note/Baidu Netdisk/Code Queue Manager on main-server、k3s Control/Code Queue 执行面/MDTODO/Decision Center/FindJob/Pipeline/MET Nonlinear on D601 的规则见 `docs/reference/microservices.md`。 +- `bun scripts/cli.ts microservice health/diagnostics/proxy code-agent-sandbox`:验证独立 Code Agent Sandbox 的 health、只读 diagnostics、trace 和 adapter/mode/credential boundary 契约,规则见 `docs/reference/code-agent-sandbox.md`。 - `bun scripts/cli.ts decision upload/list/show/health`:通过 backend-core 用户服务代理上传会议记录/决议 Markdown、列出记录和查看详情;Decision Center 运行在 D601 k3s,规则见 `docs/reference/microservices.md`。 - `bun scripts/cli.ts decision diary import/list/months/show`:把带日期标题的工作日志 Markdown 拆成 `YYYY-MM/YYYY-MM-DD.md` 日记条目并写入 PostgreSQL,规则见 `docs/reference/microservices.md`。 - `bun scripts/cli.ts deploy check/plan/apply [--file deploy.json|--env dev|prod] [--service ]`:按根目录 `deploy.json` 或 `origin/master:deploy.json#environments.` 的服务 repo 和 commit 期望状态校验或更新用户服务;`--env dev` 开放 D601 `backend-core` rollout、reviewed registry artifact consumers 和 D601 direct consumer validation,`findjob`/`pipeline` 是 D601 direct pull-only 样板,`met-nonlinear` dry-run blocked,`k3sctl-adapter` supervisor-only,`code-queue` prod unsupported,规则见 `docs/reference/deploy.md` 与 `docs/reference/dev-environment.md`。 diff --git a/config.json b/config.json index f97fe8e8..57a7fed7 100644 --- a/config.json +++ b/config.json @@ -716,6 +716,55 @@ "integrated": false } }, + { + "id": "code-agent-sandbox", + "name": "Code Agent Sandbox", + "providerId": "main-server", + "description": "独立 Code Agent Sandbox 微服务骨架,承载 Codex/OpenCode/未来 agent 的统一状态机、adapter 契约、模式边界和凭证分发语义;当前不依赖 Code Queue。", + "repository": { + "url": "https://github.com/pikasTech/unidesk", + "commitId": "local", + "dockerfile": "src/components/microservices/code-agent-sandbox/Dockerfile", + "composeFile": "docker-compose.yml", + "composeService": "code-agent-sandbox", + "containerName": "code-agent-sandbox-backend" + }, + "backend": { + "nodeBaseUrl": "http://code-agent-sandbox:4260", + "nodeBindHost": "code-agent-sandbox", + "nodePort": 4260, + "proxyMode": "unidesk-direct", + "frontendOnly": true, + "public": false, + "allowedMethods": [ + "GET", + "HEAD", + "POST", + "PUT" + ], + "allowedPathPrefixes": [ + "/health", + "/diagnostics", + "/trace", + "/logs", + "/api/" + ], + "healthPath": "/health", + "timeoutMs": 10000 + }, + "development": { + "providerId": "main-server", + "sshPassthrough": false, + "worktreePath": "/root/unidesk/src/components/microservices/code-agent-sandbox" + }, + "frontend": { + "route": "/apps/code-agent-sandbox", + "integrated": false + }, + "deployment": { + "mode": "internal-sidecar" + } + }, { "id": "mdtodo", "name": "MDTODO", diff --git a/docker-compose.yml b/docker-compose.yml index 23ab6994..15901580 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -121,6 +121,50 @@ services: timeout: 3s retries: 20 + code-agent-sandbox: + image: code-agent-sandbox + build: + context: . + dockerfile: src/components/microservices/code-agent-sandbox/Dockerfile + container_name: code-agent-sandbox-backend + restart: unless-stopped + expose: + - "4260" + environment: + HOST: "0.0.0.0" + PORT: "4260" + CODE_AGENT_SANDBOX_DATA_DIR: "/var/lib/unidesk/code-agent-sandbox" + CODE_AGENT_SANDBOX_STATE_FILE: "/var/lib/unidesk/code-agent-sandbox/state.json" + CODE_AGENT_SANDBOX_ADAPTER_SUMMARY_FILE: "/var/lib/unidesk/code-agent-sandbox/adapters.json" + CODE_AGENT_SANDBOX_ENV_FILE_PATH: "/var/lib/unidesk/code-agent-sandbox/env.json" + CODE_AGENT_SANDBOX_CONFIG_TOML_PATH: "/var/lib/unidesk/code-agent-sandbox/config.toml" + CODE_AGENT_SANDBOX_AUTH_JSON_PATH: "/var/lib/unidesk/code-agent-sandbox/auth.json" + CODE_AGENT_SANDBOX_MODE: "${CODE_AGENT_SANDBOX_MODE:-half-isolation}" + CODE_AGENT_SANDBOX_TOKEN_PROVIDER: "${CODE_AGENT_SANDBOX_TOKEN_PROVIDER:-codex}" + CODE_AGENT_SANDBOX_TOKEN_PROVIDER_SWITCH_ALLOWED: "${CODE_AGENT_SANDBOX_TOKEN_PROVIDER_SWITCH_ALLOWED:-true}" + CODE_AGENT_SANDBOX_CONFIG_WRITABLE: "${CODE_AGENT_SANDBOX_CONFIG_WRITABLE:-true}" + CODE_AGENT_SANDBOX_AUTH_WRITABLE: "${CODE_AGENT_SANDBOX_AUTH_WRITABLE:-true}" + CODE_AGENT_SANDBOX_ENV_WRITABLE: "${CODE_AGENT_SANDBOX_ENV_WRITABLE:-true}" + CODE_AGENT_SANDBOX_ADAPTERS: "${CODE_AGENT_SANDBOX_ADAPTERS:-codex,opencode,future-agent}" + CODE_AGENT_SANDBOX_DEFAULT_ADAPTER: "${CODE_AGENT_SANDBOX_DEFAULT_ADAPTER:-codex}" + CODE_AGENT_SANDBOX_MAX_PROMPT_CHARS: "${CODE_AGENT_SANDBOX_MAX_PROMPT_CHARS:-12000}" + CODE_AGENT_SANDBOX_MAX_TRACE_ITEMS: "${CODE_AGENT_SANDBOX_MAX_TRACE_ITEMS:-200}" + UNIDESK_DEPLOY_REF: "${UNIDESK_CODE_AGENT_SANDBOX_DEPLOY_REF:-deploy.json#environments.prod.services.code-agent-sandbox}" + UNIDESK_DEPLOY_SERVICE_ID: "${UNIDESK_CODE_AGENT_SANDBOX_DEPLOY_SERVICE_ID:-code-agent-sandbox}" + UNIDESK_DEPLOY_REPO: "${UNIDESK_CODE_AGENT_SANDBOX_DEPLOY_REPO:-}" + UNIDESK_DEPLOY_COMMIT: "${UNIDESK_CODE_AGENT_SANDBOX_DEPLOY_COMMIT:-}" + UNIDESK_DEPLOY_REQUESTED_COMMIT: "${UNIDESK_CODE_AGENT_SANDBOX_DEPLOY_REQUESTED_COMMIT:-}" + LOG_FILE: "/var/log/unidesk/${UNIDESK_LOG_DAY}/${UNIDESK_LOG_PREFIX}_code-agent-sandbox.jsonl" + UNIDESK_LOG_RETENTION_BYTES: "${UNIDESK_LOG_RETENTION_BYTES:-1GiB}" + volumes: + - ${UNIDESK_LOG_DIR}:/var/log/unidesk + - ./.state/code-agent-sandbox:/var/lib/unidesk/code-agent-sandbox + healthcheck: + test: ["CMD", "bun", "-e", "fetch('http://127.0.0.1:4260/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + interval: 5s + timeout: 3s + retries: 20 + todo-note: build: context: /root/todo_note diff --git a/docs/reference/code-agent-sandbox.md b/docs/reference/code-agent-sandbox.md new file mode 100644 index 00000000..c11c19de --- /dev/null +++ b/docs/reference/code-agent-sandbox.md @@ -0,0 +1,34 @@ +# Code Agent Sandbox + +Code Agent Sandbox 是独立于 Code Queue 的沙箱微服务骨架。它负责统一记录 Codex/OpenCode/未来 agent 的运行态、适配器契约、状态恢复边界和凭证语义边界。 + +## Boundary + +- Code Agent Sandbox 不依赖 Code Queue 作为前置条件。 +- Code Queue 只在后续契约接入时读取这里定义的状态机和 adapter 语义。 +- 不承诺 live migration;只承诺任务状态可恢复、未开始任务可重调度、中断 attempt 可 retry/resume、结果不丢。 + +## Runtime Modes + +- `full-isolation`:允许 sandbox 管理自己的 `config.toml`、`auth.json`、环境变量和 token provider 切换。 +- `half-isolation`:允许读取/写入边界由配置显式控制,适合本机调试和受限挂载。 +- `bridge`:只读宿主现状,不改写宿主配置,不切换 token provider。 + +## Adapter Contract + +Sandbox 以 adapter 一等抽象统一下面的动作: + +- `start` +- `attach` +- `prompt` +- `steer` +- `interrupt` +- `resume` +- `fork` +- `trace` +- `terminal status` +- `artifact summary` + +## Diagnostics + +`/health`、`/diagnostics`、`/trace` 和 `/logs` 是首批只读入口。它们用于验证模式、适配器状态、恢复边界和凭证边界,不承诺连接真实 Code Queue。 diff --git a/docs/reference/microservices.md b/docs/reference/microservices.md index e32c564b..ceb02826 100644 --- a/docs/reference/microservices.md +++ b/docs/reference/microservices.md @@ -90,6 +90,16 @@ OA Event Flow 在 UniDesk 语境中按共享控制面基础设施管理:不得 - 路由:CLI/WebUI 仍只访问 `/api/microservices/code-queue/proxy/...`;backend-core 在内部把控制/读取路径转到 `code-queue-mgr`,把 active run、judge、dev-container、执行面健康和 scheduler 相关路径转到 D601 执行面。 - 行为兼容:提交与 queued prompt edit 必须保留 Code Queue 环境提示注入、`--reference-task-id`/引用输入解析和引用任务上下文注入,避免 master 控制面路径与 D601 原写服务语义分叉。 +### Code Agent Sandbox On Main Server + +`code-agent-sandbox` 是主 server Compose 内的独立 Code Agent Sandbox 骨架,登记为 `deployment.mode=internal-sidecar`,Provider 为 `main-server`,后端地址为 Compose 网络内 `http://code-agent-sandbox:4260`。它目前不依赖 Code Queue,也不承担 queue 产品逻辑。 + +- 职责:统一 adapter 契约、模式边界、凭证边界、状态恢复表达和只读诊断入口。 +- 代理路径:允许 `/health`、`/diagnostics`、`/trace`、`/logs` 和 `/api/` 前缀;允许方法为 `GET`、`HEAD`、`POST`、`PUT`。 +- 模式:`full-isolation`、`half-isolation`、`bridge` 三种模式都必须在 health/diagnostics 中可见,bridge 模式只读宿主现状,不改写宿主配置。 +- 适配器契约:`start`、`attach`、`prompt`、`steer`、`interrupt`、`resume`、`fork`、`trace`、`terminal status` 和 `artifact summary` 统一由 adapter 层暴露。 +- 恢复边界:服务只承诺状态可恢复、未开始任务可重调度、中断 attempt 可 retry/resume、结果不丢,不承诺 live migration。 + ### Project Manager On Main Server 当前 Project Manager 作为 `id=project-manager` 的用户服务登记在 `config.json`: diff --git a/scripts/src/check.ts b/scripts/src/check.ts index bc36089a..6d23dac7 100644 --- a/scripts/src/check.ts +++ b/scripts/src/check.ts @@ -33,6 +33,7 @@ const syntaxFiles = [ "src/components/microservices/mdtodo/src/index.ts", "src/components/microservices/decision-center/src/index.ts", "src/components/microservices/code-queue-mgr/src/index.ts", + "src/components/microservices/code-agent-sandbox/src/index.ts", ]; export interface CheckOptions { @@ -165,6 +166,7 @@ function unifiedLogRotationItem(): CheckItem { "src/components/microservices/baidu-netdisk/src/index.ts", "src/components/microservices/oa-event-flow/src/index.ts", "src/components/microservices/decision-center/src/index.ts", + "src/components/microservices/code-agent-sandbox/src/index.ts", ]; const offenders = serviceFiles.flatMap((path) => { const text = readFileSync(rootPath(path), "utf8"); @@ -189,13 +191,23 @@ function unifiedLogRotationItem(): CheckItem { }; } +function extractComposeServiceBlock(composeText: string, serviceName: string): string { + const lines = composeText.split("\n"); + const startLine = lines.findIndex((line) => line === ` ${serviceName}:`); + if (startLine < 0) return ""; + let endLine = lines.length; + for (let index = startLine + 1; index < lines.length; index += 1) { + if (/^ [A-Za-z0-9][A-Za-z0-9_-]*:$/u.test(lines[index])) { + endLine = index; + break; + } + } + return lines.slice(startLine, endLine).join("\n"); +} + function codeQueueMgrHealthcheckItem(): CheckItem { const composeText = readFileSync(rootPath("docker-compose.yml"), "utf8"); - const serviceStart = composeText.indexOf("\n code-queue-mgr:"); - const nextService = serviceStart >= 0 ? composeText.indexOf("\n todo-note:", serviceStart + 1) : -1; - const serviceBlock = serviceStart >= 0 - ? composeText.slice(serviceStart, nextService >= 0 ? nextService : undefined) - : ""; + const serviceBlock = extractComposeServiceBlock(composeText, "code-queue-mgr"); const dockerfileText = readFileSync(rootPath("src/components/microservices/code-queue-mgr/Dockerfile"), "utf8"); const sourceText = readFileSync(rootPath("src/components/microservices/code-queue-mgr/src-rs/main.rs"), "utf8"); const healthcheckUsesRustProbe = serviceBlock.includes("code-queue-mgr") && serviceBlock.includes("--healthcheck"); @@ -262,6 +274,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default fileItem("src/components/microservices/mdtodo/src/index.ts"), fileItem("src/components/microservices/decision-center/src/index.ts"), fileItem("src/components/microservices/code-queue-mgr/src/index.ts"), + fileItem("src/components/microservices/code-agent-sandbox/src/index.ts"), fileItem("src/components/microservices/code-queue-mgr/src/prompt-observation.ts"), fileItem("scripts/src/deploy.ts"), fileItem("scripts/code-queue-issue3-regression-test.ts"), diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 8a91a2f3..808a21c3 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -39,6 +39,7 @@ export function rootHelp(): unknown { { command: "deploy check|plan|apply [--file deploy.json|--env dev|prod] [--service id] [--commit full-sha] [--dry-run] [--force]", description: "Reconcile services from a repo+commit manifest; --env reads origin/master:deploy.json environments and applies supported dev target-side rollouts or reviewed D601 registry artifact consumers. code-queue artifact consumption is dev-only." }, { command: "dev-env validate|prewarm-images", description: "Validate D601 unidesk-dev guardrails or prewarm dev foundation images into native k3s containerd through a bounded async job." }, { command: "artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service", description: "Manage the D601 host-managed CNCF Distribution registry and run pull-only artifact CD for supported services, including D601 direct, k3s-managed, and code-queue dev-only consumers." }, + { command: "code-agent-sandbox", description: "Independent Code Agent Sandbox service skeleton for adapter, mode, and credential-boundary diagnostics." }, { command: "schedule list|get|runs|run|delete", description: "Manage backend-core scheduled tasks and run history; schedule run supports --wait-ms N." }, { command: "schedule upsert-pgdata-backup [--time HH:MM] [--remote-base /SERVER_DATA/UNIDESK_PG_DATA]", description: "Create or update the daily PGDATA physical backup task that uploads monthly rotated archives to Baidu Netdisk." }, { command: "codex deploy [--provider-id D601] [--timeout-ms N]", description: "Disabled legacy Code Queue deploy path; use the dev-only artifact consumer instead." }, diff --git a/src/components/microservices/code-agent-sandbox/Dockerfile b/src/components/microservices/code-agent-sandbox/Dockerfile new file mode 100644 index 00000000..6209674e --- /dev/null +++ b/src/components/microservices/code-agent-sandbox/Dockerfile @@ -0,0 +1,11 @@ +FROM oven/bun:1-alpine + +WORKDIR /app/src/components/microservices/code-agent-sandbox +COPY src/components/microservices/code-agent-sandbox/package.json ./package.json +RUN bun install --production +COPY src/components/microservices/code-agent-sandbox/tsconfig.json ./tsconfig.json +COPY src/components/shared /app/src/components/shared +COPY src/components/microservices/code-agent-sandbox/src ./src + +EXPOSE 4260 +CMD ["bun", "run", "src/index.ts"] diff --git a/src/components/microservices/code-agent-sandbox/package.json b/src/components/microservices/code-agent-sandbox/package.json new file mode 100644 index 00000000..976dfc6e --- /dev/null +++ b/src/components/microservices/code-agent-sandbox/package.json @@ -0,0 +1,15 @@ +{ + "name": "@unidesk/code-agent-sandbox", + "private": true, + "type": "module", + "scripts": { + "start": "bun run src/index.ts", + "check": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": {}, + "devDependencies": { + "@types/bun": "latest", + "@types/node": "latest", + "typescript": "latest" + } +} diff --git a/src/components/microservices/code-agent-sandbox/src/index.ts b/src/components/microservices/code-agent-sandbox/src/index.ts new file mode 100644 index 00000000..95f13346 --- /dev/null +++ b/src/components/microservices/code-agent-sandbox/src/index.ts @@ -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; +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; + adapters: Record; + 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 | null = null; + +function getLogWriter(): ReturnType { + 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 = {}): 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))}…`; +} + +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 { + 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; + const adapters = typeof record.adapters === "object" && record.adapters !== null && !Array.isArray(record.adapters) + ? record.adapters as Record + : {}; + 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 : {}; + 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>((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 { + if (typeof body === "object" && body !== null && !Array.isArray(body)) return body as Record; + return {}; +} + +function normalizeEnvVars(value: unknown, fallback: Record): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) return { ...fallback }; + const result: Record = {}; + for (const [key, raw] of Object.entries(value as Record)) { + if (typeof raw === "string") result[key] = raw; + } + return result; +} + +function adapterAction(action: string, body: Record): 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> { + 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 { + 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)); + }, + }); +} diff --git a/src/components/microservices/code-agent-sandbox/tsconfig.json b/src/components/microservices/code-agent-sandbox/tsconfig.json new file mode 100644 index 00000000..e7d9d39a --- /dev/null +++ b/src/components/microservices/code-agent-sandbox/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "composite": true, + "rootDir": "../../", + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "types": ["bun", "node"], + "strict": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "dist", + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "../../shared/src/**/*.ts"] +} diff --git a/src/tsconfig.base.json b/src/tsconfig.base.json index 65125db2..732ef8e6 100644 --- a/src/tsconfig.base.json +++ b/src/tsconfig.base.json @@ -12,5 +12,6 @@ { "path": "components/microservices/baidu-netdisk" }, { "path": "components/microservices/oa-event-flow" }, { "path": "components/microservices/decision-center" } + ,{ "path": "components/microservices/code-agent-sandbox" } ] }