Explorar el Código

fix(codex): 工作目录改为代码决定,修复产物栏「已拒绝访问」

1) 工作空间路径不允许在界面修改:删除 codexWorkspacePicker 与 store 的 cwdOverrides/recentCwds/setWorkspaceDir 等,工作目录优先级简化为「会话 cwd → 案件工作空间 → 客户端默认」,界面只读展示来源。

2) 修复产物栏报错:requireAllowedRoot 原先只认 threadStart/turnRun 登记过的目录,而产物栏在还没有会话时就用案件工作空间查询,必然被拒。改为:绝对路径且真实存在的目录自动补登记并放行(记日志),否则报「工作目录不存在或不可访问」。

验证:pnpm type:check 89 条基线/本包 0 报错;pnpm build exit=0;主进程 tsc --noEmit 0 报错 + npm test 全过 + build-electron exit=0。
cc hace 2 semanas
padre
commit
0a39a6062e

+ 25 - 0
.workbuddy/memory/2026-09-24.md

@@ -183,3 +183,28 @@ HTML 报告里图表是折叠 option、未做行级 diff 染色、未真机 GUI
 - 主仓库那个暂存未提交的 `data.db` 用户明确说「不用管」,推送不受影响(push 只发提交,不发索引)。
 - 本条笔记**刻意没有提交、没有推送**:用户授权的是「推送 dev_3」这一次对外动作,
   为一条笔记再打提交会给他们共享分支添噪音。需要时再补。
+
+## 工作目录改为代码决定 + 修「已拒绝访问」
+
+**用户两条明确要求**:① 工作空间路径由代码设置,**不允许在界面上修改**;② 修掉
+「该目录不是本客户端的会话工作目录,已拒绝访问」。
+
+**报错根因**:`codexArtifactService.requireAllowedRoot` 只接受 `threadStart` / `turnRun`
+登记过的目录,而产物栏在**页面进入时**(还没有会话)就拿 `activeCwd`(= 案件工作空间)
+去查询 → 必然被拒。v2.0.5 让案件工作空间成为默认 cwd 后,这个时序问题从偶发变成必现。
+
+**修法**
+- 主进程:`requireAllowedRoot` 改为「不在白名单但**是绝对路径且真实存在的目录**」→ 补登记并放行
+  (`logger.warn` 记一条),否则报「工作目录不存在或不可访问」。
+  理由:工作目录现在由代码决定,可能的取值只有案件工作空间与客户端默认目录两种;
+  真正防越界的仍是 `resolveInside` 的路径穿越校验。白名单里「只有会话用过才登记」的时序假设不成立。
+- 前端:**删掉整个工作目录修改入口** —— 删除 `codexWorkspacePicker.vue`、store 的
+  `cwdOverrides` / `recentCwds` / `setWorkspaceDir` / `rememberCwd` / `workspaceOverride` /
+  `hasWorkspaceOverride` / 无人读取的 `caseId`,cwd 优先级简化为
+  **会话 cwd → 案件工作空间 → 客户端默认**(纯代码决定);空态工作目录卡片改为只读展示 +
+  「目录由当前案件的工作空间自动决定」提示;composer 去掉「目录」按钮与 workspace emit。
+- `selectDirectory()` 保留在 `codex/api/codexApi.ts`:`ai/views/aiPlugin/index.vue` 还在用(OS 能力,非 codex 专属)。
+
+**验证**:`pnpm type:check` 89 基线 / 本包 0 报错;`pnpm build` exit=0;
+主进程 `npx tsc --noEmit` 0 报错 + `npm test` 全过 + `npm run build-electron` exit=0。
+(产物服务没有单测覆盖 requireAllowedRoot —— 它依赖 Electron 的 dialog/shell。)

+ 34 - 7
ai-electron/electron/service/codex/codexArtifactService.ts

@@ -1,14 +1,15 @@
 import { promises as fs } from 'node:fs';
-import { basename, extname, join, resolve, sep } from 'node:path';
+import { basename, extname, isAbsolute, join, resolve, sep } from 'node:path';
 import { dialog, shell } from 'electron';
