chore: initialize qiming workspace repository

This commit is contained in:
Codex
2026-05-29 14:22:48 +08:00
commit bfd67a0f2c
10750 changed files with 1885711 additions and 0 deletions

View File

@@ -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);
});
});

View File

@@ -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,
});
});
});

View File

@@ -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;
}
}
}

View File

@@ -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");
});
});

View File

@@ -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;
}

View File

@@ -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',
});
});
});
});

View File

@@ -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;
}

View File

@@ -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);
});
});
});

View File

@@ -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;
}

View File

@@ -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,
};

View File

@@ -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,
};
}
}

View File

@@ -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';

View File

@@ -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 个 hex32 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();

View File

@@ -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");
});
});
});

View File

@@ -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;
},
};
},
};

View File

@@ -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);
}

View File

@@ -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