cc 2 weken geleden
bovenliggende
commit
9e4095fa1f
40 gewijzigde bestanden met toevoegingen van 6506 en 3 verwijderingen
  1. 3 0
      ai-electron/.gitignore
  2. 1 0
      ai-electron/cmd/builder-linux.json
  3. 1 0
      ai-electron/cmd/builder-mac-arm64.json
  4. 1 0
      ai-electron/cmd/builder-mac.json
  5. 1 0
      ai-electron/cmd/builder.json
  6. 8 1
      ai-electron/electron/config/config.local.ts
  7. 327 0
      ai-electron/electron/controller/codexCtl.ts
  8. 14 0
      ai-electron/electron/preload/lifecycle.ts
  9. 234 0
      ai-electron/electron/service/codex/approvalBroker.ts
  10. 185 0
      ai-electron/electron/service/codex/codexCtl.itest.ts
  11. 43 0
      ai-electron/electron/service/codex/codexHome.ts
  12. 26 0
      ai-electron/electron/service/codex/codexLocator.test.ts
  13. 119 0
      ai-electron/electron/service/codex/codexLocator.ts
  14. 239 0
      ai-electron/electron/service/codex/codexRuntime.itest.ts
  15. 99 0
      ai-electron/electron/service/codex/codexRuntime.test.ts
  16. 796 0
      ai-electron/electron/service/codex/codexRuntime.ts
  17. 146 0
      ai-electron/electron/service/codex/eventLog.ts
  18. 182 0
      ai-electron/electron/service/codex/eventMapper.ts
  19. 164 0
      ai-electron/electron/service/codex/index.ts
  20. 95 0
      ai-electron/electron/service/codex/jsonRpcPeer.test.ts
  21. 192 0
      ai-electron/electron/service/codex/jsonRpcPeer.ts
  22. 170 0
      ai-electron/electron/service/codex/mcpService.test.ts
  23. 212 0
      ai-electron/electron/service/codex/mcpService.ts
  24. 145 0
      ai-electron/electron/service/codex/providerService.test.ts
  25. 218 0
      ai-electron/electron/service/codex/providerService.ts
  26. 424 0
      ai-electron/electron/service/codex/skillService.test.ts
  27. 498 0
      ai-electron/electron/service/codex/skillService.ts
  28. 146 0
      ai-electron/electron/service/codex/types.ts
  29. 369 0
      ai-electron/frontend/src/ai/api/codexApi.ts
  30. 1 0
      ai-electron/frontend/src/ai/api/index.ts
  31. 1102 0
      ai-electron/frontend/src/ai/views/aiPlugin/index.vue
  32. 1 0
      ai-electron/frontend/src/core/router/helper/routeHelper.ts
  33. 16 0
      ai-electron/frontend/src/core/store/modules/menu.json
  34. 10 0
      ai-electron/frontend/uno.config.ts
  35. 10 2
      ai-electron/package.json
  36. 178 0
      ai-electron/scripts/codex-ipc-probe.html
  37. 92 0
      ai-electron/scripts/codex-ipc-smoke.mjs
  38. 19 0
      ai-electron/tsconfig.json
  39. 8 0
      ai-electron/vitest.config.mts
  40. 11 0
      ai-electron/vitest.smoke.mts

+ 3 - 0
ai-electron/.gitignore

@@ -9,3 +9,6 @@ data/
 public/electron/
 public/dist/
 pnpm-lock.yaml
+
+# IPC 探针产物
+scripts/codex-ipc-probe.json

+ 1 - 0
ai-electron/cmd/builder-linux.json

@@ -6,6 +6,7 @@
     "output": "out"
   },
   "asar": true,
+  "asarUnpack": ["node_modules/@openai/codex-*/vendor/**/*"],
   "electronLanguages": ["en-US", "zh-CN"],
   "files": [
     "public",

+ 1 - 0
ai-electron/cmd/builder-mac-arm64.json

@@ -6,6 +6,7 @@
     "output": "out"
   },
   "asar": true,
+  "asarUnpack": ["node_modules/@openai/codex-*/vendor/**/*"],
   "electronLanguages": ["en-US", "zh-CN"],
   "files": [
     "public",

+ 1 - 0
ai-electron/cmd/builder-mac.json

@@ -6,6 +6,7 @@
     "output": "out"
   },
   "asar": true,
+  "asarUnpack": ["node_modules/@openai/codex-*/vendor/**/*"],
   "electronLanguages": ["en-US", "zh-CN"],
   "files": [
     "public",

+ 1 - 0
ai-electron/cmd/builder.json

@@ -6,6 +6,7 @@
     "output": "out"
   },
   "asar": true,
+  "asarUnpack": ["node_modules/@openai/codex-*/vendor/**/*"],
   "electronLanguages": ["en-US", "zh-CN"],
   "files": [
     "public",

+ 8 - 1
ai-electron/electron/config/config.local.ts

@@ -10,6 +10,13 @@ export default (): Partial<Config> => {
     },
     jobs: {
       messageLog: false
-    }
+    },
+    /**
+     * IPC 探针模式:设 ZSJZ_CODEX_PROBE=<页面 URL> 让窗口直接加载该页面,
+     * 用于在真实 Electron 里跑 controller 的全部 channel(scripts/codex-ipc-probe.html)。
+     */
+    ...(process.env.ZSJZ_CODEX_PROBE
+      ? { remote: { enable: true, url: process.env.ZSJZ_CODEX_PROBE } }
+      : {}),
   };
 };

+ 327 - 0
ai-electron/electron/controller/codexCtl.ts

@@ -0,0 +1,327 @@
+import { shell } from 'electron';
+import { logger } from 'ee-core/log';
+import { getCodexHome, getSkillsDir, isInsideDir } from '../service/codex/codexHome';
+import { probeCodexBinary } from '../service/codex/codexLocator';
+import {
+  applyProvider as applyProviderToRuntime,
+  baseUrlHint,
+  clearProvider as clearRuntimeProvider,
+  describeDroppedKeys,
+  probeResponsesEndpoint,
+} from '../service/codex/providerService';
+import {
+  defaultWorkspaceDir,
+  getAppliedProvider,
+  getCodex,
+  setAppliedProvider,
+  tailDiagnostics,
+  toStatusResult,
+} from '../service/codex/index';
+import {
+  asMessage,
+  fail,
+  ok,
+  type ApprovalAnswers,
+  type ApprovalDecision,
+  type ApplyProviderInput,
+  type CodexPingResult,
+  type CodexStatusResult,
+  type Rpc,
+} from '../service/codex/types';
+import type { ModelOption } from '../service/codex/codexRuntime';
+import type { McpServerInput, McpServerSetting } from '../service/codex/mcpService';
+import type { SkillListItem, SkillReadResult } from '../service/codex/skillService';
+import type { AgentEvent } from '../service/codex/types';
+
+const APPROVAL_DECISIONS: readonly ApprovalDecision[] = ['accept', 'acceptForSession', 'decline', 'cancel'];
+
+/** 统一的异常包装:ee-core 的 ipcMain.handle 不 catch,抛错会被 Electron 包成 "Error occurred in handler for ..." */
+async function guard<T>(label: string, run: () => Promise<T>): Promise<Rpc<T>> {
+  try {
+    return ok(await run());
+  } catch (error) {
+    const message = asMessage(error);
+    logger.error(`[codexCtl] ${label} failed:`, message);
+    return fail(message);
+  }
+}
+
+function requireString(value: unknown, label: string): string {
+  if (typeof value !== 'string' || !value.trim()) throw new Error(`${label} 不能为空`);
+  return value.trim();
+}
+
+function toBytes(value: unknown): Uint8Array {
+  if (value instanceof Uint8Array) return value;
+  if (value instanceof ArrayBuffer) return new Uint8Array(value);
+  if (Array.isArray(value)) return new Uint8Array(value as number[]);
+  if (value && typeof value === 'object' && 'data' in value) {
+    return toBytes((value as { data: unknown }).data);
+  }
+  throw new Error('文件内容必须是二进制数据');
+}
+
+class CodexCtl {
+  /** 只探测二进制与 CODEX_HOME,不 spawn 子进程 */
+  async ping(): Promise<Rpc<CodexPingResult>> {
+    return guard('ping', async () => {
+      // getCodex() 会顺带建好 CODEX_HOME 与 skills 目录
+      await getCodex();
+      const probe = await probeCodexBinary();
+      return { ...probe, codexHome: getCodexHome() };
+    });
+  }
+
+  async status(): Promise<Rpc<CodexStatusResult>> {
+    return guard('status', async () => {
+      const { runtime } = await getCodex();
+      await getAppliedProvider();
+      return toStatusResult(runtime.status);
+    });
+  }
+
+  async start(): Promise<Rpc<CodexStatusResult>> {
+    return guard('start', async () => {
+      const { runtime } = await getCodex();
+      await getAppliedProvider();
+      return toStatusResult(await runtime.start());
+    });
+  }
+
+  async stop(): Promise<Rpc<CodexStatusResult>> {
+    return guard('stop', async () => {
+      const { runtime } = await getCodex();
+      await runtime.stop();
+      return toStatusResult(runtime.status);
+    });
+  }
+
+  async restart(): Promise<Rpc<CodexStatusResult>> {
+    return guard('restart', async () => {
+      const { runtime } = await getCodex();
+      await runtime.stop();
+      await getAppliedProvider();
+      return toStatusResult(await runtime.start());
+    });
+  }
+
+  async listModels(): Promise<Rpc<ModelOption[]>> {
+    return guard('listModels', async () => {
+      const { runtime } = await getCodex();
+      return runtime.listModels();
+    });
+  }
+
+  async providerCapabilities(): Promise<Rpc<unknown>> {
+    return guard('providerCapabilities', async () => {
+      const { runtime } = await getCodex();
+      return runtime.readModelProviderCapabilities();
+    });
+  }
+
+  /** 只做端点探测,不改运行时;页面在「应用」前给用户预览兼容性 */
+  async probeProvider(params: { baseUrl?: string; apiKey?: string | null }): Promise<Rpc<unknown>> {
+    return guard('probeProvider', async () => {
+      const baseUrl = requireString(params?.baseUrl, 'baseUrl');
+      const result = await probeResponsesEndpoint(baseUrl, params?.apiKey ?? null);
+      return { ...result, hint: baseUrlHint(baseUrl) };
+    });
+  }
+
+  /** 应用后端「模型管理」里的一条记录;会重启子进程(api key 走环境变量) */
+  async applyProvider(params: ApplyProviderInput & { modelRecordId?: string | number | null }): Promise<Rpc<unknown>> {
+    return guard('applyProvider', async () => {
+      if (!params || typeof params !== 'object') throw new Error('缺少模型配置');
+      const { runtime } = await getCodex();
+      const applied = await applyProviderToRuntime(runtime, params);
+      setAppliedProvider(applied);
+      return {
+        applied,
+        hint: baseUrlHint(applied.baseUrl),
+        droppedConfigKeys: describeDroppedKeys(params),
+        status: toStatusResult(runtime.status),
+      };
+    });
+  }
+
+  async clearProvider(): Promise<Rpc<CodexStatusResult>> {
+    return guard('clearProvider', async () => {
+      const { runtime } = await getCodex();
+      await clearRuntimeProvider(runtime);
+      setAppliedProvider(null);
+      return toStatusResult(runtime.status);
+    });
+  }
+
+  async mcpList(): Promise<Rpc<McpServerSetting[]>> {
+    return guard('mcpList', async () => (await getCodex()).mcp.list());
+  }
+
+  async mcpSave(params: McpServerInput): Promise<Rpc<McpServerSetting[]>> {
+    return guard('mcpSave', async () => (await getCodex()).mcp.save(params));
+  }
+
+  async mcpRemove(params: { id?: string }): Promise<Rpc<McpServerSetting[]>> {
+    return guard('mcpRemove', async () =>
+      (await getCodex()).mcp.remove(requireString(params?.id, 'id')),
+    );
+  }
+
+  async skillList(params?: { forceReload?: boolean }): Promise<Rpc<SkillListItem[]>> {
+    return guard('skillList', async () =>
+      (await getCodex()).skills.list({ forceReload: params?.forceReload === true }),
+    );
+  }
+
+  async skillRead(params: { path?: string; name?: string }): Promise<Rpc<SkillReadResult>> {
+    return guard('skillRead', async () => (await getCodex()).skills.read(params ?? {}));
+  }
+
+  async skillInstallFolder(params: {
+    srcPath?: string;
+    name?: string | null;
+    overwrite?: boolean;
+  }): Promise<Rpc<SkillListItem[]>> {
+    return guard('skillInstallFolder', async () =>
+      (await getCodex()).skills.installFromFolder(requireString(params?.srcPath, 'srcPath'), {
+        name: params?.name ?? null,
+        overwrite: params?.overwrite === true,
+      }),
+    );
+  }
+
+  async skillInstallZip(params: {
+    data?: unknown;
+    name?: string | null;
+    overwrite?: boolean;
+  }): Promise<Rpc<SkillListItem[]>> {
+    return guard('skillInstallZip', async () =>
+      (await getCodex()).skills.installFromZip(toBytes(params?.data), {
+        name: params?.name ?? null,
+        overwrite: params?.overwrite === true,
+      }),
+    );
+  }
+
+  async skillRemove(params: { path?: string; name?: string }): Promise<Rpc<SkillListItem[]>> {
+    return guard('skillRemove', async () => (await getCodex()).skills.remove(params ?? {}));
+  }
+
+  async skillSetEnabled(params: {
+    path?: string;
+    name?: string;
+    enabled?: boolean;
+  }): Promise<Rpc<{ effectiveEnabled: boolean }>> {
+    return guard('skillSetEnabled', async () => {
+      if (typeof params?.enabled !== 'boolean') throw new Error('enabled 必须是布尔值');
+      const effectiveEnabled = await (await getCodex()).skills.setEnabled(params, params.enabled);
+      return { effectiveEnabled };
+    });
+  }
+
+  /** 只允许打开用户 skills 目录或其中的某个 skill */
+  async skillOpenFolder(params?: { path?: string }): Promise<Rpc<{ path: string }>> {
+    return guard('skillOpenFolder', async () => {
+      const skillsDir = getSkillsDir();
+      const target = params?.path ? params.path : skillsDir;
+      if (!isInsideDir(skillsDir, target) && target !== skillsDir) {
+        throw new Error('只能打开 skills 目录内的路径');
+      }
+      const result = await shell.openPath(target);
+      if (result) throw new Error(result);
+      return { path: target };
+    });
+  }
+
+  async threadStart(params?: {
+    cwd?: string | null;
+    model?: string | null;
+    approvalPolicy?: 'untrusted' | 'on-request' | 'never';
+    sandbox?: 'read-only' | 'workspace-write' | 'danger-full-access';
+  }): Promise<Rpc<{ threadId: string }>> {
+    return guard('threadStart', async () => {
+      const { runtime } = await getCodex();
+      const cwd = params?.cwd?.trim() || (await defaultWorkspaceDir());
+      const threadId = await runtime.startThread({
+        cwd,
+        model: params?.model ?? null,
+        approvalPolicy: params?.approvalPolicy ?? 'never',
+        sandbox: params?.sandbox ?? 'workspace-write',
+      });
+      return { threadId };
+    });
+  }
+
+  /** 跑一整轮并等结果;事件流同时通过 codex/event 推给渲染进程 */
+  async turnRun(params: {
+    threadId?: string;
+    input?: string;
+    cwd?: string | null;
+    model?: string | null;
+    approvalPolicy?: 'untrusted' | 'on-request' | 'never';
+    timeoutMs?: number;
+  }): Promise<Rpc<{ turnId: string; status: string; text: string }>> {
+    return guard('turnRun', async () => {
+      const { runtime } = await getCodex();
+      const threadId = requireString(params?.threadId, 'threadId');
+      const result = await runtime.runTurn({
+        threadId,
+        prompt: requireString(params?.input, 'input'),
+        cwd: params?.cwd?.trim() || undefined,
+        model: params?.model ?? null,
+        approvalPolicy: params?.approvalPolicy ?? 'never',
+        timeoutMs: typeof params?.timeoutMs === 'number' ? params.timeoutMs : undefined,
+      });
+      return { turnId: result.turnId, status: result.status, text: result.text };
+    });
+  }
+
+  async turnInterrupt(params: { threadId?: string; turnId?: string }): Promise<Rpc<Record<string, never>>> {
+    return guard('turnInterrupt', async () => {
+      const { runtime } = await getCodex();
+      await runtime.interruptTurn(requireString(params?.threadId, 'threadId'), requireString(params?.turnId, 'turnId'));
+      return {};
+    });
+  }
+
+  async threadUnsubscribe(params: { threadId?: string }): Promise<Rpc<Record<string, never>>> {
+    return guard('threadUnsubscribe', async () => {
+      const { runtime } = await getCodex();
+      await runtime.unsubscribeThread(requireString(params?.threadId, 'threadId'));
+      return {};
+    });
+  }
+
+  async approvalResolve(params: {
+    token?: string;
+    decision?: ApprovalDecision;
+    answers?: ApprovalAnswers;
+  }): Promise<Rpc<Record<string, never>>> {
+    return guard('approvalResolve', async () => {
+      const { broker } = await getCodex();
+      const token = requireString(params?.token, 'token');
+      const decision = params?.decision;
+      if (!decision || !APPROVAL_DECISIONS.includes(decision)) throw new Error('decision 不合法');
+      broker.resolve(token, decision, params?.answers);
+      return {};
+    });
+  }
+
+  /** 读回某个 thread 已落盘的事件(页面刷新后恢复现场) */
+  async eventsRead(params: { threadId?: string; limit?: number }): Promise<Rpc<AgentEvent[]>> {
+    return guard('eventsRead', async () => {
+      const { eventLog } = await getCodex();
+      const threadId = requireString(params?.threadId, 'threadId');
+      return eventLog.read(threadId, typeof params?.limit === 'number' ? params.limit : undefined);
+    });
+  }
+
+  async logsTail(params?: { limit?: number }): Promise<Rpc<Array<{ ts: string; line: string }>>> {
+    return guard('logsTail', async () =>
+      tailDiagnostics(typeof params?.limit === 'number' ? params.limit : undefined),
+    );
+  }
+}
+
+CodexCtl.toString = () => 'CodexCtl';
+export default CodexCtl;

+ 14 - 0
ai-electron/electron/preload/lifecycle.ts

@@ -2,6 +2,8 @@ import { app as electronApp, screen } from 'electron';
 import { logger } from 'ee-core/log';
 import { getConfig } from 'ee-core/config';
 import { getMainWindow } from 'ee-core/electron';
+import { disposeCodex } from '../service/codex';
+import { locateCodexBinary } from '../service/codex/codexLocator';
 
 class Lifecycle {
   /**
@@ -9,6 +11,12 @@ class Lifecycle {
    */
   async ready(): Promise<void> {
     logger.info('[lifecycle] ready');
+    try {
+      // 只解析路径、不 spawn:打包后 asar / asar.unpacked 的定位问题一眼可见
+      logger.info('[lifecycle] codex 二进制:', await locateCodexBinary());
+    } catch (error) {
+      logger.error('[lifecycle] codex 二进制不可用:', error instanceof Error ? error.message : String(error));
+    }
   }
 
   /**
@@ -62,6 +70,12 @@ class Lifecycle {
    */  
   async beforeClose(): Promise<void> {
     logger.info('[lifecycle] before-close');
+    try {
+      // 回收 codex app-server 子进程,避免退出后留下孤儿进程
+      await disposeCodex();
+    } catch (error) {
+      logger.error('[lifecycle] dispose codex failed:', error instanceof Error ? error.message : String(error));
+    }
   }
 }
 export {

+ 234 - 0
ai-electron/electron/service/codex/approvalBroker.ts

@@ -0,0 +1,234 @@
+import { randomUUID } from 'node:crypto';
+import { EventEmitter } from 'node:events';
+import type { CodexRuntime } from './codexRuntime';
+import type { JsonRpcId, JsonRpcServerRequest } from './jsonRpcPeer';
+import type { ApprovalAnswers, ApprovalDecision, ApprovalKind, ApprovalRequest } from './types';
+
+/**
+ * 移植自 Noobi.ai src/main/approvalBroker.ts:维度由 projectId 改为 threadId。
+ * 默认 approvalPolicy 是 'never'(无人值守),本 broker 是为将来切到 on-request 时准备的,
+ * 同时兜住 Codex 主动发来的 currentTime/read 等服务端请求。
+ */
+
+interface PendingApproval {
+  request: JsonRpcServerRequest;
+  approval: ApprovalRequest;
+  timer: NodeJS.Timeout;
+}
+
+const APPROVAL_TIMEOUT_MS = 2 * 60 * 1_000;
+
+type ApprovalRuntime = Pick<CodexRuntime, 'respondToServerRequest' | 'rejectServerRequest'>;
+
+export class ApprovalBroker extends EventEmitter {
+  readonly #runtime: ApprovalRuntime;
+  readonly #pending = new Map<string, PendingApproval>();
+  readonly #tokenForRequest = new Map<JsonRpcId, string>();
+
+  constructor(runtime: ApprovalRuntime) {
+    super();
+    this.#runtime = runtime;
+  }
+
+  handle(request: JsonRpcServerRequest): void {
+    const kind = approvalKind(request.method);
+    if (!kind) {
+      if (request.method === 'currentTime/read') {
+        this.#runtime.respondToServerRequest(request.id, {
+          currentTimeAt: Math.floor(Date.now() / 1_000),
+        });
+        return;
+      }
+      this.#runtime.rejectServerRequest(request.id, -32601, `本客户端不支持 ${request.method}`);
+      this.emit('diagnostic', `已拒绝不支持的 Codex 请求:${request.method}`);
+      return;
+    }
+
+    const params = asRecord(request.params) ?? {};
+    const token = randomUUID();
+    const approval: ApprovalRequest = {
+      token,
+      threadId: readString(params.threadId),
+      kind,
+      method: request.method,
+      title: approvalTitle(kind),
+      summary: approvalSummary(kind, params),
+      details: redact(params),
+      createdAt: new Date().toISOString(),
+    };
+    const timer = setTimeout(() => this.#expire(token), APPROVAL_TIMEOUT_MS);
+    timer.unref();
+    this.#pending.set(token, { request, approval, timer });
+    this.#tokenForRequest.set(request.id, token);
+    this.emit('approval', structuredClone(approval));
+  }
+
+  resolve(token: string, decision: ApprovalDecision, answers?: ApprovalAnswers): void {
+    const current = this.#pending.get(token);
+    if (!current) throw new Error('该审批已失效');
+    const response = approvalResponse(current.request, decision, answers);
+    const pending = this.#take(token)!;
+    try {
+      this.#runtime.respondToServerRequest(pending.request.id, response);
+    } finally {
+      this.emit('closed', token);
+    }
+  }
+
+  /** Codex 自己撤销了请求(serverRequest/resolved)时,本地同步失效 */
+  resolveFromServer(requestId: JsonRpcId): void {
+    const token = this.#tokenForRequest.get(requestId);
+    if (token && this.#take(token)) this.emit('closed', token);
+  }
+
+  closeAll(): void {
+    for (const token of [...this.#pending.keys()]) {
+      const pending = this.#take(token);
+      if (!pending) continue;
+      try {
+        this.#runtime.respondToServerRequest(
+          pending.request.id,
+          approvalResponse(pending.request, 'decline'),
+        );
+      } catch {
+        // 运行时已关闭:待处理审批只在本地失效
+      } finally {
+        this.emit('closed', token);
+      }
+    }
+  }
+
+  /** 只本地失效,不往新的或已失败的运行时写响应 */
+  invalidateAll(): void {
+    for (const token of [...this.#pending.keys()]) {
+      if (this.#take(token)) this.emit('closed', token);
+    }
+  }
+
+  #expire(token: string): void {
+    const pending = this.#take(token);
+    if (!pending) return;
+    try {
+      this.#runtime.respondToServerRequest(
+        pending.request.id,
+        approvalResponse(pending.request, 'decline'),
+      );
+    } catch (error) {
+      this.emit('diagnostic', `无法发送过期审批的响应:${asError(error).message}`);
+    } finally {
+      this.emit('expired', pending.approval);
+      this.emit('closed', token);
+    }
+  }
+
+  #take(token: string): PendingApproval | null {
+    const pending = this.#pending.get(token);
+    if (!pending) return null;
+    clearTimeout(pending.timer);
+    this.#pending.delete(token);
+    if (this.#tokenForRequest.get(pending.request.id) === token) {
+      this.#tokenForRequest.delete(pending.request.id);
+    }
+    return pending;
+  }
+}
+
+function approvalKind(method: string): ApprovalKind | null {
+  if (method === 'item/commandExecution/requestApproval') return 'command';
+  if (method === 'item/fileChange/requestApproval') return 'file';
+  if (method === 'item/permissions/requestApproval') return 'permissions';
+  if (method === 'item/tool/requestUserInput' || method === 'mcpServer/elicitation/request') {
+    return 'input';
+  }
+  return null;
+}
+
+function approvalResponse(
+  request: JsonRpcServerRequest,
+  decision: ApprovalDecision,
+  answers?: ApprovalAnswers,
+): unknown {
+  if (
+    request.method === 'item/commandExecution/requestApproval' ||
+    request.method === 'item/fileChange/requestApproval'
+  ) {
+    return { decision };
+  }
+  if (request.method === 'item/permissions/requestApproval') {
+    const requested = asRecord(request.params)?.permissions;
+    const accepted = decision === 'accept' || decision === 'acceptForSession';
+    return {
+      scope: decision === 'acceptForSession' ? 'session' : 'turn',
+      permissions: accepted && requested && typeof requested === 'object' ? requested : {},
+    };
+  }
+  if (request.method === 'item/tool/requestUserInput') {
+    return { answers: decision === 'accept' ? validatedAnswers(request.params, answers) : {} };
+  }
+  if (request.method === 'mcpServer/elicitation/request') {
+    return { action: decision === 'cancel' ? 'cancel' : 'decline', content: null };
+  }
+  throw new Error(`不支持的审批响应方法:${request.method}`);
+}
+
+function validatedAnswers(
+  params: unknown,
+  answers: ApprovalAnswers | undefined,
+): Record<string, { answers: string[] }> {
+  const questions = asRecord(params)?.questions;
+  if (!Array.isArray(questions) || questions.length === 0) return {};
+  const result: Record<string, { answers: string[] }> = {};
+  for (const question of questions) {
+    const id = readString(asRecord(question)?.id);
+    if (!id) throw new Error('Codex 的提问缺少 id');
+    const values = answers?.[id];
+    if (!Array.isArray(values) || values.length === 0) throw new Error(`请回答 Codex 的提问:${id}`);
+    if (values.length > 20 || values.some((value) => typeof value !== 'string' || value.length > 10_000)) {
+      throw new Error(`提问 ${id} 的回答不合法`);
+    }
+    result[id] = { answers: [...values] };
+  }
+  return result;
+}
+
+function approvalTitle(kind: ApprovalKind): string {
+  if (kind === 'command') return '允许执行命令?';
+  if (kind === 'file') return '允许修改文件?';
+  if (kind === 'permissions') return '允许额外权限?';
+  return 'Codex 需要你的输入';
+}
+
+function approvalSummary(kind: ApprovalKind, params: Record<string, unknown>): string {
+  if (kind === 'command') {
+    return readString(params.command) ?? readString(params.reason) ?? 'Codex 请求运行一条命令';
+  }
+  if (kind === 'file') {
+    return readString(params.reason) ?? readString(params.grantRoot) ?? 'Codex 请求写入项目文件';
+  }
+  if (kind === 'permissions') {
+    return readString(params.reason) ?? 'Codex 请求扩大当前回合的访问范围';
+  }
+  return readString(params.message) ?? readString(params.reason) ?? '请在 Codex 任务中继续提供信息';
+}
+
+function redact(value: Record<string, unknown>): Record<string, unknown> {
+  const clone = structuredClone(value);
+  for (const key of Object.keys(clone)) {
+    if (/token|authorization|api.?key|secret/iu.test(key)) clone[key] = '[redacted]';
+  }
+  return clone;
+}
+
+function asRecord(value: unknown): Record<string, unknown> | null {
+  return value && typeof value === 'object' && !Array.isArray(value)
+    ? (value as Record<string, unknown>)
+    : null;
+}
+
+function readString(value: unknown): string | null {
+  return typeof value === 'string' ? value : null;
+}
+
+function asError(value: unknown): Error {
+  return value instanceof Error ? value : new Error(String(value));
+}

+ 185 - 0
ai-electron/electron/service/codex/codexCtl.itest.ts

