chore: initialize qiming workspace repository
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,717 @@
|
||||
/**
|
||||
* 单元测试: AcpEngine — 取消链路优化
|
||||
*
|
||||
* 覆盖内容:
|
||||
* - abortSession 先 reject 再等待 ACP cancel
|
||||
* - abortSession 超时后仍完成清理
|
||||
* - terminating 状态下拒绝新 prompt
|
||||
* - terminating 状态下抑制 message/tool 更新
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import * as path from "path";
|
||||
import * as dependencies from "@main/services/system/dependencies";
|
||||
|
||||
vi.mock("electron-log", () => ({
|
||||
default: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../memory", () => ({
|
||||
memoryService: {
|
||||
isInitialized: vi.fn(() => false),
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
ensureMemoryReadyForSession: vi.fn().mockResolvedValue(undefined),
|
||||
onSessionEnd: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/processTree", () => ({
|
||||
killProcessTree: vi.fn(),
|
||||
killProcessTreeGraceful: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../system/processRegistry", () => ({
|
||||
processRegistry: {
|
||||
unregister: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@main/services/packages/guiAgentServer", () => ({
|
||||
getGuiAgentServerUrl: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("@main/services/packages/windowsMcp", () => ({
|
||||
getWindowsMcpUrl: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("@main/services/system/dependencies", () => ({
|
||||
getResourcesPath: vi.fn(() => "/mock/resources"),
|
||||
getAppEnv: vi.fn(() => ({ PATH: "/mock/path" })),
|
||||
getBundledGitBashPath: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("@main/services/sandbox/policy", () => ({
|
||||
getSandboxPolicy: vi.fn(() => ({
|
||||
enabled: false,
|
||||
backend: "auto",
|
||||
mode: "compat",
|
||||
autoFallback: "startup-only",
|
||||
windowsMode: "workspace-write",
|
||||
})),
|
||||
resolveSandboxType: vi.fn(async () => ({
|
||||
type: "none",
|
||||
degraded: false,
|
||||
})),
|
||||
getBundledLinuxBwrapPath: vi.fn(() => null),
|
||||
getBundledWindowsSandboxHelperPath: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
import { AcpEngine } from "./acpEngine";
|
||||
import * as acpClient from "./acpClient";
|
||||
|
||||
type Deferred<T> = {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
};
|
||||
|
||||
function createDeferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function setupEngine(engineType: "claude-code" | "qimingcode" = "qimingcode") {
|
||||
const engine = new AcpEngine(engineType);
|
||||
const sessionId = "session-test-001";
|
||||
const session = {
|
||||
id: sessionId,
|
||||
acpSessionId: sessionId,
|
||||
createdAt: Date.now(),
|
||||
status: "active",
|
||||
} as any;
|
||||
|
||||
(engine as any).config = { engine: engineType, workspaceDir: "/tmp" } as any;
|
||||
(engine as any).acpConnection = {
|
||||
cancel: vi.fn(),
|
||||
prompt: vi.fn(),
|
||||
} as any;
|
||||
(engine as any).sessions.set(sessionId, session);
|
||||
|
||||
return {
|
||||
engine,
|
||||
sessionId,
|
||||
session,
|
||||
acpConnection: (engine as any).acpConnection as {
|
||||
cancel: any;
|
||||
prompt: any;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setupEngineForCreateSession(
|
||||
engineType: "claude-code" | "qimingcode" = "qimingcode",
|
||||
) {
|
||||
const engine = new AcpEngine(engineType);
|
||||
const newSession = vi.fn().mockResolvedValue({ sessionId: "acp-session-1" });
|
||||
|
||||
(engine as any).config = {
|
||||
engine: engineType,
|
||||
workspaceDir: "/workspace/project",
|
||||
mcpServers: {
|
||||
"gui-agent": {
|
||||
url: "http://127.0.0.1:9876/mcp",
|
||||
type: "http",
|
||||
},
|
||||
"safe-tool": {
|
||||
command: "node",
|
||||
args: ["tool.js"],
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
(engine as any).acpConnection = {
|
||||
newSession,
|
||||
prompt: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
} as any;
|
||||
|
||||
return { engine, newSession };
|
||||
}
|
||||
|
||||
describe("AcpEngine.abortSession", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("先 reject 本地 prompt,再等待 ACP cancel", async () => {
|
||||
const { engine, sessionId, session, acpConnection } = setupEngine();
|
||||
const reject = vi.fn();
|
||||
(engine as any).activePromptSessions.add(sessionId);
|
||||
(engine as any).activePromptRejects.set(sessionId, reject);
|
||||
|
||||
const deferred = createDeferred<void>();
|
||||
acpConnection.cancel.mockReturnValueOnce(deferred.promise);
|
||||
|
||||
const abortPromise = engine.abortSession(sessionId);
|
||||
|
||||
expect(reject).toHaveBeenCalledTimes(1);
|
||||
expect((engine as any).activePromptSessions.has(sessionId)).toBe(false);
|
||||
expect(session.status).toBe("terminating");
|
||||
|
||||
deferred.resolve();
|
||||
await expect(abortPromise).resolves.toBe(true);
|
||||
expect(session.status).toBe("idle");
|
||||
});
|
||||
|
||||
it("ACP cancel 超时后仍完成清理", async () => {
|
||||
const { engine, sessionId, session, acpConnection } = setupEngine();
|
||||
const reject = vi.fn();
|
||||
(engine as any).activePromptSessions.add(sessionId);
|
||||
(engine as any).activePromptRejects.set(sessionId, reject);
|
||||
|
||||
acpConnection.cancel.mockReturnValueOnce(new Promise<void>(() => {}));
|
||||
|
||||
vi.useFakeTimers();
|
||||
const abortPromise = engine.abortSession(sessionId);
|
||||
await vi.advanceTimersByTimeAsync(15_001);
|
||||
|
||||
await expect(abortPromise).resolves.toBe(true);
|
||||
expect(session.status).toBe("idle");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AcpEngine.prompt", () => {
|
||||
it("terminating 状态拒绝新 prompt", async () => {
|
||||
const { engine, sessionId, session, acpConnection } = setupEngine();
|
||||
session.status = "terminating";
|
||||
|
||||
await expect(
|
||||
engine.prompt(sessionId, [{ type: "text", text: "hi" }]),
|
||||
).rejects.toThrow("terminating");
|
||||
|
||||
expect(acpConnection.prompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("qimingcode 默认透传 mcpInit 非阻塞元信息", async () => {
|
||||
const { engine, sessionId, acpConnection } = setupEngine("qimingcode");
|
||||
acpConnection.prompt.mockResolvedValueOnce({ stopReason: "end_turn" });
|
||||
|
||||
await engine.prompt(sessionId, [{ type: "text", text: "hi" }], {
|
||||
messageID: "rid-meta-001",
|
||||
});
|
||||
|
||||
expect(acpConnection.prompt).toHaveBeenCalledTimes(1);
|
||||
expect(acpConnection.prompt).toHaveBeenCalledWith({
|
||||
sessionId,
|
||||
prompt: [{ type: "text", text: "hi" }],
|
||||
_meta: {
|
||||
requestId: "rid-meta-001",
|
||||
request_id: "rid-meta-001",
|
||||
mcpInitPolicy: "non_blocking",
|
||||
mcpInitTimeoutMs: 500,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("claude-code 不透传 qimingcode 专属 mcpInit 元信息", async () => {
|
||||
const { engine, sessionId, acpConnection } = setupEngine("claude-code");
|
||||
acpConnection.prompt.mockResolvedValueOnce({ stopReason: "end_turn" });
|
||||
|
||||
await engine.prompt(sessionId, [{ type: "text", text: "hi" }], {
|
||||
messageID: "rid-meta-002",
|
||||
});
|
||||
|
||||
expect(acpConnection.prompt).toHaveBeenCalledTimes(1);
|
||||
expect(acpConnection.prompt).toHaveBeenCalledWith({
|
||||
sessionId,
|
||||
prompt: [{ type: "text", text: "hi" }],
|
||||
_meta: {
|
||||
requestId: "rid-meta-002",
|
||||
request_id: "rid-meta-002",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("qimingcode 在 MCP 断连窗口内自动重试一次", async () => {
|
||||
const { engine, sessionId, acpConnection } = setupEngine("qimingcode");
|
||||
acpConnection.prompt
|
||||
.mockRejectedValueOnce(
|
||||
new Error("SSE stream disconnected: TypeError: terminated"),
|
||||
)
|
||||
.mockResolvedValueOnce({ stopReason: "end_turn" });
|
||||
|
||||
vi.useFakeTimers();
|
||||
const promptPromise = engine.prompt(sessionId, [
|
||||
{ type: "text", text: "hi" },
|
||||
]);
|
||||
await vi.advanceTimersByTimeAsync(1_200);
|
||||
|
||||
await expect(promptPromise).resolves.toBeDefined();
|
||||
expect(acpConnection.prompt).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("claude-code 保持原逻辑,不执行 MCP 自动重试", async () => {
|
||||
const { engine, sessionId, acpConnection } = setupEngine("claude-code");
|
||||
acpConnection.prompt.mockRejectedValueOnce(
|
||||
new Error("SSE stream disconnected: TypeError: terminated"),
|
||||
);
|
||||
|
||||
await expect(
|
||||
engine.prompt(sessionId, [{ type: "text", text: "hi" }]),
|
||||
).resolves.toBeDefined();
|
||||
expect(acpConnection.prompt).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("qimingcode MCP 断连失败时上报 mcp_reconnecting", async () => {
|
||||
const { engine, sessionId, acpConnection } = setupEngine("qimingcode");
|
||||
const onPromptEnd = vi.fn();
|
||||
engine.on("computer:promptEnd", onPromptEnd);
|
||||
|
||||
acpConnection.prompt.mockRejectedValue(
|
||||
new Error("SSE stream disconnected: TypeError: terminated"),
|
||||
);
|
||||
|
||||
vi.useFakeTimers();
|
||||
const promptPromise = engine.prompt(sessionId, [
|
||||
{ type: "text", text: "hi" },
|
||||
]);
|
||||
await vi.advanceTimersByTimeAsync(1_200);
|
||||
await promptPromise;
|
||||
|
||||
expect(acpConnection.prompt).toHaveBeenCalledTimes(2);
|
||||
expect(onPromptEnd).toHaveBeenCalled();
|
||||
const event = onPromptEnd.mock.calls.at(-1)?.[0];
|
||||
expect(event.reason).toBe("mcp_reconnecting");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AcpEngine.handleAcpSessionUpdate", () => {
|
||||
it("terminating 状态抑制 message/tool 更新", () => {
|
||||
const { engine, sessionId, session } = setupEngine();
|
||||
session.status = "terminating";
|
||||
|
||||
const onMessage = vi.fn();
|
||||
const onProgress = vi.fn();
|
||||
engine.on("message.part.updated", onMessage);
|
||||
engine.on("computer:progress", onProgress);
|
||||
|
||||
(engine as any).handleAcpSessionUpdate(sessionId, {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "hello" },
|
||||
});
|
||||
|
||||
expect(onMessage).not.toHaveBeenCalled();
|
||||
expect(onProgress).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AcpEngine.createSession", () => {
|
||||
it("沙箱启用时应移除 gui-agent MCP(互斥)", async () => {
|
||||
const { engine, newSession } = setupEngineForCreateSession("qimingcode");
|
||||
|
||||
(engine as any).storedSandboxConfig = {
|
||||
enabled: true,
|
||||
type: "windows-sandbox",
|
||||
projectWorkspaceDir: "/workspace/project",
|
||||
};
|
||||
|
||||
await engine.createSession({
|
||||
mcpServers: {
|
||||
"another-tool": {
|
||||
command: "node",
|
||||
args: ["another.js"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(newSession).toHaveBeenCalledTimes(1);
|
||||
const sent = newSession.mock.calls[0][0] as {
|
||||
mcpServers: Array<{ name: string }>;
|
||||
};
|
||||
|
||||
expect(sent.mcpServers.map((m) => m.name)).toEqual([
|
||||
"safe-tool",
|
||||
"another-tool",
|
||||
]);
|
||||
});
|
||||
|
||||
it("沙箱关闭时应保留 gui-agent MCP", async () => {
|
||||
const { engine, newSession } = setupEngineForCreateSession("qimingcode");
|
||||
(engine as any).storedSandboxConfig = null;
|
||||
|
||||
await engine.createSession();
|
||||
|
||||
expect(newSession).toHaveBeenCalledTimes(1);
|
||||
const sent = newSession.mock.calls[0][0] as {
|
||||
mcpServers: Array<{ name: string }>;
|
||||
};
|
||||
|
||||
expect(sent.mcpServers.map((m) => m.name)).toContain("gui-agent");
|
||||
});
|
||||
|
||||
it("compat 下若 sandboxed-fs 脚本缺失,不应阻断其他 MCP 加载", async () => {
|
||||
const { engine, newSession } = setupEngineForCreateSession("claude-code");
|
||||
const resourcesSpy = vi
|
||||
.spyOn(dependencies, "getResourcesPath")
|
||||
.mockReturnValue("/__missing_resources__");
|
||||
|
||||
(engine as any).storedSandboxConfig = {
|
||||
enabled: true,
|
||||
type: "windows-sandbox",
|
||||
mode: "compat",
|
||||
projectWorkspaceDir: "/workspace/project",
|
||||
windowsSandboxHelperPath: "C:\\tools\\qiming-sandbox-helper.exe",
|
||||
};
|
||||
|
||||
try {
|
||||
await engine.createSession();
|
||||
} finally {
|
||||
resourcesSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(newSession).toHaveBeenCalledTimes(1);
|
||||
const sent = newSession.mock.calls[0][0] as {
|
||||
mcpServers: Array<{ name: string }>;
|
||||
_meta?: {
|
||||
claudeCode?: { options?: { disallowedTools?: string[] } };
|
||||
};
|
||||
};
|
||||
const names = sent.mcpServers.map((m) => m.name);
|
||||
expect(names).not.toContain("sandboxed-bash");
|
||||
expect(names).not.toContain("sandboxed-fs");
|
||||
const disallowed = sent._meta?.claudeCode?.options?.disallowedTools || [];
|
||||
expect(disallowed).not.toContain("Bash");
|
||||
expect(disallowed).not.toContain("Write");
|
||||
expect(disallowed).not.toContain("Edit");
|
||||
expect(disallowed).not.toContain("NotebookEdit");
|
||||
});
|
||||
|
||||
it("compat 模式下 sandboxed-fs 注入与工具禁用应生效", async () => {
|
||||
const { engine, newSession } = setupEngineForCreateSession("claude-code");
|
||||
const resourcesSpy = vi
|
||||
.spyOn(dependencies, "getResourcesPath")
|
||||
.mockReturnValue(path.join(process.cwd(), "resources"));
|
||||
|
||||
(engine as any).storedSandboxConfig = {
|
||||
enabled: true,
|
||||
type: "windows-sandbox",
|
||||
mode: "compat",
|
||||
projectWorkspaceDir: "/workspace/project",
|
||||
windowsSandboxHelperPath: "C:\\tools\\qiming-sandbox-helper.exe",
|
||||
};
|
||||
|
||||
try {
|
||||
await engine.createSession();
|
||||
} finally {
|
||||
resourcesSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(newSession).toHaveBeenCalledTimes(1);
|
||||
const sent = newSession.mock.calls[0][0] as {
|
||||
mcpServers: Array<{
|
||||
name: string;
|
||||
env?: Array<{ name: string; value: string }>;
|
||||
}>;
|
||||
_meta?: {
|
||||
claudeCode?: { options?: { disallowedTools?: string[] } };
|
||||
};
|
||||
};
|
||||
const fsServer = sent.mcpServers.find((m) => m.name === "sandboxed-fs");
|
||||
expect(fsServer).toBeDefined();
|
||||
const modeVar = fsServer?.env?.find(
|
||||
(kv) => kv.name === "QIMING_SANDBOX_MODE",
|
||||
);
|
||||
expect(modeVar?.value).toBe("compat");
|
||||
const disallowed = sent._meta?.claudeCode?.options?.disallowedTools || [];
|
||||
expect(disallowed).toContain("Write");
|
||||
expect(disallowed).toContain("Edit");
|
||||
expect(disallowed).toContain("NotebookEdit");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AcpEngine.handlePermissionRequest(strict)", () => {
|
||||
it("strict 下 workspace 内写入仅放行 allow_once", async () => {
|
||||
const { engine, sessionId } = setupEngine("qimingcode");
|
||||
(engine as any).config = { engine: "qimingcode", workspaceDir: "/tmp/ws" };
|
||||
(engine as any).storedSandboxConfig = {
|
||||
enabled: true,
|
||||
mode: "strict",
|
||||
projectWorkspaceDir: "/tmp/ws",
|
||||
};
|
||||
(engine as any).isolatedHome = "/tmp/iso-home";
|
||||
|
||||
const result = await (engine as any).handlePermissionRequest({
|
||||
sessionId,
|
||||
toolCall: {
|
||||
toolCallId: "tc-strict-1",
|
||||
kind: "edit",
|
||||
title: "Edit",
|
||||
rawInput: { file_path: "/tmp/ws/a.txt" },
|
||||
},
|
||||
options: [
|
||||
{
|
||||
optionId: "allow-always",
|
||||
kind: "allow_always",
|
||||
name: "allow always",
|
||||
},
|
||||
{
|
||||
optionId: "allow-once",
|
||||
kind: "allow_once",
|
||||
name: "allow once",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
outcome: { outcome: "selected", optionId: "allow-once" },
|
||||
});
|
||||
});
|
||||
|
||||
it("strict 下 workspace/temp/appData 外写入应拒绝", async () => {
|
||||
const { engine, sessionId } = setupEngine("qimingcode");
|
||||
(engine as any).config = { engine: "qimingcode", workspaceDir: "/tmp/ws" };
|
||||
(engine as any).storedSandboxConfig = {
|
||||
enabled: true,
|
||||
mode: "strict",
|
||||
projectWorkspaceDir: "/tmp/ws",
|
||||
};
|
||||
(engine as any).isolatedHome = "/tmp/iso-home";
|
||||
|
||||
const result = await (engine as any).handlePermissionRequest({
|
||||
sessionId,
|
||||
toolCall: {
|
||||
toolCallId: "tc-strict-2",
|
||||
kind: "write",
|
||||
title: "Write",
|
||||
rawInput: { file_path: "/etc/passwd" },
|
||||
},
|
||||
options: [
|
||||
{
|
||||
optionId: "allow-always",
|
||||
kind: "allow_always",
|
||||
name: "allow always",
|
||||
},
|
||||
{
|
||||
optionId: "allow-once",
|
||||
kind: "allow_once",
|
||||
name: "allow once",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toEqual({ outcome: { outcome: "cancelled" } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("AcpEngine.init", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("warmup 进程也应注入 MCP 配置,避免复用后工具列表为空", async () => {
|
||||
const engine = new AcpEngine("qimingcode");
|
||||
let capturedEnv: Record<string, string> | undefined;
|
||||
|
||||
const mockConnection = {
|
||||
initialize: vi.fn().mockResolvedValue({ protocolVersion: "1.0.0" }),
|
||||
} as any;
|
||||
|
||||
const mockProcess = {
|
||||
pid: 12345,
|
||||
on: vi.fn(),
|
||||
stdout: { removeAllListeners: vi.fn() },
|
||||
stderr: { removeAllListeners: vi.fn() },
|
||||
stdin: { removeAllListeners: vi.fn() },
|
||||
removeAllListeners: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
} as any;
|
||||
|
||||
vi.spyOn(acpClient, "resolveAcpBinary").mockReturnValue({
|
||||
binPath: "qimingcode",
|
||||
binArgs: ["acp"],
|
||||
isNative: false,
|
||||
});
|
||||
vi.spyOn(acpClient, "createAcpConnection").mockImplementation(
|
||||
async (cfg: any) => {
|
||||
capturedEnv = cfg.env as Record<string, string>;
|
||||
return {
|
||||
connection: mockConnection,
|
||||
process: mockProcess,
|
||||
isolatedHome: null,
|
||||
cleanup: vi.fn(),
|
||||
} as any;
|
||||
},
|
||||
);
|
||||
vi.spyOn(acpClient, "loadAcpSdk").mockResolvedValue({
|
||||
PROTOCOL_VERSION: "1.0.0",
|
||||
} as any);
|
||||
|
||||
const ok = await engine.init({
|
||||
engine: "qimingcode",
|
||||
workspaceDir: "/tmp",
|
||||
env: { QIMING_AGENT_WARMUP: "1" },
|
||||
mcpServers: {
|
||||
"chrome-devtools": {
|
||||
command: "node",
|
||||
args: ["proxy.js", "--config-file", "/tmp/mcp.json"],
|
||||
env: {},
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(capturedEnv?.OPENCODE_CONFIG_CONTENT).toBeTruthy();
|
||||
|
||||
const injected = JSON.parse(capturedEnv!.OPENCODE_CONFIG_CONTENT!);
|
||||
expect(injected.mcp).toBeDefined();
|
||||
expect(injected.mcp["chrome-devtools"]).toBeDefined();
|
||||
expect(injected.permission.question).toBe("deny");
|
||||
|
||||
await engine.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AcpEngine.chat", () => {
|
||||
it("qimingcode: 将 request_id 透传并附带 mcpInit 默认策略", async () => {
|
||||
const { engine, sessionId, session } = setupEngine();
|
||||
session.projectId = "project-test-001";
|
||||
|
||||
const promptAsyncSpy = vi
|
||||
.spyOn(engine, "promptAsync")
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
const result = await engine.chat({
|
||||
user_id: "user-1",
|
||||
project_id: "project-test-001",
|
||||
session_id: sessionId,
|
||||
request_id: "rid-chat-001",
|
||||
prompt: "hello trace",
|
||||
} as any);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(promptAsyncSpy).toHaveBeenCalledWith(
|
||||
sessionId,
|
||||
[{ type: "text", text: "hello trace" }],
|
||||
{
|
||||
messageID: "rid-chat-001",
|
||||
mcpInitPolicy: "non_blocking",
|
||||
mcpInitTimeoutMs: 500,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("claude-code: chat 保持原逻辑仅透传 messageID", async () => {
|
||||
const { engine, sessionId, session } = setupEngine("claude-code");
|
||||
session.projectId = "project-test-001";
|
||||
|
||||
const promptAsyncSpy = vi
|
||||
.spyOn(engine, "promptAsync")
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
const result = await engine.chat({
|
||||
user_id: "user-1",
|
||||
project_id: "project-test-001",
|
||||
session_id: sessionId,
|
||||
request_id: "rid-chat-claude-001",
|
||||
prompt: "hello trace",
|
||||
} as any);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(promptAsyncSpy).toHaveBeenCalledWith(
|
||||
sessionId,
|
||||
[{ type: "text", text: "hello trace" }],
|
||||
{ messageID: "rid-chat-claude-001" },
|
||||
);
|
||||
});
|
||||
|
||||
it("claude-code: compat + 新会话 + context_servers 时等待 MCP warmup 后再发首条 prompt", async () => {
|
||||
const engine = new AcpEngine("claude-code");
|
||||
(engine as any).config = {
|
||||
engine: "claude-code",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
mcpServers: {
|
||||
whois: { command: "node", args: ["whois.js"] },
|
||||
time: { command: "node", args: ["time.js"] },
|
||||
},
|
||||
};
|
||||
(engine as any).acpConnection = {} as any;
|
||||
(engine as any).storedSandboxConfig = {
|
||||
enabled: true,
|
||||
mode: "compat",
|
||||
type: "macos-seatbelt",
|
||||
projectWorkspaceDir: "/tmp/workspace",
|
||||
};
|
||||
|
||||
vi.spyOn(engine, "createSession").mockImplementation(async () => {
|
||||
(engine as any).sessions.set("new-session-compat", {
|
||||
id: "new-session-compat",
|
||||
acpSessionId: "new-session-compat",
|
||||
createdAt: Date.now(),
|
||||
status: "idle",
|
||||
mcpServerCount: 2,
|
||||
});
|
||||
return {
|
||||
id: "new-session-compat",
|
||||
title: "project-compat",
|
||||
time: { created: Date.now() },
|
||||
} as any;
|
||||
});
|
||||
|
||||
const promptAsyncSpy = vi
|
||||
.spyOn(engine, "promptAsync")
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
vi.useFakeTimers();
|
||||
const chatPromise = engine.chat({
|
||||
user_id: "user-1",
|
||||
project_id: "project-compat",
|
||||
request_id: "rid-chat-compat-001",
|
||||
prompt: "hello compat",
|
||||
agent_config: {
|
||||
context_servers: {
|
||||
whois: { enabled: true },
|
||||
time: { enabled: true },
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1199);
|
||||
expect(promptAsyncSpy).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await expect(chatPromise).resolves.toMatchObject({ success: true });
|
||||
expect(promptAsyncSpy).toHaveBeenCalledWith(
|
||||
"new-session-compat",
|
||||
[{ type: "text", text: "hello compat" }],
|
||||
{ messageID: "rid-chat-compat-001" },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/** listSessionsDetailed:会话 title 透传到列表(L1 数据断言) */
|
||||
describe("AcpEngine.listSessionsDetailed", () => {
|
||||
it("返回的会话列表应包含 createSession 时传入的 title", () => {
|
||||
const { engine, sessionId, session } = setupEngine();
|
||||
const expectedTitle = "我的会话标题";
|
||||
(session as any).title = expectedTitle;
|
||||
|
||||
const list = engine.listSessionsDetailed();
|
||||
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].id).toBe(sessionId);
|
||||
expect(list[0].title).toBe(expectedTitle);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildTerminalSandboxEnv,
|
||||
resolveSandboxCwdWithFallback,
|
||||
resolveSandboxCwdWithinRoots,
|
||||
TERMINAL_SANDBOX_SAFE_ENV_KEYS,
|
||||
} from "./acpTerminalManager";
|
||||
|
||||
describe("AcpTerminalManager security helpers", () => {
|
||||
it("should only keep safe env keys in sandbox env", () => {
|
||||
const hostEnv: NodeJS.ProcessEnv = {
|
||||
PATH: "/usr/bin",
|
||||
TEMP: "/tmp",
|
||||
ANTHROPIC_API_KEY: "secret-key",
|
||||
OPENAI_API_KEY: "secret-openai",
|
||||
HOME: "/Users/demo",
|
||||
COMSPEC: "C:\\Windows\\System32\\cmd.exe",
|
||||
PATHEXT: ".COM;.EXE;.BAT;.CMD",
|
||||
};
|
||||
|
||||
const env = buildTerminalSandboxEnv(hostEnv, [
|
||||
{ name: "CUSTOM", value: "1" },
|
||||
]);
|
||||
|
||||
expect(env.PATH).toBe("/usr/bin");
|
||||
expect(env.TEMP).toBe("/tmp");
|
||||
expect(env.HOME).toBe("/Users/demo");
|
||||
expect(env.COMSPEC).toBe("C:\\Windows\\System32\\cmd.exe");
|
||||
expect(env.PATHEXT).toBe(".COM;.EXE;.BAT;.CMD");
|
||||
expect(env.CUSTOM).toBe("1");
|
||||
expect(env.ANTHROPIC_API_KEY).toBeUndefined();
|
||||
expect(env.OPENAI_API_KEY).toBeUndefined();
|
||||
|
||||
for (const key of Object.keys(env)) {
|
||||
if (key === "CUSTOM") continue;
|
||||
expect(
|
||||
(TERMINAL_SANDBOX_SAFE_ENV_KEYS as readonly string[]).includes(key),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("should allow cwd within writable root", () => {
|
||||
const cwd = resolveSandboxCwdWithinRoots("/workspace/project/subdir", [
|
||||
"/workspace/project",
|
||||
]);
|
||||
expect(cwd).toBe("/workspace/project/subdir");
|
||||
});
|
||||
|
||||
it("should reject cwd outside writable roots", () => {
|
||||
expect(() =>
|
||||
resolveSandboxCwdWithinRoots("/outside/path", ["/workspace/project"]),
|
||||
).toThrow(/outside writable roots/i);
|
||||
});
|
||||
|
||||
it("should fallback to first writable root when cwd is omitted", () => {
|
||||
const result = resolveSandboxCwdWithFallback(
|
||||
undefined,
|
||||
["/workspace/project", "/workspace/cache"],
|
||||
"/outside/host-cwd",
|
||||
);
|
||||
expect(result).toEqual({
|
||||
cwd: "/workspace/project",
|
||||
usedFallback: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should fallback to first writable root when cwd is outside writable roots", () => {
|
||||
const result = resolveSandboxCwdWithFallback(
|
||||
"/outside/path",
|
||||
["/workspace/project", "/workspace/cache"],
|
||||
"/outside/host-cwd",
|
||||
);
|
||||
expect(result).toEqual({
|
||||
cwd: "/workspace/project",
|
||||
usedFallback: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should keep cwd when it is within writable roots", () => {
|
||||
const result = resolveSandboxCwdWithFallback(
|
||||
"/workspace/project/subdir",
|
||||
["/workspace/project", "/workspace/cache"],
|
||||
"/outside/host-cwd",
|
||||
);
|
||||
expect(result).toEqual({
|
||||
cwd: "/workspace/project/subdir",
|
||||
usedFallback: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,740 @@
|
||||
/**
|
||||
* ACP Terminal Manager — Client-side implementation of the ACP Terminal API.
|
||||
*
|
||||
* Manages terminal lifecycle for commands delegated by ACP agents via
|
||||
* `terminal/create`. On Windows, commands are routed through
|
||||
* `qiming-sandbox-helper.exe run` for per-command sandboxing.
|
||||
* On macOS/Linux, commands are executed directly (process-level sandboxing
|
||||
* is handled separately via seatbelt/bwrap).
|
||||
*
|
||||
* Implements the ACP Terminal protocol methods:
|
||||
* - terminal/create → createTerminal()
|
||||
* - terminal/output → terminalOutput()
|
||||
* - terminal/wait_for_exit → waitForExit()
|
||||
* - terminal/kill → kill()
|
||||
* - terminal/release → release()
|
||||
*
|
||||
* @see https://agentclientprotocol.com/protocol/terminals
|
||||
*/
|
||||
|
||||
import { spawn, ChildProcess } from "child_process";
|
||||
import { randomUUID } from "crypto";
|
||||
import path from "path";
|
||||
import log from "electron-log";
|
||||
import { SandboxInvoker } from "@main/services/sandbox/SandboxInvoker";
|
||||
import { killProcessTree } from "@main/services/utils/processTree";
|
||||
import { createPlatformAdapter } from "@main/services/system/platformAdapter";
|
||||
import type { SandboxMode, WindowsSandboxMode } from "@shared/types/sandbox";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
interface TerminalExitStatus {
|
||||
exitCode: number | null;
|
||||
signal: string | null;
|
||||
}
|
||||
|
||||
interface TerminalSession {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
/** Running child process (null after exit) */
|
||||
process: ChildProcess | null;
|
||||
/** Accumulated stdout + stderr output */
|
||||
output: string;
|
||||
/** Max output bytes to retain (null = unlimited) */
|
||||
byteLimit: number | null;
|
||||
/** Whether output was truncated due to byteLimit */
|
||||
truncated: boolean;
|
||||
/** Exit status (null while running) */
|
||||
exitStatus: TerminalExitStatus | null;
|
||||
/** Whether waitForExit promise has resolved */
|
||||
resolved: boolean;
|
||||
/** Promise that resolves when the process exits */
|
||||
exitPromise: Promise<TerminalExitStatus>;
|
||||
/** Resolver for exitPromise */
|
||||
resolveExit: (status: TerminalExitStatus) => void;
|
||||
}
|
||||
|
||||
export const TERMINAL_SANDBOX_SAFE_ENV_KEYS = [
|
||||
"PATH",
|
||||
"Path",
|
||||
"SYSTEMROOT",
|
||||
"SystemRoot",
|
||||
"WINDIR",
|
||||
"windir",
|
||||
"SYSTEMDRIVE",
|
||||
"SystemDrive",
|
||||
"COMSPEC",
|
||||
"ComSpec",
|
||||
"PATHEXT",
|
||||
"PATHExt",
|
||||
"TEMP",
|
||||
"TMP",
|
||||
"USERPROFILE",
|
||||
"HOME",
|
||||
"LOCALAPPDATA",
|
||||
"APPDATA",
|
||||
"COMPUTERNAME",
|
||||
"USERNAME",
|
||||
"OS",
|
||||
"PROCESSOR_ARCHITECTURE",
|
||||
"LANG",
|
||||
"TZ",
|
||||
] as const;
|
||||
|
||||
function isPathWithinRoot(candidate: string, root: string): boolean {
|
||||
const relative = path.relative(root, candidate);
|
||||
return (
|
||||
relative === "" ||
|
||||
(!relative.startsWith("..") && !path.isAbsolute(relative))
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveSandboxCwdWithinRoots(
|
||||
requestedCwd: string,
|
||||
writableRoots: string[],
|
||||
): string {
|
||||
const resolvedCwd = path.resolve(requestedCwd);
|
||||
const resolvedRoots = writableRoots.map((root) => path.resolve(root));
|
||||
|
||||
if (resolvedRoots.length === 0) {
|
||||
return resolvedCwd;
|
||||
}
|
||||
|
||||
if (resolvedRoots.some((root) => isPathWithinRoot(resolvedCwd, root))) {
|
||||
return resolvedCwd;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Sandbox terminal cwd is outside writable roots: ${resolvedCwd}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveSandboxCwdWithFallback(
|
||||
requestedCwd: string | null | undefined,
|
||||
writableRoots: string[],
|
||||
fallbackCwd: string,
|
||||
): { cwd: string; usedFallback: boolean } {
|
||||
const resolvedRoots = writableRoots.map((root) => path.resolve(root));
|
||||
|
||||
if (resolvedRoots.length === 0) {
|
||||
return {
|
||||
cwd: path.resolve(requestedCwd ?? fallbackCwd),
|
||||
usedFallback: false,
|
||||
};
|
||||
}
|
||||
|
||||
const fallbackRoot = resolvedRoots[0];
|
||||
|
||||
if (!requestedCwd) {
|
||||
return { cwd: fallbackRoot, usedFallback: true };
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
cwd: resolveSandboxCwdWithinRoots(requestedCwd, resolvedRoots),
|
||||
usedFallback: false,
|
||||
};
|
||||
} catch {
|
||||
return { cwd: fallbackRoot, usedFallback: true };
|
||||
}
|
||||
}
|
||||
|
||||
export function buildTerminalSandboxEnv(
|
||||
hostEnv: NodeJS.ProcessEnv,
|
||||
overrides?: Array<{ name: string; value: string }>,
|
||||
): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
|
||||
for (const key of TERMINAL_SANDBOX_SAFE_ENV_KEYS) {
|
||||
const val = hostEnv[key];
|
||||
if (val !== undefined && val !== null) {
|
||||
env[key] = String(val);
|
||||
}
|
||||
}
|
||||
|
||||
if (overrides) {
|
||||
for (const { name, value } of overrides) {
|
||||
env[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
export interface AcpTerminalManagerOptions {
|
||||
/** Path to qiming-sandbox-helper.exe (Windows only) */
|
||||
windowsSandboxHelperPath?: string;
|
||||
/** Windows sandbox mode */
|
||||
windowsSandboxMode?: WindowsSandboxMode;
|
||||
/** Whether network access is allowed for sandboxed commands */
|
||||
networkEnabled?: boolean;
|
||||
/** Paths that sandboxed commands can write to */
|
||||
writablePaths?: string[];
|
||||
/** Sandbox strictness mode (strict / compat / permissive) */
|
||||
mode?: SandboxMode;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AcpTerminalManager
|
||||
// ============================================================================
|
||||
|
||||
export class AcpTerminalManager {
|
||||
private static readonly MAX_CONCURRENT = 50;
|
||||
private terminals = new Map<string, TerminalSession>();
|
||||
private sandboxInvoker: SandboxInvoker | null;
|
||||
private readonly useSandbox: boolean;
|
||||
private readonly networkEnabled: boolean;
|
||||
private readonly writablePaths: string[];
|
||||
|
||||
constructor(options?: AcpTerminalManagerOptions) {
|
||||
const platformAdapter = createPlatformAdapter();
|
||||
this.useSandbox = !!(
|
||||
options?.windowsSandboxHelperPath && platformAdapter.isWindows
|
||||
);
|
||||
this.networkEnabled = options?.networkEnabled ?? true;
|
||||
this.writablePaths = options?.writablePaths ?? [];
|
||||
|
||||
if (this.useSandbox) {
|
||||
this.sandboxInvoker = new SandboxInvoker("windows-sandbox", {
|
||||
windowsSandboxHelperPath: options!.windowsSandboxHelperPath,
|
||||
windowsSandboxMode: options!.windowsSandboxMode,
|
||||
networkEnabled: options!.networkEnabled ?? true,
|
||||
mode: options!.mode,
|
||||
});
|
||||
log.info(
|
||||
"[AcpTerminalManager] Initialized with Windows sandbox helper:",
|
||||
options!.windowsSandboxHelperPath,
|
||||
);
|
||||
} else {
|
||||
this.sandboxInvoker = null;
|
||||
log.info(
|
||||
"[AcpTerminalManager] Initialized (direct execution, no sandbox)",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- terminal/create ---
|
||||
|
||||
async createTerminal(params: {
|
||||
sessionId: string;
|
||||
command: string;
|
||||
args?: string[];
|
||||
env?: Array<{ name: string; value: string }>;
|
||||
cwd?: string | null;
|
||||
outputByteLimit?: number | null;
|
||||
}): Promise<string> {
|
||||
if (this.terminals.size >= AcpTerminalManager.MAX_CONCURRENT) {
|
||||
throw new Error(
|
||||
`Terminal limit reached (${AcpTerminalManager.MAX_CONCURRENT}). Release existing terminals first.`,
|
||||
);
|
||||
}
|
||||
|
||||
const terminalId = `term_${randomUUID()}`;
|
||||
|
||||
let resolveExit: ((status: TerminalExitStatus) => void) | null = null;
|
||||
const exitPromise = new Promise<TerminalExitStatus>((r) => {
|
||||
resolveExit = r;
|
||||
});
|
||||
|
||||
const session: TerminalSession = {
|
||||
id: terminalId,
|
||||
sessionId: params.sessionId,
|
||||
process: null,
|
||||
output: "",
|
||||
byteLimit: params.outputByteLimit ?? null,
|
||||
truncated: false,
|
||||
exitStatus: null,
|
||||
resolved: false,
|
||||
exitPromise,
|
||||
resolveExit: resolveExit!,
|
||||
};
|
||||
|
||||
// Build a sanitized environment for sandboxed commands.
|
||||
// Avoid leaking sensitive host env vars (API keys, tokens, etc.)
|
||||
// into sandboxed child processes.
|
||||
const env: Record<string, string> = this.useSandbox
|
||||
? buildTerminalSandboxEnv(process.env, params.env)
|
||||
: {
|
||||
...(process.env as Record<string, string>),
|
||||
...(params.env
|
||||
? Object.fromEntries(
|
||||
params.env.map(({ name, value }) => [name, value]),
|
||||
)
|
||||
: {}),
|
||||
};
|
||||
|
||||
const requestedCwd = params.cwd ?? process.cwd();
|
||||
let cwd = requestedCwd;
|
||||
if (this.useSandbox && this.writablePaths.length > 0) {
|
||||
const resolved = resolveSandboxCwdWithFallback(
|
||||
params.cwd,
|
||||
this.writablePaths,
|
||||
process.cwd(),
|
||||
);
|
||||
cwd = resolved.cwd;
|
||||
if (resolved.usedFallback) {
|
||||
log.warn(
|
||||
"[AcpTerminalManager] terminal/create cwd adjusted to writable root",
|
||||
{
|
||||
terminalId,
|
||||
sessionId: params.sessionId,
|
||||
requestedCwd: params.cwd ?? null,
|
||||
resolvedCwd: cwd,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Register BEFORE spawning so fast-exiting processes don't cause "not found" errors
|
||||
// in terminalOutput()/waitForExit() calls from the agent.
|
||||
this.terminals.set(terminalId, session);
|
||||
|
||||
if (this.useSandbox && this.sandboxInvoker) {
|
||||
// Windows: route through qiming-sandbox-helper.exe run
|
||||
let invocation;
|
||||
try {
|
||||
invocation = await this.sandboxInvoker.buildInvocation({
|
||||
command: params.command,
|
||||
args: params.args || [],
|
||||
cwd,
|
||||
env,
|
||||
writablePaths: this.writablePaths,
|
||||
networkEnabled: this.networkEnabled,
|
||||
subcommand: "run",
|
||||
});
|
||||
} catch (invErr) {
|
||||
this.terminals.delete(terminalId);
|
||||
throw invErr;
|
||||
}
|
||||
|
||||
log.info("[AcpTerminalManager] ✅ SANDBOXED terminal created:", {
|
||||
terminalId,
|
||||
sessionId: params.sessionId,
|
||||
originalCommand: params.command,
|
||||
originalArgs: params.args,
|
||||
wrappedCommand: invocation.command,
|
||||
wrappedArgs: invocation.args,
|
||||
sandboxed: true,
|
||||
sandboxMode: "windows-sandbox-helper",
|
||||
networkEnabled: this.networkEnabled,
|
||||
writablePaths: this.writablePaths,
|
||||
cwd,
|
||||
parseJson: invocation.parseJson,
|
||||
});
|
||||
|
||||
try {
|
||||
this.spawnProcess(
|
||||
session,
|
||||
invocation.command,
|
||||
invocation.args,
|
||||
invocation.env,
|
||||
cwd,
|
||||
true, // parseJson: helper run returns JSON
|
||||
);
|
||||
} catch (spawnErr) {
|
||||
this.cleanupFailedSpawn(session, terminalId);
|
||||
throw spawnErr;
|
||||
}
|
||||
} else {
|
||||
// macOS/Linux or no sandbox: execute directly
|
||||
log.info(
|
||||
"[AcpTerminalManager] ⚡ DIRECT terminal created (no sandbox):",
|
||||
{
|
||||
terminalId,
|
||||
sessionId: params.sessionId,
|
||||
command: params.command,
|
||||
args: params.args,
|
||||
sandboxed: false,
|
||||
platform: createPlatformAdapter().platform,
|
||||
cwd,
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
this.spawnProcess(
|
||||
session,
|
||||
params.command,
|
||||
params.args || [],
|
||||
env,
|
||||
cwd,
|
||||
false,
|
||||
);
|
||||
} catch (spawnErr) {
|
||||
this.cleanupFailedSpawn(session, terminalId);
|
||||
throw spawnErr;
|
||||
}
|
||||
}
|
||||
|
||||
return terminalId;
|
||||
}
|
||||
|
||||
// --- terminal/output ---
|
||||
|
||||
async terminalOutput(
|
||||
terminalId: string,
|
||||
sessionId?: string,
|
||||
): Promise<{
|
||||
output: string;
|
||||
truncated: boolean;
|
||||
exitStatus: TerminalExitStatus | null;
|
||||
}> {
|
||||
const t = this.terminals.get(terminalId);
|
||||
if (!t) throw new Error(`Terminal not found: ${terminalId}`);
|
||||
if (sessionId && t.sessionId !== sessionId) {
|
||||
throw new Error(
|
||||
`Terminal ${terminalId} does not belong to session ${sessionId}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
output: t.output,
|
||||
truncated: t.truncated,
|
||||
exitStatus: t.exitStatus,
|
||||
};
|
||||
}
|
||||
|
||||
// --- terminal/wait_for_exit ---
|
||||
|
||||
async waitForExit(
|
||||
terminalId: string,
|
||||
sessionId?: string,
|
||||
): Promise<TerminalExitStatus> {
|
||||
const t = this.terminals.get(terminalId);
|
||||
if (!t) throw new Error(`Terminal not found: ${terminalId}`);
|
||||
if (sessionId && t.sessionId !== sessionId) {
|
||||
throw new Error(
|
||||
`Terminal ${terminalId} does not belong to session ${sessionId}`,
|
||||
);
|
||||
}
|
||||
return t.exitPromise;
|
||||
}
|
||||
|
||||
// --- terminal/kill ---
|
||||
|
||||
async kill(terminalId: string, sessionId?: string): Promise<void> {
|
||||
const t = this.terminals.get(terminalId);
|
||||
if (!t || !t.process) return;
|
||||
if (sessionId && t.sessionId !== sessionId) {
|
||||
throw new Error(
|
||||
`Terminal ${terminalId} does not belong to session ${sessionId}`,
|
||||
);
|
||||
}
|
||||
log.info("[AcpTerminalManager] Killing terminal:", terminalId);
|
||||
try {
|
||||
const pid = t.process.pid;
|
||||
if (pid) {
|
||||
await killProcessTree(pid, "SIGKILL");
|
||||
} else {
|
||||
t.process.kill("SIGKILL");
|
||||
}
|
||||
} catch {
|
||||
// Process may have already exited
|
||||
}
|
||||
// Safety-net: resolve exitPromise in case the 'close' event is lost
|
||||
// after killProcessTree (possible on Windows where process tree kill
|
||||
// can race with the Node.js event loop).
|
||||
if (!t.resolved) {
|
||||
t.resolved = true;
|
||||
t.resolveExit({ exitCode: null, signal: "SIGKILL" });
|
||||
}
|
||||
}
|
||||
|
||||
// --- terminal/release ---
|
||||
|
||||
async release(terminalId: string, sessionId?: string): Promise<void> {
|
||||
const t = this.terminals.get(terminalId);
|
||||
if (!t) return;
|
||||
if (sessionId && t.sessionId !== sessionId) {
|
||||
throw new Error(
|
||||
`Terminal ${terminalId} does not belong to session ${sessionId}`,
|
||||
);
|
||||
}
|
||||
log.info("[AcpTerminalManager] Releasing terminal:", terminalId);
|
||||
if (t.process) {
|
||||
try {
|
||||
const pid = t.process.pid;
|
||||
if (pid) {
|
||||
await killProcessTree(pid, "SIGKILL");
|
||||
} else {
|
||||
t.process.kill("SIGKILL");
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
// Resolve exitPromise if still pending so waitForExit() doesn't hang
|
||||
if (!t.resolved) {
|
||||
t.resolved = true;
|
||||
t.resolveExit({ exitCode: null, signal: null });
|
||||
}
|
||||
this.terminals.delete(terminalId);
|
||||
}
|
||||
|
||||
/** Release all terminals belonging to a specific session */
|
||||
async releaseForSession(sessionId: string): Promise<void> {
|
||||
const ids: string[] = [];
|
||||
for (const [id, t] of this.terminals) {
|
||||
if (t.sessionId === sessionId) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
if (ids.length > 0) {
|
||||
log.info(
|
||||
`[AcpTerminalManager] Releasing ${ids.length} terminals for session ${sessionId}`,
|
||||
);
|
||||
}
|
||||
for (const id of ids) {
|
||||
await this.release(id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Release all terminals (called during engine destroy) */
|
||||
async releaseAll(): Promise<void> {
|
||||
const ids = Array.from(this.terminals.keys());
|
||||
if (ids.length > 0) {
|
||||
log.info(`[AcpTerminalManager] Releasing ${ids.length} terminals`);
|
||||
}
|
||||
for (const id of ids) {
|
||||
await this.release(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ACP Client handler methods for terminal/* operations.
|
||||
*
|
||||
* Spread into `buildClientHandler()` return value:
|
||||
* ```ts
|
||||
* return { ...existingHandlers, ...this.terminalManager.getClientHandlers() };
|
||||
* ```
|
||||
*/
|
||||
getClientHandlers(): Pick<
|
||||
import("./acpClient").AcpClientHandler,
|
||||
| "createTerminal"
|
||||
| "terminalOutput"
|
||||
| "waitForTerminalExit"
|
||||
| "killTerminal"
|
||||
| "releaseTerminal"
|
||||
> {
|
||||
return {
|
||||
createTerminal: async (params) => {
|
||||
const terminalId = await this.createTerminal(params);
|
||||
return { terminalId };
|
||||
},
|
||||
|
||||
terminalOutput: async (params) => {
|
||||
return this.terminalOutput(params.terminalId, params.sessionId);
|
||||
},
|
||||
|
||||
waitForTerminalExit: async (params) => {
|
||||
return this.waitForExit(params.terminalId, params.sessionId);
|
||||
},
|
||||
|
||||
killTerminal: async (params) => {
|
||||
await this.kill(params.terminalId, params.sessionId);
|
||||
return {};
|
||||
},
|
||||
|
||||
releaseTerminal: async (params) => {
|
||||
await this.release(params.terminalId, params.sessionId);
|
||||
return {};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Private helpers
|
||||
// ============================================================================
|
||||
|
||||
private spawnProcess(
|
||||
session: TerminalSession,
|
||||
command: string,
|
||||
args: string[],
|
||||
env: Record<string, string> | undefined,
|
||||
cwd: string,
|
||||
parseJson: boolean,
|
||||
): void {
|
||||
const sandboxed = parseJson;
|
||||
const platformAdapter = createPlatformAdapter();
|
||||
const useWindowsShell = !parseJson && platformAdapter.isWindows;
|
||||
log.info("[AcpTerminalManager] 🚀 Spawning process:", {
|
||||
terminalId: session.id,
|
||||
command,
|
||||
args: args.length > 0 ? args : undefined,
|
||||
cwd,
|
||||
sandboxed,
|
||||
shell: useWindowsShell,
|
||||
});
|
||||
|
||||
const proc = spawn(command, args, {
|
||||
cwd,
|
||||
env,
|
||||
// sandbox helper is an .exe, no shell needed; direct commands may need shell
|
||||
shell: useWindowsShell,
|
||||
windowsHide: true,
|
||||
stdio: ["ignore", "pipe", "pipe"], // stdin not wired — non-interactive commands only
|
||||
});
|
||||
session.process = proc;
|
||||
|
||||
log.info("[AcpTerminalManager] Process spawned:", {
|
||||
terminalId: session.id,
|
||||
pid: proc.pid,
|
||||
sandboxed,
|
||||
});
|
||||
|
||||
if (parseJson) {
|
||||
// Windows sandbox helper run mode: stdout is JSON blob, stderr is log
|
||||
let jsonBuffer = "";
|
||||
proc.stdout?.on("data", (data: Buffer) => {
|
||||
jsonBuffer += data.toString();
|
||||
});
|
||||
proc.stderr?.on("data", (data: Buffer) => {
|
||||
// stderr from helper itself is debug output — log but don't mix into
|
||||
// the session output buffer. The real command stderr comes from the
|
||||
// JSON envelope after the helper exits.
|
||||
log.debug(
|
||||
"[AcpTerminalManager] Sandbox helper stderr:",
|
||||
data.toString().trim(),
|
||||
);
|
||||
});
|
||||
proc.on("close", (code, signal) => {
|
||||
// Parse the JSON result from helper
|
||||
log.info(
|
||||
"[AcpTerminalManager] 📦 Sandbox helper exited, parsing JSON result:",
|
||||
{
|
||||
terminalId: session.id,
|
||||
exitCode: code,
|
||||
signal,
|
||||
jsonBufferLength: jsonBuffer.length,
|
||||
},
|
||||
);
|
||||
try {
|
||||
const result = JSON.parse(jsonBuffer) as {
|
||||
exit_code: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
timed_out: boolean;
|
||||
};
|
||||
// The real command output is inside the JSON envelope
|
||||
log.info("[AcpTerminalManager] ✅ Sandbox result parsed:", {
|
||||
terminalId: session.id,
|
||||
exit_code: result.exit_code,
|
||||
stdoutLength: result.stdout.length,
|
||||
stderrLength: result.stderr.length,
|
||||
timed_out: result.timed_out,
|
||||
});
|
||||
if (result.stdout) {
|
||||
this.appendOutput(session, Buffer.from(result.stdout));
|
||||
}
|
||||
if (result.stderr) {
|
||||
this.appendOutput(session, Buffer.from(result.stderr));
|
||||
}
|
||||
const status: TerminalExitStatus = {
|
||||
exitCode: result.exit_code,
|
||||
signal: null,
|
||||
};
|
||||
session.exitStatus = status;
|
||||
session.process = null;
|
||||
if (!session.resolved) {
|
||||
session.resolved = true;
|
||||
session.resolveExit(status);
|
||||
}
|
||||
} catch (parseErr) {
|
||||
// JSON parse failed, fall back to raw exit code
|
||||
log.warn(
|
||||
"[AcpTerminalManager] ⚠️ JSON parse failed, using raw exit code:",
|
||||
{
|
||||
terminalId: session.id,
|
||||
exitCode: code,
|
||||
jsonBufferPreview: jsonBuffer.slice(0, 200),
|
||||
error:
|
||||
parseErr instanceof Error ? parseErr.message : String(parseErr),
|
||||
},
|
||||
);
|
||||
const status: TerminalExitStatus = {
|
||||
exitCode: code ?? 1,
|
||||
signal: signal ?? null,
|
||||
};
|
||||
session.exitStatus = status;
|
||||
session.process = null;
|
||||
if (!session.resolved) {
|
||||
session.resolved = true;
|
||||
session.resolveExit(status);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Direct execution: stdout/stderr are command output
|
||||
proc.stdout?.on("data", (data: Buffer) =>
|
||||
this.appendOutput(session, data),
|
||||
);
|
||||
proc.stderr?.on("data", (data: Buffer) =>
|
||||
this.appendOutput(session, data),
|
||||
);
|
||||
proc.on("close", (code, signal) => {
|
||||
log.info("[AcpTerminalManager] 🔚 Direct process exited:", {
|
||||
terminalId: session.id,
|
||||
exitCode: code,
|
||||
signal,
|
||||
outputLength: session.output.length,
|
||||
});
|
||||
const status: TerminalExitStatus = {
|
||||
exitCode: code,
|
||||
signal: signal ?? null,
|
||||
};
|
||||
session.exitStatus = status;
|
||||
session.process = null;
|
||||
if (!session.resolved) {
|
||||
session.resolved = true;
|
||||
session.resolveExit(status);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
proc.on("error", (err) => {
|
||||
log.error("[AcpTerminalManager] Process spawn error:", err);
|
||||
const status: TerminalExitStatus = { exitCode: 1, signal: null };
|
||||
session.exitStatus = status;
|
||||
session.process = null;
|
||||
if (!session.resolved) {
|
||||
session.resolved = true;
|
||||
session.resolveExit(status);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up a terminal session when spawn() throws synchronously.
|
||||
* Resolves the exitPromise and removes the session from the map
|
||||
* to prevent resource leaks.
|
||||
*/
|
||||
private cleanupFailedSpawn(
|
||||
session: TerminalSession,
|
||||
terminalId: string,
|
||||
): void {
|
||||
log.warn(
|
||||
"[AcpTerminalManager] Spawn failed, cleaning up terminal:",
|
||||
terminalId,
|
||||
);
|
||||
const status: TerminalExitStatus = { exitCode: 1, signal: null };
|
||||
session.exitStatus = status;
|
||||
session.process = null;
|
||||
if (!session.resolved) {
|
||||
session.resolved = true;
|
||||
session.resolveExit(status);
|
||||
}
|
||||
this.terminals.delete(terminalId);
|
||||
}
|
||||
|
||||
private appendOutput(session: TerminalSession, data: Buffer): void {
|
||||
const text = data.toString();
|
||||
session.output += text;
|
||||
if (session.byteLimit && session.output.length > session.byteLimit) {
|
||||
// Truncate from the beginning, preserving the most recent output
|
||||
session.output = session.output.slice(
|
||||
session.output.length - session.byteLimit,
|
||||
);
|
||||
session.truncated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { evaluateStrictWritePermission } from "./strictPermissionGuard";
|
||||
import type { AcpPermissionRequest } from "./acpClient";
|
||||
|
||||
function makeRequest(
|
||||
overrides?: Partial<AcpPermissionRequest>,
|
||||
): AcpPermissionRequest {
|
||||
const overrideToolCall = overrides?.toolCall || {};
|
||||
return {
|
||||
sessionId: "s-1",
|
||||
toolCall: {
|
||||
toolCallId: "tc-1",
|
||||
kind: "edit",
|
||||
title: "Edit",
|
||||
rawInput: { file_path: "/workspace/a.txt" },
|
||||
...overrideToolCall,
|
||||
},
|
||||
options: [
|
||||
{ optionId: "allow-once", kind: "allow_once", name: "allow once" },
|
||||
{ optionId: "allow-always", kind: "allow_always", name: "allow always" },
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("strictPermissionGuard", () => {
|
||||
it("strict 关闭时不拦截", () => {
|
||||
const result = evaluateStrictWritePermission(makeRequest(), {
|
||||
strictEnabled: false,
|
||||
workspaceDir: "/workspace",
|
||||
appDataDir: "/home/me/.qimingclaw",
|
||||
tempDirs: ["/tmp"],
|
||||
});
|
||||
|
||||
expect(result.blocked).toBe(false);
|
||||
expect(result.reason).toBe("strict_not_active");
|
||||
});
|
||||
|
||||
it("允许 workspace 内写入", () => {
|
||||
const result = evaluateStrictWritePermission(
|
||||
makeRequest({
|
||||
toolCall: {
|
||||
toolCallId: "tc-2",
|
||||
kind: "edit",
|
||||
title: "Edit",
|
||||
rawInput: { file_path: "/workspace/src/a.ts" },
|
||||
},
|
||||
}),
|
||||
{
|
||||
strictEnabled: true,
|
||||
workspaceDir: "/workspace",
|
||||
appDataDir: "/home/me/.qimingclaw",
|
||||
tempDirs: ["/tmp"],
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.isWriteRequest).toBe(true);
|
||||
expect(result.blocked).toBe(false);
|
||||
expect(result.reason).toBe("strict_paths_allowed");
|
||||
});
|
||||
|
||||
it("相对路径按 workspace 解析,允许写入", () => {
|
||||
const result = evaluateStrictWritePermission(
|
||||
makeRequest({
|
||||
toolCall: {
|
||||
toolCallId: "tc-2b",
|
||||
kind: "edit",
|
||||
title: "Edit",
|
||||
rawInput: { file_path: "src/a.ts" },
|
||||
},
|
||||
}),
|
||||
{
|
||||
strictEnabled: true,
|
||||
workspaceDir: "/workspace",
|
||||
appDataDir: "/home/me/.qimingclaw",
|
||||
tempDirs: ["/tmp"],
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.blocked).toBe(false);
|
||||
expect(result.reason).toBe("strict_paths_allowed");
|
||||
expect(result.resolvedPaths[0]).toBe("/workspace/src/a.ts");
|
||||
});
|
||||
|
||||
it("允许 temp 目录写入", () => {
|
||||
const result = evaluateStrictWritePermission(
|
||||
makeRequest({
|
||||
toolCall: {
|
||||
toolCallId: "tc-3",
|
||||
kind: "write",
|
||||
title: "Write",
|
||||
rawInput: { file_path: "/tmp/a.log" },
|
||||
},
|
||||
}),
|
||||
{
|
||||
strictEnabled: true,
|
||||
workspaceDir: "/workspace",
|
||||
appDataDir: "/home/me/.qimingclaw",
|
||||
tempDirs: ["/tmp"],
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.blocked).toBe(false);
|
||||
expect(result.reason).toBe("strict_paths_allowed");
|
||||
});
|
||||
|
||||
it("~ 路径按 isolatedHome 解析,允许写入", () => {
|
||||
const result = evaluateStrictWritePermission(
|
||||
makeRequest({
|
||||
toolCall: {
|
||||
toolCallId: "tc-3b",
|
||||
kind: "write",
|
||||
title: "Write",
|
||||
rawInput: { file_path: "~/notes/a.txt" },
|
||||
},
|
||||
}),
|
||||
{
|
||||
strictEnabled: true,
|
||||
workspaceDir: "/workspace",
|
||||
isolatedHome: "/sandbox/home",
|
||||
appDataDir: "/home/me/.qimingclaw",
|
||||
tempDirs: ["/tmp"],
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.blocked).toBe(false);
|
||||
expect(result.reason).toBe("strict_paths_allowed");
|
||||
expect(result.resolvedPaths[0]).toBe("/sandbox/home/notes/a.txt");
|
||||
});
|
||||
|
||||
it("Windows: 绝对路径按 win32 语义解析且大小写不敏感", () => {
|
||||
const result = evaluateStrictWritePermission(
|
||||
makeRequest({
|
||||
toolCall: {
|
||||
toolCallId: "tc-win-1",
|
||||
kind: "write",
|
||||
title: "Write",
|
||||
rawInput: { file_path: "c:\\workspace\\src\\a.ts" },
|
||||
},
|
||||
}),
|
||||
{
|
||||
strictEnabled: true,
|
||||
workspaceDir: "C:\\Workspace",
|
||||
appDataDir: "C:\\Users\\me\\.qimingclaw",
|
||||
tempDirs: ["C:\\Temp"],
|
||||
platform: "win32",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.blocked).toBe(false);
|
||||
expect(result.reason).toBe("strict_paths_allowed");
|
||||
expect(result.resolvedPaths[0]).toBe("c:\\workspace\\src\\a.ts");
|
||||
});
|
||||
|
||||
it("Windows: 绝对路径超出 writable roots 时拒绝", () => {
|
||||
const result = evaluateStrictWritePermission(
|
||||
makeRequest({
|
||||
toolCall: {
|
||||
toolCallId: "tc-win-2",
|
||||
kind: "write",
|
||||
title: "Write",
|
||||
rawInput: { file_path: "D:\\outside\\a.ts" },
|
||||
},
|
||||
}),
|
||||
{
|
||||
strictEnabled: true,
|
||||
workspaceDir: "C:\\Workspace",
|
||||
appDataDir: "C:\\Users\\me\\.qimingclaw",
|
||||
tempDirs: ["C:\\Temp"],
|
||||
platform: "win32",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.blocked).toBe(true);
|
||||
expect(result.reason).toBe("strict_path_outside_roots");
|
||||
});
|
||||
|
||||
it("拒绝 workspace/temp/appData 外写入", () => {
|
||||
const result = evaluateStrictWritePermission(
|
||||
makeRequest({
|
||||
toolCall: {
|
||||
toolCallId: "tc-4",
|
||||
kind: "write",
|
||||
title: "Write",
|
||||
rawInput: { file_path: "/etc/passwd" },
|
||||
},
|
||||
}),
|
||||
{
|
||||
strictEnabled: true,
|
||||
workspaceDir: "/workspace",
|
||||
appDataDir: "/home/me/.qimingclaw",
|
||||
tempDirs: ["/tmp"],
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.blocked).toBe(true);
|
||||
expect(result.reason).toBe("strict_path_outside_roots");
|
||||
});
|
||||
|
||||
it("strict 写入请求缺少路径时 fail-closed", () => {
|
||||
const result = evaluateStrictWritePermission(
|
||||
makeRequest({
|
||||
toolCall: {
|
||||
toolCallId: "tc-5",
|
||||
kind: "edit",
|
||||
title: "Edit",
|
||||
rawInput: {},
|
||||
},
|
||||
}),
|
||||
{
|
||||
strictEnabled: true,
|
||||
workspaceDir: "/workspace",
|
||||
appDataDir: "/home/me/.qimingclaw",
|
||||
tempDirs: ["/tmp"],
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.blocked).toBe(true);
|
||||
expect(result.reason).toBe("strict_missing_path");
|
||||
});
|
||||
|
||||
it("识别 EditNotebook 的 target_notebook 路径并允许 workspace 内写入", () => {
|
||||
const result = evaluateStrictWritePermission(
|
||||
makeRequest({
|
||||
toolCall: {
|
||||
toolCallId: "tc-5b",
|
||||
kind: "edit",
|
||||
title: "Edit Notebook",
|
||||
rawInput: { target_notebook: "/workspace/docs/demo.ipynb" },
|
||||
},
|
||||
}),
|
||||
{
|
||||
strictEnabled: true,
|
||||
workspaceDir: "/workspace",
|
||||
appDataDir: "/home/me/.qimingclaw",
|
||||
tempDirs: ["/tmp"],
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.blocked).toBe(false);
|
||||
expect(result.reason).toBe("strict_paths_allowed");
|
||||
expect(result.candidatePaths).toContain("/workspace/docs/demo.ipynb");
|
||||
});
|
||||
|
||||
it("未知键名但值为相对路径时按路径处理", () => {
|
||||
const result = evaluateStrictWritePermission(
|
||||
makeRequest({
|
||||
toolCall: {
|
||||
toolCallId: "tc-5c",
|
||||
kind: "edit",
|
||||
title: "Edit",
|
||||
rawInput: { destination: "./notes/todo.md" },
|
||||
},
|
||||
}),
|
||||
{
|
||||
strictEnabled: true,
|
||||
workspaceDir: "/workspace",
|
||||
appDataDir: "/home/me/.qimingclaw",
|
||||
tempDirs: ["/tmp"],
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.blocked).toBe(false);
|
||||
expect(result.reason).toBe("strict_paths_allowed");
|
||||
expect(result.resolvedPaths[0]).toBe("/workspace/notes/todo.md");
|
||||
});
|
||||
|
||||
it("非写入请求不触发 strict 路径拦截", () => {
|
||||
const result = evaluateStrictWritePermission(
|
||||
makeRequest({
|
||||
toolCall: {
|
||||
toolCallId: "tc-6",
|
||||
kind: "bash",
|
||||
title: "Bash",
|
||||
rawInput: { command: "ls -la" },
|
||||
},
|
||||
}),
|
||||
{
|
||||
strictEnabled: true,
|
||||
workspaceDir: "/workspace",
|
||||
appDataDir: "/home/me/.qimingclaw",
|
||||
tempDirs: ["/tmp"],
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.isWriteRequest).toBe(false);
|
||||
expect(result.blocked).toBe(false);
|
||||
expect(result.reason).toBe("not_write_request");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,372 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import type { AcpPermissionRequest } from "./acpClient";
|
||||
import { getCurrentPlatform } from "@main/services/system/platformAdapter";
|
||||
|
||||
const WRITE_KEYWORDS = [
|
||||
"write",
|
||||
"edit",
|
||||
"notebook",
|
||||
"rename",
|
||||
"move",
|
||||
"delete",
|
||||
"create_file",
|
||||
"create file",
|
||||
] as const;
|
||||
|
||||
const NON_WRITE_KEYWORDS = ["bash", "terminal", "shell", "webfetch", "http"];
|
||||
|
||||
const PATH_FIELDS = new Set([
|
||||
"path",
|
||||
"file",
|
||||
"file_path",
|
||||
"filepath",
|
||||
"filePath",
|
||||
"target_notebook",
|
||||
"targetNotebook",
|
||||
"notebook_path",
|
||||
"notebookPath",
|
||||
"new_path",
|
||||
"newPath",
|
||||
"old_path",
|
||||
"oldPath",
|
||||
"target_path",
|
||||
"targetPath",
|
||||
"source_path",
|
||||
"sourcePath",
|
||||
"destination_path",
|
||||
"destinationPath",
|
||||
"directory",
|
||||
"dir",
|
||||
"folder",
|
||||
]);
|
||||
|
||||
export interface StrictPermissionContext {
|
||||
strictEnabled: boolean;
|
||||
sandboxMode?: string;
|
||||
workspaceDir?: string;
|
||||
projectWorkspaceDir?: string;
|
||||
isolatedHome?: string | null;
|
||||
appDataDir: string;
|
||||
tempDirs?: Array<string | null | undefined>;
|
||||
platform?: NodeJS.Platform;
|
||||
}
|
||||
|
||||
export interface StrictPermissionDecision {
|
||||
isWriteRequest: boolean;
|
||||
blocked: boolean;
|
||||
reason: string;
|
||||
candidatePaths: string[];
|
||||
resolvedPaths: string[];
|
||||
writableRoots: string[];
|
||||
}
|
||||
|
||||
interface PathResolveContext {
|
||||
baseDir?: string;
|
||||
homeDir?: string | null;
|
||||
platform: NodeJS.Platform;
|
||||
}
|
||||
|
||||
export function evaluateStrictWritePermission(
|
||||
request: AcpPermissionRequest,
|
||||
context: StrictPermissionContext,
|
||||
): StrictPermissionDecision {
|
||||
if (!context.strictEnabled) {
|
||||
return {
|
||||
isWriteRequest: false,
|
||||
blocked: false,
|
||||
reason: "strict_not_active",
|
||||
candidatePaths: [],
|
||||
resolvedPaths: [],
|
||||
writableRoots: [],
|
||||
};
|
||||
}
|
||||
|
||||
const isWriteRequest = isWriteLikePermissionRequest(request);
|
||||
if (!isWriteRequest) {
|
||||
return {
|
||||
isWriteRequest: false,
|
||||
blocked: false,
|
||||
reason: "not_write_request",
|
||||
candidatePaths: [],
|
||||
resolvedPaths: [],
|
||||
writableRoots: [],
|
||||
};
|
||||
}
|
||||
|
||||
const pathResolveContext = buildPathResolveContext(context);
|
||||
const writableRoots = buildStrictWritableRoots(context, pathResolveContext);
|
||||
const candidates = extractPathCandidates(request.toolCall.rawInput);
|
||||
if (candidates.length === 0) {
|
||||
return {
|
||||
isWriteRequest: true,
|
||||
blocked: true,
|
||||
reason: "strict_missing_path",
|
||||
candidatePaths: [],
|
||||
resolvedPaths: [],
|
||||
writableRoots,
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedPaths: string[] = [];
|
||||
const platform = context.platform ?? getCurrentPlatform();
|
||||
for (const candidate of candidates) {
|
||||
const resolved = resolvePathForSandbox(candidate, pathResolveContext);
|
||||
if (!resolved) {
|
||||
return {
|
||||
isWriteRequest: true,
|
||||
blocked: true,
|
||||
reason: "strict_unresolved_path",
|
||||
candidatePaths: candidates,
|
||||
resolvedPaths,
|
||||
writableRoots,
|
||||
};
|
||||
}
|
||||
resolvedPaths.push(resolved);
|
||||
if (!isPathWithinAnyRoot(resolved, writableRoots, platform)) {
|
||||
return {
|
||||
isWriteRequest: true,
|
||||
blocked: true,
|
||||
reason: "strict_path_outside_roots",
|
||||
candidatePaths: candidates,
|
||||
resolvedPaths,
|
||||
writableRoots,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isWriteRequest: true,
|
||||
blocked: false,
|
||||
reason: "strict_paths_allowed",
|
||||
candidatePaths: candidates,
|
||||
resolvedPaths,
|
||||
writableRoots,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPathResolveContext(
|
||||
context: StrictPermissionContext,
|
||||
): PathResolveContext {
|
||||
return {
|
||||
baseDir: context.projectWorkspaceDir || context.workspaceDir,
|
||||
homeDir: context.isolatedHome,
|
||||
platform: context.platform ?? getCurrentPlatform(),
|
||||
};
|
||||
}
|
||||
|
||||
function isWriteLikePermissionRequest(params: AcpPermissionRequest): boolean {
|
||||
const kindAndTitle =
|
||||
`${params.toolCall.kind ?? ""} ${params.toolCall.title ?? ""}`
|
||||
.toLowerCase()
|
||||
.trim();
|
||||
|
||||
if (NON_WRITE_KEYWORDS.some((kw) => kindAndTitle.includes(kw))) {
|
||||
return false;
|
||||
}
|
||||
if (WRITE_KEYWORDS.some((kw) => kindAndTitle.includes(kw))) {
|
||||
return true;
|
||||
}
|
||||
return extractPathCandidates(params.toolCall.rawInput).length > 0;
|
||||
}
|
||||
|
||||
function buildStrictWritableRoots(
|
||||
context: StrictPermissionContext,
|
||||
pathResolveContext: PathResolveContext,
|
||||
): string[] {
|
||||
const roots = new Set<string>();
|
||||
|
||||
const add = (candidate: string | null | undefined) => {
|
||||
if (!candidate) return;
|
||||
const resolved = resolvePathForSandbox(candidate, pathResolveContext);
|
||||
if (resolved) roots.add(resolved);
|
||||
};
|
||||
|
||||
add(context.workspaceDir);
|
||||
add(context.projectWorkspaceDir);
|
||||
// Only include appDataDir in compat/permissive — matches Rust allow.rs behavior.
|
||||
// Strict mode intentionally excludes APPDATA for minimal write surface.
|
||||
if (context.sandboxMode !== "strict") {
|
||||
add(context.appDataDir);
|
||||
}
|
||||
if (context.tempDirs) {
|
||||
for (const tempPath of context.tempDirs) {
|
||||
add(tempPath);
|
||||
}
|
||||
}
|
||||
if (context.isolatedHome) {
|
||||
add(context.isolatedHome);
|
||||
add(path.join(context.isolatedHome, "tmp"));
|
||||
}
|
||||
|
||||
return [...roots];
|
||||
}
|
||||
|
||||
function extractPathCandidates(rawInput: unknown): string[] {
|
||||
const found = new Set<string>();
|
||||
const stack: Array<{ value: unknown; keyHint: string }> = [
|
||||
{ value: rawInput, keyHint: "" },
|
||||
];
|
||||
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
if (!current) continue;
|
||||
const { value, keyHint } = current;
|
||||
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) continue;
|
||||
if (isLikelyPathValue(trimmed, keyHint)) {
|
||||
found.add(trimmed);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
stack.push({ value: item, keyHint });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
stack.push({ value: v, keyHint: k });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...found];
|
||||
}
|
||||
|
||||
function isLikelyPathValue(value: string, keyHint: string): boolean {
|
||||
if (/^https?:\/\//i.test(value)) return false;
|
||||
|
||||
const key = keyHint.trim();
|
||||
if (!key) {
|
||||
return looksLikePath(value);
|
||||
}
|
||||
if (
|
||||
PATH_FIELDS.has(key) ||
|
||||
key.endsWith("_path") ||
|
||||
key.toLowerCase().endsWith("path") ||
|
||||
key.toLowerCase().includes("directory") ||
|
||||
key.toLowerCase().includes("folder")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// 兼容未知字段名(例如部分 MCP 工具参数会用 target_notebook 之外的自定义键),
|
||||
// 只要值本身明显是路径,就按路径处理,避免 strict 模式误判为 missing_path。
|
||||
return looksLikePath(value);
|
||||
}
|
||||
|
||||
function looksLikePath(value: string): boolean {
|
||||
if (path.posix.isAbsolute(value) || path.win32.isAbsolute(value)) {
|
||||
return true;
|
||||
}
|
||||
if (value === "~" || value.startsWith("~/") || value.startsWith("~\\")) {
|
||||
return true;
|
||||
}
|
||||
if (value.startsWith("./") || value.startsWith("../")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function getPathOps(
|
||||
platform: NodeJS.Platform,
|
||||
): typeof path.posix | typeof path.win32 {
|
||||
return platform === "win32" ? path.win32 : path.posix;
|
||||
}
|
||||
|
||||
function resolvePathForSandbox(
|
||||
pathCandidate: string,
|
||||
context?: PathResolveContext,
|
||||
): string | null {
|
||||
const platform = context?.platform ?? getCurrentPlatform();
|
||||
const pathOps = getPathOps(platform);
|
||||
const expanded = expandHomePath(pathCandidate, context?.homeDir, pathOps);
|
||||
const baseDir = context?.baseDir;
|
||||
const resolved =
|
||||
baseDir && !pathOps.isAbsolute(expanded)
|
||||
? pathOps.resolve(baseDir, expanded)
|
||||
: pathOps.resolve(expanded);
|
||||
|
||||
// Cross-platform unit tests may evaluate Windows-style paths on non-Windows hosts.
|
||||
// Skip filesystem canonicalization in that case; runtime on target platform still
|
||||
// goes through realpath below.
|
||||
if (platform !== getCurrentPlatform()) {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
try {
|
||||
return fs.realpathSync(resolved);
|
||||
} catch {
|
||||
let cursor = pathOps.dirname(resolved);
|
||||
let suffix = pathOps.basename(resolved);
|
||||
while (true) {
|
||||
try {
|
||||
const cursorReal = fs.realpathSync(cursor);
|
||||
return pathOps.join(cursorReal, suffix);
|
||||
} catch {
|
||||
const parent = pathOps.dirname(cursor);
|
||||
if (parent === cursor) {
|
||||
return resolved;
|
||||
}
|
||||
suffix = pathOps.join(pathOps.basename(cursor), suffix);
|
||||
cursor = parent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function expandHomePath(
|
||||
inputPath: string,
|
||||
homeDir: string | null | undefined,
|
||||
pathOps: typeof path.posix | typeof path.win32,
|
||||
): string {
|
||||
const fallbackHome = process.env.HOME || process.env.USERPROFILE || "";
|
||||
const home = homeDir || fallbackHome;
|
||||
if (inputPath === "~") {
|
||||
return home || "~";
|
||||
}
|
||||
if (inputPath.startsWith("~/") || inputPath.startsWith("~\\")) {
|
||||
return home ? pathOps.join(home, inputPath.slice(2)) : inputPath;
|
||||
}
|
||||
return inputPath;
|
||||
}
|
||||
|
||||
function normalizePathForCompare(
|
||||
inputPath: string,
|
||||
platform: NodeJS.Platform,
|
||||
): string {
|
||||
let normalized = inputPath.replace(/\\/g, "/");
|
||||
if (platform === "win32") {
|
||||
normalized = normalized.toLowerCase();
|
||||
}
|
||||
if (normalized.length > 1 && normalized.endsWith("/")) {
|
||||
const isWindowsDriveRoot = /^[a-z]:\/$/i.test(normalized);
|
||||
if (!isWindowsDriveRoot) {
|
||||
normalized = normalized.replace(/\/+$/, "");
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isPathWithinAnyRoot(
|
||||
candidatePath: string,
|
||||
roots: string[],
|
||||
platform: NodeJS.Platform,
|
||||
): boolean {
|
||||
const normalizedCandidate = normalizePathForCompare(candidatePath, platform);
|
||||
for (const root of roots) {
|
||||
const normalizedRoot = normalizePathForCompare(root, platform);
|
||||
if (!normalizedRoot) continue;
|
||||
if (normalizedCandidate === normalizedRoot) return true;
|
||||
const prefix = normalizedRoot.endsWith("/")
|
||||
? normalizedRoot
|
||||
: `${normalizedRoot}/`;
|
||||
if (normalizedCandidate.startsWith(prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* 单元测试: agentHelpers
|
||||
*
|
||||
* 测试引擎辅助函数:
|
||||
* - mapAgentCommand: 将命令映射到引擎类型
|
||||
* - resolveAgentEnv: 解析环境变量模板
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock electron-log - must be at top level before imports
|
||||
vi.mock('electron-log', () => ({
|
||||
default: {
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { mapAgentCommand, resolveAgentEnv } from './agentHelpers';
|
||||
import type { ModelProviderConfig } from './unifiedAgent';
|
||||
|
||||
const mockLog = require('electron-log').default;
|
||||
|
||||
describe('agentHelpers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('mapAgentCommand', () => {
|
||||
it('should map "qimingcode" to qimingcode engine', () => {
|
||||
expect(mapAgentCommand('qimingcode')).toBe('qimingcode');
|
||||
});
|
||||
|
||||
it('should map "claude-code" to claude-code engine', () => {
|
||||
expect(mapAgentCommand('claude-code')).toBe('claude-code');
|
||||
});
|
||||
|
||||
it('should map "claude-code-acp-ts" to claude-code engine', () => {
|
||||
expect(mapAgentCommand('claude-code-acp-ts')).toBe('claude-code');
|
||||
});
|
||||
|
||||
it('should return null for unknown commands', () => {
|
||||
expect(mapAgentCommand('unknown-engine')).toBeNull();
|
||||
expect(mapAgentCommand('opencode')).toBeNull();
|
||||
expect(mapAgentCommand('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveAgentEnv', () => {
|
||||
it('should resolve all placeholders when modelProvider is provided', () => {
|
||||
const env = {
|
||||
API_KEY: '{MODEL_PROVIDER_API_KEY}',
|
||||
BASE_URL: '{MODEL_PROVIDER_BASE_URL}',
|
||||
MODEL: '{MODEL_PROVIDER_MODEL}',
|
||||
};
|
||||
const modelProvider: ModelProviderConfig = {
|
||||
api_key: 'sk-test-key',
|
||||
base_url: 'https://api.example.com',
|
||||
model: 'claude-opus-4-20250514',
|
||||
};
|
||||
|
||||
const result = resolveAgentEnv(env, modelProvider);
|
||||
|
||||
expect(result).toEqual({
|
||||
API_KEY: 'sk-test-key',
|
||||
BASE_URL: 'https://api.example.com',
|
||||
MODEL: 'claude-opus-4-20250514',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle partial placeholders in a single value', () => {
|
||||
const env = {
|
||||
MESSAGE: 'Using model {MODEL_PROVIDER_MODEL} with key {MODEL_PROVIDER_API_KEY}',
|
||||
};
|
||||
const modelProvider: ModelProviderConfig = {
|
||||
api_key: 'sk-key',
|
||||
base_url: 'https://api.example.com',
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
};
|
||||
|
||||
const result = resolveAgentEnv(env, modelProvider);
|
||||
|
||||
expect(result).toEqual({
|
||||
MESSAGE: 'Using model claude-sonnet-4-20250514 with key sk-key',
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip entries with unresolved placeholders when modelProvider is undefined', () => {
|
||||
const env = {
|
||||
API_KEY: '{MODEL_PROVIDER_API_KEY}',
|
||||
STATIC: 'unchanged',
|
||||
};
|
||||
|
||||
const result = resolveAgentEnv(env, undefined);
|
||||
|
||||
// MODEL_PROVIDER_* placeholders remain when modelProvider is undefined
|
||||
expect(result).not.toHaveProperty('API_KEY');
|
||||
expect(result).toHaveProperty('STATIC', 'unchanged');
|
||||
});
|
||||
|
||||
it('should keep non-MODEL_PROVIDER placeholders as-is', () => {
|
||||
const env = {
|
||||
API_KEY: '{MODEL_PROVIDER_API_KEY}',
|
||||
CUSTOM: '{CUSTOM_VAR}', // Not a MODEL_PROVIDER_* placeholder
|
||||
};
|
||||
const modelProvider: ModelProviderConfig = {
|
||||
api_key: 'sk-key',
|
||||
base_url: undefined,
|
||||
model: undefined,
|
||||
};
|
||||
|
||||
const result = resolveAgentEnv(env, modelProvider);
|
||||
|
||||
expect(result).toHaveProperty('API_KEY', 'sk-key');
|
||||
// CUSTOM_VAR is not a MODEL_PROVIDER_* placeholder, so it's kept as-is
|
||||
expect(result).toHaveProperty('CUSTOM', '{CUSTOM_VAR}');
|
||||
});
|
||||
|
||||
it('should replace undefined modelProvider values with empty string', () => {
|
||||
const env = {
|
||||
API_KEY: '{MODEL_PROVIDER_API_KEY}',
|
||||
BASE_URL: '{MODEL_PROVIDER_BASE_URL}',
|
||||
MODEL: '{MODEL_PROVIDER_MODEL}',
|
||||
};
|
||||
const modelProvider: ModelProviderConfig = {
|
||||
api_key: 'sk-key',
|
||||
base_url: undefined,
|
||||
model: undefined,
|
||||
};
|
||||
|
||||
const result = resolveAgentEnv(env, modelProvider);
|
||||
|
||||
expect(result).toEqual({
|
||||
API_KEY: 'sk-key',
|
||||
BASE_URL: '',
|
||||
MODEL: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty values in modelProvider', () => {
|
||||
const env = {
|
||||
API_KEY: '{MODEL_PROVIDER_API_KEY}',
|
||||
BASE_URL: '{MODEL_PROVIDER_BASE_URL}',
|
||||
};
|
||||
const modelProvider: ModelProviderConfig = {
|
||||
api_key: '',
|
||||
base_url: '',
|
||||
model: 'claude-opus-4-20250514',
|
||||
};
|
||||
|
||||
const result = resolveAgentEnv(env, modelProvider);
|
||||
|
||||
expect(result).toEqual({
|
||||
API_KEY: '',
|
||||
BASE_URL: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('should replace multiple occurrences of the same placeholder', () => {
|
||||
const env = {
|
||||
CONFIG: 'Key: {MODEL_PROVIDER_API_KEY}, Key again: {MODEL_PROVIDER_API_KEY}',
|
||||
};
|
||||
const modelProvider: ModelProviderConfig = {
|
||||
api_key: 'sk-test',
|
||||
base_url: 'https://api.example.com',
|
||||
model: 'claude-opus-4-20250514',
|
||||
};
|
||||
|
||||
const result = resolveAgentEnv(env, modelProvider);
|
||||
|
||||
expect(result).toEqual({
|
||||
CONFIG: 'Key: sk-test, Key again: sk-test',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle mixed placeholders and static text', () => {
|
||||
const env = {
|
||||
URL: 'https://{MODEL_PROVIDER_BASE_URL}/v1/models/{MODEL_PROVIDER_MODEL}',
|
||||
};
|
||||
const modelProvider: ModelProviderConfig = {
|
||||
api_key: 'sk-key',
|
||||
base_url: 'api.anthropic.com',
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
};
|
||||
|
||||
const result = resolveAgentEnv(env, modelProvider);
|
||||
|
||||
expect(result).toEqual({
|
||||
URL: 'https://api.anthropic.com/v1/models/claude-3-5-sonnet-20241022',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import log from "electron-log";
|
||||
import type { AgentEngineType, ModelProviderConfig } from "./unifiedAgent";
|
||||
|
||||
/** Map agent_config.agent_server.command to engine type */
|
||||
export function mapAgentCommand(command: string): AgentEngineType | null {
|
||||
if (command === "qimingcode") return "qimingcode";
|
||||
if (command === "claude-code" || command === "claude-code-acp-ts")
|
||||
return "claude-code";
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve template placeholders in agent_server.env using model_provider.
|
||||
* rcoder does this internally in handle_chat_core; Electron needs to do it here.
|
||||
*
|
||||
* Templates: {MODEL_PROVIDER_BASE_URL}, {MODEL_PROVIDER_API_KEY}, {MODEL_PROVIDER_MODEL}
|
||||
*/
|
||||
export function resolveAgentEnv(
|
||||
env: Record<string, string>,
|
||||
modelProvider?: ModelProviderConfig,
|
||||
): Record<string, string> {
|
||||
const resolved: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
let v = value;
|
||||
if (modelProvider) {
|
||||
v = v.replace(
|
||||
/\{MODEL_PROVIDER_BASE_URL\}/g,
|
||||
modelProvider.base_url || "",
|
||||
);
|
||||
v = v.replace(/\{MODEL_PROVIDER_API_KEY\}/g, modelProvider.api_key || "");
|
||||
v = v.replace(/\{MODEL_PROVIDER_MODEL\}/g, modelProvider.model || "");
|
||||
}
|
||||
// Skip entries with unresolved placeholders (missing model_provider fields)
|
||||
if (/\{MODEL_PROVIDER_\w+\}/.test(v)) {
|
||||
log.warn(
|
||||
`[resolveAgentEnv] ⚠️ Skipping unresolved template variable: ${key}=${v}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
resolved[key] = v;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Tests for engineHooks — env provider and prompt enhancer registries
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
|
||||
vi.mock("electron-log", () => ({
|
||||
default: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// engineHooks uses module-level arrays, so we need to re-import for isolation
|
||||
// Since we can't easily reset module state, we test behavior additively
|
||||
|
||||
import {
|
||||
registerEnvProvider,
|
||||
collectEnvFromProviders,
|
||||
registerPromptEnhancer,
|
||||
enhanceSystemPrompt,
|
||||
} from "./engineHooks";
|
||||
|
||||
describe("engineHooks", () => {
|
||||
// Note: providers accumulate across tests within this module since they're module-level singletons.
|
||||
// Tests are designed to be additive and order-independent where possible.
|
||||
|
||||
describe("env providers", () => {
|
||||
it("collectEnvFromProviders returns empty object when no providers return env", () => {
|
||||
// Register a provider that returns undefined
|
||||
registerEnvProvider(() => undefined);
|
||||
// collectEnvFromProviders should still return an object (may have env from prior tests)
|
||||
const result = collectEnvFromProviders();
|
||||
expect(typeof result).toBe("object");
|
||||
});
|
||||
|
||||
it("collects env vars from providers", () => {
|
||||
registerEnvProvider(() => ({ TEST_KEY: "test_value" }));
|
||||
const result = collectEnvFromProviders();
|
||||
expect(result.TEST_KEY).toBe("test_value");
|
||||
});
|
||||
|
||||
it("merges env from multiple providers", () => {
|
||||
registerEnvProvider(() => ({ KEY_A: "a" }));
|
||||
registerEnvProvider(() => ({ KEY_B: "b" }));
|
||||
const result = collectEnvFromProviders();
|
||||
expect(result.KEY_A).toBe("a");
|
||||
expect(result.KEY_B).toBe("b");
|
||||
});
|
||||
|
||||
it("later providers override earlier ones for same key", () => {
|
||||
registerEnvProvider(() => ({ OVERRIDE_KEY: "first" }));
|
||||
registerEnvProvider(() => ({ OVERRIDE_KEY: "second" }));
|
||||
const result = collectEnvFromProviders();
|
||||
expect(result.OVERRIDE_KEY).toBe("second");
|
||||
});
|
||||
|
||||
it("handles provider errors gracefully", () => {
|
||||
registerEnvProvider(() => {
|
||||
throw new Error("provider error");
|
||||
});
|
||||
// Should not throw, just skip the erroring provider
|
||||
const result = collectEnvFromProviders();
|
||||
expect(typeof result).toBe("object");
|
||||
});
|
||||
|
||||
it("skips providers that return undefined", () => {
|
||||
registerEnvProvider(() => undefined);
|
||||
registerEnvProvider(() => ({ VALID_KEY: "valid" }));
|
||||
const result = collectEnvFromProviders();
|
||||
expect(result.VALID_KEY).toBe("valid");
|
||||
});
|
||||
});
|
||||
|
||||
describe("prompt enhancers", () => {
|
||||
it("returns base prompt when no enhancers modify it", () => {
|
||||
registerPromptEnhancer((base) => base);
|
||||
const result = enhanceSystemPrompt("hello");
|
||||
expect(result).toBe("hello");
|
||||
});
|
||||
|
||||
it("returns undefined for undefined base with pass-through enhancer", () => {
|
||||
registerPromptEnhancer((base) => base);
|
||||
// The enhancer just passes through, so undefined stays undefined
|
||||
// (but previous enhancers from other tests may have been registered)
|
||||
// We test the function contract directly
|
||||
const result = enhanceSystemPrompt(undefined);
|
||||
// Could be undefined or enhanced — depends on previously registered enhancers
|
||||
expect(result === undefined || typeof result === "string").toBe(true);
|
||||
});
|
||||
|
||||
it("enhancer can append to base prompt", () => {
|
||||
registerPromptEnhancer((base) =>
|
||||
base ? `${base}\n\nAPPENDED` : "APPENDED",
|
||||
);
|
||||
const result = enhanceSystemPrompt("original");
|
||||
expect(result).toContain("original");
|
||||
expect(result).toContain("APPENDED");
|
||||
});
|
||||
|
||||
it("chains multiple enhancers in order", () => {
|
||||
registerPromptEnhancer((base) => `${base || ""}-A`);
|
||||
registerPromptEnhancer((base) => `${base || ""}-B`);
|
||||
const result = enhanceSystemPrompt("start");
|
||||
// Both A and B should be present, and B appended after A
|
||||
expect(result).toContain("-A");
|
||||
expect(result).toContain("-B");
|
||||
});
|
||||
|
||||
it("handles enhancer errors gracefully", () => {
|
||||
registerPromptEnhancer(() => {
|
||||
throw new Error("enhancer error");
|
||||
});
|
||||
// Should not throw
|
||||
const result = enhanceSystemPrompt("test");
|
||||
expect(typeof result === "string" || result === undefined).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Engine Hooks — extensible registry for env providers and prompt enhancers.
|
||||
*
|
||||
* Allows feature modules (e.g., GUI Agent) to inject env vars and system prompts
|
||||
* into agent engines without modifying core engine code.
|
||||
*
|
||||
* Usage:
|
||||
* // In feature module:
|
||||
* registerEnvProvider(() => ({ GUI_AGENT_PORT: '60010', GUI_AGENT_TOKEN: 'xxx' }));
|
||||
* registerPromptEnhancer((base) => base ? `${base}\n\n${extra}` : extra);
|
||||
*/
|
||||
|
||||
import log from "electron-log";
|
||||
|
||||
// ==================== Env Providers ====================
|
||||
|
||||
export type EnvProvider = () => Record<string, string> | undefined;
|
||||
|
||||
const envProviders: EnvProvider[] = [];
|
||||
|
||||
/** Register a function that returns extra env vars to inject into engine processes. Returns an unregister function. */
|
||||
export function registerEnvProvider(provider: EnvProvider): () => void {
|
||||
envProviders.push(provider);
|
||||
return () => {
|
||||
const idx = envProviders.indexOf(provider);
|
||||
if (idx >= 0) envProviders.splice(idx, 1);
|
||||
};
|
||||
}
|
||||
|
||||
/** Collect env vars from all registered providers. */
|
||||
export function collectEnvFromProviders(): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const provider of envProviders) {
|
||||
try {
|
||||
const env = provider();
|
||||
if (env) Object.assign(result, env);
|
||||
} catch (e) {
|
||||
log.warn("[EngineHooks] env provider error:", e);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ==================== Prompt Enhancers ====================
|
||||
|
||||
export type PromptEnhancer = (
|
||||
basePrompt: string | undefined,
|
||||
) => string | undefined;
|
||||
|
||||
const promptEnhancers: PromptEnhancer[] = [];
|
||||
|
||||
/** Register a function that can append/modify the system prompt. Returns an unregister function. */
|
||||
export function registerPromptEnhancer(enhancer: PromptEnhancer): () => void {
|
||||
promptEnhancers.push(enhancer);
|
||||
return () => {
|
||||
const idx = promptEnhancers.indexOf(enhancer);
|
||||
if (idx >= 0) promptEnhancers.splice(idx, 1);
|
||||
};
|
||||
}
|
||||
|
||||
/** Run all registered enhancers on the base system prompt. */
|
||||
export function enhanceSystemPrompt(
|
||||
basePrompt: string | undefined,
|
||||
): string | undefined {
|
||||
let result = basePrompt;
|
||||
for (const enhancer of promptEnhancers) {
|
||||
try {
|
||||
result = enhancer(result);
|
||||
} catch (e) {
|
||||
log.warn("[EngineHooks] prompt enhancer error:", e);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,551 @@
|
||||
/**
|
||||
* Agent Engine Manager - 引擎安装与配置隔离
|
||||
*
|
||||
* 支持 claude-code 和 qimingcode 的:
|
||||
* - 本地安装 (应用目录)
|
||||
* - 环境变量隔离
|
||||
* - 配置隔离
|
||||
*/
|
||||
|
||||
import * as path from "path";
|
||||
import * as os from "os";
|
||||
import * as fs from "fs";
|
||||
import { spawn } from "child_process";
|
||||
import log from "electron-log";
|
||||
import { getAppEnv, getQimingCodeBundledBinPath } from "../system/dependencies";
|
||||
import { mcpProxyManager } from "../packages/mcp";
|
||||
import { spawnJsFile, resolveNpmPackageEntry } from "../utils/spawnNoWindow";
|
||||
import { APP_DATA_DIR_NAME } from "../constants";
|
||||
import { APP_NAME_IDENTIFIER } from "@shared/constants";
|
||||
import { isWindows } from "../system/shellEnv";
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
export type AgentEngine = "claude-code" | "qimingcode";
|
||||
|
||||
export interface EngineConfig {
|
||||
engine: AgentEngine;
|
||||
// 安装
|
||||
installPath?: string;
|
||||
// 运行时隔离
|
||||
isolatedHome?: string;
|
||||
isolatedConfig?: string;
|
||||
// API 配置
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
// 工作目录
|
||||
workspaceDir?: string;
|
||||
}
|
||||
|
||||
export interface EngineStatus {
|
||||
installed: boolean;
|
||||
version?: string;
|
||||
running: boolean;
|
||||
pid?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ==================== Paths ====================
|
||||
|
||||
function getAppDataDir(): string {
|
||||
const home = process.env.HOME || process.env.USERPROFILE || "";
|
||||
return path.join(home, APP_DATA_DIR_NAME);
|
||||
}
|
||||
|
||||
function getEnginesDir(): string {
|
||||
return path.join(getAppDataDir(), "engines");
|
||||
}
|
||||
|
||||
function getEngineDir(engine: AgentEngine): string {
|
||||
return path.join(getEnginesDir(), engine);
|
||||
}
|
||||
|
||||
function getIsolatedHomeDir(runId: string): string {
|
||||
return path.join(os.tmpdir(), `${APP_NAME_IDENTIFIER}-${runId}`);
|
||||
}
|
||||
|
||||
// ==================== Engine Detection ====================
|
||||
|
||||
/**
|
||||
* 检测引擎是否已安装 (本地)
|
||||
*/
|
||||
export function isEngineInstalledLocally(engine: AgentEngine): boolean {
|
||||
const engineDir = getEngineDir(engine);
|
||||
|
||||
if (engine === "claude-code") {
|
||||
// 检查 claude-code 可执行文件
|
||||
const binPaths = [
|
||||
path.join(engineDir, "bin", "claude-code"),
|
||||
path.join(engineDir, "claude-code"),
|
||||
path.join(getAppDataDir(), "node_modules", ".bin", "claude-code"),
|
||||
];
|
||||
|
||||
for (const p of binPaths) {
|
||||
if (fs.existsSync(p)) return true;
|
||||
if (fs.existsSync(p + ".exe")) return true;
|
||||
if (fs.existsSync(p + ".cmd")) return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (engine === "qimingcode") {
|
||||
// 优先检查打包的二进制
|
||||
const bundledPath = getQimingCodeBundledBinPath();
|
||||
if (bundledPath) return true;
|
||||
|
||||
const binPaths = [
|
||||
path.join(engineDir, "bin", "qimingcode"),
|
||||
path.join(engineDir, "qimingcode"),
|
||||
path.join(getAppDataDir(), "node_modules", ".bin", "qimingcode"),
|
||||
];
|
||||
|
||||
for (const p of binPaths) {
|
||||
if (fs.existsSync(p)) return true;
|
||||
if (fs.existsSync(p + ".exe")) return true;
|
||||
if (fs.existsSync(p + ".cmd")) return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测系统全局安装
|
||||
*/
|
||||
export async function isEngineInstalledGlobally(
|
||||
engine: AgentEngine,
|
||||
): Promise<boolean> {
|
||||
const cmd = engine === "claude-code" ? "claude-code" : "qimingcode";
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const checkCmd = isWindows() ? "where" : "which";
|
||||
const proc = spawn(checkCmd, [cmd], {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
shell: isWindows(),
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
resolve(code === 0);
|
||||
});
|
||||
|
||||
proc.on("error", () => resolve(false));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取引擎版本
|
||||
*/
|
||||
export async function getEngineVersion(
|
||||
engine: AgentEngine,
|
||||
): Promise<string | null> {
|
||||
// 先尝试本地
|
||||
const localEngine = findEngineBinary(engine);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const cmd =
|
||||
localEngine || (engine === "claude-code" ? "claude-code" : "qimingcode");
|
||||
const args = ["--version"];
|
||||
|
||||
const proc = spawn(cmd, args, {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
shell: isWindows(),
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
proc.stdout?.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
proc.on("close", () => {
|
||||
const match = stdout.match(/(\d+\.\d+\.\d+)/);
|
||||
resolve(match ? match[1] : null);
|
||||
});
|
||||
|
||||
proc.on("error", () => resolve(null));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取引擎包目录
|
||||
*/
|
||||
function getEnginePackageDir(engine: AgentEngine): string | null {
|
||||
const nodeModules = path.join(getAppDataDir(), "node_modules");
|
||||
const packageName = engine === "claude-code" ? "claude-code" : "qimingcode";
|
||||
const packageDir = path.join(nodeModules, packageName);
|
||||
return fs.existsSync(packageDir) ? packageDir : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找引擎 JS 入口文件路径
|
||||
*
|
||||
* 使用通用工具 resolveNpmPackageEntry 解析入口文件
|
||||
*/
|
||||
export function findEngineBinary(engine: AgentEngine): string | null {
|
||||
// qimingcode:优先使用应用内打包的二进制
|
||||
if (engine === "qimingcode") {
|
||||
const bundledPath = getQimingCodeBundledBinPath();
|
||||
if (bundledPath) {
|
||||
log.info(`[Engine] qimingcode: using bundled binary: ${bundledPath}`);
|
||||
return bundledPath;
|
||||
}
|
||||
}
|
||||
|
||||
const packageDir = getEnginePackageDir(engine);
|
||||
if (!packageDir) return null;
|
||||
|
||||
const packageName = engine === "claude-code" ? "claude-code" : "qimingcode";
|
||||
return resolveNpmPackageEntry(packageDir, packageName);
|
||||
}
|
||||
|
||||
// ==================== Engine Installation ====================
|
||||
|
||||
/**
|
||||
* 安装引擎到本地目录
|
||||
*/
|
||||
export async function installEngine(
|
||||
engine: AgentEngine,
|
||||
options?: { registry?: string },
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const engineDir = getEngineDir(engine);
|
||||
|
||||
// 确保目录存在
|
||||
if (!fs.existsSync(engineDir)) {
|
||||
fs.mkdirSync(engineDir, { recursive: true });
|
||||
}
|
||||
|
||||
const packageName = engine === "claude-code" ? "claude-code" : "qimingcode";
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const npmCmd = isWindows() ? "npm.cmd" : "npm";
|
||||
const args = ["install", "--save", packageName];
|
||||
|
||||
if (options?.registry) {
|
||||
args.push(`--registry=${options.registry}`);
|
||||
}
|
||||
|
||||
log.info(`[Engine] Installing ${packageName} to ${engineDir}`);
|
||||
|
||||
const proc = spawn(npmCmd, args, {
|
||||
cwd: engineDir,
|
||||
env: {
|
||||
...process.env,
|
||||
...getAppEnv(),
|
||||
},
|
||||
stdio: "pipe",
|
||||
shell: isWindows(),
|
||||
});
|
||||
|
||||
let stderr = "";
|
||||
proc.stderr?.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on("error", (error) => {
|
||||
log.error(`[Engine] Install error:`, error);
|
||||
resolve({ success: false, error: error.message });
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
log.info(`[Engine] ${packageName} installed successfully`);
|
||||
resolve({ success: true });
|
||||
} else {
|
||||
log.error(`[Engine] Install failed:`, stderr);
|
||||
resolve({ success: false, error: stderr || "Install failed" });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Environment Isolation ====================
|
||||
|
||||
/**
|
||||
* 生成唯一运行 ID
|
||||
*/
|
||||
function generateRunId(): string {
|
||||
return `run-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建隔离环境
|
||||
*/
|
||||
export function createIsolatedEnvironment(config: EngineConfig): {
|
||||
env: Record<string, string>;
|
||||
runId: string;
|
||||
cleanup: () => void;
|
||||
} {
|
||||
const runId = generateRunId();
|
||||
const isolatedHome = getIsolatedHomeDir(runId);
|
||||
|
||||
// 创建隔离目录(recursive: true 保证幂等)
|
||||
fs.mkdirSync(path.join(isolatedHome, ".claude"), { recursive: true });
|
||||
fs.mkdirSync(path.join(isolatedHome, ".qimingcode"), { recursive: true });
|
||||
|
||||
// 注入 MCP 配置到 .claude/settings.json
|
||||
// 如果 MCP Proxy 运行中,使用 mcp-proxy convert 桥接;否则直接配置 stdio 服务器
|
||||
const mcpConfig = mcpProxyManager.getAgentMcpConfig();
|
||||
if (mcpConfig) {
|
||||
const settingsPath = path.join(isolatedHome, ".claude", "settings.json");
|
||||
const settings = { mcpServers: mcpConfig };
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
||||
log.info(`[Engine] Wrote MCP config to ${settingsPath}`);
|
||||
}
|
||||
|
||||
// 构建隔离环境变量
|
||||
// 使用 getAppEnv() 注入应用内依赖路径(PATH / NODE_PATH)
|
||||
const appEnv = getAppEnv();
|
||||
|
||||
// 清洗系统环境变量:移除可能干扰 agent 引擎的变量
|
||||
// 避免用户系统配置(~/.zshrc 等)中设置的 CLAUDE_*/ANTHROPIC_* 变量泄漏到隔离环境
|
||||
const sanitizedEnv: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (value === undefined) continue;
|
||||
// 清除所有 CLAUDE_* 和 ANTHROPIC_* 变量,后续由我们显式注入
|
||||
if (key.startsWith("CLAUDE_") || key.startsWith("ANTHROPIC_")) continue;
|
||||
sanitizedEnv[key] = value;
|
||||
}
|
||||
|
||||
const env: Record<string, string> = {
|
||||
...sanitizedEnv,
|
||||
...appEnv,
|
||||
|
||||
// 隔离 HOME 目录 - 避免读取用户全局配置(~/.claude/settings.json 等)
|
||||
HOME: isolatedHome,
|
||||
USERPROFILE: isolatedHome, // Windows 兼容
|
||||
|
||||
// 隔离 XDG 配置
|
||||
XDG_CONFIG_HOME: path.join(isolatedHome, ".config"),
|
||||
XDG_DATA_HOME: path.join(isolatedHome, ".local", "share"),
|
||||
XDG_CACHE_HOME: path.join(isolatedHome, ".cache"),
|
||||
|
||||
// 显式配置路径 — 引擎只会读取隔离目录内的配置
|
||||
CLAUDE_CONFIG_DIR: path.join(isolatedHome, ".claude"),
|
||||
QIMINGCODE_CONFIG_DIR: path.join(isolatedHome, ".qimingcode"),
|
||||
};
|
||||
|
||||
// API 配置 (优先使用注入的值)
|
||||
if (config.apiKey) {
|
||||
env.ANTHROPIC_API_KEY = config.apiKey;
|
||||
}
|
||||
|
||||
if (config.baseUrl) {
|
||||
env.ANTHROPIC_BASE_URL = config.baseUrl;
|
||||
env.ANTHROPIC_API_BASE_URL = config.baseUrl;
|
||||
}
|
||||
|
||||
if (config.model) {
|
||||
env.ANTHROPIC_MODEL = config.model;
|
||||
}
|
||||
|
||||
// 清理函数
|
||||
const cleanup = () => {
|
||||
try {
|
||||
// 可选: 清理临时目录
|
||||
// fs.rmSync(isolatedHome, { recursive: true, force: true });
|
||||
log.info(`[Engine] Run ${runId} cleanup (kept temp dir for debugging)`);
|
||||
} catch (error) {
|
||||
log.error(`[Engine] Cleanup error:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
log.info(`[Engine] Created isolated environment: ${isolatedHome}`);
|
||||
|
||||
return { env, runId, cleanup };
|
||||
}
|
||||
|
||||
// ==================== Engine Runner ====================
|
||||
|
||||
interface RunningEngine {
|
||||
process: ReturnType<typeof spawn>;
|
||||
config: EngineConfig;
|
||||
runId: string;
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
const runningEngines: Map<string, RunningEngine> = new Map();
|
||||
|
||||
/**
|
||||
* 启动引擎
|
||||
*/
|
||||
export async function startEngine(
|
||||
config: EngineConfig,
|
||||
): Promise<{ success: boolean; error?: string; engineId?: string }> {
|
||||
// 查找引擎可执行文件
|
||||
const engineBinary = findEngineBinary(config.engine);
|
||||
|
||||
if (!engineBinary) {
|
||||
return { success: false, error: `${config.engine} not installed` };
|
||||
}
|
||||
|
||||
// 创建隔离环境
|
||||
const { env, runId, cleanup } = createIsolatedEnvironment(config);
|
||||
|
||||
// 构建启动参数
|
||||
let args: string[] = [];
|
||||
|
||||
switch (config.engine) {
|
||||
case "claude-code":
|
||||
args = ["--sACP"];
|
||||
break;
|
||||
case "qimingcode":
|
||||
args = ["serve", "--stdio"];
|
||||
break;
|
||||
}
|
||||
|
||||
log.info(
|
||||
`[Engine] Starting ${config.engine}: node ${engineBinary} ${args.join(" ")}`,
|
||||
);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
// 使用通用 spawnJsFile 启动,自动处理 Windows 无弹窗
|
||||
const proc = spawnJsFile(engineBinary, args, {
|
||||
env,
|
||||
cwd: config.workspaceDir || getAppDataDir(),
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
proc.on("error", (error) => {
|
||||
log.error(`[Engine] Start error:`, error);
|
||||
cleanup();
|
||||
resolve({ success: false, error: error.message });
|
||||
});
|
||||
|
||||
proc.on("exit", (code) => {
|
||||
log.info(`[Engine] ${config.engine} exited with code ${code}`);
|
||||
cleanup();
|
||||
runningEngines.delete(runId);
|
||||
});
|
||||
|
||||
// 等待进程启动
|
||||
setTimeout(() => {
|
||||
if (proc.pid) {
|
||||
const engineId = runId;
|
||||
runningEngines.set(runId, {
|
||||
process: proc,
|
||||
config,
|
||||
runId,
|
||||
cleanup,
|
||||
});
|
||||
|
||||
log.info(`[Engine] ${config.engine} started with PID ${proc.pid}`);
|
||||
resolve({ success: true, engineId });
|
||||
} else {
|
||||
cleanup();
|
||||
resolve({ success: false, error: "Failed to start process" });
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止引擎
|
||||
*/
|
||||
export async function stopEngine(
|
||||
engineId: string,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const engine = runningEngines.get(engineId);
|
||||
|
||||
if (!engine) {
|
||||
return { success: false, error: "Engine not running" };
|
||||
}
|
||||
|
||||
try {
|
||||
engine.process.kill();
|
||||
engine.cleanup();
|
||||
runningEngines.delete(engineId);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取引擎状态
|
||||
*/
|
||||
export function getEngineStatus(
|
||||
engineId?: string,
|
||||
): EngineStatus | Record<string, EngineStatus> {
|
||||
if (engineId) {
|
||||
const engine = runningEngines.get(engineId);
|
||||
if (engine) {
|
||||
return {
|
||||
installed: true,
|
||||
running: true,
|
||||
pid: engine.process.pid,
|
||||
};
|
||||
}
|
||||
return { installed: false, running: false };
|
||||
}
|
||||
|
||||
// 返回所有运行中的引擎
|
||||
const statuses: Record<string, EngineStatus> = {};
|
||||
for (const [id, engine] of runningEngines) {
|
||||
statuses[id] = {
|
||||
installed: true,
|
||||
running: true,
|
||||
pid: engine.process.pid,
|
||||
};
|
||||
}
|
||||
return statuses;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息到引擎 (通过 stdin)
|
||||
*/
|
||||
export async function sendToEngine(
|
||||
engineId: string,
|
||||
message: string,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const engine = runningEngines.get(engineId);
|
||||
|
||||
if (!engine || !engine.process.stdin) {
|
||||
return { success: false, error: "Engine not running" };
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
engine.process.stdin!.write(message + "\n", (error) => {
|
||||
if (error) {
|
||||
resolve({ success: false, error: error.message });
|
||||
} else {
|
||||
resolve({ success: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止所有引擎
|
||||
*/
|
||||
export async function stopAllEngines(): Promise<void> {
|
||||
for (const [id, engine] of runningEngines) {
|
||||
try {
|
||||
engine.process.kill();
|
||||
engine.cleanup();
|
||||
} catch (error) {
|
||||
log.error(`[Engine] Error stopping ${id}:`, error);
|
||||
}
|
||||
}
|
||||
runningEngines.clear();
|
||||
}
|
||||
|
||||
export default {
|
||||
// Detection
|
||||
isEngineInstalledLocally,
|
||||
isEngineInstalledGlobally,
|
||||
getEngineVersion,
|
||||
findEngineBinary,
|
||||
|
||||
// Installation
|
||||
installEngine,
|
||||
|
||||
// Isolation
|
||||
createIsolatedEnvironment,
|
||||
|
||||
// Runtime
|
||||
startEngine,
|
||||
stopEngine,
|
||||
getEngineStatus,
|
||||
sendToEngine,
|
||||
stopAllEngines,
|
||||
};
|
||||
@@ -0,0 +1,849 @@
|
||||
/**
|
||||
* EngineWarmup — qimingcode 引擎热启动管理
|
||||
*
|
||||
* 在服务 init() 后台预创建一个 qimingcode ACP 引擎进程,以 "__warmup__" 占位。
|
||||
* 首次会话请求时复用并 re-key 为实际 projectId,省掉 ~2s 进程冷启动。
|
||||
*
|
||||
* 注意:始终预热 qimingcode,与 init() 传入的 engineType 无关。
|
||||
* 因为实际请求中 agent_server.command 会动态决定引擎类型。
|
||||
*
|
||||
* 用法(UnifiedAgentService 中仅两行):
|
||||
* this.warmup.start(baseConfig, (e) => this.forwardEvents(e));
|
||||
* // getOrCreateEngine 中:
|
||||
* const reused = await this.warmup.tryReuse(projectId, effectiveConfig);
|
||||
*/
|
||||
|
||||
import * as crypto from "crypto";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import log from "electron-log";
|
||||
import { AcpEngine } from "./acp/acpEngine";
|
||||
import type { AgentConfig, AgentEngineType } from "./types";
|
||||
import type { McpServerEntry } from "../packages/mcp";
|
||||
import { firstTokenTrace } from "./perf/firstTokenTrace";
|
||||
|
||||
const WARMUP_KEY = "__warmup__";
|
||||
const WARMUP_ENGINE_TYPE: AgentEngineType = "qimingcode";
|
||||
const RESPAWN_DELAY_MS = 500; // 防抖延迟
|
||||
const MCP_CONFIG_HASH_RE = /mcp-config-[^-]+-([0-9a-f]{16})\.json$/i;
|
||||
const WARMUP_ENV_FLAG = "QIMING_AGENT_WARMUP";
|
||||
const WARMUP_MCP_READY_FLAG = "QIMING_AGENT_WARMUP_MCP_READY";
|
||||
const WARMUP_SANDBOX_POLICY_FINGERPRINT_FLAG =
|
||||
"QIMING_AGENT_WARMUP_SANDBOX_POLICY_FP";
|
||||
|
||||
type SandboxPolicyFingerprintProvider = () => string | null;
|
||||
|
||||
type WarmupStartOptions = {
|
||||
/** 允许在已有活跃 project 引擎时补仓 warmup */
|
||||
allowWhenActiveEngines?: boolean;
|
||||
/** 用最新请求的 MCP 覆盖 warmup 配置,避免后续复用 miss */
|
||||
mcpServers?: AgentConfig["mcpServers"];
|
||||
/** 触发原因,仅用于日志 */
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
type EngineWarmupOptions = {
|
||||
/** Return a stable fingerprint string for current sandbox policy. */
|
||||
getSandboxPolicyFingerprint?: SandboxPolicyFingerprintProvider;
|
||||
};
|
||||
|
||||
type StdioMcpEntry = Extract<McpServerEntry, { command: string }>;
|
||||
type RemoteMcpEntry = Extract<McpServerEntry, { url: string }>;
|
||||
|
||||
export class EngineWarmup {
|
||||
private respawnScheduled = false;
|
||||
private respawnTimer: NodeJS.Timeout | null = null;
|
||||
// 缓存上一次会话的 MCP 配置,用于 respawn() 时预热
|
||||
private lastMcpServers: AgentConfig["mcpServers"] | null = null;
|
||||
// 缓存上一次请求的运行时配置,确保 refill warmup 能命中后续请求
|
||||
private lastRuntimeConfig: Pick<
|
||||
AgentConfig,
|
||||
"model" | "apiKey" | "baseUrl" | "apiProtocol"
|
||||
> | null = null;
|
||||
private disposed = false;
|
||||
|
||||
constructor(
|
||||
private readonly engines: Map<string, AcpEngine>,
|
||||
private readonly configs: Map<string, AgentConfig>,
|
||||
private readonly rawMcpServers: Map<string, Record<string, McpServerEntry>>,
|
||||
private readonly options?: EngineWarmupOptions,
|
||||
) {}
|
||||
|
||||
/** 清理 respawn 定时器,防止实例销毁后仍有 pending callback。 */
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
if (this.respawnTimer) {
|
||||
clearTimeout(this.respawnTimer);
|
||||
this.respawnTimer = null;
|
||||
}
|
||||
this.respawnScheduled = false;
|
||||
this.lastMcpServers = null;
|
||||
this.lastRuntimeConfig = null;
|
||||
}
|
||||
|
||||
/** 重新启用 warmup(用于 service destroy 后再次 init)。 */
|
||||
reactivate(): void {
|
||||
this.disposed = false;
|
||||
this.respawnScheduled = false;
|
||||
this.respawnTimer = null;
|
||||
}
|
||||
|
||||
/** 后台预创建 qimingcode 引擎。非阻塞,失败不影响正常流程。始终预热 qimingcode。 */
|
||||
start(
|
||||
baseConfig: AgentConfig | null,
|
||||
onEngineCreated: (engine: AcpEngine) => void,
|
||||
options?: WarmupStartOptions,
|
||||
): void {
|
||||
if (!baseConfig || this.disposed) return;
|
||||
// 仅检查是否已有 warmup 引擎,不检查其他引擎
|
||||
// 确保 init() 时一定会初始化 warmup 一次
|
||||
if (this.engines.has(WARMUP_KEY)) {
|
||||
log.debug("[EngineWarmup] Warmup engine already exists, skipping warmup");
|
||||
return;
|
||||
}
|
||||
const activeEngineCount = this.engines.size;
|
||||
if (activeEngineCount > 0 && !options?.allowWhenActiveEngines) {
|
||||
log.debug(
|
||||
`[EngineWarmup] ${activeEngineCount} active engine(s) present, skipping warmup`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (options?.mcpServers !== undefined) {
|
||||
this.lastMcpServers =
|
||||
options.mcpServers && Object.keys(options.mcpServers).length > 0
|
||||
? options.mcpServers
|
||||
: null;
|
||||
}
|
||||
|
||||
const reason = options?.reason ? `, reason=${options.reason}` : "";
|
||||
log.info(
|
||||
`[EngineWarmup] 🔥 Background warming qimingcode engine...${reason}`,
|
||||
);
|
||||
const engine = new AcpEngine(WARMUP_ENGINE_TYPE);
|
||||
onEngineCreated(engine);
|
||||
|
||||
// 确保 config.engine 与实际引擎类型一致(init 时 engineType 可能是 claude-code)
|
||||
// 应用缓存的运行时配置(model/apiKey/baseUrl/apiProtocol),确保 refill warmup 可命中后续请求
|
||||
const warmupConfig: AgentConfig = {
|
||||
...baseConfig,
|
||||
engine: WARMUP_ENGINE_TYPE,
|
||||
...(this.lastRuntimeConfig
|
||||
? {
|
||||
model: this.lastRuntimeConfig.model || baseConfig.model,
|
||||
apiKey: this.lastRuntimeConfig.apiKey || baseConfig.apiKey,
|
||||
baseUrl: this.lastRuntimeConfig.baseUrl || baseConfig.baseUrl,
|
||||
apiProtocol:
|
||||
this.lastRuntimeConfig.apiProtocol || baseConfig.apiProtocol,
|
||||
}
|
||||
: {}),
|
||||
env: {
|
||||
...(baseConfig.env || {}),
|
||||
[WARMUP_ENV_FLAG]: "1",
|
||||
},
|
||||
};
|
||||
const seededMcpServers =
|
||||
options?.mcpServers !== undefined
|
||||
? options.mcpServers
|
||||
: (this.lastMcpServers ?? baseConfig.mcpServers);
|
||||
if (seededMcpServers) {
|
||||
warmupConfig.mcpServers = seededMcpServers;
|
||||
warmupConfig.env = {
|
||||
...(warmupConfig.env || {}),
|
||||
[WARMUP_MCP_READY_FLAG]: "1",
|
||||
};
|
||||
}
|
||||
const sandboxPolicyFingerprint =
|
||||
this.options?.getSandboxPolicyFingerprint?.() || null;
|
||||
if (sandboxPolicyFingerprint) {
|
||||
warmupConfig.env = {
|
||||
...(warmupConfig.env || {}),
|
||||
[WARMUP_SANDBOX_POLICY_FINGERPRINT_FLAG]: sandboxPolicyFingerprint,
|
||||
};
|
||||
log.debug("[EngineWarmup] warmup sandbox policy snapshot", {
|
||||
fingerprintDigest: this.digest(sandboxPolicyFingerprint),
|
||||
});
|
||||
}
|
||||
|
||||
// 立即占位,避免 warmup 进行中 getOrCreateEngine 创建重复引擎
|
||||
this.engines.set(WARMUP_KEY, engine);
|
||||
this.configs.set(WARMUP_KEY, warmupConfig);
|
||||
|
||||
const cleanup = () => {
|
||||
this.engines.delete(WARMUP_KEY);
|
||||
this.configs.delete(WARMUP_KEY);
|
||||
this.rawMcpServers.delete(WARMUP_KEY);
|
||||
engine.removeAllListeners();
|
||||
engine.destroy().catch(() => {});
|
||||
};
|
||||
|
||||
engine
|
||||
.init(warmupConfig)
|
||||
.then((ok) => {
|
||||
if (ok && this.engines.has(WARMUP_KEY)) {
|
||||
log.info(
|
||||
"[EngineWarmup] 🔥 qimingcode warm start complete, engine ready",
|
||||
);
|
||||
} else {
|
||||
log.warn("[EngineWarmup] 🔥 Warm start failed (init returned false)");
|
||||
cleanup();
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
log.warn(
|
||||
"[EngineWarmup] 🔥 Warm start failed:",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
cleanup();
|
||||
});
|
||||
}
|
||||
|
||||
private sortValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map((v) => this.sortValue(v));
|
||||
if (!value || typeof value !== "object") return value;
|
||||
const src = value as Record<string, unknown>;
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(src).sort()) {
|
||||
out[key] = this.sortValue(src[key]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private stableStringify(value: unknown): string {
|
||||
return JSON.stringify(this.sortValue(value));
|
||||
}
|
||||
|
||||
private digest(value: string): string {
|
||||
return crypto.createHash("sha1").update(value).digest("hex").slice(0, 12);
|
||||
}
|
||||
|
||||
private sortedStringRecord(
|
||||
record: Record<string, string | undefined> | undefined,
|
||||
): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
if (!record) return out;
|
||||
for (const key of Object.keys(record).sort()) {
|
||||
const value = record[key];
|
||||
if (typeof value === "string") {
|
||||
out[key] = value;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private normalizeTransport(
|
||||
value: string | undefined,
|
||||
): "sse" | "streamable-http" {
|
||||
return value === "sse" ? "sse" : "streamable-http";
|
||||
}
|
||||
|
||||
private readConfigFileFingerprint(
|
||||
configFilePath: string | undefined,
|
||||
): string {
|
||||
if (!configFilePath) return "missing";
|
||||
try {
|
||||
const raw = fs.readFileSync(configFilePath, "utf8");
|
||||
const parsed = JSON.parse(raw);
|
||||
return this.stableStringify(parsed);
|
||||
} catch {
|
||||
const match = path.basename(configFilePath).match(MCP_CONFIG_HASH_RE);
|
||||
if (match?.[1]) return `hash:${match[1].toLowerCase()}`;
|
||||
return `basename:${path.basename(configFilePath)}`;
|
||||
}
|
||||
}
|
||||
|
||||
private extractConfigFilePath(args: string[]): string | undefined {
|
||||
const idx = args.indexOf("--config-file");
|
||||
if (idx < 0 || idx + 1 >= args.length) return undefined;
|
||||
return args[idx + 1];
|
||||
}
|
||||
|
||||
private isProxyLikeStdio(entry: StdioMcpEntry): boolean {
|
||||
const commandBase = path.basename(entry.command).toLowerCase();
|
||||
const args = entry.args || [];
|
||||
if (commandBase === "mcp-proxy") return true;
|
||||
if (args.includes("--config-file")) return true;
|
||||
const scriptPath = args[0];
|
||||
if (typeof scriptPath === "string") {
|
||||
const scriptBase = path.basename(scriptPath).toLowerCase();
|
||||
if (scriptBase.includes("mcp") && scriptBase.endsWith(".js")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private normalizeProxyArgs(args: string[]): string[] {
|
||||
const normalized: string[] = [];
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg === "--config-file") {
|
||||
i += 1; // Skip the transient config file path argument.
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
i === 0 &&
|
||||
(arg.endsWith(".js") || arg.endsWith(".cjs") || arg.endsWith(".mjs"))
|
||||
) {
|
||||
normalized.push(path.basename(arg));
|
||||
continue;
|
||||
}
|
||||
normalized.push(arg);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private tryExtractBridgeServerFromUrl(urlValue: string): string | null {
|
||||
try {
|
||||
const parsed = new URL(urlValue);
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
if (host !== "127.0.0.1" && host !== "localhost") return null;
|
||||
const segments = parsed.pathname.split("/").filter(Boolean);
|
||||
if (segments.length === 2 && segments[0] === "mcp" && segments[1]) {
|
||||
return decodeURIComponent(segments[1]);
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private readProxyConfigMeta(configFilePath: string | undefined): {
|
||||
serverName: string;
|
||||
persistent: boolean;
|
||||
} | null {
|
||||
if (!configFilePath) return null;
|
||||
try {
|
||||
const raw = fs.readFileSync(configFilePath, "utf8");
|
||||
const parsed = JSON.parse(raw) as {
|
||||
mcpServers?: Record<string, { persistent?: boolean }>;
|
||||
};
|
||||
const servers = parsed.mcpServers;
|
||||
if (!servers) return null;
|
||||
const serverNames = Object.keys(servers);
|
||||
if (serverNames.length !== 1) return null;
|
||||
const keyName = serverNames[0];
|
||||
const server = servers[keyName] as { persistent?: boolean; url?: string };
|
||||
const urlServerName =
|
||||
typeof server?.url === "string"
|
||||
? this.tryExtractBridgeServerFromUrl(server.url)
|
||||
: null;
|
||||
const serverName = urlServerName || keyName;
|
||||
const persistent = server?.persistent === true || !!urlServerName;
|
||||
return { serverName, persistent };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private serializeRemoteEntry(entry: RemoteMcpEntry): string {
|
||||
const transportSource =
|
||||
(entry as { type?: string; transport?: string }).type ??
|
||||
(entry as { type?: string; transport?: string }).transport;
|
||||
const transport = this.normalizeTransport(transportSource);
|
||||
const headers = this.sortedStringRecord(
|
||||
(entry as { headers?: Record<string, string> }).headers,
|
||||
);
|
||||
const allowTools = [
|
||||
...((entry as { allowTools?: string[] }).allowTools || []),
|
||||
].sort();
|
||||
const denyTools = [
|
||||
...((entry as { denyTools?: string[] }).denyTools || []),
|
||||
].sort();
|
||||
// PersistentMcpBridge 的 URL 形态(http://127.0.0.1:<port>/mcp/<name>)
|
||||
// 与 warmup 时可能出现的 proxy-stdio 形态语义等价,统一归一化。
|
||||
const bridgeServerName = this.tryExtractBridgeServerFromUrl(entry.url);
|
||||
if (bridgeServerName) {
|
||||
return this.stableStringify({
|
||||
mode: "persistent-bridge",
|
||||
server: bridgeServerName,
|
||||
allowTools,
|
||||
denyTools,
|
||||
});
|
||||
}
|
||||
const authToken = (entry as { authToken?: string }).authToken;
|
||||
const authTokenDigest = authToken ? this.digest(authToken) : undefined;
|
||||
return this.stableStringify({
|
||||
mode: "remote",
|
||||
url: entry.url,
|
||||
transport,
|
||||
headers,
|
||||
authTokenDigest,
|
||||
allowTools,
|
||||
denyTools,
|
||||
});
|
||||
}
|
||||
|
||||
private serializeStdioEntry(entry: StdioMcpEntry): string {
|
||||
const env = { ...(entry.env || {}) };
|
||||
delete env.MCP_PROXY_LOG_FILE;
|
||||
const isProxy = this.isProxyLikeStdio(entry);
|
||||
const args = [...(entry.args || [])];
|
||||
const configFilePath = this.extractConfigFilePath(args);
|
||||
const allowTools = [...(entry.allowTools || [])].sort();
|
||||
const denyTools = [...(entry.denyTools || [])].sort();
|
||||
|
||||
if (isProxy) {
|
||||
// getAgentMcpConfig() 在 bridge 未 ready 时会返回 proxy-stdio 入口;
|
||||
// bridge ready 后同一 persistent server 会变成 URL 入口。
|
||||
// 若 config-file 解析到 persistent server,则归一化为同一语义键,避免误判不兼容。
|
||||
const meta = this.readProxyConfigMeta(configFilePath);
|
||||
if (meta?.persistent) {
|
||||
return this.stableStringify({
|
||||
mode: "persistent-bridge",
|
||||
server: meta.serverName,
|
||||
allowTools,
|
||||
denyTools,
|
||||
});
|
||||
}
|
||||
return this.stableStringify({
|
||||
mode: "proxy-stdio",
|
||||
// Node path may differ between warmup and request; compare basename only.
|
||||
command: path.basename(entry.command).toLowerCase(),
|
||||
args: this.normalizeProxyArgs(args),
|
||||
configFingerprint: this.readConfigFileFingerprint(configFilePath),
|
||||
env: this.sortedStringRecord(env),
|
||||
allowTools,
|
||||
denyTools,
|
||||
});
|
||||
}
|
||||
|
||||
return this.stableStringify({
|
||||
mode: "stdio",
|
||||
command: entry.command,
|
||||
args,
|
||||
env: this.sortedStringRecord(env),
|
||||
allowTools,
|
||||
denyTools,
|
||||
});
|
||||
}
|
||||
|
||||
private serializeMcpEntry(entry: McpServerEntry): string {
|
||||
if ("url" in entry && typeof entry.url === "string") {
|
||||
return this.serializeRemoteEntry(entry as RemoteMcpEntry);
|
||||
}
|
||||
if ("command" in entry) {
|
||||
return this.serializeStdioEntry(entry as StdioMcpEntry);
|
||||
}
|
||||
return this.stableStringify(entry);
|
||||
}
|
||||
|
||||
private checkMcpCompatibility(
|
||||
warmupMcp: NonNullable<AgentConfig["mcpServers"]>,
|
||||
requestMcp: NonNullable<AgentConfig["mcpServers"]>,
|
||||
): { compatible: boolean; reason: string; detail?: Record<string, string> } {
|
||||
const warmupKeys = Object.keys(warmupMcp).sort();
|
||||
const requestKeys = Object.keys(requestMcp).sort();
|
||||
|
||||
if (warmupKeys.join("\0") !== requestKeys.join("\0")) {
|
||||
return {
|
||||
compatible: false,
|
||||
reason: `MCP key set mismatch (warmup=${warmupKeys.join(",") || "(none)"}, request=${requestKeys.join(",") || "(none)"})`,
|
||||
};
|
||||
}
|
||||
|
||||
for (const key of warmupKeys) {
|
||||
const aSer = this.serializeMcpEntry(warmupMcp[key]);
|
||||
const bSer = this.serializeMcpEntry(requestMcp[key]);
|
||||
if (aSer !== bSer) {
|
||||
return {
|
||||
compatible: false,
|
||||
reason: `MCP[${key}] config mismatch`,
|
||||
detail: {
|
||||
warmupDigest: this.digest(aSer),
|
||||
requestDigest: this.digest(bSer),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { compatible: true, reason: "ok" };
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存请求中的运行时配置(model/apiKey/baseUrl/apiProtocol),
|
||||
* 确保后续 warmup refill 创建的引擎与实际请求配置一致。
|
||||
*/
|
||||
private cacheRuntimeConfig(config: AgentConfig): void {
|
||||
if (config.model || config.apiKey || config.baseUrl || config.apiProtocol) {
|
||||
this.lastRuntimeConfig = {
|
||||
model: config.model,
|
||||
apiKey: config.apiKey,
|
||||
baseUrl: config.baseUrl,
|
||||
apiProtocol: config.apiProtocol,
|
||||
};
|
||||
log.debug(
|
||||
"[EngineWarmup] 💾 Caching runtime config for subsequent warmup",
|
||||
{
|
||||
model: config.model || "(none)",
|
||||
baseUrl: config.baseUrl || "(none)",
|
||||
apiKeySet: !!config.apiKey,
|
||||
apiProtocol: config.apiProtocol || "(none)",
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的 warmup 引擎销毁 + 清理逻辑。
|
||||
* 所有 tryReuse() 中的"不复用"分支都应调用此方法,避免重复代码。
|
||||
*/
|
||||
private async teardownWarmup(
|
||||
engine: AcpEngine,
|
||||
reason: string,
|
||||
traceData?: { projectId: string; engineType: string; reason: string },
|
||||
): Promise<null> {
|
||||
if (traceData) {
|
||||
firstTokenTrace.trace(
|
||||
"warmup.reuse.miss",
|
||||
{ projectId: traceData.projectId, engine: traceData.engineType },
|
||||
{ reason: traceData.reason },
|
||||
);
|
||||
}
|
||||
log.debug(`[EngineWarmup] teardown warmup: ${reason}`);
|
||||
engine.removeAllListeners();
|
||||
await engine.destroy().catch(() => {});
|
||||
this.engines.delete(WARMUP_KEY);
|
||||
this.configs.delete(WARMUP_KEY);
|
||||
this.rawMcpServers.delete(WARMUP_KEY);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试复用 warmup 引擎。成功时 re-key 为 projectId 并返回引擎;
|
||||
* 未就绪或已死时清理并返回 null。
|
||||
*
|
||||
* 复用限制:由于 updateConfig() 无法改变已启动进程的环境,因此:
|
||||
* - 仅当 warmup 的运行时配置(model/api/baseUrl/apiProtocol)与请求兼容时才复用
|
||||
* - 仅当 warmup MCP 与请求 MCP 语义完全兼容时才复用
|
||||
* - 不兼容时立即回退冷启动,避免首个 MCP 加载失败
|
||||
*
|
||||
* @param startTime 请求开始时间戳,用于计算节省的时间
|
||||
*/
|
||||
async tryReuse(
|
||||
projectId: string,
|
||||
effectiveConfig: AgentConfig,
|
||||
startTime?: number,
|
||||
): Promise<AcpEngine | null> {
|
||||
if (!this.engines.has(WARMUP_KEY)) return null;
|
||||
|
||||
const engine = this.engines.get(WARMUP_KEY)!;
|
||||
if (engine.isReady) {
|
||||
const savedTime = startTime ? Date.now() - startTime : 0;
|
||||
const warmupConfig = this.configs.get(WARMUP_KEY)!;
|
||||
|
||||
// 检查 model family 兼容性(qimingcode vs claude-code 等不能互转)
|
||||
const warmupEngineType = warmupConfig.engine || WARMUP_ENGINE_TYPE;
|
||||
const requestEngineType = effectiveConfig.engine;
|
||||
if (
|
||||
requestEngineType &&
|
||||
requestEngineType !== warmupEngineType &&
|
||||
requestEngineType !== "qimingcode" // qimingcode 是 warmup 的默认类型
|
||||
) {
|
||||
log.info(
|
||||
`[EngineWarmup] ⚠️ Engine type incompatible (warmup=${warmupEngineType}, request=${requestEngineType}), not reusing`,
|
||||
);
|
||||
return this.teardownWarmup(engine, "engine_type_incompatible", {
|
||||
projectId,
|
||||
engineType: requestEngineType,
|
||||
reason: "engine_type_incompatible",
|
||||
});
|
||||
}
|
||||
|
||||
const warmupSandboxPolicyFingerprint =
|
||||
(warmupConfig.env || {})[WARMUP_SANDBOX_POLICY_FINGERPRINT_FLAG] || "";
|
||||
const currentSandboxPolicyFingerprint =
|
||||
this.options?.getSandboxPolicyFingerprint?.() || "";
|
||||
if (currentSandboxPolicyFingerprint) {
|
||||
log.debug("[EngineWarmup] warmup sandbox policy compatibility check", {
|
||||
hasWarmupMarker: !!warmupSandboxPolicyFingerprint,
|
||||
warmupFingerprintDigest: warmupSandboxPolicyFingerprint
|
||||
? this.digest(warmupSandboxPolicyFingerprint)
|
||||
: "(none)",
|
||||
currentFingerprintDigest: this.digest(
|
||||
currentSandboxPolicyFingerprint,
|
||||
),
|
||||
compatible:
|
||||
warmupSandboxPolicyFingerprint === currentSandboxPolicyFingerprint,
|
||||
});
|
||||
}
|
||||
if (currentSandboxPolicyFingerprint && !warmupSandboxPolicyFingerprint) {
|
||||
log.info(
|
||||
"[EngineWarmup] ⚠️ Warmup sandbox policy marker missing, skipping reuse for safety",
|
||||
);
|
||||
return this.teardownWarmup(
|
||||
engine,
|
||||
"legacy_warmup_without_sandbox_policy_marker",
|
||||
{
|
||||
projectId,
|
||||
engineType: requestEngineType || warmupEngineType,
|
||||
reason: "legacy_warmup_without_sandbox_policy_marker",
|
||||
},
|
||||
);
|
||||
}
|
||||
if (
|
||||
currentSandboxPolicyFingerprint &&
|
||||
warmupSandboxPolicyFingerprint &&
|
||||
warmupSandboxPolicyFingerprint !== currentSandboxPolicyFingerprint
|
||||
) {
|
||||
log.info(
|
||||
"[EngineWarmup] ⚠️ Sandbox policy changed, skipping warmup reuse",
|
||||
{
|
||||
warmupFingerprintDigest: this.digest(
|
||||
warmupSandboxPolicyFingerprint,
|
||||
),
|
||||
currentFingerprintDigest: this.digest(
|
||||
currentSandboxPolicyFingerprint,
|
||||
),
|
||||
},
|
||||
);
|
||||
return this.teardownWarmup(engine, "sandbox_policy_incompatible", {
|
||||
projectId,
|
||||
engineType: requestEngineType || warmupEngineType,
|
||||
reason: "sandbox_policy_incompatible",
|
||||
});
|
||||
}
|
||||
|
||||
// Runtime config compatibility check.
|
||||
// updateConfig() cannot patch process env after spawn, so mismatched
|
||||
// model/auth/baseUrl/protocol must fallback to cold create.
|
||||
const runtimeMismatch: string[] = [];
|
||||
if (
|
||||
effectiveConfig.model &&
|
||||
effectiveConfig.model !== (warmupConfig.model || "")
|
||||
) {
|
||||
runtimeMismatch.push("model");
|
||||
}
|
||||
if (
|
||||
effectiveConfig.apiKey &&
|
||||
effectiveConfig.apiKey !== (warmupConfig.apiKey || "")
|
||||
) {
|
||||
runtimeMismatch.push("apiKey");
|
||||
}
|
||||
if (
|
||||
effectiveConfig.baseUrl &&
|
||||
effectiveConfig.baseUrl !== (warmupConfig.baseUrl || "")
|
||||
) {
|
||||
runtimeMismatch.push("baseUrl");
|
||||
}
|
||||
if (
|
||||
effectiveConfig.apiProtocol &&
|
||||
effectiveConfig.apiProtocol !== (warmupConfig.apiProtocol || "")
|
||||
) {
|
||||
runtimeMismatch.push("apiProtocol");
|
||||
}
|
||||
|
||||
if (runtimeMismatch.length > 0) {
|
||||
const requestMcp = effectiveConfig.mcpServers || {};
|
||||
const requestMcpKeys = Object.keys(requestMcp).sort();
|
||||
// 缓存本次请求的运行时配置,供后续 warmup refill 使用
|
||||
this.cacheRuntimeConfig(effectiveConfig);
|
||||
log.info(
|
||||
`[EngineWarmup] ⚠️ Runtime config incompatible, not reusing warmup (${runtimeMismatch.join(",")})`,
|
||||
{
|
||||
warmupModel: warmupConfig.model || "(none)",
|
||||
requestModel: effectiveConfig.model || "(none)",
|
||||
warmupBaseUrl: warmupConfig.baseUrl || "(none)",
|
||||
requestBaseUrl: effectiveConfig.baseUrl || "(none)",
|
||||
warmupApiKeySet: !!warmupConfig.apiKey,
|
||||
requestApiKeySet: !!effectiveConfig.apiKey,
|
||||
warmupApiProtocol: warmupConfig.apiProtocol || "(none)",
|
||||
requestApiProtocol: effectiveConfig.apiProtocol || "(none)",
|
||||
},
|
||||
);
|
||||
this.lastMcpServers = requestMcpKeys.length > 0 ? requestMcp : null;
|
||||
return this.teardownWarmup(engine, "runtime_config_incompatible", {
|
||||
projectId,
|
||||
engineType: requestEngineType || warmupEngineType,
|
||||
reason: "runtime_config_incompatible",
|
||||
});
|
||||
}
|
||||
|
||||
// Sandbox mode compatibility check.
|
||||
// The sandbox mode (strict/compat/permissive) is baked into the process wrapper
|
||||
// at spawn time (qiming-sandbox-helper.exe serve --write-restricted).
|
||||
// updateConfig() cannot change the process-level sandbox after spawn,
|
||||
// so mismatched modes must fallback to cold create.
|
||||
const warmupSandboxMode = engine.sandboxMode;
|
||||
if (!effectiveConfig.__sandboxMode) {
|
||||
log.debug(
|
||||
"[EngineWarmup] __sandboxMode not set on effectiveConfig, assuming compat",
|
||||
);
|
||||
}
|
||||
const requestSandboxMode = effectiveConfig.__sandboxMode ?? "compat";
|
||||
if (warmupSandboxMode !== requestSandboxMode) {
|
||||
log.info(
|
||||
`[EngineWarmup] ⚠️ Sandbox mode incompatible (warmup=${warmupSandboxMode}, request=${requestSandboxMode}), not reusing`,
|
||||
);
|
||||
this.lastMcpServers =
|
||||
Object.keys(effectiveConfig.mcpServers || {}).length > 0
|
||||
? effectiveConfig.mcpServers || null
|
||||
: null;
|
||||
return this.teardownWarmup(engine, "sandbox_mode_incompatible", {
|
||||
projectId,
|
||||
engineType: requestEngineType || warmupEngineType,
|
||||
reason: "sandbox_mode_incompatible",
|
||||
});
|
||||
}
|
||||
|
||||
const warmupMcp = warmupConfig.mcpServers || {};
|
||||
const requestMcp = effectiveConfig.mcpServers || {};
|
||||
const warmupMcpKeys = Object.keys(warmupMcp).sort();
|
||||
const requestMcpKeys = Object.keys(requestMcp).sort();
|
||||
|
||||
// 兼容旧 warmup 进程:
|
||||
// 历史版本 warmup 进程未注入 MCP(仅 permission),即使 mcpServers 配置看起来一致,
|
||||
// 实际运行时仍可能出现 MCP.tools()=0。检测到缺少新标记时强制放弃复用,回退冷启动。
|
||||
const warmupMcpReady =
|
||||
(warmupConfig.env || {})[WARMUP_MCP_READY_FLAG] === "1";
|
||||
if (requestMcpKeys.length > 0 && !warmupMcpReady) {
|
||||
log.info(
|
||||
"[EngineWarmup] ⚠️ Legacy warmup process detected (missing MCP ready marker), not reusing",
|
||||
{ requestMcpKeys, warmupMcpKeys },
|
||||
);
|
||||
this.lastMcpServers = requestMcp;
|
||||
return this.teardownWarmup(
|
||||
engine,
|
||||
"legacy_warmup_without_mcp_ready_marker",
|
||||
{
|
||||
projectId,
|
||||
engineType: requestEngineType || warmupEngineType,
|
||||
reason: "legacy_warmup_without_mcp_ready_marker",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const { compatible, reason, detail } = this.checkMcpCompatibility(
|
||||
warmupMcp,
|
||||
requestMcp,
|
||||
);
|
||||
|
||||
if (!compatible) {
|
||||
log.info(
|
||||
`[EngineWarmup] ⚠️ MCP config incompatible, not reusing warmup (${reason})`,
|
||||
);
|
||||
if (detail) {
|
||||
log.debug("[EngineWarmup] MCP compatibility diff details:", detail);
|
||||
}
|
||||
// 缓存本次请求的 MCP 配置,供 respawn() 使用
|
||||
if (requestMcpKeys.length > 0) {
|
||||
this.lastMcpServers = requestMcp;
|
||||
log.debug(
|
||||
`[EngineWarmup] 💾 Cached MCP config for respawn: ${requestMcpKeys.join(",")}`,
|
||||
);
|
||||
} else {
|
||||
this.lastMcpServers = null;
|
||||
}
|
||||
return this.teardownWarmup(engine, `mcp_incompatible: ${reason}`, {
|
||||
projectId,
|
||||
engineType: requestEngineType || warmupEngineType,
|
||||
reason: `mcp_incompatible: ${reason}`,
|
||||
});
|
||||
}
|
||||
|
||||
log.info(
|
||||
`[EngineWarmup] ♻️ Reusing warmup engine for project: ${projectId} (saved ~${savedTime}ms, MCP: ${requestMcpKeys.join(",") || "(none)"})`,
|
||||
);
|
||||
// 复用成功时也缓存运行时配置,确保 refill warmup 有一致的配置
|
||||
this.cacheRuntimeConfig(effectiveConfig);
|
||||
firstTokenTrace.trace(
|
||||
"warmup.reuse.hit",
|
||||
{ projectId, engine: warmupEngineType },
|
||||
{
|
||||
savedMs: savedTime,
|
||||
mcpKeys: requestMcpKeys,
|
||||
},
|
||||
);
|
||||
this.lastMcpServers = requestMcpKeys.length > 0 ? requestMcp : null;
|
||||
|
||||
this.engines.delete(WARMUP_KEY);
|
||||
this.configs.delete(WARMUP_KEY);
|
||||
this.rawMcpServers.delete(WARMUP_KEY);
|
||||
this.engines.set(projectId, engine);
|
||||
this.configs.set(projectId, effectiveConfig);
|
||||
return engine;
|
||||
}
|
||||
|
||||
// 未就绪或已死,清理
|
||||
// 缓存运行时配置,确保后续 refill warmup 有正确的 model/apiKey/baseUrl/apiProtocol
|
||||
this.cacheRuntimeConfig(effectiveConfig);
|
||||
return this.teardownWarmup(engine, "warmup_not_ready", {
|
||||
projectId,
|
||||
engineType: effectiveConfig.engine || WARMUP_ENGINE_TYPE,
|
||||
reason: "warmup_not_ready",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新启动 warmup 引擎(在会话结束后调用)。
|
||||
* 使用防抖机制避免短时间内多次调用。
|
||||
* 使用上次会话缓存的 MCP 配置(如果有),确保 warmup 有正确的 MCP。
|
||||
*/
|
||||
respawn(
|
||||
baseConfig: AgentConfig | null,
|
||||
onEngineCreated: (engine: AcpEngine) => void,
|
||||
): void {
|
||||
if (!baseConfig || this.disposed) return;
|
||||
|
||||
// 清除之前的定时器
|
||||
if (this.respawnTimer) {
|
||||
clearTimeout(this.respawnTimer);
|
||||
}
|
||||
|
||||
this.respawnScheduled = true;
|
||||
|
||||
this.respawnTimer = setTimeout(() => {
|
||||
if (this.disposed) return;
|
||||
this.respawnScheduled = false;
|
||||
this.respawnTimer = null;
|
||||
|
||||
if (this.engines.has(WARMUP_KEY)) {
|
||||
log.debug(
|
||||
"[EngineWarmup] Warmup engine already exists, skipping re-warmup",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (this.engines.size > 0) {
|
||||
log.debug(
|
||||
`[EngineWarmup] ${this.engines.size} active engine(s) present, skipping re-warmup`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用缓存的 MCP 配置(如果有),确保 warmup 有正确的 MCP
|
||||
// 注意:lastMcpServers 来自上一次请求,如果全局配置已变更可能过期
|
||||
if (this.lastMcpServers) {
|
||||
log.debug(
|
||||
"[EngineWarmup] Warming up with cached MCP config (may be stale; first request will validate)",
|
||||
);
|
||||
}
|
||||
const respawnConfig = this.lastMcpServers
|
||||
? { ...baseConfig, mcpServers: this.lastMcpServers }
|
||||
: baseConfig;
|
||||
|
||||
const mcpInfo = respawnConfig.mcpServers
|
||||
? `MCP: ${Object.keys(respawnConfig.mcpServers).join(",")}`
|
||||
: "MCP: (none)";
|
||||
log.info(
|
||||
`[EngineWarmup] 🔁 Session ended, re-warming qimingcode engine (${mcpInfo})...`,
|
||||
);
|
||||
|
||||
this.start(respawnConfig, onEngineCreated);
|
||||
}, RESPAWN_DELAY_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前 warmup 引擎状态(用于监控和调试)。
|
||||
*/
|
||||
getWarmupStatus(): {
|
||||
hasWarmup: boolean;
|
||||
isReady: boolean;
|
||||
engineCount: number;
|
||||
} {
|
||||
const warmupEngine = this.engines.get(WARMUP_KEY);
|
||||
return {
|
||||
hasWarmup: this.engines.has(WARMUP_KEY),
|
||||
isReady: warmupEngine?.isReady ?? false,
|
||||
engineCount: this.engines.size,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Main process engine services
|
||||
export { agentService, UnifiedAgentService } from './unifiedAgent';
|
||||
export type {
|
||||
AgentConfig,
|
||||
AgentEngineType,
|
||||
AcpSessionStatus,
|
||||
MessageWithParts,
|
||||
PromptOptions,
|
||||
CommandOptions,
|
||||
SdkSession,
|
||||
SessionStatus,
|
||||
ToolInfo,
|
||||
ProviderInfo,
|
||||
Message,
|
||||
Part,
|
||||
UserMessage,
|
||||
AssistantMessage,
|
||||
TextPart,
|
||||
ReasoningPart,
|
||||
FilePart,
|
||||
ToolPart,
|
||||
StepStartPart,
|
||||
StepFinishPart,
|
||||
SnapshotPart,
|
||||
PatchPart,
|
||||
TextPartInput,
|
||||
FilePartInput,
|
||||
FileDiff,
|
||||
} from './unifiedAgent';
|
||||
export { AcpEngine } from './acp/acpEngine';
|
||||
export {
|
||||
createAcpConnection,
|
||||
loadAcpSdk,
|
||||
resolveAcpBinary,
|
||||
type AcpConnectionConfig,
|
||||
type AcpConnectionResult,
|
||||
type AcpClientSideConnection,
|
||||
type AcpClientHandler,
|
||||
type AcpSessionUpdate,
|
||||
type AcpAgentMessageChunk,
|
||||
type AcpAgentThoughtChunk,
|
||||
type AcpToolCall,
|
||||
type AcpToolCallUpdate,
|
||||
type AcpSessionInfoUpdate,
|
||||
type AcpUsageUpdate,
|
||||
type AcpPermissionOption,
|
||||
type AcpPermissionOptionKind,
|
||||
type AcpPermissionRequest,
|
||||
type AcpPermissionResponse,
|
||||
type AcpEnvVariable,
|
||||
type AcpHttpHeader,
|
||||
type AcpMcpServer,
|
||||
type AcpSdkModule,
|
||||
} from './acp/acpClient';
|
||||
export { mapAgentCommand, resolveAgentEnv } from './agentHelpers';
|
||||
export {
|
||||
isEngineInstalledLocally,
|
||||
findEngineBinary,
|
||||
createIsolatedEnvironment,
|
||||
getEngineStatus,
|
||||
type AgentEngine,
|
||||
type EngineConfig,
|
||||
type EngineStatus,
|
||||
} from './engineManager';
|
||||
@@ -0,0 +1,400 @@
|
||||
import * as crypto from "crypto";
|
||||
import * as fs from "fs";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
import log from "electron-log";
|
||||
import { APP_DATA_DIR_NAME, LOGS_DIR_NAME } from "../../constants";
|
||||
|
||||
type TraceLevel = "basic" | "deep";
|
||||
|
||||
export interface FirstTokenTraceContext {
|
||||
requestId?: string;
|
||||
sessionId?: string;
|
||||
projectId?: string;
|
||||
engine?: string;
|
||||
}
|
||||
|
||||
interface TraceEvent {
|
||||
ts: number;
|
||||
iso: string;
|
||||
stage: string;
|
||||
request_id?: string;
|
||||
session_id?: string;
|
||||
project_id?: string;
|
||||
engine?: string;
|
||||
since_request_start_ms?: number;
|
||||
since_prev_event_ms?: number;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface TraceRequestState {
|
||||
requestId: string;
|
||||
startAt: number;
|
||||
firstEventAt: number;
|
||||
projectId?: string;
|
||||
sessionId?: string;
|
||||
engine?: string;
|
||||
events: TraceEvent[];
|
||||
firstTokenReported: boolean;
|
||||
closed: boolean;
|
||||
}
|
||||
|
||||
const TRACE_TTL_MS = 30 * 60 * 1000;
|
||||
const TRACE_DIR_NAME = "first-token-trace";
|
||||
|
||||
function parseBool(v: string | undefined): boolean {
|
||||
if (!v) return false;
|
||||
const normalized = v.trim().toLowerCase();
|
||||
return normalized === "1" || normalized === "true" || normalized === "yes";
|
||||
}
|
||||
|
||||
function parseSample(v: string | undefined): number {
|
||||
if (!v) return 1;
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return 1;
|
||||
if (n <= 0) return 0;
|
||||
if (n >= 1) return 1;
|
||||
return n;
|
||||
}
|
||||
|
||||
function todayDateStr(): string {
|
||||
const d = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
function formatMs(v: number | undefined): string {
|
||||
if (v === undefined || !Number.isFinite(v)) return "N/A";
|
||||
return `${Math.max(0, Math.round(v))}ms`;
|
||||
}
|
||||
|
||||
function shortId(id: string | undefined): string {
|
||||
if (!id) return "(none)";
|
||||
return id.length <= 12 ? id : `${id.slice(0, 12)}...`;
|
||||
}
|
||||
|
||||
function safeGap(
|
||||
a: number | undefined,
|
||||
b: number | undefined,
|
||||
): number | undefined {
|
||||
if (a === undefined || b === undefined) return undefined;
|
||||
return Math.max(0, b - a);
|
||||
}
|
||||
|
||||
function safeStringify(v: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(v);
|
||||
} catch {
|
||||
return String(v);
|
||||
}
|
||||
}
|
||||
|
||||
export class FirstTokenTrace {
|
||||
private readonly enabled = parseBool(process.env.QIMING_TRACE_FIRST_TOKEN);
|
||||
private readonly level: TraceLevel =
|
||||
process.env.QIMING_TRACE_LEVEL?.toLowerCase() === "deep" ? "deep" : "basic";
|
||||
private readonly sampleRate = parseSample(process.env.QIMING_TRACE_SAMPLE);
|
||||
private readonly traceDir = path.join(
|
||||
os.homedir(),
|
||||
APP_DATA_DIR_NAME,
|
||||
LOGS_DIR_NAME,
|
||||
TRACE_DIR_NAME,
|
||||
);
|
||||
private readonly logFile =
|
||||
process.env.QIMING_TRACE_LOG_FILE ||
|
||||
path.join(this.traceDir, `trace.${todayDateStr()}.jsonl`);
|
||||
private readonly reportFile =
|
||||
process.env.QIMING_TRACE_REPORT_FILE ||
|
||||
path.join(this.traceDir, `waterfall.${todayDateStr()}.md`);
|
||||
private readonly qimingcodeLogDir = path.join(this.traceDir, "qimingcode");
|
||||
|
||||
private readonly requests = new Map<string, TraceRequestState>();
|
||||
private readonly sessionToRequest = new Map<string, string>();
|
||||
private readonly projectToRequest = new Map<string, string>();
|
||||
private readonly sampled = new Map<string, boolean>();
|
||||
|
||||
// 缓冲写入:累积 trace 行,达到阈值或超时时 flush
|
||||
private writeBuffer: string[] = [];
|
||||
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private static readonly FLUSH_SIZE = 50;
|
||||
private static readonly FLUSH_INTERVAL_MS = 2000;
|
||||
|
||||
// prune debounce:最多每秒执行一次
|
||||
private lastPruneAt = 0;
|
||||
private static readonly PRUNE_INTERVAL_MS = 1000;
|
||||
|
||||
constructor() {
|
||||
if (!this.enabled) return;
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(this.logFile), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(this.reportFile), { recursive: true });
|
||||
if (this.level === "deep") {
|
||||
fs.mkdirSync(this.qimingcodeLogDir, { recursive: true });
|
||||
}
|
||||
this.enqueueWrite(
|
||||
safeStringify({
|
||||
ts: Date.now(),
|
||||
iso: new Date().toISOString(),
|
||||
stage: "trace.config",
|
||||
data: {
|
||||
enabled: this.enabled,
|
||||
level: this.level,
|
||||
sampleRate: this.sampleRate,
|
||||
logFile: this.logFile,
|
||||
reportFile: this.reportFile,
|
||||
qimingcodeLogDir:
|
||||
this.level === "deep" ? this.qimingcodeLogDir : "(disabled)",
|
||||
},
|
||||
}),
|
||||
);
|
||||
log.info(
|
||||
`[FirstTokenTrace] enabled level=${this.level} sample=${this.sampleRate} log=${this.logFile} report=${this.reportFile}`,
|
||||
);
|
||||
} catch (e) {
|
||||
log.warn("[FirstTokenTrace] init failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
isEnabled(): boolean {
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
isDeepMode(): boolean {
|
||||
return this.enabled && this.level === "deep";
|
||||
}
|
||||
|
||||
getLogFilePath(): string | null {
|
||||
return this.enabled ? this.logFile : null;
|
||||
}
|
||||
|
||||
getReportFilePath(): string | null {
|
||||
return this.enabled ? this.reportFile : null;
|
||||
}
|
||||
|
||||
getQimingCodeLogDir(): string | null {
|
||||
if (!this.isDeepMode()) return null;
|
||||
return this.qimingcodeLogDir;
|
||||
}
|
||||
|
||||
trace(
|
||||
stage: string,
|
||||
context: FirstTokenTraceContext = {},
|
||||
data?: Record<string, unknown>,
|
||||
): void {
|
||||
if (!this.enabled) return;
|
||||
const now = Date.now();
|
||||
this.maybePrune(now);
|
||||
|
||||
const requestId =
|
||||
context.requestId ||
|
||||
(context.sessionId
|
||||
? this.sessionToRequest.get(context.sessionId)
|
||||
: undefined) ||
|
||||
(context.projectId
|
||||
? this.projectToRequest.get(context.projectId)
|
||||
: undefined);
|
||||
|
||||
if (requestId && !this.shouldSample(requestId)) return;
|
||||
|
||||
let state: TraceRequestState | undefined;
|
||||
if (requestId) {
|
||||
state = this.requests.get(requestId);
|
||||
if (!state) {
|
||||
state = {
|
||||
requestId,
|
||||
startAt: now,
|
||||
firstEventAt: now,
|
||||
projectId: context.projectId,
|
||||
sessionId: context.sessionId,
|
||||
engine: context.engine,
|
||||
events: [],
|
||||
firstTokenReported: false,
|
||||
closed: false,
|
||||
};
|
||||
this.requests.set(requestId, state);
|
||||
}
|
||||
if (context.projectId) {
|
||||
state.projectId = context.projectId;
|
||||
this.projectToRequest.set(context.projectId, requestId);
|
||||
}
|
||||
if (context.sessionId) {
|
||||
state.sessionId = context.sessionId;
|
||||
this.sessionToRequest.set(context.sessionId, requestId);
|
||||
}
|
||||
if (context.engine) state.engine = context.engine;
|
||||
}
|
||||
|
||||
const previous = state?.events[state.events.length - 1];
|
||||
const event: TraceEvent = {
|
||||
ts: now,
|
||||
iso: new Date(now).toISOString(),
|
||||
stage,
|
||||
request_id: requestId,
|
||||
session_id: context.sessionId || state?.sessionId,
|
||||
project_id: context.projectId || state?.projectId,
|
||||
engine: context.engine || state?.engine,
|
||||
since_request_start_ms: state ? now - state.startAt : undefined,
|
||||
since_prev_event_ms: previous ? now - previous.ts : undefined,
|
||||
data,
|
||||
};
|
||||
|
||||
if (state) {
|
||||
state.events.push(event);
|
||||
}
|
||||
this.enqueueWrite(safeStringify(event));
|
||||
|
||||
if (stage === "sse.first_token" && state && !state.firstTokenReported) {
|
||||
state.firstTokenReported = true;
|
||||
this.appendWaterfall(state, "first_token");
|
||||
}
|
||||
if (
|
||||
(stage === "sse.end_turn" ||
|
||||
stage === "acp.prompt.completed" ||
|
||||
stage === "chat.failed") &&
|
||||
state
|
||||
) {
|
||||
state.closed = true;
|
||||
if (!state.firstTokenReported) {
|
||||
this.appendWaterfall(state, "closed_without_first_token");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private shouldSample(requestId: string): boolean {
|
||||
if (this.sampleRate >= 1) return true;
|
||||
if (this.sampleRate <= 0) return false;
|
||||
const cached = this.sampled.get(requestId);
|
||||
if (cached !== undefined) return cached;
|
||||
const md5 = crypto.createHash("md5").update(requestId).digest("hex");
|
||||
// 取 MD5 前 8 个 hex(32 bit)作为 [0, 2^32-1] 的整数,再除以 2^32 映射到 [0, 1)。
|
||||
// 必须用 2^32 作除数:若用 2^32-1,则最大哈希会得到 1.0,落在 [0,1) 之外且破坏均匀性。
|
||||
const v = parseInt(md5.slice(0, 8), 16) / 0x100000000;
|
||||
const keep = v < this.sampleRate;
|
||||
this.sampled.set(requestId, keep);
|
||||
return keep;
|
||||
}
|
||||
|
||||
private enqueueWrite(line: string): void {
|
||||
this.writeBuffer.push(line);
|
||||
if (this.writeBuffer.length >= FirstTokenTrace.FLUSH_SIZE) {
|
||||
this.flushBuffer();
|
||||
return;
|
||||
}
|
||||
if (!this.flushTimer) {
|
||||
this.flushTimer = setTimeout(
|
||||
() => this.flushBuffer(),
|
||||
FirstTokenTrace.FLUSH_INTERVAL_MS,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private flushBuffer(): void {
|
||||
if (this.flushTimer) {
|
||||
clearTimeout(this.flushTimer);
|
||||
this.flushTimer = null;
|
||||
}
|
||||
if (this.writeBuffer.length === 0) return;
|
||||
const batch = this.writeBuffer.join("\n") + "\n";
|
||||
this.writeBuffer = [];
|
||||
fs.writeFile(
|
||||
this.logFile,
|
||||
batch,
|
||||
{ flag: "a", encoding: "utf8" },
|
||||
(err) => {
|
||||
if (err) log.warn("[FirstTokenTrace] flush trace failed:", err);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private maybePrune(now: number): void {
|
||||
if (now - this.lastPruneAt < FirstTokenTrace.PRUNE_INTERVAL_MS) return;
|
||||
this.lastPruneAt = now;
|
||||
for (const [requestId, state] of this.requests.entries()) {
|
||||
if (now - state.firstEventAt <= TRACE_TTL_MS) continue;
|
||||
this.requests.delete(requestId);
|
||||
this.sampled.delete(requestId);
|
||||
if (state.sessionId) this.sessionToRequest.delete(state.sessionId);
|
||||
if (state.projectId) this.projectToRequest.delete(state.projectId);
|
||||
}
|
||||
}
|
||||
|
||||
private findStageAt(
|
||||
state: TraceRequestState,
|
||||
stage: string,
|
||||
): number | undefined {
|
||||
const evt = state.events.find((e) => e.stage === stage);
|
||||
return evt?.ts;
|
||||
}
|
||||
|
||||
private appendWaterfall(state: TraceRequestState, trigger: string): void {
|
||||
const events = state.events.filter(
|
||||
(e) =>
|
||||
!e.stage.startsWith("acp.stdout.") &&
|
||||
!e.stage.startsWith("acp.stderr.") &&
|
||||
!e.stage.startsWith("trace.config"),
|
||||
);
|
||||
if (events.length === 0) return;
|
||||
|
||||
const startTs = this.findStageAt(state, "chat.received") ?? events[0].ts;
|
||||
const firstTokenTs = this.findStageAt(state, "sse.first_token");
|
||||
const chatEndTs = this.findStageAt(state, "chat.response.sent");
|
||||
const promptSentTs = this.findStageAt(state, "acp.prompt.sent");
|
||||
|
||||
const segmentA = safeGap(startTs, chatEndTs);
|
||||
const segmentB = safeGap(promptSentTs, firstTokenTs);
|
||||
const total = safeGap(startTs, firstTokenTs);
|
||||
|
||||
let bottleneckName = "(none)";
|
||||
let bottleneckMs = 0;
|
||||
for (let i = 1; i < events.length; i++) {
|
||||
const gap = events[i].ts - events[i - 1].ts;
|
||||
if (gap > bottleneckMs) {
|
||||
bottleneckMs = gap;
|
||||
bottleneckName = `${events[i - 1].stage} -> ${events[i].stage}`;
|
||||
}
|
||||
}
|
||||
|
||||
const warmupHit = events.some((e) => e.stage === "warmup.reuse.hit");
|
||||
const warmupMiss = events.find((e) => e.stage === "warmup.reuse.miss");
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(
|
||||
`## ${new Date().toISOString()} rid=${shortId(state.requestId)} session=${shortId(
|
||||
state.sessionId,
|
||||
)}`,
|
||||
);
|
||||
lines.push("");
|
||||
lines.push(`- trigger: \`${trigger}\``);
|
||||
lines.push(`- request_id: \`${state.requestId}\``);
|
||||
lines.push(`- project_id: \`${state.projectId || "(none)"}\``);
|
||||
lines.push(`- session_id: \`${state.sessionId || "(none)"}\``);
|
||||
lines.push(`- engine: \`${state.engine || "(unknown)"}\``);
|
||||
lines.push(`- warmup: \`${warmupHit ? "hit" : "miss"}\``);
|
||||
if (warmupMiss?.data?.reason) {
|
||||
lines.push(`- warmup_miss_reason: \`${String(warmupMiss.data.reason)}\``);
|
||||
}
|
||||
lines.push(`- segment_A_chat: \`${formatMs(segmentA)}\``);
|
||||
lines.push(`- segment_B_prompt_to_first_token: \`${formatMs(segmentB)}\``);
|
||||
lines.push(`- total_first_token: \`${formatMs(total)}\``);
|
||||
lines.push(
|
||||
`- bottleneck_gap: \`${formatMs(bottleneckMs)}\` at \`${bottleneckName}\``,
|
||||
);
|
||||
lines.push("");
|
||||
lines.push("| Stage | Time | +From Start | +From Prev |");
|
||||
lines.push("| --- | --- | --- | --- |");
|
||||
for (const evt of events) {
|
||||
lines.push(
|
||||
`| ${evt.stage} | ${evt.iso} | ${formatMs(evt.ts - startTs)} | ${formatMs(evt.since_prev_event_ms)} |`,
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
try {
|
||||
fs.appendFileSync(this.reportFile, `${lines.join("\n")}\n`, "utf8");
|
||||
} catch (e) {
|
||||
log.warn("[FirstTokenTrace] append report failed:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const firstTokenTrace = new FirstTokenTrace();
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// 使用 vi.hoisted 创建共享 mock,确保测试中可访问
|
||||
const mockPerfLogger = vi.hoisted(() => ({
|
||||
info: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../bootstrap/logConfig", () => ({
|
||||
getPerfLogger: () => mockPerfLogger,
|
||||
}));
|
||||
|
||||
// 在 mock 设置后再 import,确保使用 mock 版本
|
||||
import { perfEmitter } from "./perfEmitter";
|
||||
|
||||
describe("perfEmitter", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("duration", () => {
|
||||
it("should format duration log with ms suffix", () => {
|
||||
perfEmitter.duration("test.stage", 123);
|
||||
|
||||
expect(mockPerfLogger.info).toHaveBeenCalledTimes(1);
|
||||
expect(mockPerfLogger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining("[PERF] test.stage: 123ms"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should include extra fields when provided", () => {
|
||||
perfEmitter.duration("test.stage", 100, { key: "value", count: 42 });
|
||||
|
||||
expect(mockPerfLogger.info).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/test\.stage: 100ms\s+key=value\s+count=42/),
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle boolean extra values", () => {
|
||||
perfEmitter.duration("test.stage", 50, { enabled: true, active: false });
|
||||
|
||||
expect(mockPerfLogger.info).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/enabled=true\s+active=false/),
|
||||
);
|
||||
});
|
||||
|
||||
it("should skip undefined extra values", () => {
|
||||
perfEmitter.duration("test.stage", 50, { key: "value", skip: undefined });
|
||||
|
||||
const call = mockPerfLogger.info.mock.calls[0][0];
|
||||
expect(call).toContain("key=value");
|
||||
expect(call).not.toContain("skip");
|
||||
});
|
||||
});
|
||||
|
||||
describe("point", () => {
|
||||
it("should format point log without duration", () => {
|
||||
perfEmitter.point("checkpoint.reached");
|
||||
|
||||
expect(mockPerfLogger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining("[PERF] checkpoint.reached"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should include extra fields for point logs", () => {
|
||||
perfEmitter.point("checkpoint.reached", { stage: "init" });
|
||||
|
||||
expect(mockPerfLogger.info).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/checkpoint\.reached\s+stage=init/),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatExtra (internal)", () => {
|
||||
it("should handle empty extra object", () => {
|
||||
perfEmitter.duration("test", 0, {});
|
||||
|
||||
const call = mockPerfLogger.info.mock.calls[0][0];
|
||||
// 无 extra 字段,不应有额外的空格+key=value
|
||||
expect(call).toMatch(/\[PERF\] test: 0ms$/);
|
||||
});
|
||||
|
||||
it("should handle null values", () => {
|
||||
perfEmitter.duration("test", 0, { key: null });
|
||||
|
||||
const call = mockPerfLogger.info.mock.calls[0][0];
|
||||
expect(call).toContain("key=");
|
||||
});
|
||||
|
||||
it("should handle multiple extra fields", () => {
|
||||
perfEmitter.duration("test", 1, { a: "1", b: "2", c: "3" });
|
||||
|
||||
const call = mockPerfLogger.info.mock.calls[0][0];
|
||||
expect(call).toContain("a=1");
|
||||
expect(call).toContain("b=2");
|
||||
expect(call).toContain("c=3");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { getPerfLogger } from "../../../bootstrap/logConfig";
|
||||
|
||||
type PerfExtra = Record<string, string | number | boolean | null | undefined>;
|
||||
|
||||
function formatExtra(extra?: PerfExtra): string {
|
||||
if (!extra) return "";
|
||||
const entries = Object.entries(extra).filter(([, v]) => v !== undefined);
|
||||
if (!entries.length) return "";
|
||||
return (
|
||||
" " +
|
||||
entries
|
||||
.map(([k, v]) => `${k}=${typeof v === "boolean" ? String(v) : (v ?? "")}`)
|
||||
.join(" ")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计时器:创建时记录起始时间,end() 时自动计算并输出耗时。
|
||||
* 用法:const timer = perfEmitter.start(); ... ; timer.end('stage.name', { extra });
|
||||
*/
|
||||
export interface PerfTimer {
|
||||
end(name: string, extra?: PerfExtra): number;
|
||||
}
|
||||
|
||||
/**
|
||||
* PERF 输出统一入口,降低业务代码对具体日志实现的侵入。
|
||||
*/
|
||||
export const perfEmitter = {
|
||||
duration(name: string, ms: number, extra?: PerfExtra): void {
|
||||
getPerfLogger().info(`[PERF] ${name}: ${ms}ms${formatExtra(extra)}`);
|
||||
},
|
||||
|
||||
point(name: string, extra?: PerfExtra): void {
|
||||
getPerfLogger().info(`[PERF] ${name}${formatExtra(extra)}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建计时器,后续调用 end() 时自动输出耗时。
|
||||
* 示例:
|
||||
* const t = perfEmitter.start();
|
||||
* await doSomething();
|
||||
* t.end('stage.name', { key: 'value' });
|
||||
*/
|
||||
start(): PerfTimer {
|
||||
const t0 = Date.now();
|
||||
return {
|
||||
end(name: string, extra?: PerfExtra): number {
|
||||
const ms = Date.now() - t0;
|
||||
getPerfLogger().info(`[PERF] ${name}: ${ms}ms${formatExtra(extra)}`);
|
||||
return ms;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { SandboxPolicy } from "@shared/types/sandbox";
|
||||
|
||||
const DEFAULT_SANDBOX_POLICY_FINGERPRINT_INPUT = {
|
||||
enabled: false,
|
||||
backend: "auto",
|
||||
mode: "compat",
|
||||
autoFallback: "startup-only",
|
||||
windowsMode: "workspace-write",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Build a stable sandbox policy fingerprint string for warmup compatibility checks.
|
||||
* Keep this small and deterministic so different call sites can compare safely.
|
||||
*/
|
||||
export function buildSandboxPolicyFingerprint(
|
||||
policy: Partial<SandboxPolicy> | null | undefined,
|
||||
): string {
|
||||
const normalized = {
|
||||
enabled:
|
||||
typeof policy?.enabled === "boolean"
|
||||
? policy.enabled
|
||||
: DEFAULT_SANDBOX_POLICY_FINGERPRINT_INPUT.enabled,
|
||||
backend:
|
||||
policy?.backend ?? DEFAULT_SANDBOX_POLICY_FINGERPRINT_INPUT.backend,
|
||||
mode: policy?.mode ?? DEFAULT_SANDBOX_POLICY_FINGERPRINT_INPUT.mode,
|
||||
autoFallback:
|
||||
policy?.autoFallback ??
|
||||
DEFAULT_SANDBOX_POLICY_FINGERPRINT_INPUT.autoFallback,
|
||||
windowsMode:
|
||||
policy?.windowsMode ??
|
||||
DEFAULT_SANDBOX_POLICY_FINGERPRINT_INPUT.windowsMode,
|
||||
};
|
||||
return JSON.stringify(normalized);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 共享引擎类型定义
|
||||
*
|
||||
* 从 unifiedAgent.ts 提取,避免 engineWarmup.ts ↔ unifiedAgent.ts 的循环 import。
|
||||
*/
|
||||
|
||||
export type AgentEngineType = "qimingcode" | "claude-code";
|
||||
|
||||
export interface AgentConfig {
|
||||
engine: AgentEngineType;
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
apiProtocol?: string; // 'anthropic' or 'openai' - API protocol to use
|
||||
workspaceDir: string;
|
||||
hostname?: string;
|
||||
port?: number;
|
||||
timeout?: number;
|
||||
engineBinaryPath?: string;
|
||||
env?: Record<string, string>;
|
||||
mcpServers?: Record<
|
||||
string,
|
||||
| { command: string; args: string[]; env?: Record<string, string> }
|
||||
| { url: string; type?: "http" | "sse" }
|
||||
>;
|
||||
permissionMode?: "default" | "acceptEdits" | "bypassPermissions";
|
||||
systemPrompt?: string;
|
||||
purpose?: "engine";
|
||||
/** @internal Sandbox strictness mode injected by UnifiedAgentService for warmup compatibility check. */
|
||||
__sandboxMode?: string;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user