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,283 @@
/**
* 单元测试: logRedact
*
* 测试日志脱敏逻辑
*/
import { describe, it, expect } from 'vitest';
import { redactForLog, redactStringForLog } from './logRedact';
describe('logRedact', () => {
describe('redactForLog', () => {
describe('基本脱敏', () => {
it('should mask api_key', () => {
const input = { api_key: 'ak-1234567890abcdef' };
const result = redactForLog(input) as Record<string, string>;
expect(result.api_key).toBe('ak-12345...');
expect(result.api_key).not.toContain('90abcdef');
});
it('should mask token', () => {
const input = { token: 'my-secret-token-12345' };
const result = redactForLog(input) as Record<string, string>;
expect(result.token).toContain('...');
expect(result.token).not.toContain('secret');
});
it('should mask password (long value shows prefix)', () => {
const input = { password: 'supersecretpassword' };
const result = redactForLog(input) as Record<string, string>;
// 长值显示前缀 + "..."
expect(result.password).toContain('...');
expect(result.password).not.toContain('secretpassword');
});
it('should mask short values as ***', () => {
const input = { api_key: 'short' };
const result = redactForLog(input) as Record<string, string>;
expect(result.api_key).toBe('***');
});
});
describe('模式匹配', () => {
it('should match *_API_KEY pattern', () => {
const input = { CUSTOM_API_KEY: 'ak-custom-key-12345678' };
const result = redactForLog(input) as Record<string, string>;
expect(result.CUSTOM_API_KEY).toContain('...');
});
it('should match *_TOKEN pattern', () => {
const input = { SESSION_TOKEN: 'session-token-abcdefg' };
const result = redactForLog(input) as Record<string, string>;
expect(result.SESSION_TOKEN).toContain('...');
});
it('should match *_SECRET pattern', () => {
const input = { APP_SECRET: 'app-secret-value' };
const result = redactForLog(input) as Record<string, string>;
expect(result.APP_SECRET).toContain('...');
});
});
describe('特殊键名', () => {
it('should mask anthropic_api_key', () => {
const input = { anthropic_api_key: 'sk-ant-1234567890' };
const result = redactForLog(input) as Record<string, string>;
expect(result.anthropic_api_key).toContain('...');
});
it('should mask openai_api_key', () => {
const input = { openai_api_key: 'sk-openai-1234567890' };
const result = redactForLog(input) as Record<string, string>;
expect(result.openai_api_key).toContain('...');
});
it('should mask amap_maps_api_key', () => {
const input = { amap_maps_api_key: 'amap-key-12345678' };
const result = redactForLog(input) as Record<string, string>;
expect(result.amap_maps_api_key).toContain('...');
});
});
describe('非字符串值处理', () => {
it('should handle null value', () => {
const input = { api_key: null };
const result = redactForLog(input) as Record<string, null>;
expect(result.api_key).toBeNull();
});
it('should handle undefined value (treated as *** for sensitive key)', () => {
const input = { api_key: undefined };
const result = redactForLog(input) as Record<string, unknown>;
// 敏感键的 undefined 值被替换为 '***'
expect(result.api_key).toBe('***');
});
it('should handle number value as ***', () => {
const input = { api_key: 12345 };
const result = redactForLog(input) as Record<string, unknown>;
expect(result.api_key).toBe('***');
});
it('should handle boolean value as ***', () => {
const input = { api_key: true };
const result = redactForLog(input) as Record<string, unknown>;
expect(result.api_key).toBe('***');
});
it('should handle nested object', () => {
const input = { env: { api_key: 'nested-key-12345678' } };
const result = redactForLog(input) as Record<string, unknown>;
const env = result.env as Record<string, string>;
expect(env.api_key).toContain('...');
});
});
describe('数组和嵌套', () => {
it('should handle array', () => {
const input = [
{ api_key: 'key1-12345678' },
{ api_key: 'key2-12345678' },
];
const result = redactForLog(input) as Array<Record<string, string>>;
expect(result[0].api_key).toContain('...');
expect(result[1].api_key).toContain('...');
});
it('should handle deeply nested object', () => {
const input = {
level1: {
level2: {
api_key: 'deep-key-12345678',
},
},
};
const result = redactForLog(input) as Record<string, unknown>;
const level1 = result.level1 as Record<string, unknown>;
const level2 = level1.level2 as Record<string, string>;
expect(level2.api_key).toContain('...');
});
});
describe('不修改原对象', () => {
it('should not modify original object', () => {
const input = { api_key: 'original-key-12345678' };
const originalValue = input.api_key;
redactForLog(input);
expect(input.api_key).toBe(originalValue);
});
});
describe('非敏感键保留', () => {
it('should preserve non-sensitive keys', () => {
const input = {
user_id: 'user123',
project_id: 'proj456',
api_key: 'secret-key-12345678',
};
const result = redactForLog(input) as Record<string, unknown>;
expect(result.user_id).toBe('user123');
expect(result.project_id).toBe('proj456');
expect(result.api_key).toContain('...');
});
});
describe('原始类型', () => {
it('should return null as-is', () => {
expect(redactForLog(null)).toBeNull();
});
it('should return undefined as-is', () => {
expect(redactForLog(undefined)).toBeUndefined();
});
it('should return string as-is', () => {
expect(redactForLog('hello')).toBe('hello');
});
it('should return number as-is', () => {
expect(redactForLog(42)).toBe(42);
});
});
});
describe('redactStringForLog', () => {
describe('JSON 键值对', () => {
it('should mask api_key in JSON string', () => {
const input = '{"api_key": "ak-1234567890abcdef"}';
const result = redactStringForLog(input);
expect(result).toContain('ak-12345...');
expect(result).not.toContain('90abcdef');
});
it('should mask ANTHROPIC_API_KEY in JSON string', () => {
const input = '{"ANTHROPIC_API_KEY": "sk-ant-1234567890"}';
const result = redactStringForLog(input);
expect(result).toContain('...');
});
it('should be case-insensitive', () => {
const input = '{"API_KEY": "ak-1234567890"}';
const result = redactStringForLog(input);
expect(result).toContain('...');
});
});
describe('URL 参数', () => {
it('should mask ak= in URL', () => {
const input = 'https://example.com/api?ak=my-secret-key';
const result = redactStringForLog(input);
expect(result).toContain('ak=***');
expect(result).not.toContain('my-secret-key');
});
});
describe('API key 前缀', () => {
it('should mask ak- prefix', () => {
const input = 'key: ak-1234567890abcdef';
const result = redactStringForLog(input);
expect(result).toBe('key: ak-12345678...');
});
it('should mask sk- prefix', () => {
const input = 'key: sk-1234567890abcdef';
const result = redactStringForLog(input);
expect(result).toBe('key: sk-12345678...');
});
it('should mask sak- prefix', () => {
const input = 'key: sak-1234567890abcdef';
const result = redactStringForLog(input);
// sak- 前缀保留 4 字符sak-+ 8 字符 = 12 字符
expect(result).toBe('key: sak-12345678...');
});
it('should mask pk- prefix', () => {
const input = 'key: pk-1234567890abcdef';
const result = redactStringForLog(input);
expect(result).toBe('key: pk-12345678...');
});
it('should mask Bearer token', () => {
const input = 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9';
const result = redactStringForLog(input);
expect(result).toContain('Bearer eyJhbGci...');
expect(result).not.toContain('IkpXVCJ9');
});
});
describe('边界情况', () => {
it('should handle non-string input', () => {
expect(redactStringForLog(null as any)).toBe('null');
expect(redactStringForLog(undefined as any)).toBe('undefined');
expect(redactStringForLog(123 as any)).toBe('123');
});
it('should handle empty string', () => {
expect(redactStringForLog('')).toBe('');
});
it('should handle string without sensitive data', () => {
const input = '{"user_id": "123", "name": "test"}';
expect(redactStringForLog(input)).toBe(input);
});
it('should handle short API key (<=8 chars)', () => {
const input = 'key: ak-short';
// 短 key 不匹配 ak-[a-zA-Z0-9]{8} 模式
expect(redactStringForLog(input)).toBe('key: ak-short');
});
});
describe('多个匹配', () => {
it('should mask multiple keys', () => {
const input = '{"api_key": "ak-1111111111", "token": "sk-2222222222"}';
const result = redactStringForLog(input);
// api_key 被 JSON 键匹配处理,保留 min(8, floor(12/2))=6 字符
expect(result).toContain('ak-111...');
// token 被 sk- 前缀正则匹配,保留 sk- 后 8 字符
expect(result).toContain('sk-22222222...');
});
});
});
});

