fix(agent): bound CLI subprocess execution

This commit is contained in:
2026-08-18 17:00:23 +08:00
parent 9aa5a96e60
commit 18e8b25f48
7 changed files with 384 additions and 65 deletions
+21 -64
View File
@@ -1,5 +1,4 @@
import { randomUUID } from "node:crypto";
import { spawn } from "node:child_process";
import cors from "cors";
import express from "express";
@@ -11,6 +10,7 @@ import {
runWithCredentialRefresh,
} from "./auth/credentialRefresh.js";
import { SessionTranscriptStore } from "./sessions/transcriptStore.js";
import { executeCliCommand } from "./cli/executeCliCommand.js";
import { ChatSessionBridge } from "./chat/sessionBridge.js";
import { config } from "./config.js";
import { SessionUiStateStore } from "./sessions/uiStateStore.js";
@@ -229,7 +229,12 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => {
result = await runWithCredentialRefresh(
credentialRefreshCoordinator,
context,
(activeContext) => executeCliCommand(activeContext, command, timeoutSec),
(activeContext) =>
executeCliCommand(activeContext, command, timeoutSec, {
apiBaseUrl: config.TJWATER_API_BASE_URL,
cliPath: config.TJWATER_CLI_PATH,
maxOutputBytes: config.MAX_INLINE_RESULT_BYTES,
}),
);
} catch (error) {
if (!(error instanceof CredentialRefreshError)) {
@@ -266,6 +271,20 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => {
return;
}
if (result.outcome === "output_limit") {
res.status(502).json({
ok: false,
schema_version: "tjwater-cli/v1",
summary: "CLI 输出超过安全限制",
error: {
code: "OUTPUT_LIMIT_EXCEEDED",
message: `${result.exceededStream ?? "output"} exceeded ${config.MAX_INLINE_RESULT_BYTES} bytes`,
retryable: false,
},
});
return;
}
if (result.status === 401) {
markAuthExpired(
getRuntimeSessionContext(sessionId) ?? context,
@@ -300,68 +319,6 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => {
}
});
const executeCliCommand = async (
context: RuntimeSessionContext,
command: string,
timeoutSec: number,
) => {
const child = spawn(
config.TJWATER_CLI_PATH,
["--auth-stdin", ...command.split(/\s+/).filter(Boolean)],
{ stdio: ["pipe", "pipe", "pipe"] },
);
let stdout = "";
let stderr = "";
child.stdout.on("data", (data: Buffer) => {
stdout += data.toString("utf-8");
});
child.stderr.on("data", (data: Buffer) => {
stderr += data.toString("utf-8");
});
child.stdin.write(
JSON.stringify({
server: config.TJWATER_API_BASE_URL,
access_token: context.accessToken,
project_id: context.projectId,
}),
);
child.stdin.end();
const exitCode = await new Promise<number | null>((resolve, reject) => {
const timer = setTimeout(() => {
child.kill("SIGTERM");
resolve(-1);
}, timeoutSec * 1000);
child.on("close", (code) => {
clearTimeout(timer);
resolve(code);
});
child.on("error", (error) => {
clearTimeout(timer);
reject(error);
});
});
let errorCode = "";
try {
const payload = JSON.parse(stdout) as { error?: { code?: unknown } };
errorCode =
typeof payload.error?.code === "string" ? payload.error.code : "";
} catch {
errorCode = "";
}
const status =
exitCode === -1
? 504
: errorCode === "HTTP_401" || errorCode === "UNAUTHENTICATED"
? 401
: errorCode === "HTTP_403"
? 403
: exitCode === 0
? 200
: 502;
return { exitCode, status, stderr, stdout };
};
app.post("/internal/tools/store-render-ref", async (req, res) => {
if (req.header("x-agent-internal-token") !== internalToken) {
res.status(403).json({ message: "forbidden" });