Files
orca/src/main/execution-host-workspace-trust.ts
T
Brennan Benson 5b3366f78e test: unit tests can no longer write a developer's real agent or Orca settings (#23979)
* fix(agent-trust): write per-user trust under the home the launched agent reads

The Cursor, Copilot, Qoder and Antigravity writers and the local Codex config list
resolved ~ with os.homedir() at write time, so any test that reached them wrote into
the developer's real ~/.codex, ~/.cursor, ~/.copilot or ~/.gemini. Each writer now
takes the home, derived once from the launch env (HOME, or USERPROFILE on Windows,
else this host's home) by launchedAgentHome, which the SSH relay already used.

* test: give tests that wrote the real agent or Orca home a temp one

The structured Codex adoption replay pre-trusted /repos/workspace-1 in the real
~/.codex/config.toml; it now runs with a temp HOME and userData. The Codex
session-resume and WSL hook tests created Orca's managed Codex home under the live
userData, and the Claude Agent Teams tests wrote their tmux shim into ~/.orca; each
now runs against a temp userData or HOME.

* test: fail any unit test that writes the real agent or Orca home

A vitest setup file wraps the node:fs mutating calls and refuses a target under the
account's real ~/.codex, ~/.claude(.json), ~/.orca, ~/.cursor, ~/.copilot, ~/.gemini,
~/.qoder or Orca userData, found through os.userInfo() so a test that swaps HOME
cannot hide it. The refusal is recorded and rethrown after the test, since trust
writers swallow errors. Reads are untouched. It stands down only while an opted-in
real-agent suite's own switch is set. It also unsets what an Orca terminal exports
toward the live app (userData, Codex and Claude homes, and the Codex launch preflight
CLI, which a shell test would otherwise run), so a local run matches CI.

* test: type the guarded fs call from its narrowed original
2026-09-30 00:02:35 -07:00

146 lines
5.2 KiB
TypeScript

import { realpathSync } from 'node:fs'
import { homedir } from 'node:os'
import { resolve } from 'node:path'
import { markQoderWorkspaceTrusted } from './qoder/workspace-trust'
import {
type AgentTrustPreset,
markAntigravityWorkspaceTrusted,
markCodexProjectTrusted,
markCopilotFolderTrusted,
markCursorWorkspaceTrusted
} from './agent-trust-presets'
import { awaitAgentTrustWriteWithinDeadline } from './agent-trust-write-deadline'
import {
type ClaudeTrustConfigTarget,
grantClaudeWorkspaceTrust
} from './claude/claude-folder-trust-file'
import { isTooBroadToPreTrust } from '../shared/home-or-filesystem-root'
/**
* What the host that runs the agent knows about where that agent reads trust. Relay-safe:
* main describes this machine or a WSL guest, the SSH relay describes its own host.
*/
export type WorkspaceTrustHost = {
/** Homes the agent may read trust under; with none known, an agent that inherits trust writes nothing. */
homes: readonly (string | null | undefined)[]
/** The home the launched agent resolves `~` to, where the per-user trust files live. */
agentHome: string
/** The config Claude reads on this host, or null when this host cannot tell. */
claudeConfig: () => ClaudeTrustConfigTarget | null
/** Every config.toml the launched Codex may read, in the hook installer's lock order. */
codexConfigFiles: () => readonly string[]
deadlineMs: number
}
/** The home an agent launched with `launchEnv` on this host resolves `~` to. */
export function launchedAgentHome(
launchEnv: Record<string, string | undefined> | undefined
): string {
return (process.platform === 'win32' ? launchEnv?.USERPROFILE : launchEnv?.HOME) || homedir()
}
/**
* Whether trust on a home or a disk root would also trust the folders below it, per each agent's
* own lookup; such trust is never pre-written. Claude walks up parent folders (to the repo root,
* else the disk root); Copilot and Qoder accept any trusted ancestor. Codex matches its start
* folder or that folder's repo root, Antigravity the exact folder, and Cursor never inherits from
* a home, a folder above one or a shallow path.
*/
export const AGENT_TRUST_INHERITS_FROM_A_HOME: Record<AgentTrustPreset, boolean> = {
claude: true,
codex: false,
cursor: false,
copilot: true,
qoder: true,
antigravity: false
}
/**
* Agents whose lookup keys on the folder they start in, so Orca trusts that folder, not the
* workspace root, even outside any workspace. Codex checks its start folder, then that folder's
* repo root; a non-git workspace root above the start folder is neither.
*/
export const AGENT_TRUST_KEYED_BY_START_FOLDER: Record<AgentTrustPreset, boolean> = {
claude: false,
codex: true,
cursor: false,
copilot: false,
qoder: false,
antigravity: false
}
// Why resolve() too: Claude stores it, and it collapses `..` even where realpath fails.
function withResolvedForm(path: string): string[] {
try {
return [path, resolve(path), realpathSync.native(path)]
} catch {
return [path, resolve(path)]
}
}
/**
* Whether trust stored for `storedPath` would cover a home: it is a root, a home or a folder
* above one. Both sides are compared given and resolved, since the writers store the realpath.
*/
function wouldTrustAHome(storedPath: string, homes: readonly string[]): boolean {
const homeForms = homes.flatMap(withResolvedForm)
return withResolvedForm(storedPath).some((form) => isTooBroadToPreTrust(form, homeForms))
}
async function writePreset(
preset: AgentTrustPreset,
storedPath: string,
host: WorkspaceTrustHost
): Promise<void> {
switch (preset) {
case 'claude': {
const target = host.claudeConfig()
if (target) {
await grantClaudeWorkspaceTrust(target, storedPath)
}
return
}
case 'codex':
return markCodexProjectTrusted(storedPath, host.codexConfigFiles())
case 'cursor':
return markCursorWorkspaceTrusted(storedPath, host.agentHome)
case 'copilot':
return markCopilotFolderTrusted(storedPath, host.agentHome)
case 'qoder':
return markQoderWorkspaceTrusted(storedPath, host.agentHome)
case 'antigravity':
return markAntigravityWorkspaceTrusted(storedPath, host.agentHome)
}
}
/**
* The one place a preset's trust is written, on the host that runs the agent. For an agent that
* inherits trust from a home, refuses when the path the writer would store covers a home. Never
* throws or waits past the host's deadline: any failure or miss means the agent asks.
*/
export async function applyWorkspaceTrustOnThisHost(
preset: AgentTrustPreset,
workspacePath: string,
describeHost: () => WorkspaceTrustHost
): Promise<void> {
try {
const host = describeHost()
if (AGENT_TRUST_INHERITS_FROM_A_HOME[preset]) {
const homes = host.homes.filter((home): home is string => Boolean(home))
if (homes.length === 0 || wouldTrustAHome(workspacePath, homes)) {
return
}
}
await awaitAgentTrustWriteWithinDeadline(writePreset(preset, workspacePath, host), {
preset,
workspacePath,
deadlineMs: host.deadlineMs
})
} catch (error) {
console.warn(
`[agent-trust] ${preset} trust for ${workspacePath} failed; the agent will ask`,
error
)
}
}