View File

@@ -0,0 +1,121 @@
/**
* 日志脱敏:避免将 api_key、token、agent_config.env 等敏感信息写入日志。
*
* 使用方式:在 log.info/debug 前对对象调用 redactForLog(obj),或对字符串调用 redactStringForLog(s).
*
* 本地调试需要看完整密钥时(勿用于生产/勿提交日志):通过 .env.development 配置
* `QIMING_AGENT_LOG_FULL_SECRETS=true`,将跳过脱敏。
*/
import { FEATURES } from "@shared/featureFlags";
/**
* 未设置 LOG_FULL_SECRETS 时默认为 false → 始终脱敏。
* 为 true 时不脱敏(仅本机调试用)。
*/
const logRedactDisabled: boolean = FEATURES.LOG_FULL_SECRETS;
/** 需要脱敏的键名(小写匹配),值替换为前缀 + "..." */
const SENSITIVE_KEYS_LOWER = new Set([
"api_key",
"apikey",
"anthropic_api_key",
"anthropic_auth_token",
"openai_api_key",
"sandbox_access_key",
"auth_token",
"access_key",
"secret",
"password",
"token",
"amap_maps_api_key",
]);
/** 键名匹配这些模式则脱敏(如 *_API_KEY, *_TOKEN */
const SENSITIVE_KEY_PATTERNS =
/_(?:API_KEY|AUTH_TOKEN|ACCESS_KEY|SECRET|PASSWORD|TOKEN)$/i;
/** 脱敏后显示的前缀长度ak-xxxx 保留前 8 个字符) */
const MASK_PREFIX_LEN = 8;
function maskValue(value: string): string {
if (logRedactDisabled) return value;
if (!value || typeof value !== "string") return "***";
const trimmed = value.trim();
if (trimmed.length <= MASK_PREFIX_LEN) return "***";
return (
trimmed.slice(
0,
Math.min(MASK_PREFIX_LEN, Math.floor(trimmed.length / 2)),
) + "..."
);
}
function isSensitiveKey(key: string): boolean {
const lower = key.toLowerCase();
if (SENSITIVE_KEYS_LOWER.has(lower)) return true;
if (SENSITIVE_KEY_PATTERNS.test(key)) return true;
return false;
}
/**
* 深拷贝对象并在拷贝中脱敏敏感字段(不修改原对象)
*/
export function redactForLog(obj: unknown): unknown {
if (logRedactDisabled) return obj;
if (obj === null || typeof obj !== "object") {
return obj;
}
if (Array.isArray(obj)) {
return obj.map((item) => redactForLog(item));
}
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
if (isSensitiveKey(key)) {
if (value === null) {
out[key] = null;
} else if (typeof value === "string") {
out[key] = maskValue(value);
} else if (typeof value === "object") {
// 嵌套对象也脱敏(如 env.api_key
out[key] = redactForLog(value);
} else {
out[key] = "***";
}
continue;
}
out[key] = redactForLog(value);
}
return out;
}
/**
* 对 JSON 字符串中的敏感值做正则脱敏(用于已序列化的 agent_config 等)
* - 匹配 "key": "ak-xxx" / "key": "sk-xxx" 等
* - 匹配 URL 中的 ak=xxx
* - 匹配常见 API key 前缀ak-, sk-, sak-, pk-,Bearer 等
*/
export function redactStringForLog(s: string): string {
if (typeof s !== "string") return String(s);
if (logRedactDisabled) return s;
return (
s
.replace(
/"((?:ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|SANDBOX_ACCESS_KEY|OPENAI_API_KEY|api_key|apiKey|AMAP_MAPS_API_KEY))"\s*:\s*"([^"]+)"/gi,
(_, k, v) => {
return `"${k}":"${maskValue(v)}"`;
},
)
.replace(/([?&]ak=)([a-zA-Z0-9_-]+)/g, (_, prefix) => `${prefix}***`)
.replace(/([?&]ts=)([a-zA-Z0-9_-]+)/g, (_, prefix) => `${prefix}***`)
// 匹配常见 API key 前缀ak-, sk-, sak-, pk-,Bearer
.replace(/(ak-[a-zA-Z0-9]{8})[a-zA-Z0-9_-]+/g, "$1...")
.replace(/(sk-[a-zA-Z0-9]{8})[a-zA-Z0-9_-]+/g, "$1...")
.replace(/(sak-[a-zA-Z0-9]{8})[a-zA-Z0-9_-]+/g, "$1...")
.replace(/(pk-[a-zA-Z0-9]{8})[a-zA-Z0-9_-]+/g, "$1...")
// Bearer token
.replace(/(Bearer\s+)([a-zA-Z0-9_-]{8})[a-zA-Z0-9_-]+/gi, "$1$2...")
);
}

View File

