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 (<KIMI_CODE_HOME or ~/.kimi-code>/workspace-trust/wd_<slug>_<hash>) 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>
This commit is contained in:
Neil
2026-10-24 22:56:46 -07:00
committed by GitHub
co-authored by Naveen Chatlapalli
parent 4ff84bb835
commit 062ebbaf26
10 changed files with 138 additions and 24 deletions
+37 -1
View File
@@ -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-'))
+30 -1
View File
@@ -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,
+19 -13
View File
@@ -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(
+18 -1
View File
@@ -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<typeof ClaudeFolderTrustFile.grantClaudeWorkspaceTrust>()
}))
@@ -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'))
+1
View File
@@ -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
@@ -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,
+18 -5
View File
@@ -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 `<agentHome>/.kimi-code`. */
kimiHome?: string
deadlineMs: number
}
@@ -44,9 +47,13 @@ export const AGENT_TRUST_INHERITS_FROM_A_HOME: Record<AgentTrustPreset, boolean>
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<AgentTrustPreset> = 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<AgentTrustPreset, boolean> = {
claude: false,
@@ -55,7 +62,8 @@ export const AGENT_TRUST_KEYED_BY_START_FOLDER: Record<AgentTrustPreset, boolean
copilot: false,
qoder: false,
'qoder-cn': false,
antigravity: false
antigravity: false,
kimi: true
}
// Claude stores resolved paths; resolve also collapses .. when realpath fails.
@@ -98,6 +106,11 @@ async function writePreset(
return markQoderWorkspaceTrusted(storedPath, host.agentHome)
case 'antigravity':
return markAntigravityWorkspaceTrusted(storedPath, host.agentHome)
case 'kimi':
return markKimiWorkspaceTrusted(
storedPath,
host.kimiHome || join(host.agentHome, '.kimi-code')
)
}
}
@@ -109,7 +122,7 @@ export async function applyWorkspaceTrustOnThisHost(
): Promise<void> {
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
@@ -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'))
}
}
+9 -1
View File
@@ -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`. */
+1
View File
@@ -258,6 +258,7 @@ const TUI_AGENT_CONFIG_SOURCE: Record<TuiAgent, TuiAgentConfigSource> = {
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',