@@ -0,0 +1,185 @@
+import { createServer, type Server } from 'node:http';
+import { mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import CodexCtl from '../../controller/codexCtl';
+import { disposeCodex } from './index';
+import type { Rpc } from './types';
+
+/**
+ * controller 层集成冒烟:绕过 Electron 传输(P0 已单独验证),
+ * 直接实例化 CodexCtl,覆盖 guard() 异常包装 + 真实 codex 子进程 + MCP/Skill 全链路。
+ */
+
+function unwrap<T>(rpc: Rpc<T>): T {
+  if (!rpc.success) throw new Error(rpc.message);
+  return rpc.data;
+}
+
+function expectFailure<T>(rpc: Rpc<T>, pattern: RegExp): void {
+  expect(rpc.success).toBe(false);
+  if (!rpc.success) expect(rpc.message).toMatch(pattern);
+}
+
+const SKILL_MD = ['---', 'name: ctl-smoke', 'description: controller 冒烟用技能', '---', '', '# ctl', ''].join('\n');
+
+const ctl = new CodexCtl();
+let server: Server;
+let notFoundUrl: string;
+let tmpRoot: string;
+
+beforeAll(async () => {
+  server = createServer((req, res) => {
+    req.resume();
+    res.writeHead(req.url?.endsWith('/responses') ? 404 : 200, { 'content-type': 'application/json' });
+    res.end('{}');
+  });
+  await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
+  const address = server.address();
+  const port = typeof address === 'object' && address ? address.port : 0;
+  notFoundUrl = `http://127.0.0.1:${port}/v1`;
+  tmpRoot = await mkdtemp(join(tmpdir(), 'zsjz-ctl-smoke-'));
+}, 120_000);
+
+afterAll(async () => {
+  await disposeCodex();
+  await new Promise<void>((resolve) => server?.close(() => resolve()));
+  await rm(tmpRoot, { recursive: true, force: true }).catch(() => undefined);
+});
+
+describe('CodexCtl 运行时', () => {
+  it('ping 返回真实 codex 版本与 CODEX_HOME', async () => {
+    const data = unwrap(await ctl.ping());
+    expect(data.available).toBe(true);
+    expect(data.version).toMatch(/codex-cli/);
+    expect(data.codexHome).toBeTruthy();
+  });
+
+  it('status 不 spawn 子进程也能返回', async () => {
+    const data = unwrap(await ctl.status());
+    expect(['stopped', 'starting', 'ready', 'error']).toContain(data.state);
+    expect(data.codexHome).toBeTruthy();
+    expect(data.applied === null || typeof data.applied === 'object').toBe(true);
+  });
+
+  it('start / stop 改变运行状态', async () => {
+    const started = unwrap(await ctl.start());
+    expect(started.running).toBe(true);
+    const stopped = unwrap(await ctl.stop());
+    expect(stopped.state).toBe('stopped');
+  }, 120_000);
+});
+
+describe('CodexCtl MCP', () => {
+  it('新增 → 列表 → 删除', async () => {
+    const saved = unwrap(
+      await ctl.mcpSave({
+        id: 'ctl-stdio',
+        transport: 'stdio',
+        command: 'node',
+        args: ['-e', 'process.exit(0)'],
+        enabled: true,
+      }),
+    );
+    expect(saved.find((item) => item.id === 'ctl-stdio')).toMatchObject({
+      transport: 'stdio',
+      command: 'node',
+      enabled: true,
+    });
+
+    const list = unwrap(await ctl.mcpList());
+    expect(list.map((item) => item.id)).toContain('ctl-stdio');
+
+    const removed = unwrap(await ctl.mcpRemove({ id: 'ctl-stdio' }));
+    expect(removed.find((item) => item.id === 'ctl-stdio')).toBeUndefined();
+  }, 120_000);
+
+  it('非法入参被 guard 包成 success:false,不抛给 Electron', async () => {
+    expectFailure(await ctl.mcpSave({ id: '有 空格', transport: 'stdio', command: 'x', enabled: true }), /MCP ID/);
+    expectFailure(await ctl.mcpRemove({}), /不能为空/);
+    expectFailure(
+      await ctl.mcpSave({ id: 'a', transport: 'http', url: 'http://10.1.2.3/mcp', enabled: true }),
+      /HTTPS/,
+    );
+  }, 120_000);
+});
+
+describe('CodexCtl Skill', () => {
+  it('从目录安装 → 列表可见 → 启停 → 卸载', async () => {
+    const source = join(tmpRoot, 'skill');
+    await mkdir(source, { recursive: true });
+    await writeFile(join(source, 'SKILL.md'), SKILL_MD, 'utf8');
+
+    const installed = unwrap(await ctl.skillInstallFolder({ srcPath: source }));
+    const item = installed.find((skill) => skill.name === 'ctl-smoke');
+    expect(item).toBeTruthy();
+    expect(item?.managed).toBe(true);
+    expect(item?.description).toBe('controller 冒烟用技能');
+
+    const toggled = unwrap(await ctl.skillSetEnabled({ path: item!.path, enabled: false }));
+    expect(toggled.effectiveEnabled).toBe(false);
+
+    const read = unwrap(await ctl.skillRead({ name: 'ctl-smoke' }));
+    expect(read.content).toContain('ctl-smoke');
+
+    const removed = unwrap(await ctl.skillRemove({ name: 'ctl-smoke' }));
+    expect(removed.find((skill) => skill.name === 'ctl-smoke')).toBeUndefined();
+    expect(await stat(item!.path).catch(() => null)).toBeNull();
+  }, 180_000);
+
+  it('内置 skill 不可卸载', async () => {
+    const list = unwrap(await ctl.skillList({ forceReload: true }));
+    const builtin = list.find((skill) => !skill.managed);
+    expect(builtin).toBeTruthy();
+    expectFailure(await ctl.skillRemove({ path: builtin!.path }), /内置或暂存目录/);
+  }, 120_000);
+
+  it('zip 字节安装与非法字节都被正确处理', async () => {
+    expectFailure(await ctl.skillInstallZip({ data: [1, 2, 3] }), /无法解析 zip/);
+    expectFailure(await ctl.skillInstallFolder({ srcPath: join(tmpRoot, 'not-exist') }), /不是一个目录/);
+  }, 120_000);
+});
+
+describe('CodexCtl 模型 provider', () => {
+  it('探测到未实现 Responses 的端点时给出明确原因', async () => {
+    expectFailure(
+      await ctl.applyProvider({ modelId: 'x', baseUrl: notFoundUrl, apiKey: 'sk-nope', providerType: 'OPENAI' }),
+      /未实现 Responses API/,
+    );
+  }, 120_000);
+
+  it('probeProvider 返回兼容性与 base_url 提醒', async () => {
+    const result = (await ctl.probeProvider({ baseUrl: notFoundUrl })) as Rpc<{
+      compatible: boolean;
+      status: number | null;
+      detail: string;
+      hint: string | null;
+    }>;
+    const data = unwrap(result);
+    expect(data.compatible).toBe(false);
+    expect(data.status).toBe(404);
+  }, 120_000);
+
+  it('缺 modelId 时报错', async () => {
+    expectFailure(await ctl.applyProvider({ modelId: '  ' }), /modelId/);
+  }, 120_000);
+});
+
+describe('CodexCtl 会话与诊断', () => {
+  it('turnInterrupt 缺参时报错而不是抛异常', async () => {
+    expectFailure(await ctl.turnInterrupt({}), /不能为空/);
+  });
+
+  it('approvalResolve 校验 decision 白名单', async () => {
+    expectFailure(
+      await ctl.approvalResolve({ token: 'nope', decision: 'whatever' as never }),
+      /decision 不合法/,
+    );
+  });
+
+  it('logsTail 返回数组', async () => {
+    const logs = unwrap(await ctl.logsTail({ limit: 10 }));
+    expect(Array.isArray(logs)).toBe(true);
+  });
+});

+ 43 - 0
ai-electron/electron/service/codex/codexHome.ts

@@ -0,0 +1,43 @@
+import { mkdir } from 'node:fs/promises';
+import { join, resolve, sep } from 'node:path';
+import { getDataDir } from 'ee-core/ps';
+
+/** Codex 的 HOME:配置(config.toml)、skills、会话记录都落在这里 */
+export function getCodexHome(): string {
+  return join(getDataDir(), 'codex-home');
+}
+
+/** 用户自装 skill 的目录;内置 skill 在同级的 .system/.curated/.experimental 下,不允许删改 */
+export function getSkillsDir(): string {
+  return join(getCodexHome(), 'skills');
+}
+
+/** 我方自己的运行数据目录,刻意放在 CODEX_HOME 之外,避免被 Codex 当成项目内容扫描 */
+export function getCodexDataDir(): string {
+  return join(getDataDir(), 'codex-data');
+}
+
+export function getEventsDir(): string {
+  return join(getCodexDataDir(), 'events');
+}
+
+export async function ensureCodexHome(): Promise<string> {
+  const codexHome = getCodexHome();
+  // 0700:目录里有 config.toml 与用户装的 skill,不给同机其它账户读
+  await mkdir(codexHome, { recursive: true, mode: 0o700 });
+  await mkdir(getSkillsDir(), { recursive: true });
+  await mkdir(getEventsDir(), { recursive: true });
+  return codexHome;
+}
+
+/** 目录名合法性:小写连字符,最长 64 */
+export const SKILL_NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+){0,9}$/u;
+export const MAX_SKILL_NAME_LENGTH = 64;
+
+/** 路径包含判断,用于拦住 skill 安装/删除时的越界与 `..` 穿越 */
+export function isInsideDir(parentDir: string, target: string): boolean {
+  const parent = resolve(parentDir);
+  const child = resolve(target);
+  if (child === parent) return false;
+  return child.startsWith(parent.endsWith(sep) ? parent : parent + sep);
+}

+ 26 - 0
ai-electron/electron/service/codex/codexLocator.test.ts

@@ -0,0 +1,26 @@
+import { describe, expect, it } from 'vitest';
+import { unpackedAsarPath } from './codexLocator';
+
+describe('unpackedAsarPath', () => {
+  it('把 asar 内的可执行文件路径换成 unpacked 目录', () => {
+    expect(
+      unpackedAsarPath('C:\\app\\resources\\app.asar\\node_modules\\@openai\\codex-win32-x64\\bin\\codex.exe'),
+    ).toBe(
+      'C:\\app\\resources\\app.asar.unpacked\\node_modules\\@openai\\codex-win32-x64\\bin\\codex.exe',
+    );
+    expect(unpackedAsarPath('/app/resources/app.asar/node_modules/x/bin/codex')).toBe(
+      '/app/resources/app.asar.unpacked/node_modules/x/bin/codex',
+    );
+  });
+
+  it('非路径边界的 app.asar 字样不误伤', () => {
+    expect(unpackedAsarPath('/tmp/app.asarfoo/bar')).toBe('/tmp/app.asarfoo/bar');
+    expect(unpackedAsarPath('/tmp/notapp.asar/bar')).toBe('/tmp/notapp.asar/bar');
+  });
+
+  it('未打包时原样返回', () => {
+    expect(unpackedAsarPath('E:/workspace/zsjz-ai/ai-electron/node_modules/x/codex.exe')).toBe(
+      'E:/workspace/zsjz-ai/ai-electron/node_modules/x/codex.exe',
+    );
+  });
+});

+ 119 - 0
ai-electron/electron/service/codex/codexLocator.ts

@@ -0,0 +1,119 @@
+import { access, realpath } from 'node:fs/promises';
+import { constants } from 'node:fs';
+import { createRequire } from 'node:module';
+import { dirname, join } from 'node:path';
+import { spawnSync } from 'node:child_process';
+import { getBaseDir } from 'ee-core/ps';
+
+/** 移植自 Noobi.ai src/main/codexLocator.ts,改动:去掉 ChatGPT.app 分支、改用 CJS 可用的 require 基准、增加不抛错的探测入口 */
+const TARGETS: Record<string, { packageName: string; triple: string; executable: string }> = {
+  'darwin-arm64': {
+    packageName: '@openai/codex-darwin-arm64',
+    triple: 'aarch64-apple-darwin',
+    executable: 'codex',
+  },
+  'darwin-x64': {
+    packageName: '@openai/codex-darwin-x64',
+    triple: 'x86_64-apple-darwin',
+    executable: 'codex',
+  },
+  'linux-arm64': {
+    packageName: '@openai/codex-linux-arm64',
+    triple: 'aarch64-unknown-linux-musl',
+    executable: 'codex',
+  },
+  'linux-x64': {
+    packageName: '@openai/codex-linux-x64',
+    triple: 'x86_64-unknown-linux-musl',
+    executable: 'codex',
+  },
+  'win32-arm64': {
+    packageName: '@openai/codex-win32-arm64',
+    triple: 'aarch64-pc-windows-msvc',
+    executable: 'codex.exe',
+  },
+  'win32-x64': {
+    packageName: '@openai/codex-win32-x64',
+    triple: 'x86_64-pc-windows-msvc',
+    executable: 'codex.exe',
+  },
+};
+
+export interface CodexBinaryProbe {
+  available: boolean;
+  binaryPath: string | null;
+  version: string | null;
+  reason: string | null;
+}
+
+/** 探测顺序:环境变量 → 平台包 vendor → 应用根 node_modules → 系统 PATH */
+function collectCandidates(): string[] {
+  const candidates: string[] = [];
+  if (process.env.ZSJZ_CODEX_BIN) candidates.push(process.env.ZSJZ_CODEX_BIN);
+
+  const target = TARGETS[`${process.platform}-${process.arch}`];
+  if (target) {
+    // ee-bin 把主进程 bundle 成单文件 CJS,这里不能依赖 import.meta.url
+    try {
+      const packageJson = createRequire(__filename).resolve(`${target.packageName}/package.json`);
+      candidates.push(join(dirname(packageJson), 'vendor', target.triple, 'bin', target.executable));
+    } catch {
+      // 平台包是 optionalDependency,缺失时继续尝试下面的候选
+    }
+    candidates.push(join(getBaseDir(), 'node_modules', target.packageName, 'vendor', target.triple, 'bin', target.executable));
+  }
+
+  const which = spawnSync(process.platform === 'win32' ? 'where' : 'which', ['codex'], {
+    encoding: 'utf8',
+    timeout: 3_000,
+  });
+  if (which.status === 0 && which.stdout.trim()) {
+    candidates.push(which.stdout.trim().split(/\r?\n/u)[0]!);
+  }
+  return deduplicate(candidates);
+}
+
+/** 打包后 node_modules 在 app.asar 内,可执行文件必须走 asar.unpacked(builder.json 的 asarUnpack) */
+export function unpackedAsarPath(value: string): string {
+  return value.replace(/([/\\])app\.asar([/\\])/u, '$1app.asar.unpacked$2');
+}
+
+function deduplicate(values: string[]): string[] {
+  return [...new Set(values.filter(Boolean))];
+}
+
+export async function locateCodexBinary(): Promise<string> {
+  const failures: string[] = [];
+  for (const lexical of collectCandidates()) {
+    const candidate = unpackedAsarPath(lexical);
+    try {
+      await access(candidate, constants.X_OK);
+      return await realpath(candidate);
+    } catch (error) {
+      failures.push(`${candidate}: ${error instanceof Error ? error.message : String(error)}`);
+    }
+  }
+  throw new Error(
+    `找不到可执行的 Codex 二进制,请设置 ZSJZ_CODEX_BIN。已检查 ${failures.length} 个候选路径。`,
+  );
+}
+
+/** 供 ping 使用:任何失败都降级成 available:false + reason,不抛错 */
+export async function probeCodexBinary(): Promise<CodexBinaryProbe> {
+  try {
+    const binaryPath = await locateCodexBinary();
+    return { available: true, binaryPath, version: readCodexVersion(binaryPath), reason: null };
+  } catch (error) {
+    return {
+      available: false,
+      binaryPath: null,
+      version: null,
+      reason: error instanceof Error ? error.message : String(error),
+    };
+  }
+}
+
+export function readCodexVersion(binaryPath: string): string | null {
+  const result = spawnSync(binaryPath, ['--version'], { encoding: 'utf8', timeout: 5_000 });
+  return result.status === 0 ? result.stdout.trim() || null : null;
+}

+ 239 - 0
ai-electron/electron/service/codex/codexRuntime.itest.ts