@@ -0,0 +1,386 @@
/**
* Unit tests: processTree (process tree kill utility)
*
* Covers:
* 1. killProcessTree on Unix — group kill, descendant fallback, ESRCH
* 2. killProcessTree on Windows — taskkill args, exit code 128, other errors
* 3. killProcessTreeGraceful — SIGTERM-only, SIGKILL escalation
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// ── Mocks ──────────────────────────────────────────────────
const mockExecFile = vi.fn();
vi.mock("child_process", () => ({
execFile: (...args: unknown[]) => mockExecFile(...args),
}));
const mockLog = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
};
vi.mock("electron-log", () => ({ default: mockLog }));
// ── Helpers ──────────────────────────────────────────────────
/**
* Set up mockExecFile to handle pgrep calls (return no children by default).
* Can be overridden by setting up a custom implementation before the test.
*/
function mockPgrepNoChildren() {
mockExecFile.mockImplementation(
(
cmd: string,
_args: string[],
cb: (err: Error | null, stdout?: string) => void,
) => {
if (cmd === "pgrep") {
// No children found
cb(new Error("no match"), "");
return;
}
cb(null);
},
);
}
// ── Tests ──────────────────────────────────────────────────
describe("processTree", () => {
let processKillSpy: ReturnType<typeof vi.spyOn>;
const originalPlatform = process.platform;
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
processKillSpy = vi.spyOn(process, "kill").mockImplementation(
// Default: process.kill succeeds (no-op)
() => true,
);
mockPgrepNoChildren();
});
afterEach(() => {
processKillSpy.mockRestore();
Object.defineProperty(process, "platform", {
value: originalPlatform,
configurable: true,
});
});
function setPlatform(platform: string) {
Object.defineProperty(process, "platform", {
value: platform,
configurable: true,
});
}
// ── killProcessTree (Unix) ──
describe("killProcessTree (Unix)", () => {
it("should call process.kill(-pid, signal) for group kill", async () => {
setPlatform("darwin");
const { killProcessTree } = await import("./processTree");
await killProcessTree(1234, "SIGTERM");
expect(processKillSpy).toHaveBeenCalledWith(-1234, "SIGTERM");
});
it("should fall back to descendant kill when group kill fails with non-ESRCH error", async () => {
setPlatform("darwin");
const epermError = Object.assign(new Error("EPERM"), { code: "EPERM" });
processKillSpy.mockImplementation(
(pid: number, _signal?: string | number) => {
if (pid < 0) throw epermError; // group kill fails
return true; // direct kill succeeds
},
);
const { killProcessTree } = await import("./processTree");
await killProcessTree(1234, "SIGTERM");
// First call: group kill (-1234) — fails with EPERM
expect(processKillSpy).toHaveBeenCalledWith(-1234, "SIGTERM");
// Falls back to descendant kill: pgrep returns no children, so kills root directly
expect(processKillSpy).toHaveBeenCalledWith(1234, "SIGTERM");
});
it("should fall back to descendant kill on ESRCH (group doesn't exist but process may be alive)", async () => {
setPlatform("darwin");
const esrchError = Object.assign(new Error("ESRCH"), { code: "ESRCH" });
processKillSpy.mockImplementation(
(pid: number, _signal?: string | number) => {
if (pid < 0) throw esrchError; // group doesn't exist
return true; // direct kill succeeds
},
);
const { killProcessTree } = await import("./processTree");
await killProcessTree(1234, "SIGTERM");
// Group kill attempted first
expect(processKillSpy).toHaveBeenCalledWith(-1234, "SIGTERM");
// Falls back to descendant kill: kills root process directly
expect(processKillSpy).toHaveBeenCalledWith(1234, "SIGTERM");
});
it("should kill descendants found by pgrep", async () => {
setPlatform("darwin");
const esrchError = Object.assign(new Error("ESRCH"), { code: "ESRCH" });
processKillSpy.mockImplementation(
(pid: number, _signal?: string | number) => {
if (pid < 0) throw esrchError; // group doesn't exist
return true;
},
);
// pgrep returns children for pid 1234, no grandchildren
mockExecFile.mockImplementation(
(
cmd: string,
args: string[],
cb: (err: Error | null, stdout?: string) => void,
) => {
if (cmd === "pgrep" && args[1] === "1234") {
cb(null, "5555\n6666\n");
return;
}
if (cmd === "pgrep") {
cb(new Error("no match"), "");
return;
}
cb(null);
},
);
const { killProcessTree } = await import("./processTree");
await killProcessTree(1234, "SIGTERM");
// Should kill children (bottom-up: reversed) then root
expect(processKillSpy).toHaveBeenCalledWith(6666, "SIGTERM");
expect(processKillSpy).toHaveBeenCalledWith(5555, "SIGTERM");
expect(processKillSpy).toHaveBeenCalledWith(1234, "SIGTERM");
});
it("should use SIGTERM as default signal", async () => {
setPlatform("darwin");
const { killProcessTree } = await import("./processTree");
await killProcessTree(5678);
expect(processKillSpy).toHaveBeenCalledWith(-5678, "SIGTERM");
});
});
// ── killProcessTree (Windows) ──
describe("killProcessTree (Windows)", () => {
it("should call taskkill with correct args", async () => {
setPlatform("win32");
mockExecFile.mockImplementation(((...args: unknown[]) => {
const cb = args[args.length - 1] as (err: Error | null) => void;
cb(null);
}) as typeof mockExecFile);
const { killProcessTree } = await import("./processTree");
await killProcessTree(4567);
expect(mockExecFile).toHaveBeenCalledWith(
"taskkill",
["/T", "/F", "/PID", "4567"],
{ windowsHide: true },
expect.any(Function),
);
});
it("should resolve when process not found (exit code 128)", async () => {
setPlatform("win32");
mockExecFile.mockImplementation(((...args: unknown[]) => {
const cb = args[args.length - 1] as (err: Error | null) => void;
const err = Object.assign(new Error("process not found"), {
code: 128,
});
cb(err);
}) as typeof mockExecFile);
const { killProcessTree } = await import("./processTree");
await expect(killProcessTree(4567)).resolves.toBeUndefined();
});
it("should resolve when process not found (ESRCH code)", async () => {
setPlatform("win32");
mockExecFile.mockImplementation(((...args: unknown[]) => {
const cb = args[args.length - 1] as (err: Error | null) => void;
const err = Object.assign(new Error("no such process"), {
code: "ESRCH",
});
cb(err);
}) as typeof mockExecFile);
const { killProcessTree } = await import("./processTree");
await expect(killProcessTree(4567)).resolves.toBeUndefined();
});
it("should reject on other errors", async () => {
setPlatform("win32");
mockExecFile.mockImplementation(((...args: unknown[]) => {
const cb = args[args.length - 1] as (err: Error | null) => void;
const err = Object.assign(new Error("access denied"), {
code: 5,
});
cb(err);
}) as typeof mockExecFile);
const { killProcessTree } = await import("./processTree");
await expect(killProcessTree(4567)).rejects.toThrow("access denied");
});
});
// ── killProcessTreeGraceful ──
describe("killProcessTreeGraceful", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("should only send SIGTERM when process exits promptly", async () => {
setPlatform("darwin");
let killCallCount = 0;
processKillSpy.mockImplementation(
(pid: number, signal?: string | number) => {
killCallCount++;
// First call: group kill with SIGTERM (succeeds)
if (killCallCount === 1) {
expect(pid).toBe(-9999);
expect(signal).toBe("SIGTERM");
return true;
}
// Second call: waitForExit check with signal 0 → process gone
if (signal === 0) {
const err = Object.assign(new Error("ESRCH"), { code: "ESRCH" });
throw err;
}
return true;
},
);
const { killProcessTreeGraceful } = await import("./processTree");
const promise = killProcessTreeGraceful(9999, 3000);
// Advance timers to let waitForExit polling run
await vi.advanceTimersByTimeAsync(0);
await promise;
// SIGKILL should NOT have been sent — no log.warn about escalation
const warnCalls = mockLog.warn.mock.calls.map((c) => c[0]);
expect(warnCalls).not.toContain(
expect.stringContaining("sending SIGKILL"),
);
});
it("should escalate to SIGKILL when process does not exit within timeout", async () => {
setPlatform("darwin");
let sigkillSent = false;
processKillSpy.mockImplementation(
(pid: number, signal?: string | number) => {
// Group kill calls (negative pid)
if (pid < 0) {
if (signal === "SIGKILL") {
sigkillSent = true;
}
return true;
}
// waitForExit: signal 0 checks — process stays alive until SIGKILL sent
if (signal === 0) {
if (sigkillSent) {
const err = Object.assign(new Error("ESRCH"), { code: "ESRCH" });
throw err;
}
return true; // still alive
}
return true;
},
);
const { killProcessTreeGraceful } = await import("./processTree");
const promise = killProcessTreeGraceful(9999, 1000);
// Advance past the timeout to trigger SIGKILL escalation
// waitForExit polls every 200ms, so we need to advance enough times
for (let i = 0; i < 10; i++) {
await vi.advanceTimersByTimeAsync(200);
}
await promise;
expect(sigkillSent).toBe(true);
expect(mockLog.warn).toHaveBeenCalledWith(
expect.stringContaining("sending SIGKILL"),
);
});
});
describe("collectListeningPidsOnPortFromNetstatStdout", () => {
it("returns PIDs for 127.0.0.1 LISTENING", async () => {
const { collectListeningPidsOnPortFromNetstatStdout } =
await import("./processTree");
const sample = `
TCP 127.0.0.1:60008 0.0.0.0:0 LISTENING 12345
TCP 0.0.0.0:135 0.0.0.0:0 LISTENING 4
`;
expect(
collectListeningPidsOnPortFromNetstatStdout(sample, 60008),
).toEqual([12345]);
});
it("ignores ESTABLISHED when port only appears on remote side", async () => {
const { collectListeningPidsOnPortFromNetstatStdout } =
await import("./processTree");
const sample =
" TCP 127.0.0.1:80 192.168.1.1:60008 ESTABLISHED 99999";
expect(
collectListeningPidsOnPortFromNetstatStdout(sample, 60008),
).toEqual([]);
});
it("parses Chinese 监听 line", async () => {
const { collectListeningPidsOnPortFromNetstatStdout } =
await import("./processTree");
const sample =
" TCP 127.0.0.1:60008 0.0.0.0:0 监听 23456";
expect(
collectListeningPidsOnPortFromNetstatStdout(sample, 60008),
).toEqual([23456]);
});
it("parses [::ffff:127.0.0.1]:port (IPv4-mapped)", async () => {
const { collectListeningPidsOnPortFromNetstatStdout } =
await import("./processTree");
const sample =
" TCP [::ffff:127.0.0.1]:60008 [::]:0 LISTENING 77777";
expect(
collectListeningPidsOnPortFromNetstatStdout(sample, 60008),
).toEqual([77777]);
});
});
});

View File

