From 01c9d30eeefc4e925c36684218641a966e70bb72 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 00:31:07 +0200 Subject: [PATCH 1/5] fix(mcp): write team MCP servers to Codex's project config.toml (#954) Codex reads mcp_servers from /.codex/config.toml once the project is trusted. #252 left the codex entry without mcpProject on the assumption that Codex has no project-scope MCP, so a project pull wrote nothing for it. The reconcile, ownership and git-exclude paths already handle a Codex project file; only the built-in mapping was missing. --- src/__tests__/mcp-reconcile.test.ts | 31 +++++++++++++++++++++++++++-- src/__tests__/tool-roots.test.ts | 2 ++ src/mcp-reconcile.ts | 4 ++-- src/types.ts | 2 ++ 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/__tests__/mcp-reconcile.test.ts b/src/__tests__/mcp-reconcile.test.ts index 19145eb61..ef75c6271 100644 --- a/src/__tests__/mcp-reconcile.test.ts +++ b/src/__tests__/mcp-reconcile.test.ts @@ -795,8 +795,9 @@ servers: expect(byTool.claude).toBe(path.join(projectRoot, '.mcp.json')); expect(byTool.cursor).toBe(path.join(projectRoot, '.cursor', 'mcp.json')); expect(byTool.codebuddy).toBe(path.join(projectRoot, '.codebuddy', 'mcp.json')); - // No project-scope MCP support: codex has no such concept, and tclaude reads - // the /.mcp.json that the claude target already writes. + // No `mcpProject` in this map means no project target: codex here is + // mapped without one, and tclaude reads the /.mcp.json that the + // claude target already writes. expect(byTool.codex).toBeUndefined(); expect(byTool.tclaude).toBeUndefined(); @@ -850,6 +851,32 @@ servers: expect(await fse.readJson(userFile)).toEqual(personal); }); + // #954: Codex reads /.codex/config.toml once the project is trusted. + it('writes team servers to the Codex project config by default, and removes only its own block', async () => { + const projectRoot = path.join(tmpDir, 'codex-project'); + await fse.ensureDir(path.join(projectRoot, '.codex', 'skills')); + const projectFile = path.join(projectRoot, '.codex', 'config.toml'); + const own = '# project settings\nmodel = "gpt-5"\n'; + await fse.writeFile(projectFile, own); + const defaults = TeamaiConfigSchema.parse({ team: 't', repo: 'r', provider: 'git' }); + const projectConfig: LocalConfig = { ...localConfig, scope: 'project', projectRoot }; + await writeMcpYaml('servers:\n - name: team-docs\n transport: stdio\n command: docs-server\n'); + + const targets = await resolveMcpTargets(defaults, projectConfig); + expect(targets.find((target) => target.tool === 'codex')?.file).toBe(projectFile); + + await reconcileMcpForConfig(defaults, projectConfig); + const written = await fse.readFile(projectFile, 'utf-8'); + expect(written.startsWith(own)).toBe(true); + expect(codexServerNames(written)).toEqual(['team-docs']); + expect(await fse.pathExists(path.join(homeDir, '.codex', 'config.toml'))).toBe(false); + + await writeMcpYaml('servers: []\n'); + await reconcileMcpForConfig(defaults, projectConfig); + expect(codexServerNames(await fse.readFile(projectFile, 'utf-8'))).toEqual([]); + expect(await fse.readFile(projectFile, 'utf-8')).toContain(own.trimEnd()); + }); + it('resolves a project secret to plaintext in every tool, keyed off `type`', async () => { const projectRoot = path.join(tmpDir, 'proj2'); for (const d of ['.claude', '.cursor', '.codebuddy']) { diff --git a/src/__tests__/tool-roots.test.ts b/src/__tests__/tool-roots.test.ts index 31ebded2f..b3a0d7a0b 100644 --- a/src/__tests__/tool-roots.test.ts +++ b/src/__tests__/tool-roots.test.ts @@ -86,6 +86,8 @@ describe('toolRoots — re-rooting a relocated tool', () => { claudemd: '.codex-alt/AGENTS.md', agents: '.codex-alt/agents', mcp: '.codex-alt/config.toml', + // A project-scope path hangs off the project root, which no member root moves. + mcpProject: '.codex/config.toml', userScope: { claudemd: '.codex-alt/AGENTS.md' }, }); expect(paths.claude).toEqual(teamConfig.toolPaths.claude); diff --git a/src/mcp-reconcile.ts b/src/mcp-reconcile.ts index 507a035f1..8c5f91c5d 100644 --- a/src/mcp-reconcile.ts +++ b/src/mcp-reconcile.ts @@ -301,8 +301,8 @@ export async function resolveMcpTargets( // No fallback between scopes: a tool's project-scope location is a // different thing from its user-scope one, not a default for it. Absent - // `mcpProject` means the tool has no project-scope MCP support (codex), or - // is already covered by a sibling target writing the shared file (tclaude + // `mcpProject` means the tool has no project-scope MCP support, or is + // already covered by a sibling target writing the shared file (tclaude // reads the /.mcp.json that `claude` writes). const rel = projectScope ? paths.mcpProject : paths.mcp; if (!rel) continue; diff --git a/src/types.ts b/src/types.ts index 9dcc42884..6944c8240 100644 --- a/src/types.ts +++ b/src/types.ts @@ -337,11 +337,13 @@ export const TeamaiConfigSchema = z.object({ // which only Codex reads. In project scope they come from the session-start // hook, since the project AGENTS.md is the owners' file and other tools // read it too (#938, #945); so the entry has no project `claudemd`. + // Codex reads the project MCP config only in a trusted project (#954). codex: { skills: '.codex/skills', settings: '.codex/hooks.json', agents: '.codex/agents', mcp: '.codex/config.toml', + mcpProject: '.codex/config.toml', userScope: { claudemd: '.codex/AGENTS.md' }, }, // codex-internal and tcodex run the same Codex from their own home root, so From 0205676c376e240e2274858bd557b06c013b9bc4 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 00:34:54 +0200 Subject: [PATCH 2/5] feat(doctor): name an untrusted Codex project that holds team MCP servers (#954) Codex loads /.codex/config.toml only in a trusted project and skips an untrusted one without a word, so delivered team servers sit inert. Doctor now reads the projects table of the Codex user config (honoring toolRoots), takes the first entry for the checkout or its main checkout by real path, as Codex does, and fails with the manual fix. Read-only: it never writes trust. --- src/__tests__/doctor-mcp-delivery.test.ts | 104 ++++++++++++++++++++++ src/doctor-delivery.ts | 101 ++++++++++++++++++++- src/doctor.ts | 2 + 3 files changed, 206 insertions(+), 1 deletion(-) diff --git a/src/__tests__/doctor-mcp-delivery.test.ts b/src/__tests__/doctor-mcp-delivery.test.ts index 12c827b6b..2e4252ccd 100644 --- a/src/__tests__/doctor-mcp-delivery.test.ts +++ b/src/__tests__/doctor-mcp-delivery.test.ts @@ -876,4 +876,108 @@ describe('doctor — MCP servers delivered on disk', () => { }); }); }); + + // Codex loads a project's .codex/config.toml only in a trusted project, by + // the first `projects` entry for the checkout or its main checkout (#954). + describe('Codex project trust for team MCP servers (#954)', () => { + const NAME = 'Codex trusts this project, so it loads its team MCP servers'; + let projectRoot: string; + + async function useCheckout(root: string): Promise { + projectRoot = root; + Object.assign(localConfig, { scope: 'project', projectRoot: root }); + await fse.outputFile(path.join(root, '.codex', 'config.toml'), '[mcp_servers.docs]\ncommand = "docs-server"\nargs = []\n'); + await fse.ensureDir(path.join(root, '.codex', 'skills')); + await fse.outputJson(managedMcpManifestPath(getDataHome(localConfig), root), { + [managedMcpManifestKey('codex', true)]: [{ name: 'docs', hash: 'h' }], + }); + } + + async function trustCheck(): Promise { + return (await checks()).find((c) => c.name === NAME); + } + + async function writeCodexConfig(toml: string, root = '.codex'): Promise { + const file = path.join(homeDir, root, 'config.toml'); + await fse.outputFile(file, toml); + return file; + } + + const trusted = (dir: string, level = 'trusted'): string => `[projects.${JSON.stringify(dir)}]\ntrust_level = "${level}"\n`; + + beforeEach(async () => { + const root = path.join(tempDir, 'codex-repo'); + await fse.ensureDir(root); + execFileSync('git', ['init', '-q'], { cwd: root }); + teamConfig.toolPaths = { codex: { skills: '.codex/skills', mcp: '.codex/config.toml', mcpProject: '.codex/config.toml' } }; + await useCheckout(root); + }); + + it('fails while Codex has no entry for the project, and gives the lines that trust it', async () => { + const codexConfig = await writeCodexConfig('model = "gpt-5"\n'); + const real = await fse.realpath(projectRoot); + + const check = await trustCheck(); + if (!check) throw new Error('no Codex trust check'); + expect(await check.check()).toBe(false); + expect(check.fix).toContain(path.join(projectRoot, '.codex', 'config.toml')); + expect(check.fix).toContain('docs'); + expect(check.fix).toContain(codexConfig); + expect(check.fix).toContain(`[projects.${JSON.stringify(real)}] trust_level = "trusted"`); + }); + + it('passes once Codex trusts the project by its real path', async () => { + await writeCodexConfig(trusted(await fse.realpath(projectRoot))); + + expect(await (await trustCheck())?.check()).toBe(true); + }); + + it('fails when Codex marks the project untrusted, and says which entry decides', async () => { + const real = await fse.realpath(projectRoot); + await writeCodexConfig(trusted(real, 'untrusted')); + + const check = await trustCheck(); + expect(await check?.check()).toBe(false); + expect(check?.fix).toContain(`[projects.${JSON.stringify(real)}]`); + expect(check?.fix).toContain('"untrusted"'); + }); + + it('passes in a linked worktree once Codex trusts the main checkout', async () => { + const main = projectRoot; + const git = (...args: string[]): void => { + execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', '-c', 'commit.gpgsign=false', ...args], { cwd: main }); + }; + git('commit', '-q', '--allow-empty', '-m', 'init'); + const worktree = path.join(tempDir, 'codex-wt'); + git('worktree', 'add', '-q', worktree); + await useCheckout(worktree); + await writeCodexConfig(trusted(await fse.realpath(main))); + + expect(await (await trustCheck())?.check()).toBe(true); + }); + + it('reads the Codex config of the recorded CODEX_HOME root', async () => { + Object.assign(localConfig, { toolRoots: { codex: path.join(homeDir, '.codex-alt') } }); + await writeCodexConfig(trusted(await fse.realpath(projectRoot))); + const altConfig = await writeCodexConfig('', '.codex-alt'); + + const check = await trustCheck(); + expect(await check?.check()).toBe(false); + expect(check?.fix).toContain(altConfig); + }); + + it('fails with the parse error when the Codex config does not parse', async () => { + const codexConfig = await writeCodexConfig('[projects\n'); + + const check = await trustCheck(); + expect(await check?.check()).toBe(false); + expect(check?.fix).toContain(`${codexConfig} could not be parsed`); + }); + + it('is not built while the project config holds no team server', async () => { + await fse.outputJson(managedMcpManifestPath(getDataHome(localConfig), projectRoot), {}); + + expect(await trustCheck()).toBeUndefined(); + }); + }); }); diff --git a/src/doctor-delivery.ts b/src/doctor-delivery.ts index aade871d3..4ededd5e8 100644 --- a/src/doctor-delivery.ts +++ b/src/doctor-delivery.ts @@ -2,7 +2,10 @@ import path from 'node:path'; import fs from 'node:fs'; import { isDeepStrictEqual } from 'node:util'; import { expandHome, listFilesRecursive, pathExists, readFileSafe } from './utils/fs.js'; -import { getDataHome, getMcpSharing, isAgentExcluded, managedMcpManifestKey, resolveToolBaseDir, scopedToolPaths } from './types.js'; +import { + CODEX_TOOL_ID, DEFAULT_CODEX_ROOT, getDataHome, getMcpSharing, isAgentExcluded, managedMcpManifestKey, resolveToolBaseDir, + resolveToolRootDir, scopedToolPaths, +} from './types.js'; import type { DeliveryTarget, LocalConfig, ManagedMcpManifest, ResourceItem, TeamaiConfig } from './types.js'; import type { EntryLayout, EntryResolution } from './namespaced-entries.js'; import { splitFrontmatter } from './utils/frontmatter.js'; @@ -646,6 +649,102 @@ export async function buildMcpDeliveryChecks(ctx: DoctorContext): Promise"` entry it finds for the checkout, + * then for the main checkout of its repository, each keyed by real path. + * `dirs` lists them in that order. + */ +async function codexProjectTrust(configFile: string, dirs: string[]): Promise { + if (!await pathExists(configFile)) return { kind: 'untrusted' }; + const raw = await readFileSafe(configFile); + if (raw === null) return { kind: 'unreadable', reason: 'it could not be read' }; + let projects: unknown; + try { + const { parse } = await import('smol-toml'); + projects = parse(raw).projects; + } catch (error) { + return { kind: 'unreadable', reason: error instanceof Error ? error.message.split('\n')[0] : String(error) }; + } + if (typeof projects !== 'object' || projects === null) return { kind: 'untrusted' }; + for (const dir of dirs) { + const entry = (projects as Record)[dir]; + if (typeof entry !== 'object' || entry === null) continue; + const level = String((entry as { trust_level?: unknown }).trust_level); + return level === 'trusted' ? { kind: 'trusted' } : { kind: 'untrusted', entry: { dir, level } }; + } + return { kind: 'untrusted' }; +} + +/** + * Codex loads a project's `.codex/config.toml` only in a trusted project, and + * skips an untrusted one silently (#954), so team servers a pull or + * `teamai mcp inject` wrote there are on disk and inert. Built while that file + * holds a server this worktree's `managed-mcp.json` records for Codex. + * Read-only: teamai never writes Codex's trust. + */ +export async function buildCodexProjectTrustCheck(ctx: DoctorContext): Promise { + const { localConfig, teamConfig } = ctx; + const { projectRoot } = localConfig; + if (!teamConfig || localConfig.scope !== 'project' || !projectRoot) return []; + + const { resolveMcpTargets, mcpTargetExcluded, installedMcpEntries } = await import('./mcp-reconcile.js'); + const { isCodexTrustGatedTool } = await import('./hooks.js'); + const targets = (await resolveMcpTargets(teamConfig, localConfig)) + .filter((target) => isCodexTrustGatedTool(target.tool) && !mcpTargetExcluded(localConfig, target)); + if (targets.length === 0) return []; + + const { loadProjectMcpManifest } = await import('./utils/mcp-manifest.js'); + const { resolveAnchors } = await import('./utils/git.js'); + const { realFilePath } = await import('./mcp-git-exclude.js'); + const { manifest } = await loadProjectMcpManifest(getDataHome(localConfig), projectRoot, { dryRun: true }); + const anchors = await resolveAnchors(projectRoot); + const checkout = anchors?.workspaceRoot ?? await realFilePath(projectRoot); + const main = anchors?.projectAnchor ?? checkout; + const dirs = [...new Set([await realFilePath(projectRoot), checkout, main])]; + const configFile = path.join(resolveToolRootDir(CODEX_TOOL_ID, DEFAULT_CODEX_ROOT, localConfig.toolRoots), 'config.toml'); + + const checks: Check[] = []; + for (const target of targets) { + const installed = await installedMcpEntries(target); + const names = (manifest[managedMcpManifestKey(target.tool, true)] ?? []) + .map((record) => record.name) + .filter((name) => installed?.has(name)); + if (names.length === 0) continue; + + const trust = await codexProjectTrust(configFile, dirs); + const held = `${target.file} holds team MCP servers (${nameList(names)}), but Codex loads a project's ` + + '.codex/config.toml only in a trusted project'; + const table = (dir: string): string => `[projects.${JSON.stringify(dir)}]`; + let fix = ''; + if (trust.kind === 'unreadable') { + fix = `${held}, and ${configFile} could not be parsed (${trust.reason}), so whether Codex trusts ${main} ` + + `is unknown. Fix ${configFile}, then run \`teamai doctor\` again.`; + } else if (trust.kind === 'untrusted' && trust.entry) { + fix = `${held}, and ${configFile} sets trust_level = ${JSON.stringify(trust.entry.level)} in ` + + `${table(trust.entry.dir)}, the entry Codex reads for this checkout. Set it to "trusted", ` + + 'or trust the project when Codex asks.'; + } else if (trust.kind === 'untrusted') { + fix = `${held}, and ${configFile} does not trust ${main}. Open Codex in ${main} and trust the project ` + + `when it asks, or add these two lines to ${configFile}: ${table(main)} trust_level = "trusted". ` + + 'Trusting the main checkout covers every worktree of it.'; + } + checks.push({ + name: 'Codex trusts this project, so it loads its team MCP servers', + source: 'local', + check: async () => trust.kind === 'trusted', + fix, + }); + } + return checks; +} + /** * A project MCP config holding a resolved `${VAR}` that git would commit * (#882). Pull lists such a file in `.git/info/exclude`; this is the standing diff --git a/src/doctor.ts b/src/doctor.ts index a75c24e0e..3b348e74c 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -29,6 +29,7 @@ import { buildInstructionDeliveryChecks, buildNamespaceNotes, buildMcpDeliveryChecks, + buildCodexProjectTrustCheck, buildMcpGitExcludeCheck, buildEnvDeliveryCheck, buildEntryResolutionChecks, @@ -526,6 +527,7 @@ export async function buildChecks(ctx: DoctorContext, stage: CheckStage = 'docto ...(stage === 'doctor' ? await buildInstructionDeliveryChecks(ctx) : []), ...await buildAgentModelChecks(ctx, stage), ...await buildMcpDeliveryChecks(ctx), + ...await buildCodexProjectTrustCheck(ctx), ...await buildMcpGitExcludeCheck(ctx), ...await buildDocsCheck(ctx), ...await buildEnvDeliveryCheck(ctx), From 9973c888464bbd8544d3f6334a06f7a869894563 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 00:35:53 +0200 Subject: [PATCH 3/5] docs(mcp): Codex project MCP config and its trust requirement (#954) --- docs/usage-guide.md | 8 ++++++-- docs/usage-guide.zh-CN.md | 8 ++++++-- skill-data/core/references/troubleshooting.md | 5 +++++ src/__tests__/e2e/project-scoped-delivery.test.ts | 12 +++++------- 4 files changed, 22 insertions(+), 11 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index b354d651a..10db9826d 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -964,7 +964,7 @@ Most tools get one file per rule in their rules directory. Codex, `codex-interna The culture, shared-instructions and recall blocks follow the same split. In user scope they go to that same `AGENTS.md`, and your own content outside the markers is kept. In a project the session-start hook adds them with the rules, and `pull` leaves the project `AGENTS.md` unchanged. -> A `toolPaths` in the team `teamai.yaml` replaces the built-in defaults whole. A team that sets it should give each Codex-family entry `userScope.claudemd: .codex/AGENTS.md` (`.codex-internal/…`, `.tcodex/…`) for the user-scope rules and blocks, and drop its `rules` path, since Codex never reads that directory. A top-level `claudemd` would put the blocks back in the project `AGENTS.md`, so leave it out. In a project the hook needs only the entry's `settings` path, where it is installed. +> A `toolPaths` in the team `teamai.yaml` replaces the built-in defaults whole. A team that sets it should give each Codex-family entry `userScope.claudemd: .codex/AGENTS.md` (`.codex-internal/…`, `.tcodex/…`) for the user-scope rules and blocks, and drop its `rules` path, since Codex never reads that directory. A top-level `claudemd` would put the blocks back in the project `AGENTS.md`, so leave it out. In a project the hook needs only the entry's `settings` path, where it is installed. The `codex` entry also needs `mcpProject: .codex/config.toml` for the project's team MCP servers. > Upgrading from a release that copied rules to `.codex/rules/`: the next `pull` removes the `.md` copies teamai delivered there, including `teamai-recall.md`. Cleanup follows the recorded `toolRoots` location and checks both a publisher's bare local filename and its namespaced copy. A copy you edited is kept and named in a warning, and the `*.rules` files are never touched. A copy of a rule the team has since removed is deleted only if it matches its recorded delivery hash; without that record, it is kept and named too. The same pull adds `additionalContextLimit: 0` and a `SubagentStart` entry to the teamai hooks in `hooks.json`, so the public Codex asks you once to approve the changed hooks. @@ -1249,7 +1249,7 @@ Where each tool's servers land: | codebuddy | `~/.codebuddy/mcp.json` | `/.mcp.json` | | workbuddy | `~/.workbuddy/mcp.json` | `/.workbuddy/mcp.json` | | copilot | `$COPILOT_HOME/mcp-config.json` | `/.github/mcp.json` | -| codex | `~/.codex/config.toml` | not supported | +| codex | `~/.codex/config.toml` | `/.codex/config.toml` | | qoder | `~/.qoder/settings.json` | `/.qoder/settings.json` | | qoder-cn | `~/.qoder-cn/settings.json` | `/.qoder/settings.json` | | kiro | `~/.kiro/settings/mcp.json` | `/.kiro/settings/mcp.json` | @@ -1257,6 +1257,8 @@ Where each tool's servers land: | omp | `~/.omp/agent/mcp.json` | `/.omp/mcp.json` | | pi | `~/.pi/agent/mcp.json` | `/.pi/mcp.json` | +Codex reads `/.codex/config.toml` only in a trusted project. Trust the project when Codex asks, or add a `[projects."
"]` table with `trust_level = "trusted"` to `~/.codex/config.toml`; trusting the main checkout covers every worktree of the repository. `teamai doctor` reports an untrusted project whose file holds team servers. + CodeBuddy Code's [MCP documentation](https://www.codebuddy.ai/docs/cli/mcp) lists the project root's `.mcp.json` as its preferred project configuration. @@ -2308,6 +2310,8 @@ Three tools do not read a rules directory, so a per-file check cannot speak for `MCP servers delivered to ` compares each server the team's `mcp.yaml` resolves for that tool against the entry in the tool's own config, and names any the reconcile skipped with its reason. The comparison is the entry, not the name: reconciliation leaves an entry teamai does not own alone, so a server of your own under a team name holds the key while the team's definition never arrives, and a stale copy is just as undelivered. Both are reported as `not the team's definition`, and only `teamai pull --force` replaces an entry teamai did not write. An unresolved `${VAR}` is reported here with the variable's name, which is otherwise said once during a pull and never again. A declared secret with no value is not a failure: doctor prints it as a note (`notes` in `--json`) with the command that sets it, and the exit code stays as it would be without it; a note also says when an entry kept for it may hold an old value, and when a key is declared as a secret and also set in `env.yaml`. An `mcp.yaml` that does not parse is not a team without MCP: it is reported as `Team MCP servers can be read` with the parse error, since it injects nothing into any tool and every run after the first is silent about it. Team hooks and team model profiles that cannot be resolved (a file that does not parse, a name defined twice in one file, or one name in two active namespaces) fail `Team hooks can be resolved` and `Team model profiles can be resolved` with the reason pull logs once; `teamai status` points here when it counts them as 0. `Env variables injected in shell profile` no longer stops at finding the marker comment: it checks that `env/env.yaml` parses and declares its variables under the `variables:` key (a plain `KEY: value` mapping parses as none, while an explicit `variables: []` is a configuration with nothing to deliver and fails nothing), that each one reached `env.sh` with the value `env.yaml` declares, or your value for this team (one set with `--from-env` is not written there) — a key left over from an older value exports it to every shell and MCP server until the next pull, and the comparison reads `env.sh` back through the generator's own inverse, so a multiline value quoted across several lines is matched rather than called stale — and that this scope's injected block (the one sourcing its own `env.sh`, since a profile can also carry another scope's) would actually load it — an unquoted Windows path degrades to something a POSIX shell cannot read, so `source` never runs and nothing says so. `No stale env blocks left behind` is a separate check: which file `pull` prefers has changed over time (Windows Git Bash's login shell reads `.bash_profile`/`.bash_login`/`.profile`, never `.bashrc`), and a pull only ever adds a block, never migrates an old one away, so a dead block from an earlier install or platform change can sit in another candidate file indefinitely. It names every such file (checking `.zshrc`, `.bashrc`, `.bash_profile`, `.bash_login` and `.profile`, current and legacy spellings alike) and points at `teamai uninstall` to remove them — separately from delivery, so a working env block never reads as broken just because an old one is still lying around. +`Codex trusts this project, so it loads its team MCP servers` is built in project scope while the project's `.codex/config.toml` holds a server this worktree's `managed-mcp.json` records for Codex: Codex loads that file only in a trusted project, and skips an untrusted one without saying so. It reads the `projects` table of the Codex user config (`~/.codex/config.toml`, or the one under `toolRoots.codex`) as Codex does, taking the first `projects.""` entry for the checkout, then for its main checkout, each by real path (`/private/tmp/...`, not `/tmp/...`). It fails, naming the file and its servers, until that entry sets `trust_level = "trusted"`. Trust the project when Codex asks, or add the entry for the main checkout yourself, which covers every worktree. doctor only reads that file. + `Contributed learnings are published` fails while `teamai contribute` has notes queued that could not be pushed. A manual `teamai pull` does not repeat it at the end when the pull has already said it: the pull tries to publish the queue and reports the outcome itself, with the push error that made it fail — more than this check can tell you. If the pull never got that far, because the team repo failed to refresh, the check is printed as usual. `--json` prints the same report as one object on stdout and routes every log line to stderr, so `teamai doctor --json 2>/dev/null` parses whole. The exit code is unchanged. Each check carries the fix suggestion it prints in human mode: diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 6e6d2f3ad..971267b32 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -885,7 +885,7 @@ teamai push culture、共享指令和 recall 区块采用同样的划分。user scope 下它们写入同一个 `AGENTS.md`,标记之外你自己的内容保持不变。在项目中,session-start hook 把它们与 rule 一起加入会话,`pull` 不改动项目 `AGENTS.md`。 -> 团队 `teamai.yaml` 中的 `toolPaths` 会整体替换内置默认值。设置了它的团队应为每个 Codex 系条目加上 `userScope.claudemd: .codex/AGENTS.md`(`.codex-internal/…`、`.tcodex/…`),用于 user scope 的 rule 和区块,并去掉其 `rules` 路径,因为 Codex 从不读取该目录。顶层的 `claudemd` 会把区块重新写进项目 `AGENTS.md`,所以不要设置。在项目中,hook 只需要该条目的 `settings` 路径,它安装在那里。 +> 团队 `teamai.yaml` 中的 `toolPaths` 会整体替换内置默认值。设置了它的团队应为每个 Codex 系条目加上 `userScope.claudemd: .codex/AGENTS.md`(`.codex-internal/…`、`.tcodex/…`),用于 user scope 的 rule 和区块,并去掉其 `rules` 路径,因为 Codex 从不读取该目录。顶层的 `claudemd` 会把区块重新写进项目 `AGENTS.md`,所以不要设置。在项目中,hook 只需要该条目的 `settings` 路径,它安装在那里。`codex` 条目还需要 `mcpProject: .codex/config.toml`,项目的团队 MCP server 才会写入。 > 从把 rule 复制到 `.codex/rules/` 的旧版本升级后,下一次 `pull` 会删除 teamai 投递到那里的 `.md` 副本,包括 `teamai-recall.md`。清理使用记录的 `toolRoots` 位置,同时检查发布者本地的无命名空间文件名及命名空间副本。你改过的副本会保留,并在警告中点名;`*.rules` 文件从不改动。团队此后已删除的 rule,其副本只有与记录的投递哈希一致时才会删除;没有该记录时也会保留并点名。同一次 pull 会为 `hooks.json` 中的 teamai hook 加上 `additionalContextLimit: 0` 和一个 `SubagentStart` 条目,因此公开版 Codex 会请你批准一次改动后的 hook。 @@ -1127,7 +1127,7 @@ namespace 文件;只有当根文件未定义、而多个 namespace 文件都 | codebuddy | `~/.codebuddy/mcp.json` | `/.mcp.json` | | workbuddy | `~/.workbuddy/mcp.json` | `/.workbuddy/mcp.json` | | copilot | `$COPILOT_HOME/mcp-config.json` | `/.github/mcp.json` | -| codex | `~/.codex/config.toml` | 不支持 | +| codex | `~/.codex/config.toml` | `/.codex/config.toml` | | qoder | `~/.qoder/settings.json` | `/.qoder/settings.json` | | qoder-cn | `~/.qoder-cn/settings.json` | `/.qoder/settings.json` | | kiro | `~/.kiro/settings/mcp.json` | `/.kiro/settings/mcp.json` | @@ -1135,6 +1135,8 @@ namespace 文件;只有当根文件未定义、而多个 namespace 文件都 | omp | `~/.omp/agent/mcp.json` | `/.omp/mcp.json` | | pi | `~/.pi/agent/mcp.json` | `/.pi/mcp.json` | +Codex 只在受信任的项目中读取 `/.codex/config.toml`。请在 Codex 询问时信任该项目,或在 `~/.codex/config.toml` 中加入 `[projects."<主 checkout 的真实路径>"]` 表并设置 `trust_level = "trusted"`;信任主 checkout 即覆盖该仓库的所有 worktree。项目未受信任、而其文件含有团队 server 时,`teamai doctor` 会报告。 + CodeBuddy Code 的 [MCP 文档](https://www.codebuddy.cn/docs/cli/mcp) 明确将项目根目录的 `.mcp.json` 列为首选项目配置。 @@ -2171,6 +2173,8 @@ teamai remove rules --force # 跳过确认,用于脚本和 CI `MCP servers delivered to ` 将团队 `mcp.yaml` 为该工具解析出的每个 server 与该工具自己配置文件中的条目逐一比对,并列出 reconcile 跳过的 server 及原因。比对的是条目内容而非名字:reconcile 不会覆盖不属于 teamai 的条目,因此你自己写的同名 server 会占住这个名字,团队的定义从未真正送达;过期的旧副本同样等于没送达。两者都报告为 `not the team's definition`,而覆盖非 teamai 写入的条目只有 `teamai pull --force` 能做到。未解析的 `${VAR}` 会在这里连同变量名一起报告——否则它只在 pull 时出现一次,之后再无提示。没有值的已声明密钥不算失败:doctor 把它作为备注打印(`--json` 中的 `notes`),并附上设置它的命令,退出码与没有它时相同;备注还会说明为它保留的条目可能含有旧值,以及某个 key 既声明为密钥、又在 `env.yaml` 中设置的情况。无法解析的 `mcp.yaml` 并不等于团队没有 MCP:它会作为 `Team MCP servers can be read` 连同解析错误一起报告,因为这种文件不会向任何工具注入内容,而且除第一次之外的每次运行都对此保持沉默。无法解析的团队 hooks 与团队模型配置(文件无法解析、同一文件内重复的名字,或两个活动 namespace 中的同名条目)会让 `Team hooks can be resolved` 与 `Team model profiles can be resolved` 失败,并给出 pull 只记录一次的原因;`teamai status` 把它们计为 0 时会指向这里。`Env variables injected in shell profile` 不再只查标记注释:它会检查 `env/env.yaml` 能否解析、以及是否在 `variables:` 键下声明了变量(写成普通的 `KEY: value` 映射等于没有声明;而显式写成 `variables: []` 属于没有内容要下发的配置,不会判为失败)、每个变量是否以 `env.yaml` 声明的值(或你为该团队设置的值;用 `--from-env` 设置的不会写入)写进了 `env.sh`(残留的旧值会一直被导出到每个 shell 和 MCP server,直到下次 pull;比对时会用生成器自身的逆运算读回 `env.sh`,因此跨多行引用的多行值能够正确匹配,而不会被误判为过期),以及本作用域注入的代码块(即 source 本作用域 `env.sh` 的那一块,因为同一个 profile 里还可能有其他作用域的代码块)是否真的能加载它——未加引号的 Windows 路径在 POSIX shell 中会被转义破坏,`source` 从不执行,而且没有任何提示。`No stale env blocks left behind` 是独立的一项检查:pull 优先选用哪个文件会随时间变化(Windows 上 Git Bash 的登录 shell 读取的是 `.bash_profile`/`.bash_login`/`.profile`,从不读取 `.bashrc`),而 pull 只会新增代码块,从不迁移旧的,因此早期安装或平台变化留下的失效代码块可能一直留在另一个候选文件里。它会列出每一个这样的文件(检查 `.zshrc`、`.bashrc`、`.bash_profile`、`.bash_login` 和 `.profile`,新旧写法都算),并指向 `teamai uninstall` 来清除它们——这与投递检查分开进行,因此不会因为还留着一个旧副本,就让一个正常工作的 env 代码块被判成故障。 +`Codex trusts this project, so it loads its team MCP servers` 在 project scope 下、项目的 `.codex/config.toml` 含有本 worktree 的 `managed-mcp.json` 为 Codex 记录的 server 时生成:Codex 只在受信任的项目中加载该文件,对未受信任的项目则静默跳过。它按 Codex 的方式读取 Codex 用户配置(`~/.codex/config.toml`,或 `toolRoots.codex` 下的那份)中的 `projects` 表:先取当前 checkout 的 `projects.""` 条目,再取其主 checkout 的,均按真实路径(`/private/tmp/...` 而非 `/tmp/...`)。在该条目设置 `trust_level = "trusted"` 之前,它会失败,并指出文件及其中的 server。请在 Codex 询问时信任该项目,或自行为主 checkout 加上该条目,这样即覆盖所有 worktree。doctor 只读取该文件。 + `Contributed learnings are published` 会在 `teamai contribute` 写下、但尚未推送成功的笔记仍在队列中时失败。当本次 pull 已经说过时,手动 `teamai pull` 结束时不会再重复它:pull 会尝试发布队列并自行报告结果,还会带上导致失败的推送错误——这是该检查本身给不出的信息。如果 pull 因为团队仓库刷新失败而根本没走到那一步,该检查会照常打印。 `--json` 把同一份报告作为单个对象打印到 stdout,并将所有日志改走 stderr,因此 `teamai doctor --json 2>/dev/null` 可以整体解析;退出码不变。每个检查都会带上人类模式下显示的修复建议: diff --git a/skill-data/core/references/troubleshooting.md b/skill-data/core/references/troubleshooting.md index a1a37513d..5f74016ad 100644 --- a/skill-data/core/references/troubleshooting.md +++ b/skill-data/core/references/troubleshooting.md @@ -167,6 +167,11 @@ trusts them (`teamai doctor` prints a reminder when it detects this). Guide the user to trust the teamai hooks in Codex, then reopen a session. Until then, run `teamai pull` manually. +In project scope the team MCP servers land in `/.codex/config.toml`, +which Codex loads only in a trusted project. When `teamai doctor` fails +`Codex trusts this project, so it loads its team MCP servers`, have the user trust +the project when Codex asks, or add the `[projects.""]` entry the fix names. + ### Cursor Cursor writes hooks to `~/.cursor/hooks.json` and also runs `~/.claude/settings.json`. `hook-dispatch --tool claude` and team hook commands written for `claude` exit only when `CURSOR_VERSION` is set and `~/.cursor/hooks.json` or `$CURSOR_PROJECT_DIR/.cursor/hooks.json` contains `--tool cursor`. A setup with only Claude has no second copy, so those hooks still run inside Cursor. Claude Code does not set `CURSOR_VERSION`. An already installed team hook picks up the guard on the next `teamai pull` or `teamai hooks inject`. If `teamai hooks list` shows Cursor without hooks, run `teamai pull` at the start of the session. diff --git a/src/__tests__/e2e/project-scoped-delivery.test.ts b/src/__tests__/e2e/project-scoped-delivery.test.ts index 8aeca8777..0b7f2a899 100644 --- a/src/__tests__/e2e/project-scoped-delivery.test.ts +++ b/src/__tests__/e2e/project-scoped-delivery.test.ts @@ -19,9 +19,8 @@ import { fileURLToPath } from 'node:url'; // the guarantee that makes the filter safe to change your mind about. // // Both MCP render paths are covered: Claude's JSON in project scope, and — in a -// second user-scope leg — Codex's TOML, since Codex has no project-scope MCP -// location. A filter that drops a server before rendering has to drop it from -// both. +// second user-scope leg — Codex's TOML. A filter that drops a server before +// rendering has to drop it from both. const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, '..', '..', '..'); @@ -390,10 +389,9 @@ describe('project-scoped hooks, MCP servers and env variables via the real CLI ( }, 60_000); }); -// Codex has no project-scope MCP location (no `mcpProject` in toolPaths), so its -// TOML renderer is only reachable from user scope. It is the second of the two -// MCP render paths: a filter that drops a server before rendering has to drop it -// from the TOML file as much as from Claude's JSON. +// Codex's TOML renderer is the second of the two MCP render paths, exercised +// here from user scope: a filter that drops a server before rendering has to +// drop it from the TOML file as much as from Claude's JSON. describe('project-scoped MCP reaches the Codex TOML renderer too (issue #668)', () => { let sandbox: string; let home: string; From b9f20dca73fc6f024b9954625e870b49e17b1887 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 00:46:42 +0200 Subject: [PATCH 4/5] fix(doctor): match Codex on trust entries without a level, give pasteable fixes (#954) Review follow-up. An entry without trust_level decides nothing in Codex (checked with codex mcp list: a bare worktree entry falls through to the trusted main checkout), so the check skips it instead of reporting trust_level = "undefined". The fix text names a [projects.""] table rather than printing two TOML lines on one, drops the Codex-asks advice for an entry already marked untrusted, and tells an unreadable config from an unparsable one. Docs note that a pull reports the failure too. --- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- src/__tests__/doctor-mcp-delivery.test.ts | 46 +++++++--- src/doctor-delivery.ts | 103 +++++++++++----------- 4 files changed, 87 insertions(+), 66 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 10db9826d..896624446 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -2310,7 +2310,7 @@ Three tools do not read a rules directory, so a per-file check cannot speak for `MCP servers delivered to ` compares each server the team's `mcp.yaml` resolves for that tool against the entry in the tool's own config, and names any the reconcile skipped with its reason. The comparison is the entry, not the name: reconciliation leaves an entry teamai does not own alone, so a server of your own under a team name holds the key while the team's definition never arrives, and a stale copy is just as undelivered. Both are reported as `not the team's definition`, and only `teamai pull --force` replaces an entry teamai did not write. An unresolved `${VAR}` is reported here with the variable's name, which is otherwise said once during a pull and never again. A declared secret with no value is not a failure: doctor prints it as a note (`notes` in `--json`) with the command that sets it, and the exit code stays as it would be without it; a note also says when an entry kept for it may hold an old value, and when a key is declared as a secret and also set in `env.yaml`. An `mcp.yaml` that does not parse is not a team without MCP: it is reported as `Team MCP servers can be read` with the parse error, since it injects nothing into any tool and every run after the first is silent about it. Team hooks and team model profiles that cannot be resolved (a file that does not parse, a name defined twice in one file, or one name in two active namespaces) fail `Team hooks can be resolved` and `Team model profiles can be resolved` with the reason pull logs once; `teamai status` points here when it counts them as 0. `Env variables injected in shell profile` no longer stops at finding the marker comment: it checks that `env/env.yaml` parses and declares its variables under the `variables:` key (a plain `KEY: value` mapping parses as none, while an explicit `variables: []` is a configuration with nothing to deliver and fails nothing), that each one reached `env.sh` with the value `env.yaml` declares, or your value for this team (one set with `--from-env` is not written there) — a key left over from an older value exports it to every shell and MCP server until the next pull, and the comparison reads `env.sh` back through the generator's own inverse, so a multiline value quoted across several lines is matched rather than called stale — and that this scope's injected block (the one sourcing its own `env.sh`, since a profile can also carry another scope's) would actually load it — an unquoted Windows path degrades to something a POSIX shell cannot read, so `source` never runs and nothing says so. `No stale env blocks left behind` is a separate check: which file `pull` prefers has changed over time (Windows Git Bash's login shell reads `.bash_profile`/`.bash_login`/`.profile`, never `.bashrc`), and a pull only ever adds a block, never migrates an old one away, so a dead block from an earlier install or platform change can sit in another candidate file indefinitely. It names every such file (checking `.zshrc`, `.bashrc`, `.bash_profile`, `.bash_login` and `.profile`, current and legacy spellings alike) and points at `teamai uninstall` to remove them — separately from delivery, so a working env block never reads as broken just because an old one is still lying around. -`Codex trusts this project, so it loads its team MCP servers` is built in project scope while the project's `.codex/config.toml` holds a server this worktree's `managed-mcp.json` records for Codex: Codex loads that file only in a trusted project, and skips an untrusted one without saying so. It reads the `projects` table of the Codex user config (`~/.codex/config.toml`, or the one under `toolRoots.codex`) as Codex does, taking the first `projects.""` entry for the checkout, then for its main checkout, each by real path (`/private/tmp/...`, not `/tmp/...`). It fails, naming the file and its servers, until that entry sets `trust_level = "trusted"`. Trust the project when Codex asks, or add the entry for the main checkout yourself, which covers every worktree. doctor only reads that file. +`Codex trusts this project, so it loads its team MCP servers` is built in project scope while the project's `.codex/config.toml` holds a server this worktree's `managed-mcp.json` records for Codex: Codex loads that file only in a trusted project, and skips an untrusted one without saying so. It reads the `projects` table of the Codex user config (`~/.codex/config.toml`, or the one under `toolRoots.codex`) as Codex does, taking the first `projects.""` entry that sets a `trust_level` for the checkout, then for its main checkout, each by real path (`/private/tmp/...`, not `/tmp/...`). It fails, naming the file and its servers, until that entry sets `trust_level = "trusted"`, and a pull reports the failure in its closing checks too. Trust the project when Codex asks, or add the entry for the main checkout yourself, which covers every worktree. doctor only reads that file. `Contributed learnings are published` fails while `teamai contribute` has notes queued that could not be pushed. A manual `teamai pull` does not repeat it at the end when the pull has already said it: the pull tries to publish the queue and reports the outcome itself, with the push error that made it fail — more than this check can tell you. If the pull never got that far, because the team repo failed to refresh, the check is printed as usual. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 971267b32..415f67175 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -2173,7 +2173,7 @@ teamai remove rules --force # 跳过确认,用于脚本和 CI `MCP servers delivered to ` 将团队 `mcp.yaml` 为该工具解析出的每个 server 与该工具自己配置文件中的条目逐一比对,并列出 reconcile 跳过的 server 及原因。比对的是条目内容而非名字:reconcile 不会覆盖不属于 teamai 的条目,因此你自己写的同名 server 会占住这个名字,团队的定义从未真正送达;过期的旧副本同样等于没送达。两者都报告为 `not the team's definition`,而覆盖非 teamai 写入的条目只有 `teamai pull --force` 能做到。未解析的 `${VAR}` 会在这里连同变量名一起报告——否则它只在 pull 时出现一次,之后再无提示。没有值的已声明密钥不算失败:doctor 把它作为备注打印(`--json` 中的 `notes`),并附上设置它的命令,退出码与没有它时相同;备注还会说明为它保留的条目可能含有旧值,以及某个 key 既声明为密钥、又在 `env.yaml` 中设置的情况。无法解析的 `mcp.yaml` 并不等于团队没有 MCP:它会作为 `Team MCP servers can be read` 连同解析错误一起报告,因为这种文件不会向任何工具注入内容,而且除第一次之外的每次运行都对此保持沉默。无法解析的团队 hooks 与团队模型配置(文件无法解析、同一文件内重复的名字,或两个活动 namespace 中的同名条目)会让 `Team hooks can be resolved` 与 `Team model profiles can be resolved` 失败,并给出 pull 只记录一次的原因;`teamai status` 把它们计为 0 时会指向这里。`Env variables injected in shell profile` 不再只查标记注释:它会检查 `env/env.yaml` 能否解析、以及是否在 `variables:` 键下声明了变量(写成普通的 `KEY: value` 映射等于没有声明;而显式写成 `variables: []` 属于没有内容要下发的配置,不会判为失败)、每个变量是否以 `env.yaml` 声明的值(或你为该团队设置的值;用 `--from-env` 设置的不会写入)写进了 `env.sh`(残留的旧值会一直被导出到每个 shell 和 MCP server,直到下次 pull;比对时会用生成器自身的逆运算读回 `env.sh`,因此跨多行引用的多行值能够正确匹配,而不会被误判为过期),以及本作用域注入的代码块(即 source 本作用域 `env.sh` 的那一块,因为同一个 profile 里还可能有其他作用域的代码块)是否真的能加载它——未加引号的 Windows 路径在 POSIX shell 中会被转义破坏,`source` 从不执行,而且没有任何提示。`No stale env blocks left behind` 是独立的一项检查:pull 优先选用哪个文件会随时间变化(Windows 上 Git Bash 的登录 shell 读取的是 `.bash_profile`/`.bash_login`/`.profile`,从不读取 `.bashrc`),而 pull 只会新增代码块,从不迁移旧的,因此早期安装或平台变化留下的失效代码块可能一直留在另一个候选文件里。它会列出每一个这样的文件(检查 `.zshrc`、`.bashrc`、`.bash_profile`、`.bash_login` 和 `.profile`,新旧写法都算),并指向 `teamai uninstall` 来清除它们——这与投递检查分开进行,因此不会因为还留着一个旧副本,就让一个正常工作的 env 代码块被判成故障。 -`Codex trusts this project, so it loads its team MCP servers` 在 project scope 下、项目的 `.codex/config.toml` 含有本 worktree 的 `managed-mcp.json` 为 Codex 记录的 server 时生成:Codex 只在受信任的项目中加载该文件,对未受信任的项目则静默跳过。它按 Codex 的方式读取 Codex 用户配置(`~/.codex/config.toml`,或 `toolRoots.codex` 下的那份)中的 `projects` 表:先取当前 checkout 的 `projects.""` 条目,再取其主 checkout 的,均按真实路径(`/private/tmp/...` 而非 `/tmp/...`)。在该条目设置 `trust_level = "trusted"` 之前,它会失败,并指出文件及其中的 server。请在 Codex 询问时信任该项目,或自行为主 checkout 加上该条目,这样即覆盖所有 worktree。doctor 只读取该文件。 +`Codex trusts this project, so it loads its team MCP servers` 在 project scope 下、项目的 `.codex/config.toml` 含有本 worktree 的 `managed-mcp.json` 为 Codex 记录的 server 时生成:Codex 只在受信任的项目中加载该文件,对未受信任的项目则静默跳过。它按 Codex 的方式读取 Codex 用户配置(`~/.codex/config.toml`,或 `toolRoots.codex` 下的那份)中的 `projects` 表:先取当前 checkout 的、设置了 `trust_level` 的 `projects.""` 条目,再取其主 checkout 的,均按真实路径(`/private/tmp/...` 而非 `/tmp/...`)。在该条目设置 `trust_level = "trusted"` 之前,它会失败,并指出文件及其中的 server;pull 结束时的检查也会报告这一失败。请在 Codex 询问时信任该项目,或自行为主 checkout 加上该条目,这样即覆盖所有 worktree。doctor 只读取该文件。 `Contributed learnings are published` 会在 `teamai contribute` 写下、但尚未推送成功的笔记仍在队列中时失败。当本次 pull 已经说过时,手动 `teamai pull` 结束时不会再重复它:pull 会尝试发布队列并自行报告结果,还会带上导致失败的推送错误——这是该检查本身给不出的信息。如果 pull 因为团队仓库刷新失败而根本没走到那一步,该检查会照常打印。 diff --git a/src/__tests__/doctor-mcp-delivery.test.ts b/src/__tests__/doctor-mcp-delivery.test.ts index 2e4252ccd..0c625f4ef 100644 --- a/src/__tests__/doctor-mcp-delivery.test.ts +++ b/src/__tests__/doctor-mcp-delivery.test.ts @@ -923,7 +923,7 @@ describe('doctor — MCP servers delivered on disk', () => { expect(check.fix).toContain(path.join(projectRoot, '.codex', 'config.toml')); expect(check.fix).toContain('docs'); expect(check.fix).toContain(codexConfig); - expect(check.fix).toContain(`[projects.${JSON.stringify(real)}] trust_level = "trusted"`); + expect(check.fix).toContain(`add a [projects.${JSON.stringify(real)}] table holding trust_level = "trusted"`); }); it('passes once Codex trusts the project by its real path', async () => { @@ -942,18 +942,40 @@ describe('doctor — MCP servers delivered on disk', () => { expect(check?.fix).toContain('"untrusted"'); }); - it('passes in a linked worktree once Codex trusts the main checkout', async () => { - const main = projectRoot; - const git = (...args: string[]): void => { - execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', '-c', 'commit.gpgsign=false', ...args], { cwd: main }); - }; - git('commit', '-q', '--allow-empty', '-m', 'init'); - const worktree = path.join(tempDir, 'codex-wt'); - git('worktree', 'add', '-q', worktree); - await useCheckout(worktree); - await writeCodexConfig(trusted(await fse.realpath(main))); + describe('in a linked worktree', () => { + let main: string; - expect(await (await trustCheck())?.check()).toBe(true); + beforeEach(async () => { + main = await fse.realpath(projectRoot); + const git = (...args: string[]): void => { + execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', '-c', 'commit.gpgsign=false', ...args], { cwd: main }); + }; + git('commit', '-q', '--allow-empty', '-m', 'init'); + const worktree = path.join(tempDir, 'codex-wt'); + git('worktree', 'add', '-q', worktree); + await useCheckout(worktree); + }); + + it('passes once Codex trusts the main checkout', async () => { + await writeCodexConfig(trusted(main)); + + expect(await (await trustCheck())?.check()).toBe(true); + }); + + it('passes over a worktree entry without a trust_level, which decides nothing', async () => { + await writeCodexConfig(`[projects.${JSON.stringify(await fse.realpath(projectRoot))}]\n${trusted(main)}`); + + expect(await (await trustCheck())?.check()).toBe(true); + }); + + it('fails when the worktree entry is untrusted, whatever the main checkout says', async () => { + const worktree = await fse.realpath(projectRoot); + await writeCodexConfig(trusted(worktree, 'untrusted') + trusted(main)); + + const check = await trustCheck(); + expect(await check?.check()).toBe(false); + expect(check?.fix).toContain(`[projects.${JSON.stringify(worktree)}]`); + }); }); it('reads the Codex config of the recorded CODEX_HOME root', async () => { diff --git a/src/doctor-delivery.ts b/src/doctor-delivery.ts index 4ededd5e8..2adc5b5d0 100644 --- a/src/doctor-delivery.ts +++ b/src/doctor-delivery.ts @@ -652,42 +652,59 @@ export async function buildMcpDeliveryChecks(ctx: DoctorContext): Promise"` entry it finds for the checkout, - * then for the main checkout of its repository, each keyed by real path. - * `dirs` lists them in that order. + * Codex takes the first `projects.""` entry holding a `trust_level` for + * the checkout, then for the main checkout of its repository, each keyed by + * real path; an entry without one decides nothing. `dirs` lists them in that + * order. */ async function codexProjectTrust(configFile: string, dirs: string[]): Promise { if (!await pathExists(configFile)) return { kind: 'untrusted' }; const raw = await readFileSafe(configFile); - if (raw === null) return { kind: 'unreadable', reason: 'it could not be read' }; + if (raw === null) return { kind: 'unreadable', reason: 'could not be read' }; let projects: unknown; try { const { parse } = await import('smol-toml'); projects = parse(raw).projects; } catch (error) { - return { kind: 'unreadable', reason: error instanceof Error ? error.message.split('\n')[0] : String(error) }; + return { kind: 'unreadable', reason: `could not be parsed (${error instanceof Error ? error.message.split('\n')[0] : String(error)})` }; } if (typeof projects !== 'object' || projects === null) return { kind: 'untrusted' }; for (const dir of dirs) { - const entry = (projects as Record)[dir]; - if (typeof entry !== 'object' || entry === null) continue; - const level = String((entry as { trust_level?: unknown }).trust_level); - return level === 'trusted' ? { kind: 'trusted' } : { kind: 'untrusted', entry: { dir, level } }; + const level = ((projects as Record)[dir])?.trust_level; + if (typeof level !== 'string') continue; + return level === 'trusted' ? { kind: 'trusted' } : { kind: 'untrusted', decidedBy: { dir, level } }; } return { kind: 'untrusted' }; } +/** The manual fix for a project Codex does not trust; `cause` opens the sentence. */ +function codexTrustFix(trust: Exclude, cause: string, configFile: string, main: string): string { + const table = (dir: string): string => `[projects.${JSON.stringify(dir)}]`; + switch (trust.kind) { + case 'unreadable': + return `${cause}, and ${configFile} ${trust.reason}, so whether Codex trusts this checkout is unknown. ` + + `Fix ${configFile}, then run \`teamai doctor\` again.`; + case 'untrusted': + if (trust.decidedBy) { + return `${cause}, and ${configFile} sets trust_level = ${JSON.stringify(trust.decidedBy.level)} in ` + + `${table(trust.decidedBy.dir)}, the entry Codex reads for this checkout. Set it to "trusted".`; + } + return `${cause}, and ${configFile} does not trust ${main}. Open Codex in ${main} and trust the project ` + + `when it asks, or add a ${table(main)} table holding trust_level = "trusted" to ${configFile}. ` + + 'Trusting the main checkout covers every worktree of it.'; + } +} + /** * Codex loads a project's `.codex/config.toml` only in a trusted project, and * skips an untrusted one silently (#954), so team servers a pull or * `teamai mcp inject` wrote there are on disk and inert. Built while that file * holds a server this worktree's `managed-mcp.json` records for Codex. - * Read-only: teamai never writes Codex's trust. + * Read-only: this check only reads Codex's config. */ export async function buildCodexProjectTrustCheck(ctx: DoctorContext): Promise { const { localConfig, teamConfig } = ctx; @@ -696,53 +713,35 @@ export async function buildCodexProjectTrustCheck(ctx: DoctorContext): Promise isCodexTrustGatedTool(target.tool) && !mcpTargetExcluded(localConfig, target)); - if (targets.length === 0) return []; + const target = (await resolveMcpTargets(teamConfig, localConfig)) + .find((candidate) => isCodexTrustGatedTool(candidate.tool) && !mcpTargetExcluded(localConfig, candidate)); + if (!target) return []; const { loadProjectMcpManifest } = await import('./utils/mcp-manifest.js'); + const { manifest } = await loadProjectMcpManifest(getDataHome(localConfig), projectRoot, { dryRun: true }); + const installed = await installedMcpEntries(target); + const names = (manifest[managedMcpManifestKey(target.tool, true)] ?? []) + .map((record) => record.name) + .filter((name) => installed?.has(name)); + if (names.length === 0) return []; + const { resolveAnchors } = await import('./utils/git.js'); const { realFilePath } = await import('./mcp-git-exclude.js'); - const { manifest } = await loadProjectMcpManifest(getDataHome(localConfig), projectRoot, { dryRun: true }); + const root = await realFilePath(projectRoot); const anchors = await resolveAnchors(projectRoot); - const checkout = anchors?.workspaceRoot ?? await realFilePath(projectRoot); - const main = anchors?.projectAnchor ?? checkout; - const dirs = [...new Set([await realFilePath(projectRoot), checkout, main])]; + const main = anchors?.projectAnchor ?? root; + const dirs = [...new Set([root, anchors?.workspaceRoot ?? root, main])]; const configFile = path.join(resolveToolRootDir(CODEX_TOOL_ID, DEFAULT_CODEX_ROOT, localConfig.toolRoots), 'config.toml'); + const trust = await codexProjectTrust(configFile, dirs); + const cause = `${target.file} holds team MCP servers (${nameList(names)}), but Codex loads a project's ` + + '.codex/config.toml only in a trusted project'; - const checks: Check[] = []; - for (const target of targets) { - const installed = await installedMcpEntries(target); - const names = (manifest[managedMcpManifestKey(target.tool, true)] ?? []) - .map((record) => record.name) - .filter((name) => installed?.has(name)); - if (names.length === 0) continue; - - const trust = await codexProjectTrust(configFile, dirs); - const held = `${target.file} holds team MCP servers (${nameList(names)}), but Codex loads a project's ` - + '.codex/config.toml only in a trusted project'; - const table = (dir: string): string => `[projects.${JSON.stringify(dir)}]`; - let fix = ''; - if (trust.kind === 'unreadable') { - fix = `${held}, and ${configFile} could not be parsed (${trust.reason}), so whether Codex trusts ${main} ` - + `is unknown. Fix ${configFile}, then run \`teamai doctor\` again.`; - } else if (trust.kind === 'untrusted' && trust.entry) { - fix = `${held}, and ${configFile} sets trust_level = ${JSON.stringify(trust.entry.level)} in ` - + `${table(trust.entry.dir)}, the entry Codex reads for this checkout. Set it to "trusted", ` - + 'or trust the project when Codex asks.'; - } else if (trust.kind === 'untrusted') { - fix = `${held}, and ${configFile} does not trust ${main}. Open Codex in ${main} and trust the project ` - + `when it asks, or add these two lines to ${configFile}: ${table(main)} trust_level = "trusted". ` - + 'Trusting the main checkout covers every worktree of it.'; - } - checks.push({ - name: 'Codex trusts this project, so it loads its team MCP servers', - source: 'local', - check: async () => trust.kind === 'trusted', - fix, - }); - } - return checks; + return [{ + name: 'Codex trusts this project, so it loads its team MCP servers', + source: 'local', + check: async () => trust.kind === 'trusted', + fix: trust.kind === 'trusted' ? '' : codexTrustFix(trust, cause, configFile, main), + }]; } /** From 2526c14b0f6e60f18a75c78de09c35d3d1129411 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 15:16:15 +0200 Subject: [PATCH 5/5] docs(skill): drop the Codex trust note from the core troubleshooting skill (#954) Review asked to leave the skill unchanged. The doctor check already prints the manual fix. --- skill-data/core/references/troubleshooting.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/skill-data/core/references/troubleshooting.md b/skill-data/core/references/troubleshooting.md index 5f74016ad..a1a37513d 100644 --- a/skill-data/core/references/troubleshooting.md +++ b/skill-data/core/references/troubleshooting.md @@ -167,11 +167,6 @@ trusts them (`teamai doctor` prints a reminder when it detects this). Guide the user to trust the teamai hooks in Codex, then reopen a session. Until then, run `teamai pull` manually. -In project scope the team MCP servers land in `/.codex/config.toml`, -which Codex loads only in a trusted project. When `teamai doctor` fails -`Codex trusts this project, so it loads its team MCP servers`, have the user trust -the project when Codex asks, or add the `[projects.""]` entry the fix names. - ### Cursor Cursor writes hooks to `~/.cursor/hooks.json` and also runs `~/.claude/settings.json`. `hook-dispatch --tool claude` and team hook commands written for `claude` exit only when `CURSOR_VERSION` is set and `~/.cursor/hooks.json` or `$CURSOR_PROJECT_DIR/.cursor/hooks.json` contains `--tool cursor`. A setup with only Claude has no second copy, so those hooks still run inside Cursor. Claude Code does not set `CURSOR_VERSION`. An already installed team hook picks up the guard on the next `teamai pull` or `teamai hooks inject`. If `teamai hooks list` shows Cursor without hooks, run `teamai pull` at the start of the session.