feat: add codex remote control daemon actions
This commit is contained in:
@@ -151,6 +151,10 @@
|
|||||||
- 当前 `local-agent` 还新增了两条统一电脑控制 runtime:
|
- 当前 `local-agent` 还新增了两条统一电脑控制 runtime:
|
||||||
- `local-agent/browser-control-task-runner.mjs`
|
- `local-agent/browser-control-task-runner.mjs`
|
||||||
- `local-agent/computer-use-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` 任务已经可以被 `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/应用名”
|
- 当前 `browser_control / desktop_control` 的完成回写已贯通 `targetUrl / targetApp -> RemoteRuntimeAdapter -> /api/v1/master-agent/tasks/[taskId]/complete -> boss-state.json`,服务端写入 `control_summary` 消息时会保留 `controlTarget`,Android 会话页可直接渲染“目标:URL/应用名”
|
||||||
- 相关配置项:
|
- 相关配置项:
|
||||||
|
|||||||
@@ -655,6 +655,14 @@ function renderOverviewTab(status, { bound, heroTitle, heroSubtitle, qrBlock })
|
|||||||
<div class="metric-title">Codex Remote Control</div>
|
<div class="metric-title">Codex Remote Control</div>
|
||||||
<div class="metric-value">${escapeHtml(status.codex.remoteControl.statusLabel)}</div>
|
<div class="metric-value">${escapeHtml(status.codex.remoteControl.statusLabel)}</div>
|
||||||
<div class="metric-detail">${escapeHtml(status.codex.remoteControl.startCommandLabel)}</div>
|
<div class="metric-detail">${escapeHtml(status.codex.remoteControl.startCommandLabel)}</div>
|
||||||
|
<div class="button-row remote-actions">
|
||||||
|
<form action="/api/v1/boss-agent/codex-remote-control/start" method="post">
|
||||||
|
<button class="button secondary" type="submit">启动远控</button>
|
||||||
|
</form>
|
||||||
|
<form action="/api/v1/boss-agent/codex-remote-control/stop" method="post">
|
||||||
|
<button class="button secondary" type="submit">停止远控</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card metric">
|
<div class="card metric">
|
||||||
<div class="metric-title">boss-agent OTA</div>
|
<div class="metric-title">boss-agent OTA</div>
|
||||||
|
|||||||
149
local-agent/codex-remote-control-daemon.mjs
Normal file
149
local-agent/codex-remote-control-daemon.mjs
Normal file
@@ -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 } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -41,6 +41,9 @@ import {
|
|||||||
checkBossAgentOtaUpdate,
|
checkBossAgentOtaUpdate,
|
||||||
getBossAgentOtaRunnerConfig,
|
getBossAgentOtaRunnerConfig,
|
||||||
} from "./boss-agent-ota-runner.mjs";
|
} from "./boss-agent-ota-runner.mjs";
|
||||||
|
import {
|
||||||
|
runCodexRemoteControlDaemonAction,
|
||||||
|
} from "./codex-remote-control-daemon.mjs";
|
||||||
import {
|
import {
|
||||||
sanitizeSensitiveTaskFailureDetailForLog,
|
sanitizeSensitiveTaskFailureDetailForLog,
|
||||||
sanitizeSensitiveTaskFailureDetailForTransport,
|
sanitizeSensitiveTaskFailureDetailForTransport,
|
||||||
@@ -1554,6 +1557,41 @@ const server = createServer(async (request, response) => {
|
|||||||
return;
|
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") {
|
if (requestUrl.pathname === "/api/v1/boss-agent/permissions/open") {
|
||||||
const target = requestUrl.searchParams.get("target") || "core";
|
const target = requestUrl.searchParams.get("target") || "core";
|
||||||
const returnTab = normalizeBossAgentTab(requestUrl.searchParams.get("returnTab") ?? "permissions");
|
const returnTab = normalizeBossAgentTab(requestUrl.searchParams.get("returnTab") ?? "permissions");
|
||||||
|
|||||||
@@ -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 Computer Use/);
|
||||||
assert.match(html, /Codex Remote Control/);
|
assert.match(html, /Codex Remote Control/);
|
||||||
assert.match(html, /remote-control start --json/);
|
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, /boss-agent OTA/);
|
||||||
assert.match(html, /发现新版本/);
|
assert.match(html, /发现新版本/);
|
||||||
assert.match(html, /默认公司/);
|
assert.match(html, /默认公司/);
|
||||||
|
|||||||
68
tests/codex-remote-control-daemon.test.mjs
Normal file
68
tests/codex-remote-control-daemon.test.mjs
Normal file
@@ -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/,
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user