@@ -0,0 +1,486 @@
/**
* Process tree kill utility.
*
* Kills a process and all its descendants to prevent zombie processes.
*
* Strategy (Unix):
* 1. Try process group kill (-pid) — works when spawned with detached: true
* and setsid() succeeds (PGID = pid).
* 2. Fall back to recursive descendant kill via `pgrep -P` — handles the case
* where detached: true didn't create a new process group (e.g. dev mode
* under make → Electron, where all processes share the terminal's PGID).
* 3. Kill the root process directly.
*
* Windows: Uses taskkill /T /F /PID for tree kill.
*/
import { execFile } from "child_process";
import * as fs from "fs";
import log from "electron-log";
import { createPlatformAdapter } from "../system/platformAdapter";
const isWindows = (): boolean => createPlatformAdapter().isWindows;
/**
* Kill a process tree (the process and all its descendants).
*
* @param pid - Process ID to kill
* @param signal - Signal to send (default: SIGTERM). Ignored on Windows (always force-kills).
*/
export async function killProcessTree(
pid: number,
signal: NodeJS.Signals = "SIGTERM",
): Promise<void> {
if (isWindows()) {
return killProcessTreeWindows(pid);
}
return killProcessTreeUnix(pid, signal);
}
/**
* Kill a process tree with graceful escalation:
* 1. Send SIGTERM to the process tree
* 2. Wait up to `timeoutMs` for the root process to exit
* 3. If still alive, send SIGKILL to the process tree
*
* @param pid - Process ID to kill
* @param timeoutMs - Time to wait before escalating to SIGKILL (default: 5000ms)
*/
export async function killProcessTreeGraceful(
pid: number,
timeoutMs = 5000,
): Promise<void> {
// Step 1: Send SIGTERM to the entire tree
try {
await killProcessTree(pid, "SIGTERM");
} catch (e) {
log.warn(`[processTree] SIGTERM failed for pid ${pid}:`, e);
}
// Step 2: Wait for root process to exit
const alive = await waitForExit(pid, timeoutMs);
// Step 3: Escalate to SIGKILL if still alive
if (alive) {
log.warn(
`[processTree] Process ${pid} still alive after ${timeoutMs}ms, sending SIGKILL`,
);
try {
await killProcessTree(pid, "SIGKILL");
} catch (e) {
log.warn(`[processTree] SIGKILL failed for pid ${pid}:`, e);
}
}
}
/** 系统保留 / 非用户进程 PID 上限,按端口清进程时跳过 */
const SYSTEM_PID_CEILING = 4;
/**
* 从 Windows `netstat -ano` 标准输出中解析「本机在该 TCP 端口上 LISTENING」的 PID。
* 用于 uv 已退出但子进程仍占用端口、ManagedProcess 已无 PID 等场景的兜底排查。
*/
export function collectListeningPidsOnPortFromNetstatStdout(
stdout: string,
port: number,
): number[] {
if (!Number.isInteger(port) || port < 1 || port > 65535) {
return [];
}
const portSuffix = `:${port}`;
const localMarkers = [
`127.0.0.1${portSuffix}`,
`0.0.0.0${portSuffix}`,
`[::1]${portSuffix}`,
`[::ffff:127.0.0.1]${portSuffix}`,
`[::]${portSuffix}`,
`*${portSuffix}`,
];
const pids = new Set<number>();
for (const line of stdout.split(/\r?\n/)) {
const isListening = /\bLISTENING\b/i.test(line) || line.includes("监听");
if (!isListening) {
continue;
}
if (!localMarkers.some((m) => line.includes(m))) {
continue;
}
const m =
line.match(/\bLISTENING\s+(\d+)\s*$/i) || line.match(/监听\s+(\d+)\s*$/);
if (!m) {
continue;
}
const pid = parseInt(m[1], 10);
if (Number.isFinite(pid) && pid > SYSTEM_PID_CEILING) {
pids.add(pid);
}
}
return [...pids];
}
/**
* Windows按 TCP 端口清理 LISTENING 进程(整树终止)。
* 在 stopWindowsMcp / 手动重启 GUI MCP 时,作为 taskkill 跟踪 PID 之外的第二道防线。
*/
export async function killProcessTreesListeningOnTcpPortWindows(
port: number,
timeoutMsPerTree = 3000,
): Promise<void> {
if (!isWindows()) {
return;
}
if (!Number.isInteger(port) || port < 1 || port > 65535) {
return;
}
return new Promise((resolve) => {
execFile("netstat", ["-ano"], { windowsHide: true }, (err, stdout) => {
void (async () => {
if (err) {
log.warn("[processTree] netstat -ano failed:", err);
resolve();
return;
}
const raw = collectListeningPidsOnPortFromNetstatStdout(stdout, port);
const own = process.pid;
const pids = raw.filter(
(pid) => pid > SYSTEM_PID_CEILING && pid !== own,
);
if (pids.length > 0) {
log.info(
`[processTree] port ${port} TCP LISTENING -> taskkill tree for PIDs: ${pids.join(", ")}`,
);
}
for (const pid of pids) {
try {
await killProcessTreeGraceful(pid, timeoutMsPerTree);
} catch (e) {
log.warn(
`[processTree] killProcessTreeGraceful pid=${pid} port=${port}:`,
e,
);
}
}
resolve();
})();
});
});
}
/**
* macOS/Linux按 TCP 端口清理 LISTENING 进程(整树终止)。
* macOS 使用 lsofLinux 优先 lsoflsof 不可用时回退到 /proc/net/tcp。
*/
export async function killProcessTreesListeningOnTcpPortUnix(
port: number,
timeoutMsPerTree = 3000,
): Promise<void> {
const platformAdapter = createPlatformAdapter();
if (platformAdapter.isWindows) {
return;
}
if (!Number.isInteger(port) || port < 1 || port > 65535) {
return;
}
// Linux: 优先尝试 lsof若不可用则回退到 /proc/net/tcp
if (platformAdapter.isLinux) {
const pids = await getListeningPidsFromProcNet(port);
if (pids.length > 0) {
log.info(
`[processTree] port ${port} TCP LISTENING (from /proc/net/tcp) -> killing PIDs: ${pids.join(", ")}`,
);
for (const pid of pids) {
try {
await killProcessTreeGraceful(pid, timeoutMsPerTree);
} catch (e) {
log.warn(
`[processTree] killProcessTreeGraceful pid=${pid} port=${port}:`,
e,
);
}
}
}
return;
}
// macOS: 使用 lsof
return new Promise((resolve) => {
execFile("lsof", ["-t", "-i", `:${port}`], (err, stdout) => {
void (async () => {
if (err || !stdout.trim()) {
// 无进程占用或 lsof 失败,静默忽略
resolve();
return;
}
const pids = stdout
.trim()
.split("\n")
.map((s) => parseInt(s, 10))
.filter((n) => Number.isFinite(n) && n > SYSTEM_PID_CEILING);
// 排除当前进程自己
const own = process.pid;
const filteredPids = pids.filter((pid) => pid !== own);
if (filteredPids.length > 0) {
log.info(
`[processTree] port ${port} TCP LISTENING (from lsof) -> killing PIDs: ${filteredPids.join(", ")}`,
);
}
for (const pid of filteredPids) {
try {
await killProcessTreeGraceful(pid, timeoutMsPerTree);
} catch (e) {
log.warn(
`[processTree] killProcessTreeGraceful pid=${pid} port=${port}:`,
e,
);
}
}
resolve();
})();
});
});
}
/**
* Linux 专用:从 /proc/net/tcp 解析占用指定端口的 PID 列表。
* /proc/net/tcp 的本地地址是十六进制表示inode 可用于关联 /proc/{pid}/fd。
* 注意:仅匹配 IPv4 (0.0.0.0 和 127.0.0.1) 和 IPv6 (::1) 的 LISTEN 套接字。
*
* @param port 十进制端口号
* @returns PID 列表
*/
function getListeningPidsFromProcNet(port: number): Promise<number[]> {
return new Promise((resolve) => {
const pids = new Set<number>();
const portHex = port.toString(16).toUpperCase().padStart(4, "0");
// 读取 /proc/net/tcp
fs.readFile("/proc/net/tcp", "utf8", (err, data) => {
if (err) {
log.warn("[processTree] Failed to read /proc/net/tcp:", err);
resolve([]);
return;
}
const lines = data.split("\n").slice(1); // 跳过标题行
for (const line of lines) {
const fields = line.trim().split(/\s+/);
if (fields.length < 10) continue;
const localAddress = fields[1];
const inode = fields[9];
// 检查是否为 LISTEN 状态且端口匹配
const isListening = fields[3] === "0A"; // 0A = LISTEN in hex
const portMatch = localAddress.endsWith(`:${portHex}`);
if (!isListening || !portMatch || !inode) continue;
// 查找拥有此 inode 的进程
const inodeNum = parseInt(inode, 10);
if (!Number.isFinite(inodeNum)) continue;
// 遍历 /proc/*/fd寻找 socket:[inode] 符号链接
try {
const procEntries = fs.readdirSync("/proc");
for (const entryName of procEntries) {
if (entryName === "." || entryName === "..") continue;
const pid = parseInt(entryName, 10);
if (!Number.isFinite(pid) || pid <= SYSTEM_PID_CEILING) continue;
const fdDir = `/proc/${pid}/fd`;
try {
const fdEntries = fs.readdirSync(fdDir);
for (const fdEntry of fdEntries) {
try {
const linkTarget = fs.readlinkSync(`${fdDir}/${fdEntry}`);
if (linkTarget === `socket:[${inodeNum}]`) {
pids.add(pid);
}
} catch {
// 权限不足或 fd 已消失,跳过
}
}
} catch {
// 进程已退出或无权限
}
}
} catch {
// /proc 读取失败
}
}
// 排除当前进程
pids.delete(process.pid);
resolve([...pids]);
});
});
}
/**
* 跨平台:按 TCP 端口清理 LISTENING 进程(整树终止)。
* Windows 调用 killProcessTreesListeningOnTcpPortWindows
* macOS/Linux 调用 killProcessTreesListeningOnTcpPortUnix。
*/
export async function killProcessTreesListeningOnTcpPort(
port: number,
timeoutMsPerTree = 3000,
): Promise<void> {
if (isWindows()) {
return killProcessTreesListeningOnTcpPortWindows(port, timeoutMsPerTree);
}
return killProcessTreesListeningOnTcpPortUnix(port, timeoutMsPerTree);
}
// ==================== Internal ====================
function killProcessTreeWindows(pid: number): Promise<void> {
return new Promise((resolve, reject) => {
execFile(
"taskkill",
["/T", "/F", "/PID", String(pid)],
{ windowsHide: true },
(err) => {
if (err) {
// taskkill returns exit code 128 if process not found — treat as success
const code = (err as any).code;
if (code === 128 || code === "ESRCH") {
resolve();
} else {
reject(err);
}
} else {
resolve();
}
},
);
});
}
/**
* Kill a process and all its descendants on Unix.
*
* Two strategies, tried in order:
* 1. Process group kill: `kill(-pid, signal)` — fast, kills all processes whose
* PGID equals `pid`. Works when child was spawned with `detached: true` and
* setsid() actually created a new process group.
* 2. Recursive descendant kill: walks the process tree using `pgrep -P` and
* kills each descendant bottom-up. This handles the case where the process
* group was NOT changed (e.g. Electron dev mode under make, where all
* processes share the terminal's PGID).
*/
async function killProcessTreeUnix(
pid: number,
signal: NodeJS.Signals,
): Promise<void> {
let groupKillWorked = false;
// Strategy 1: Try process group kill
try {
process.kill(-pid, signal);
groupKillWorked = true;
log.info(`[processTree] Sent ${signal} to process group -${pid}`);
} catch (e: any) {
if (e.code === "ESRCH") {
// ESRCH from group kill means no process with PGID=pid exists.
// But the process itself may still be alive with a different PGID
// (e.g. dev mode where detached: true didn't create a new group).
log.info(
`[processTree] No process group -${pid}, falling back to descendant kill`,
);
} else {
log.warn(`[processTree] Group kill failed for pid ${pid}:`, e.message);
}
}
// Strategy 2: Recursive descendant kill (always run as safety net)
if (!groupKillWorked) {
const descendants = await getDescendants(pid);
if (descendants.length > 0) {
log.info(
`[processTree] Killing ${descendants.length} descendant(s) of pid ${pid}: ${descendants.join(",")}`,
);
}
// Kill bottom-up (children first, then parent)
for (const childPid of descendants.reverse()) {
try {
process.kill(childPid, signal);
} catch {
// ESRCH — already dead, ignore
}
}
// Kill root process
try {
process.kill(pid, signal);
} catch (e: any) {
if (e.code !== "ESRCH") {
log.warn(`[processTree] Direct kill failed for pid ${pid}:`, e.message);
}
}
}
}
/**
* Get all descendant PIDs of a process (children, grandchildren, etc.)
* using `pgrep -P`. Returns PIDs in depth-first order.
*/
async function getDescendants(pid: number): Promise<number[]> {
const result: number[] = [];
const children = await getChildPids(pid);
for (const child of children) {
result.push(child);
const grandchildren = await getDescendants(child);
result.push(...grandchildren);
}
return result;
}
/**
* Get direct child PIDs of a process using `pgrep -P`.
*/
function getChildPids(pid: number): Promise<number[]> {
return new Promise((resolve) => {
execFile("pgrep", ["-P", String(pid)], (err, stdout) => {
if (err || !stdout.trim()) {
// No children or pgrep failed
resolve([]);
return;
}
const pids = stdout
.trim()
.split("\n")
.map((s) => parseInt(s, 10))
.filter((n) => !isNaN(n));
resolve(pids);
});
});
}
/**
* Check if a process is still alive, polling until timeout.
* Returns true if still alive, false if exited.
*/
function waitForExit(pid: number, timeoutMs: number): Promise<boolean> {
return new Promise((resolve) => {
const start = Date.now();
const check = () => {
try {
// signal 0 doesn't kill, just checks if process exists
process.kill(pid, 0);
// Still alive
if (Date.now() - start >= timeoutMs) {
resolve(true);
} else {
setTimeout(check, 200);
}
} catch {
// Process gone
resolve(false);
}
};
check();
});
}

