feat: add code agent sandbox skeleton
This commit is contained in:
@@ -36,6 +36,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文
|
|||||||
- `bun scripts/cli.ts provider attach <providerId> [--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 provider attach <providerId> [--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 <providerId> [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 ssh <providerId> [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 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 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 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 <id>]`:按根目录 `deploy.json` 或 `origin/master:deploy.json#environments.<env>` 的服务 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`。
|
- `bun scripts/cli.ts deploy check/plan/apply [--file deploy.json|--env dev|prod] [--service <id>]`:按根目录 `deploy.json` 或 `origin/master:deploy.json#environments.<env>` 的服务 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`。
|
||||||
|
|||||||
+49
@@ -716,6 +716,55 @@
|
|||||||
"integrated": false
|
"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",
|
"id": "mdtodo",
|
||||||
"name": "MDTODO",
|
"name": "MDTODO",
|
||||||
|
|||||||
@@ -121,6 +121,50 @@ services:
|
|||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 20
|
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:
|
todo-note:
|
||||||
build:
|
build:
|
||||||
context: /root/todo_note
|
context: /root/todo_note
|
||||||
|
|||||||
@@ -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。
|
||||||
@@ -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 执行面。
|
- 路由: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 原写服务语义分叉。
|
- 行为兼容:提交与 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 On Main Server
|
||||||
|
|
||||||
当前 Project Manager 作为 `id=project-manager` 的用户服务登记在 `config.json`:
|
当前 Project Manager 作为 `id=project-manager` 的用户服务登记在 `config.json`:
|
||||||
|
|||||||
+18
-5
@@ -33,6 +33,7 @@ const syntaxFiles = [
|
|||||||
"src/components/microservices/mdtodo/src/index.ts",
|
"src/components/microservices/mdtodo/src/index.ts",
|
||||||
"src/components/microservices/decision-center/src/index.ts",
|
"src/components/microservices/decision-center/src/index.ts",
|
||||||
"src/components/microservices/code-queue-mgr/src/index.ts",
|
"src/components/microservices/code-queue-mgr/src/index.ts",
|
||||||
|
"src/components/microservices/code-agent-sandbox/src/index.ts",
|
||||||
];
|
];
|
||||||
|
|
||||||
export interface CheckOptions {
|
export interface CheckOptions {
|
||||||
@@ -165,6 +166,7 @@ function unifiedLogRotationItem(): CheckItem {
|
|||||||
"src/components/microservices/baidu-netdisk/src/index.ts",
|
"src/components/microservices/baidu-netdisk/src/index.ts",
|
||||||
"src/components/microservices/oa-event-flow/src/index.ts",
|
"src/components/microservices/oa-event-flow/src/index.ts",
|
||||||
"src/components/microservices/decision-center/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 offenders = serviceFiles.flatMap((path) => {
|
||||||
const text = readFileSync(rootPath(path), "utf8");
|
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 {
|
function codeQueueMgrHealthcheckItem(): CheckItem {
|
||||||
const composeText = readFileSync(rootPath("docker-compose.yml"), "utf8");
|
const composeText = readFileSync(rootPath("docker-compose.yml"), "utf8");
|
||||||
const serviceStart = composeText.indexOf("\n code-queue-mgr:");
|
const serviceBlock = extractComposeServiceBlock(composeText, "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 dockerfileText = readFileSync(rootPath("src/components/microservices/code-queue-mgr/Dockerfile"), "utf8");
|
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 sourceText = readFileSync(rootPath("src/components/microservices/code-queue-mgr/src-rs/main.rs"), "utf8");
|
||||||
const healthcheckUsesRustProbe = serviceBlock.includes("code-queue-mgr") && serviceBlock.includes("--healthcheck");
|
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/mdtodo/src/index.ts"),
|
||||||
fileItem("src/components/microservices/decision-center/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-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("src/components/microservices/code-queue-mgr/src/prompt-observation.ts"),
|
||||||
fileItem("scripts/src/deploy.ts"),
|
fileItem("scripts/src/deploy.ts"),
|
||||||
fileItem("scripts/code-queue-issue3-regression-test.ts"),
|
fileItem("scripts/code-queue-issue3-regression-test.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: "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: "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: "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 <id> supports --wait-ms N." },
|
{ command: "schedule list|get|runs|run|delete", description: "Manage backend-core scheduled tasks and run history; schedule run <id> 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: "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 <commitId> [--provider-id D601] [--timeout-ms N]", description: "Disabled legacy Code Queue deploy path; use the dev-only artifact consumer instead." },
|
{ command: "codex deploy <commitId> [--provider-id D601] [--timeout-ms N]", description: "Disabled legacy Code Queue deploy path; use the dev-only artifact consumer instead." },
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -12,5 +12,6 @@
|
|||||||
{ "path": "components/microservices/baidu-netdisk" },
|
{ "path": "components/microservices/baidu-netdisk" },
|
||||||
{ "path": "components/microservices/oa-event-flow" },
|
{ "path": "components/microservices/oa-event-flow" },
|
||||||
{ "path": "components/microservices/decision-center" }
|
{ "path": "components/microservices/decision-center" }
|
||||||
|
,{ "path": "components/microservices/code-agent-sandbox" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user