+import { logger } from 'ee-core/log';
 import { getCodexDataDir } from './codexHome';
 
 /**
  * 产物(Codex 在会话工作目录里生成的文件)的读写服务。
  *
  * 安全边界:
- *  1. **只有注册过的会话工作目录**能被访问 —— registerArtifactRoot 由 codexCtl 在
- *     threadStart / turnRun 时调用,渲染进程无法凭空传一个路径来读任意文件;
+ *  1. **只允许访问「代码决定的工作目录」** —— 案件工作空间与客户端默认目录。
+ *     白名单支持事后补登记(见 requireAllowedRoot),但仅限绝对路径且真实存在的目录;
  *  2. 所有相对路径都做 resolve + 前缀校验,挡住 `..` 穿越与绝对路径注入。
  *
  * 只做「列目录 / 读内容 / 另存 / 打开」,不提供删除与写入:
@@ -75,13 +76,39 @@ export function registerArtifactRoot(dir: string | null | undefined): string | n
   return resolved;
 }
 
+/** 目录名合法性:必须存在且是目录,才允许进入白名单 */
+async function isUsableDirectory(dir: string): Promise<boolean> {
+  try {
+    const stat = await fs.stat(dir);
+    return stat.isDirectory();
+  } catch {
+    return false;
+  }
+}
+
+/**
+ * 解析并校验访问根目录。
+ *
+ * 白名单**不再要求事先登记**:工作目录是由代码决定的(案件工作空间 / 客户端默认目录),
+ * 而产物栏在「还没建会话」时就要列出案件工作空间的数据文件,
+ * 那时 threadStart 还没执行过、目录自然没登记过 —— 以前就会在这里被拒。
+ *
+ * 因此改成:不在白名单但**是绝对路径且确实存在的目录**时补登记(记一条日志便于排查),
+ * 不满足这两个条件仍然拒绝。真正防越界的还是下面 resolveInside 的路径穿越校验。
+ */
 async function requireAllowedRoot(root: string): Promise<string> {
-  if (!root || !String(root).trim()) throw new Error('缺少会话工作目录');
-  const resolved = resolve(String(root).trim());
+  const raw = String(root ?? '').trim();
+  if (!raw) throw new Error('缺少会话工作目录');
+  const resolved = resolve(raw);
+
   await ensureRootsLoaded();
-  if (!allowedRoots.has(normalizePath(resolved))) {
-    throw new Error('该目录不是本客户端的会话工作目录,已拒绝访问');
+  if (allowedRoots.has(normalizePath(resolved))) return resolved;
+
+  if (!isAbsolute(raw) || !(await isUsableDirectory(resolved))) {
+    throw new Error(`工作目录不存在或不可访问:${resolved}`);
   }
+  registerArtifactRoot(resolved);
+  logger.warn(`[codexArtifact] 补登记工作目录:${resolved}`);
   return resolved;
 }
 

+ 0 - 5
ai-electron/frontend/src/codex/components/codexComposer.vue

@@ -17,10 +17,6 @@
             <Icon icon="mdi:bookmark-multiple-outline" :size="13" />
             模板
           </button>
-          <button class="codex-btn" type="button" title="工作目录" @click="emit('workspace')">
-            <Icon icon="mdi:folder-outline" :size="13" />
-            目录
-          </button>
 
           <CodexRunOptionsPanel />
 
@@ -85,7 +81,6 @@
     (e: 'send', text: string): void;
     (e: 'stop'): void;
     (e: 'templates'): void;
-    (e: 'workspace'): void;
   }>();
 
   const ATextarea = Textarea;

+ 0 - 249
ai-electron/frontend/src/codex/components/codexWorkspacePicker.vue