@@ -0,0 +1,239 @@
+import { createServer, type Server } from 'node:http';
+import { mkdtemp, mkdir, rm, stat, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { CodexRuntime } from './codexRuntime';
+import { ensureCodexHome, getSkillsDir } from './codexHome';
+import { McpService } from './mcpService';
+import { SkillService } from './skillService';
+import { applyProvider, clearProvider, readAppliedProvider } from './providerService';
+
+/**
+ * 集成冒烟:真实 spawn codex app-server,但不需要账号登录、不需要真实模型端点。
+ * 默认 npm test 不跑(.itest.ts),用 npm run smoke:codex 单独跑。
+ */
+
+const API_KEY = 'sk-smoke-secret-value-1234567890';
+const SKILL_MD = ['---', 'name: smoke-skill', 'description: 冒烟测试用的技能', '---', '', '# smoke', ''].join('\n');
+
+let server: Server;
+let nextStatus = 401;
+let baseUrl: string;
+let codexHome: string;
+let runtime: CodexRuntime;
+let tmpRoot: string;
+
+beforeAll(async () => {
+  // 假端点:401 表示「路由存在但需要鉴权」,足以让 provider 探测判为兼容
+  server = createServer((req, res) => {
+    req.resume();
+    res.writeHead(nextStatus, { 'content-type': 'application/json' });
+    res.end(JSON.stringify({ error: { message: 'smoke endpoint' } }));
+  });
+  await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
+  const address = server.address();
+  const port = typeof address === 'object' && address ? address.port : 0;
+  baseUrl = `http://127.0.0.1:${port}/v1`;
+  codexHome = await ensureCodexHome();
+  tmpRoot = await mkdtemp(join(tmpdir(), 'zsjz-codex-smoke-'));
+  runtime = new CodexRuntime({
+    codexHome,
+    provider: { id: 'smoke', name: '冒烟端点', baseUrl, model: 'smoke-model', apiKey: API_KEY },
+  });
+  await runtime.start();
+}, 120_000);
+
+afterAll(async () => {
+  await runtime?.dispose();
+  await new Promise<void>((resolve) => server?.close(() => resolve()));
+  await rm(tmpRoot, { recursive: true, force: true }).catch(() => undefined);
+});
+
+describe('免登录启动与配置注入', () => {
+  it('自定义 provider 完成握手,config 里没有 api key', async () => {
+    const status = runtime.status;
+    expect(status.state).toBe('ready');
+    expect(status.version).toMatch(/codex-cli/);
+    expect(status.providerId).toBe('smoke');
+
+    const config = await runtime.readConfig();
+    expect(config.model_provider).toBe('smoke');
+    expect(config.model).toBe('smoke-model');
+    const entry = (config.model_providers as Record<string, Record<string, unknown>>)?.smoke;
+    expect(entry?.base_url).toBe(baseUrl);
+    expect(entry?.wire_api).toBe('responses');
+    expect(entry?.requires_openai_auth).toBe(false);
+    expect(entry?.env_key).toBe('ZSJZ_CODEX_API_KEY');
+    expect(JSON.stringify(config)).not.toContain(API_KEY);
+  });
+
+  it('defaultModel 用 provider 配置的模型,不被内置目录默认值覆盖', () => {
+    expect(runtime.status.defaultModel).toBe('smoke-model');
+    expect(Array.isArray(runtime.status.models)).toBe(true);
+  });
+
+  it('未登录也能列 skill 与 MCP 状态', async () => {
+    const skills = await runtime.listSkills({ forceReload: true });
+    expect(Array.isArray(skills)).toBe(true);
+    expect(skills.some((skill) => skill.scope === 'system')).toBe(true);
+    expect(Array.isArray(await runtime.listMcpServerStatuses())).toBe(true);
+  });
+});
+
+describe('MCP 配置热生效', () => {
+  it('保存 → 出现在 config 与列表 → 删除后消失,全程不重启', async () => {
+    const service = new McpService(runtime);
+    const before = await service.list();
+
+    const saved = await service.save({
+      id: 'smoke-stdio',
+      transport: 'stdio',
+      command: 'node',
+      args: ['-e', 'process.exit(0)'],
+      enabled: false,
+    });
+    const entry = saved.find((item) => item.id === 'smoke-stdio');
+    expect(entry).toMatchObject({ transport: 'stdio', command: 'node', enabled: false });
+    expect(saved.length).toBe(before.length + 1);
+
+    const config = await runtime.readConfig();
+    const servers = config.mcp_servers as Record<string, Record<string, unknown>>;
+    expect(servers['smoke-stdio']).toMatchObject({ command: 'node', enabled: false });
+
+    const afterRemove = await service.remove('smoke-stdio');
+    expect(afterRemove.find((item) => item.id === 'smoke-stdio')).toBeUndefined();
+    expect(runtime.status.state).toBe('ready');
+  });
+
+  it('http 传输写入 url 与 bearer_token_env_var', async () => {
+    const service = new McpService(runtime);
+    await service.save({
+      id: 'smoke-http',
+      transport: 'http',
+      url: 'http://127.0.0.1:9999/mcp',
+      enabled: false,
+      bearerTokenEnvVar: 'ZSJZ_SMOKE_TOKEN',
+    });
+    const config = await runtime.readConfig();
+    const servers = config.mcp_servers as Record<string, Record<string, unknown>>;
+    expect(servers['smoke-http']).toMatchObject({
+      url: 'http://127.0.0.1:9999/mcp',
+      bearer_token_env_var: 'ZSJZ_SMOKE_TOKEN',
+    });
+    await service.remove('smoke-http');
+  });
+});
+
+describe('Skill 安装能被 Codex 识别', () => {
+  it('装目录 → skills/list 可见 → 启停 → 卸载', async () => {
+    const service = new SkillService(runtime);
+    const source = join(tmpRoot, 'skill-src');
+    await mkdir(join(source, 'references'), { recursive: true });
+    await writeFile(join(source, 'SKILL.md'), SKILL_MD, 'utf8');
+    await writeFile(join(source, 'references', 'note.md'), 'note', 'utf8');
+
+    await service.installFromFolder(source, { overwrite: true });
+    const installedDir = join(getSkillsDir(), 'smoke-skill');
+    expect((await stat(join(installedDir, 'SKILL.md'))).isFile()).toBe(true);
+
+    // 关键断言:我们自己写的目录布局确实被 Codex 认出来
+    const listed = await service.list({ forceReload: true });
+    const installed = listed.find((item) => item.name === 'smoke-skill');
+    expect(installed).toBeTruthy();
+    expect(installed?.managed).toBe(true);
+    expect(installed?.description).toBe('冒烟测试用的技能');
+
+    expect(await service.setEnabled({ name: 'smoke-skill' }, false)).toBe(false);
+    const disabled = await service.list({ forceReload: true });
+    expect(disabled.find((item) => item.name === 'smoke-skill')?.enabled).toBe(false);
+    expect(await service.setEnabled({ name: 'smoke-skill' }, true)).toBe(true);
+
+    const read = await service.read({ name: 'smoke-skill' });
+    expect(read.content).toContain('smoke-skill');
+    expect(read.files.map((file) => file.path).sort()).toEqual(['SKILL.md', 'references/note.md']);
+
+    await service.remove({ name: 'smoke-skill' });
+    expect(await stat(installedDir).catch(() => null)).toBeNull();
+    const afterRemove = await service.list({ forceReload: true });
+    expect(afterRemove.find((item) => item.name === 'smoke-skill')).toBeUndefined();
+  });
+
+  it('删除后 config 里的残留条目要真的被清掉', async () => {
+    const service = new SkillService(runtime);
+    const source = join(tmpRoot, 'stale-skill');
+    await mkdir(source, { recursive: true });
+    await writeFile(join(source, 'SKILL.md'), SKILL_MD.replace('smoke-skill', 'stale-skill'), 'utf8');
+
+    await service.installFromFolder(source, { overwrite: true });
+    await service.setEnabled({ name: 'stale-skill' }, false);
+    const before = await runtime.readConfig();
+    expect(JSON.stringify(before)).toContain('stale-skill');
+
+    await service.remove({ name: 'stale-skill' });
+    const after = await runtime.readConfig();
+    expect(JSON.stringify(after)).not.toContain('stale-skill');
+  }, 120_000);
+
+  it('内置 skill 被标记为不可管理', async () => {
+    const service = new SkillService(runtime);
+    const items = await service.list();
+    const builtin = items.find((item) => item.scope === 'system');
+    expect(builtin?.managed).toBe(false);
+    if (builtin) {
+      await expect(service.remove({ path: builtin.path })).rejects.toThrow(/内置或暂存目录/);
+    }
+  });
+});
+
+describe('切换 provider 会重启子进程', () => {
+  it('applyProvider 后 config 更新且磁盘上没有密钥', async () => {
+    nextStatus = 401;
+    const applied = await applyProvider(runtime, {
+      modelId: 'smoke-model-2',
+      name: '冒烟端点 2',
+      baseUrl,
+      apiKey: API_KEY,
+      providerType: 'OPENAI',
+      modelRecordId: 42,
+    });
+    expect(applied).toMatchObject({ model: 'smoke-model-2', providerId: 'zsjz', modelRecordId: '42' });
+    expect(runtime.status.state).toBe('ready');
+
+    const config = await runtime.readConfig();
+    expect(config.model_provider).toBe('zsjz');
+    expect(config.model).toBe('smoke-model-2');
+    expect(JSON.stringify(config)).not.toContain(API_KEY);
+
+    const persisted = await readAppliedProvider();
+    expect(persisted?.model).toBe('smoke-model-2');
+    expect(JSON.stringify(persisted)).not.toContain(API_KEY);
+  });
+
+  it('端点未实现 Responses API 时拒绝应用', async () => {
+    nextStatus = 404;
+    await expect(
+      applyProvider(runtime, { modelId: 'x', baseUrl, apiKey: API_KEY, providerType: 'OPENAI' }),
+    ).rejects.toThrow(/未实现 Responses API/);
+  });
+
+  it('clearProvider 回到无 provider 状态', async () => {
+    await clearProvider(runtime);
+    expect(await readAppliedProvider()).toBeNull();
+    expect(runtime.provider).toBeNull();
+  });
+});
+
+describe('停止与回收', () => {
+  it('stop 后状态归零,未经懒启动的调用直接失败', async () => {
+    await runtime.stop();
+    expect(runtime.status.state).toBe('stopped');
+    await expect(runtime.interruptTurn('thread-x', 'turn-x')).rejects.toThrow(/未运行/);
+  });
+
+  it('诊断信息会屏蔽密钥形态的字符串', async () => {
+    const { sanitizeDiagnostic } = await import('./codexRuntime');
+    expect(sanitizeDiagnostic('token sk-abcdefghij1234567890 leaked')).not.toContain('sk-abcdefghij1234567890');
+    expect(sanitizeDiagnostic('Authorization: Bearer abc.def-ghi')).toContain('[redacted]');
+  });
+});

+ 99 - 0
ai-electron/electron/service/codex/codexRuntime.test.ts

@@ -0,0 +1,99 @@
+import { describe, expect, it } from 'vitest';
+import { buildArgs, DEFAULT_API_KEY_ENV, type CodexProviderSpec } from './codexRuntime';
+
+/** 这组断言锁的是「喂给 codex.exe 的 -c 参数」——provider 能不能生效全在这里 */
+
+const API_KEY = 'sk-unit-secret-abcdef123456';
+
+const custom: CodexProviderSpec = {
+  id: 'zsjz',
+  name: '知数 · 第三方',
+  baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
+  model: 'qwen3-max',
+  apiKey: API_KEY,
+  httpHeaders: { 'X-DashScope-WorkSpace': 'ws-1' },
+  extra: { request_max_retries: 3, temperature: 0.5, query_params: { version: 'v2' } },
+};
+
+describe('buildArgs', () => {
+  it('没有 provider 时只起 app-server,且不传 --strict-config', () => {
+    expect(buildArgs(null)).toEqual(['app-server', '--listen', 'stdio://']);
+    expect(buildArgs(null)).not.toContain('--strict-config');
+  });
+
+  it('自定义 provider 走 responses + 免登录 + env_key', () => {
+    const args = buildArgs(custom);
+    expect(args).toContain('-c');
+    expect(args.join(' ')).toContain('model_provider="zsjz"');
+    expect(args.join(' ')).toContain('model="qwen3-max"');
+    expect(args.join(' ')).toContain('model_providers.zsjz.wire_api="responses"');
+    expect(args.join(' ')).toContain('model_providers.zsjz.requires_openai_auth=false');
+    expect(args.join(' ')).toContain(`model_providers.zsjz.env_key="${DEFAULT_API_KEY_ENV}"`);
+    expect(args.join(' ')).toContain('model_providers.zsjz.base_url="https://dashscope.aliyuncs.com/compatible-mode/v1"');
+  });
+
+  it('API Key 绝不出现在命令行参数里', () => {
+    expect(JSON.stringify(buildArgs(custom))).not.toContain(API_KEY);
+  });
+
+  it('没有 apiKey 时不写 env_key(本地无鉴权服务)', () => {
+    const args = buildArgs({ ...custom, apiKey: null });
+    expect(args.join(' ')).not.toContain('env_key');
+  });
+
+  it('http_headers 与 query_params 输出成 TOML inline table', () => {
+    const joined = buildArgs(custom).join(' ');
+    // 只含 A-Za-z0-9_- 的键按 TOML bare key 规则输出,无需加引号
+    expect(joined).toContain('model_providers.zsjz.http_headers={X-DashScope-WorkSpace="ws-1"}');
+    expect(joined).toContain('model_providers.zsjz.query_params={version="v2"}');
+  });
+
+  it('config 白名单外的键被丢弃', () => {
+    const joined = buildArgs(custom).join(' ');
+    expect(joined).toContain('request_max_retries=3');
+    expect(joined).not.toContain('temperature');
+  });
+
+  it('内置 ollama:带上真实 base_url,否则只会打 localhost', () => {
+    const args = buildArgs({
+      id: null,
+      builtinProvider: 'ollama',
+      model: 'qwen',
+      baseUrl: 'http://10.66.66.66:8080/v1',
+    });
+    const joined = args.join(' ');
+    expect(joined).toContain('model_provider="ollama"');
+    expect(joined).toContain('model_providers.ollama.base_url="http://10.66.66.66:8080/v1"');
+    expect(joined).not.toContain('wire_api');
+    expect(joined).not.toContain('env_key');
+  });
+
+  it('内置 provider 未配地址时不覆盖 base_url', () => {
+    const joined = buildArgs({ id: null, builtinProvider: 'lmstudio', model: 'local-model' }).join(' ');
+    expect(joined).toContain('model_provider="lmstudio"');
+    expect(joined).not.toContain('base_url');
+  });
+
+  it('值里的引号与反斜杠被 TOML 安全转义', () => {
+    const joined = buildArgs({
+      ...custom,
+      name: '带"引号"和\\反斜杠',
+      baseUrl: 'https://x/v1',
+    }).join(' ');
+    expect(joined).toContain('model_providers.zsjz.name="带\\"引号\\"和\\\\反斜杠"');
+  });
+
+  it('嵌套 map 输出成 TOML inline table,而不是 JSON', () => {
+    const joined = buildArgs({
+      ...custom,
+      extra: { query_params: { version: 'v2', flag: 'on' } },
+    }).join(' ');
+    expect(joined).toContain('model_providers.zsjz.query_params={version="v2",flag="on"}');
+    expect(joined).not.toContain('{"version"');
+  });
+
+  it('不符合 bare key 规则的键加引号', () => {
+    const joined = buildArgs({ ...custom, httpHeaders: { 'X Token': 'a' } }).join(' ');
+    expect(joined).toContain('model_providers.zsjz.http_headers={"X Token"="a"}');
+  });
+});

+ 796 - 0
ai-electron/electron/service/codex/codexRuntime.ts

@@ -0,0 +1,796 @@
+import { EventEmitter } from 'node:events';
+import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
+import { locateCodexBinary, readCodexVersion } from './codexLocator';
+import { JsonRpcPeer, type JsonRpcServerRequest } from './jsonRpcPeer';
+
+/**
+ * 移植自 Noobi.ai src/main/codexAppServer.ts,主要改动:
+ * 1. 删掉全部账号相关能力(account/read、account/login/start、account/logout)——本模块不做任何登录;
+ * 2. 不再传 --strict-config(用户与程序共写 config.toml,未知字段只应告警不该退出);
+ * 3. 新增 provider 注入:用 -c 覆盖 model_provider / model / model_providers.*,api_key 只走子进程环境变量;
+ * 4. clientInfo 改为本项目标识。
+ */
+
+export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
+
+export interface ModelOption {
+  id: string;
+  model: string;
+  displayName: string;
+  description: string;
+  isDefault: boolean;
+  defaultEffort: string;
+  efforts: string[];
+}
+
+export interface RuntimeCapabilities {
+  namespaceTools: boolean;
+  imageGeneration: boolean;
+  webSearch: boolean;
+}
+
+export interface RuntimeStatus {
+  state: 'stopped' | 'starting' | 'ready' | 'error';
+  binaryPath: string | null;
+  version: string | null;
+  codexHome: string | null;
+  models: ModelOption[];
+  capabilities: RuntimeCapabilities;
+  providerId: string | null;
+  defaultModel: string | null;
+  /** model/list 拿不到可用模型时为 true */
+  degraded: boolean;
+  error: string | null;
+}
+
+export interface CodexMcpServerStatus {
+  name: string;
+  authStatus: string;
+  connected: boolean;
+  toolCount: number;
+}
+
+export interface CodexSkillSummary {
+  name: string;
+  description: string;
+  path: string;
+  scope: 'user' | 'repo' | 'system' | 'admin' | string;
+  enabled: boolean;
+  cwd: string;
+}
+
+/** 一次 spawn 用的模型来源。id 为 null 表示使用 Codex 内置 provider(ollama / lmstudio) */
+export interface CodexProviderSpec {
+  id: string | null;
+  name?: string | null;
+  baseUrl?: string | null;
+  model: string;
+  /** 内置 provider id,例如 'ollama';给了就不再写 model_providers 段 */
+  builtinProvider?: string | null;
+  /** apiKey 只注入子进程环境变量,不落任何盘 */
+  apiKey?: string | null;
+  envKey?: string;
+  httpHeaders?: Record<string, string> | null;
+  envHttpHeaders?: Record<string, string> | null;
+  /** 仅放行 request_max_retries / stream_max_retries / stream_idle_timeout_ms / query_params */
+  extra?: Record<string, JsonValue> | null;
+}
+
+export interface CodexRuntimeOptions {
+  codexHome: string;
+  provider?: CodexProviderSpec | null;
+  clientInfo?: { name: string; title: string; version: string };
+}
+
+export interface StartThreadOptions {
+  cwd: string;
+  model?: string | null;
+  sandbox?: 'read-only' | 'workspace-write' | 'danger-full-access';
+  approvalPolicy?: 'untrusted' | 'on-request' | 'never';
+  developerInstructions?: string;
+  ephemeral?: boolean;
+}
+
+export interface StartTurnOptions {
+  threadId: string;
+  prompt: string;
+  cwd?: string;
+  model?: string | null;
+  effort?: string | null;
+  approvalPolicy?: 'untrusted' | 'on-request' | 'never';
+  skills?: Array<{ name: string; path: string }>;
+  /** 仅宿主侧的完成期限,不会发给 App Server */
+  timeoutMs?: number;
+}
+
+export interface TurnResult {
+  turnId: string;
+  status: string;
+  text: string;
+  raw: unknown;
+}
+
+interface SkillsListResponse {
+  data: Array<{
+    cwd: string;
+    skills: Array<{
+      name: string;
+      description: string;
+      path: string;
+      scope: string;
+      enabled: boolean;
+    }>;
+  }>;
+}
+
+interface ListMcpServerStatusResponse {
+  data: Array<{
+    name: string;
+    serverInfo: unknown | null;
+    tools: Record<string, unknown>;
+    authStatus: string;
+  }>;
+  nextCursor: string | null;
+}
+
+interface InitializeResponse {
+  userAgent: string;
+  codexHome: string;
+  platformFamily: string;
+  platformOs: string;
+}
+
+interface ModelListResponse {
+  data: Array<{
+    id: string;
+    model: string;
+    displayName: string;
+    description: string;
+    isDefault: boolean;
+    defaultReasoningEffort: string;
+    supportedReasoningEfforts: Array<{ reasoningEffort: string }>;
+  }>;
+  nextCursor: string | null;
+}
+
+interface ModelProviderCapabilitiesResponse {
+  namespaceTools: boolean;
+  imageGeneration: boolean;
+  webSearch: boolean;
+}
+
+interface ThreadResponse {
+  thread: { id: string };
+  model: string;
+}
+
+interface TurnStartResponse {
+  turn: { id: string; status: string };
+}
+
+interface EarlyTurnState {
+  text: string;
+  completed: TurnResult | null;
+}
+
+interface TurnWaiter {
+  text: string;
+  resolve(result: TurnResult): void;
+  reject(error: Error): void;
+  timer: NodeJS.Timeout;
+}
+
+const TURN_TIMEOUT_MS = 20 * 60 * 1_000;
+export const DEFAULT_API_KEY_ENV = 'ZSJZ_CODEX_API_KEY';
+const PROVIDER_EXTRA_ALLOWLIST = new Set([
+  'request_max_retries',
+  'stream_max_retries',
+  'stream_idle_timeout_ms',
+  'query_params',
+]);
+
+export class CodexRuntime extends EventEmitter {
+  readonly #codexHome: string;
+  readonly #clientInfo: { name: string; title: string; version: string };
+  #provider: CodexProviderSpec | null;
+  #child: ChildProcessWithoutNullStreams | null = null;
+  #peer: JsonRpcPeer | null = null;
+  #startPromise: Promise<RuntimeStatus> | null = null;
+  #runtime: RuntimeStatus = emptyRuntimeStatus();
+  #turnWaiters = new Map<string, TurnWaiter>();
+  #earlyTurnStates = new Map<string, EarlyTurnState>();
+  #runTurnStartsInFlight = 0;
+  #generation = 0;
+
+  constructor(options: CodexRuntimeOptions) {
+    super();
+    this.#codexHome = options.codexHome;
+    this.#provider = options.provider ?? null;
+    this.#clientInfo = options.clientInfo ?? {
+      name: 'zsjz_ai',
+      title: '清鉴线索调查工具',
+      version: '0.1.0',
+    };
+  }
+
+  get status(): RuntimeStatus {
+    return structuredClone(this.#runtime);
+  }
+
+  get provider(): CodexProviderSpec | null {
+    return this.#provider ? { ...this.#provider, apiKey: null } : null;
+  }
+
+  /** 换 provider 必须重启子进程:api_key 走的是子进程环境变量,无法热更新 */
+  async applyProvider(provider: CodexProviderSpec | null): Promise<RuntimeStatus> {
+    this.#provider = provider;
+    if (this.#child) await this.stop();
+    return this.start();
+  }
+
+  async start(): Promise<RuntimeStatus> {
+    if (this.#runtime.state === 'ready' && this.#peer) return this.status;
+    if (this.#startPromise) return this.#startPromise;
+    const generation = ++this.#generation;
+    this.#startPromise = this.#start(generation);
+    try {
+      return await this.#startPromise;
+    } finally {
+      this.#startPromise = null;
+    }
+  }
+
+  async refresh(): Promise<RuntimeStatus> {
+    await this.start();
+    let models: ModelOption[] = [];
+    let degraded = false;
+    try {
+      models = await this.listModels();
+    } catch (error) {
+      // 第三方/本地端点常常不实现 model/list,这里降级而不是失败
+      degraded = true;
+      this.emit('diagnostic', `model/list 不可用:${asError(error).message}`);
+    }
+    const capabilities = await this.readModelProviderCapabilities().catch(() => emptyCapabilities());
+    this.#runtime = {
+      ...this.#runtime,
+      models,
+      capabilities,
+      degraded: degraded || models.length === 0,
+      // model/list 返回的是 Codex 内置模型目录(与自定义 provider 无关),
+      // 实际会用的是 provider 里配置的模型,所以它优先
+      defaultModel: this.#provider?.model ?? models.find((item) => item.isDefault)?.model ?? null,
+      state: 'ready',
+      error: null,
+    };
+    this.emit('status', this.status);
+    return this.status;
+  }
+
+  async readModelProviderCapabilities(): Promise<RuntimeCapabilities> {
+    const result = await this.#request<ModelProviderCapabilitiesResponse>(
+      'modelProvider/capabilities/read',
+      {},
+    );
+    return {
+      namespaceTools: result.namespaceTools === true,
+      imageGeneration: result.imageGeneration === true,
+      webSearch: result.webSearch === true,
+    };
+  }
+
+  async listModels(): Promise<ModelOption[]> {
+    const models: ModelOption[] = [];
+    let cursor: string | null = null;
+    for (let page = 0; page < 10; page += 1) {
+      const result: ModelListResponse = await this.#request<ModelListResponse>('model/list', {
+        cursor,
+        limit: 100,
+        includeHidden: false,
+      });
+      models.push(
+        ...result.data.map((model) => ({
+          id: model.id,
+          model: model.model,
+          displayName: model.displayName,
+          description: model.description,
+          isDefault: model.isDefault,
+          defaultEffort: model.defaultReasoningEffort,
+          efforts: model.supportedReasoningEfforts.map((item) => item.reasoningEffort),
+        })),
+      );
+      cursor = result.nextCursor;
+      if (!cursor) break;
+    }
+    return models;
+  }
+
+  async listSkills(options: { cwds?: string[]; forceReload?: boolean } = {}): Promise<CodexSkillSummary[]> {
+    await this.start();
+    const result = await this.#request<SkillsListResponse>('skills/list', {
+      ...(options.cwds?.length ? { cwds: options.cwds } : {}),
+      forceReload: options.forceReload ?? false,
+    });
+    return result.data.flatMap((entry) =>
+      entry.skills.map((skill) => ({
+        name: skill.name,
+        description: skill.description,
+        path: skill.path,
+        scope: skill.scope,
+        enabled: skill.enabled,
+        cwd: entry.cwd,
+      })),
+    );
+  }
+
+  async setSkillEnabled(selector: { path?: string; name?: string }, enabled: boolean): Promise<boolean> {
+    await this.start();
+    const result = await this.#request<{ effectiveEnabled: boolean }>('skills/config/write', {
+      ...(selector.path ? { path: selector.path } : {}),
+      ...(!selector.path && selector.name ? { name: selector.name } : {}),
+      enabled,
+    });
+    return result.effectiveEnabled;
+  }
+
+  async listMcpServerStatuses(): Promise<CodexMcpServerStatus[]> {
+    await this.start();
+    const statuses: CodexMcpServerStatus[] = [];
+    let cursor: string | null = null;
+    for (let page = 0; page < 20; page += 1) {
+      const result: ListMcpServerStatusResponse = await this.#request<ListMcpServerStatusResponse>(
+        'mcpServerStatus/list',
+        {
+          cursor,
+          limit: 100,
+          detail: 'toolsAndAuthOnly',
+        },
+      );
+      statuses.push(
+        ...result.data.map((server) => ({
+          name: server.name,
+          authStatus: server.authStatus,
+          connected: server.serverInfo !== null,
+          toolCount: Object.keys(server.tools ?? {}).length,
+        })),
+      );
+      cursor = result.nextCursor;
+      if (!cursor) break;
+    }
+    return statuses;
+  }
+
+  async readConfig(): Promise<Record<string, unknown>> {
+    await this.start();
+    const result = await this.#request<{ config: Record<string, unknown> }>('config/read', {
+      includeLayers: false,
+    });
+    return structuredClone(result.config);
+  }
+
+  /** 局部写:Codex 自己也持有 config.toml 写权,禁止整文件覆盖 */
+  async writeConfigValue(keyPath: string, value: JsonValue): Promise<void> {
+    await this.start();
+    await this.#request<unknown>('config/value/write', { keyPath, value, mergeStrategy: 'replace' });
+  }
+
+  async reloadMcpServers(): Promise<void> {
+    await this.start();
+    await this.#request<unknown>('config/mcpServer/reload');
+  }
+
+  async startThread(options: StartThreadOptions): Promise<string> {
+    await this.start();
+    const result = await this.#request<ThreadResponse>('thread/start', {
+      cwd: options.cwd,
+      ...(options.model ? { model: options.model } : {}),
+      sandbox: options.sandbox ?? 'workspace-write',
+      approvalPolicy: options.approvalPolicy ?? 'never',
+      ...(options.developerInstructions ? { developerInstructions: options.developerInstructions } : {}),
+      ...(options.ephemeral === undefined ? {} : { ephemeral: options.ephemeral }),
+      experimentalRawEvents: false,
+    });
+    return result.thread.id;
+  }
+
+  async resumeThread(threadId: string, options: StartThreadOptions): Promise<string> {
+    await this.start();
+    const result = await this.#request<ThreadResponse>('thread/resume', {
+      threadId,
+      cwd: options.cwd,
+      ...(options.model ? { model: options.model } : {}),
+      sandbox: options.sandbox ?? 'workspace-write',
+      approvalPolicy: options.approvalPolicy ?? 'never',
+      ...(options.developerInstructions ? { developerInstructions: options.developerInstructions } : {}),
+      excludeTurns: true,
+    });
+    return result.thread.id;
+  }
+
+  async startTurn(options: StartTurnOptions): Promise<string> {
+    await this.start();
+    const result = await this.#request<TurnStartResponse>('turn/start', {
+      threadId: options.threadId,
+      input: [
+        { type: 'text', text: options.prompt, text_elements: [] },
+        ...(options.skills ?? []).map((skill) => ({
+          type: 'skill' as const,
+          name: skill.name,
+          path: skill.path,
+        })),
+      ],
+      ...(options.cwd ? { cwd: options.cwd } : {}),
+      ...(options.model ? { model: options.model } : {}),
+      ...(options.effort ? { effort: options.effort } : {}),
+      ...(options.approvalPolicy ? { approvalPolicy: options.approvalPolicy } : {}),
+    });
+    return result.turn.id;
+  }
+
+  async runTurn(options: StartTurnOptions): Promise<TurnResult> {
+    this.#runTurnStartsInFlight += 1;
+    let turnId: string;
+    try {
+      turnId = await this.startTurn(options);
+    } catch (error) {
+      this.#releaseRunTurnStart();
+      throw error;
+    }
+
+    // turn/completed 可能早于 turn/start 的响应回来,先存起来
+    const early = this.#earlyTurnStates.get(turnId);
+    this.#earlyTurnStates.delete(turnId);
+    if (early?.completed) {
+      this.#releaseRunTurnStart();
+      return early.completed;
+    }
+
+    return new Promise<TurnResult>((resolve, reject) => {
+      const timeoutMs =
+        options.timeoutMs === undefined
+          ? TURN_TIMEOUT_MS
+          : Math.min(TURN_TIMEOUT_MS, Math.max(1_000, Math.trunc(options.timeoutMs)));
+      const timer = setTimeout(() => {
+        this.#turnWaiters.delete(turnId);
+        void this.interruptTurn(options.threadId, turnId).catch(() => undefined);
+        reject(new Error(`Codex turn ${turnId} 超时`));
+      }, timeoutMs);
+      timer.unref();
+      this.#turnWaiters.set(turnId, { text: early?.text ?? '', resolve, reject, timer });
+      this.#releaseRunTurnStart();
+    });
+  }
+
+  async interruptTurn(threadId: string, turnId: string): Promise<void> {
+    await this.#request<Record<string, never>>('turn/interrupt', { threadId, turnId });
+  }
+
+  async unsubscribeThread(threadId: string): Promise<void> {
+    await this.#request<{ status: string }>('thread/unsubscribe', { threadId });
+  }
+
+  respondToServerRequest(requestId: string | number, result: unknown): void {
+    if (!this.#peer) throw new Error('Codex App Server 未运行');
+    this.#peer.respond(requestId, result);
+  }
+
+  rejectServerRequest(requestId: string | number, code: number, message: string): void {
+    if (!this.#peer) throw new Error('Codex App Server 未运行');
+    this.#peer.respondError(requestId, { code, message });
+  }
+
+  async stop(): Promise<void> {
+    this.#generation += 1;
+    const child = this.#child;
+    const peer = this.#peer;
+    this.#child = null;
+    this.#peer = null;
+    for (const waiter of this.#turnWaiters.values()) {
+      clearTimeout(waiter.timer);
+      waiter.reject(new Error('Codex App Server 在本轮对话进行中被停止'));
+    }
+    this.#turnWaiters.clear();
+    this.#earlyTurnStates.clear();
+    this.#runTurnStartsInFlight = 0;
+    if (child && child.exitCode === null) {
+      peer?.endOutput();
+      await Promise.race([
+        new Promise<void>((resolve) => child.once('exit', () => resolve())),
+        new Promise<void>((resolve) => setTimeout(resolve, 1_500)),
+      ]);
+      if (child.exitCode === null) {
+        child.kill('SIGTERM');
+        await Promise.race([
+          new Promise<void>((resolve) => child.once('exit', () => resolve())),
+          new Promise<void>((resolve) => setTimeout(resolve, 1_000)),
+        ]);
+      }
+      if (child.exitCode === null) child.kill('SIGKILL');
+    }
+    peer?.close(new Error('Codex App Server 已停止'));
+    this.#runtime = { ...emptyRuntimeStatus(), providerId: this.#provider?.id ?? null };
+    this.emit('status', this.status);
+  }
+
+  async dispose(): Promise<void> {
+    await this.stop();
+    this.removeAllListeners();
+  }
+
+  async #start(generation: number): Promise<RuntimeStatus> {
+    this.#runtime = { ...emptyRuntimeStatus(), state: 'starting', providerId: this.#provider?.id ?? null };
+    this.emit('status', this.status);
+    let child: ChildProcessWithoutNullStreams | null = null;
+    let peer: JsonRpcPeer | null = null;
+    try {
+      const binaryPath = await locateCodexBinary();
+      this.#assertGeneration(generation);
+      const version = readCodexVersion(binaryPath);
+      this.#assertGeneration(generation);
+      const provider = this.#provider;
+      child = spawn(binaryPath, buildArgs(provider), {
+        cwd: process.cwd(),
+        env: buildEnv(this.#codexHome, provider),
+        stdio: ['pipe', 'pipe', 'pipe'],
+        windowsHide: true,
+      });
+      this.#child = child;
+      peer = new JsonRpcPeer(child.stdout, child.stdin);
+      this.#peer = peer;
+      peer.on('notification', (notification) => this.#handleNotification(notification));
+      peer.on('serverRequest', (request: JsonRpcServerRequest) => this.emit('serverRequest', request));
+      peer.on('protocolError', (error) => this.emit('diagnostic', asError(error).message));
+      peer.once('closed', (error) => {
+        if (this.#peer !== peer || this.#child !== child) return;
+        if (child?.exitCode === null) child.kill('SIGTERM');
+        this.#handleExit(child!, asError(error));
+      });
+      peer.start();
+      child.stderr.setEncoding('utf8');
+      child.stderr.on('data', (chunk: string) => {
+        for (const line of chunk.split(/\r?\n/u).filter(Boolean)) {
+          this.emit('diagnostic', sanitizeDiagnostic(line));
+        }
+      });
+      child.once('error', (error) => this.#handleExit(child!, error));
+      child.once('exit', (code, signal) => {
+        this.#handleExit(child!, new Error(`Codex App Server 退出(${code ?? signal ?? 'unknown'})`));
+      });
+
+      const initialized = await peer.request<InitializeResponse>('initialize', {
+        clientInfo: this.#clientInfo,
+        capabilities: { experimentalApi: true },
+      });
+      this.#assertGeneration(generation);
+      peer.notify('initialized');
+      this.#runtime = {
+        ...emptyRuntimeStatus(),
+        state: 'ready',
+        binaryPath,
+        version,
+        codexHome: initialized.codexHome,
+        providerId: provider?.id ?? provider?.builtinProvider ?? null,
+      };
+      return await this.refresh();
+    } catch (error) {
+      const message = asError(error).message;
+      peer?.close(asError(error));
+      if (this.#peer === peer) this.#peer = null;
+      if (this.#child === child) this.#child = null;
+      if (child && child.exitCode === null) child.kill('SIGTERM');
+      if (generation === this.#generation) {
+        this.#runtime = { ...this.#runtime, state: 'error', error: sanitizeDiagnostic(message) };
+        this.emit('status', this.status);
+      }
+      throw error;
+    }
+  }
+
+  #request<T>(method: string, params?: unknown): Promise<T> {
+    if (!this.#peer) throw new Error('Codex App Server 未运行');
+    return this.#peer.request<T>(method, params);
+  }
+
+  #handleNotification(notification: { method: string; params?: unknown }): void {
+    const params = asRecord(notification.params);
+    const turnId = readString(params?.turnId) ?? readString(asRecord(params?.turn)?.id);
+    if (turnId) {
+      const waiter = this.#turnWaiters.get(turnId);
+      if (waiter && notification.method === 'item/agentMessage/delta') {
+        waiter.text += readString(params?.delta) ?? '';
+      }
+      if (waiter && notification.method === 'item/completed') {
+        const item = asRecord(params?.item);
+        if (item?.type === 'agentMessage' && typeof item.text === 'string') waiter.text = item.text;
+      }
+      if (waiter && notification.method === 'turn/completed') {
+        clearTimeout(waiter.timer);
+        this.#turnWaiters.delete(turnId);
+        const turn = asRecord(params?.turn);
+        waiter.resolve({
+          turnId,
+          status: readString(turn?.status) ?? 'completed',
+          text: waiter.text,
+          raw: params,
+        });
+      }
+      if (!waiter && this.#runTurnStartsInFlight > 0) {
+        const early = this.#earlyTurnStates.get(turnId) ?? { text: '', completed: null };
+        if (notification.method === 'item/agentMessage/delta') {
+          early.text += readString(params?.delta) ?? '';
+        }
+        if (notification.method === 'item/completed') {
+          const item = asRecord(params?.item);
+          if (item?.type === 'agentMessage' && typeof item.text === 'string') early.text = item.text;
+        }
+        if (notification.method === 'turn/completed') {
+          const turn = asRecord(params?.turn);
+          early.completed = {
+            turnId,
+            status: readString(turn?.status) ?? 'completed',
+            text: early.text,
+            raw: params,
+          };
+        }
+        this.#earlyTurnStates.set(turnId, early);
+      }
+    }
+    this.emit('notification', notification);
+  }
+
+  #handleExit(child: ChildProcessWithoutNullStreams, error: Error): void {
+    if (this.#child !== child) return;
+    this.#peer?.close(error);
+    this.#peer = null;
+    this.#child = null;
+    this.#runtime = {
+      ...this.#runtime,
+      state: 'error',
+      degraded: true,
+      error: sanitizeDiagnostic(error.message),
+    };
+    for (const waiter of this.#turnWaiters.values()) {
+      clearTimeout(waiter.timer);
+      waiter.reject(error);
+    }
+    this.#turnWaiters.clear();
+    this.#earlyTurnStates.clear();
+    this.#runTurnStartsInFlight = 0;
+    this.emit('status', this.status);
+  }
+
+  #assertGeneration(generation: number): void {
+    if (generation !== this.#generation) throw new Error('Codex App Server 的启动已被取消');
+  }
+
+  #releaseRunTurnStart(): void {
+    this.#runTurnStartsInFlight = Math.max(0, this.#runTurnStartsInFlight - 1);
+    if (this.#runTurnStartsInFlight === 0) this.#earlyTurnStates.clear();
+  }
+}
+
+/** 每个配置项单独一个 -c,避免手写嵌套 inline table */
+export function buildArgs(provider: CodexProviderSpec | null): string[] {
+  const args = ['app-server', '--listen', 'stdio://'];
+  if (!provider) return args;
+
+  const providerKey = provider.builtinProvider ?? provider.id;
+  if (!providerKey) return args;
+  args.push('-c', `model_provider=${tomlString(providerKey)}`);
+  args.push('-c', `model=${tomlString(provider.model)}`);
+  if (provider.builtinProvider) {
+    // 内置 provider 只允许覆盖 base_url,其余字段由 Codex 自己决定;
+    // 不覆盖就会打到 localhost:11434,远端 Ollama / LM Studio 连不上
+    if (provider.baseUrl) {
+      args.push('-c', `model_providers.${provider.builtinProvider}.base_url=${tomlString(provider.baseUrl)}`);
+    }
+    return args;
+  }
+
+  const prefix = `model_providers.${provider.id}`;
+  if (provider.name) args.push('-c', `${prefix}.name=${tomlString(provider.name)}`);
+  if (provider.baseUrl) args.push('-c', `${prefix}.base_url=${tomlString(provider.baseUrl)}`);
+  // wire_api 只支持 responses:0.155.1 已下线 chat
+  args.push('-c', `${prefix}.wire_api="responses"`);
+  args.push('-c', `${prefix}.requires_openai_auth=false`);
+  if (provider.apiKey) {
+    args.push('-c', `${prefix}.env_key=${tomlString(provider.envKey ?? DEFAULT_API_KEY_ENV)}`);
+  }
+  if (provider.httpHeaders && Object.keys(provider.httpHeaders).length) {
+    args.push('-c', `${prefix}.http_headers=${tomlInlineTable(provider.httpHeaders)}`);
+  }
+  if (provider.envHttpHeaders && Object.keys(provider.envHttpHeaders).length) {
+    args.push('-c', `${prefix}.env_http_headers=${tomlInlineTable(provider.envHttpHeaders)}`);
+  }
+  for (const [key, value] of Object.entries(provider.extra ?? {})) {
+    if (!PROVIDER_EXTRA_ALLOWLIST.has(key) || value === null || value === undefined) continue;
+    args.push('-c', `${prefix}.${key}=${tomlValue(value)}`);
+  }
+  return args;
+}
+
+function buildEnv(codexHome: string, provider: CodexProviderSpec | null): NodeJS.ProcessEnv {
+  const env: NodeJS.ProcessEnv = {
+    ...process.env,
+    CODEX_HOME: codexHome,
+    RUST_LOG: process.env.RUST_LOG ?? 'warn',
+    LOG_FORMAT: 'json',
+  };
+  // 密钥只存在于子进程环境变量,config.toml 里只写 env_key 名字
+  if (provider?.apiKey && !provider.builtinProvider) {
+    env[provider.envKey ?? DEFAULT_API_KEY_ENV] = provider.apiKey;
+  }
+  return env;
+}
+
+function tomlString(value: string): string {
+  return JSON.stringify(value);
+}
+
+function tomlInlineTable(value: Record<string, string>): string {
+  const entries = Object.entries(value).map(([key, item]) => `${tomlKey(key)}=${tomlString(item)}`);
+  return `{${entries.join(',')}}`;
+}
+
+function tomlKey(key: string): string {
+  return /^[A-Za-z0-9_-]+$/.test(key) ? key : tomlString(key);
+}
+
+function tomlValue(value: JsonValue): string {
+  if (typeof value === 'string') return tomlString(value);
+  if (typeof value === 'number' || typeof value === 'boolean') return String(value);
+  if (Array.isArray(value)) {
+    return `[${value.filter((item) => item !== null).map(tomlValue).join(',')}]`;
+  }
+  if (value && typeof value === 'object') {
+    // TOML inline table 用 `=` 而不是 JSON 的 `:`,不能直接 JSON.stringify
+    return tomlInlineTable(
+      Object.fromEntries(
+        Object.entries(value as Record<string, JsonValue>)
+          .filter(([, item]) => typeof item === 'string')
+          .map(([key, item]) => [key, String(item)]),
+      ),
+    );
+  }
+  return '""';
+}
+
+function emptyRuntimeStatus(): RuntimeStatus {
+  return {
+    state: 'stopped',
+    binaryPath: null,
+    version: null,
+    codexHome: null,
+    models: [],
+    capabilities: emptyCapabilities(),
+    providerId: null,
+    defaultModel: null,
+    degraded: false,
+    error: null,
+  };
+}
+
+function emptyCapabilities(): RuntimeCapabilities {
+  return { namespaceTools: false, imageGeneration: false, webSearch: false };
+}
+
+function asRecord(value: unknown): Record<string, unknown> | null {
+  return value && typeof value === 'object' && !Array.isArray(value)
+    ? (value as Record<string, unknown>)
+    : null;
+}
+
+function readString(value: unknown): string | null {
+  return typeof value === 'string' ? value : null;
+}
+
+function asError(value: unknown): Error {
+  return value instanceof Error ? value : new Error(String(value));
+}
+
+export function sanitizeDiagnostic(value: string): string {
+  return value
+    .replace(/sk-[A-Za-z0-9_-]{12,}/gu, 'sk-[redacted]')
+    .replace(/Bearer\s+[A-Za-z0-9._~-]+/giu, 'Bearer [redacted]')
+    .slice(0, 4_000);
+}

+ 146 - 0
ai-electron/electron/service/codex/eventLog.ts

@@ -0,0 +1,146 @@
+import { appendFile, mkdir, readFile, rename, rm, stat } from 'node:fs/promises';
+import { join } from 'node:path';
+import type { AgentEvent } from './types';
+
+/** 移植自 Noobi.ai src/main/eventLog.ts,维度由 projectId 改为 threadId */
+
+const MAX_EVENT_BYTES = 64 * 1024;
+const MAX_TAIL_EVENTS = 400;
+const MAX_LOG_BYTES = 8 * 1024 * 1024;
+
+export class EventLog {
+  readonly #directory: string;
+  readonly #queues = new Map<string, Promise<void>>();
+
+  constructor(directory: string) {
+    this.#directory = directory;
+  }
+
+  async init(): Promise<void> {
+    await mkdir(this.#directory, { recursive: true });
+  }
+
+  async append(event: AgentEvent): Promise<void> {
+    validateThreadId(event.threadId);
+    const serialized = serializeEvent(event);
+    const previous = this.#queues.get(event.threadId) ?? Promise.resolve();
+    const current = previous
+      .catch(() => undefined)
+      .then(async () => {
+        await rotateIfNeeded(this.#path(event.threadId));
+        await appendFile(this.#path(event.threadId), `${serialized}\n`, {
+          encoding: 'utf8',
+          mode: 0o600,
+        });
+      });
+    this.#queues.set(event.threadId, current);
+    const release = (): void => {
+      if (this.#queues.get(event.threadId) === current) this.#queues.delete(event.threadId);
+    };
+    void current.then(release, release);
+    await current;
+  }
+
+  /** 只取尾部若干条,并把同一 id 的增量拼回完整消息 */
+  async read(threadId: string, limit = MAX_TAIL_EVENTS): Promise<AgentEvent[]> {
+    validateThreadId(threadId);
+    await this.#queues.get(threadId)?.catch(() => undefined);
+    let source: string;
+    try {
+      source = await readFile(this.#path(threadId), 'utf8');
+    } catch (error) {
+      if (asNodeError(error).code === 'ENOENT') return [];
+      throw error;
+    }
+    const events = new Map<string, AgentEvent>();
+    const lines = source.split(/\r?\n/u).filter(Boolean).slice(-limit);
+    for (const line of lines) {
+      try {
+        const event = JSON.parse(line) as AgentEvent;
+        if (event.threadId === threadId && typeof event.id === 'string') {
+          const previous = events.get(event.id);
+          events.set(
+            event.id,
+            previous && event.isDelta
+              ? {
+                  ...previous,
+                  ...event,
+                  message: `${previous.message}${event.message}`.slice(-120_000),
+                }
+              : event,
+          );
+        }
+      } catch {
+        // 最后一行可能只写了一半,或是不兼容的旧记录,忽略
+      }
+    }
+    return [...events.values()].sort((left, right) => left.timestamp.localeCompare(right.timestamp));
+  }
+
+  async flush(): Promise<void> {
+    await Promise.allSettled([...this.#queues.values()]);
+  }
+
+  async remove(threadId: string): Promise<void> {
+    validateThreadId(threadId);
+    await this.#queues.get(threadId)?.catch(() => undefined);
+    await Promise.all([
+      rm(this.#path(threadId), { force: true }),
+      rm(`${this.#path(threadId)}.previous`, { force: true }),
+    ]);
+  }
+
+  #path(threadId: string): string {
+    return join(this.#directory, `${threadId}.jsonl`);
+  }
+}
+
+/** 超过 64 KiB 的事件用二分法截断 message,保证元数据仍能落盘 */
+function serializeEvent(event: AgentEvent): string {
+  const serialized = JSON.stringify(event);
+  if (Buffer.byteLength(serialized, 'utf8') <= MAX_EVENT_BYTES) return serialized;
+
+  const marker = '\n…(事件日志已按 64 KiB 截断)';
+  const codePoints = Array.from(event.message);
+  let lower = 0;
+  let upper = codePoints.length;
+  let best = JSON.stringify({ ...event, message: marker });
+
+  if (Buffer.byteLength(best, 'utf8') > MAX_EVENT_BYTES) {
+    throw new Error('事件元数据超过 64 KiB 落盘上限');
+  }
+
+  while (lower <= upper) {
+    const middle = Math.floor((lower + upper) / 2);
+    const candidate = JSON.stringify({
+      ...event,
+      message: `${codePoints.slice(0, middle).join('')}${marker}`,
+    });
+    if (Buffer.byteLength(candidate, 'utf8') <= MAX_EVENT_BYTES) {
+      best = candidate;
+      lower = middle + 1;
+    } else {
+      upper = middle - 1;
+    }
+  }
+  return best;
+}
+
+async function rotateIfNeeded(path: string): Promise<void> {
+  try {
+    if ((await stat(path)).size <= MAX_LOG_BYTES) return;
+    const previous = `${path}.previous`;
+    await rm(previous, { force: true });
+    await rename(path, previous);
+  } catch (error) {
+    if (asNodeError(error).code !== 'ENOENT') throw error;
+  }
+}
+
+function validateThreadId(value: string): void {
+  if (!/^[a-zA-Z0-9_-]{1,128}$/u.test(value)) throw new Error('非法的 threadId');
+}
+
+function asNodeError(value: unknown): NodeJS.ErrnoException {
+  return value as NodeJS.ErrnoException;
+}

+ 182 - 0
ai-electron/electron/service/codex/eventMapper.ts

@@ -0,0 +1,182 @@
+import { randomUUID } from 'node:crypto';
+import type { AgentEvent, AgentEventKind } from './types';
+
+/**
+ * 移植自 Noobi.ai src/main/eventMapper.ts。
+ * 删掉了整块 stage 推断(那是游戏流水线专用)与 planner/implementer/reviewer 角色标签,
+ * 事件维度改成 threadId;也删掉了 imageGeneration / dynamicToolCall 这类 Noobi 自有素材工具。
+ */
+
+const UNTHREADED = 'unthreaded';
+
+export function threadIdOf(notification: { params?: unknown }): string {
+  const params = asRecord(notification.params);
+  const turn = asRecord(params?.turn);
+  const item = asRecord(params?.item);
+  return (
+    readString(params?.threadId) ?? readString(turn?.threadId) ?? readString(item?.threadId) ?? UNTHREADED
+  );
+}
+
+export function notificationToEvent(notification: {
+  method: string;
+  params?: unknown;
+}): AgentEvent | null {
+  const params = asRecord(notification.params) ?? {};
+  const item = asRecord(params.item);
+  const turn = asRecord(params.turn);
+  const threadId = threadIdOf(notification);
+  const turnId = readString(params.turnId) ?? readString(turn?.id) ?? 'turn';
+  const itemId = readString(params.itemId) ?? readString(item?.id) ?? undefined;
+  const method = notification.method;
+  let kind: AgentEventKind = 'lifecycle';
+  let title = 'Codex';
+  let message = '';
+  let isDelta = false;
+
+  switch (method) {
+    case 'item/agentMessage/delta':
+      kind = 'assistant';
+      title = '回复';
+      message = readString(params.delta) ?? '';
+      isDelta = true;
+      break;
+    case 'item/reasoning/summaryTextDelta':
+      kind = 'thought';
+      title = '思考摘要';
+      message = readString(params.delta) ?? '';
+      isDelta = true;
+      break;
+    case 'item/commandExecution/outputDelta':
+      kind = 'tool';
+      title = '命令输出';
+      message = readString(params.delta) ?? '';
+      isDelta = true;
+      break;
+    case 'turn/plan/updated':
+      kind = 'plan';
+      title = '计划更新';
+      message = describe(params.plan ?? params);
+      break;
+    case 'item/fileChange/patchUpdated':
+      kind = 'file';
+      title = '文件变更';
+      message = readString(params.patch) ?? readString(params.diff) ?? '正在生成补丁';
+      isDelta = true;
+      break;
+    case 'item/started':
+    case 'item/completed': {
+      const type = readString(item?.type) ?? 'item';
+      const presentation = describeItem(item, type);
+      kind = presentation.kind;
+      title = presentation.title;
+      message = presentation.message || (method === 'item/completed' ? '已完成' : '已开始');
+      break;
+    }
+    case 'turn/started':
+      title = '回合开始';
+      message = 'Codex 已开始处理当前任务';
+      break;
+    case 'turn/completed':
+      title = '回合结束';
+      message = `状态:${readString(turn?.status) ?? 'completed'}`;
+      break;
+    case 'error':
+      kind = 'error';
+      title = '运行错误';
+      message = readString(params.message) ?? readString(asRecord(params.error)?.message) ?? describe(params);
+      break;
+    case 'warning':
+    case 'configWarning':
+      kind = 'error';
+      title = '警告';
+      message = readString(params.message) ?? describe(params);
+      break;
+    default:
+      return null;
+  }
+
+  if (!message) return null;
+  return {
+    id: itemId ? `${threadId}:${turnId}:${itemId}:${kind}` : randomUUID(),
+    threadId,
+    turnId,
+    kind,
+    title,
+    message: clip(message),
+    timestamp: new Date().toISOString(),
+    method,
+    ...(itemId ? { itemId } : {}),
+    ...(isDelta ? { isDelta: true } : {}),
+  };
+}
+
+function describeItem(
+  item: Record<string, unknown> | null,
+  type: string,
+): { kind: AgentEventKind; title: string; message: string } {
+  if (!item) return { kind: 'lifecycle', title: type, message: '' };
+  switch (type) {
+    case 'agentMessage':
+      return { kind: 'assistant', title: '回复', message: readString(item.text) ?? '' };
+    case 'reasoning':
+      return {
+        kind: 'thought',
+        title: '推理摘要',
+        message: readTextArray(item.summary) || readTextArray(item.content),
+      };
+    case 'commandExecution':
+      return {
+        kind: 'tool',
+        title: '执行命令',
+        message: readString(item.command) ?? describe(item.commandActions ?? item),
+      };
+    case 'fileChange':
+      return { kind: 'file', title: '修改文件', message: describe(item.changes ?? item) };
+    case 'mcpToolCall':
+      return {
+        kind: 'tool',
+        title: `工具 ${cleanToolName(readString(item.tool))}`.trim(),
+        message: describe(item.arguments ?? item.result ?? item),
+      };
+    case 'plan':
+      return { kind: 'plan', title: '计划', message: describe(item) };
+    default:
+      return { kind: 'lifecycle', title: type, message: describe(item) };
+  }
+}
+
+function readTextArray(value: unknown): string {
+  if (!Array.isArray(value)) return '';
+  return value
+    .map((entry) => (typeof entry === 'string' ? entry : readString(asRecord(entry)?.text) ?? ''))
+    .filter(Boolean)
+    .join('\n');
+}
+
+function describe(value: unknown): string {
+  if (typeof value === 'string') return value;
+  try {
+    return JSON.stringify(value, null, 2);
+  } catch {
+    return String(value);
+  }
+}
+
+function clip(value: string, maxLength = 24_000): string {
+  return value.length > maxLength ? `${value.slice(0, maxLength)}\n…(已截断)` : value;
+}
+
+function cleanToolName(value: string | null): string {
+  return value?.replace(/[^A-Za-z0-9_.-]/gu, '').slice(0, 128) ?? '';
+}
+
+function asRecord(value: unknown): Record<string, unknown> | null {
+  return value && typeof value === 'object' && !Array.isArray(value)
+    ? (value as Record<string, unknown>)
+    : null;
+}
+
+function readString(value: unknown): string | null {
+  return typeof value === 'string' ? value : null;
+}

+ 164 - 0
ai-electron/electron/service/codex/index.ts

@@ -0,0 +1,164 @@
+import { join } from 'node:path';
+import { mkdir } from 'node:fs/promises';
+import { getMainWindow } from 'ee-core/electron';
+import { logger } from 'ee-core/log';
+import { ApprovalBroker } from './approvalBroker';
+import { ensureCodexHome, getCodexDataDir, getCodexHome, getEventsDir } from './codexHome';
+import { CodexRuntime, type RuntimeStatus } from './codexRuntime';
+import { EventLog } from './eventLog';
+import { notificationToEvent } from './eventMapper';
+import { McpService } from './mcpService';
+import { readAppliedProvider } from './providerService';
+import { SkillService } from './skillService';
+import {
+  asMessage,
+  CODEX_APPROVAL_CHANNEL,
+  CODEX_APPROVAL_CLOSED_CHANNEL,
+  CODEX_DIAGNOSTIC_CHANNEL,
+  CODEX_EVENT_CHANNEL,
+  CODEX_STATUS_CHANNEL,
+  type AppliedProviderInfo,
+  type CodexStatusResult,
+} from './types';
+import type { JsonRpcServerRequest } from './jsonRpcPeer';
+
+/**
+ * Codex 能力的装配层:懒启动单例、事件转发到渲染进程、诊断环形缓冲。
+ * 刻意不在启动时自动带上 provider —— apiKey 只能由渲染进程从后端取到后传入,
+ * 所以重启后需要页面重新「应用模型」,未应用时 MCP/Skill 管理照样可用。
+ */
+
+const MAX_DIAGNOSTICS = 500;
+
+export interface CodexContainer {
+  runtime: CodexRuntime;
+  broker: ApprovalBroker;
+  eventLog: EventLog;
+  mcp: McpService;
+  skills: SkillService;
+}
+
+let container: CodexContainer | null = null;
+let creating: Promise<CodexContainer> | null = null;
+let appliedCache: AppliedProviderInfo | null = null;
+let appliedLoaded = false;
+const diagnostics: Array<{ ts: string; line: string }> = [];
+
+function send(channel: string, payload: unknown): void {
+  try {
+    const win = getMainWindow();
+    if (win && !win.isDestroyed()) win.webContents.send(channel, payload);
+  } catch {
+    // 窗口尚未就绪或已销毁:丢事件,不影响主流程
+  }
+}
+
+function pushDiagnostic(line: string): void {
+  const entry = { ts: new Date().toISOString(), line };
+  diagnostics.push(entry);
+  if (diagnostics.length > MAX_DIAGNOSTICS) {
+    diagnostics.splice(0, diagnostics.length - MAX_DIAGNOSTICS);
+  }
+  send(CODEX_DIAGNOSTIC_CHANNEL, entry);
+}
+
+export async function getCodex(): Promise<CodexContainer> {
+  if (container) return container;
+  if (creating) return creating;
+  creating = (async () => {
+    await ensureCodexHome();
+    const runtime = new CodexRuntime({ codexHome: getCodexHome() });
+    const broker = new ApprovalBroker(runtime);
+    const eventLog = new EventLog(getEventsDir());
+    await eventLog.init();
+
+    runtime.on('notification', (notification: { method: string; params?: unknown }) => {
+      if (notification.method === 'serverRequest/resolved') {
+        const requestId = (notification.params as { requestId?: string | number } | undefined)?.requestId;
+        if (requestId !== undefined) broker.resolveFromServer(requestId);
+      }
+      const event = notificationToEvent(notification);
+      if (!event) return;
+      send(CODEX_EVENT_CHANNEL, event);
+      eventLog.append(event).catch((error: unknown) => {
+        logger.error('[codex] 事件落盘失败:', asMessage(error));
+      });
+    });
+    runtime.on('status', (status: RuntimeStatus) => send(CODEX_STATUS_CHANNEL, toStatusResult(status)));
+    runtime.on('diagnostic', (line: string) => pushDiagnostic(line));
+    runtime.on('serverRequest', (request: JsonRpcServerRequest) => broker.handle(request));
+
+    broker.on('approval', (approval: unknown) => send(CODEX_APPROVAL_CHANNEL, approval));
+    broker.on('closed', (token: string) => send(CODEX_APPROVAL_CLOSED_CHANNEL, { token }));
+    broker.on('expired', (approval: unknown) => {
+      pushDiagnostic(`审批超时已自动拒绝:${JSON.stringify(approval)}`);
+    });
+    broker.on('diagnostic', (line: string) => pushDiagnostic(line));
+
+    container = {
+      runtime,
+      broker,
+      eventLog,
+      mcp: new McpService(runtime),
+      skills: new SkillService(runtime, { onDiagnostic: pushDiagnostic }),
+    };
+    return container;
+  })();
+  try {
+    return await creating;
+  } finally {
+    creating = null;
+  }
+}
+
+export async function getAppliedProvider(): Promise<AppliedProviderInfo | null> {
+  if (!appliedLoaded) {
+    appliedCache = await readAppliedProvider();
+    appliedLoaded = true;
+  }
+  return appliedCache;
+}
+
+export function setAppliedProvider(value: AppliedProviderInfo | null): void {
+  appliedCache = value;
+  appliedLoaded = true;
+}
+
+export function toStatusResult(status: RuntimeStatus): CodexStatusResult {
+  return {
+    state: status.state,
+    running: status.state === 'ready',
+    version: status.version,
+    binaryPath: status.binaryPath,
+    codexHome: getCodexHome(),
+    defaultModel: status.defaultModel,
+    providerId: status.providerId,
+    degraded: status.degraded,
+    error: status.error,
+    applied: appliedCache,
+  };
+}
+
+export function tailDiagnostics(limit = 200): Array<{ ts: string; line: string }> {
+  const size = Math.max(1, Math.min(MAX_DIAGNOSTICS, Math.trunc(limit)));
+  return diagnostics.slice(-size);
+}
+
+/** 会话默认工作目录:用户没指定 cwd 时用,避免 Codex 直接在安装目录里动手 */
+export async function defaultWorkspaceDir(): Promise<string> {
+  const dir = join(getCodexDataDir(), 'workspace');
+  await mkdir(dir, { recursive: true });
+  return dir;
+}
+
+export async function disposeCodex(): Promise<void> {
+  const current = container;
+  container = null;
+  if (!current) return;
+  current.broker.closeAll();
+  await current.eventLog.flush().catch(() => undefined);
+  await Promise.race([
+    current.runtime.dispose(),
+    new Promise<void>((resolve) => setTimeout(resolve, 3_000)),
+  ]);
+}

+ 95 - 0
ai-electron/electron/service/codex/jsonRpcPeer.test.ts

@@ -0,0 +1,95 @@
+import { PassThrough } from 'node:stream';
+import { describe, expect, it, vi } from 'vitest';
+import { JsonRpcPeer, JsonRpcRequestError } from './jsonRpcPeer';
+
+/** 移植自 Noobi.ai src/main/jsonRpcPeer.test.ts:纯内存流,不依赖 codex 二进制与网络 */
+
+function makePeer() {
+  const input = new PassThrough();
+  const output = new PassThrough();
+  output.setEncoding('utf8');
+  const peer = new JsonRpcPeer(input, output);
+  peer.start();
+  return { input, output, peer };
+}
+
+describe('JsonRpcPeer', () => {
+  it('把响应匹配回对应的 pending 请求', async () => {
+    const { input, output, peer } = makePeer();
+    const written = new Promise<string>((resolve) => output.once('data', resolve));
+    const pending = peer.request<{ models: unknown[] }>('model/list');
+
+    const request = JSON.parse(await written) as { id: number; method: string };
+    expect(request.method).toBe('model/list');
+    input.write(`${JSON.stringify({ id: request.id, result: { models: [] } })}\n`);
+
+    await expect(pending).resolves.toEqual({ models: [] });
+    peer.close();
+  });
+
+  it('区分通知与服务端请求,且能交错处理', async () => {
+    const { input, peer } = makePeer();
+    const notification = vi.fn();
+    const serverRequest = vi.fn();
+    peer.on('notification', notification);
+    peer.on('serverRequest', serverRequest);
+
+    input.write('{"method":"turn/started","params":{"turnId":"turn-1"}}\n');
+    input.write('{"id":"approval-1","method":"item/commandExecution/requestApproval","params":{}}\n');
+
+    await new Promise((resolve) => setImmediate(resolve));
+    expect(notification).toHaveBeenCalledWith({
+      method: 'turn/started',
+      params: { turnId: 'turn-1' },
+    });
+    expect(serverRequest).toHaveBeenCalledWith({
+      id: 'approval-1',
+      method: 'item/commandExecution/requestApproval',
+      params: {},
+    });
+    peer.close();
+  });
+
+  it('把 App Server 返回的协议错误抛给调用方', async () => {
+    const { input, output, peer } = makePeer();
+    const written = new Promise<string>((resolve) => output.once('data', resolve));
+    const pending = peer.request('thread/start');
+    const request = JSON.parse(await written) as { id: number };
+    input.write(`${JSON.stringify({
+      id: request.id,
+      error: { code: -32602, message: 'invalid params' },
+    })}\n`);
+
+    await expect(pending).rejects.toBeInstanceOf(JsonRpcRequestError);
+    peer.close();
+  });
+
+  it('遇到非法 JSON 只报错、不中断流', async () => {
+    const { input, peer } = makePeer();
+    const protocolError = vi.fn();
+    const notification = vi.fn();
+    peer.on('protocolError', protocolError);
+    peer.on('notification', notification);
+
+    input.write('{not-json}\n');
+    input.write('{"method":"warning","params":{"message":"still alive"}}\n');
+    await new Promise((resolve) => setImmediate(resolve));
+
+    expect(protocolError).toHaveBeenCalledOnce();
+    expect(notification).toHaveBeenCalledOnce();
+    peer.close();
+  });
+
+  it('关闭后拒绝全部 pending 请求并广播 closed', async () => {
+    const { peer } = makePeer();
+    const closed = vi.fn();
+    peer.on('closed', closed);
+    const pending = peer.request('model/list');
+
+    peer.close(new Error('子进程退出'));
+
+    await expect(pending).rejects.toThrow('子进程退出');
+    expect(closed).toHaveBeenCalledOnce();
+    await expect(peer.request('model/list')).rejects.toThrow('已关闭');
+  });
+});

+ 192 - 0
ai-electron/electron/service/codex/jsonRpcPeer.ts

@@ -0,0 +1,192 @@
+import { EventEmitter } from 'node:events';
+import { createInterface, type Interface } from 'node:readline';
+import type { Readable, Writable } from 'node:stream';
+
+/**
+ * 移植自 Noobi.ai src/main/jsonRpcPeer.ts(Codex app-server 的换行分隔 JSONL 通道),逻辑保持不变。
+ */
+
+export type JsonRpcId = string | number;
+
+export interface JsonRpcErrorShape {
+  code: number;
+  message: string;
+  data?: unknown;
+}
+
+export interface JsonRpcServerRequest {
+  id: JsonRpcId;
+  method: string;
+  params?: unknown;
+}
+
+interface PendingRequest {
+  method: string;
+  resolve(value: unknown): void;
+  reject(reason: Error): void;
+  timer: NodeJS.Timeout;
+}
+
+const MAX_OUTBOUND_PROTOCOL_LINE_BYTES = 16 * 1024 * 1024;
+// 大 diff / 长工具输出会整行走 JSONL,留足余量但不允许无上限分配
+const MAX_INBOUND_PROTOCOL_LINE_BYTES = 48 * 1024 * 1024;
+
+export class JsonRpcRequestError extends Error {
+  readonly code: number;
+  readonly data: unknown;
+
+  constructor(method: string, error: JsonRpcErrorShape) {
+    super(`${method}: ${error.message}`);
+    this.name = 'JsonRpcRequestError';
+    this.code = error.code;
+    this.data = error.data;
+  }
+}
+
+export class JsonRpcPeer extends EventEmitter {
+  readonly #input: Readable;
+  readonly #output: Writable;
+  readonly #pending = new Map<JsonRpcId, PendingRequest>();
+  #reader: Interface | null = null;
+  #nextId = 1;
+  #closed = false;
+
+  constructor(input: Readable, output: Writable) {
+    super();
+    this.#input = input;
+    this.#output = output;
+  }
+
+  start(): void {
+    if (this.#reader) return;
+    this.#reader = createInterface({ input: this.#input, crlfDelay: Infinity });
+    this.#reader.on('line', (line) => this.#handleLine(line));
+    this.#reader.on('close', () => this.close(new Error('Codex 协议流已关闭')));
+  }
+
+  async request<T>(method: string, params?: unknown, timeoutMs = 30_000): Promise<T> {
+    if (this.#closed) throw new Error('Codex 协议通道已关闭');
+    const id = this.#nextId++;
+    return new Promise<T>((resolve, reject) => {
+      const timer = setTimeout(() => {
+        this.#pending.delete(id);
+        reject(new Error(`${method} 在 ${timeoutMs}ms 内超时`));
+      }, timeoutMs);
+      timer.unref();
+      this.#pending.set(id, {
+        method,
+        resolve: (value) => resolve(value as T),
+        reject,
+        timer,
+      });
+      try {
+        this.#write({ id, method, ...(params === undefined ? {} : { params }) });
+      } catch (error) {
+        clearTimeout(timer);
+        this.#pending.delete(id);
+        reject(asError(error));
+      }
+    });
+  }
+
+  notify(method: string, params?: unknown): void {
+    this.#write({ method, ...(params === undefined ? {} : { params }) });
+  }
+
+  respond(id: JsonRpcId, result: unknown): void {
+    this.#write({ id, result });
+  }
+
+  respondError(id: JsonRpcId, error: JsonRpcErrorShape): void {
+    this.#write({ id, error });
+  }
+
+  endOutput(): void {
+    if (!this.#closed) this.#output.end();
+  }
+
+  close(reason = new Error('Codex 协议通道已关闭')): void {
+    if (this.#closed) return;
+    this.#closed = true;
+    this.#reader?.close();
+    this.#reader = null;
+    for (const pending of this.#pending.values()) {
+      clearTimeout(pending.timer);
+      pending.reject(reason);
+    }
+    this.#pending.clear();
+    this.emit('closed', reason);
+  }
+
+  #write(message: Record<string, unknown>): void {
+    if (this.#closed) throw new Error('Codex 协议通道已关闭');
+    const line = `${JSON.stringify(message)}\n`;
+    if (Buffer.byteLength(line, 'utf8') > MAX_OUTBOUND_PROTOCOL_LINE_BYTES) {
+      throw new Error('Codex 协议消息超过 16 MiB 上限');
+    }
+    this.#output.write(line, 'utf8');
+  }
+
+  #handleLine(line: string): void {
+    if (!line.trim()) return;
+    if (Buffer.byteLength(line, 'utf8') > MAX_INBOUND_PROTOCOL_LINE_BYTES) {
+      const error = new Error('Codex 返回的单行协议内容超过 48 MiB 上限');
+      this.emit('protocolError', error);
+      this.close(error);
+      return;
+    }
+    let message: Record<string, unknown>;
+    try {
+      const parsed = JSON.parse(line) as unknown;
+      if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+        throw new Error('协议消息不是对象');
+      }
+      message = parsed as Record<string, unknown>;
+    } catch (error) {
+      this.emit('protocolError', new Error(`非法的 Codex JSONL: ${asError(error).message}`));
+      return;
+    }
+
+    const method = typeof message.method === 'string' ? message.method : null;
+    const id = isJsonRpcId(message.id) ? message.id : null;
+    if (method) {
+      if (id !== null) {
+        this.emit('serverRequest', { id, method, params: message.params } satisfies JsonRpcServerRequest);
+      } else {
+        this.emit('notification', { method, params: message.params });
+      }
+      return;
+    }
+
+    if (id === null) {
+      this.emit('protocolError', new Error('Codex 响应缺少 id'));
+      return;
+    }
+    const pending = this.#pending.get(id);
+    if (!pending) {
+      this.emit('protocolError', new Error(`Codex 返回了未知的响应 id: ${String(id)}`));
+      return;
+    }
+    clearTimeout(pending.timer);
+    this.#pending.delete(id);
+    if (isJsonRpcError(message.error)) {
+      pending.reject(new JsonRpcRequestError(pending.method, message.error));
+    } else {
+      pending.resolve(message.result);
+    }
+  }
+}
+
+function isJsonRpcId(value: unknown): value is JsonRpcId {
+  return typeof value === 'string' || (typeof value === 'number' && Number.isFinite(value));
+}
+
+function isJsonRpcError(value: unknown): value is JsonRpcErrorShape {
+  if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
+  const candidate = value as Record<string, unknown>;
+  return typeof candidate.code === 'number' && typeof candidate.message === 'string';
+}
+
+function asError(value: unknown): Error {
+  return value instanceof Error ? value : new Error(String(value));
+}

+ 170 - 0
ai-electron/electron/service/codex/mcpService.test.ts

@@ -0,0 +1,170 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { McpService, type McpServerInput } from './mcpService';
+import type { JsonValue } from './codexRuntime';
+
+/** 内存版 runtime:只实现 McpService 用到的四个方法 */
+function makeRuntime(statuses: Array<{ name: string; connected: boolean; toolCount: number; authStatus: string }> = []) {
+  const servers: Record<string, JsonValue> = {};
+  return {
+    servers,
+    readConfig: vi.fn(async () => ({ mcp_servers: structuredClone(servers) })),
+    writeConfigValue: vi.fn(async (keyPath: string, value: JsonValue) => {
+      const id = keyPath.replace(/^mcp_servers\./u, '');
+      if (value === null) delete servers[id];
+      else servers[id] = value;
+    }),
+    reloadMcpServers: vi.fn(async () => undefined),
+    listMcpServerStatuses: vi.fn(async () => statuses),
+  };
+}
+
+const stdioInput: McpServerInput = {
+  id: 'demo-tools',
+  transport: 'stdio',
+  command: 'npx',
+  args: ['-y', '@example/mcp'],
+  enabled: true,
+};
+
+let runtime: ReturnType<typeof makeRuntime>;
+let service: McpService;
+
+beforeEach(() => {
+  runtime = makeRuntime();
+  service = new McpService(runtime);
+});
+
+describe('save', () => {
+  it('stdio 写成 command + args,并热重载', async () => {
+    await service.save(stdioInput);
+    expect(runtime.writeConfigValue).toHaveBeenCalledWith('mcp_servers.demo-tools', {
+      enabled: true,
+      command: 'npx',
+      args: ['-y', '@example/mcp'],
+    });
+    expect(runtime.reloadMcpServers).toHaveBeenCalledOnce();
+  });
+
+  it('http 写成 url + bearer_token_env_var,不接受明文 token', async () => {
+    await service.save({
+      id: 'remote',
+      transport: 'http',
+      url: 'https://mcp.example.com/v1',
+      enabled: false,
+      bearerTokenEnvVar: 'ZSJZ_MCP_TOKEN',
+    });
+    expect(runtime.writeConfigValue).toHaveBeenCalledWith('mcp_servers.remote', {
+      enabled: false,
+      url: 'https://mcp.example.com/v1',
+      bearer_token_env_var: 'ZSJZ_MCP_TOKEN',
+    });
+  });
+
+  it('非法 id / 传输方式 / enabled 被拒', async () => {
+    await expect(service.save({ ...stdioInput, id: 'has space' })).rejects.toThrow(/MCP ID/);
+    await expect(service.save({ ...stdioInput, id: '中文名' })).rejects.toThrow(/MCP ID/);
+    await expect(
+      service.save({ ...stdioInput, transport: 'ws' as unknown as 'stdio' }),
+    ).rejects.toThrow(/stdio 或 http/);
+    await expect(
+      service.save({ ...stdioInput, enabled: 'yes' as unknown as boolean }),
+    ).rejects.toThrow(/布尔值/);
+    expect(runtime.writeConfigValue).not.toHaveBeenCalled();
+  });
+
+  it('stdio 必须有命令,参数数量与长度受限', async () => {
+    await expect(service.save({ ...stdioInput, command: '   ' })).rejects.toThrow(/有效命令/);
+    await expect(service.save({ ...stdioInput, args: new Array(65).fill('a') })).rejects.toThrow(
+      /参数列表无效/,
+    );
+    await expect(service.save({ ...stdioInput, args: ['a'.repeat(2001)] })).rejects.toThrow(
+      /参数列表无效/,
+    );
+    await expect(service.save({ ...stdioInput, args: ['a\0b'] })).rejects.toThrow(/参数列表无效/);
+  });
+
+  it('http URL 必须合法:禁凭据、禁片段、远程必须 https', async () => {
+    await expect(
+      service.save({ id: 'a', transport: 'http', url: 'https://user:pass@example.com/v1', enabled: true }),
+    ).rejects.toThrow(/不得包含凭据/);
+    await expect(
+      service.save({ id: 'a', transport: 'http', url: 'https://example.com/v1#frag', enabled: true }),
+    ).rejects.toThrow(/不得包含凭据/);
+    await expect(
+      service.save({ id: 'a', transport: 'http', url: 'http://10.0.0.8:3000/v1', enabled: true }),
+    ).rejects.toThrow(/HTTPS/);
+    // 本机 http 允许(本地模型/本地 MCP 常见形态)
+    await expect(
+      service.save({ id: 'a', transport: 'http', url: 'http://127.0.0.1:3000/v1', enabled: true }),
+    ).resolves.toBeTruthy();
+  });
+
+  it('bearer token 只接受环境变量名', async () => {
+    await expect(
+      service.save({
+        id: 'a',
+        transport: 'http',
+        url: 'https://example.com/v1',
+        enabled: true,
+        bearerTokenEnvVar: 'sk-actual-secret-value',
+      }),
+    ).rejects.toThrow(/环境变量名无效/);
+  });
+});
+
+describe('remove', () => {
+  it('写 null 删除该叶子并重载', async () => {
+    await service.save(stdioInput);
+    await service.remove('demo-tools');
+    expect(runtime.writeConfigValue).toHaveBeenLastCalledWith('mcp_servers.demo-tools', null);
+    expect(runtime.servers['demo-tools']).toBeUndefined();
+    expect(runtime.reloadMcpServers).toHaveBeenCalledTimes(2);
+  });
+
+  it('拒绝非法 id', async () => {
+    await expect(service.remove('../etc')).rejects.toThrow(/MCP ID/);
+  });
+});
+
+describe('list', () => {
+  it('忽略非法 id 的残留条目', async () => {
+    runtime.servers['demo-tools'] = { enabled: true, command: 'npx', args: ['-y', '@example/mcp'] };
+    runtime.servers['bad id'] = { enabled: true, command: 'x', args: [] };
+
+    const items = await service.list();
+    expect(items.map((item) => item.id)).toEqual(['demo-tools']);
+    expect(items[0]).toMatchObject({
+      transport: 'stdio',
+      command: 'npx',
+      enabled: true,
+      connected: false,
+      toolCount: 0,
+      authStatus: 'unknown',
+    });
+  });
+
+  it('合并 app-server 返回的连接状态', async () => {
+    const statusRuntime = makeRuntime([
+      { name: 'demo-tools', connected: true, toolCount: 7, authStatus: 'authorized' },
+    ]);
+    statusRuntime.servers['demo-tools'] = { enabled: true, command: 'npx', args: [] };
+
+    const items = await new McpService(statusRuntime).list();
+    expect(items[0]).toMatchObject({ connected: true, toolCount: 7, authStatus: 'authorized' });
+  });
+
+  it('listMcpServerStatuses 失败时仍能列出配置', async () => {
+    const brokenRuntime = makeRuntime();
+    brokenRuntime.listMcpServerStatuses.mockRejectedValue(new Error('app-server 未就绪'));
+    brokenRuntime.servers['demo-tools'] = { enabled: true, command: 'npx', args: [] };
+
+    const items = await new McpService(brokenRuntime).list();
+    expect(items[0]).toMatchObject({ id: 'demo-tools', connected: false });
+  });
+
+  it('url 存在时识别为 http 传输', async () => {
+    runtime.servers['remote'] = { enabled: false, url: 'https://mcp.example.com/v1' };
+    const items = await service.list();
+    expect(items[0]).toMatchObject({ transport: 'http', enabled: false, url: 'https://mcp.example.com/v1' });
+  });
+});

+ 212 - 0
ai-electron/electron/service/codex/mcpService.ts

@@ -0,0 +1,212 @@
+import type { CodexMcpServerStatus, CodexRuntime, JsonValue } from './codexRuntime';
+
+/**
+ * 移植自 Noobi.ai src/main/mcpConfigManager.ts:不自己解析 TOML,
+ * 全部通过 app-server 的 config/value/write + config/mcpServer/reload 代理,保存后热生效无需重启。
+ * 校验阈值原样保留:绝不接受 shell 源与明文 bearer token,STDIO 参数只走 argv 数组。
+ */
+
+const MCP_ID = /^[a-zA-Z0-9_-]{1,64}$/u;
+const ENVIRONMENT_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/u;
+const MAX_ARGUMENTS = 64;
+const MAX_ARGUMENT_LENGTH = 2_000;
+const MAX_COMMAND_LENGTH = 500;
+const MAX_URL_LENGTH = 2_048;
+
+export type McpTransport = 'stdio' | 'http';
+
+export interface McpServerInput {
+  id: string;
+  transport: McpTransport;
+  command?: string | null;
+  args?: string[];
+  url?: string | null;
+  enabled: boolean;
+  bearerTokenEnvVar?: string | null;
+}
+
+export interface McpServerSetting {
+  id: string;
+  transport: McpTransport;
+  command: string | null;
+  args: string[];
+  url: string | null;
+  enabled: boolean;
+  bearerTokenEnvVar: string | null;
+  connected: boolean;
+  toolCount: number;
+  authStatus: string;
+}
+
+type McpRuntime = Pick<
+  CodexRuntime,
+  'readConfig' | 'writeConfigValue' | 'reloadMcpServers' | 'listMcpServerStatuses'
+>;
+
+export class McpService {
+  readonly #runtime: McpRuntime;
+
+  constructor(runtime: McpRuntime) {
+    this.#runtime = runtime;
+  }
+
+  async list(): Promise<McpServerSetting[]> {
+    const [config, statuses] = await Promise.all([
+      this.#runtime.readConfig(),
+      this.#runtime.listMcpServerStatuses().catch(() => [] as CodexMcpServerStatus[]),
+    ]);
+    const statusByName = new Map(statuses.map((status) => [status.name, status]));
+    const rawServers = asRecord(config.mcp_servers) ?? {};
+    return Object.entries(rawServers)
+      .filter(([id]) => MCP_ID.test(id))
+      .flatMap(([id, value]) => {
+        const parsed = parseStoredServer(id, value, statusByName.get(id));
+        return parsed ? [parsed] : [];
+      })
+      .sort((left, right) => left.id.localeCompare(right.id));
+  }
+
+  async save(input: McpServerInput): Promise<McpServerSetting[]> {
+    const server = validateServerInput(input);
+    const config: Record<string, JsonValue> = { enabled: server.enabled };
+    if (server.transport === 'stdio') {
+      config.command = server.command!;
+      config.args = server.args;
+    } else {
+      config.url = server.url!;
+      if (server.bearerTokenEnvVar) {
+        config.bearer_token_env_var = server.bearerTokenEnvVar;
+      }
+    }
+    await this.#runtime.writeConfigValue(`mcp_servers.${server.id}`, config);
+    await this.#runtime.reloadMcpServers();
+    return this.list();
+  }
+
+  async remove(id: string): Promise<McpServerSetting[]> {
+    const validatedId = validateId(id);
+    // Codex 的配置写入把 JSON null 当作删除该叶子路径
+    await this.#runtime.writeConfigValue(`mcp_servers.${validatedId}`, null);
+    await this.#runtime.reloadMcpServers();
+    return this.list();
+  }
+}
+
+function validateServerInput(input: McpServerInput): Required<
+  Omit<McpServerInput, 'command' | 'url' | 'bearerTokenEnvVar'>
+> & { command: string | null; url: string | null; bearerTokenEnvVar: string | null } {
+  if (!input || typeof input !== 'object') throw new Error('无效的 MCP 配置');
+  const id = validateId(input.id);
+  if (input.transport !== 'stdio' && input.transport !== 'http') {
+    throw new Error('MCP 传输方式必须是 stdio 或 http');
+  }
+  if (typeof input.enabled !== 'boolean') throw new Error('MCP enabled 必须是布尔值');
+  const args = input.args ?? [];
+  if (
+    !Array.isArray(args) ||
+    args.length > MAX_ARGUMENTS ||
+    args.some(
+      (value) => typeof value !== 'string' || value.length > MAX_ARGUMENT_LENGTH || value.includes('\0'),
+    )
+  ) {
+    throw new Error('MCP 参数列表无效');
+  }
+  if (input.transport === 'stdio') {
+    const command = input.command?.trim() ?? '';
+    if (!command || command.length > MAX_COMMAND_LENGTH || command.includes('\0')) {
+      throw new Error('STDIO MCP 必须提供有效命令');
+    }
+    return {
+      id,
+      transport: 'stdio',
+      command,
+      args: [...args],
+      url: null,
+      enabled: input.enabled,
+      bearerTokenEnvVar: null,
+    };
+  }
+
+  const url = validateHttpUrl(input.url);
+  const bearerTokenEnvVar = input.bearerTokenEnvVar?.trim() || null;
+  if (bearerTokenEnvVar && !ENVIRONMENT_NAME.test(bearerTokenEnvVar)) {
+    throw new Error('Bearer Token 环境变量名无效');
+  }
+  return {
+    id,
+    transport: 'http',
+    command: null,
+    args: [],
+    url,
+    enabled: input.enabled,
+    bearerTokenEnvVar,
+  };
+}
+
+function validateId(value: unknown): string {
+  if (typeof value !== 'string' || !MCP_ID.test(value)) {
+    throw new Error('MCP ID 只能包含字母、数字、连字符和下划线');
+  }
+  return value;
+}
+
+function validateHttpUrl(value: unknown): string {
+  if (typeof value !== 'string' || value.length > MAX_URL_LENGTH) {
+    throw new Error('HTTP MCP 必须提供有效 URL');
+  }
+  let parsed: URL;
+  try {
+    parsed = new URL(value);
+  } catch {
+    throw new Error('HTTP MCP 必须提供有效 URL');
+  }
+  if (parsed.username || parsed.password || parsed.hash) {
+    throw new Error('MCP URL 不得包含凭据或片段');
+  }
+  const local =
+    parsed.hostname === 'localhost' ||
+    parsed.hostname === '127.0.0.1' ||
+    parsed.hostname === '[::1]' ||
+    parsed.hostname === '::1';
+  if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && local)) {
+    throw new Error('远程 MCP 必须使用 HTTPS;HTTP 仅允许本机地址');
+  }
+  return parsed.toString();
+}
+
+function parseStoredServer(
+  id: string,
+  value: unknown,
+  status?: CodexMcpServerStatus,
+): McpServerSetting | null {
+  const record = asRecord(value);
+  if (!record) return null;
+  const command = readString(record.command);
+  const url = readString(record.url);
+  const transport: McpTransport = url ? 'http' : 'stdio';
+  const args = Array.isArray(record.args)
+    ? record.args.filter((item): item is string => typeof item === 'string').slice(0, MAX_ARGUMENTS)
+    : [];
+  return {
+    id,
+    transport,
+    command,
+    args,
+    url,
+    enabled: record.enabled !== false,
+    bearerTokenEnvVar: readString(record.bearer_token_env_var),
+    connected: status?.connected ?? false,
+    toolCount: status?.toolCount ?? 0,
+    authStatus: status?.authStatus ?? 'unknown',
+  };
+}
+
+function asRecord(value: unknown): Record<string, unknown> | null {
+  return value && typeof value === 'object' && !Array.isArray(value)
+    ? (value as Record<string, unknown>)
+    : null;
+}
+
+function readString(value: unknown): string | null {
+  return typeof value === 'string' ? value : null;
+}

+ 145 - 0
ai-electron/electron/service/codex/providerService.test.ts

@@ -0,0 +1,145 @@
+import { createServer, type Server } from 'node:http';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import {
+  baseUrlHint,
+  describeDroppedKeys,
+  normalizeBaseUrl,
+  probeResponsesEndpoint,
+  toProviderSpec,
+} from './providerService';
+import type { ApplyProviderInput } from './types';
+
+let server: Server;
+let port = 0;
+let nextStatus = 404;
+let lastRequest: { url: string | null; auth: string | null } = { url: null, auth: null };
+
+beforeAll(async () => {
+  server = createServer((req, res) => {
+    lastRequest = { url: req.url ?? null, auth: req.headers.authorization ?? null };
+    req.resume();
+    res.writeHead(nextStatus, { 'content-type': 'application/json' });
+    res.end(JSON.stringify({ status: nextStatus }));
+  });
+  await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
+  const address = server.address();
+  port = typeof address === 'object' && address ? address.port : 0;
+});
+
+afterAll(async () => {
+  await new Promise<void>((resolve) => server.close(() => resolve()));
+});
+
+const openaiLike: ApplyProviderInput = {
+  modelId: 'qwen3-max',
+  name: '通义千问',
+  baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1/',
+  apiKey: 'sk-test-key',
+  providerType: 'DASHSCOPE',
+  headersJson: JSON.stringify({ 'X-DashScope-WorkSpace': 'ws-1' }),
+  config: JSON.stringify({ request_max_retries: 3, temperature: 0.2 }),
+};
+
+describe('normalizeBaseUrl / baseUrlHint', () => {
+  it('去掉尾部斜杠,空值返回 null', () => {
+    expect(normalizeBaseUrl('https://x.com/v1///')).toBe('https://x.com/v1');
+    expect(normalizeBaseUrl('   ')).toBeNull();
+    expect(normalizeBaseUrl(null)).toBeNull();
+  });
+
+  it('缺版本段时给非阻断提示', () => {
+    expect(baseUrlHint('https://x.com')).toMatch(/v1/);
+    expect(baseUrlHint('https://x.com/v1')).toBeNull();
+    expect(baseUrlHint('ftp://x.com/v1')).toMatch(/http/);
+    expect(baseUrlHint(null)).toMatch(/未配置/);
+  });
+});
+
+describe('toProviderSpec', () => {
+  it('OpenAI 兼容端点:写 model_providers.zsjz,key 只留给 env', () => {
+    const spec = toProviderSpec(openaiLike);
+    expect(spec).toMatchObject({
+      id: 'zsjz',
+      model: 'qwen3-max',
+      baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
+      apiKey: 'sk-test-key',
+      envKey: 'ZSJZ_CODEX_API_KEY',
+    });
+    expect(spec.builtinProvider ?? null).toBeNull();
+    expect(spec.httpHeaders).toEqual({ 'X-DashScope-WorkSpace': 'ws-1' });
+    // config 里只放行白名单键,temperature 必须被丢掉
+    expect(spec.extra).toEqual({ request_max_retries: 3 });
+  });
+
+  it('OLLAMA 走 Codex 内置 provider,不需要 baseUrl 与 key', () => {
+    const spec = toProviderSpec({ modelId: 'qwen3:8b', providerType: 'OLLAMA' });
+    expect(spec).toMatchObject({ id: null, builtinProvider: 'ollama', model: 'qwen3:8b' });
+    expect(spec.baseUrl).toBeNull();
+  });
+
+  it('远端 Ollama 必须保留并补齐 base_url,不能退回 localhost:11434', () => {
+    expect(
+      toProviderSpec({ modelId: 'qwen', providerType: 'OLLAMA', baseUrl: 'http://10.66.66.66:8080' }).baseUrl,
+    ).toBe('http://10.66.66.66:8080/v1');
+    expect(
+      toProviderSpec({ modelId: 'qwen', providerType: 'OLLAMA', baseUrl: 'http://host:11434/v1/' }).baseUrl,
+    ).toBe('http://host:11434/v1');
+  });
+
+  it('可显式指定内置 provider(lmstudio)', () => {
+    const spec = toProviderSpec({ modelId: 'local-model', builtinProvider: 'lmstudio' });
+    expect(spec.builtinProvider).toBe('lmstudio');
+  });
+
+  it('缺 modelId 或非内置 provider 缺 baseUrl 时报错', () => {
+    expect(() => toProviderSpec({ modelId: '  ' })).toThrow(/modelId/);
+    expect(() => toProviderSpec({ modelId: 'gpt', providerType: 'OPENAI' })).toThrow(/baseUrl/);
+  });
+
+  it('headersJson 与 config 支持对象或 JSON 字符串,非法 JSON 忽略', () => {
+    expect(toProviderSpec({ ...openaiLike, headersJson: { A: '1' } }).httpHeaders).toEqual({ A: '1' });
+    expect(toProviderSpec({ ...openaiLike, headersJson: '{bad json' }).httpHeaders).toBeNull();
+    expect(toProviderSpec({ ...openaiLike, config: { stream_max_retries: 5 } }).extra).toEqual({
+      stream_max_retries: 5,
+    });
+  });
+
+  it('describeDroppedKeys 报告被丢弃的非白名单键', () => {
+    expect(describeDroppedKeys(openaiLike)).toEqual(['temperature']);
+    expect(describeDroppedKeys({ modelId: 'x' })).toEqual([]);
+  });
+});
+
+describe('probeResponsesEndpoint', () => {
+  it('打到 base_url 下的 /responses', async () => {
+    nextStatus = 401;
+    await probeResponsesEndpoint(`http://127.0.0.1:${port}/v1/`, 'sk-test-key');
+    expect(lastRequest.url).toBe('/v1/responses');
+    expect(lastRequest.auth).toBe('Bearer sk-test-key');
+  });
+
+  it('404/405 判为未实现 Responses API', async () => {
+    nextStatus = 404;
+    const notFound = await probeResponsesEndpoint(`http://127.0.0.1:${port}/v1`);
+    expect(notFound).toMatchObject({ compatible: false, status: 404 });
+    expect(notFound.detail).toMatch(/未实现 Responses API/);
+
+    nextStatus = 405;
+    expect((await probeResponsesEndpoint(`http://127.0.0.1:${port}/v1`)).compatible).toBe(false);
+  });
+
+  it('401/400/2xx 判为路由存在(兼容)', async () => {
+    for (const status of [401, 400, 200]) {
+      nextStatus = status;
+      const result = await probeResponsesEndpoint(`http://127.0.0.1:${port}/v1`);
+      expect(result).toMatchObject({ compatible: true, status });
+    }
+  });
+
+  it('端点不可达时 status 为 null', async () => {
+    const result = await probeResponsesEndpoint('http://127.0.0.1:1/v1');
+    expect(result.compatible).toBe(false);
+    expect(result.status).toBeNull();
+    expect(result.detail).toMatch(/不可达/);
+  });
+});

+ 218 - 0
ai-electron/electron/service/codex/providerService.ts

@@ -0,0 +1,218 @@
+import { mkdir, readFile, writeFile } from 'node:fs/promises';
+import { join } from 'node:path';
+import { getCodexDataDir } from './codexHome';
+import { DEFAULT_API_KEY_ENV, type CodexProviderSpec, type CodexRuntime, type JsonValue } from './codexRuntime';
+import type { AppliedProviderInfo, ApplyProviderInput } from './types';
+
+/**
+ * 把后端「模型管理」里的一条记录翻译成 Codex 的 model provider。
+ * 全程不做任何 Codex/OpenAI 账号登录:靠 wire_api="responses" + requires_openai_auth=false + env_key。
+ * apiKey 只存在于内存与子进程环境变量,落盘的 AppliedProvider 不含任何密钥。
+ */
+
+export const PROVIDER_ID = 'zsjz';
+
+export type AppliedProvider = AppliedProviderInfo;
+
+export interface ProviderCompatibility {
+  compatible: boolean;
+  /** null 表示网络不可达,无法判定 */
+  status: number | null;
+  detail: string;
+}
+
+const EXTRA_ALLOWLIST: readonly string[] = [
+  'request_max_retries',
+  'stream_max_retries',
+  'stream_idle_timeout_ms',
+  'query_params',
+];
+
+function providerFile(): string {
+  return join(getCodexDataDir(), 'provider.json');
+}
+
+/** Codex 要求 base_url 是 API 根(形如 https://api.openai.com/v1),它自己再拼 /responses */
+export function normalizeBaseUrl(raw: string | null | undefined): string | null {
+  const value = (raw ?? '').trim().replace(/\/+$/u, '');
+  return value || null;
+}
+
+/** 给页面的非阻断提示;返回 null 表示没问题 */
+export function baseUrlHint(baseUrl: string | null): string | null {
+  if (!baseUrl) return '未配置 base_url';
+  if (!/^https?:\/\//u.test(baseUrl)) return 'base_url 必须以 http:// 或 https:// 开头';
+  if (!/\/v\d+$/u.test(baseUrl)) {
+    return 'base_url 通常应写到版本段(如 .../v1),否则 Codex 拼出的 /responses 可能 404';
+  }
+  return null;
+}
+
+function parseJsonRecord(raw: unknown): Record<string, unknown> | null {
+  if (!raw) return null;
+  if (typeof raw === 'object' && !Array.isArray(raw)) return raw as Record<string, unknown>;
+  if (typeof raw !== 'string') return null;
+  try {
+    const parsed = JSON.parse(raw) as unknown;
+    return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
+      ? (parsed as Record<string, unknown>)
+      : null;
+  } catch {
+    return null;
+  }
+}
+
+function readStringMap(raw: unknown): Record<string, string> | null {
+  const record = parseJsonRecord(raw);
+  if (!record) return null;
+  const result: Record<string, string> = {};
+  for (const [key, value] of Object.entries(record)) {
+    if (typeof value === 'string' && value) result[key] = value;
+  }
+  return Object.keys(result).length ? result : null;
+}
+
+/** Codex 内置 ollama/lmstudio 走 OpenAI 兼容根(.../v1),而库里常只存到 host:port */
+function normalizeOssBaseUrl(raw: string | null | undefined): string | null {
+  const base = normalizeBaseUrl(raw);
+  if (!base) return null;
+  return /\/v\d+$/u.test(base) ? base : `${base}/v1`;
+}
+
+/** OLLAMA 用 Codex 内置 provider(Responses API,默认端口 11434),不需要 api key */
+function resolveBuiltinProvider(input: ApplyProviderInput): string | null {
+  if (input.builtinProvider) return input.builtinProvider;
+  const type = `${input.providerType ?? ''}`.trim().toUpperCase();
+  if (type === 'OLLAMA') return 'ollama';
+  if (type === 'LMSTUDIO') return 'lmstudio';
+  return null;
+}
+
+export function toProviderSpec(input: ApplyProviderInput): CodexProviderSpec {
+  const model = `${input.modelId ?? ''}`.trim();
+  if (!model) throw new Error('缺少 modelId');
+
+  const builtinProvider = resolveBuiltinProvider(input);
+  if (builtinProvider) {
+    return {
+      id: null,
+      builtinProvider,
+      model,
+      name: input.name ?? `${builtinProvider} 本地模型`,
+      // 内置 provider 的默认地址是 localhost,必须把库里的真实地址带上,否则连不到远端 Ollama
+      baseUrl: normalizeOssBaseUrl(input.baseUrl),
+    };
+  }
+
+  const baseUrl = normalizeBaseUrl(input.baseUrl);
+  if (!baseUrl) throw new Error('缺少 baseUrl');
+
+  const config = parseJsonRecord(input.config);
+  const extra: Record<string, JsonValue> = {};
+  for (const [key, value] of Object.entries(config ?? {})) {
+    if (EXTRA_ALLOWLIST.includes(key)) extra[key] = value as JsonValue;
+  }
+
+  return {
+    id: PROVIDER_ID,
+    name: input.name ?? model,
+    baseUrl,
+    model,
+    apiKey: input.apiKey ?? null,
+    envKey: DEFAULT_API_KEY_ENV,
+    httpHeaders: readStringMap(input.headersJson),
+    extra: Object.keys(extra).length ? extra : null,
+  };
+}
+
+/** config 里被丢弃的非白名单键,用于页面提示 */
+export function describeDroppedKeys(input: ApplyProviderInput): string[] {
+  const config = parseJsonRecord(input.config);
+  return Object.keys(config ?? {}).filter((key) => !EXTRA_ALLOWLIST.includes(key));
+}
+
+/**
+ * 探测端点是否实现了 Responses API。
+ * 404/405 = 未实现(Codex 0.155 起已下线 wire_api="chat",chat-only 端点用不了);
+ * 401/403/400/422/2xx = 路由存在,判为兼容。
+ */
+export async function probeResponsesEndpoint(
+  baseUrl: string,
+  apiKey?: string | null,
+): Promise<ProviderCompatibility> {
+  const url = `${baseUrl.replace(/\/+$/u, '')}/responses`;
+  let status: number;
+  try {
+    const response = await fetch(url, {
+      method: 'POST',
+      headers: {
+        'content-type': 'application/json',
+        ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}),
+      },
+      body: JSON.stringify({ model: 'probe', input: 'probe' }),
+      signal: AbortSignal.timeout(8_000),
+    });
+    status = response.status;
+  } catch (error) {
+    return {
+      compatible: false,
+      status: null,
+      detail: `端点不可达:${error instanceof Error ? error.message : String(error)}`,
+    };
+  }
+  if (status === 404 || status === 405) {
+    return {
+      compatible: false,
+      status,
+      detail: `该端点未实现 Responses API(HTTP ${status})。Codex 0.155 起已下线 wire_api="chat",只提供 /chat/completions 的服务需要额外网关转换。`,
+    };
+  }
+  return { compatible: true, status, detail: `端点已实现 Responses API(HTTP ${status})` };
+}
+
+export async function readAppliedProvider(): Promise<AppliedProvider | null> {
+  try {
+    const raw = JSON.parse(await readFile(providerFile(), 'utf8')) as Partial<AppliedProvider>;
+    return raw.model ? (raw as AppliedProvider) : null;
+  } catch {
+    return null;
+  }
+}
+
+async function writeAppliedProvider(value: AppliedProvider | null): Promise<void> {
+  await mkdir(getCodexDataDir(), { recursive: true });
+  await writeFile(providerFile(), JSON.stringify(value ?? {}, null, 2), 'utf8');
+}
+
+/** 应用一条模型配置:先探端点,再重启子进程(api_key 走 env,换 key 必须重启) */
+export async function applyProvider(
+  runtime: CodexRuntime,
+  input: ApplyProviderInput & { modelRecordId?: string | number | null },
+): Promise<AppliedProvider> {
+  const spec = toProviderSpec(input);
+  // 只要显式配了地址就探(含远端 Ollama):应用成功后才发现连不上,比这里直接报错更难排查。
+  // 内置 provider 没配地址时不探(默认 localhost,可能压根没起本地服务)。
+  if (spec.baseUrl) {
+    const probe = await probeResponsesEndpoint(spec.baseUrl, spec.apiKey);
+    if (!probe.compatible) throw new Error(probe.detail);
+  }
+  await runtime.applyProvider(spec);
+  const applied: AppliedProvider = {
+    model: spec.model,
+    modelRecordId: input.modelRecordId === undefined || input.modelRecordId === null
+      ? null
+      : String(input.modelRecordId),
+    baseUrl: spec.baseUrl ?? null,
+    providerId: spec.id,
+    builtinProvider: spec.builtinProvider ?? null,
+    name: spec.name ?? null,
+    appliedAt: new Date().toISOString(),
+  };
+  await writeAppliedProvider(applied);
+  return applied;
+}
+
+export async function clearProvider(runtime: CodexRuntime): Promise<void> {
+  await runtime.applyProvider(null);
+  await writeAppliedProvider(null);
+}

+ 424 - 0
ai-electron/electron/service/codex/skillService.test.ts

@@ -0,0 +1,424 @@
+import { mkdir, mkdtemp, readdir, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { crc32 } from 'node:zlib';
+import { afterEach, beforeEach, describe, expect, it, vi, type TestContext } from 'vitest';
+import {
+  assertSafeRelativePath,
+  normalizeSkillName,
+  parseFrontmatter,
+  SkillService,
+} from './skillService';
+
+const SKILL_MD = ['---', 'name: demo-skill', 'description: 一个用于测试的技能', '---', '', '# demo', ''].join('\n');
+
+interface RawZipEntry {
+  name: string;
+  data?: string | Buffer;
+  /** Unix mode,写在 external file attributes 高 16 位;0o120777 即符号链接 */
+  mode?: number;
+}
+
+/**
+ * 手搓 stored(不压缩)zip。
+ * 不能用 yazl:它自己就拒绝构造 `../`、绝对路径这类条目,而安全测试恰恰需要它们。
+ */
+function makeZip(entries: RawZipEntry[]): Buffer {
+  const locals: Buffer[] = [];
+  const centrals: Buffer[] = [];
+  let offset = 0;
+
+  for (const entry of entries) {
+    const isDir = entry.name.endsWith('/');
+    const data = isDir
+      ? Buffer.alloc(0)
+      : Buffer.isBuffer(entry.data)
+        ? entry.data
+        : Buffer.from(entry.data ?? '', 'utf8');
+    const nameBuf = Buffer.from(entry.name, 'utf8');
+    const crc = isDir ? 0 : crc32(data);
+    const mode = entry.mode ?? (isDir ? 0o40755 : 0o100644);
+
+    const local = Buffer.alloc(30);
+    local.writeUInt32LE(0x04034b50, 0);
+    local.writeUInt16LE(20, 4);
+    local.writeUInt16LE(0, 6);
+    local.writeUInt16LE(0, 8); // method: stored
+    local.writeUInt16LE(0, 10);
+    local.writeUInt16LE(0x21, 12);
+    local.writeUInt32LE(crc >>> 0, 14);
+    local.writeUInt32LE(data.length, 18);
+    local.writeUInt32LE(data.length, 22);
+    local.writeUInt16LE(nameBuf.length, 26);
+    local.writeUInt16LE(0, 28);
+    locals.push(local, nameBuf, data);
+
+    const central = Buffer.alloc(46);
+    central.writeUInt32LE(0x02014b50, 0);
+    central.writeUInt16LE((0x03 << 8) | 20, 4); // version made by: unix
+    central.writeUInt16LE(20, 6);
+    central.writeUInt16LE(0, 8);
+    central.writeUInt16LE(0, 10);
+    central.writeUInt16LE(0, 12);
+    central.writeUInt16LE(0x21, 14);
+    central.writeUInt32LE(crc >>> 0, 16);
+    central.writeUInt32LE(data.length, 20);
+    central.writeUInt32LE(data.length, 24);
+    central.writeUInt16LE(nameBuf.length, 28);
+    central.writeUInt16LE(0, 30);
+    central.writeUInt16LE(0, 32);
+    central.writeUInt16LE(0, 34);
+    central.writeUInt16LE(0, 36);
+    central.writeUInt32LE((mode << 16) >>> 0, 38);
+    central.writeUInt32LE(offset, 42);
+    centrals.push(central, nameBuf);
+
+    offset += local.length + nameBuf.length + data.length;
+  }
+
+  const centralDir = Buffer.concat(centrals);
+  const eocd = Buffer.alloc(22);
+  eocd.writeUInt32LE(0x06054b50, 0);
+  eocd.writeUInt16LE(entries.length, 8);
+  eocd.writeUInt16LE(entries.length, 10);
+  eocd.writeUInt32LE(centralDir.length, 12);
+  eocd.writeUInt32LE(offset, 16);
+  return Buffer.concat([...locals, centralDir, eocd]);
+}
+
+function makeRuntime(skills: Array<{ name: string; path: string }> = []) {
+  return {
+    listSkills: vi.fn().mockResolvedValue(
+      skills.map((skill) => ({
+        name: skill.name,
+        description: 'd',
+        path: skill.path,
+        scope: 'user',
+        enabled: true,
+        cwd: '',
+      })),
+    ),
+    setSkillEnabled: vi.fn().mockResolvedValue(true),
+    readConfig: vi.fn(async () => ({}) as Record<string, unknown>),
+    writeConfigValue: vi.fn(async () => undefined),
+  };
+}
+
+let root: string;
+let skillsDir: string;
+let runtime: ReturnType<typeof makeRuntime>;
+let service: SkillService;
+
+beforeEach(async () => {
+  root = await mkdtemp(join(tmpdir(), 'zsjz-skill-'));
+  skillsDir = join(root, 'skills');
+  await mkdir(skillsDir, { recursive: true });
+  runtime = makeRuntime();
+  service = new SkillService(runtime, { skillsDir });
+});
+
+afterEach(async () => {
+  await rm(root, { recursive: true, force: true });
+});
+
+async function writeSkillSource(dir: string, content = SKILL_MD): Promise<string> {
+  await mkdir(dir, { recursive: true });
+  await writeFile(join(dir, 'SKILL.md'), content, 'utf8');
+  return dir;
+}
+
+describe('normalizeSkillName', () => {
+  it('归一化为小写连字符命名', () => {
+    expect(normalizeSkillName('My Skill_Name')).toBe('my-skill-name');
+    expect(normalizeSkillName('  demo--skill  ')).toBe('demo-skill');
+  });
+
+  it('拒绝空名与超长名', () => {
+    expect(() => normalizeSkillName('***')).toThrow(/非法/);
+    expect(() => normalizeSkillName('a'.repeat(65))).toThrow(/非法/);
+  });
+});
+
+describe('parseFrontmatter', () => {
+  it('读取 name 与 description', () => {
+    expect(parseFrontmatter(SKILL_MD)).toEqual({ name: 'demo-skill', description: '一个用于测试的技能' });
+  });
+
+  it('支持引号与缺失字段', () => {
+    expect(parseFrontmatter('---\nname: "quoted"\n---\n')).toEqual({ name: 'quoted', description: null });
+    expect(parseFrontmatter('# 没有 frontmatter')).toEqual({ name: null, description: null });
+  });
+});
+
+describe('installFromFolder', () => {
+  it('按 frontmatter 的 name 落盘', async () => {
+    const source = await writeSkillSource(join(root, 'src'));
+    await service.installFromFolder(source);
+
+    const installed = join(skillsDir, 'demo-skill', 'SKILL.md');
+    expect((await stat(installed)).isFile()).toBe(true);
+    expect(await readFile(installed, 'utf8')).toContain('demo-skill');
+    expect(runtime.listSkills).toHaveBeenCalledWith({ forceReload: true });
+  });
+
+  it('缺 SKILL.md 时拒绝', async () => {
+    const source = join(root, 'empty');
+    await mkdir(source, { recursive: true });
+    await writeFile(join(source, 'readme.md'), 'x', 'utf8');
+    await expect(service.installFromFolder(source)).rejects.toThrow(/缺少 SKILL\.md/);
+  });
+
+  it('frontmatter 缺 description 时拒绝', async () => {
+    const source = await writeSkillSource(join(root, 'no-desc'), '---\nname: no-desc\n---\n# x\n');
+    await expect(service.installFromFolder(source)).rejects.toThrow(/缺少 description/);
+  });
+
+  it('重名需显式覆盖', async () => {
+    const source = await writeSkillSource(join(root, 'src'));
+    await service.installFromFolder(source);
+    await expect(service.installFromFolder(source)).rejects.toThrow(/已存在/);
+    await service.installFromFolder(source, { overwrite: true });
+    expect((await stat(join(skillsDir, 'demo-skill', 'SKILL.md'))).isFile()).toBe(true);
+  });
+
+  it('安装失败时清掉暂存目录', async () => {
+    const source = join(root, 'bad');
+    await mkdir(source, { recursive: true });
+    await writeFile(join(source, 'notes.md'), 'x', 'utf8');
+    await expect(service.installFromFolder(source)).rejects.toThrow();
+    expect(await readdir(skillsDir)).toEqual([]);
+  });
+
+  it('拒绝源目录里的链接(Windows 用 junction)', async (ctx: TestContext) => {
+    const outside = join(root, 'outside');
+    await mkdir(outside, { recursive: true });
+    await writeFile(join(outside, 'secret.md'), 'secret', 'utf8');
+    const source = await writeSkillSource(join(root, 'src'));
+    try {
+      await symlink(outside, join(source, 'link-dir'), 'junction');
+    } catch {
+      ctx.skip();
+      return;
+    }
+    await expect(service.installFromFolder(source)).rejects.toThrow(/符号链接/);
+  });
+});
+
+describe('installFromZip', () => {
+  it('SKILL.md 在 zip 根目录时可直接安装', async () => {
+    await service.installFromZip(makeZip([{ name: 'SKILL.md', data: SKILL_MD }]));
+    expect((await stat(join(skillsDir, 'demo-skill', 'SKILL.md'))).isFile()).toBe(true);
+  });
+
+  it('带一层外壳目录时安装内层', async () => {
+    await service.installFromZip(
+      makeZip([
+        { name: 'wrapper/SKILL.md', data: SKILL_MD },
+        { name: 'wrapper/references/a.md', data: 'ref' },
+      ]),
+    );
+    expect((await stat(join(skillsDir, 'demo-skill', 'SKILL.md'))).isFile()).toBe(true);
+    expect((await stat(join(skillsDir, 'demo-skill', 'references', 'a.md'))).isFile()).toBe(true);
+    expect(await stat(join(skillsDir, 'wrapper')).catch(() => null)).toBeNull();
+  });
+
+  // 下面几例里 yauzl 会先拦下非法条目名,我方 assertSafeRelativePath 是第二道防线(单独测)
+  it('拒绝 ../ 路径穿越条目,且不会有文件逃逸', async () => {
+    const zip = makeZip([
+      { name: 'SKILL.md', data: SKILL_MD },
+      { name: '../evil.md', data: 'pwned' },
+    ]);
+    await expect(service.installFromZip(zip)).rejects.toThrow();
+    expect(await stat(join(root, 'evil.md')).catch(() => null)).toBeNull();
+    expect(await readdir(skillsDir)).toEqual([]);
+  });
+
+  it('拒绝嵌套的 ../ 穿越条目', async () => {
+    const zip = makeZip([
+      { name: 'SKILL.md', data: SKILL_MD },
+      { name: 'docs/../../evil.md', data: 'pwned' },
+    ]);
+    await expect(service.installFromZip(zip)).rejects.toThrow();
+    expect(await stat(join(root, 'evil.md')).catch(() => null)).toBeNull();
+  });
+
+  it('拒绝反斜杠形式的穿越条目', async () => {
+    const zip = makeZip([
+      { name: 'SKILL.md', data: SKILL_MD },
+      { name: '..\\evil.md', data: 'pwned' },
+    ]);
+    await expect(service.installFromZip(zip)).rejects.toThrow();
+  });
+
+  it('拒绝绝对路径条目', async () => {
+    const zip = makeZip([
+      { name: 'SKILL.md', data: SKILL_MD },
+      { name: '/etc/passwd', data: 'root' },
+    ]);
+    await expect(service.installFromZip(zip)).rejects.toThrow();
+  });
+
+  it('拒绝 Windows 盘符绝对路径条目', async () => {
+    const zip = makeZip([
+      { name: 'SKILL.md', data: SKILL_MD },
+      { name: 'C:/Windows/evil.md', data: 'x' },
+    ]);
+    await expect(service.installFromZip(zip)).rejects.toThrow();
+  });
+
+  it('拒绝 __MACOSX 元数据', async () => {
+    const zip = makeZip([
+      { name: 'SKILL.md', data: SKILL_MD },
+      { name: '__MACOSX/._SKILL.md', data: 'junk' },
+    ]);
+    await expect(service.installFromZip(zip)).rejects.toThrow(/__MACOSX/);
+  });
+
+  it('拒绝白名单外的文件类型', async () => {
+    const zip = makeZip([
+      { name: 'SKILL.md', data: SKILL_MD },
+      { name: 'scripts/payload.exe', data: 'MZ' },
+    ]);
+    await expect(service.installFromZip(zip)).rejects.toThrow(/不允许的文件类型/);
+  });
+
+  it('拒绝符号链接条目', async () => {
+    const zip = makeZip([
+      { name: 'SKILL.md', data: SKILL_MD },
+      { name: 'link.md', data: '../../../etc/passwd', mode: 0o120777 },
+    ]);
+    await expect(service.installFromZip(zip)).rejects.toThrow(/符号链接/);
+  });
+
+  it('拒绝超过单文件 2 MiB 的条目', async () => {
+    const zip = makeZip([
+      { name: 'SKILL.md', data: SKILL_MD },
+      { name: 'assets/big.md', data: Buffer.alloc(2 * 1024 * 1024 + 10, 0x61) },
+    ]);
+    await expect(service.installFromZip(zip)).rejects.toThrow(/2 MiB/);
+  });
+
+  it('zip 内没有 SKILL.md 时拒绝', async () => {
+    await expect(service.installFromZip(makeZip([{ name: 'notes.md', data: 'x' }]))).rejects.toThrow(
+      /SKILL\.md/,
+    );
+  });
+
+  it('空 zip 被拒绝', async () => {
+    await expect(service.installFromZip(makeZip([]))).rejects.toThrow(/没有可用文件/);
+  });
+});
+
+describe('remove', () => {
+  it('删除用户 skill', async () => {
+    const source = await writeSkillSource(join(root, 'src'));
+    await service.installFromFolder(source);
+    await service.remove({ name: 'demo-skill' });
+    expect(await stat(join(skillsDir, 'demo-skill')).catch(() => null)).toBeNull();
+  });
+
+  it('拒绝删除内置目录', async () => {
+    const systemDir = join(skillsDir, '.system', 'imagegen');
+    await mkdir(systemDir, { recursive: true });
+    await writeFile(join(systemDir, 'SKILL.md'), SKILL_MD, 'utf8');
+
+    await expect(service.remove({ path: systemDir })).rejects.toThrow(/内置或暂存目录/);
+    expect((await stat(systemDir)).isDirectory()).toBe(true);
+  });
+
+  it('拒绝越界路径与多级路径', async () => {
+    await expect(service.remove({ path: root })).rejects.toThrow(/skills 目录/);
+    await expect(service.remove({ path: join(skillsDir, 'a', 'b') })).rejects.toThrow(/一级子目录/);
+    await expect(service.remove({})).rejects.toThrow(/path 或 name/);
+  });
+
+  it('删除后清掉 config 里的残留条目(path 与 name 两种形态都要清)', async () => {
+    const source = await writeSkillSource(join(root, 'src'));
+    const runtimeWithConfig = makeRuntime();
+    const otherEntry = { path: join(skillsDir, 'other', 'SKILL.md'), enabled: true };
+    runtimeWithConfig.readConfig = vi.fn(async () => ({
+      skills: {
+        config: [
+          { path: join(skillsDir, 'demo-skill', 'SKILL.md'), enabled: false },
+          { name: 'demo-skill', enabled: false },
+          otherEntry,
+        ],
+      },
+    }));
+    runtimeWithConfig.writeConfigValue = vi.fn(async () => undefined);
+
+    const svc = new SkillService(runtimeWithConfig, { skillsDir });
+    await svc.installFromFolder(source);
+    await svc.remove({ name: 'demo-skill' });
+
+    // 不清残留的话,同名 skill 重新装回来会直接是禁用状态
+    expect(runtimeWithConfig.writeConfigValue).toHaveBeenCalledWith('skills.config', [otherEntry]);
+  });
+
+  it('没有 skills.config 时不写配置', async () => {
+    const source = await writeSkillSource(join(root, 'src'));
+    const bareRuntime = makeRuntime();
+    bareRuntime.readConfig = vi.fn(async () => ({}));
+    bareRuntime.writeConfigValue = vi.fn(async () => undefined);
+
+    const svc = new SkillService(bareRuntime, { skillsDir });
+    await svc.installFromFolder(source);
+    await svc.remove({ name: 'demo-skill' });
+    expect(bareRuntime.writeConfigValue).not.toHaveBeenCalled();
+  });
+});
+
+describe('list', () => {
+  it('标记内置 skill 为不可管理', async () => {
+    const svc = new SkillService(
+      makeRuntime([
+        { name: 'imagegen', path: join(skillsDir, '.system', 'imagegen', 'SKILL.md') },
+        { name: 'demo-skill', path: join(skillsDir, 'demo-skill', 'SKILL.md') },
+      ]),
+      { skillsDir },
+    );
+    const items = await svc.list();
+    expect(items.find((item) => item.name === 'imagegen')?.managed).toBe(false);
+    expect(items.find((item) => item.name === 'demo-skill')?.managed).toBe(true);
+  });
+});
+
+describe('setEnabled', () => {
+  it('按 name 归一化后转发给原生 RPC', async () => {
+    await expect(service.setEnabled({ name: 'Demo Skill' }, false)).resolves.toBe(true);
+    expect(runtime.setSkillEnabled).toHaveBeenCalledWith({ name: 'demo-skill' }, false);
+  });
+});
+
+describe('read', () => {
+  it('返回 SKILL.md 正文与文件清单', async () => {
+    const source = await writeSkillSource(join(root, 'src'));
+    await writeFile(join(source, 'notes.md'), 'n', 'utf8');
+    await service.installFromFolder(source);
+
+    const result = await service.read({ name: 'demo-skill' });
+    expect(result.content).toContain('demo-skill');
+    expect(result.files.map((file) => file.path).sort()).toEqual(['SKILL.md', 'notes.md']);
+  });
+});
+
+describe('assertSafeRelativePath(第二道防线)', () => {
+  it('放行正常相对路径并归一化分隔符', () => {
+    expect(assertSafeRelativePath('references/a.md')).toBe('references/a.md');
+    expect(assertSafeRelativePath('references\\a.md')).toBe('references/a.md');
+  });
+
+  it.each([
+    ['../evil.md', /穿越/],
+    ['docs/../../evil.md', /穿越/],
+    ['..\\evil.md', /穿越/],
+    ['/etc/passwd', /绝对路径/],
+    ['//server/share/x.md', /绝对路径/],
+    ['C:/Windows/evil.md', /绝对路径/],
+    ['__MACOSX/._SKILL.md', /__MACOSX/],
+    ['.staging-abc/SKILL.md', /非法/],
+    ['', /非法/],
+  ])('拒绝 %s', (input, expected) => {
+    expect(() => assertSafeRelativePath(input)).toThrow(expected);
+  });
+});

+ 498 - 0
ai-electron/electron/service/codex/skillService.ts

@@ -0,0 +1,498 @@
+import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
+import { basename, dirname, join, relative, resolve, sep } from 'node:path';
+import { randomUUID } from 'node:crypto';
+import { fromBuffer, type Entry, type ZipFile } from 'yauzl';
+import { getSkillsDir, isInsideDir, MAX_SKILL_NAME_LENGTH, SKILL_NAME_PATTERN } from './codexHome';
+import type { CodexRuntime, CodexSkillSummary, JsonValue } from './codexRuntime';
+
+/**
+ * Skill 的文件层管理。Codex app-server 只给了 skills/list 与 skills/config/write(启停),
+ * 没有安装/卸载接口,所以目录的增删由本模块负责,启停仍走原生 RPC。
+ *
+ * 目录布局:<CODEX_HOME>/skills/<kebab-name>/SKILL.md(+ 可选 agents/ scripts/ references/ assets/)。
+ * Codex 会把内置 skill 实体化到同级的 .system/.curated/.experimental,这些一律不可删改。
+ */
+
+const MAX_ENTRY_COUNT = 300;
+const MAX_FILE_BYTES = 2 * 1024 * 1024;
+const MAX_TOTAL_BYTES = 25 * 1024 * 1024;
+const MAX_SKILL_MD_BYTES = 64 * 1024;
+const SKILL_FILE_NAME = 'SKILL.md';
+const STAGING_PREFIX = '.staging-';
+
+const ALLOWED_EXTENSIONS = new Set([
+  'md', 'txt', 'json', 'yaml', 'yml', 'toml',
+  'py', 'js', 'mjs', 'cjs', 'ts', 'sh', 'ps1', 'bat',
+  'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'csv',
+]);
+
+export interface SkillListItem extends CodexSkillSummary {
+  /** 是否落在本模块可管理的用户目录里(内置 skill 为 false,不允许删除) */
+  managed: boolean;
+}
+
+export interface SkillFileEntry {
+  path: string;
+  size: number;
+}
+
+export interface SkillReadResult {
+  dir: string;
+  content: string;
+  files: SkillFileEntry[];
+}
+
+export interface SkillInstallOptions {
+  /** 覆盖 frontmatter 里的 name;不传则用 frontmatter 的 name */
+  name?: string | null;
+  overwrite?: boolean;
+}
+
+export interface SkillTarget {
+  path?: string | null;
+  name?: string | null;
+}
+
+type SkillRuntime = Pick<CodexRuntime, 'listSkills' | 'setSkillEnabled'> &
+  Partial<Pick<CodexRuntime, 'readConfig' | 'writeConfigValue'>>;
+
+export interface SkillServiceOptions {
+  /** 默认取 <CODEX_HOME>/skills;单测注入临时目录 */
+  skillsDir?: string;
+  /** 非致命问题的上报口(诊断日志),避免异常被静默吞掉 */
+  onDiagnostic?: (line: string) => void;
+}
+
+export class SkillService {
+  readonly #runtime: SkillRuntime;
+  readonly #skillsDir: string;
+  readonly #options: SkillServiceOptions;
+
+  constructor(runtime: SkillRuntime, options: SkillServiceOptions = {}) {
+    this.#runtime = runtime;
+    this.#options = options;
+    this.#skillsDir = options.skillsDir ?? getSkillsDir();
+  }
+
+  get skillsDir(): string {
+    return this.#skillsDir;
+  }
+
+  async list(options: { forceReload?: boolean } = {}): Promise<SkillListItem[]> {
+    const skills = await this.#runtime.listSkills({ forceReload: options.forceReload ?? false });
+    return skills.map((skill) => ({
+      ...skill,
+      managed: isManagedSkillPath(this.#skillsDir, skill.path),
+    }));
+  }
+
+  async setEnabled(target: SkillTarget, enabled: boolean): Promise<boolean> {
+    const selector = await this.#resolveSelector(target);
+    return this.#runtime.setSkillEnabled(selector, enabled);
+  }
+
+  async read(target: SkillTarget): Promise<SkillReadResult> {
+    const dir = await this.#resolveManagedDir(target);
+    const content = await readFile(join(dir, SKILL_FILE_NAME), 'utf8');
+    const files = await collectFiles(dir);
+    return { dir, content, files };
+  }
+
+  /** 从一个已存在的目录安装(配合 osCtl.selectDirectory 选目录) */
+  async installFromFolder(srcPath: string, options: SkillInstallOptions = {}): Promise<SkillListItem[]> {
+    const source = resolve(srcPath);
+    const sourceStat = await stat(source).catch(() => null);
+    if (!sourceStat?.isDirectory()) throw new Error('源路径不是一个目录');
+
+    const staging = await this.#createStagingDir();
+    try {
+      const manifest = await copyFolderChecked(source, staging);
+      const name = await finalizeStagedSkill(staging, manifest, options);
+      await this.#promoteStaging(staging, name, options.overwrite === true);
+    } catch (error) {
+      await rm(staging, { recursive: true, force: true }).catch(() => undefined);
+      throw error;
+    }
+    return this.list({ forceReload: true });
+  }
+
+  /** 从 zip 字节安装(渲染进程读文件后把字节传过来,主进程不需要拿到本机路径) */
+  async installFromZip(data: Uint8Array, options: SkillInstallOptions = {}): Promise<SkillListItem[]> {
+    if (!data || data.byteLength === 0) throw new Error('zip 内容为空');
+    if (data.byteLength > MAX_TOTAL_BYTES) throw new Error(`zip 超过 ${MAX_TOTAL_BYTES / 1024 / 1024} MiB 上限`);
+
+    const staging = await this.#createStagingDir();
+    try {
+      const manifest = await extractZipChecked(data, staging);
+      const root = await resolveZipRoot(staging, manifest);
+      const name = await finalizeStagedSkill(root, manifest, options);
+      await this.#promoteStaging(staging, name, options.overwrite === true, root);
+    } catch (error) {
+      await rm(staging, { recursive: true, force: true }).catch(() => undefined);
+      throw error;
+    }
+    return this.list({ forceReload: true });
+  }
+
+  async remove(target: SkillTarget): Promise<SkillListItem[]> {
+    const dir = await this.#resolveManagedDir(target);
+    await rm(dir, { recursive: true, force: true });
+    await this.#clearStaleConfig(dir, basename(dir));
+    return this.list({ forceReload: true });
+  }
+
+  /**
+   * 删掉目录后清掉 config.toml 里的 [[skills.config]] 残留条目。
+   * 不清的话,将来装回同名 skill 会直接继承旧的 enabled=false,表现为「装了但不生效」。
+   */
+  async #clearStaleConfig(dir: string, name: string): Promise<void> {
+    const runtime = this.#runtime;
+    if (typeof runtime.readConfig !== 'function' || typeof runtime.writeConfigValue !== 'function') return;
+    try {
+      // 必须按方法调用,解构出来会丢 this 绑定
+      const config = await runtime.readConfig();
+      const skills = asRecord(config.skills);
+      const entries = Array.isArray(skills?.config) ? (skills.config as unknown[]) : null;
+      if (!entries) return;
+      const kept = entries.filter((item) => !isStaleEntry(item, dir, name));
+      if (kept.length === entries.length) return;
+      await runtime.writeConfigValue('skills.config', kept as JsonValue[]);
+    } catch (error) {
+      // 目录已经删掉了,清理失败不该让删除整体失败,但一定要上报,否则表现为配置里有幽灵条目
+      this.#options.onDiagnostic?.(
+        `清理 skill 配置残留失败:${error instanceof Error ? error.message : String(error)}`,
+      );
+    }
+  }
+
+  /** 把 UI 传来的 path/name 收敛成一个「确实在用户 skills 目录内」的绝对路径 */
+  async #resolveManagedDir(target: SkillTarget): Promise<string> {
+    const skillsDir = this.#skillsDir;
+    if (target.path) {
+      const dir = resolve(target.path);
+      assertManagedSkillDir(skillsDir, dir);
+      const manifest = await stat(join(dir, SKILL_FILE_NAME)).catch(() => null);
+      if (!manifest?.isFile()) throw new Error('该目录下没有 SKILL.md');
+      return dir;
+    }
+    if (target.name) {
+      const name = normalizeSkillName(target.name);
+      const dir = join(skillsDir, name);
+      assertManagedSkillDir(skillsDir, dir);
+      const manifest = await stat(join(dir, SKILL_FILE_NAME)).catch(() => null);
+      if (!manifest?.isFile()) throw new Error(`未找到名为 ${name} 的 skill`);
+      return dir;
+    }
+    throw new Error('必须提供 skill 的 path 或 name');
+  }
+
+  async #resolveSelector(target: SkillTarget): Promise<{ path?: string; name?: string }> {
+    if (target.path) return { path: target.path };
+    if (target.name) return { name: normalizeSkillName(target.name) };
+    throw new Error('必须提供 skill 的 path 或 name');
+  }
+
+  async #createStagingDir(): Promise<string> {
+    const staging = join(this.#skillsDir, `${STAGING_PREFIX}${randomUUID()}`);
+    await mkdir(staging, { recursive: true });
+    return staging;
+  }
+
+  /** staging → skills/<name>,用 rename 保证原子性 */
+  async #promoteStaging(
+    staging: string,
+    name: string,
+    overwrite: boolean,
+    stagedRoot = staging,
+  ): Promise<void> {
+    const target = join(this.#skillsDir, name);
+    const existing = await stat(target).catch(() => null);
+    if (existing) {
+      if (!overwrite) throw new Error(`skill「${name}」已存在,需显式覆盖`);
+      await rm(target, { recursive: true, force: true });
+    }
+    await mkdir(this.#skillsDir, { recursive: true });
+    await rename(stagedRoot, target);
+    if (resolve(stagedRoot) !== resolve(staging)) {
+      // zip 里带了一层外壳目录:搬完内层后清掉外壳
+      await rm(staging, { recursive: true, force: true }).catch(() => undefined);
+    }
+  }
+}
+
+/** 内置目录(.system/.curated/.experimental)与暂存目录一律不可管理 */
+function isManagedSkillPath(skillsDir: string, skillPath: string): boolean {
+  if (!isInsideDir(skillsDir, skillPath)) return false;
+  const rel = relative(skillsDir, skillPath);
+  const first = rel.split(sep)[0] ?? '';
+  return Boolean(first) && !first.startsWith('.') && !rel.includes(`..${sep}`);
+}
+
+function assertManagedSkillDir(skillsDir: string, dir: string): void {
+  if (!isInsideDir(skillsDir, dir)) throw new Error('只能管理用户 skills 目录内的 skill');
+  const rel = relative(skillsDir, dir);
+  const segments = rel.split(sep);
+  // 先判内置/暂存目录,否则 .system/imagegen 会先撞上「一级子目录」的报错,信息不准
+  if (segments[0]?.startsWith('.')) throw new Error('内置或暂存目录不可删改');
+  if (segments.length !== 1 || !segments[0]) throw new Error('skill 必须是 skills 目录下的一级子目录');
+  if (!SKILL_NAME_PATTERN.test(segments[0])) throw new Error('skill 目录名不合法');
+}
+
+export function normalizeSkillName(raw: string): string {
+  const name = raw
+    .trim()
+    .toLowerCase()
+    .replace(/[\s_]+/gu, '-')
+    .replace(/[^a-z0-9-]/gu, '')
+    .replace(/-{2,}/gu, '-')
+    .replace(/^-|-$/gu, '');
+  if (!name || name.length > MAX_SKILL_NAME_LENGTH) {
+    throw new Error(`skill 名称非法(需为小写连字符命名,长度 ≤ ${MAX_SKILL_NAME_LENGTH})`);
+  }
+  if (!SKILL_NAME_PATTERN.test(name)) throw new Error('skill 名称只能是字母数字与连字符的组合');
+  return name;
+}
+
+/** 校验并落定 staged skill:必须有合法 frontmatter,返回最终 skill 名 */
+async function finalizeStagedSkill(
+  root: string,
+  manifest: FileManifest,
+  options: SkillInstallOptions,
+): Promise<string> {
+  if (manifest.totalBytes > MAX_TOTAL_BYTES) {
+    throw new Error(`skill 内容超过 ${MAX_TOTAL_BYTES / 1024 / 1024} MiB 上限`);
+  }
+  const skillMd = join(root, SKILL_FILE_NAME);
+  const skillStat = await stat(skillMd).catch(() => null);
+  if (!skillStat?.isFile()) throw new Error('缺少 SKILL.md');
+  if (skillStat.size > MAX_SKILL_MD_BYTES) throw new Error(`SKILL.md 超过 ${MAX_SKILL_MD_BYTES / 1024} KiB 上限`);
+
+  const frontmatter = parseFrontmatter(await readFile(skillMd, 'utf8'));
+  const rawName = options.name?.trim() || frontmatter.name;
+  if (!rawName) throw new Error('SKILL.md 的 frontmatter 缺少 name');
+  if (!frontmatter.description) throw new Error('SKILL.md 的 frontmatter 缺少 description');
+  return normalizeSkillName(rawName);
+}
+
+interface FileManifest {
+  entries: number;
+  totalBytes: number;
+  paths: string[];
+}
+
+function assertAllowedExtension(relPath: string): void {
+  const ext = basename(relPath).split('.').pop()?.toLowerCase() ?? '';
+  if (!basename(relPath).includes('.')) return;
+  if (!ALLOWED_EXTENSIONS.has(ext)) throw new Error(`不允许的文件类型:${relPath}`);
+}
+
+/** 相对路径安全校验:拒绝绝对路径、`..`、盘符、UNC 与 macOS 垃圾目录。yauzl 也会拦一层,这里是第二道防线 */
+export function assertSafeRelativePath(relPath: string): string {
+  if (!relPath || relPath.length > 512) throw new Error('条目路径非法');
+  if (relPath.startsWith('__MACOSX/') || relPath.includes('/__MACOSX/')) {
+    throw new Error('zip 含 __MACOSX 元数据目录');
+  }
+  const normalized = relPath.replace(/\\/gu, '/');
+  if (/^[a-zA-Z]:/u.test(normalized) || normalized.startsWith('/') || normalized.startsWith('//')) {
+    throw new Error('zip 含绝对路径条目');
+  }
+  const segments = normalized.split('/').filter(Boolean);
+  if (!segments.length) throw new Error('zip 条目路径为空');
+  for (const segment of segments) {
+    if (segment === '..') throw new Error('zip 含路径穿越条目');
+    if (segment === '.' || segment.startsWith(STAGING_PREFIX)) throw new Error('zip 条目路径非法');
+    if (segment.length > 128) throw new Error('zip 条目路径过长');
+  }
+  return segments.join('/');
+}
+
+async function copyFolderChecked(source: string, staging: string): Promise<FileManifest> {
+  const manifest: FileManifest = { entries: 0, totalBytes: 0, paths: [] };
+  const walk = async (dir: string): Promise<void> => {
+    for (const entry of await readdir(dir, { withFileTypes: true })) {
+      const abs = join(dir, entry.name);
+      const rel = relative(source, abs).split(sep).join('/');
+      if (entry.isSymbolicLink()) throw new Error('源目录含符号链接,拒绝安装');
+      if (entry.name.startsWith('.')) continue;
+      if (entry.isDirectory()) {
+        await mkdir(join(staging, rel), { recursive: true });
+        await walk(abs);
+        continue;
+      }
+      if (!entry.isFile()) throw new Error(`不支持的条目类型:${rel}`);
+      assertSafeRelativePath(rel);
+      assertAllowedExtension(rel);
+      const info = await stat(abs);
+      if (info.size > MAX_FILE_BYTES) throw new Error(`文件超过单文件 2 MiB 上限:${rel}`);
+      manifest.entries += 1;
+      manifest.totalBytes += info.size;
+      manifest.paths.push(rel);
+      if (manifest.entries > MAX_ENTRY_COUNT) throw new Error(`条目数超过 ${MAX_ENTRY_COUNT} 上限`);
+      if (manifest.totalBytes > MAX_TOTAL_BYTES) throw new Error('内容总量超过上限');
+      await mkdir(dirname(join(staging, rel)), { recursive: true });
+      await writeFile(join(staging, rel), await readFile(abs));
+    }
+  };
+  await walk(source);
+  if (!manifest.entries) throw new Error('源目录是空的');
+  return manifest;
+}
+
+function isSymlinkEntry(entry: Entry): boolean {
+  // zip 的 Unix mode 存在 externalFileAttributes 高 16 位
+  const mode = (entry.externalFileAttributes ?? 0) >>> 16;
+  return (mode & 0o170000) === 0o120000;
+}
+
+async function extractZipChecked(data: Uint8Array, staging: string): Promise<FileManifest> {
+  const manifest: FileManifest = { entries: 0, totalBytes: 0, paths: [] };
+  const zip: ZipFile = await new Promise((resolvePromise, rejectPromise) => {
+    fromBuffer(Buffer.from(data), { lazyEntries: true, autoClose: false }, (error, zipFile) => {
+      // yauzl 的原文是英文且面向 zip 格式细节,页面会直接展示 message,这里包一层可读提示
+      if (zipFile && !error) {
+        resolvePromise(zipFile);
+        return;
+      }
+      rejectPromise(new Error(`无法解析 zip 文件:${error?.message ?? '格式不正确'}`));
+    });
+  });
+
+  try {
+    await new Promise<void>((resolvePromise, reject) => {
+      zip.once('error', reject);
+      zip.once('end', () => resolvePromise());
+      zip.on('entry', (entry: Entry) => {
+        void handleEntry(entry).then(
+          () => zip.readEntry(),
+          (error: unknown) => reject(error instanceof Error ? error : new Error(String(error))),
+        );
+      });
+
+      async function handleEntry(entry: Entry): Promise<void> {
+        const rawName = entry.fileName.replace(/\\/gu, '/');
+        if (isSymlinkEntry(entry)) throw new Error(`zip 含符号链接条目:${rawName}`);
+        const isDir = rawName.endsWith('/');
+        const rel = assertSafeRelativePath(isDir ? rawName.slice(0, -1) : rawName);
+        if (isDir) {
+          await mkdir(join(staging, rel), { recursive: true });
+          return;
+        }
+        if (entry.uncompressedSize > MAX_FILE_BYTES) {
+          throw new Error(`文件超过单文件 2 MiB 上限:${rel}`);
+        }
+        assertAllowedExtension(rel);
+        manifest.entries += 1;
+        if (manifest.entries > MAX_ENTRY_COUNT) throw new Error(`条目数超过 ${MAX_ENTRY_COUNT} 上限`);
+        manifest.totalBytes += entry.uncompressedSize;
+        if (manifest.totalBytes > MAX_TOTAL_BYTES) throw new Error('解压总量超过 25 MiB 上限');
+        manifest.paths.push(rel);
+
+        const buffer = await new Promise<Buffer>((resolveBuffer, rejectBuffer) => {
+          zip.openReadStream(entry, (error, stream) => {
+            if (error || !stream) {
+              rejectBuffer(error ?? new Error(`无法读取 zip 条目:${rel}`));
+              return;
+            }
+            const chunks: Buffer[] = [];
+            let received = 0;
+            stream.on('data', (chunk: Buffer) => {
+              received += chunk.length;
+              // 防 zip bomb:实际解压量超过声明值或超过单文件上限就立即中止
+              if (received > MAX_FILE_BYTES || received > entry.uncompressedSize) {
+                stream.destroy();
+                rejectBuffer(new Error(`zip 条目解压量异常:${rel}`));
+                return;
+              }
+              chunks.push(chunk);
+            });
+            stream.once('error', rejectBuffer);
+            stream.once('end', () => resolveBuffer(Buffer.concat(chunks)));
+          });
+        });
+        const destination = join(staging, rel);
+        await mkdir(join(destination, '..'), { recursive: true });
+        await writeFile(destination, buffer);
+      }
+
+      zip.readEntry();
+    });
+  } finally {
+    zip.close();
+  }
+  if (!manifest.entries) throw new Error('zip 内没有可用文件');
+  return manifest;
+}
+
+/** zip 常见形态是「一层外壳目录 + SKILL.md」,此时把内层当作 skill 根 */
+async function resolveZipRoot(staging: string, manifest: FileManifest): Promise<string> {
+  if (manifest.paths.includes(SKILL_FILE_NAME)) return staging;
+  const tops = new Set(manifest.paths.map((item) => item.split('/')[0]!));
+  if (tops.size === 1) {
+    const candidate = join(staging, [...tops][0]!);
+    const inner = await stat(join(candidate, SKILL_FILE_NAME)).catch(() => null);
+    if (inner?.isFile()) return candidate;
+  }
+  throw new Error('zip 根目录(或唯一的外壳目录)下没有 SKILL.md');
+}
+
+async function collectFiles(dir: string): Promise<SkillFileEntry[]> {
+  const files: SkillFileEntry[] = [];
+  const walk = async (current: string): Promise<void> => {
+    for (const entry of await readdir(current, { withFileTypes: true })) {
+      const abs = join(current, entry.name);
+      if (entry.isDirectory()) {
+        await walk(abs);
+        continue;
+      }
+      if (!entry.isFile()) continue;
+      const info = await stat(abs);
+      files.push({ path: relative(dir, abs).split(sep).join('/'), size: info.size });
+    }
+  };
+  await walk(dir);
+  return files.sort((left, right) => left.path.localeCompare(right.path));
+}
+
+/** 只取 YAML frontmatter 里的 name / description 单行标量,够用且不引 YAML 依赖 */
+export function parseFrontmatter(content: string): { name: string | null; description: string | null } {
+  const match = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---/u.exec(content);
+  if (!match) return { name: null, description: null };
+  const fields: Record<string, string> = {};
+  for (const line of match[1]!.split(/\r?\n/u)) {
+    const pair = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/u.exec(line.trim());
+    if (!pair) continue;
+    fields[pair[1]!.toLowerCase()] = stripQuotes(pair[2]!.trim());
+  }
+  return { name: fields.name || null, description: fields.description || null };
+}
+
+function stripQuotes(value: string): string {
+  if (value.length >= 2 && ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))) {
+    return value.slice(1, -1);
+  }
+  return value.replace(/^>\s*|^\|\s*/u, '').trim();
+}
+
+/** 配置条目可能按 path(指向 SKILL.md)记录,也可能只按 name 记录 */
+function isStaleEntry(item: unknown, removedDir: string, removedName: string): boolean {
+  const record = asRecord(item);
+  if (!record) return false;
+  const entryPath = readString(record.path);
+  if (entryPath) {
+    const target = resolve(entryPath);
+    const dir = resolve(removedDir);
+    return target === dir || target.startsWith(dir + sep) || dirname(target) === dir;
+  }
+  const entryName = readString(record.name);
+  return Boolean(entryName) && entryName === removedName;
+}
+
+function asRecord(value: unknown): Record<string, unknown> | null {
+  return value && typeof value === 'object' && !Array.isArray(value)
+    ? (value as Record<string, unknown>)
+    : null;
+}
+
+function readString(value: unknown): string | null {
+  return typeof value === 'string' ? value : null;
+}

+ 146 - 0
ai-electron/electron/service/codex/types.ts

@@ -0,0 +1,146 @@
+/**
+ * Codex 模块的前后端契约(渲染进程只认这些形状)。
+ * ee-core 的 ipcMain.handle 不捕获异常,抛错会被 Electron 包成
+ * "Error occurred in handler for ...",所以 controller 层统一返回本结构。
+ */
+
+export type Rpc<T> =
+  | { success: true; data: T; message?: string }
+  | { success: false; message: string };
+
+export function ok<T>(data: T, message?: string): Rpc<T> {
+  return message ? { success: true, data, message } : { success: true, data };
+}
+
+export function fail(message: string): Rpc<never> {
+  return { success: false, message };
+}
+
+export function asMessage(error: unknown): string {
+  return error instanceof Error ? error.message : String(error);
+}
+
+/** GET /models 返回的 ModelVO(ai-server module/agent/vo/ModelVO.java) */
+export interface BackendModelRecord {
+  id: string | number;
+  provider?: string | null;
+  modelId?: string | null;
+  name?: string | null;
+  /** 用途:llm / embedding(不是厂商协议) */
+  type?: string | null;
+  apiKey?: string | null;
+  baseUrl?: string | null;
+  defaultModel?: boolean | null;
+  config?: string | null;
+  headersJson?: string | null;
+  extraJson?: string | null;
+  status?: string | null;
+}
+
+/**
+ * 应用到 Codex 运行时的入参。厂商协议类型在 agent_model_provider.type 上(OLLAMA/OPENAI/…),
+ * 不在 model 记录里,所以由渲染进程把 /models 与 /providers 关联好后显式传进来。
+ */
+export interface ApplyProviderInput {
+  modelId: string;
+  name?: string | null;
+  baseUrl?: string | null;
+  apiKey?: string | null;
+  headersJson?: string | Record<string, string> | null;
+  config?: string | Record<string, unknown> | null;
+  /** agent_model_provider.type;OLLAMA 走 Codex 内置 provider,不需要 api key */
+  providerType?: string | null;
+  /** 显式指定内置 provider id(默认由 providerType 推导,如 ollama) */
+  builtinProvider?: string | null;
+}
+
+export interface CodexPingResult {
+  available: boolean;
+  version: string | null;
+  binaryPath: string | null;
+  codexHome: string;
+  reason: string | null;
+}
+
+export interface CodexStatusResult {
+  state: 'stopped' | 'starting' | 'ready' | 'error';
+  running: boolean;
+  version: string | null;
+  binaryPath: string | null;
+  codexHome: string;
+  defaultModel: string | null;
+  providerId: string | null;
+  /** model/list 拿不到可用模型时为 true,页面据此提示降级 */
+  degraded: boolean;
+  error: string | null;
+  /** 上次应用的模型(不含密钥);重启后需要页面重新应用才能注入 api key */
+  applied: AppliedProviderInfo | null;
+}
+
+export interface AppliedProviderInfo {
+  model: string;
+  modelRecordId: string | null;
+  baseUrl: string | null;
+  providerId: string | null;
+  builtinProvider: string | null;
+  name: string | null;
+  appliedAt: string;
+}
+
+/** 主进程 → 渲染进程的事件推送 channel */
+export const CODEX_EVENT_CHANNEL = 'codex/event';
+export const CODEX_STATUS_CHANNEL = 'codex/status';
+export const CODEX_APPROVAL_CHANNEL = 'codex/approval';
+export const CODEX_APPROVAL_CLOSED_CHANNEL = 'codex/approval-closed';
+export const CODEX_DIAGNOSTIC_CHANNEL = 'codex/diagnostic';
+
+export type AgentEventKind =
+  | 'lifecycle'
+  | 'assistant'
+  | 'thought'
+  | 'tool'
+  | 'file'
+  | 'plan'
+  | 'error';
+
+/** 由 app-server 通知映射出的对外事件,前端按 id 覆盖、按 isDelta 追加 */
+export interface AgentEvent {
+  id: string;
+  threadId: string;
+  turnId: string;
+  kind: AgentEventKind;
+  title: string;
+  message: string;
+  timestamp: string;
+  method?: string;
+  itemId?: string;
+  isDelta?: boolean;
+}
+
+export type ApprovalKind = 'command' | 'file' | 'permissions' | 'input';
+export type ApprovalDecision = 'accept' | 'acceptForSession' | 'decline' | 'cancel';
+export type ApprovalAnswers = Record<string, string[]>;
+
+export interface ApprovalRequest {
+  token: string;
+  threadId: string | null;
+  kind: ApprovalKind;
+  method: string;
+  title: string;
+  summary: string;
+  details: Record<string, unknown>;
+  createdAt: string;
+}
+
+export interface ThreadStartInput {
+  cwd?: string | null;
+  model?: string | null;
+  approvalPolicy?: 'untrusted' | 'on-request' | 'never';
+  sandbox?: 'read-only' | 'workspace-write' | 'danger-full-access';
+}
+
+export interface TurnRunInput extends ThreadStartInput {
+  threadId: string;
+  input: string;
+  timeoutMs?: number;
+}

+ 369 - 0
ai-electron/frontend/src/ai/api/codexApi.ts

@@ -0,0 +1,369 @@
+/**
+ * Codex 桌面端能力的 IPC 封装。
+ * 与 src/ai/api/http.ts 无关:这里不走 HTTP,也没有 /js/a 前缀与 token。
+ * 主进程实现在 ai-electron/electron/controller/codexCtl.ts + service/codex/*。
+ */
+
+/** 主进程 controller 层统一返回结构 */
+export type CodexRpc<T> = { success: true; data: T; message?: string } | { success: false; message: string };
+
+const CHANNEL_PREFIX = 'controller/codexCtl/';
+/** ee-core 既有 controller,用于选目录 */
+const OS_CHANNEL_PREFIX = 'controller/osCtl/';
+
+export const CODEX_EVENT_CHANNEL = 'codex/event';
+export const CODEX_STATUS_CHANNEL = 'codex/status';
+export const CODEX_DIAGNOSTIC_CHANNEL = 'codex/diagnostic';
+export const CODEX_APPROVAL_CHANNEL = 'codex/approval';
+export const CODEX_APPROVAL_CLOSED_CHANNEL = 'codex/approval-closed';
+
+type IpcRendererLike = {
+  invoke: (channel: string, ...args: unknown[]) => Promise<unknown>;
+  on?: (channel: string, listener: (event: unknown, payload: unknown) => void) => void;
+  removeListener?: (channel: string, listener: (event: unknown, payload: unknown) => void) => void;
+};
+
+/**
+ * 两种桥都认:ee-core 脚手架默认 nodeIntegration:true + contextIsolation:false(渲染进程里 require 可用),
+ * 若后续启用 preload/bridge.ts + contextIsolation 则走 window.electron。
+ */
+function resolveIpcRenderer(): IpcRendererLike | null {
+  const bridge = window.electron?.ipcRenderer as IpcRendererLike | undefined;
+  if (bridge?.invoke) return bridge;
+  const nodeRequire = (window as unknown as { require?: (id: string) => { ipcRenderer?: IpcRendererLike } }).require;
+  try {
+    const renderer = nodeRequire?.('electron')?.ipcRenderer;
+    return renderer?.invoke ? renderer : null;
+  } catch {
+    return null;
+  }
+}
+
+/** 浏览器(非桌面端)里恒为 false,页面据此整页降级 */
+export function isCodexBridgeAvailable(): boolean {
+  return resolveIpcRenderer() !== null;
+}
+
+async function invokeChannel<T>(channel: string, params?: unknown): Promise<T> {
+  const ipcRenderer = resolveIpcRenderer();
+  if (!ipcRenderer) throw new Error('当前环境不可用:请在桌面客户端内打开');
+  const result = (await ipcRenderer.invoke(channel, params)) as CodexRpc<T> | undefined;
+  if (!result) throw new Error(`桌面端无响应:${channel}`);
+  if (!result.success) throw new Error(result.message || `桌面端调用失败:${channel}`);
+  return result.data;
+}
+
+/** 调用失败(桥不可用 / success:false / invoke reject)统一抛 Error,调用方 showMessage(e.message) */
+export function codexInvoke<T>(method: string, params?: unknown): Promise<T> {
+  return invokeChannel<T>(`${CHANNEL_PREFIX}${method}`, params);
+}
+
+/** 订阅主进程推送,返回取消订阅函数;非桌面端返回空函数 */
+export function onCodexChannel<T>(channel: string, handler: (payload: T) => void): () => void {
+  const ipcRenderer = resolveIpcRenderer();
+  if (!ipcRenderer?.on) return () => undefined;
+  const listener = (_event: unknown, payload: unknown) => handler(payload as T);
+  ipcRenderer.on(channel, listener);
+  return () => ipcRenderer.removeListener?.(channel, listener);
+}
+
+// ─── 运行时 ────────────────────────────────────────────────────────────────
+
+export interface CodexPingResult {
+  available: boolean;
+  version: string | null;
+  binaryPath: string | null;
+  codexHome: string;
+  reason: string | null;
+}
+
+export interface AppliedProviderInfo {
+  model: string;
+  modelRecordId: string | null;
+  baseUrl: string | null;
+  providerId: string | null;
+  builtinProvider: string | null;
+  name: string | null;
+  appliedAt: string;
+}
+
+export interface CodexStatus {
+  state: 'stopped' | 'starting' | 'ready' | 'error';
+  running: boolean;
+  version: string | null;
+  binaryPath: string | null;
+  codexHome: string;
+  defaultModel: string | null;
+  providerId: string | null;
+  degraded: boolean;
+  error: string | null;
+  applied: AppliedProviderInfo | null;
+}
+
+export function codexPing(): Promise<CodexPingResult> {
+  return codexInvoke<CodexPingResult>('ping');
+}
+
+export function codexStatus(): Promise<CodexStatus> {
+  return codexInvoke<CodexStatus>('status');
+}
+
+export function codexStart(): Promise<CodexStatus> {
+  return codexInvoke<CodexStatus>('start');
+}
+
+export function codexStop(): Promise<CodexStatus> {
+  return codexInvoke<CodexStatus>('stop');
+}
+
+export function codexRestart(): Promise<CodexStatus> {
+  return codexInvoke<CodexStatus>('restart');
+}
+
+export interface CodexModelOption {
+  id: string;
+  model: string;
+  displayName: string;
+  description: string;
+  isDefault: boolean;
+  defaultEffort: string;
+  efforts: string[];
+}
+
+export function codexListModels(): Promise<CodexModelOption[]> {
+  return codexInvoke<CodexModelOption[]>('listModels');
+}
+
+// ─── 模型 provider ─────────────────────────────────────────────────────────
+
+/** 与主进程 ApplyProviderInput 对应;providerType 取自 agent_model_provider.type */
+export interface ApplyProviderInput {
+  modelId: string;
+  name?: string | null;
+  baseUrl?: string | null;
+  apiKey?: string | null;
+  headersJson?: string | Record<string, string> | null;
+  config?: string | Record<string, unknown> | null;
+  providerType?: string | null;
+  builtinProvider?: string | null;
+  modelRecordId?: string | number | null;
+}
+
+export interface ProviderProbeResult {
+  compatible: boolean;
+  status: number | null;
+  detail: string;
+  hint: string | null;
+}
+
+export interface ApplyProviderResult {
+  applied: AppliedProviderInfo;
+  hint: string | null;
+  droppedConfigKeys: string[];
+  status: CodexStatus;
+}
+
+export function codexProbeProvider(input: { baseUrl: string; apiKey?: string | null }): Promise<ProviderProbeResult> {
+  return codexInvoke<ProviderProbeResult>('probeProvider', input);
+}
+
+export function codexApplyProvider(input: ApplyProviderInput): Promise<ApplyProviderResult> {
+  return codexInvoke<ApplyProviderResult>('applyProvider', input);
+}
+
+export function codexClearProvider(): Promise<CodexStatus> {
+  return codexInvoke<CodexStatus>('clearProvider');
+}
+
+// ─── MCP ───────────────────────────────────────────────────────────────────
+
+export interface CodexMcpServer {
+  id: string;
+  transport: 'stdio' | 'http';
+  command: string | null;
+  args: string[];
+  url: string | null;
+  enabled: boolean;
+  bearerTokenEnvVar: string | null;
+  connected: boolean;
+  toolCount: number;
+  authStatus: string;
+}
+
+export interface CodexMcpServerInput {
+  id: string;
+  transport: 'stdio' | 'http';
+  command?: string | null;
+  args?: string[];
+  url?: string | null;
+  enabled: boolean;
+  bearerTokenEnvVar?: string | null;
+}
+
+export function codexMcpList(): Promise<CodexMcpServer[]> {
+  return codexInvoke<CodexMcpServer[]>('mcpList');
+}
+
+export function codexMcpSave(input: CodexMcpServerInput): Promise<CodexMcpServer[]> {
+  return codexInvoke<CodexMcpServer[]>('mcpSave', input);
+}
+
+export function codexMcpRemove(id: string): Promise<CodexMcpServer[]> {
+  return codexInvoke<CodexMcpServer[]>('mcpRemove', { id });
+}
+
+// ─── Skill ─────────────────────────────────────────────────────────────────
+
+export interface CodexSkill {
+  name: string;
+  description: string;
+  path: string;
+  scope: string;
+  enabled: boolean;
+  cwd: string;
+  /** false = Codex 内置(.system 等),不可删除 */
+  managed: boolean;
+}
+
+export interface CodexSkillReadResult {
+  dir: string;
+  content: string;
+  files: Array<{ path: string; size: number }>;
+}
+
+export function codexSkillList(forceReload = false): Promise<CodexSkill[]> {
+  return codexInvoke<CodexSkill[]>('skillList', { forceReload });
+}
+
+export function codexSkillRead(target: { path?: string; name?: string }): Promise<CodexSkillReadResult> {
+  return codexInvoke<CodexSkillReadResult>('skillRead', target);
+}
+
+export function codexSkillInstallFolder(input: {
+  srcPath: string;
+  name?: string | null;
+  overwrite?: boolean;
+}): Promise<CodexSkill[]> {
+  return codexInvoke<CodexSkill[]>('skillInstallFolder', input);
+}
+
+export function codexSkillInstallZip(input: {
+  data: Uint8Array;
+  name?: string | null;
+  overwrite?: boolean;
+}): Promise<CodexSkill[]> {
+  return codexInvoke<CodexSkill[]>('skillInstallZip', input);
+}
+
+export function codexSkillRemove(target: { path?: string; name?: string }): Promise<CodexSkill[]> {
+  return codexInvoke<CodexSkill[]>('skillRemove', target);
+}
+
+export function codexSkillSetEnabled(
+  target: { path?: string; name?: string },
+  enabled: boolean,
+): Promise<{ effectiveEnabled: boolean }> {
+  return codexInvoke<{ effectiveEnabled: boolean }>('skillSetEnabled', { ...target, enabled });
+}
+
+export function codexSkillOpenFolder(path?: string): Promise<{ path: string }> {
+  return codexInvoke<{ path: string }>('skillOpenFolder', { path });
+}
+
+/** 复用 ee-core 既有的目录选择对话框 */
+export function selectDirectory(): Promise<string> {
+  return invokeChannel<string>(`${OS_CHANNEL_PREFIX}selectDirectory`);
+}
+
+// ─── 会话(契约已就位,对话 UI 由业务页自行实现) ─────────────────────────────
+
+export interface CodexThreadStartInput {
+  cwd?: string | null;
+  model?: string | null;
+  approvalPolicy?: 'untrusted' | 'on-request' | 'never';
+  sandbox?: 'read-only' | 'workspace-write' | 'danger-full-access';
+}
+
+export interface CodexTurnRunInput extends CodexThreadStartInput {
+  threadId: string;
+  input: string;
+  timeoutMs?: number;
+}
+
+export interface CodexTurnResult {
+  turnId: string;
+  status: string;
+  text: string;
+}
+
+export function codexThreadStart(input?: CodexThreadStartInput): Promise<{ threadId: string }> {
+  return codexInvoke<{ threadId: string }>('threadStart', input);
+}
+
+export function codexTurnRun(input: CodexTurnRunInput): Promise<CodexTurnResult> {
+  return codexInvoke<CodexTurnResult>('turnRun', input);
+}
+
+export function codexTurnInterrupt(threadId: string, turnId: string): Promise<Record<string, never>> {
+  return codexInvoke<Record<string, never>>('turnInterrupt', { threadId, turnId });
+}
+
+export function codexThreadUnsubscribe(threadId: string): Promise<Record<string, never>> {
+  return codexInvoke<Record<string, never>>('threadUnsubscribe', { threadId });
+}
+
+export function codexApprovalResolve(input: {
+  token: string;
+  decision: 'accept' | 'acceptForSession' | 'decline' | 'cancel';
+  answers?: Record<string, string[]>;
+}): Promise<Record<string, never>> {
+  return codexInvoke<Record<string, never>>('approvalResolve', input);
+}
+
+// ─── 事件与诊断 ────────────────────────────────────────────────────────────
+
+export type CodexAgentEventKind = 'lifecycle' | 'assistant' | 'thought' | 'tool' | 'file' | 'plan' | 'error';
+
+export interface CodexAgentEvent {
+  id: string;
+  threadId: string;
+  turnId: string;
+  kind: CodexAgentEventKind;
+  title: string;
+  message: string;
+  timestamp: string;
+  method?: string;
+  itemId?: string;
+  /** true 表示增量文本,需按 id 追加到已有事件 */
+  isDelta?: boolean;
+}
+
+export interface CodexDiagnostic {
+  ts: string;
+  line: string;
+}
+
+export function codexEventsRead(threadId: string, limit?: number): Promise<CodexAgentEvent[]> {
+  return codexInvoke<CodexAgentEvent[]>('eventsRead', { threadId, limit });
+}
+
+export function codexLogsTail(limit?: number): Promise<CodexDiagnostic[]> {
+  return codexInvoke<CodexDiagnostic[]>('logsTail', { limit });
+}
+
+export function onCodexEvent(handler: (event: CodexAgentEvent) => void): () => void {
+  return onCodexChannel<CodexAgentEvent>(CODEX_EVENT_CHANNEL, handler);
+}
+
+export function onCodexStatus(handler: (status: CodexStatus) => void): () => void {
+  return onCodexChannel<CodexStatus>(CODEX_STATUS_CHANNEL, handler);
+}
+
+export function onCodexDiagnostic(handler: (item: CodexDiagnostic) => void): () => void {
+  return onCodexChannel<CodexDiagnostic>(CODEX_DIAGNOSTIC_CHANNEL, handler);
+}
+
+/** 把 File 读成主进程能直接校验的字节 */
+export async function readFileBytes(file: File): Promise<Uint8Array> {
+  return new Uint8Array(await file.arrayBuffer());
+}

+ 1 - 0
ai-electron/frontend/src/ai/api/index.ts

@@ -3,5 +3,6 @@
  */
 export * as chatApi from './chatApi';
 export * as modelApi from './modelApi';
+export * as codexApi from './codexApi';
 export { aiHttp } from './http';
 export * from './types';

+ 1102 - 0
ai-electron/frontend/src/ai/views/aiPlugin/index.vue

@@ -0,0 +1,1102 @@
+<template>
+  <div class="ai-page ai-plugin">
+    <header class="ai-topbar">
+      <div class="ai-topbar__title">
+        插件
+        <span class="ai-topbar__meta">{{ summary }}</span>
+      </div>
+      <span class="ai-topbar__spacer"></span>
+      <div class="ai-topbar__actions">
+        <a-button size="small" :loading="loading" :disabled="!bridgeAvailable" @click="reload">
+          <template #icon><Icon icon="mdi:refresh" :size="14" /></template>
+          刷新
+        </a-button>
+      </div>
+    </header>
+
+    <div class="ai-scroll ai-plugin__body">
+      <div class="ai-plugin__inner">
+        <a-alert
+          v-if="!bridgeAvailable"
+          type="warning"
+          show-icon
+          message="本功能仅在桌面客户端可用"
+          description="Skill 与 MCP 由本机 Codex 运行时管理,需要通过桌面客户端的主进程通道读写。当前是浏览器环境,未发起任何调用。"
+        />
+
+        <a-tabs v-else v-model:active-key="activeTab" class="ai-plugin__tabs" size="small" destroy-inactive-tab-pane>
+          <!-- ── MCP ─────────────────────────────────────────────── -->
+          <a-tab-pane key="mcp">
+            <template #tab>
+              <span>MCP 服务</span>
+              <span class="ai-plugin__tab-count">{{ mcpServers.length }}</span>
+            </template>
+
+            <div class="ai-plugin__hint">
+              MCP 服务写在本机 Codex 配置(<b>CODEX_HOME/config.toml</b> 的 <b>mcp_servers</b> 段),保存后
+              <b>热生效、无需重启</b>。出于安全考虑只接受 argv 数组形式的命令,HTTP 端点的凭据只能引用
+              <b>环境变量名</b>,不接受明文 token。
+            </div>
+
+            <div class="ai-plugin__ops">
+              <a-button size="small" type="primary" @click="openMcpForm(null)">
+                <template #icon><Icon icon="mdi:plus" :size="14" /></template>
+                新增 MCP
+              </a-button>
+            </div>
+
+            <a-table
+              class="ai-plugin__table"
+              size="middle"
+              :columns="mcpColumns"
+              :data-source="mcpServers"
+              :loading="loading"
+              :pagination="false"
+              row-key="id"
+            >
+              <template #bodyCell="{ column, record }">
+                <template v-if="column.key === 'id'">
+                  <div class="ai-plugin__name">{{ record.id }}</div>
+                  <div class="ai-plugin__sub">{{ record.transport === 'http' ? 'HTTP / SSE' : 'STDIO' }}</div>
+                </template>
+                <template v-else-if="column.key === 'target'">
+                  <span class="ai-plugin__mono ai-plugin__ellipsis" :title="mcpTarget(record)">
+                    {{ mcpTarget(record) }}
+                  </span>
+                </template>
+                <template v-else-if="column.key === 'state'">
+                  <span class="ai-plugin__status" :class="record.connected ? 'is-on' : 'is-off'">
+                    {{ record.connected ? `已连接 · ${record.toolCount} 个工具` : '未连接' }}
+                  </span>
+                  <div v-if="record.authStatus && record.authStatus !== 'unknown'" class="ai-plugin__sub">
+                    鉴权:{{ record.authStatus }}
+                  </div>
+                </template>
+                <template v-else-if="column.key === 'enabled'">
+                  <a-switch :checked="record.enabled" size="small" @change="(value: any) => toggleMcp(record, value)" />
+                </template>
+                <template v-else-if="column.key === 'action'">
+                  <div class="ai-plugin__cell-ops">
+                    <a-button type="link" size="small" @click="openMcpForm(record)">编辑</a-button>
+                    <a-button type="link" size="small" danger @click="removeMcp(record)">删除</a-button>
+                  </div>
+                </template>
+              </template>
+              <template #emptyText>
+                <div class="ai-empty-tip">
+                  还没有配置 MCP 服务。点击「新增 MCP」添加一个 stdio 命令或 HTTP 端点,Codex 就能调用它的工具。
+                </div>
+              </template>
+            </a-table>
+          </a-tab-pane>
+
+          <!-- ── Skill ───────────────────────────────────────────── -->
+          <a-tab-pane key="skill">
+            <template #tab>
+              <span>Skill</span>
+              <span class="ai-plugin__tab-count">{{ skills.length }}</span>
+            </template>
+
+            <div class="ai-plugin__hint">
+              Skill 是带 <b>SKILL.md</b> 的目录,装在 <b>{{ status?.codexHome || 'CODEX_HOME' }}/skills</b> 下。 Codex
+              原生只提供「列出 / 启停」,<b>安装与卸载由本客户端负责</b>:目录名需为小写连字符命名, SKILL.md 的
+              frontmatter 必须包含 <b>name</b> 与 <b>description</b>。标记为「内置」的 skill 不可删除。
+            </div>
+
+            <div class="ai-plugin__ops">
+              <a-button size="small" type="primary" :loading="installing" @click="pickSkillFolder">
+                <template #icon><Icon icon="mdi:folder-open-outline" :size="14" /></template>
+                从目录安装
+              </a-button>
+              <a-button size="small" :loading="installing" @click="triggerZipPick">
+                <template #icon><Icon icon="mdi:upload" :size="14" /></template>
+                上传 zip 安装
+              </a-button>
+              <a-button size="small" @click="openSkillsDir">
+                <template #icon><Icon icon="mdi:folder-open-outline" :size="14" /></template>
+                打开 skills 目录
+              </a-button>
+              <input ref="zipInputRef" type="file" accept=".zip" style="display: none" @change="onZipPicked" />
+            </div>
+
+            <a-table
+              class="ai-plugin__table"
+              size="middle"
+              :columns="skillColumns"
+              :data-source="skills"
+              :loading="loading"
+              :pagination="false"
+              row-key="path"
+            >
+              <template #bodyCell="{ column, record }">
+                <template v-if="column.key === 'name'">
+                  <div class="ai-plugin__name">
+                    {{ record.name }}
+                    <span v-if="!record.managed" class="ai-plugin__badge">内置</span>
+                  </div>
+                  <div class="ai-plugin__sub ai-plugin__ellipsis" :title="record.path">{{ record.path }}</div>
+                </template>
+                <template v-else-if="column.key === 'description'">
+                  <span class="ai-plugin__desc" :title="record.description">{{ record.description || '—' }}</span>
+                </template>
+                <template v-else-if="column.key === 'scope'">
+                  <span class="ai-plugin__chip">{{ scopeLabel(record.scope) }}</span>
+                </template>
+                <template v-else-if="column.key === 'enabled'">
+                  <a-switch
+                    :checked="record.enabled"
+                    size="small"
+                    @change="(value: any) => toggleSkill(record, value)"
+                  />
+                </template>
+                <template v-else-if="column.key === 'action'">
+                  <div class="ai-plugin__cell-ops">
+                    <a-button type="link" size="small" @click="previewSkill(record)">查看</a-button>
+                    <a-button type="link" size="small" danger :disabled="!record.managed" @click="removeSkill(record)">
+                      卸载
+                    </a-button>
+                  </div>
+                </template>
+              </template>
+              <template #emptyText>
+                <div class="ai-empty-tip">Codex 当前没有可见的 skill。可以从目录或 zip 安装一个。</div>
+              </template>
+            </a-table>
+          </a-tab-pane>
+
+          <!-- ── 运行时 ──────────────────────────────────────────── -->
+          <a-tab-pane key="runtime" tab="Codex 运行时">
+            <div class="ai-plugin__hint">
+              本客户端<b>不做任何 Codex / OpenAI 账号登录</b>:模型来自后端「模型管理」, 选中后由主进程写入本机 Codex
+              配置,<b>API Key 只注入子进程环境变量,不落盘</b>。 Codex 0.155 起只支持 Responses 协议,仅提供
+              <b>/chat/completions</b> 的端点会在探测阶段被拦下。
+            </div>
+
+            <div class="ai-plugin__panel">
+              <div class="ai-plugin__panel-title">运行状态</div>
+              <div class="ai-plugin__kv">
+                <span>状态</span>
+                <b :class="stateClass">{{ stateLabel }}</b>
+                <span>Codex 版本</span>
+                <b class="ai-plugin__mono">{{ status?.version || '—' }}</b>
+                <span>当前模型</span>
+                <b class="ai-plugin__mono">{{ status?.defaultModel || '未配置' }}</b>
+                <span>Provider</span>
+                <b class="ai-plugin__mono">{{ status?.providerId || '—' }}</b>
+                <span>CODEX_HOME</span>
+                <b class="ai-plugin__mono ai-plugin__ellipsis" :title="status?.codexHome">
+                  {{ status?.codexHome || '—' }}
+                </b>
+              </div>
+              <a-alert
+                v-if="status?.degraded && status?.running"
+                class="ai-plugin__alert"
+                type="warning"
+                show-icon
+                message="模型目录不可用"
+                description="model/list 没有返回可用模型。这不影响已配置的自定义模型,但页面上的模型列表可能为空。"
+              />
+              <a-alert
+                v-if="status?.error"
+                class="ai-plugin__alert"
+                type="error"
+                show-icon
+                message="运行时错误"
+                :description="status.error"
+              />
+              <div class="ai-plugin__ops">
+                <a-button size="small" :loading="busy" :disabled="status?.running" @click="startRuntime">
+                  <template #icon><Icon icon="mdi:play" :size="14" /></template>
+                  启动
+                </a-button>
+                <a-button size="small" :loading="busy" :disabled="!status?.running" @click="restartRuntime">
+                  <template #icon><Icon icon="mdi:refresh" :size="14" /></template>
+                  重启
+                </a-button>
+                <a-button size="small" danger :loading="busy" :disabled="!status?.running" @click="stopRuntime">
+                  <template #icon><Icon icon="mdi:stop" :size="14" /></template>
+                  停止
+                </a-button>
+              </div>
+            </div>
+
+            <div class="ai-plugin__panel">
+              <div class="ai-plugin__panel-title">模型(来自后端「模型管理」)</div>
+              <div class="ai-plugin__row">
+                <a-select
+                  v-model:value="selectedModelId"
+                  class="ai-plugin__select"
+                  :options="modelOptions"
+                  :loading="modelsLoading"
+                  placeholder="选择一个对话模型"
+                  show-search
+                  option-filter-prop="label"
+                />
+                <a-button size="small" :loading="probing" :disabled="!selectedModel" @click="probeSelected">
+                  探测端点
+                </a-button>
+                <a-button
+                  size="small"
+                  type="primary"
+                  :loading="applying"
+                  :disabled="!selectedModel"
+                  @click="applySelected"
+                >
+                  应用
+                </a-button>
+                <a-button size="small" :disabled="!status?.applied" @click="clearApplied">清除</a-button>
+              </div>
+
+              <a-alert
+                v-if="probeResult"
+                class="ai-plugin__alert"
+                :type="probeResult.compatible ? 'success' : 'error'"
+                show-icon
+                :message="probeResult.compatible ? '端点兼容' : '端点不兼容'"
+                :description="probeResult.detail"
+              />
+              <a-alert
+                v-if="probeResult?.hint"
+                class="ai-plugin__alert"
+                type="warning"
+                show-icon
+                message="base_url 提醒"
+                :description="probeResult.hint"
+              />
+              <div v-if="status?.applied" class="ai-plugin__applied">
+                已应用:<b>{{ status.applied.name || status.applied.model }}</b>
+                <span class="ai-plugin__mono">{{ status.applied.model }}</span>
+                <span class="ai-plugin__mono">{{ status.applied.baseUrl || status.applied.builtinProvider }}</span>
+                <span class="ai-plugin__sub">{{ status.applied.appliedAt }}</span>
+              </div>
+              <div class="ai-plugin__note">
+                重启客户端后需要重新「应用」一次:API Key 不会落盘,只能由页面从后端取到后重新注入。
+              </div>
+            </div>
+
+            <div class="ai-plugin__panel">
+              <div class="ai-plugin__panel-title">
+                诊断日志
+                <a-button type="link" size="small" @click="loadDiagnostics">刷新</a-button>
+              </div>
+              <div class="ai-plugin__logs">
+                <div v-for="(item, index) in diagnostics" :key="index" class="ai-plugin__log">
+                  <span class="ai-plugin__mono">{{ item.ts.slice(11, 19) }}</span>
+                  <span>{{ item.line }}</span>
+                </div>
+                <div v-if="!diagnostics.length" class="ai-empty-tip">暂无诊断输出。</div>
+              </div>
+            </div>
+          </a-tab-pane>
+        </a-tabs>
+      </div>
+    </div>
+
+    <!-- MCP 表单 -->
+    <a-modal
+      v-model:open="mcpFormOpen"
+      :title="mcpEditing ? '编辑 MCP 服务' : '新增 MCP 服务'"
+      :width="620"
+      :confirm-loading="mcpSubmitting"
+      :mask-closable="false"
+      ok-text="保存"
+      cancel-text="取消"
+      @ok="submitMcp"
+    >
+      <a-form class="ai-plugin__form" :model="mcpForm" layout="vertical">
+        <a-form-item label="ID" required>
+          <a-input
+            v-model:value="mcpForm.id"
+            :disabled="Boolean(mcpEditing)"
+            placeholder="字母、数字、连字符或下划线,最长 64"
+            :maxlength="64"
+          />
+        </a-form-item>
+        <a-form-item label="传输方式" required>
+          <a-radio-group v-model:value="mcpForm.transport" size="small">
+            <a-radio-button value="stdio">STDIO(本机命令)</a-radio-button>
+            <a-radio-button value="http">HTTP / SSE(远程端点)</a-radio-button>
+          </a-radio-group>
+        </a-form-item>
+
+        <template v-if="mcpForm.transport === 'stdio'">
+          <a-form-item label="命令" required>
+            <a-input v-model:value="mcpForm.command" placeholder="如:npx" :maxlength="500" />
+          </a-form-item>
+          <a-form-item label="参数(每行一个)">
+            <a-textarea
+              v-model:value="mcpForm.argsText"
+              :rows="4"
+              placeholder="-y&#10;@modelcontextprotocol/server-everything"
+            />
+          </a-form-item>
+        </template>
+
+        <template v-else>
+          <a-form-item label="URL" required>
+            <a-input
+              v-model:value="mcpForm.url"
+              placeholder="https://mcp.example.com/v1(远程必须 HTTPS,HTTP 仅允许本机)"
+              :maxlength="2048"
+            />
+          </a-form-item>
+          <a-form-item label="Bearer Token 环境变量名">
+            <a-input v-model:value="mcpForm.bearerTokenEnvVar" placeholder="如:ZSJZ_MCP_TOKEN" :maxlength="128" />
+            <div class="ai-plugin__note">只填环境变量名,不要填 token 本身。</div>
+          </a-form-item>
+        </template>
+
+        <a-form-item label="启用">
+          <a-switch v-model:checked="mcpForm.enabled" size="small" />
+        </a-form-item>
+      </a-form>
+    </a-modal>
+
+    <!-- Skill 预览 -->
+    <a-drawer v-model:open="skillPreviewOpen" title="SKILL.md" :width="720" placement="right">
+      <div v-if="skillPreview" class="ai-plugin__preview">
+        <div class="ai-plugin__mono">{{ skillPreview.dir }}</div>
+        <pre class="ai-plugin__pre">{{ skillPreview.content }}</pre>
+        <div class="ai-plugin__panel-title">附带文件</div>
+        <div v-for="file in skillPreview.files" :key="file.path" class="ai-plugin__log">
+          <span class="ai-plugin__mono">{{ file.path }}</span>
+          <span class="ai-plugin__sub">{{ file.size }} B</span>
+        </div>
+      </div>
+    </a-drawer>
+  </div>
+</template>
+
+<script lang="ts" setup name="AiPluginIndex">
+  /**
+   * AI 插件页:管理本机 Codex 运行时的 MCP 服务、Skill 与模型 provider。
+   *
+   * 全部能力走桌面端 IPC(src/ai/api/codexApi.ts → electron/controller/codexCtl.ts),
+   * 不走 /js/a HTTP;只有「模型」下拉复用后端模型管理接口。
+   * 浏览器环境下不发任何调用,整页降级为提示。
+   */
+  import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
+  import {
+    Alert,
+    Button,
+    Drawer,
+    Form,
+    FormItem,
+    Input,
+    Modal,
+    RadioButton,
+    RadioGroup,
+    Select,
+    Switch,
+    Table,
+    Tabs,
+    Textarea,
+  } from 'ant-design-vue';
+  import type { TableColumnsType } from 'ant-design-vue';
+  import Icon from '@/core/components/Icon/src/Icon.vue';
+  import { useMessage } from '@/core/hooks/web/useMessage';
+  import * as codexApi from '../../api/codexApi';
+  import * as modelApi from '../../api/modelApi';
+  import type { AiModel, ModelProvider } from '../../api/types';
+
+  const AAlert = Alert;
+  const AButton = Button;
+  const ADrawer = Drawer;
+  const AForm = Form;
+  const AFormItem = FormItem;
+  const AInput = Input;
+  const AModal = Modal;
+  const ARadioGroup = RadioGroup;
+  const ARadioButton = RadioButton;
+  const ASelect = Select;
+  const ASwitch = Switch;
+  const ATable = Table;
+  const ATabs = Tabs;
+  const ATabPane = Tabs.TabPane;
+  const ATextarea = Textarea;
+
+  const { showMessage } = useMessage();
+
+  const bridgeAvailable = ref(codexApi.isCodexBridgeAvailable());
+  const activeTab = ref<'mcp' | 'skill' | 'runtime'>('mcp');
+  const loading = ref(false);
+  const busy = ref(false);
+  const installing = ref(false);
+
+  const status = ref<codexApi.CodexStatus | null>(null);
+  const mcpServers = ref<codexApi.CodexMcpServer[]>([]);
+  const skills = ref<codexApi.CodexSkill[]>([]);
+  const diagnostics = ref<codexApi.CodexDiagnostic[]>([]);
+
+  const models = ref<AiModel[]>([]);
+  const providers = ref<ModelProvider[]>([]);
+  const modelsLoading = ref(false);
+  const selectedModelId = ref<string | undefined>(undefined);
+  const probing = ref(false);
+  const applying = ref(false);
+  const probeResult = ref<codexApi.ProviderProbeResult | null>(null);
+
+  const zipInputRef = ref<HTMLInputElement | null>(null);
+  const skillPreviewOpen = ref(false);
+  const skillPreview = ref<codexApi.CodexSkillReadResult | null>(null);
+
+  const mcpFormOpen = ref(false);
+  const mcpEditing = ref<codexApi.CodexMcpServer | null>(null);
+  const mcpSubmitting = ref(false);
+  const mcpForm = reactive({
+    id: '',
+    transport: 'stdio' as 'stdio' | 'http',
+    command: '',
+    argsText: '',
+    url: '',
+    bearerTokenEnvVar: '',
+    enabled: true,
+  });
+
+  const unsubscribers: Array<() => void> = [];
+
+  const summary = computed(() => {
+    if (!bridgeAvailable.value) return '仅桌面客户端可用';
+    const parts = [`${mcpServers.value.length} 个 MCP`, `${skills.value.length} 个 Skill`];
+    parts.push(status.value?.running ? `运行中 · ${status.value.defaultModel || '未配置模型'}` : '未启动');
+    return parts.join(' · ');
+  });
+
+  const stateLabel = computed(() => {
+    const state = status.value?.state;
+    if (state === 'ready') return '运行中';
+    if (state === 'starting') return '启动中';
+    if (state === 'error') return '异常';
+    return '已停止';
+  });
+
+  const stateClass = computed(() => {
+    const state = status.value?.state;
+    if (state === 'ready') return 'is-on';
+    if (state === 'error') return 'is-err';
+    return 'is-off';
+  });
+
+  /** 只列对话模型:Codex 是 agent,向量模型用不上 */
+  const modelOptions = computed(() =>
+    models.value
+      .filter((item) => !item.type || item.type === 'llm')
+      .map((item) => ({
+        label: `${item.name || item.modelId || item.id}(${item.modelId || '—'})`,
+        value: item.id,
+      })),
+  );
+
+  const selectedModel = computed(() => models.value.find((item) => item.id === selectedModelId.value) || null);
+
+  const mcpColumns: TableColumnsType = [
+    { title: 'ID', key: 'id', width: 200 },
+    { title: '命令 / URL', key: 'target', ellipsis: true },
+    { title: '连接状态', key: 'state', width: 170 },
+    { title: '启用', key: 'enabled', width: 80 },
+    { title: '操作', key: 'action', width: 140, fixed: 'right' },
+  ];
+
+  const skillColumns: TableColumnsType = [
+    { title: 'Skill', key: 'name', width: 260 },
+    { title: '描述', key: 'description', ellipsis: true },
+    { title: '来源', key: 'scope', width: 90 },
+    { title: '启用', key: 'enabled', width: 80 },
+    { title: '操作', key: 'action', width: 140, fixed: 'right' },
+  ];
+
+  function mcpTarget(record: any) {
+    if (record.transport === 'http') return record.url || '—';
+    return [record.command, ...(record.args || [])].filter(Boolean).join(' ') || '—';
+  }
+
+  function scopeLabel(scope: string) {
+    if (scope === 'system') return '内置';
+    if (scope === 'user') return '用户';
+    if (scope === 'repo') return '仓库';
+    if (scope === 'admin') return '管理员';
+    return scope || '—';
+  }
+
+  onMounted(async () => {
+    if (!bridgeAvailable.value) return;
+    unsubscribers.push(codexApi.onCodexStatus((payload) => (status.value = payload)));
+    unsubscribers.push(
+      codexApi.onCodexDiagnostic((item) => {
+        diagnostics.value = [...diagnostics.value, item].slice(-200);
+      }),
+    );
+    await reload();
+    await loadModels();
+  });
+
+  onBeforeUnmount(() => {
+    for (const unsubscribe of unsubscribers) unsubscribe();
+    unsubscribers.length = 0;
+  });
+
+  async function reload() {
+    if (!bridgeAvailable.value) return;
+    loading.value = true;
+    try {
+      const [nextStatus, nextMcp, nextSkills, logs] = await Promise.all([
+        codexApi.codexStatus(),
+        codexApi.codexMcpList().catch(() => [] as codexApi.CodexMcpServer[]),
+        codexApi.codexSkillList().catch(() => [] as codexApi.CodexSkill[]),
+        codexApi.codexLogsTail(200).catch(() => [] as codexApi.CodexDiagnostic[]),
+      ]);
+      status.value = nextStatus;
+      mcpServers.value = nextMcp;
+      skills.value = nextSkills;
+      diagnostics.value = logs;
+    } catch (e: any) {
+      showMessage(e?.message || '加载插件信息失败', 'error');
+    } finally {
+      loading.value = false;
+    }
+  }
+
+  async function loadModels() {
+    modelsLoading.value = true;
+    try {
+      const [list, providerList] = await Promise.all([
+        modelApi.listModels(),
+        modelApi.listProviders().catch(() => [] as ModelProvider[]),
+      ]);
+      models.value = list || [];
+      providers.value = providerList || [];
+      const applied = status.value?.applied;
+      selectedModelId.value = applied?.modelRecordId || models.value.find((m) => m.defaultModel)?.id || undefined;
+    } catch (e: any) {
+      showMessage(e?.message || '加载模型列表失败(需要登录后端)', 'error');
+    } finally {
+      modelsLoading.value = false;
+    }
+  }
+
+  /** 把后端模型记录翻译成主进程要的入参;厂商协议类型来自 agent_model_provider.type */
+  function buildApplyInput(model: AiModel): codexApi.ApplyProviderInput {
+    const providerType = providers.value.find((item) => item.name === model.provider)?.type ?? null;
+    return {
+      modelId: model.modelId || '',
+      name: model.name || null,
+      baseUrl: model.baseUrl || null,
+      apiKey: model.apiKey || null,
+      headersJson: model.headersJson || null,
+      config: model.config || null,
+      providerType,
+      modelRecordId: model.id,
+    };
+  }
+
+  async function probeSelected() {
+    const model = selectedModel.value;
+    if (!model) return;
+    probing.value = true;
+    probeResult.value = null;
+    try {
+      const input = buildApplyInput(model);
+      probeResult.value = await codexApi.codexProbeProvider({
+        baseUrl: input.baseUrl || '',
+        apiKey: input.apiKey,
+      });
+    } catch (e: any) {
+      showMessage(e?.message || '探测失败', 'error');
+    } finally {
+      probing.value = false;
+    }
+  }
+
+  async function applySelected() {
+    const model = selectedModel.value;
+    if (!model) return;
+    applying.value = true;
+    try {
+      const result = await codexApi.codexApplyProvider(buildApplyInput(model));
+      status.value = result.status;
+      probeResult.value = null;
+      showMessage(`已应用模型「${result.applied.model}」`, 'success');
+      if (result.droppedConfigKeys.length) {
+        showMessage(`config 中不支持的键已忽略:${result.droppedConfigKeys.join('、')}`, 'warning');
+      }
+      if (result.hint) showMessage(result.hint, 'warning');
+    } catch (e: any) {
+      showMessage(e?.message || '应用模型失败', 'error');
+    } finally {
+      applying.value = false;
+    }
+  }
+
+  async function clearApplied() {
+    try {
+      status.value = await codexApi.codexClearProvider();
+      showMessage('已清除模型配置', 'success');
+    } catch (e: any) {
+      showMessage(e?.message || '清除失败', 'error');
+    }
+  }
+
+  async function startRuntime() {
+    busy.value = true;
+    try {
+      status.value = await codexApi.codexStart();
+      showMessage('Codex 运行时已启动', 'success');
+    } catch (e: any) {
+      showMessage(e?.message || '启动失败', 'error');
+    } finally {
+      busy.value = false;
+    }
+  }
+
+  async function restartRuntime() {
+    busy.value = true;
+    try {
+      status.value = await codexApi.codexRestart();
+      showMessage('Codex 运行时已重启', 'success');
+    } catch (e: any) {
+      showMessage(e?.message || '重启失败', 'error');
+    } finally {
+      busy.value = false;
+    }
+  }
+
+  async function stopRuntime() {
+    busy.value = true;
+    try {
+      status.value = await codexApi.codexStop();
+      showMessage('Codex 运行时已停止', 'success');
+    } catch (e: any) {
+      showMessage(e?.message || '停止失败', 'error');
+    } finally {
+      busy.value = false;
+    }
+  }
+
+  async function loadDiagnostics() {
+    try {
+      diagnostics.value = await codexApi.codexLogsTail(200);
+    } catch (e: any) {
+      showMessage(e?.message || '读取诊断日志失败', 'error');
+    }
+  }
+
+  // ── MCP ──────────────────────────────────────────────────────────────
+
+  function openMcpForm(record: any) {
+    mcpEditing.value = record ? (record as codexApi.CodexMcpServer) : null;
+    mcpForm.id = record?.id ?? '';
+    mcpForm.transport = record?.transport ?? 'stdio';
+    mcpForm.command = record?.command ?? '';
+    mcpForm.argsText = (record?.args ?? []).join('\n');
+    mcpForm.url = record?.url ?? '';
+    mcpForm.bearerTokenEnvVar = record?.bearerTokenEnvVar ?? '';
+    mcpForm.enabled = record ? record.enabled : true;
+    mcpFormOpen.value = true;
+  }
+
+  async function submitMcp() {
+    if (!mcpForm.id.trim()) {
+      showMessage('请填写 ID', 'error');
+      return;
+    }
+    mcpSubmitting.value = true;
+    try {
+      const args = mcpForm.argsText
+        .split(/\r?\n/)
+        .map((line) => line.trim())
+        .filter(Boolean);
+      mcpServers.value = await codexApi.codexMcpSave({
+        id: mcpForm.id.trim(),
+        transport: mcpForm.transport,
+        command: mcpForm.transport === 'stdio' ? mcpForm.command.trim() : null,
+        args: mcpForm.transport === 'stdio' ? args : [],
+        url: mcpForm.transport === 'http' ? mcpForm.url.trim() : null,
+        bearerTokenEnvVar: mcpForm.transport === 'http' ? mcpForm.bearerTokenEnvVar.trim() || null : null,
+        enabled: mcpForm.enabled,
+      });
+      mcpFormOpen.value = false;
+      showMessage('已保存并热生效', 'success');
+    } catch (e: any) {
+      showMessage(e?.message || '保存失败', 'error');
+    } finally {
+      mcpSubmitting.value = false;
+    }
+  }
+
+  async function toggleMcp(record: any, enabled: boolean) {
+    try {
+      mcpServers.value = await codexApi.codexMcpSave({
+        id: record.id,
+        transport: record.transport,
+        command: record.command,
+        args: record.args,
+        url: record.url,
+        bearerTokenEnvVar: record.bearerTokenEnvVar,
+        enabled,
+      });
+    } catch (e: any) {
+      showMessage(e?.message || '切换失败', 'error');
+    }
+  }
+
+  function removeMcp(record: any) {
+    Modal.confirm({
+      title: '删除 MCP 服务',
+      content: `确认删除「${record.id}」?该配置只影响本机 Codex。`,
+      okText: '删除',
+      okType: 'danger',
+      cancelText: '取消',
+      onOk: async () => {
+        try {
+          mcpServers.value = await codexApi.codexMcpRemove(record.id);
+          showMessage('已删除', 'success');
+        } catch (e: any) {
+          showMessage(e?.message || '删除失败', 'error');
+        }
+      },
+    });
+  }
+
+  // ── Skill ────────────────────────────────────────────────────────────
+
+  async function pickSkillFolder() {
+    try {
+      const srcPath = await codexApi.selectDirectory();
+      if (!srcPath) return;
+      await installSkill(() => codexApi.codexSkillInstallFolder({ srcPath }));
+    } catch (e: any) {
+      showMessage(e?.message || '选择目录失败', 'error');
+    }
+  }
+
+  function triggerZipPick() {
+    zipInputRef.value?.click();
+  }
+
+  async function onZipPicked(event: Event) {
+    const input = event.target as HTMLInputElement;
+    const file = input.files?.[0];
+    input.value = '';
+    if (!file) return;
+    try {
+      const data = await codexApi.readFileBytes(file);
+      await installSkill(() => codexApi.codexSkillInstallZip({ data }));
+    } catch (e: any) {
+      showMessage(e?.message || '读取 zip 失败', 'error');
+    }
+  }
+
+  async function installSkill(run: () => Promise<codexApi.CodexSkill[]>) {
+    installing.value = true;
+    try {
+      skills.value = await run();
+      showMessage('安装成功', 'success');
+    } catch (e: any) {
+      showMessage(e?.message || '安装失败', 'error');
+    } finally {
+      installing.value = false;
+    }
+  }
+
+  async function toggleSkill(record: any, enabled: boolean) {
+    try {
+      const result = await codexApi.codexSkillSetEnabled({ path: record.path }, enabled);
+      skills.value = skills.value.map((item) =>
+        item.path === record.path ? { ...item, enabled: result.effectiveEnabled } : item,
+      );
+    } catch (e: any) {
+      showMessage(e?.message || '切换失败', 'error');
+    }
+  }
+
+  async function previewSkill(record: any) {
+    try {
+      skillPreview.value = await codexApi.codexSkillRead({ path: record.path });
+      skillPreviewOpen.value = true;
+    } catch (e: any) {
+      showMessage(e?.message || '读取失败', 'error');
+    }
+  }
+
+  function removeSkill(record: any) {
+    Modal.confirm({
+      title: '卸载 Skill',
+      content: `确认删除「${record.name}」?将移除 ${record.path} 整个目录。`,
+      okText: '卸载',
+      okType: 'danger',
+      cancelText: '取消',
+      onOk: async () => {
+        try {
+          skills.value = await codexApi.codexSkillRemove({ path: record.path });
+          showMessage('已卸载', 'success');
+        } catch (e: any) {
+          showMessage(e?.message || '卸载失败', 'error');
+        }
+      },
+    });
+  }
+
+  async function openSkillsDir() {
+    try {
+      await codexApi.codexSkillOpenFolder();
+    } catch (e: any) {
+      showMessage(e?.message || '打开目录失败', 'error');
+    }
+  }
+</script>
+
+<style lang="less" scoped>
+  .ai-plugin {
+    &__body {
+      flex: 1 1 auto;
+      min-height: 0;
+      overflow-y: auto;
+    }
+
+    &__inner {
+      max-width: 1280px;
+      margin: 0 auto;
+      padding: var(--ai-sp-2) var(--ai-sp-5) var(--ai-sp-5);
+    }
+
+    &__tabs {
+      :deep(.ant-tabs-nav) {
+        margin-bottom: var(--ai-sp-4);
+      }
+
+      :deep(.ant-tabs-tab) {
+        font-size: 13.5px;
+      }
+    }
+
+    &__tab-count {
+      display: inline-block;
+      min-width: 18px;
+      margin-left: var(--ai-sp-2);
+      padding: 0 5px;
+      font-size: 11px;
+      line-height: 17px;
+      text-align: center;
+      color: var(--ai-text-2);
+      background: var(--ai-bg-subtle);
+      border: 1px solid var(--ai-border);
+      border-radius: 999px;
+    }
+
+    &__hint {
+      margin-bottom: var(--ai-sp-4);
+      padding: var(--ai-sp-3);
+      font-size: 12.5px;
+      line-height: 1.75;
+      color: var(--ai-text-2);
+      background: var(--ai-primary-softer);
+      border-left: 3px solid fade(@primary-color, 45%);
+      border-radius: 0 var(--ai-radius-sm) var(--ai-radius-sm) 0;
+
+      b {
+        color: var(--ai-text);
+      }
+    }
+
+    &__ops {
+      display: flex;
+      gap: var(--ai-sp-2);
+      margin-bottom: var(--ai-sp-3);
+    }
+
+    &__cell-ops {
+      display: flex;
+      align-items: center;
+      gap: var(--ai-sp-1);
+    }
+
+    &__table {
+      background: var(--ai-bg);
+    }
+
+    &__name {
+      display: flex;
+      align-items: center;
+      gap: var(--ai-sp-2);
+      font-size: 13.5px;
+      font-weight: 600;
+      color: var(--ai-text);
+    }
+
+    &__badge {
+      padding: 0 6px;
+      font-size: 11px;
+      line-height: 17px;
+      color: var(--ai-text-2);
+      background: var(--ai-bg-subtle);
+      border: 1px solid var(--ai-border);
+      border-radius: var(--ai-radius-sm);
+    }
+
+    &__sub {
+      margin-top: 2px;
+      font-family: 'SFMono-Regular', Consolas, monospace;
+      font-size: 11.5px;
+      color: var(--ai-text-2);
+    }
+
+    &__desc {
+      font-size: 12.5px;
+      color: var(--ai-text-2);
+    }
+
+    &__chip {
+      display: inline-block;
+      padding: 1px 8px;
+      font-size: 12px;
+      line-height: 20px;
+      color: var(--ai-text);
+      background: var(--ai-bg-subtle);
+      border: 1px solid var(--ai-border);
+      border-radius: 999px;
+    }
+
+    &__mono {
+      font-family: 'SFMono-Regular', Consolas, monospace;
+      font-size: 12px;
+      color: var(--ai-text-2);
+    }
+
+    &__ellipsis {
+      display: inline-block;
+      max-width: 100%;
+      overflow: hidden;
+      white-space: nowrap;
+      text-overflow: ellipsis;
+    }
+
+    &__status {
+      font-size: 12px;
+
+      &.is-on {
+        color: @success-color;
+      }
+
+      &.is-off {
+        color: var(--ai-text-2);
+      }
+
+      &.is-err {
+        color: @error-color;
+      }
+    }
+
+    &__panel {
+      margin-bottom: var(--ai-sp-4);
+      padding: var(--ai-sp-4);
+      background: var(--ai-bg);
+      border: 1px solid var(--ai-border);
+      border-radius: var(--ai-radius-sm);
+    }
+
+    &__panel-title {
+      display: flex;
+      align-items: center;
+      gap: var(--ai-sp-2);
+      margin-bottom: var(--ai-sp-3);
+      font-size: 13px;
+      font-weight: 600;
+      color: var(--ai-text);
+    }
+
+    &__kv {
+      display: grid;
+      grid-template-columns: 120px 1fr;
+      gap: var(--ai-sp-2) var(--ai-sp-3);
+      font-size: 12.5px;
+      color: var(--ai-text-2);
+
+      b {
+        font-weight: 500;
+        color: var(--ai-text);
+
+        &.is-on {
+          color: @success-color;
+        }
+
+        &.is-err {
+          color: @error-color;
+        }
+
+        &.is-off {
+          color: var(--ai-text-2);
+        }
+      }
+    }
+
+    &__row {
+      display: flex;
+      flex-wrap: wrap;
+      gap: var(--ai-sp-2);
+      align-items: center;
+    }
+
+    &__select {
+      min-width: 320px;
+    }
+
+    &__alert {
+      margin-top: var(--ai-sp-3);
+    }
+
+    &__applied {
+      display: flex;
+      flex-wrap: wrap;
+      gap: var(--ai-sp-2);
+      align-items: center;
+      margin-top: var(--ai-sp-3);
+      font-size: 12.5px;
+      color: var(--ai-text-2);
+    }
+
+    &__note {
+      margin-top: var(--ai-sp-2);
+      font-size: 12px;
+      line-height: 1.7;
+      color: var(--ai-text-2);
+    }
+
+    &__logs {
+      max-height: 260px;
+      overflow-y: auto;
+      font-size: 12px;
+    }
+
+    &__log {
+      display: flex;
+      gap: var(--ai-sp-2);
+      padding: 2px 0;
+      color: var(--ai-text-2);
+      border-bottom: 1px dashed var(--ai-border);
+    }
+
+    &__form {
+      :deep(.ant-form-item) {
+        margin-bottom: var(--ai-sp-3);
+      }
+    }
+
+    &__preview {
+      font-size: 12.5px;
+    }
+
+    &__pre {
+      max-height: 50vh;
+      padding: var(--ai-sp-3);
+      margin: var(--ai-sp-2) 0 var(--ai-sp-4);
+      overflow: auto;
+      font-family: 'SFMono-Regular', Consolas, monospace;
+      font-size: 12px;
+      line-height: 1.7;
+      white-space: pre-wrap;
+      background: var(--ai-bg-subtle);
+      border: 1px solid var(--ai-border);
+      border-radius: var(--ai-radius-sm);
+    }
+  }
+</style>

+ 1 - 0
ai-electron/frontend/src/core/router/helper/routeHelper.ts

@@ -58,6 +58,7 @@ const explicitDynamicViewMap: Record<string, () => Promise<Recordable>> = {
   // AI 数据分析应用(模块自持视图,不走通用 glob 匹配,避免与 node_modules 内同名 views 冲突)
   '/aiAnalysis/index': () => import('@/ai/views/aiAnalysis/index.vue'),
   '/aiModel/index': () => import('@/ai/views/aiModel/index.vue'),
+  '/aiPlugin/index': () => import('@/ai/views/aiPlugin/index.vue'),
 };
 
 function normalizeViewModuleKey(key: string) {

+ 16 - 0
ai-electron/frontend/src/core/store/modules/menu.json

@@ -653,6 +653,22 @@
         "leaf": false,
         "url": "/aiModel/index",
         "target": ""
+      },
+      {
+        "path": "/aiPlugin/index",
+        "component": "/aiPlugin/index",
+        "children": [],
+        "meta": {
+          "icon": "i-mdi:puzzle-outline",
+          "hideMenu": false,
+          "color": "",
+          "title": "插件"
+        },
+        "name": "ViewsAiPluginIndex",
+        "id": "10266",
+        "leaf": false,
+        "url": "/aiPlugin/index",
+        "target": ""
       }
     ]
   }

+ 10 - 0
ai-electron/frontend/uno.config.ts

@@ -76,6 +76,16 @@ export default defineConfig({
     'i-mdi:account-search-outline',
     'i-mdi:account-multiple-outline',
     'i-mdi:swap-horizontal',
+    // AI 插件页(Skill / MCP / Codex 运行时);puzzle-outline 同时是 menu.json 里的菜单图标
+    'i-mdi:puzzle-outline',
+    'i-mdi:server-network',
+    'i-mdi:cog-outline',
+    'i-mdi:power',
+    'i-mdi:play',
+    'i-mdi:upload',
+    'i-mdi:folder-open-outline',
+    'i-mdi:eye-outline',
+    'i-mdi:check-circle-outline',
   ],
   presets: [
     presetWind3(),

+ 10 - 2
ai-electron/package.json

@@ -12,6 +12,11 @@
     "build-frontend": "ee-bin build --cmds=frontend && ee-bin move --flag=frontend_dist",
     "build-electron": "ee-bin build --cmds=electron",
     "icon": "ee-bin icon",
+    "test": "vitest run",
+    "test:watch": "vitest",
+    "smoke:codex": "vitest run --config vitest.smoke.mts",
+    "smoke:ipc": "npm run build-electron && node scripts/codex-ipc-smoke.mjs",
+    "typecheck": "tsc --noEmit",
     "re-sqlite": "electron-rebuild -f -w better-sqlite3",
     "build-w": "ee-bin build --cmds=win64",
     "build-we": "ee-bin build --cmds=win_e",
@@ -32,15 +37,18 @@
   "devDependencies": {
     "@electron/rebuild": "^3.7.1",
     "@types/node": "^22.19.1",
+    "@types/yauzl": "^3.4.0",
     "debug": "^4.4.0",
     "ee-bin": "^5.0.0",
     "electron": "^37.8.0",
     "electron-builder": "^26.3.5",
-    "typescript": "^5.9.3"
+    "typescript": "^5.9.3",
+    "vitest": "^5.0.1"
   },
   "dependencies": {
     "@openai/codex": "^0.155.1",
     "ee-core": "^5.0.1",
-    "electron-updater": "^6.7.3"
+    "electron-updater": "^6.7.3",
+    "yauzl": "^2.10.0"
   }
 }

+ 178 - 0
ai-electron/scripts/codex-ipc-probe.html

@@ -0,0 +1,178 @@
+<!doctype html>
+<html lang="zh">
+  <head>
+    <meta charset="utf-8" />
+    <title>Codex IPC 探针</title>
+    <style>
+      body { font: 12px/1.6 Consolas, monospace; margin: 16px; }
+      .ok { color: #1a7f37; }
+      .bad { color: #c00; }
+    </style>
+  </head>
+  <body>
+    <pre id="out">Codex IPC probe running…</pre>
+    <script>
+      /**
+       * 真实 Electron IPC 链路探针:在应用窗口内逐条调用 controller/codexCtl/*,
+       * 覆盖结构化克隆(含 Uint8Array 入参)、错误包装、以及真实 codex 子进程往返。
+       *
+       * 用法(env 开关,不污染常规 dev 启动):
+       *   ZSJZ_CODEX_PROBE=file:///<repo>/ai-electron/scripts/codex-ipc-probe.html \
+       *   ZSJZ_CODEX_PROBE_OUT=./scripts/codex-ipc-probe.json npx electron . --env=local
+       */
+      const { ipcRenderer } = require('electron');
+      const fs = require('node:fs');
+      const os = require('node:os');
+      const path = require('node:path');
+
+      const outFile = process.env.ZSJZ_CODEX_PROBE_OUT || path.join(os.tmpdir(), 'codex-ipc-probe.json');
+      const results = [];
+      const pre = document.getElementById('out');
+
+      const call = (method, params) => ipcRenderer.invoke(`controller/codexCtl/${method}`, params);
+
+      function log(name, ok, detail) {
+        results.push({ name, ok, detail });
+        pre.innerHTML += `\n<span class="${ok ? 'ok' : 'bad'}">${ok ? 'PASS' : 'FAIL'} ${name}</span> ${JSON.stringify(detail).slice(0, 300)}`;
+      }
+
+      function isRpc(value) {
+        return Boolean(value) && typeof value === 'object' && typeof value.success === 'boolean';
+      }
+
+      async function check(name, run, assert) {
+        try {
+          const rpc = await run();
+          if (!isRpc(rpc)) {
+            log(name, false, { reason: '返回不是 {success,data,message} 结构', rpc });
+            return null;
+          }
+          const problem = assert ? assert(rpc) : rpc.success === false ? rpc.message : null;
+          log(name, !problem, problem || (rpc.success ? rpc.data : { message: rpc.message }));
+          return rpc;
+        } catch (error) {
+          log(name, false, { reason: 'invoke 直接 reject(说明主进程抛到了 Electron 层)', error: String(error) });
+          return null;
+        }
+      }
+
+      /** 断言「应当失败」:失败信息要回来,且不能被 Electron 包成 handler 错误 */
+      function expectFailure(pattern) {
+        return (rpc) => {
+          if (rpc.success) return '本该失败却成功了';
+          if (!pattern.test(rpc.message)) return `错误信息不匹配 ${pattern}: ${rpc.message}`;
+          return null;
+        };
+      }
+
+      function expectData(verify) {
+        return (rpc) => {
+          if (!rpc.success) return rpc.message;
+          return verify(rpc.data) ? null : `数据不符合预期: ${JSON.stringify(rpc.data).slice(0, 200)}`;
+        };
+      }
+
+      async function main() {
+        pre.textContent = '';
+        const tmpSkill = fs.mkdtempSync(path.join(os.tmpdir(), 'zsjz-probe-skill-'));
+        fs.writeFileSync(
+          path.join(tmpSkill, 'SKILL.md'),
+          '---\nname: ipc-probe-skill\ndescription: 探针安装的技能\n---\n\n# probe\n',
+          'utf8',
+        );
+
+        await check('ping', () => call('ping'), expectData((d) => d.available === true && /codex-cli/.test(d.version || '')));
+        await check('status', () => call('status'), expectData((d) => typeof d.running === 'boolean' && Boolean(d.codexHome)));
+        await check('start', () => call('start'), expectData((d) => d.running === true));
+        await check('listModels', () => call('listModels'), (rpc) => (rpc.success && Array.isArray(rpc.data) ? null : rpc.message));
+
+        await check(
+          'mcpSave 含中文/空格参数',
+          () => call('mcpSave', { id: 'probe-stdio', transport: 'stdio', command: 'node', args: ['-e', '过程 输出'], enabled: true }),
+          expectData((list) => list.some((item) => item.id === 'probe-stdio' && item.args[1] === '过程 输出')),
+        );
+        await check('mcpList', () => call('mcpList'), expectData((list) => list.some((item) => item.id === 'probe-stdio')));
+        await check('mcpSave 非法 id', () => call('mcpSave', { id: '有 空格', transport: 'stdio', command: 'x', enabled: true }), expectFailure(/MCP ID/));
+        await check('mcpSave 明文 http 远程', () => call('mcpSave', { id: 'a', transport: 'http', url: 'http://10.0.0.8/mcp', enabled: true }), expectFailure(/HTTPS/));
+        await check('mcpRemove 缺 id', () => call('mcpRemove', {}), expectFailure(/不能为空/));
+        await check('mcpRemove', () => call('mcpRemove', { id: 'probe-stdio' }), expectData((list) => !list.some((item) => item.id === 'probe-stdio')));
+
+        await check('skillInstallFolder', () => call('skillInstallFolder', { srcPath: tmpSkill, overwrite: true }), expectData((list) => list.some((s) => s.name === 'ipc-probe-skill' && s.managed)));
+        await check('skillSetEnabled false', () => call('skillSetEnabled', { name: 'ipc-probe-skill', enabled: false }), expectData((d) => d.effectiveEnabled === false));
+        await check('skillRead', () => call('skillRead', { name: 'ipc-probe-skill' }), expectData((d) => d.content.includes('ipc-probe-skill')));
+        await check('skillInstallZip 非 zip 字节', () => call('skillInstallZip', { data: new Uint8Array([1, 2, 3, 4]) }), expectFailure(/无法解析 zip/));
+        await check('skillRemove 内置', () => call('skillList', { forceReload: true }).then((rpc) => (rpc.success && rpc.data.find((s) => !s.managed) ? call('skillRemove', { path: rpc.data.find((s) => !s.managed).path }) : rpc)), expectFailure(/内置或暂存目录/));
+        await check('skillRemove', () => call('skillRemove', { name: 'ipc-probe-skill' }), expectData((list) => !list.some((s) => s.name === 'ipc-probe-skill')));
+        await check('删除后 config.toml 无残留条目', async () => {
+          const st = await call('status');
+          const file = st.success ? path.join(st.data.codexHome, 'config.toml') : null;
+          if (!file || !fs.existsSync(file)) return { success: true, data: { note: '无 config.toml' } };
+          const text = fs.readFileSync(file, 'utf8');
+          return text.includes('ipc-probe-skill')
+            ? { success: false, message: 'config.toml 仍留有该 skill 的条目(重装会继承禁用)' }
+            : { success: true, data: { cleaned: true } };
+        });
+
+        await check('probeProvider 不可达端点', () => call('probeProvider', { baseUrl: 'http://127.0.0.1:9/v1' }), expectData((d) => d.compatible === false && d.status === null));
+
+        // 用本地假端点跑一次真实 apply / clear(不触碰任何真实模型密钥)
+        const http = require('node:http');
+        const fake = http.createServer((req, res) => {
+          req.resume();
+          res.writeHead(req.url && req.url.endsWith('/responses') ? 401 : 200, { 'content-type': 'application/json' });
+          res.end('{}');
+        });
+        await new Promise((resolve) => fake.listen(0, '127.0.0.1', resolve));
+        const fakeUrl = `http://127.0.0.1:${fake.address().port}/v1`;
+
+        await check(
+          'applyProvider 真实往返',
+          () => call('applyProvider', { modelId: 'probe-live', baseUrl: fakeUrl, apiKey: 'sk-probe-local', providerType: 'OPENAI', modelRecordId: 'probe-1' }),
+          expectData((d) => d.applied.model === 'probe-live' && d.applied.providerId === 'zsjz' && d.status.running === true && d.status.defaultModel === 'probe-live'),
+        );
+        await check('密钥不落盘(config.toml / provider.json)', async () => {
+          const st = await call('status');
+          if (!st.success) return st;
+          const codexHome = st.data.codexHome;
+          const dataDir = path.dirname(codexHome);
+          const targets = [
+            path.join(codexHome, 'config.toml'),
+            path.join(dataDir, 'codex-data', 'provider.json'),
+          ];
+          for (const file of targets) {
+            if (!fs.existsSync(file)) continue;
+            if (fs.readFileSync(file, 'utf8').includes('sk-probe-local')) {
+              return { success: false, message: `${file} 里出现了密钥` };
+            }
+          }
+          return { success: true, data: { checked: targets.filter((f) => fs.existsSync(f)) } };
+        });
+        await check(
+          'applyProvider 远端 Ollama 不可达时报错(回归)',
+          () => call('applyProvider', { modelId: 'qwen', baseUrl: 'http://10.66.66.66:8080', providerType: 'OLLAMA' }),
+          expectFailure(/不可达|未实现/),
+        );
+        await check('clearProvider', () => call('clearProvider'), expectData((d) => d.providerId === null && d.applied === null));
+        fake.close();
+
+        await check('applyProvider 缺 modelId', () => call('applyProvider', { modelId: ' ' }), expectFailure(/modelId/));
+        await check('turnInterrupt 缺参', () => call('turnInterrupt', {}), expectFailure(/不能为空/));
+        await check('approvalResolve 非法 decision', () => call('approvalResolve', { token: 'x', decision: 'whatever' }), expectFailure(/decision/));
+        await check('eventsRead 非法 threadId', () => call('eventsRead', { threadId: '../etc' }), expectFailure(/不能为空|threadId|非法/));
+        await check('logsTail', () => call('logsTail', { limit: 5 }), expectData((d) => Array.isArray(d)));
+        await check('stop', () => call('stop'), expectData((d) => d.state === 'stopped'));
+
+        fs.writeFileSync(outFile, JSON.stringify({ at: new Date().toISOString(), results }, null, 2), 'utf8');
+        const failed = results.filter((item) => !item.ok);
+        pre.innerHTML += `\n\n${failed.length === 0 ? 'ALL PASS' : 'FAILED ' + failed.length} → ${outFile}`;
+        // 让最后的写入落盘再退出
+        setTimeout(() => process.exit(failed.length === 0 ? 0 : 1), 300);
+      }
+
+      main().catch((error) => {
+        fs.writeFileSync(outFile, JSON.stringify({ error: String(error), results }, null, 2), 'utf8');
+        setTimeout(() => process.exit(1), 100);
+      });
+    </script>
+  </body>
+</html>

+ 92 - 0
ai-electron/scripts/codex-ipc-smoke.mjs

@@ -0,0 +1,92 @@
+import { spawn } from 'node:child_process';
+import fs from 'node:fs';
+import path from 'node:path';
+import { createRequire } from 'node:module';
+import { fileURLToPath } from 'node:url';
+
+/**
+ * 在真实 Electron 里跑 IPC 探针:拉起应用(窗口加载 scripts/codex-ipc-probe.html),
+ * 探针页逐条调用 controller/codexCtl/* 并写出结果,这里汇总打印。
+ *
+ * 用法:npm run smoke:ipc
+ */
+
+const require = createRequire(import.meta.url);
+const here = path.dirname(fileURLToPath(import.meta.url));
+const appDir = path.join(here, '..');
+const probeFile = path.join(here, 'codex-ipc-probe.html');
+const outFile = path.join(here, 'codex-ipc-probe.json');
+const timeoutMs = Number(process.env.ZSJZ_PROBE_TIMEOUT || 180_000);
+
+fs.rmSync(outFile, { force: true });
+
+const electronPath = require('electron');
+const child = spawn(electronPath, ['.', '--env=local'], {
+  cwd: appDir,
+  stdio: 'inherit',
+  env: {
+    ...process.env,
+    ZSJZ_CODEX_PROBE: `file:///${probeFile.replace(/\\/g, '/')}`,
+    ZSJZ_CODEX_PROBE_OUT: outFile,
+  },
+});
+
+let finished = false;
+const timer = setTimeout(() => {
+  if (finished) return;
+  finished = true;
+  clearInterval(poll);
+  console.error(`\n[smoke:ipc] 超时(${timeoutMs / 1000}s)未拿到探针结果,结束应用`);
+  child.kill('SIGKILL');
+  printAndExit(null, '探针超时未完成');
+}, timeoutMs);
+
+/**
+ * 探针页跑完只写结果文件——渲染进程里的 process.exit() 关不掉主进程,
+ * 所以这里轮询结果文件,拿到后再结束应用。
+ */
+const poll = setInterval(() => {
+  if (finished || !fs.existsSync(outFile)) return;
+  setTimeout(() => {
+    if (finished) return;
+    finished = true;
+    clearInterval(poll);
+    clearTimeout(timer);
+    let report = null;
+    try {
+      report = JSON.parse(fs.readFileSync(outFile, 'utf8'));
+    } catch {
+      // 结果文件损坏,按失败处理
+    }
+    child.kill('SIGKILL');
+    printAndExit(report, report ? null : '探针结果文件无法解析');
+  }, 800);
+}, 500);
+
+child.on('exit', (code) => {
+  if (finished) return;
+  finished = true;
+  clearInterval(poll);
+  clearTimeout(timer);
+  let report = null;
+  try {
+    report = JSON.parse(fs.readFileSync(outFile, 'utf8'));
+  } catch {
+    // 探针没跑完就退出了
+  }
+  printAndExit(report, report ? null : `探针未写出结果(electron exit=${code})`);
+});
+
+function printAndExit(report, fatal) {
+  if (!report) {
+    console.error(`[smoke:ipc] ${fatal}`);
+    process.exit(1);
+  }
+  for (const item of report.results) {
+    const detail = JSON.stringify(item.detail ?? '');
+    console.log(`${item.ok ? 'PASS' : 'FAIL'}  ${item.name}  ${detail.slice(0, 160)}`);
+  }
+  const failed = report.results.filter((item) => !item.ok);
+  console.log(`\n[smoke:ipc] ${report.results.length - failed.length}/${report.results.length} 通过`);
+  process.exit(failed.length === 0 ? 0 : 1);
+}

+ 19 - 0
ai-electron/tsconfig.json

@@ -0,0 +1,19 @@
+{
+  "compilerOptions": {
+    "target": "ES2022",
+    "lib": ["ES2022"],
+    "module": "ESNext",
+    "moduleResolution": "bundler",
+    "types": ["node"],
+    "allowJs": true,
+    "checkJs": false,
+    "strict": true,
+    "noEmit": true,
+    "skipLibCheck": true,
+    "esModuleInterop": true,
+    "resolveJsonModule": true,
+    "forceConsistentCasingInFileNames": true
+  },
+  "include": ["electron/**/*.ts", "vitest.config.mts"],
+  "exclude": ["node_modules", "frontend", "public", "dist", "out"]
+}

+ 8 - 0
ai-electron/vitest.config.mts

@@ -0,0 +1,8 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+  test: {
+    // 只跑主进程侧的单测;frontend/ 有自己的一套工程,不在此列
+    include: ['electron/**/*.test.ts'],
+  },
+});

+ 11 - 0
ai-electron/vitest.smoke.mts

@@ -0,0 +1,11 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+  test: {
+    // 集成冒烟:会真实 spawn codex 子进程,不进 npm test
+    include: ['electron/**/*.itest.ts'],
+    testTimeout: 120_000,
+    hookTimeout: 120_000,
+    fileParallelism: false,
+  },
+});