From 062ebbaf26032162a90faf44ae99ff4d61a030ea Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:56:46 -0700 Subject: [PATCH] fix(agents): pre-trust the folder for Kimi Code so its trust menu does not swallow the prompt (#33411) Kimi Code workspace pre-trust: Orca writes Kimi's own trust record (/workspace-trust/wd__) for the exact launch folder, so Kimi's "Trust this folder?" menu no longer swallows Orca's prompt. Never pre-trusts the home folder or a filesystem root. Fixes #26680. Supersedes #26738, #26741. Co-authored-by: Naveen Chatlapalli <54518943+1aifanatic@users.noreply.github.com> --- src/main/agent-trust-presets.test.ts | 38 ++++++++++++++++++- src/main/agent-trust-presets.ts | 31 ++++++++++++++- src/main/agent-workspace-trust-spawn.test.ts | 32 +++++++++------- src/main/agent-workspace-trust.test.ts | 19 +++++++++- src/main/agent-workspace-trust.ts | 1 + .../execution-host-workspace-trust.test.ts | 5 ++- src/main/execution-host-workspace-trust.ts | 23 ++++++++--- src/main/workspace-trust-test-fixtures.ts | 2 + src/shared/tui-agent-config-types.ts | 10 ++++- src/shared/tui-agent-config.ts | 1 + 10 files changed, 138 insertions(+), 24 deletions(-) diff --git a/src/main/agent-trust-presets.test.ts b/src/main/agent-trust-presets.test.ts index ca5721c4598..9ea50677c3a 100644 --- a/src/main/agent-trust-presets.test.ts +++ b/src/main/agent-trust-presets.test.ts @@ -7,6 +7,7 @@ import { realpathSync, rmSync, statSync, + symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -36,7 +37,8 @@ const { markAntigravityWorkspaceTrusted, markCodexProjectTrusted, markCopilotFolderTrusted, - markCursorWorkspaceTrusted + markCursorWorkspaceTrusted, + markKimiWorkspaceTrusted } = await import('./agent-trust-presets') const { runExclusivelyForCodexTrustConfig } = await import('./codex/codex-trust-config-mutation-queue') @@ -106,6 +108,40 @@ describe('markCursorWorkspaceTrusted', () => { }) }) +describe('markKimiWorkspaceTrusted', () => { + const kimiHome = () => join(testState.fakeHomeDir, '.kimi-code') + const records = () => readdirSync(join(kimiHome(), 'workspace-trust')) + + it.skipIf(process.platform === 'win32')('uses the record name Kimi Code 2.3.1 wrote', () => { + // Name captured from a real Kimi Code 2.3.1 "Trust and continue" on macOS. + markKimiWorkspaceTrusted('/private/tmp/kimi-inst/ws/Sample Project', kimiHome()) + expect(records()).toEqual(['wd_sample-project_017c73b76124']) + }) + + it.skipIf(process.platform !== 'win32')('lowercases Windows paths like Kimi', () => { + markKimiWorkspaceTrusted('C:\\projects\\Sample Project', kimiHome()) + markKimiWorkspaceTrusted('c:\\PROJECTS\\sample project', kimiHome()) + expect(records()).toEqual(['wd_sample-project_f415ab4f33c6']) + }) + + it.skipIf(process.platform === 'win32')( + 'keys a symlinked workspace by the physical cwd Kimi sees', + () => { + const real = mkdtempSync(join(tmpdir(), 'orca-kimi-ws-')) + const link = join(testState.fakeHomeDir, 'linked-ws') + symlinkSync(real, link) + try { + markKimiWorkspaceTrusted(link, kimiHome()) + const [name] = records() + const record = JSON.parse(readFileSync(join(kimiHome(), 'workspace-trust', name), 'utf-8')) + expect(record).toEqual({ root: realpathSync.native(real), trustedAt: expect.any(Number) }) + } finally { + rmSync(real, { recursive: true, force: true }) + } + } + ) +}) + describe('markCopilotFolderTrusted', () => { it('appends the workspace to trustedFolders in ~/.copilot/config.json', () => { const workspace = mkdtempSync(join(tmpdir(), 'orca-copilot-ws-')) diff --git a/src/main/agent-trust-presets.ts b/src/main/agent-trust-presets.ts index 800957777b3..089a3187248 100644 --- a/src/main/agent-trust-presets.ts +++ b/src/main/agent-trust-presets.ts @@ -1,5 +1,6 @@ +import { createHash } from 'node:crypto' import { existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs' -import { join } from 'node:path' +import { dirname, join, resolve } from 'node:path' import { writeFileAtomically } from './codex-accounts/fs-utils' import { upsertProjectTrustLevel } from './codex/config-toml-trust' import { runExclusivelyForCodexTrustConfig } from './codex/codex-trust-config-mutation-queue' @@ -95,6 +96,34 @@ export function markAntigravityWorkspaceTrusted(workspacePath: string, home: str writeFileAtomically(configPath, `${JSON.stringify(config, null, 2)}\n`) } +/** Kimi Code 2.3.1 keeps one exact-folder record per cwd (workspaceTrust/trustRecord.ts). */ +export function markKimiWorkspaceTrusted(workspacePath: string, kimiHome: string): void { + // Kimi keys on process.cwd(): physical on POSIX, the spawn path as given on Windows. + const root = process.platform === 'win32' ? resolve(workspacePath) : canonicalize(workspacePath) + const trustFile = join(kimiHome, 'workspace-trust', kimiWorkspaceTrustKey(root)) + if (existsSync(trustFile)) { + return + } + mkdirSync(dirname(trustFile), { recursive: true, mode: 0o700 }) + writeFileAtomically(trustFile, JSON.stringify({ root, trustedAt: Date.now() }), { mode: 0o600 }) +} + +/** Mirrors Kimi's trustKey: canonicalWorkspaceRoot then encodeWorkDirKey (workdir-slug.ts). */ +function kimiWorkspaceTrustKey(root: string): string { + const slashed = root.replaceAll('\\', '/') + const trimmed = slashed.replace(/\/+$/, '') + const normalized = /^(?:[A-Za-z]:\/|\/\/)/.test(slashed) ? trimmed.toLowerCase() : trimmed + const slug = (normalized.split('/').pop() ?? '') + .toLowerCase() + .replaceAll(/[^a-z0-9._-]+/g, '-') + .replaceAll(/^-+|-+$/g, '') + .slice(0, 40) + .replaceAll(/^-+|-+$/g, '') + const name = slug === '' || slug === '.' || slug === '..' ? 'workspace' : slug + const hash = createHash('sha256').update(normalized).digest('hex').slice(0, 12) + return `wd_${name}_${hash}` +} + /** Lock configs in hook-installer order: managed runtime home before system home. */ export function markCodexProjectTrusted( workspacePath: string, diff --git a/src/main/agent-workspace-trust-spawn.test.ts b/src/main/agent-workspace-trust-spawn.test.ts index b87d58ff855..4e22ce9a842 100644 --- a/src/main/agent-workspace-trust-spawn.test.ts +++ b/src/main/agent-workspace-trust-spawn.test.ts @@ -124,21 +124,27 @@ describe('applyAgentWorkspaceTrustToSpawn', () => { expect(spawnOptions).toEqual({ agentWorkspaceTrust: { workspacePath: '/srv/wt' } }) }) - it('trusts Codex in a floating terminal at the folder it starts in', async () => { - await applyAgentWorkspaceTrustToSpawn( - spawnArgs({ launchAgent: 'codex', worktreeId: 'global-floating-terminal', cwd: '/Users/me' }) - ) - expect(applyAgentWorkspaceTrust).toHaveBeenCalledWith('codex', '/Users/me', expect.anything()) - }) + it.each(['codex', 'kimi'])( + 'trusts %s in a floating terminal at the folder it starts in', + async (agent) => { + await applyAgentWorkspaceTrustToSpawn( + spawnArgs({ launchAgent: agent, worktreeId: 'global-floating-terminal', cwd: '/Users/me' }) + ) + expect(applyAgentWorkspaceTrust).toHaveBeenCalledWith(agent, '/Users/me', expect.anything()) + } + ) - it('trusts Codex at a subfolder it starts in, which its lookup keys on', async () => { - await applyAgentWorkspaceTrustToSpawn( - spawnArgs({ launchAgent: 'codex', worktreeId: 'folder:fw-1', cwd: '/notes/sub' }) - ) - expect(applyAgentWorkspaceTrust).toHaveBeenCalledWith('codex', '/notes/sub', expect.anything()) - }) + it.each(['codex', 'kimi'])( + 'trusts %s at a subfolder it starts in, which its lookup keys on', + async (agent) => { + await applyAgentWorkspaceTrustToSpawn( + spawnArgs({ launchAgent: agent, worktreeId: 'folder:fw-1', cwd: '/notes/sub' }) + ) + expect(applyAgentWorkspaceTrust).toHaveBeenCalledWith(agent, '/notes/sub', expect.anything()) + } + ) - it.each(PRESET_AGENTS.filter((agent) => agent !== 'codex'))( + it.each(PRESET_AGENTS.filter((agent) => agent !== 'codex' && agent !== 'kimi'))( 'trusts the workspace root for %s, whose trust covers its subfolders or matches the root', async (agent) => { await applyAgentWorkspaceTrustToSpawn( diff --git a/src/main/agent-workspace-trust.test.ts b/src/main/agent-workspace-trust.test.ts index ddda0ca8c35..b6a4228bd33 100644 --- a/src/main/agent-workspace-trust.test.ts +++ b/src/main/agent-workspace-trust.test.ts @@ -20,6 +20,7 @@ const mocks = vi.hoisted(() => ({ copilot: vi.fn<(path: string, home: string) => void>(), antigravity: vi.fn<(path: string, home: string) => void>(), qoder: vi.fn<(path: string, home: string) => void>(), + kimi: vi.fn<(path: string, kimiHome: string) => void>(), codexConfigFiles: vi.fn<(agentHome: string) => string[]>(() => CODEX_CONFIG_FILES), claudeGrant: vi.fn() })) @@ -28,7 +29,8 @@ vi.mock('./agent-trust-presets', () => ({ markCodexProjectTrusted: mocks.codex, markCursorWorkspaceTrusted: mocks.cursor, markCopilotFolderTrusted: mocks.copilot, - markAntigravityWorkspaceTrusted: mocks.antigravity + markAntigravityWorkspaceTrusted: mocks.antigravity, + markKimiWorkspaceTrusted: mocks.kimi })) vi.mock('./codex/codex-home-paths', () => ({ getLocalCodexTrustConfigFiles: mocks.codexConfigFiles @@ -104,6 +106,21 @@ describe('applyAgentWorkspaceTrust on this machine', () => { } }) + it("writes Kimi trust under the launch env's KIMI_CODE_HOME, else its ~/.kimi-code", async () => { + vi.stubEnv('KIMI_CODE_HOME', '') + await applyAgentWorkspaceTrust('kimi', WORKSPACE, { + ...local, + env: { HOME: '/home/agent', USERPROFILE: '/home/agent', KIMI_CODE_HOME: '/kimi/custom' } + }) + expect(mocks.kimi).toHaveBeenLastCalledWith(WORKSPACE, '/kimi/custom') + await applyAgentWorkspaceTrust('kimi', WORKSPACE, { + ...local, + env: { HOME: '/home/agent', USERPROFILE: '/home/agent' } + }) + expect(mocks.kimi).toHaveBeenLastCalledWith(WORKSPACE, join('/home/agent', '.kimi-code')) + vi.unstubAllEnvs() + }) + it('contains a rejected or throwing write so the launch proceeds', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) mocks.codex.mockRejectedValueOnce(new Error('write failed')) diff --git a/src/main/agent-workspace-trust.ts b/src/main/agent-workspace-trust.ts index e9de9db5a63..9b346dedab4 100644 --- a/src/main/agent-workspace-trust.ts +++ b/src/main/agent-workspace-trust.ts @@ -72,6 +72,7 @@ export async function applyAgentWorkspaceTrust( wslDistro: context.wslDistro }), codexConfigFiles: () => getLocalCodexTrustConfigFiles(agentHome, context.codexHome), + kimiHome: context.env?.KIMI_CODE_HOME || process.env.KIMI_CODE_HOME, // Codex shares its config lock queue with hook installs. deadlineMs: preset === 'codex' ? AGENT_TRUST_WRITE_DEADLINE_MS : SHORT_AGENT_TRUST_WRITE_DEADLINE_MS diff --git a/src/main/execution-host-workspace-trust.test.ts b/src/main/execution-host-workspace-trust.test.ts index 445122b0c10..4ae1fc095a2 100644 --- a/src/main/execution-host-workspace-trust.test.ts +++ b/src/main/execution-host-workspace-trust.test.ts @@ -33,7 +33,8 @@ const PRESETS: readonly AgentTrustPreset[] = [ 'cursor', 'copilot', 'qoder', - 'antigravity' + 'antigravity', + 'kimi' ] const INHERITING_PRESETS: readonly AgentTrustPreset[] = ['claude', 'copilot', 'qoder'] const EXACT_OR_SELF_LIMITING_PRESETS: readonly AgentTrustPreset[] = [ @@ -103,7 +104,7 @@ describe('applyWorkspaceTrustOnThisHost', () => { ] ] describe.each(tooBroad)('for %s', (_label, arrange) => { - it.each(INHERITING_PRESETS)('writes no %s trust', async (preset) => { + it.each([...INHERITING_PRESETS, 'kimi'] as const)('writes no %s trust', async (preset) => { const { workspace, homes } = arrange() await applyWorkspaceTrustOnThisHost( preset, diff --git a/src/main/execution-host-workspace-trust.ts b/src/main/execution-host-workspace-trust.ts index 4546b9b88b2..2bf6e1d6cd8 100644 --- a/src/main/execution-host-workspace-trust.ts +++ b/src/main/execution-host-workspace-trust.ts @@ -1,13 +1,14 @@ import { realpathSync } from 'node:fs' import { homedir } from 'node:os' -import { resolve } from 'node:path' +import { join, resolve } from 'node:path' import { markQoderWorkspaceTrusted } from './qoder/workspace-trust' import { type AgentTrustPreset, markAntigravityWorkspaceTrusted, markCodexProjectTrusted, markCopilotFolderTrusted, - markCursorWorkspaceTrusted + markCursorWorkspaceTrusted, + markKimiWorkspaceTrusted } from './agent-trust-presets' import { awaitAgentTrustWriteWithinDeadline } from './agent-trust-write-deadline' import { @@ -26,6 +27,8 @@ export type WorkspaceTrustHost = { claudeConfig: () => ClaudeTrustConfigTarget | null /** Every config.toml the launched Codex may read, in the hook installer's lock order. */ codexConfigFiles: () => readonly string[] + /** KIMI_CODE_HOME for the launched Kimi; unset means `/.kimi-code`. */ + kimiHome?: string deadlineMs: number } @@ -44,9 +47,13 @@ export const AGENT_TRUST_INHERITS_FROM_A_HOME: Record copilot: true, qoder: true, 'qoder-cn': true, - antigravity: false + antigravity: false, + kimi: false } +/** Exact-folder agents that load project config (Kimi: MCP servers) from a trusted home or root. */ +const AGENT_TRUST_UNSAFE_AT_A_HOME: ReadonlySet = new Set(['kimi']) + /** Trust these agents’ starting folders; a workspace ancestor may not be a valid trust key. */ export const AGENT_TRUST_KEYED_BY_START_FOLDER: Record = { claude: false, @@ -55,7 +62,8 @@ export const AGENT_TRUST_KEYED_BY_START_FOLDER: Record { try { const host = describeHost() - if (AGENT_TRUST_INHERITS_FROM_A_HOME[preset]) { + if (AGENT_TRUST_INHERITS_FROM_A_HOME[preset] || AGENT_TRUST_UNSAFE_AT_A_HOME.has(preset)) { const homes = host.homes.filter((home): home is string => Boolean(home)) if (homes.length === 0 || wouldTrustAHome(workspacePath, homes)) { return diff --git a/src/main/workspace-trust-test-fixtures.ts b/src/main/workspace-trust-test-fixtures.ts index 24eefa2b9d6..0fbaee74a4f 100644 --- a/src/main/workspace-trust-test-fixtures.ts +++ b/src/main/workspace-trust-test-fixtures.ts @@ -20,6 +20,8 @@ export function workspaceTrustWritten(home: string, preset: AgentTrustPreset): b return existsSync(join(home, '.qoder', 'settings.json')) case 'antigravity': return existsSync(join(home, '.gemini', 'antigravity-cli', 'settings.json')) + case 'kimi': + return existsSync(join(home, '.kimi-code', 'workspace-trust')) } } diff --git a/src/shared/tui-agent-config-types.ts b/src/shared/tui-agent-config-types.ts index dc5908b8063..978f4ec6e4d 100644 --- a/src/shared/tui-agent-config-types.ts +++ b/src/shared/tui-agent-config-types.ts @@ -43,7 +43,15 @@ export type TuiAgentConfig = { /** Claude Code follows pasted text only where the user's typed words ask, so dispatch briefs need a typed lead line. */ pasteNeedsTypedRequest?: boolean /** Trust Orca pre-writes at PTY spawn (agent-workspace-trust.ts) so the agent's first-launch "trust this folder?" menu doesn't consume the bracketed paste. */ - preflightTrust?: 'claude' | 'cursor' | 'copilot' | 'codex' | 'antigravity' | 'qoder' | 'qoder-cn' + preflightTrust?: + | 'claude' + | 'cursor' + | 'copilot' + | 'codex' + | 'antigravity' + | 'qoder' + | 'qoder-cn' + | 'kimi' /** Agent-specific signal that the composer is ready for paste, stronger than the default quiet-render window. */ draftPasteReadySignal?: DraftPasteMarkerSignal /** Signal before a paste that Enter follows, for agents that show their composer before it can submit; defaults to `draftPasteReadySignal`. */ diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index 1dc0854d56b..709d8abd803 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -258,6 +258,7 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { ctrlEnterEncoding: 'csi-u' }, kimi: { + preflightTrust: 'kimi', // Why: the `kimi` launcher runs as `kimi-code`, so foreground-process recognition never // matches the agent without the alias — terminal reuse and `dispatch --inject` fail. detectCmd: 'kimi',