@@ -1,249 +0,0 @@
-<template>
-  <a-modal
-    :open="open"
-    title="工作目录"
-    wrap-class-name="codex-workspace-modal"
-    :width="560"
-    :footer="null"
-    @cancel="emit('update:open', false)"
-  >
-    <p class="codex-workspace-modal__desc">
-      Codex 会在该目录里取数与生成文件,产物栏也指向它。目录按会话固定,改目录请新建会话。
-    </p>
-
-    <!-- 案件工作空间:进案件后由后端回传,新会话默认走它 -->
-    <div v-if="store.caseWorkspace" class="codex-workspace-modal__current is-case">
-      <Icon icon="mdi:briefcase-outline" :size="14" />
-      <span class="codex-workspace-modal__label">案件工作空间</span>
-      <span class="codex-workspace-modal__path" :title="store.caseWorkspace">{{ store.caseWorkspace }}</span>
-      <span class="codex-workspace-modal__tag">新会话默认</span>
-    </div>
-    <p v-else class="codex-workspace-modal__desc codex-workspace-modal__desc--warn">
-      当前未进入案件,或案件未回传工作空间:新会话将使用下面指定的目录,没指定则用客户端默认目录。
-    </p>
-
-    <div class="codex-workspace-modal__current">
-      <Icon icon="mdi:folder-outline" :size="14" />
-      <span class="codex-workspace-modal__label">手动指定</span>
-      <span class="codex-workspace-modal__path" :title="store.workspaceOverride || '未指定'">
-        {{ store.workspaceOverride || '未指定' }}
-      </span>
-      <a-button size="small" @click="onPick">选择目录…</a-button>
-      <a-button v-if="store.workspaceOverride" size="small" @click="onClearOverride">清除</a-button>
-    </div>
-
-    <div class="codex-workspace-modal__section">最近使用</div>
-
-    <div v-if="!recent.length" class="codex-placeholder">还没有记录。选择一个目录后会出现在这里。</div>
-
-    <ul v-else class="codex-workspace-modal__list">
-      <li v-for="dir in recent" :key="dir" class="codex-workspace-modal__item">
-        <Icon icon="mdi:folder-outline" :size="14" class="codex-workspace-modal__icon" />
-        <span class="codex-workspace-modal__path" :title="dir">{{ dir }}</span>
-        <div class="codex-workspace-modal__actions">
-          <button class="codex-btn" type="button" :disabled="creating" @click="onUse(dir)">用此目录新建会话</button>
-          <button class="codex-btn" type="button" @click="store.removeRecentCwd(dir)">移除</button>
-        </div>
-      </li>
-    </ul>
-
-    <div class="codex-workspace-modal__footer">
-      <a-button size="small" :disabled="creating" @click="onCreateHere">
-        用「{{ effectiveDir }}」新建会话
-      </a-button>
-    </div>
-  </a-modal>
-</template>
-
-<script lang="ts" setup>
-  /**
-   * 工作目录弹窗:查看/切换默认目录,并从最近目录一键开新会话。
-   *
-   * 「最近目录」存 localStorage;切换默认目录只影响**新会话**,
-   * 因为 Codex 的 cwd 在 threadStart 时就固定了(老会话改不了,改了也是撒谎)。
-   */
-  import { computed, ref } from 'vue';
-  import { Button, Modal } from 'ant-design-vue';
-  import Icon from '@/core/components/Icon/src/Icon.vue';
-  import { useMessage } from '@/core/hooks/web/useMessage';
-  import { selectDirectory } from '../api/codexApi';
-  import { useCodexChatStore } from '../store/codexChatStore';
-
-  defineProps<{ open: boolean }>();
-  const emit = defineEmits<{
-    (e: 'update:open', value: boolean): void;
-    (e: 'created'): void;
-  }>();
-
-  const AModal = Modal;
-  const AButton = Button;
-
-  const store = useCodexChatStore();
-  const { showMessage } = useMessage();
-  const creating = ref(false);
-
-  const recent = computed(() => store.recentCwds);
-  /** 新建会话实际会用的目录(与 store 的优先级一致,便于用户核对) */
-  const effectiveDir = computed(() => store.activeCwd || '客户端默认目录');
-
-  async function onPick() {
-    try {
-      const dir = await selectDirectory();
-      if (!dir) return;
-      store.setWorkspaceDir(dir);
-      showMessage('已指定工作目录,新建会话后生效', 'success');
-    } catch (error: any) {
-      showMessage(error?.message || '选择目录失败', 'error');
-    }
-  }
-
-  /** 清掉手动指定,回落到案件工作空间 / 客户端默认目录 */
-  function onClearOverride() {
-    store.setWorkspaceDir('');
-    showMessage('已清除手动指定,新建会话后生效', 'success');
-  }
-
-  /** 用指定目录新建会话(目录不可用时给出明确报错,不静默失败) */
-  async function createWith(dir: string | null) {
-    if (creating.value) return;
-    creating.value = true;
-    try {
-      if (dir) store.setWorkspaceDir(dir);
-      await store.createSession({ cwd: dir ?? undefined });
-      emit('update:open', false);
-      emit('created');
-    } catch (error: any) {
-      showMessage(error?.message || '新建会话失败', 'error');
-    } finally {
-      creating.value = false;
-    }
-  }
-
-  function onUse(dir: string) {
-    createWith(dir);
-  }
-
-  function onCreateHere() {
-    createWith(store.activeCwd || null);
-  }
-</script>
-
-<style lang="less">
-  .codex-workspace-modal__desc {
-    margin: 0 0 var(--codex-sp-3);
-    font-size: 12.5px;
-    line-height: 1.7;
-    color: var(--codex-text-2);
-
-    &--warn {
-      color: var(--codex-warning);
-    }
-  }
-
-  .codex-workspace-modal__current {
-    display: flex;
-    align-items: center;
-    gap: var(--codex-sp-2);
-    padding: var(--codex-sp-2) var(--codex-sp-3);
-    margin-bottom: var(--codex-sp-2);
-    background: var(--codex-bg-subtle);
-    border: 1px solid var(--codex-border);
-    border-radius: var(--codex-radius);
-
-    /* 案件工作空间:强调它是「新会话默认从这儿取」 */
-    &.is-case {
-      background: var(--codex-primary-softer);
-      border-color: fade(@primary-color, 25%);
-    }
-
-    &:last-of-type {
-      margin-bottom: var(--codex-sp-4);
-    }
-  }
-
-  .codex-workspace-modal__tag {
-    flex: 0 0 auto;
-    padding: 0 6px;
-    font-size: 10.5px;
-    line-height: 16px;
-    color: var(--codex-primary);
-    background: var(--codex-primary-soft);
-    border-radius: 8px;
-  }
-
-  .codex-workspace-modal__label {
-    flex: 0 0 auto;
-    font-size: 12px;
-    font-weight: 600;
-    color: var(--codex-text-2);
-  }
-
-  .codex-workspace-modal__path {
-    flex: 1 1 auto;
-    min-width: 0;
-    overflow: hidden;
-    font-size: 12px;
-    color: var(--codex-text);
-    white-space: nowrap;
-    text-overflow: ellipsis;
-    /* 长路径保留尾部(盘符 + 末尾目录)更有辨识度 */
-    direction: rtl;
-    text-align: left;
-  }
-
-  .codex-workspace-modal__section {
-    margin-bottom: var(--codex-sp-2);
-    font-size: 11px;
-    font-weight: 600;
-    letter-spacing: 0.04em;
-    text-transform: uppercase;
-    color: var(--codex-text-2);
-    border-bottom: 1px solid var(--codex-border);
-    padding-bottom: 4px;
-  }
-
-  .codex-workspace-modal__list {
-    display: flex;
-    flex-direction: column;
-    gap: 2px;
-    margin: 0;
-    padding: 0;
-    list-style: none;
-  }
-
-  .codex-workspace-modal__item {
-    display: flex;
-    align-items: center;
-    gap: var(--codex-sp-2);
-    padding: 4px 6px;
-    border-radius: var(--codex-radius-sm);
-
-    &:hover {
-      background: var(--codex-bg-hover);
-    }
-
-    .codex-workspace-modal__actions {
-      display: none;
-      align-items: center;
-      gap: 2px;
-      flex: 0 0 auto;
-    }
-
-    &:hover .codex-workspace-modal__actions {
-      display: flex;
-    }
-  }
-
-  .codex-workspace-modal__icon {
-    flex: 0 0 auto;
-    color: var(--codex-text-2);
-  }
-
-  .codex-workspace-modal__footer {
-    display: flex;
-    justify-content: flex-end;
-    margin-top: var(--codex-sp-4);
-    padding-top: var(--codex-sp-3);
-    border-top: 1px solid var(--codex-border);
-  }
-</style>

