mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
co-authored by
Naveen Chatlapalli
parent
4ff84bb835
commit
062ebbaf26
@@ -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-'))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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`. */
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user