View File

@@ -0,0 +1,57 @@
/**
* Spawn utility functions
*
* Provides cross-platform spawn wrappers that handle Windows-specific requirements.
*/
import { spawn, SpawnOptions, ChildProcess } from 'child_process';
import { isWindows } from '../system/shellEnv';
/**
* Cross-platform spawn wrapper that automatically adds shell: true on Windows.
*
* On Windows, spawning .cmd files (like npm.cmd) requires shell: true.
* This wrapper ensures consistent behavior across platforms.
*
* @param command - The command to spawn
* @param args - Arguments to pass to the command
* @param options - Spawn options (shell option will be overridden on Windows)
* @returns ChildProcess instance
*/
export function spawnCrossPlatform(
command: string,
args: string[],
options?: SpawnOptions,
): ChildProcess {
return spawn(command, args, {
...options,
shell: isWindows(),
});
}
/**
* Get the platform-specific npm command name
*
* @returns 'npm.cmd' on Windows, 'npm' on other platforms
*/
export function getNpmCommand(): string {
return isWindows() ? 'npm.cmd' : 'npm';
}
/**
* Get the platform-specific node command name
*
* @returns 'node.cmd' on Windows, 'node' on other platforms
*/
export function getNodeCommand(): string {
return isWindows() ? 'node.cmd' : 'node';
}
/**
* Get the platform-specific command checker
*
* @returns 'where' on Windows, 'which' on other platforms
*/
export function getCommandChecker(): string {
return isWindows() ? 'where' : 'which';
}

View File