+ 5 - 139
ai-electron/frontend/src/codex/store/codexChatStore.ts

@@ -32,14 +32,7 @@ import { codexTextKey } from '../utils/codexVisualRegistry';
 
 const SESSIONS_KEY = 'codex.sessions.v1';
 const ACTIVE_KEY = 'codex.active-thread.v1';
-const CWD_KEY = 'codex.workspace-dir.v1';
-const CWD_OVERRIDE_KEY = 'codex.workspace-dir.v2';
 const PROMPTS_KEY = 'codex.prompts.v1';
-const RECENT_CWD_KEY = 'codex.recent-cwds.v1';
-/** 最近工作目录保留条数 */
-const MAX_RECENT_CWDS = 10;
-/** 无案件时覆盖项的键 */
-const NO_CASE_KEY = 'no-case';
 
 /** 单个会话缓存的事件上限 */
 const MAX_EVENTS_PER_THREAD = 2000;
@@ -143,75 +136,6 @@ function writeSessions(sessions: CodexSessionMeta[]) {
   }
 }
 
-/** 目录去重用的规范化键:Windows 下大小写不敏感 */
-function cwdKey(value: string): string {
-  return /^[A-Za-z]:/.test(value) ? value.toLowerCase() : value;
-}
-
-function readRecentCwds(): string[] {
-  try {
-    const raw = window.localStorage.getItem(RECENT_CWD_KEY);
-    if (!raw) return [];
-    const parsed = JSON.parse(raw);
-    if (!Array.isArray(parsed)) return [];
-    const seen = new Set<string>();
-    const result: string[] = [];
-    for (const item of parsed) {
-      if (typeof item !== 'string' || !item.trim()) continue;
-      const key = cwdKey(item);
-      if (seen.has(key)) continue;
-      seen.add(key);
-      result.push(item);
-    }
-    return result.slice(0, MAX_RECENT_CWDS);
-  } catch {
-    return [];
-  }
-}
-
-function writeRecentCwds(list: string[]) {
-  try {
-    window.localStorage.setItem(RECENT_CWD_KEY, JSON.stringify(list.slice(0, MAX_RECENT_CWDS)));
-  } catch {
-    // 只影响持久化
-  }
-}
-
-/**
- * 用户显式指定的工作目录,**按案件分别记忆**。
- *
- * 为什么不存一个全局值:进案件后工作目录应优先跟着案件工作空间走,
- * 若把用户的手选目录存成全局值,下一个案件就会被上一个案件的目录污染。
- */
-function readCwdOverrides(): Record<string, string> {
-  try {
-    const raw = window.localStorage.getItem(CWD_OVERRIDE_KEY);
-    if (raw) {
-      const parsed = JSON.parse(raw);
-      if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
-        const result: Record<string, string> = {};
-        for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
-          if (typeof value === 'string' && value.trim()) result[key] = value;
-        }
-        return result;
-      }
-    }
-    // 迁移旧版全局值:挂到「无案件」下,避免升级后丢用户选过的目录
-    const legacy = window.localStorage.getItem(CWD_KEY);
-    return legacy ? { [NO_CASE_KEY]: legacy } : {};
-  } catch {
-    return {};
-  }
-}
-
-function writeCwdOverrides(map: Record<string, string>) {
-  try {
-    window.localStorage.setItem(CWD_OVERRIDE_KEY, JSON.stringify(map));
-  } catch {
-    /* 只影响持久化 */
-  }
-}
-
 /** 每个会话最多留存的提问条数 */
 const MAX_PROMPTS_PER_THREAD = 200;
 /** 单条提问落盘上限 */
