diff --git a/docs/architecture/api_and_service_inventory_cn.md b/docs/architecture/api_and_service_inventory_cn.md index 3c2a02f..395dfd5 100644 --- a/docs/architecture/api_and_service_inventory_cn.md +++ b/docs/architecture/api_and_service_inventory_cn.md @@ -151,6 +151,10 @@ - 当前 `local-agent` 还新增了两条统一电脑控制 runtime: - `local-agent/browser-control-task-runner.mjs` - `local-agent/computer-use-task-runner.mjs` +- 当前本机 boss-agent 还新增 Codex Remote Control 显式控制入口: + - `POST http://127.0.0.1:4317/api/v1/boss-agent/codex-remote-control/start` + - `POST http://127.0.0.1:4317/api/v1/boss-agent/codex-remote-control/stop` + - 这两个入口只在本机 agent 上执行 `codex remote-control start|stop --json`,返回和日志都会清洗敏感字段;状态页刷新不会自动调用,后续接入 APP/后台时仍必须加显式操作、RBAC、审批和审计 - 当前 `browser_control / desktop_control` 任务已经可以被 `local-agent/server.mjs` 识别并分流;当本机配置了对应 runtime 命令时,会通过 JSON stdin/stdout 协议委托给外部进程执行,否则返回明确 runtime disabled 错误,不再回退占位成功结果 - 当前 `browser_control / desktop_control` 的完成回写已贯通 `targetUrl / targetApp -> RemoteRuntimeAdapter -> /api/v1/master-agent/tasks/[taskId]/complete -> boss-state.json`,服务端写入 `control_summary` 消息时会保留 `controlTarget`,Android 会话页可直接渲染“目标:URL/应用名” - 相关配置项: diff --git a/local-agent/boss-agent-status.mjs b/local-agent/boss-agent-status.mjs index c8920ba..eae8105 100644 --- a/local-agent/boss-agent-status.mjs +++ b/local-agent/boss-agent-status.mjs @@ -655,6 +655,14 @@ function renderOverviewTab(status, { bound, heroTitle, heroSubtitle, qrBlock })
Codex Remote Control
${escapeHtml(status.codex.remoteControl.statusLabel)}
${escapeHtml(status.codex.remoteControl.startCommandLabel)}
+
+
+ +
+
+ +
+
boss-agent OTA
diff --git a/local-agent/codex-remote-control-daemon.mjs b/local-agent/codex-remote-control-daemon.mjs new file mode 100644 index 0000000..c8609b7 --- /dev/null +++ b/local-agent/codex-remote-control-daemon.mjs @@ -0,0 +1,149 @@ +import { spawn } from "node:child_process"; + +function nonEmpty(value) { + const text = String(value ?? "").trim(); + return text || undefined; +} + +function parseBoolean(value, fallback = false) { + if (value === undefined || value === null || value === "") return fallback; + if (value === false || value === "false" || value === "0" || value === 0) return false; + return true; +} + +function normalizeArgs(value, fallback = []) { + if (!Array.isArray(value)) return [...fallback]; + return value.map((item) => nonEmpty(item)).filter(Boolean); +} + +function redactSensitiveText(value) { + return String(value ?? "") + .replace(/sk-[A-Za-z0-9_-]{8,}/g, "[REDACTED]") + .replace(/Bearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, "Bearer [REDACTED]") + .replace(/token["']?\s*[:=]\s*["']?[^"',\s}]+/gi, "token:[REDACTED]") + .slice(0, 1200); +} + +function parseEnvArgs(value) { + const text = nonEmpty(value); + if (!text) return undefined; + try { + const parsed = JSON.parse(text); + if (Array.isArray(parsed)) { + return parsed.map((item) => nonEmpty(item)).filter(Boolean); + } + } catch { + // Fall back to whitespace parsing for simple local overrides. + } + return text.split(/\s+/).map((item) => nonEmpty(item)).filter(Boolean); +} + +export function getCodexRemoteControlDaemonConfig(env = process.env, config = {}) { + const appServerEnabled = parseBoolean(config.codexAppServerEnabled, false); + const enabled = parseBoolean( + env.BOSS_CODEX_REMOTE_CONTROL_ENABLED ?? config.codexRemoteControlEnabled, + appServerEnabled, + ); + const command = + nonEmpty(env.BOSS_CODEX_REMOTE_CONTROL_COMMAND) ?? + nonEmpty(config.codexRemoteControlCommand) ?? + nonEmpty(config.codexAppServerCommand) ?? + "codex"; + const startArgs = normalizeArgs( + parseEnvArgs(env.BOSS_CODEX_REMOTE_CONTROL_START_ARGS) ?? config.codexRemoteControlStartArgs ?? config.codexRemoteControlArgs, + ["remote-control", "start", "--json"], + ); + const stopArgs = normalizeArgs( + parseEnvArgs(env.BOSS_CODEX_REMOTE_CONTROL_STOP_ARGS) ?? config.codexRemoteControlStopArgs, + ["remote-control", "stop", "--json"], + ); + const timeoutMs = Number(env.BOSS_CODEX_REMOTE_CONTROL_TIMEOUT_MS ?? config.codexRemoteControlTimeoutMs ?? 15_000); + + return { + enabled, + command, + startArgs, + stopArgs, + timeoutMs: Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 15_000, + }; +} + +function runCommand(command, args, options = {}) { + return new Promise((resolve) => { + const child = spawn(command, args, { + cwd: options.cwd, + env: options.env, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + child.kill("SIGKILL"); + }, options.timeoutMs ?? 15_000); + + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("error", (error) => { + clearTimeout(timer); + resolve({ ok: false, exitCode: null, stdout, stderr: error.message, timedOut }); + }); + child.on("close", (code) => { + clearTimeout(timer); + resolve({ ok: code === 0 && !timedOut, exitCode: code, stdout, stderr, timedOut }); + }); + }); +} + +function parseJsonOutput(stdout) { + const text = String(stdout ?? "").trim(); + if (!text) return null; + try { + return JSON.parse(text); + } catch { + return null; + } +} + +export async function runCodexRemoteControlDaemonAction(action, config = {}, options = {}) { + if (action !== "start" && action !== "stop") { + throw new Error(`Unsupported codex remote-control action: ${action}`); + } + + const daemonConfig = getCodexRemoteControlDaemonConfig(options.env ?? process.env, config); + if (!daemonConfig.enabled) { + return { + status: "failed", + action, + commandLabel: "", + outputSummary: "Codex Remote Control daemon is disabled.", + error: "CODEX_REMOTE_CONTROL_DISABLED", + }; + } + + const args = action === "start" ? daemonConfig.startArgs : daemonConfig.stopArgs; + const commandResult = await runCommand(daemonConfig.command, args, { + cwd: nonEmpty(config.codexRemoteControlWorkdir) ?? nonEmpty(config.codexAppServerWorkdir), + env: options.env ?? process.env, + timeoutMs: daemonConfig.timeoutMs, + }); + const outputSummary = redactSensitiveText(commandResult.stdout || commandResult.stderr); + const parsedOutput = parseJsonOutput(outputSummary); + + return { + status: commandResult.ok ? "completed" : "failed", + action, + exitCode: commandResult.exitCode, + timedOut: commandResult.timedOut, + commandLabel: [daemonConfig.command, ...args].join(" "), + outputSummary, + ...(parsedOutput && typeof parsedOutput === "object" ? { parsedOutput } : {}), + }; +} diff --git a/local-agent/server.mjs b/local-agent/server.mjs index b565863..9fc8469 100755 --- a/local-agent/server.mjs +++ b/local-agent/server.mjs @@ -41,6 +41,9 @@ import { checkBossAgentOtaUpdate, getBossAgentOtaRunnerConfig, } from "./boss-agent-ota-runner.mjs"; +import { + runCodexRemoteControlDaemonAction, +} from "./codex-remote-control-daemon.mjs"; import { sanitizeSensitiveTaskFailureDetailForLog, sanitizeSensitiveTaskFailureDetailForTransport, @@ -1554,6 +1557,41 @@ const server = createServer(async (request, response) => { return; } + const codexRemoteControlMatch = requestUrl.pathname.match( + /^\/api\/v1\/boss-agent\/codex-remote-control\/(start|stop)$/, + ); + if (codexRemoteControlMatch && request.method === "POST") { + const action = codexRemoteControlMatch[1]; + const result = await runCodexRemoteControlDaemonAction(action, config); + runtime.lastCodexRemoteControlAction = { + action, + status: result.status, + at: new Date().toISOString(), + commandLabel: result.commandLabel, + outputSummary: result.outputSummary, + }; + await postAppLog(config, runtime, { + level: result.status === "failed" ? "error" : "info", + category: result.status === "failed" + ? "local_agent.codex_remote_control_failed" + : "local_agent.codex_remote_control_changed", + message: result.status === "failed" + ? `Codex Remote Control ${action} 失败` + : `Codex Remote Control 已${action === "start" ? "启动" : "停止"}`, + detail: result.outputSummary, + mirrorToMaster: result.status === "failed", + }); + const wantsJson = String(request.headers.accept || "").includes("application/json"); + if (!wantsJson) { + response.writeHead(302, { Location: "/boss-agent?tab=overview" }); + response.end(); + return; + } + response.writeHead(result.status === "failed" ? 400 : 200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ ok: result.status !== "failed", result })); + return; + } + if (requestUrl.pathname === "/api/v1/boss-agent/permissions/open") { const target = requestUrl.searchParams.get("target") || "core"; const returnTab = normalizeBossAgentTab(requestUrl.searchParams.get("returnTab") ?? "permissions"); diff --git a/tests/boss-agent-status.test.mjs b/tests/boss-agent-status.test.mjs index 9501482..84d145b 100644 --- a/tests/boss-agent-status.test.mjs +++ b/tests/boss-agent-status.test.mjs @@ -167,6 +167,8 @@ test("boss-agent status treats token-backed devices as bound and renders enterpr assert.match(html, /Codex Computer Use/); assert.match(html, /Codex Remote Control/); assert.match(html, /remote-control start --json/); + assert.match(html, /action="\/api\/v1\/boss-agent\/codex-remote-control\/start"/); + assert.match(html, /action="\/api\/v1\/boss-agent\/codex-remote-control\/stop"/); assert.match(html, /boss-agent OTA/); assert.match(html, /发现新版本/); assert.match(html, /默认公司/); diff --git a/tests/codex-remote-control-daemon.test.mjs b/tests/codex-remote-control-daemon.test.mjs new file mode 100644 index 0000000..e2a3fd9 --- /dev/null +++ b/tests/codex-remote-control-daemon.test.mjs @@ -0,0 +1,68 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { + getCodexRemoteControlDaemonConfig, + runCodexRemoteControlDaemonAction, +} from "../local-agent/codex-remote-control-daemon.mjs"; + +test("codex remote-control daemon config defaults to explicit start and stop commands", () => { + const config = getCodexRemoteControlDaemonConfig( + {}, + { + codexAppServerEnabled: true, + codexAppServerCommand: "codex", + }, + ); + + assert.equal(config.enabled, true); + assert.equal(config.command, "codex"); + assert.deepEqual(config.startArgs, ["remote-control", "start", "--json"]); + assert.deepEqual(config.stopArgs, ["remote-control", "stop", "--json"]); +}); + +test("codex remote-control daemon action executes only explicit start and redacts sensitive output", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "boss-codex-remote-control-")); + try { + const argvPath = path.join(tempDir, "argv.json"); + const fakeCodexPath = path.join(tempDir, "codex"); + await writeFile( + fakeCodexPath, + `#!/usr/bin/env node +const fs = require("node:fs"); +fs.writeFileSync(${JSON.stringify(argvPath)}, JSON.stringify(process.argv.slice(2))); +process.stdout.write(JSON.stringify({ ok: true, token: "sk-secret-should-not-leak", status: "started" })); +`, + "utf8", + ); + await chmod(fakeCodexPath, 0o755); + + const result = await runCodexRemoteControlDaemonAction("start", { + codexRemoteControlEnabled: true, + codexRemoteControlCommand: fakeCodexPath, + codexRemoteControlTimeoutMs: 3000, + }); + + assert.equal(result.status, "completed"); + assert.equal(result.action, "start"); + assert.equal(result.commandLabel.endsWith("remote-control start --json"), true); + assert.deepEqual(JSON.parse(await readFile(argvPath, "utf8")), ["remote-control", "start", "--json"]); + assert.equal(JSON.stringify(result).includes("sk-secret-should-not-leak"), false); + assert.equal(result.outputSummary.includes("[REDACTED]"), true); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test("codex remote-control daemon rejects unsupported actions before spawning", async () => { + await assert.rejects( + () => + runCodexRemoteControlDaemonAction("restart", { + codexRemoteControlEnabled: true, + }), + /Unsupported codex remote-control action/, + ); +});