@@ -0,0 +1,427 @@
/**
* Unit tests for spawnNoWindow utility
*
* Tests the cross-platform spawn functionality:
* - Windows: Uses Electron's bundled Node with ELECTRON_RUN_AS_NODE=1
* - macOS/Linux: Uses system node to avoid Dock icon issue
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as path from 'path';
import * as fs from 'fs';
import { resolveNpmPackageEntry, spawnJsFile, spawnNpmPackage, isWindows, isMacOS, getNodePath, findSystemNode, resetCache } from './spawnNoWindow';
// Cross-platform path matching helper: normalizes backslashes for endsWith checks
const pathEndsWith = (p: string, suffix: string) =>
p.replace(/\\/g, '/').endsWith(suffix);
// Mock child_process
const mockSpawn = vi.fn();
const mockExecSync = vi.fn();
vi.mock('child_process', () => ({
spawn: (...args: unknown[]) => mockSpawn(...args),
execSync: (...args: unknown[]) => mockExecSync(...args),
}));
// Mock fs
const mockExistsSync = vi.fn();
const mockReadFileSync = vi.fn();
vi.mock('fs', () => ({
existsSync: (p: string) => mockExistsSync(p),
readFileSync: (p: string) => mockReadFileSync(p),
}));
// Store original platform
const originalPlatform = process.platform;
describe('spawnNoWindow', () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset internal caches before each test
resetCache();
// Default mocks
mockSpawn.mockReturnValue({
pid: 12345,
on: vi.fn(),
stdout: { on: vi.fn() },
stderr: { on: vi.fn() },
});
mockExecSync.mockReturnValue('__PATH__=/usr/local/bin:/opt/homebrew/bin');
mockExistsSync.mockImplementation((p: string) => {
if (p.includes('node')) return true;
return false;
});
});
afterEach(() => {
vi.resetAllMocks();
});
describe('isWindows', () => {
it('should return boolean based on platform', () => {
const result = isWindows();
expect(typeof result).toBe('boolean');
});
});
describe('isMacOS', () => {
it('should return boolean based on platform', () => {
const result = isMacOS();
expect(typeof result).toBe('boolean');
});
});
describe('getNodePath', () => {
it('should return process.execPath', () => {
const result = getNodePath();
expect(result).toBe(process.execPath);
});
});
describe('resolveNpmPackageEntry', () => {
it('should return null if package.json not found', () => {
mockExistsSync.mockReturnValue(false);
const result = resolveNpmPackageEntry('/nonexistent/package');
expect(result).toBeNull();
expect(mockExistsSync).toHaveBeenCalledWith(path.join('/nonexistent/package', 'package.json'));
});
it('should resolve entry from string bin field', () => {
mockExistsSync.mockImplementation((p: string) => {
if (pathEndsWith(p, 'package.json')) return true;
if (pathEndsWith(p, 'cli.js')) return true;
return false;
});
mockReadFileSync.mockReturnValue(JSON.stringify({
name: 'test-package',
bin: './cli.js',
}));
const result = resolveNpmPackageEntry('/packages/test-package');
expect(result).toBe(path.join('/packages/test-package', 'cli.js'));
});
it('should resolve entry from object bin field', () => {
mockExistsSync.mockImplementation((p: string) => {
if (pathEndsWith(p, 'package.json')) return true;
if (pathEndsWith(p, 'dist/index.js')) return true;
return false;
});
mockReadFileSync.mockReturnValue(JSON.stringify({
name: 'test-package',
bin: {
'test-command': './dist/index.js',
},
}));
const result = resolveNpmPackageEntry('/packages/test-package', 'test-command');
expect(result).toBe(path.join('/packages/test-package', 'dist', 'index.js'));
});
it('should fallback to common locations if bin not found', () => {
mockExistsSync.mockImplementation((p: string) => {
if (pathEndsWith(p, 'package.json')) return true;
if (pathEndsWith(p, 'index.js')) return true;
return false;
});
mockReadFileSync.mockReturnValue(JSON.stringify({
name: 'test-package',
// No bin field
}));
const result = resolveNpmPackageEntry('/packages/test-package');
expect(result).toBe(path.join('/packages/test-package', 'index.js'));
});
it('should handle malformed package.json', () => {
mockExistsSync.mockImplementation((p: string) => {
if (pathEndsWith(p, 'package.json')) return true;
return false;
});
mockReadFileSync.mockReturnValue('not valid json');
const result = resolveNpmPackageEntry('/packages/test-package');
expect(result).toBeNull();
});
});
describe('spawnJsFile - Windows behavior', () => {
it('should use ELECTRON_RUN_AS_NODE=1 on Windows', () => {
// This test verifies Windows behavior
// On Windows, process.execPath is used with ELECTRON_RUN_AS_NODE=1
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', { value: 'win32' });
try {
spawnJsFile('/path/to/script.js');
const callArgs = mockSpawn.mock.calls[0];
// On Windows, should use process.execPath
expect(callArgs[0]).toBe(process.execPath);
// On Windows, should set ELECTRON_RUN_AS_NODE
expect(callArgs[2].env.ELECTRON_RUN_AS_NODE).toBe('1');
expect(callArgs[2].windowsHide).toBe(true);
} finally {
Object.defineProperty(process, 'platform', originalPlatform!);
}
});
it('should merge custom environment variables on Windows', () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', { value: 'win32' });
try {
spawnJsFile('/path/to/script.js', [], {
env: { CUSTOM_VAR: 'custom_value' },
});
const callArgs = mockSpawn.mock.calls[0];
expect(callArgs[2].env.CUSTOM_VAR).toBe('custom_value');
expect(callArgs[2].env.ELECTRON_RUN_AS_NODE).toBe('1');
} finally {
Object.defineProperty(process, 'platform', originalPlatform!);
}
});
});
describe('spawnJsFile - macOS/Linux behavior', () => {
it('should use system node on macOS (no ELECTRON_RUN_AS_NODE)', () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', { value: 'darwin' });
// Mock fs.existsSync to find node
mockExistsSync.mockImplementation((p: string) => {
if (p === '/usr/local/bin/node') return true;
return false;
});
try {
spawnJsFile('/path/to/script.js');
const callArgs = mockSpawn.mock.calls[0];
// On macOS, should use system node (not process.execPath)
expect(callArgs[0]).toBe('/usr/local/bin/node');
// On macOS, should NOT set ELECTRON_RUN_AS_NODE
expect(callArgs[2].env.ELECTRON_RUN_AS_NODE).toBeUndefined();
expect(callArgs[2].windowsHide).toBe(true);
} finally {
Object.defineProperty(process, 'platform', originalPlatform!);
}
});
it('should use system node on Linux (no ELECTRON_RUN_AS_NODE)', () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', { value: 'linux' });
// Mock fs.existsSync to find node
mockExistsSync.mockImplementation((p: string) => {
if (p === '/usr/local/bin/node') return true;
return false;
});
try {
spawnJsFile('/path/to/script.js');
const callArgs = mockSpawn.mock.calls[0];
// On Linux, should use system node
expect(callArgs[0]).toBe('/usr/local/bin/node');
// On Linux, should NOT set ELECTRON_RUN_AS_NODE
expect(callArgs[2].env.ELECTRON_RUN_AS_NODE).toBeUndefined();
} finally {
Object.defineProperty(process, 'platform', originalPlatform!);
}
});
it('should allow custom node path on macOS', () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', { value: 'darwin' });
try {
spawnJsFile('/path/to/script.js', [], {
nodePath: '/custom/node',
});
const callArgs = mockSpawn.mock.calls[0];
expect(callArgs[0]).toBe('/custom/node');
} finally {
Object.defineProperty(process, 'platform', originalPlatform!);
}
});
});
describe('spawnNpmPackage', () => {
it('should return null if entry not found', () => {
mockExistsSync.mockReturnValue(false);
const result = spawnNpmPackage('/nonexistent/package');
expect(result).toBeNull();
expect(mockSpawn).not.toHaveBeenCalled();
});
it('should spawn with resolved entry', () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', { value: 'win32' });
mockExistsSync.mockImplementation((p: string) => {
if (pathEndsWith(p, 'package.json')) return true;
if (pathEndsWith(p, 'cli.js')) return true;
return false;
});
mockReadFileSync.mockReturnValue(JSON.stringify({
name: 'test-package',
bin: './cli.js',
}));
try {
const result = spawnNpmPackage('/packages/test-package', ['--port', '8080']);
expect(result).not.toBeNull();
expect(mockSpawn).toHaveBeenCalledWith(
process.execPath,
[path.join('/packages/test-package', 'cli.js'), '--port', '8080'],
expect.objectContaining({
windowsHide: true,
})
);
} finally {
Object.defineProperty(process, 'platform', originalPlatform!);
}
});
it('should pass binName for resolution', () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', { value: 'win32' });
mockExistsSync.mockImplementation((p: string) => {
if (pathEndsWith(p, 'package.json')) return true;
if (pathEndsWith(p, 'dist/bin.js')) return true;
return false;
});
mockReadFileSync.mockReturnValue(JSON.stringify({
name: 'test-package',
bin: {
'custom-bin': './dist/bin.js',
},
}));
try {
const result = spawnNpmPackage('/packages/test-package', [], {}, 'custom-bin');
expect(result).not.toBeNull();
const callArgs = mockSpawn.mock.calls[0];
expect(callArgs[1][0]).toBe(path.join('/packages/test-package', 'dist', 'bin.js'));
} finally {
Object.defineProperty(process, 'platform', originalPlatform!);
}
});
});
describe('findSystemNode', () => {
it('should return process.execPath on Windows', () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', { value: 'win32' });
try {
const result = findSystemNode();
expect(result).toBe(process.execPath);
} finally {
Object.defineProperty(process, 'platform', originalPlatform!);
}
});
it('should find node in user PATH on macOS', () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', { value: 'darwin' });
mockExecSync.mockReturnValue('__PATH__=/opt/homebrew/bin:/usr/local/bin');
mockExistsSync.mockImplementation((p: string) => {
if (p === '/opt/homebrew/bin/node') return true;
return false;
});
try {
const result = findSystemNode();
expect(result).toBe('/opt/homebrew/bin/node');
} finally {
Object.defineProperty(process, 'platform', originalPlatform!);
}
});
it('should fallback to common paths if not in PATH', () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', { value: 'darwin' });
mockExecSync.mockReturnValue('__PATH__=/some/path/without/node');
mockExistsSync.mockImplementation((p: string) => {
if (p === '/usr/local/bin/node') return true;
return false;
});
try {
const result = findSystemNode();
expect(result).toBe('/usr/local/bin/node');
} finally {
Object.defineProperty(process, 'platform', originalPlatform!);
}
});
it('should return "node" as final fallback', () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', { value: 'darwin' });
mockExecSync.mockImplementation(() => {
throw new Error('Shell failed');
});
mockExistsSync.mockReturnValue(false);
try {
const result = findSystemNode();
expect(result).toBe('node');
} finally {
Object.defineProperty(process, 'platform', originalPlatform!);
}
});
});
describe('resetCache', () => {
it('should reset cached values', () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', { value: 'darwin' });
mockExecSync.mockReturnValue('__PATH__=/opt/homebrew/bin');
mockExistsSync.mockImplementation((p: string) => {
if (p === '/opt/homebrew/bin/node') return true;
return false;
});
try {
// First call - should cache the result
const result1 = findSystemNode();
expect(result1).toBe('/opt/homebrew/bin/node');
// Reset cache
resetCache();
// Change mock behavior
mockExistsSync.mockImplementation((p: string) => {
if (p === '/usr/local/bin/node') return true;
return false;
});
mockExecSync.mockReturnValue('__PATH__=/usr/local/bin');
// Second call - should use new mock after cache reset
const result2 = findSystemNode();
expect(result2).toBe('/usr/local/bin/node');
} finally {
Object.defineProperty(process, 'platform', originalPlatform!);
}
});
});
});

View File

@@ -0,0 +1,450 @@
/**
* Spawn utility for cross-platform console-free execution
*
* This module provides solutions for spawning Node.js-based npm packages
* without console popups (Windows) or Dock icons (macOS).
*
* ## Problems Solved
*
* 1. **Windows CMD Popup**: When spawning `.cmd` files (npm bin entries),
* a console window may flash/popup even with `windowsHide: true`.
*
* 2. **macOS Dock Icon**: When using `ELECTRON_RUN_AS_NODE=1` with Electron's
* executable, it appears as a new app in the Dock and bounces.
*
* ## Solutions (Platform-Specific)
*
* - **Windows**: Use Electron's bundled Node.js with `ELECTRON_RUN_AS_NODE=1`
* + `windowsHide: true` to bypass `.cmd` files.
*
* - **macOS/Linux**: Resolve and use the system `node` executable from user's
* shell PATH. This avoids creating a new Dock icon.
*
* ## Usage
* ```typescript
* import { spawnNpmPackage, spawnJsFile } from './spawnNoWindow';
*
* // Spawn an npm package by name
* const child = spawnNpmPackage('mcp-proxy', ['proxy', '--port', '8080']);
*
* // Spawn a JS file directly
* const child = spawnJsFile('/path/to/script.js', ['--arg']);
* ```
*
* ## Reference
* This approach is inspired by LobsterAI's coworkUtil.ts implementation.
*/
import { spawn, ChildProcess, SpawnOptions, execSync } from "child_process";
import * as path from "path";
import * as fs from "fs";
import log from "electron-log";
import { getAppEnv, getNodeBinPath } from "../system/dependencies";
/**
* Options for spawnNoWindow functions
*/
export interface SpawnNoWindowOptions extends Omit<SpawnOptions, "shell"> {
/**
* Custom Node.js executable path (defaults to process.execPath)
*/
nodePath?: string;
/**
* Extra environment variables
*/
env?: NodeJS.ProcessEnv;
}
/**
* Resolve the JS entry file path from an npm package's package.json
*
* @param packageDir - The package directory (containing package.json)
* @param binName - The bin name to resolve (defaults to package name)
* @returns The absolute path to the JS entry file, or null if not found
*/
export function resolveNpmPackageEntry(
packageDir: string,
binName?: string,
): string | null {
const pkgJsonPath = path.join(packageDir, "package.json");
if (!fs.existsSync(pkgJsonPath)) {
return null;
}
try {
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8"));
// Get bin path from package.json
let binPath: string | undefined;
if (typeof pkg.bin === "string") {
binPath = pkg.bin;
} else if (pkg.bin && typeof pkg.bin === "object") {
const name = binName || pkg.name;
const binValue = name ? pkg.bin[name] : Object.values(pkg.bin)[0];
if (typeof binValue === "string") {
binPath = binValue;
}
}
if (binPath) {
// 处理相对路径(如 ./dist/bin.js
// path.join 不会自动规范化,需要手动处理
let entryPath: string;
if (binPath.startsWith("./")) {
// 相对路径:直接拼接并规范化
entryPath = path.normalize(path.join(packageDir, binPath));
} else {
entryPath = path.join(packageDir, binPath);
}
if (fs.existsSync(entryPath)) {
return entryPath;
}
}
// Fallback: try common entry file locations
const fallbacks = [
path.join(packageDir, "index.js"),
path.join(packageDir, "cli.js"),
path.join(packageDir, "dist", "index.js"),
];
for (const p of fallbacks) {
if (fs.existsSync(p)) return p;
}
} catch {
// Failed to parse package.json
}
return null;
}
/**
* Spawn a JS file using Node.js without console window on Windows
* or without creating a new Dock icon on macOS.
*
* Platform-Specific Behavior:
*
* - **Windows**: Uses Electron's bundled Node.js with `ELECTRON_RUN_AS_NODE=1`
* to bypass `.cmd` files that cause console popups.
*
* - **macOS/Linux**: Uses the system `node` executable (resolved from user's
* shell PATH) to avoid creating a new Dock icon.
*
* @param jsFile - Absolute path to the JS file to execute
* @param args - Arguments to pass to the JS file
* @param options - Spawn options
* @returns ChildProcess instance
*/
export function spawnJsFile(
jsFile: string,
args: string[] = [],
options: SpawnNoWindowOptions = {},
): ChildProcess {
const { nodePath, env, ...spawnOptions } = options;
// Platform-specific node executable selection
let node: string;
let mergedEnv: Record<string, string | undefined>;
// 检测是否在 Electron 环境中运行
// 测试环境中 app.getPath 不可用,需要使用回退方案
const isElectron = typeof process.versions?.electron === "string";
// 与 Tauri 一致:调用方已传入完整 env如 mcp-proxy 的 getAppEnv时直接使用不再二次合并 getAppEnv(),避免 PATH/UV_* 被覆盖或时机不一致
const callerProvidedFullEnv =
env &&
typeof env === "object" &&
"PATH" in env &&
Object.keys(env).length > 5;
if (env && !callerProvidedFullEnv && isElectron) {
log.info(
`[spawnNoWindow] Trace: not using full env (PATH=${!!env?.PATH}, keys=${Object.keys(env || {}).length}), will merge getAppEnv()`,
);
}
if (isWindows()) {
node = nodePath || process.execPath;
if (callerProvidedFullEnv && isElectron) {
mergedEnv = { ...env, ELECTRON_RUN_AS_NODE: "1" } as Record<
string,
string | undefined
>;
const pathStr = mergedEnv.PATH || "";
const pathWithUv = pathStr
.split(";")
.filter((p) => p && (p.includes("uv") || p.includes("qimingclaw")));
log.info(
`[spawnNoWindow] Windows: using caller-provided full env (${Object.keys(env).length} entries)`,
);
log.info(
`[spawnNoWindow] Trace: uv segments in child PATH=${pathWithUv.length}, first 5=${pathWithUv.slice(0, 5).join(";") || "(none)"}`,
);
} else {
const appEnv = isElectron ? getAppEnv() : null;
mergedEnv = appEnv
? { ...appEnv, ...env, ELECTRON_RUN_AS_NODE: "1" }
: {
...process.env,
...env,
ELECTRON_RUN_AS_NODE: "1",
PATH: getEnhancedPath(),
};
}
log.info(`[spawnNoWindow] Windows debug info (Electron: ${isElectron}):`);
log.info(`[spawnNoWindow] - process.execPath: ${process.execPath}`);
log.info(`[spawnNoWindow] - Using node: ${node}`);
log.info(
`[spawnNoWindow] - PATH first 5: ${(mergedEnv.PATH || "").split(";").slice(0, 5).join(";")}`,
);
} else {
// Linux/macOS必须走应用集成的 Node 24resources/node/<platform-arch>/bin/node避免容器内 /usr/bin/node 不存在导致 ENOENT
// 调用方未传 nodePath 时Electron 下优先 getNodeBinPath()(应用集成 Node 24无集成 node 时才回退 findSystemNode()
const integratedNode = isElectron ? getNodeBinPath() : null;
node = nodePath || (integratedNode ?? findSystemNode());
if (integratedNode && node === integratedNode) {
log.info(
`[spawnNoWindow] ${process.platform}: using bundled Node 24: ${node}`,
);
}
if (callerProvidedFullEnv && isElectron) {
mergedEnv = { ...env } as Record<string, string | undefined>;
const pathStr = mergedEnv.PATH || "";
const pathWithUv = pathStr
.split(":")
.filter((p) => p && (p.includes("uv") || p.includes("qimingclaw")));
log.info(
`[spawnNoWindow] ${process.platform}: using caller-provided full env (${Object.keys(env).length} entries)`,
);
log.info(
`[spawnNoWindow] Trace: uv segments in child PATH=${pathWithUv.length}, first 5=${pathWithUv.slice(0, 5).join(":") || "(none)"}`,
);
} else {
const appEnv = isElectron ? getAppEnv() : null;
mergedEnv = appEnv
? { ...appEnv, ...env }
: {
...process.env,
...env,
PATH: getEnhancedPath(),
};
}
// 使用绝对路径的 node 时,将其所在目录插入 PATH 最前,确保子进程内再 spawn('node') 或 process.execPath 能解析到同一可执行文件(避免容器内 /usr/bin/node 不存在导致 ENOENT
if (node && path.isAbsolute(node)) {
const nodeDir = path.dirname(node);
const currentPath = mergedEnv.PATH || "";
if (!currentPath.startsWith(nodeDir + path.delimiter)) {
mergedEnv.PATH = currentPath
? `${nodeDir}${path.delimiter}${currentPath}`
: nodeDir;
log.info(
`[spawnNoWindow] ${process.platform}: prepended node dir to PATH front: ${nodeDir}`,
);
}
}
log.info(
`[spawnNoWindow] ${process.platform} debug info (Electron: ${isElectron}):`,
);
log.info(`[spawnNoWindow] - Using node: ${node}`);
log.info(
`[spawnNoWindow] - PATH first 5: ${(mergedEnv.PATH || "").split(":").slice(0, 5).join(":")}`,
);
}
return spawn(node, [jsFile, ...args], {
...spawnOptions,
env: mergedEnv,
windowsHide: true,
// Don't use shell - we're executing node directly
});
}
/**
* Spawn an npm package by resolving its entry file
*
* This is the recommended way to spawn npm packages on Windows
* to avoid console popup windows.
*
* @param packageDir - The npm package directory
* @param args - Arguments to pass to the package
* @param options - Spawn options
* @param binName - Optional bin name (if different from package name)
* @returns ChildProcess instance, or null if entry file not found
*/
export function spawnNpmPackage(
packageDir: string,
args: string[] = [],
options: SpawnNoWindowOptions = {},
binName?: string,
): ChildProcess | null {
const entryFile = resolveNpmPackageEntry(packageDir, binName);
if (!entryFile) {
return null;
}
return spawnJsFile(entryFile, args, options);
}
/**
* Check if we're running on Windows
*/
export function isWindows(): boolean {
return process.platform === "win32";
}
/**
* Check if we're running on macOS
*/
export function isMacOS(): boolean {
return process.platform === "darwin";
}
/**
* Helper to get the node executable path
* On Electron, this returns the bundled Node.js path
*/
export function getNodePath(): string {
return process.execPath;
}
// ==================== System Node Resolution (macOS/Linux) ====================
/**
* Cached user shell PATH. Resolved once and reused across calls.
* On packaged Electron apps (macOS), the user's shell profile is not inherited,
* so node/npm won't be in PATH unless we resolve it.
*/
let cachedUserShellPath: string | null | undefined;
/**
* Cached system node path. Resolved once and reused across calls.
*/
let cachedSystemNodePath: string | undefined;
/**
* Reset all internal caches.
* Useful for testing or when PATH may have changed.
*/
export function resetCache(): void {
cachedUserShellPath = undefined;
cachedSystemNodePath = undefined;
}
/**
* Resolve the user's login shell PATH on macOS/Linux.
*
* Packaged Electron apps on macOS don't inherit the user's shell profile,
* so node/npm and other tools won't be in PATH unless we resolve it.
*
* @returns The user's shell PATH, or null if not resolvable
*/
function resolveUserShellPath(): string | null {
if (cachedUserShellPath !== undefined) return cachedUserShellPath;
if (isWindows()) {
cachedUserShellPath = null;
return null;
}
try {
const shell = process.env.SHELL || "/bin/bash";
// Use login shell (-il) to source user's profile files
const result = execSync(`${shell} -ilc 'echo __PATH__=$PATH'`, {
encoding: "utf-8",
timeout: 5000,
env: { ...process.env },
});
const match = result.match(/__PATH__=(.+)/);
cachedUserShellPath = match ? match[1].trim() : null;
} catch (error) {
console.warn("[spawnNoWindow] Failed to resolve user shell PATH:", error);
cachedUserShellPath = null;
}
return cachedUserShellPath;
}
/**
* Find the system `node` executable path on macOS/Linux.
*
* Uses the user's shell PATH to find node, which works with:
* - Homebrew installed node (`/opt/homebrew/bin/node` or `/usr/local/bin/node`)
* - nvm (`~/.nvm/versions/node/...`)
* - volta (`~/.volta/bin/node`)
* - fnm (`~/.fnm/...`)
*
* @returns The path to system node, or 'node' as fallback
*/
export function findSystemNode(): string {
if (isWindows()) {
// On Windows, we use Electron's bundled node with ELECTRON_RUN_AS_NODE
return process.execPath;
}
// Return cached result if available
if (cachedSystemNodePath !== undefined) {
return cachedSystemNodePath;
}
// Try to get user's shell PATH
const userPath = resolveUserShellPath();
if (userPath) {
// Search for node in user's PATH
const pathDirs = userPath.split(path.delimiter);
for (const dir of pathDirs) {
const nodePath = path.join(dir, "node");
if (fs.existsSync(nodePath)) {
cachedSystemNodePath = nodePath;
return nodePath;
}
}
}
// Fallback: try common node installation paths
const home = process.env.HOME || process.env.USERPROFILE || "";
const commonPaths = [
"/usr/local/bin/node",
"/opt/homebrew/bin/node",
"/usr/bin/node",
path.join(home, ".nvm/versions/node/current/bin/node"),
path.join(home, ".volta/bin/node"),
path.join(home, ".fnm/current/bin/node"),
];
for (const p of commonPaths) {
if (fs.existsSync(p)) {
cachedSystemNodePath = p;
return p;
}
}
// Final fallback: assume 'node' is in PATH
// Log warning in development mode
if (process.env.NODE_ENV === "development" || !process.env.NODE_ENV) {
console.warn(
'[spawnNoWindow] Could not find system node, using "node" from PATH',
);
}
cachedSystemNodePath = "node";
return "node";
}
/**
* Get the PATH environment value with user's shell PATH prepended.
* Used for macOS/Linux to ensure subprocesses can find node and other tools.
*/
function getEnhancedPath(): string {
const currentPath = process.env.PATH || "";
const userPath = resolveUserShellPath();
if (userPath) {
// Prepend user's shell PATH to get node, npm, etc.
return `${userPath}${path.delimiter}${currentPath}`;
}
return currentPath;
}