@@ -274,14 +198,8 @@ export const useCodexChatStore = defineStore('codex-chat', {
     diagnostics: [] as CodexDiagnosticItem[],
     /** 主进程默认工作目录(用户没选目录时用) */
     defaultCwd: '',
-    /** 当前案件 ID(用于按案件区分工作目录覆盖项) */
-    caseId: '',
     /** 当前案件的工作空间(后端 /case/open 回传,见 plat/api/case/caseApi.ts 的 workspacePath) */
     caseWorkspace: '',
-    /** 用户显式指定的工作目录,按案件 key 记 */
-    cwdOverrides: {} as Record<string, string>,
-    /** 最近用过的工作目录(最新在前) */
-    recentCwds: [] as string[],
     /** 新会话继承的默认运行参数(沙箱 / 审批策略) */
     runDefaults: { ...DEFAULT_RUN_OPTIONS } as CodexRunOptions,
     /** 看板:钉住的块(只存内容哈希) */
@@ -330,32 +248,24 @@ export const useCodexChatStore = defineStore('codex-chat', {
       return state.runningThreads.length;
     },
     /**
-     * 当前会话的工作目录。
+     * 当前会话的工作目录(**由代码决定,界面不可改**)。
      *
      * 优先级:**会话自身 cwd(Codex 在 threadStart 时已固定,不可改)**
-     *   → 本案件的显式覆盖(用户手选)
-     *   → 案件工作空间(后端回传,进案件后默认走它)
+     *   → 案件工作空间(后端回传,进案件后就是它)
      *   → 客户端默认目录
      */
     activeCwd(state): string {
       // getter 里只能读 state:activeSession 本身也是 getter,不能相互引用
       const session = state.sessions.find((item) => item.threadId === state.activeThreadId);
       if (session?.cwd) return session.cwd;
-      const override = state.cwdOverrides[state.caseId || NO_CASE_KEY];
-      if (override) return override;
       return state.caseWorkspace || state.defaultCwd;
     },
-    /** 当前工作目录的来源(UI 用来说明「为什么会用这个目录」) */
-    activeCwdSource(state): 'session' | 'override' | 'case' | 'default' {
+    /** 当前工作目录的来源(UI 只读展示,让用户知道「为什么会用这个目录」) */
+    activeCwdSource(state): 'session' | 'case' | 'default' {
       const session = state.sessions.find((item) => item.threadId === state.activeThreadId);
       if (session?.cwd) return 'session';
-      if (state.cwdOverrides[state.caseId || NO_CASE_KEY]) return 'override';
       return state.caseWorkspace ? 'case' : 'default';
     },
-    /** 当前案件下用户显式指定的工作目录(空串 = 没指定) */
-    workspaceOverride(state): string {
-      return state.cwdOverrides[state.caseId || NO_CASE_KEY] || '';
-    },
     /** 最新一轮回合的文件变更(产物栏「本轮变更」用) */
     artifactChanges(state): CodexArtifactChange[] {
       const threadId = state.activeThreadId;
@@ -407,8 +317,6 @@ export const useCodexChatStore = defineStore('codex-chat', {
 
       this.sessions = readSessions();
       this.prompts = readPrompts();
-      this.recentCwds = readRecentCwds();
-      this.cwdOverrides = readCwdOverrides();
       this.runDefaults = readRunDefaults();
       this.board = readBoard();
       this.syncCaseContext();
@@ -475,7 +383,7 @@ export const useCodexChatStore = defineStore('codex-chat', {
     },
 
     /**
-     * 从案件缓存同步「当前案件 + 案件工作空间」。
+     * 从案件缓存同步案件工作空间。
      *
      * 后端在 `/case/open`、`/case/current`、`/case/list` 返回里带 `workspacePath`
      * (见 ai-server `CaseInfoService.fillWorkspacePath`),前端由 `useUserStore` 缓存。
@@ -483,49 +391,10 @@ export const useCodexChatStore = defineStore('codex-chat', {
      */
     syncCaseContext() {
       const caseInfo = useUserStore().getCaseInfo || {};
-      const id = caseInfo?.id ? String(caseInfo.id) : '';
       const workspace = String(caseInfo?.workspacePath || '').trim();
-      if (id !== this.caseId) this.caseId = id;
       if (workspace !== this.caseWorkspace) this.caseWorkspace = workspace;
     },
 
-    /** 该案件是否已有用户显式指定的目录 */
-    hasWorkspaceOverride(): boolean {
-      return !!this.cwdOverrides[this.caseId || NO_CASE_KEY];
-    },
-
-    /**
-     * 显式指定工作目录(只影响**新建**的会话)。
-     *
-     * 按当前案件记忆;传空串表示清除覆盖,回落到案件工作空间 / 客户端默认目录。
-     */
-    setWorkspaceDir(cwd: string) {
-      const key = this.caseId || NO_CASE_KEY;
-      const value = String(cwd ?? '').trim();
-      const next = { ...this.cwdOverrides };
-      if (value) next[key] = value;
-      else delete next[key];
-      this.cwdOverrides = next;
-      writeCwdOverrides(next);
-      if (value) this.rememberCwd(value);
-    },
-
-    /** 记进「最近目录」(去重、最新在前、上限 10) */
-    rememberCwd(cwd: string) {
-      const value = String(cwd ?? '').trim();
-      if (!value) return;
-      const key = cwdKey(value);
-      const rest = this.recentCwds.filter((item) => cwdKey(item) !== key);
-      this.recentCwds = [value, ...rest].slice(0, MAX_RECENT_CWDS);
-      writeRecentCwds(this.recentCwds);
-    },
-
-    removeRecentCwd(cwd: string) {
-      const key = cwdKey(String(cwd ?? ''));
-      this.recentCwds = this.recentCwds.filter((item) => cwdKey(item) !== key);
-      writeRecentCwds(this.recentCwds);
-    },
-
     /** 该会话是否正在跑回合(多会话并行下用于按钮态与侧栏标识) */
     isThreadRunning(threadId: string): boolean {
       return !!threadId && this.runningThreads.includes(threadId);
@@ -749,7 +618,6 @@ export const useCodexChatStore = defineStore('codex-chat', {
       this.activeThreadId = meta.threadId;
       this.events[meta.threadId] = [];
       this.prompts[meta.threadId] = [];
-      this.rememberCwd(meta.cwd);
       this.persistSessions();
       await this.reloadArtifacts();
       return meta;
@@ -911,8 +779,6 @@ export const useCodexChatStore = defineStore('codex-chat', {
     resolveCwd(): string {
       const session = this.sessions.find((item) => item.threadId === this.activeThreadId);
       if (session?.cwd) return session.cwd;
-      const override = this.cwdOverrides[this.caseId || NO_CASE_KEY];
-      if (override) return override;
       return this.caseWorkspace || this.defaultCwd;
     },
 

+ 4 - 38
ai-electron/frontend/src/codex/views/codexAnalysis/codexAnalysis.vue

@@ -50,7 +50,7 @@
                   </a-button>
                 </div>
 
-                <!-- 工作目录:产物与 Codex 的 cwd 都指向它;进案件后默认是案件工作空间 -->
+                <!-- 工作目录:**由代码决定**(案件工作空间 / 客户端默认目录),界面只读展示,不可修改 -->
                 <div class="codex-workspace">
                   <div class="codex-workspace__head">
                     <Icon icon="mdi:folder-outline" :size="14" />
@@ -60,17 +60,9 @@
                   <div class="codex-workspace__path" :title="store.activeCwd || '未设置'">
                     {{ store.activeCwd || '未设置(将使用客户端默认目录)' }}
                   </div>
-                  <a-button size="small" :disabled="store.hasMessages" @click="onPickDirectory">选择目录</a-button>
-                  <a-button
-                    v-if="canResetToCase"
-                    size="small"
-                    :disabled="store.hasMessages"
-                    @click="onResetToCase"
-                  >
-                    用案件目录
-                  </a-button>
-                  <button class="codex-btn" type="button" @click="workspaceOpen = true">最近目录</button>
-                  <span v-if="store.hasMessages" class="codex-workspace__tip">当前会话已开始,目录对新会话生效</span>
+                  <span class="codex-workspace__tip">
+                    目录由当前案件的工作空间自动决定,无需手动选择;新建会话后生效。
+                  </span>
                 </div>
 
                 <ol class="codex-empty__list">
@@ -100,7 +92,6 @@
             @send="onSend"
             @stop="onStop"
             @templates="templatesOpen = true"
-            @workspace="workspaceOpen = true"
             @update:draft="composerDraft = $event"
           />
         </main>
@@ -115,7 +106,6 @@
     <CodexApprovalDialog />
     <CodexDiagnosticDrawer v-model:open="diagnosticsOpen" />
     <CodexTemplatePanel v-model:open="templatesOpen" :draft="composerDraft" @pick="onPickTemplate" />
-    <CodexWorkspacePicker v-model:open="workspaceOpen" @created="onCreate" />
     <CodexSnapshotPanel v-model:open="snapshotOpen" />
   </div>
 </template>
@@ -133,7 +123,6 @@
   import Icon from '@/core/components/Icon/src/Icon.vue';
   import { useMessage } from '@/core/hooks/web/useMessage';
   import { useUserStore } from '@/core/store/modules/case';
-  import { selectDirectory } from '../../api/codexApi';
   import { useCodexChatStore } from '../../store/codexChatStore';
   import type { CodexReportFormat } from '../../types/codexTypes';
   import {
@@ -150,7 +139,6 @@
   import CodexApprovalDialog from '../../components/codexApprovalDialog.vue';
   import CodexDiagnosticDrawer from '../../components/codexDiagnosticDrawer.vue';
   import CodexTemplatePanel from '../../components/codexTemplatePanel.vue';
-  import CodexWorkspacePicker from '../../components/codexWorkspacePicker.vue';
   import CodexBoardPanel from '../../components/codexBoardPanel.vue';
   import CodexSnapshotPanel from '../../components/codexSnapshotPanel.vue';
 
@@ -164,7 +152,6 @@
   const listRef = ref<InstanceType<typeof CodexMessageList>>();
   const diagnosticsOpen = ref(false);
   const templatesOpen = ref(false);
-  const workspaceOpen = ref(false);
   const snapshotOpen = ref(false);
   const starting = ref(false);
   /** 输入框草稿:模板抽屉要拿它做「存为模板」 */
@@ -197,16 +184,12 @@
     switch (cwdSource.value) {
       case 'session':
         return '当前会话';
-      case 'override':
-        return '手动指定';
       case 'case':
         return caseName.value ? `案件工作空间 · ${caseName.value}` : '案件工作空间';
       default:
         return '客户端默认';
     }
   });
-  /** 有案件工作空间、且当前被手动覆盖时,才给「用案件目录」的退路 */
-  const canResetToCase = computed(() => cwdSource.value === 'override' && !!store.caseWorkspace);
 
   const gateHint = computed(() => {
     if (store.runtimeError) return store.runtimeError;
@@ -252,23 +235,6 @@
     }
   }
 
-  async function onPickDirectory() {
-    try {
-      const dir = await selectDirectory();
-      if (!dir) return;
-      store.setWorkspaceDir(dir);
-      showMessage('已指定工作目录,新建会话后生效', 'success');
-    } catch (error: any) {
-      showMessage(error?.message || '选择目录失败', 'error');
-    }
-  }
-
-  /** 清掉手动指定,回落到案件工作空间 */
-  function onResetToCase() {
-    store.setWorkspaceDir('');
-    showMessage('已回落到案件工作空间,新建会话后生效', 'success');
-  }
-
   async function onSend(text: string) {
     try {
       await store.send(text);