refactor(onboarding): scope first-run hook consent to the installation

The gate this replaces decided "is this profile new?" by inspecting the disk at
load: a state file or any backup meant pre-existing. That inference was wrong
three separate times. Telemetry seeding writes a file for a brand-new profile,
so a second profile installed hooks globally before its own checkbox appeared,
undoing the first profile's opt-out. A user who quit onboarding halfway read as
consented on relaunch. Two candidate redesigns died on the same inference.

Freshness is now recorded when it is a fact rather than discovered later. A
marker under the user-data root, established in preflight before any profile or
Store exists, says whether this installation predates the change. Absence means
pre-change, so an upgrading user is never deferred and nothing is written for
them at all.

Every ambiguity installs. A corrupt marker, a malformed partial record, an
unreadable root, or a failed write all resolve to install-as-today; a deferral
requires no marker, none of Orca's own artifacts on disk, and a successful
write. Deferring a user who already has hooks would stop maintaining them, so
that direction is the one that must never happen by accident.

Consent is installation-scoped, not per-profile, so a second profile inherits
the answer instead of being asked again -- hooks are user-global and the person
has already seen the disclosure. Profile seeding now carries
agentStatusHooksEnabled too; previously a parent's opt-out was silently lost and
the child reinstalled.

The policy is threaded through every writer, not just startup. SSH, the WSL
relay, Codex launch and resume, and the offline CLI all bypass
installManagedAgentHooks and each wrote user-global config on its own.

Also required, not incidental: the Codex refresh no longer runs the legacy
system cleanup. That is a profile-local operation reaching into the real
~/.codex, which "existing users untouched" forbids outright.
This commit is contained in:
Brennan Benson
2026-09-13 19:59:50 -07:00
parent ac10f8aa74
commit b402822fbb
50 changed files with 1814 additions and 633 deletions
+1
View File
@@ -11,6 +11,7 @@
"../src/main/agent-hooks/installer-utils.ts",
"../src/main/agent-hooks/installer-utils-remote.ts",
"../src/main/agent-hooks/agent-status-hooks-enablement.ts",
"../src/main/agent-hooks/managed-hook-install-policy.ts",
"../src/main/agent-hooks/local-agent-cli-presence.ts",
"../src/main/agent-hooks/managed-agent-hook-controls.ts",
"../src/main/agent-hooks/managed-agent-hook-registry.ts",
+4
View File
@@ -253,6 +253,10 @@ export const electronViteConfig: UserConfig = {
'agent-hooks/managed-agent-hook-controls': resolve(
'src/main/agent-hooks/managed-agent-hook-controls.ts'
),
// Same reason: `orca agent hooks on|off` resolves its own install verdict.
'agent-hooks/managed-hook-install-policy': resolve(
'src/main/agent-hooks/managed-hook-install-policy.ts'
),
'codex/managed-home-shell-preflight': resolve(
'src/main/codex/managed-home-shell-preflight.ts'
),
+9 -1
View File
@@ -16,6 +16,7 @@ import { normalizeDisabledTuiAgents } from '../../shared/tui-agent-selection'
import type { GlobalSettings } from '../../shared/global-settings-types'
import type { PersistedState } from '../../shared/persisted-state-types'
import { prepareManagedCodexHomeBeforeShellLaunch } from '../../main/codex/managed-home-shell-preflight'
import { getManagedHookInstallDecision } from '../../main/agent-hooks/managed-hook-install-policy'
type AgentHookCommandResult = {
enabled: boolean
@@ -201,9 +202,16 @@ async function setAgentHooksEnabled(
const updatedRuntime = await updateRunningRuntime(client, enabled)
const offlineUpdate = updatedRuntime ? null : updateEnabledOnDisk(enabled)
const settingsPath = offlineUpdate?.settingsPath ?? getDataPath()
// Why 'cli' and not 'desktop': `orca agent hooks on` IS the user answering, and this process
// never paints the wizard — the same reason `--serve` and `orcad` install as they always have.
// The policy's deny arm still holds, so `off` can never be turned into an install here.
const installDecision = getManagedHookInstallDecision({
settings: offlineUpdate?.settings ?? { agentStatusHooksEnabled: enabled },
mode: 'cli'
})
const statuses = updatedRuntime
? getManagedAgentHookStatuses()
: await applyAgentStatusHooksEnabled(enabled, offlineUpdate?.settings)
: await applyAgentStatusHooksEnabled(enabled, offlineUpdate?.settings, { installDecision })
return {
enabled,
settingsPath,
+13 -7
View File
@@ -167,13 +167,19 @@ describe('RuntimeClient module-graph deferral', () => {
expect(
applyAgentStatusHooksEnabledMock,
`${argv.join(' ')} hook application`
).toHaveBeenCalledExactlyOnceWith(false, {
agentCmdOverrides: {},
// Echoed back from the disk write so installManagedAgentHooks' own authorization guard
// reads the value the CLI just set instead of falling through to the default.
agentStatusHooksEnabled: false,
disabledTuiAgents: []
})
).toHaveBeenCalledExactlyOnceWith(
false,
{
agentCmdOverrides: {},
// Echoed back from the disk write so installManagedAgentHooks' own authorization guard
// reads the value the CLI just set instead of falling through to the default.
agentStatusHooksEnabled: false,
disabledTuiAgents: []
},
// The CLI resolves its own verdict; `off` must reach the installer as a deny, never as
// an ambient allow that a stale settings read could turn into an install.
{ installDecision: { kind: 'deny', reason: 'hooks-disabled' } }
)
} else {
expect(
applyAgentStatusHooksEnabledMock,
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
detect: vi.fn(),
@@ -49,6 +49,7 @@ import {
shouldInstallStartupManagedAgentHook,
shouldContinueManagedHookStartup
} from './managed-agent-hook-controls'
import { setManagedHookInstallDecisionResolver } from './managed-hook-install-policy'
function status(agent: 'claude' | 'codex', state: 'installed' | 'not_installed') {
return {
@@ -396,3 +397,80 @@ describe('installManagedAgentHooks caller-independent off switch', () => {
expect(mocks.installCodex).toHaveBeenCalledTimes(1)
})
})
describe('installManagedAgentHooks caller-independent deferral', () => {
beforeEach(() => {
vi.clearAllMocks()
setManagedHookInstallDecisionResolver(null)
mocks.installClaude.mockReturnValue(status('claude', 'installed'))
mocks.installCodex.mockReturnValue(status('codex', 'installed'))
mocks.refreshClaude.mockResolvedValue(undefined)
mocks.refreshCodex.mockResolvedValue(undefined)
mocks.detect.mockResolvedValue({
claude: { state: 'found' },
codex: { state: 'found' }
})
})
afterEach(() => setManagedHookInstallDecisionResolver(null))
it('writes nothing when the caller passes a deferred decision', async () => {
const results = await installManagedAgentHooks(
{ agentStatusHooksEnabled: true },
{ installDecision: { kind: 'defer', reason: 'onboarding-pending' } }
)
expect(mocks.installClaude).not.toHaveBeenCalled()
// Not even the Orca-owned launcher scripts, and no PATH probing.
expect(mocks.refreshClaude).not.toHaveBeenCalled()
expect(mocks.detect).not.toHaveBeenCalled()
expect(results).toEqual([
expect.objectContaining({
agent: 'claude',
state: 'skipped',
skipReason: 'onboarding_pending'
}),
expect.objectContaining({
agent: 'codex',
state: 'skipped',
skipReason: 'onboarding_pending'
})
])
})
it('removes nothing on the deferred path — not asked is not declined (STA-5679)', async () => {
await installManagedAgentHooks(
{ agentStatusHooksEnabled: true },
{ installDecision: { kind: 'defer', reason: 'onboarding-pending' } }
)
expect(mocks.removeClaude).not.toHaveBeenCalled()
expect(mocks.removeCodex).not.toHaveBeenCalled()
expect(mocks.removeClaudeAsync).not.toHaveBeenCalled()
})
it('defers for a caller that passes no decision, when the host says defer', async () => {
// The guard has to hold for callers added later, which is the whole point of the chokepoint.
setManagedHookInstallDecisionResolver(() => ({ kind: 'defer', reason: 'onboarding-pending' }))
await installManagedAgentHooks({ agentStatusHooksEnabled: true })
expect(mocks.installClaude).not.toHaveBeenCalled()
})
it('installs for a caller that passes no decision when no host has answered', async () => {
await installManagedAgentHooks({ agentStatusHooksEnabled: true })
expect(mocks.installClaude).toHaveBeenCalledTimes(1)
})
it('denies an explicit off switch even against an allowing decision', async () => {
const results = await installManagedAgentHooks(
{ agentStatusHooksEnabled: false },
{ installDecision: { kind: 'allow', reason: 'pre-change' } }
)
expect(mocks.installClaude).not.toHaveBeenCalled()
expect(results[0]).toMatchObject({ skipReason: 'hooks_disabled' })
})
})
@@ -6,7 +6,10 @@ import {
import { normalizeDisabledTuiAgents } from '../../shared/tui-agent-selection'
import type { GlobalSettings } from '../../shared/global-settings-types'
import { detectLocalManagedAgentCliPresence } from './local-agent-cli-presence'
import { isAgentStatusHooksEnabled } from './agent-status-hooks-enablement'
import {
authorizeManagedHookInstall,
type ManagedHookInstallDecision
} from './managed-hook-install-policy'
import {
MANAGED_AGENT_HOOK_ASYNC_REMOVERS,
MANAGED_AGENT_HOOK_INSTALLERS,
@@ -33,6 +36,9 @@ type ManagedHookSettings = Partial<
type InstallOptions = {
/** Set only for an explicit user action, never for startup reconciliation. */
userInitiated?: boolean
/** The authorization for this write. Omitted means "ask the host", which installs when no host
* has answered — the CLI's own process and every pre-bootstrap caller. */
installDecision?: ManagedHookInstallDecision
shouldHydrateShellPath?: boolean
onInstallError?: (agent: AgentHookTarget, error: unknown) => void
shouldContinue?: (agent: AgentHookTarget) => boolean
@@ -119,13 +125,16 @@ export async function installManagedAgentHooks(
settings: ManagedHookSettings = null,
options: InstallOptions = {}
): Promise<AgentHookInstallStatus[]> {
// Why here and not only at the call sites: "hooks off" has to hold for every caller and every
// launch, including ones added later. Never mirrored with a remove — declined means write
// nothing, because removal deletes user-global files another Orca profile owns (STA-5679).
if (!isAgentStatusHooksEnabled(settings)) {
return selectedInstallers(options).map(([agent]) =>
skippedStatus(agent, 'hooks_disabled', 'Agent status hooks are turned off.')
)
// Why here and not only at the call sites: authorization has to hold for every caller and every
// launch, including ones added later. Never mirrored with a remove — neither "declined" nor
// "not asked yet" may delete user-global files another Orca profile owns (STA-5679).
const decision = authorizeManagedHookInstall(settings, options.installDecision)
if (decision.kind !== 'allow') {
const [skipReason, detail] =
decision.kind === 'deny'
? (['hooks_disabled', 'Agent status hooks are turned off.'] as const)
: (['onboarding_pending', 'Waiting for the first-run agent status question.'] as const)
return selectedInstallers(options).map(([agent]) => skippedStatus(agent, skipReason, detail))
}
await refreshExistingManagedScripts(options)
const installers = selectedInstallers(options)
@@ -1,90 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { GlobalSettings } from '../../shared/global-settings-types'
import {
isManagedHookFirstRunGatePending,
isManagedHookInstallDeferredForFirstRun
} from './managed-hook-first-run-gate'
type Latch = GlobalSettings['managedAgentHookFirstRunGate']
const cases: {
name: string
latch: Latch
closedAt: number | null
lastCompletedStep: number
deferred: boolean
}[] = [
{
name: 'fresh profile that has not passed step 1',
latch: 'pending',
closedAt: null,
lastCompletedStep: -1,
deferred: true
},
{
name: 'fresh profile still sitting on step 0',
latch: 'pending',
closedAt: null,
lastCompletedStep: 0,
deferred: true
},
{
name: 'fresh profile that just passed step 1',
latch: 'pending',
closedAt: null,
lastCompletedStep: 1,
deferred: false
},
{
name: 'fresh profile whose wizard was dismissed',
latch: 'pending',
closedAt: 1,
lastCompletedStep: -1,
deferred: false
},
{
name: 'retired latch, wizard re-opened to the start',
latch: 'done',
closedAt: null,
lastCompletedStep: -1,
deferred: false
},
{
name: 'pre-release profile with no latch at all',
latch: undefined,
closedAt: null,
lastCompletedStep: -1,
deferred: false
}
]
describe('isManagedHookInstallDeferredForFirstRun', () => {
for (const testCase of cases) {
it(`${testCase.deferred ? 'defers' : 'does not defer'} for a ${testCase.name}`, () => {
expect(
isManagedHookInstallDeferredForFirstRun({
onboarding: {
closedAt: testCase.closedAt,
lastCompletedStep: testCase.lastCompletedStep
},
settings: { managedAgentHookFirstRunGate: testCase.latch }
})
).toBe(testCase.deferred)
})
}
it('fails open when settings are unavailable', () => {
const onboarding = { closedAt: null, lastCompletedStep: -1 }
expect(isManagedHookInstallDeferredForFirstRun({ onboarding, settings: null })).toBe(false)
expect(isManagedHookInstallDeferredForFirstRun({ onboarding, settings: undefined })).toBe(false)
})
})
describe('isManagedHookFirstRunGatePending', () => {
it('is true only while the latch is armed', () => {
expect(isManagedHookFirstRunGatePending({ managedAgentHookFirstRunGate: 'pending' })).toBe(true)
expect(isManagedHookFirstRunGatePending({ managedAgentHookFirstRunGate: 'done' })).toBe(false)
expect(isManagedHookFirstRunGatePending({})).toBe(false)
expect(isManagedHookFirstRunGatePending(null)).toBe(false)
})
})
@@ -1,25 +0,0 @@
import type { GlobalSettings } from '../../shared/global-settings-types'
import type { OnboardingState } from '../../shared/onboarding-state-types'
type FirstRunGateSettings = Pick<GlobalSettings, 'managedAgentHookFirstRunGate'> | null | undefined
/**
* A fresh profile must not write user-global agent configs until the user has either passed
* onboarding step 1 **or** left onboarding. Leaving counts deliberately: an accidental Esc must
* not leave agent status permanently broken, and by then the user has seen step 1 with the box in
* whatever state they left it. Kept apart from `resolveStartupManagedHookAction`: that answers
* "what does this profile's off switch say", a different axis from first-run progress.
*/
export function isManagedHookInstallDeferredForFirstRun(input: {
onboarding: Pick<OnboardingState, 'closedAt' | 'lastCompletedStep'>
settings: FirstRunGateSettings
}): boolean {
const hasPassedStepOneOrLeft =
input.onboarding.closedAt !== null || input.onboarding.lastCompletedStep >= 1
return input.settings?.managedAgentHookFirstRunGate === 'pending' && !hasPassedStepOneOrLeft
}
/** The latch is armed, i.e. this profile has never taken a non-deferring launch. */
export function isManagedHookFirstRunGatePending(settings: FirstRunGateSettings): boolean {
return settings?.managedAgentHookFirstRunGate === 'pending'
}
@@ -0,0 +1,141 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
getManagedHookInstallDecision,
resolveManagedHookInstallDecision,
setManagedHookInstallDecisionResolver,
type ManagedHookInstallationMarker,
type ManagedHookInstallHostMode
} from './managed-hook-install-policy'
const PRE_CHANGE: ManagedHookInstallationMarker = {
installCohort: 'pre-change',
onboardingDecision: 'passed'
}
const FRESH_PENDING: ManagedHookInstallationMarker = {
installCohort: 'post-change',
onboardingDecision: 'pending'
}
const FRESH_PASSED: ManagedHookInstallationMarker = {
installCohort: 'post-change',
onboardingDecision: 'passed'
}
// What a torn or hand-edited record normalizes to. The parser answers `null`, and `null` means the
// installation is unrecorded, which is the pre-change cohort — never pending.
const UNRECORDED = undefined
afterEach(() => setManagedHookInstallDecisionResolver(null))
describe('getManagedHookInstallDecision', () => {
const cases: {
name: string
enabled?: boolean
installation: ManagedHookInstallationMarker | undefined
mode: ManagedHookInstallHostMode
expected: ReturnType<typeof getManagedHookInstallDecision>
}[] = [
{
name: 'an existing user upgrading installs, onboarding state notwithstanding',
installation: PRE_CHANGE,
mode: 'desktop',
expected: { kind: 'allow', reason: 'pre-change' }
},
{
name: 'an unrecorded installation installs',
installation: UNRECORDED,
mode: 'desktop',
expected: { kind: 'allow', reason: 'pre-change' }
},
{
name: 'a fresh desktop install defers until the question is answered',
installation: FRESH_PENDING,
mode: 'desktop',
expected: { kind: 'defer', reason: 'onboarding-pending' }
},
{
name: 'a fresh install that answered installs',
installation: FRESH_PASSED,
mode: 'desktop',
expected: { kind: 'allow', reason: 'onboarding-passed' }
},
{
name: 'a serve host never defers — it never paints the wizard',
installation: FRESH_PENDING,
mode: 'serve',
expected: { kind: 'allow', reason: 'headless' }
},
{
name: 'orcad never defers',
installation: FRESH_PENDING,
mode: 'orcad',
expected: { kind: 'allow', reason: 'headless' }
},
{
name: 'the CLI never defers',
installation: FRESH_PENDING,
mode: 'cli',
expected: { kind: 'allow', reason: 'headless' }
},
{
name: 'the off switch outranks the pre-change cohort',
enabled: false,
installation: PRE_CHANGE,
mode: 'desktop',
expected: { kind: 'deny', reason: 'hooks-disabled' }
},
{
name: 'the off switch outranks a headless host',
enabled: false,
installation: FRESH_PENDING,
mode: 'serve',
expected: { kind: 'deny', reason: 'hooks-disabled' }
},
{
name: 'the off switch outranks a passed onboarding',
enabled: false,
installation: FRESH_PASSED,
mode: 'desktop',
expected: { kind: 'deny', reason: 'hooks-disabled' }
}
]
for (const testCase of cases) {
it(testCase.name, () => {
expect(
getManagedHookInstallDecision({
settings:
testCase.enabled === undefined ? {} : { agentStatusHooksEnabled: testCase.enabled },
installation: testCase.installation,
mode: testCase.mode
})
).toEqual(testCase.expected)
})
}
it('treats an absent settings object as on, because the default is on', () => {
expect(
getManagedHookInstallDecision({ settings: null, installation: PRE_CHANGE, mode: 'desktop' })
).toEqual({ kind: 'allow', reason: 'pre-change' })
})
})
describe('resolveManagedHookInstallDecision without a host resolver', () => {
it('installs, because an unestablished installation is an ambiguous one', () => {
expect(resolveManagedHookInstallDecision({})).toEqual({ kind: 'allow', reason: 'pre-change' })
})
it('still denies an explicit off switch, so no caller can install against it', () => {
expect(resolveManagedHookInstallDecision({ agentStatusHooksEnabled: false })).toEqual({
kind: 'deny',
reason: 'hooks-disabled'
})
})
it('uses the host resolver once one is installed', () => {
setManagedHookInstallDecisionResolver(() => ({ kind: 'defer', reason: 'onboarding-pending' }))
expect(resolveManagedHookInstallDecision({})).toEqual({
kind: 'defer',
reason: 'onboarding-pending'
})
})
})
@@ -0,0 +1,110 @@
import type { GlobalSettings } from '../../shared/global-settings-types'
export type ManagedHookInstallCohort = 'pre-change' | 'post-change'
export type ManagedHookOnboardingDecision = 'pending' | 'passed'
/**
* The installation-scoped record the policy reads. Declared here, next to its only consumer, so
* the `orca` CLI can compile the policy without the persistence module that writes it.
*/
export type ManagedHookInstallationMarker = {
installCohort: ManagedHookInstallCohort
onboardingDecision: ManagedHookOnboardingDecision
}
/**
* The single authorization answer every writer of user-global agent config must obtain.
*
* A tri-state, not a boolean: `false` at the writer boundary means "remove", so "not asked yet" was
* previously unrepresentable and any caller that synthesized one swept files a different Orca
* profile owns (STA-5679). `defer` writes nothing and removes nothing.
*/
export type ManagedHookInstallDecision =
| { kind: 'allow'; reason: 'pre-change' | 'onboarding-passed' | 'headless' }
| { kind: 'defer'; reason: 'onboarding-pending' }
| { kind: 'deny'; reason: 'hooks-disabled' }
/** Only `desktop` paints the onboarding question; every other host installs as it always has. */
export type ManagedHookInstallHostMode = 'desktop' | 'serve' | 'orcad' | 'cli'
export type ManagedHookInstallPolicySettings =
| Partial<Pick<GlobalSettings, 'agentStatusHooksEnabled'>>
| null
| undefined
const ALLOW_PRE_CHANGE: ManagedHookInstallDecision = { kind: 'allow', reason: 'pre-change' }
const ALLOW_HEADLESS: ManagedHookInstallDecision = { kind: 'allow', reason: 'headless' }
const ALLOW_PASSED: ManagedHookInstallDecision = { kind: 'allow', reason: 'onboarding-passed' }
const DEFER_PENDING: ManagedHookInstallDecision = { kind: 'defer', reason: 'onboarding-pending' }
const DENY_DISABLED: ManagedHookInstallDecision = { kind: 'deny', reason: 'hooks-disabled' }
function isExplicitlyDisabled(settings: ManagedHookInstallPolicySettings): boolean {
return settings?.agentStatusHooksEnabled === false
}
export function getManagedHookInstallDecision(input: {
settings: ManagedHookInstallPolicySettings
/** Omitted by hosts that establish no marker; an unrecorded installation is a pre-change one. */
installation?: ManagedHookInstallationMarker
mode: ManagedHookInstallHostMode
}): ManagedHookInstallDecision {
// Order is load-bearing. The off switch wins over everything, including the pre-change cohort.
if (isExplicitlyDisabled(input.settings)) {
return DENY_DISABLED
}
if (input.installation?.installCohort !== 'post-change') {
return ALLOW_PRE_CHANGE
}
if (input.mode !== 'desktop') {
return ALLOW_HEADLESS
}
return input.installation.onboardingDecision === 'pending' ? DEFER_PENDING : ALLOW_PASSED
}
/**
* The verdict the install chokepoint acts on.
*
* A caller may supply its own decision — the startup pass and the CLI both do, so their plan and
* their write cannot disagree — but the off switch is never overridable that way. "Unchecked never
* installs, from any caller" has to hold even for a caller whose decision was computed from a
* settings snapshot that has since gone stale.
*/
export function authorizeManagedHookInstall(
settings: ManagedHookInstallPolicySettings,
supplied?: ManagedHookInstallDecision
): ManagedHookInstallDecision {
if (isExplicitlyDisabled(settings)) {
return DENY_DISABLED
}
return supplied ?? resolveManagedHookInstallDecision(settings)
}
type ManagedHookInstallDecisionResolver = (
settings: ManagedHookInstallPolicySettings
) => ManagedHookInstallDecision
let resolver: ManagedHookInstallDecisionResolver | null = null
/** Installed by the desktop bootstrap once the installation marker and host mode are known. */
export function setManagedHookInstallDecisionResolver(
next: ManagedHookInstallDecisionResolver | null
): void {
resolver = next
}
/**
* The ambient decision for hosts that do not pass one explicitly.
*
* With no resolver — the `orca` CLI's own process, `orcad`, a unit test, a boot step that runs
* before the marker exists — this answers `allow`, because an unestablished installation is an
* ambiguous one and ambiguity installs. The off switch is still honoured here so "unchecked never
* installs, from any caller" does not depend on the resolver being wired.
*/
export function resolveManagedHookInstallDecision(
settings: ManagedHookInstallPolicySettings
): ManagedHookInstallDecision {
if (resolver) {
return resolver(settings)
}
return isExplicitlyDisabled(settings) ? DENY_DISABLED : ALLOW_PRE_CHANGE
}
@@ -0,0 +1,153 @@
import { readFileSync, readdirSync, statSync } from 'node:fs'
import { join, relative, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
/**
* Keep every writer of user-global agent config behind one authorization verdict.
*
* `installManagedAgentHooks` is the chokepoint and resolves the verdict itself, so its callers need
* nothing. The writers below bypass it — they write a remote host's configs, a WSL guest's, or the
* user's real `~/.codex` — so each must obtain the verdict for itself. Four independent passes over
* this codebase counted the writers as 5, then 7, then 14, then ~20; this test is what stops the
* next one from being missed.
*
* WHAT THIS CANNOT CATCH, and is not evidence about:
* - a file that names the policy but never lets it decide. This scans for the reference, not for
* the call being on the write path, so it proves a writer was CONNECTED to the policy once, not
* that it still obeys it. Measured: replacing a real call with a hardcoded `allow` while leaving
* the import in place keeps this green. Only the behavioural tests next to each writer catch it;
* - a dynamic `require()` or a string-built import of any of these modules;
* - a writer reached through an injected callback (`deps.installHooks`), which is exactly how
* `wsl-hook-fs-adapter.ts` works — its gate lives in the caller, not in it;
* - a shell command generated as text and run on a remote host or inside a distro;
* - the `orca` CLI's own process, or the relay bundle, which are separate programs;
* - a future writer that edits a config file through a lower-level helper this list does not name.
* Those remain review and integration-test responsibilities.
*/
/** Writers that do NOT pass through `installManagedAgentHooks`. */
const BYPASSING_WRITER_SYMBOLS = [
'ensureRealHomeCodexHookState',
'installRemoteManagedAgentHooks',
'installWslGuestHooks'
] as const
/** The modules that DEFINE the writers above. A definition is not a call site. */
const WRITER_OWNER_MODULES = [
'src/main/codex/codex-real-home-hook-install.ts',
'src/main/agent-hooks/remote-managed-hook-installers.ts'
] as const
/**
* Accepted evidence that a file obtained the verdict: the policy module itself, or one of the two
* named adapters that do nothing but return it.
*/
const POLICY_EVIDENCE = [
'managed-hook-install-policy',
'resolveStartupManagedHookPlan',
'isWslGuestManagedHookInstallAllowed'
] as const
/**
* Files that import a bypassing writer and legitimately hold no verdict. Each line is a reason, not
* a parking space; the list may only shrink.
*/
const WRITER_BOUNDARY_ALLOWLIST: Record<string, string> = {
// Runs inside the relay on the remote host or WSL guest. The host decides whether to send the
// install RPC at all; this process has no settings store to consult.
'src/main/agent-hooks/managed-hook-runtime.ts': 'relay-side receiver, gated by the sender',
// Takes the installer as an injected `typeof` parameter. Its only caller,
// wsl-hook-relay-guest-install.ts, holds the gate.
'src/main/agent-hooks/wsl-hook-fs-adapter.ts': 'injected installer, gated by its caller',
// Declares the installer as the production default of a DI seam and gates relay start on the
// verdict through isWslHookRelayAllowed.
'src/main/agent-hooks/wsl-hook-relay-deps.ts': 'owns the gate itself'
}
const SCANNED_EXTENSIONS = ['.ts', '.tsx']
const IGNORED_DIRECTORIES = new Set([
'node_modules',
'dist',
'out',
'build',
'.git',
'__fixtures__'
])
function isTestFile(path: string): boolean {
return /\.(?:test|spec)\.tsx?$/.test(path) || path.includes('/__tests__/')
}
function collectSourceFiles(root: string): string[] {
let found: string[] = []
let entries: string[]
try {
entries = readdirSync(root)
} catch {
return found
}
for (const entry of entries) {
if (IGNORED_DIRECTORIES.has(entry)) {
continue
}
const full = join(root, entry)
if (statSync(full).isDirectory()) {
found = found.concat(collectSourceFiles(full))
continue
}
if (SCANNED_EXTENSIONS.some((extension) => full.endsWith(extension))) {
found.push(full)
}
}
return found
}
/** Drop comment-only lines so prose naming a writer is not an offender. */
function codeText(contents: string): string {
return contents
.split('\n')
.filter((line) => !/^\s*(?:\/\/|\/\*|\*)/.test(line))
.join('\n')
}
const repoRoot = resolve(__dirname, '..', '..', '..')
const files = collectSourceFiles(join(repoRoot, 'src'))
.map((file) => relative(repoRoot, file).split('\\').join('/'))
.filter((path) => !isTestFile(path))
const writers = files
.map((path) => ({ path, code: codeText(readFileSync(join(repoRoot, path), 'utf8')) }))
.filter(({ code }) => BYPASSING_WRITER_SYMBOLS.some((symbol) => code.includes(symbol)))
.filter(({ path }) => !WRITER_OWNER_MODULES.some((owner) => path === owner))
describe('managed hook writer boundary', () => {
it('scans a plausible number of files', () => {
// A broken root or extension list would make the guard silently vacuous.
expect(files.length).toBeGreaterThan(500)
})
it('finds the writers it is meant to be guarding', () => {
// Renaming every symbol out from under this list would otherwise leave it green and empty.
expect(writers.length).toBeGreaterThanOrEqual(6)
})
it('gives every bypassing writer its own policy verdict', () => {
const unguarded = writers
.filter(({ path }) => !(path in WRITER_BOUNDARY_ALLOWLIST))
.filter(({ code }) => !POLICY_EVIDENCE.some((evidence) => code.includes(evidence)))
.map(({ path }) => path)
expect(
unguarded,
'This file writes user-global agent config without asking the install policy. Call ' +
'resolveManagedHookInstallDecision from src/main/agent-hooks/managed-hook-install-policy.'
).toEqual([])
})
it('has no stale allowlist entry', () => {
const scanned = new Set(writers.map(({ path }) => path))
const stale = Object.keys(WRITER_BOUNDARY_ALLOWLIST).filter((path) => !scanned.has(path))
expect(stale, 'Allowlist entry no longer touches a writer — delete the line.').toEqual([])
})
})
+11 -3
View File
@@ -4,7 +4,7 @@
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { isAgentStatusHooksEnabled } from './managed-agent-hook-controls'
import { resolveManagedHookInstallDecision } from './managed-hook-install-policy'
import { agentHookServer } from './server'
import type { ManagedHookDetectionSettings } from './managed-hook-detection-commands'
import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers'
@@ -72,15 +72,23 @@ export type WslHookRelayManagerDeps = {
}
/** Every relay start — spawn, PTY reattach, crash recovery — funnels through this gate,
* so the user's agent-status-hooks switch is read live instead of at each call site. */
* so the install authorization is read live instead of at each call site. */
export function isWslHookRelayAllowed(deps: WslHookRelayManagerDeps): boolean {
return (
deps.platform() === 'win32' &&
deps.remoteHooksEnabled() &&
isAgentStatusHooksEnabled(deps.managedHookSettings())
isWslGuestManagedHookInstallAllowed(deps)
)
}
/** The guest's ~/.claude, ~/.codex and friends are user-global inside the distro, so the same
* policy that guards the host's writers guards these. */
export function isWslGuestManagedHookInstallAllowed(
deps: Pick<WslHookRelayManagerDeps, 'managedHookSettings'>
): boolean {
return resolveManagedHookInstallDecision(deps.managedHookSettings()).kind === 'allow'
}
export const defaultWslHookRelayDeps: WslHookRelayManagerDeps = {
platform: () => process.platform,
remoteHooksEnabled: () => isRemoteAgentHooksEnabled(),
@@ -6,7 +6,11 @@ import type { ManagedHookDetectionSettings } from './managed-hook-detection-comm
import type { installRemoteManagedAgentHooks } from './remote-managed-hook-installers'
import { requestGuestOpenCodeOverlayDir } from './wsl-guest-plugin-install'
import { installWslGuestHooks } from './wsl-hook-fs-adapter'
import { REINSTALL_MIN_INTERVAL_MS, type WslHookRelayManagerDeps } from './wsl-hook-relay-deps'
import {
isWslGuestManagedHookInstallAllowed,
REINSTALL_MIN_INTERVAL_MS,
type WslHookRelayManagerDeps
} from './wsl-hook-relay-deps'
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import type { PluginSources } from '../../relay/plugin-overlay'
@@ -35,6 +39,11 @@ export async function runWslRelayGuestInstall(
mux: SshChannelMultiplexer,
guestHome: string
): Promise<void> {
// Why re-checked here and not only at relay start: the rate-limited rerun below fires on a relay
// that is already up, long after the start gate answered.
if (!isWslGuestManagedHookInstallAllowed(deps)) {
return
}
state.lastInstallAt = Date.now()
await installWslGuestHooks({
mux,
@@ -0,0 +1,88 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { setManagedHookInstallDecisionResolver } from './managed-hook-install-policy'
import {
isWslGuestManagedHookInstallAllowed,
isWslHookRelayAllowed,
type WslHookRelayManagerDeps
} from './wsl-hook-relay-deps'
import { runWslRelayGuestInstall } from './wsl-hook-relay-guest-install'
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
const { installWslGuestHooksMock, requestOverlayMock } = vi.hoisted(() => ({
installWslGuestHooksMock: vi.fn(async () => undefined),
requestOverlayMock: vi.fn(async () => ({ kind: 'none' }) as const)
}))
vi.mock('./wsl-hook-fs-adapter', () => ({ installWslGuestHooks: installWslGuestHooksMock }))
vi.mock('./wsl-guest-plugin-install', () => ({
requestGuestOpenCodeOverlayDir: requestOverlayMock
}))
function relayDeps(enabled?: boolean): WslHookRelayManagerDeps {
return {
platform: () => 'win32',
remoteHooksEnabled: () => true,
managedHookSettings: () => (enabled === undefined ? {} : { agentStatusHooksEnabled: enabled })
} as unknown as WslHookRelayManagerDeps
}
function guestDeps(enabled?: boolean) {
return {
installHooks: vi.fn(),
installCodex: vi.fn(),
managedHookSettings: relayDeps(enabled).managedHookSettings,
pluginSources: () => ({ opencodePluginSource: '' }),
warn: vi.fn()
}
}
beforeEach(() => {
installWslGuestHooksMock.mockClear()
requestOverlayMock.mockClear()
setManagedHookInstallDecisionResolver(null)
})
afterEach(() => setManagedHookInstallDecisionResolver(null))
describe('WSL hook relay install authorization', () => {
it('starts the relay for an installation that is not deferring', () => {
expect(isWslHookRelayAllowed(relayDeps())).toBe(true)
})
it('does not start the relay while the first-run question is unanswered', () => {
setManagedHookInstallDecisionResolver(() => ({ kind: 'defer', reason: 'onboarding-pending' }))
expect(isWslHookRelayAllowed(relayDeps())).toBe(false)
})
it('does not start the relay with hooks turned off', () => {
expect(isWslHookRelayAllowed(relayDeps(false))).toBe(false)
})
it('is what the guest install pass consults too', () => {
setManagedHookInstallDecisionResolver(() => ({ kind: 'defer', reason: 'onboarding-pending' }))
expect(isWslGuestManagedHookInstallAllowed(guestDeps())).toBe(false)
})
})
describe('runWslRelayGuestInstall', () => {
const mux = { isDisposed: () => false } as unknown as SshChannelMultiplexer
it("writes nothing in the guest's home while the install is deferred", async () => {
setManagedHookInstallDecisionResolver(() => ({ kind: 'defer', reason: 'onboarding-pending' }))
const state = { distro: 'Ubuntu' }
await runWslRelayGuestInstall(guestDeps(), state, mux, '/home/tester')
// The rate-limited rerun fires on a live relay, long after the start gate answered.
expect(installWslGuestHooksMock).not.toHaveBeenCalled()
expect(state).not.toHaveProperty('lastInstallAt')
})
it('installs in the guest when the install is allowed', async () => {
await runWslRelayGuestInstall(guestDeps(), { distro: 'Ubuntu' }, mux, '/home/tester')
expect(installWslGuestHooksMock).toHaveBeenCalledTimes(1)
})
})
@@ -24,6 +24,12 @@ import {
snapshotCodexRuntimeHookTrustProvenance
} from './hook-trust-promotion'
/**
* Refresh Orca's own profile-local managed Codex home. Deliberately does NOT run the legacy
* cleanup: that sweeps the user's real ~/.codex/config.toml, which an ordinary launch of a managed
* home has no business touching. `removeCodexHooksExclusively` still calls it on both of its paths,
* so an explicit opt-out converges exactly as before.
*/
export async function refreshCodexRuntimeUserHooksExclusively(
runtimeHomePath: string,
getStatus: (runtimeHomePath: string) => AgentHookInstallStatus
@@ -34,8 +40,6 @@ export async function refreshCodexRuntimeUserHooksExclusively(
promoteCodexRuntimeHookApprovalsToSystem(runtimeHomePath)
const config = readHooksJson(configPath)
if (!config) {
// Why: disabled launch prep once called remove(); preserve that legacy cleanup even when runtime hooks.json is malformed.
await cleanupLegacyManagedHookRepresentations()
return {
agent: 'codex',
state: 'error',
@@ -84,7 +88,6 @@ export async function refreshCodexRuntimeUserHooksExclusively(
}
snapshotCodexRuntimeHookTrustProvenance(runtimeHomePath)
await cleanupLegacyManagedHookRepresentations()
return getStatus(runtimeHomePath)
}
@@ -0,0 +1,103 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
/**
* `refreshCodexRuntimeUserHooksExclusively` maintains Orca's own profile-local managed Codex home.
* It used to also run the legacy sweep, which mutates the user's REAL `~/.codex/config.toml` — so
* an ordinary pane launch rewrote a file outside Orca's control. Explicit removal still sweeps.
*
* The assertion is at the call boundary, not on the filesystem: it proves the refresh no longer
* reaches the sweep, not independently that the sweep is the only thing that writes ~/.codex.
*/
const { cleanupLegacyMock, readHooksJsonMock, hookPlanMock, systemHomeMock } = vi.hoisted(() => ({
cleanupLegacyMock: vi.fn(async () => undefined),
readHooksJsonMock: vi.fn(),
hookPlanMock: vi.fn(),
systemHomeMock: vi.fn(() => '/home/tester/.codex')
}))
vi.mock('./codex-hook-legacy-cleanup', () => ({
cleanupLegacyManagedHookRepresentations: cleanupLegacyMock
}))
vi.mock('../agent-hooks/installer-utils', () => ({
createManagedCommandMatcher: vi.fn(() => () => false),
readHooksJson: readHooksJsonMock,
removeManagedCommands: vi.fn((definitions: unknown[]) => definitions)
}))
vi.mock('./codex-hook-user-mirroring', () => ({
applyMirroredRuntimeUserHookTrustStates: vi.fn(),
getRuntimeHooksWithSystemUserHooks: hookPlanMock
}))
vi.mock('./codex-hook-definition', () => ({
getCodexConfigTomlPath: (home: string) => `${home}/config.toml`,
getConfigPath: (home?: string) => `${home ?? '/home/tester/.codex'}/hooks.json`,
writeCodexHooksJson: vi.fn()
}))
vi.mock('./codex-config-mirror', () => ({ syncSystemConfigIntoManagedCodexHome: vi.fn() }))
vi.mock('./config-toml-trust', () => ({ upsertHookTrustEntries: vi.fn() }))
vi.mock('./codex-hook-identity', () => ({ getCodexManagedScriptFileName: () => 'orca-hook.sh' }))
vi.mock('./codex-hook-trust-cleanup', () => ({
removeRuntimeManagedHookTrustEntries: vi.fn(),
removeStaleRuntimeHookTrustEntries: vi.fn()
}))
vi.mock('./codex-home-paths', () => ({ getSystemCodexHomePath: systemHomeMock }))
vi.mock('./hook-trust-promotion', () => ({
promoteCodexRuntimeHookApprovalsToSystem: vi.fn(),
snapshotCodexRuntimeHookTrustProvenance: vi.fn()
}))
vi.mock('node:fs', () => ({ existsSync: () => false }))
import {
refreshCodexRuntimeUserHooksExclusively,
removeCodexHooksExclusively
} from './codex-hook-local-maintenance'
const STATUS: AgentHookInstallStatus = {
agent: 'codex',
state: 'installed',
configPath: '/orca/managed/.codex/hooks.json',
managedHooksPresent: true,
detail: null
}
beforeEach(() => {
cleanupLegacyMock.mockClear()
readHooksJsonMock.mockReturnValue({ hooks: {} })
hookPlanMock.mockReturnValue({ hooks: {}, trustEntries: [] })
})
describe('refreshCodexRuntimeUserHooksExclusively', () => {
it('never sweeps the real ~/.codex on an ordinary managed-home refresh', async () => {
await refreshCodexRuntimeUserHooksExclusively('/orca/managed/.codex', () => STATUS)
expect(cleanupLegacyMock).not.toHaveBeenCalled()
})
it('never sweeps it when the managed hooks.json is unparseable either', async () => {
readHooksJsonMock.mockReturnValue(null)
const status = await refreshCodexRuntimeUserHooksExclusively(
'/orca/managed/.codex',
() => STATUS
)
expect(status.state).toBe('error')
expect(cleanupLegacyMock).not.toHaveBeenCalled()
})
})
describe('removeCodexHooksExclusively', () => {
it('still sweeps, because opt-out convergence is a deliberate user action', async () => {
await removeCodexHooksExclusively(() => STATUS)
expect(cleanupLegacyMock).toHaveBeenCalledTimes(1)
})
it('still sweeps when the hooks.json is unparseable', async () => {
readHooksJsonMock.mockReturnValue(null)
await removeCodexHooksExclusively(() => STATUS)
expect(cleanupLegacyMock).toHaveBeenCalledTimes(1)
})
})
+58 -21
View File
@@ -10,13 +10,16 @@ const {
removeHandlerMock,
installManagedAgentHooksMock,
recordManagedHookInstallFailureMock,
sanitizeOnboardingUpdateMock
sanitizeOnboardingUpdateMock,
installationFake
} = vi.hoisted(() => ({
handleMock: vi.fn(),
removeHandlerMock: vi.fn(),
installManagedAgentHooksMock: vi.fn(),
recordManagedHookInstallFailureMock: vi.fn(),
sanitizeOnboardingUpdateMock: vi.fn((updates: unknown) => updates)
sanitizeOnboardingUpdateMock: vi.fn((updates: unknown) => updates),
// Stands in for the installation-scoped marker file the desktop bootstrap established.
installationFake: { pending: true, markerWriteFails: false, passedWrites: 0 }
}))
vi.mock('electron', () => ({
@@ -25,7 +28,20 @@ vi.mock('electron', () => ({
}))
vi.mock('../persistence', () => ({
sanitizeOnboardingUpdate: sanitizeOnboardingUpdateMock
sanitizeOnboardingUpdate: sanitizeOnboardingUpdateMock,
getCanonicalUserDataPath: () => '/tmp/orca-onboarding-hooks-test'
}))
vi.mock('../persistence/managed-hook-installation-marker', () => ({
isManagedHookOnboardingPending: () => installationFake.pending,
recordManagedHookOnboardingPassed: () => {
installationFake.passedWrites += 1
if (installationFake.markerWriteFails) {
return false
}
installationFake.pending = false
return true
}
}))
// Only the installer is faked: the registry behind the real module pulls in every per-agent hook
@@ -62,11 +78,7 @@ function createStoreFake(initial: {
lastCompletedStep: -1,
...initial.onboarding
} as OnboardingState
let settings = {
disabledTuiAgents: [],
managedAgentHookFirstRunGate: 'pending',
...initial.settings
} as GlobalSettings
let settings = { disabledTuiAgents: [], ...initial.settings } as GlobalSettings
return {
getOnboarding: vi.fn(() => onboarding),
getSettings: vi.fn(() => settings),
@@ -117,16 +129,19 @@ describe('onboarding:update first-run managed hook install', () => {
removeHandlerMock.mockReset()
installManagedAgentHooksMock.mockReset().mockResolvedValue([])
recordManagedHookInstallFailureMock.mockReset()
installationFake.pending = true
installationFake.markerWriteFails = false
installationFake.passedWrites = 0
})
it('installs once when passing step 1 lifts the deferral, and retires the latch', () => {
it('installs once when passing step 1 lifts the deferral, and records the answer', () => {
const store = createStoreFake({})
const update = registerAndGetUpdateHandler(store)
update({ lastCompletedStep: 1 })
expect(installManagedAgentHooksMock).toHaveBeenCalledTimes(1)
expect(store.getSettings().managedAgentHookFirstRunGate).toBe('done')
expect(installationFake.pending).toBe(false)
})
it('never marks the first-run install user-initiated', () => {
@@ -140,24 +155,26 @@ describe('onboarding:update first-run managed hook install', () => {
expect(installManagedAgentHooksMock.mock.calls[0][1]).not.toHaveProperty('userInitiated', true)
})
it('installs nothing when the user unchecked the box, but still retires the latch', () => {
it('installs nothing when the user unchecked the box, but still records the answer', () => {
const store = createStoreFake({ settings: { agentStatusHooksEnabled: false } })
const update = registerAndGetUpdateHandler(store)
update({ lastCompletedStep: 1 })
expect(installManagedAgentHooksMock).not.toHaveBeenCalled()
expect(store.getSettings().managedAgentHookFirstRunGate).toBe('done')
// Recording matters even here: leaving it pending would defer forever, so re-enabling in
// Settings later would never install.
expect(installationFake.pending).toBe(false)
})
it('retires the latch and installs when the wizard is dismissed instead of advanced', () => {
it('records the answer and installs when the wizard is dismissed instead of advanced', () => {
const store = createStoreFake({})
const update = registerAndGetUpdateHandler(store)
update({ closedAt: 1_700_000_000_000, outcome: 'dismissed' })
expect(installManagedAgentHooksMock).toHaveBeenCalledTimes(1)
expect(store.getSettings().managedAgentHookFirstRunGate).toBe('done')
expect(installationFake.pending).toBe(false)
})
it('stays deferred while the user is still before step 1', () => {
@@ -167,7 +184,18 @@ describe('onboarding:update first-run managed hook install', () => {
update({ lastCompletedStep: 0 })
expect(installManagedAgentHooksMock).not.toHaveBeenCalled()
expect(store.getSettings().managedAgentHookFirstRunGate).toBe('pending')
expect(installationFake.pending).toBe(true)
})
it('installs nothing and stays pending when the marker write fails', () => {
installationFake.markerWriteFails = true
const store = createStoreFake({})
const update = registerAndGetUpdateHandler(store)
update({ lastCompletedStep: 1 })
expect(installManagedAgentHooksMock).not.toHaveBeenCalled()
expect(installationFake.pending).toBe(true)
})
it('does not install again on a later update in the same run', () => {
@@ -192,14 +220,16 @@ describe('onboarding:update first-run managed hook install', () => {
expect(installManagedAgentHooksMock).toHaveBeenCalledTimes(1)
})
it('leaves a pre-release profile with no latch completely alone', () => {
const store = createStoreFake({ settings: { managedAgentHookFirstRunGate: undefined } })
it('leaves a pre-change installation completely alone', () => {
installationFake.pending = false
const store = createStoreFake({})
const update = registerAndGetUpdateHandler(store)
update({ lastCompletedStep: 1 })
expect(installManagedAgentHooksMock).not.toHaveBeenCalled()
expect(store.updateSettings).not.toHaveBeenCalled()
expect(installationFake.passedWrites).toBe(0)
})
})
@@ -209,6 +239,9 @@ describe('onboarding:update step-1 consent transaction', () => {
removeHandlerMock.mockReset()
installManagedAgentHooksMock.mockReset().mockResolvedValue([])
recordManagedHookInstallFailureMock.mockReset()
installationFake.pending = true
installationFake.markerWriteFails = false
installationFake.passedWrites = 0
})
it('honours a declining consent even when the stored preference is still default-on', () => {
@@ -220,7 +253,7 @@ describe('onboarding:update step-1 consent transaction', () => {
expect(installManagedAgentHooksMock).not.toHaveBeenCalled()
expect(store.getSettings().agentStatusHooksEnabled).toBe(false)
expect(store.getSettings().managedAgentHookFirstRunGate).toBe('done')
expect(installationFake.pending).toBe(false)
})
it('re-enables from the consent when the stored preference says off', () => {
@@ -243,7 +276,7 @@ describe('onboarding:update step-1 consent transaction', () => {
expect(store.updateOnboarding).not.toHaveBeenCalled()
expect(store.getOnboarding().lastCompletedStep).toBe(-1)
expect(store.getSettings().managedAgentHookFirstRunGate).toBe('pending')
expect(installationFake.pending).toBe(true)
expect(installManagedAgentHooksMock).not.toHaveBeenCalled()
})
@@ -257,8 +290,9 @@ describe('onboarding:update step-1 consent transaction', () => {
expect(installManagedAgentHooksMock).not.toHaveBeenCalled()
})
it('never writes the preference for a profile that was not deferring', () => {
const store = createStoreFake({ settings: { managedAgentHookFirstRunGate: 'done' } })
it('never writes the preference for an installation that was not deferring', () => {
installationFake.pending = false
const store = createStoreFake({})
const update = registerAndGetUpdateHandler(store)
update({ lastCompletedStep: 1 }, { agentStatusHooksEnabled: false })
@@ -287,6 +321,9 @@ describe('onboarding:update install cancellation', () => {
removeHandlerMock.mockReset()
installManagedAgentHooksMock.mockReset().mockResolvedValue([])
recordManagedHookInstallFailureMock.mockReset()
installationFake.pending = true
installationFake.markerWriteFails = false
installationFake.passedWrites = 0
})
it('stops the in-flight install once the user turns hooks off in Settings', () => {
+23 -17
View File
@@ -1,5 +1,9 @@
import { app, ipcMain } from 'electron'
import { sanitizeOnboardingUpdate, type Store } from '../persistence'
import { getCanonicalUserDataPath, sanitizeOnboardingUpdate, type Store } from '../persistence'
import {
isManagedHookOnboardingPending,
recordManagedHookOnboardingPassed
} from '../persistence/managed-hook-installation-marker'
import type { OnboardingState } from '../../shared/onboarding-state-types'
import {
installManagedAgentHooks,
@@ -7,7 +11,6 @@ import {
shouldContinueManagedHookStartup
} from '../agent-hooks/managed-agent-hook-controls'
import { recordManagedHookInstallFailure } from '../agent-hooks/install-telemetry'
import { isManagedHookInstallDeferredForFirstRun } from '../agent-hooks/managed-hook-first-run-gate'
type OnboardingHandlerDeps = {
/** Live quit flag, so a Continue-triggered install stops mid-loop on shutdown. */
@@ -22,6 +25,16 @@ function readAgentStatusHooksConsent(consent: unknown): boolean | undefined {
return typeof value === 'boolean' ? value : undefined
}
/**
* Leaving counts as answering: an accidental Esc must not leave agent status permanently broken,
* and by then the user has seen step 1 with the box in whatever state they left it.
*/
function hasMovedPastTheHooksQuestion(
onboarding: Pick<OnboardingState, 'closedAt' | 'lastCompletedStep'>
): boolean {
return onboarding.closedAt !== null || onboarding.lastCompletedStep >= 1
}
export function registerOnboardingHandlers(store: Store, deps: OnboardingHandlerDeps = {}): void {
ipcMain.removeHandler('onboarding:get')
ipcMain.removeHandler('onboarding:update')
@@ -33,30 +46,23 @@ export function registerOnboardingHandlers(store: Store, deps: OnboardingHandler
ipcMain.handle(
'onboarding:update',
(_event, updates: unknown, consent: unknown): OnboardingState => {
const wasDeferred = isManagedHookInstallDeferredForFirstRun({
onboarding: store.getOnboarding(),
settings: store.getSettings()
})
const wasPending = isManagedHookOnboardingPending()
// Why persist consent before advancing: the renderer's on-change write reports no failure, so
// lifting the latch on the default-on value would install for a user who unchecked. A throw
// here aborts the advance too, keeping preference, onboarding and latch from diverging.
const consented = wasDeferred ? readAgentStatusHooksConsent(consent) : undefined
// recording the answer on the default-on value would install for a user who unchecked. A
// throw here aborts the advance too, keeping preference and marker from diverging.
const consented = wasPending ? readAgentStatusHooksConsent(consent) : undefined
if (consented !== undefined) {
store.updateSettings({ agentStatusHooksEnabled: consented })
}
const next = store.updateOnboarding(sanitizeOnboardingUpdate(updates))
if (!wasDeferred) {
if (!wasPending || !hasMovedPastTheHooksQuestion(next)) {
return next
}
// Why the deferral-lifting transition and not the step-1 crossing: Esc and skip end the
// deferral too, and by then the user has seen step 1 with the box in the state they left it.
if (
isManagedHookInstallDeferredForFirstRun({ onboarding: next, settings: store.getSettings() })
) {
// Why the write must land first: a crash between "installed" and "recorded" is survivable,
// but recording before an unchecked preference is durable would install against the box.
if (!recordManagedHookOnboardingPassed(getCanonicalUserDataPath())) {
return next
}
// Idempotency comes from the latch, so a later wizard re-open never installs again.
store.updateSettings({ managedAgentHookFirstRunGate: 'done' })
const settings = store.getSettings()
if (!isAgentStatusHooksEnabled(settings)) {
return next
@@ -37,7 +37,7 @@ vi.mock('../tray/system-tray', () => ({
vi.mock('../orca-profiles/profile-index-store', () => ({
createLocalOrcaProfile: vi.fn(),
getOrcaProfileListState: vi.fn(),
seedNewOrcaProfileTelemetryConsent: vi.fn(),
seedNewOrcaProfileInheritedConsent: vi.fn(),
setActiveOrcaProfile: vi.fn()
}))
+4 -4
View File
@@ -9,7 +9,7 @@ const {
destroySystemTrayMock,
createLocalOrcaProfileMock,
getOrcaProfileListStateMock,
seedNewOrcaProfileTelemetryConsentMock,
seedNewOrcaProfileInheritedConsentMock,
setActiveOrcaProfileMock,
transferOrcaProfileProjectMock
} = vi.hoisted(() => ({
@@ -21,7 +21,7 @@ const {
destroySystemTrayMock: vi.fn(),
createLocalOrcaProfileMock: vi.fn(),
getOrcaProfileListStateMock: vi.fn(),
seedNewOrcaProfileTelemetryConsentMock: vi.fn(),
seedNewOrcaProfileInheritedConsentMock: vi.fn(),
setActiveOrcaProfileMock: vi.fn(),
transferOrcaProfileProjectMock: vi.fn()
}))
@@ -50,7 +50,7 @@ vi.mock('../app-relaunch', () => ({
vi.mock('../orca-profiles/profile-index-store', () => ({
createLocalOrcaProfile: createLocalOrcaProfileMock,
getOrcaProfileListState: getOrcaProfileListStateMock,
seedNewOrcaProfileTelemetryConsent: seedNewOrcaProfileTelemetryConsentMock,
seedNewOrcaProfileInheritedConsent: seedNewOrcaProfileInheritedConsentMock,
setActiveOrcaProfile: setActiveOrcaProfileMock
}))
@@ -84,7 +84,7 @@ describe('registerOrcaProfileHandlers', () => {
destroySystemTrayMock.mockReset()
createLocalOrcaProfileMock.mockReset()
getOrcaProfileListStateMock.mockReset()
seedNewOrcaProfileTelemetryConsentMock.mockReset()
seedNewOrcaProfileInheritedConsentMock.mockReset()
setActiveOrcaProfileMock.mockReset()
transferOrcaProfileProjectMock.mockReset()
})
+3 -3
View File
@@ -23,7 +23,7 @@ import type {
import {
createLocalOrcaProfile,
getOrcaProfileListState,
seedNewOrcaProfileTelemetryConsent,
seedNewOrcaProfileInheritedConsent,
setActiveOrcaProfile
} from '../orca-profiles/profile-index-store'
import {
@@ -190,7 +190,7 @@ export function registerOrcaProfileHandlers(
'orcaProfiles:createLocal',
(_event, args?: CreateLocalOrcaProfileArgs): CreateLocalOrcaProfileResult => {
const result = createLocalOrcaProfile(args)
seedNewOrcaProfileTelemetryConsent(result.profile.id, store.getSettings().telemetry)
seedNewOrcaProfileInheritedConsent(result.profile.id, store.getSettings())
return result
}
)
@@ -288,7 +288,7 @@ export function registerOrcaProfileHandlers(
createCloudLinkedProfileArgsFromUnknown(rawArgs)
)
if (result.status === 'created') {
seedNewOrcaProfileTelemetryConsent(result.profile.id, store.getSettings().telemetry)
seedNewOrcaProfileInheritedConsent(result.profile.id, store.getSettings())
options.onAuthMutation?.()
}
return result
+12 -29
View File
@@ -78,6 +78,7 @@ vi.mock('../../shared/runtime-environment-store', () => ({
}))
import { registerSettingsHandlers } from './settings'
import { setManagedHookInstallDecisionResolver } from '../agent-hooks/managed-hook-install-policy'
const settingsInvokeEvent = { sender: { id: 1 } }
type SettingsChangedListener = (
@@ -118,8 +119,9 @@ describe('registerSettingsHandlers', () => {
browserWindowGetAllWindowsMock.mockReset()
store.getSettings.mockReset()
store.updateSettings.mockReset()
// Default: a profile past onboarding step 1, so the first-run gate never defers.
store.getOnboarding.mockReset().mockReturnValue({ closedAt: null, lastCompletedStep: 5 })
// Default: an installation that never defers, so the hook reconcile runs as it always has.
setManagedHookInstallDecisionResolver(null)
store.onSettingsChanged.mockClear()
})
@@ -192,16 +194,15 @@ describe('registerSettingsHandlers', () => {
)
})
it('skips the hook reconcile while the first-run gate defers, but still persists the choice', async () => {
const before = {
agentStatusHooksEnabled: true,
disabledTuiAgents: [],
managedAgentHookFirstRunGate: 'pending'
}
it('skips the hook reconcile while the install is deferred, but still persists the choice', async () => {
const before = { agentStatusHooksEnabled: true, disabledTuiAgents: [] }
const updated = { ...before, agentStatusHooksEnabled: false }
store.getSettings.mockReturnValue(before)
store.updateSettings.mockReturnValue(updated)
store.getOnboarding.mockReturnValue({ closedAt: null, lastCompletedStep: -1 })
setManagedHookInstallDecisionResolver(() => ({
kind: 'defer',
reason: 'onboarding-pending'
}))
registerSettingsHandlers(store as never)
const handler = handleMock.mock.calls.find((call) => call[0] === 'settings:set')?.[1] as (
event: typeof settingsInvokeEvent,
@@ -219,16 +220,12 @@ describe('registerSettingsHandlers', () => {
expect(result).toBe(updated)
})
it('reconciles hooks once the first-run gate has lifted', async () => {
const before = {
agentStatusHooksEnabled: true,
disabledTuiAgents: [],
managedAgentHookFirstRunGate: 'pending'
}
it('reconciles hooks once the install is no longer deferred', async () => {
const before = { agentStatusHooksEnabled: true, disabledTuiAgents: [] }
const updated = { ...before, agentStatusHooksEnabled: false }
store.getSettings.mockReturnValue(before)
store.updateSettings.mockReturnValue(updated)
store.getOnboarding.mockReturnValue({ closedAt: null, lastCompletedStep: 1 })
setManagedHookInstallDecisionResolver(() => ({ kind: 'deny', reason: 'hooks-disabled' }))
registerSettingsHandlers(store as never)
const handler = handleMock.mock.calls.find((call) => call[0] === 'settings:set')?.[1] as (
event: typeof settingsInvokeEvent,
@@ -240,20 +237,6 @@ describe('registerSettingsHandlers', () => {
expect(applyAgentStatusHooksEnabledMock).toHaveBeenCalledTimes(1)
})
it('drops a renderer attempt to write the main-owned first-run latch', async () => {
store.getSettings.mockReturnValue({ managedAgentHookFirstRunGate: 'pending' })
store.updateSettings.mockReturnValue({ managedAgentHookFirstRunGate: 'pending' })
registerSettingsHandlers(store as never)
const handler = handleMock.mock.calls.find((call) => call[0] === 'settings:set')?.[1] as (
event: typeof settingsInvokeEvent,
args: Record<string, unknown>
) => Promise<unknown>
await handler(settingsInvokeEvent, { managedAgentHookFirstRunGate: 'done' })
expect(store.updateSettings).toHaveBeenCalledWith({}, expect.anything())
})
it('rejects durable Active Server writes through generic settings:set', async () => {
store.getSettings.mockReturnValue({ activeRuntimeEnvironmentId: null })
store.updateSettings.mockReturnValue({ activeRuntimeEnvironmentId: null })
+4 -12
View File
@@ -12,7 +12,7 @@ import { SETTINGS_CHANGED_WHITELIST, type SettingsChangedKey } from '../../share
import type { AgentAwakeService } from '../agent-awake-service'
import { sanitizeFloatingWorkspaceDirectorySetting } from './floating-workspace-directory'
import { applyAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls'
import { isManagedHookInstallDeferredForFirstRun } from '../agent-hooks/managed-hook-first-run-gate'
import { resolveManagedHookInstallDecision } from '../agent-hooks/managed-hook-install-policy'
import { recordManagedHookInstallFailure } from '../agent-hooks/install-telemetry'
import { applyElectronProxySettings } from '../network/proxy-settings'
import { applyBrowserSessionProxies } from '../browser/browser-session-proxy'
@@ -55,8 +55,6 @@ function sanitizeRendererSettingsUpdate(args: Partial<GlobalSettings>): Partial<
// writes must pass the dedicated reviewed-fingerprint handlers.
delete sanitizedArgs.pluginConsents
delete sanitizedArgs.disabledPlugins
// Main-owned first-run authority: a renderer write or a replayed settings backup must not re-arm it.
delete sanitizedArgs.managedAgentHookFirstRunGate
return sanitizedArgs
}
@@ -235,15 +233,9 @@ export function registerSettingsHandlers(
('disabledTuiAgents' in sanitizedArgs &&
!haveSameDisabledTuiAgents(before.disabledTuiAgents, result.disabledTuiAgents))
// Why only the reconcile is skipped: the preference above already persisted. Reconciling here
// would install before onboarding step 1 is passed, or remove user-global hooks a different
// Orca profile owns (STA-5679).
if (
hookSettingChanged &&
!isManagedHookInstallDeferredForFirstRun({
onboarding: store.getOnboarding(),
settings: result
})
) {
// would install before the first-run question is answered, or remove user-global hooks a
// different Orca profile owns (STA-5679).
if (hookSettingChanged && resolveManagedHookInstallDecision(result).kind !== 'defer') {
try {
await applyAgentStatusHooksEnabled(result.agentStatusHooksEnabled, result, {
userInitiated: true,
@@ -0,0 +1,39 @@
import { existsSync, mkdirSync, renameSync, writeFileSync } from 'node:fs'
import { dirname } from 'node:path'
import type { GlobalSettings } from '../../shared/global-settings-types'
import { getOrcaProfileDataFile, getProfileUserDataPath } from './profile-storage-paths'
export type InheritableOrcaProfileConsent = Partial<
Pick<GlobalSettings, 'telemetry' | 'agentStatusHooksEnabled'>
> | null
// Why: a brand-new profile has no data file, so every consent-shaped default applies afresh —
// telemetry re-defaults to opted-in, and agent status hooks re-default to ON and reinstall
// user-global entries the parent profile deliberately declined. Both decisions are machine-wide,
// and the person already answered them, so the new profile inherits rather than re-asks.
export function seedNewOrcaProfileInheritedConsent(
profileId: string,
consent: InheritableOrcaProfileConsent,
userDataPath = getProfileUserDataPath()
): void {
const settings: Partial<GlobalSettings> = {}
if (consent?.telemetry) {
settings.telemetry = consent.telemetry
}
// Why the explicit boolean test: `false` is the value that must survive, and the old
// telemetry-only early return dropped it whenever the parent had no telemetry block.
if (typeof consent?.agentStatusHooksEnabled === 'boolean') {
settings.agentStatusHooksEnabled = consent.agentStatusHooksEnabled
}
if (Object.keys(settings).length === 0) {
return
}
const dataFile = getOrcaProfileDataFile(profileId, userDataPath)
if (existsSync(dataFile)) {
return
}
mkdirSync(dirname(dataFile), { recursive: true })
const tmpPath = `${dataFile}.tmp`
writeFileSync(tmpPath, JSON.stringify({ settings }, null, 2), 'utf-8')
renameSync(tmpPath, dataFile)
}
+4 -23
View File
@@ -9,7 +9,6 @@ import {
import { randomUUID } from 'node:crypto'
import { dirname } from 'node:path'
import { bestEffortFsyncDirectorySync, fsyncFileSync } from '../../shared/secure-file'
import type { GlobalSettings } from '../../shared/global-settings-types'
import {
createDefaultLocalOrcaProfile,
DEFAULT_LOCAL_ORCA_PROFILE_ID,
@@ -34,6 +33,10 @@ import {
profileBackupPath
} from './profile-storage-paths'
export {
seedNewOrcaProfileInheritedConsent,
type InheritableOrcaProfileConsent
} from './profile-consent-seeding'
export {
getOrcaProfileBrowserSessionMetaFile,
getOrcaProfileDataFile,
@@ -160,28 +163,6 @@ function copyLegacyStateToProfile(userDataPath: string, profileId: string): void
}
}
// Why: a brand-new profile has no data file, which the telemetry cohort
// migration reads as a fresh install and defaults to opted-in. Copying the
// active profile's consent block keeps an opted-out user opted out (and keeps
// one installId per install) when they create additional profiles.
export function seedNewOrcaProfileTelemetryConsent(
profileId: string,
telemetry: GlobalSettings['telemetry'],
userDataPath = getProfileUserDataPath()
): void {
if (!telemetry) {
return
}
const dataFile = getOrcaProfileDataFile(profileId, userDataPath)
if (existsSync(dataFile)) {
return
}
mkdirSync(dirname(dataFile), { recursive: true })
const tmpPath = `${dataFile}.tmp`
writeFileSync(tmpPath, JSON.stringify({ settings: { telemetry } }, null, 2), 'utf-8')
renameSync(tmpPath, dataFile)
}
function createInitialProfileIndex(now = Date.now()): OrcaProfileIndex {
const profile = createDefaultLocalOrcaProfile(now)
return {
@@ -0,0 +1,93 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import type { GlobalSettings } from '../../shared/global-settings-types'
import { getOrcaProfileDataFile } from './profile-storage-paths'
import { seedNewOrcaProfileInheritedConsent } from './profile-consent-seeding'
/**
* Hooks are user-global and telemetry is per-install, so both decisions belong to the person, not
* to the profile. A new profile that re-defaulted agentStatusHooksEnabled to ON would reinstall
* the very entries the parent profile declined.
*/
let userDataPath: string
const NEW_PROFILE = 'local-new'
const TELEMETRY: GlobalSettings['telemetry'] = {
existedBeforeTelemetryRelease: true,
optedIn: false,
installId: 'install-1'
}
beforeEach(() => {
userDataPath = mkdtempSync(join(tmpdir(), 'orca-profile-seed-'))
})
afterEach(() => {
rmSync(userDataPath, { recursive: true, force: true })
})
function seededSettings(): Partial<GlobalSettings> | null {
const dataFile = getOrcaProfileDataFile(NEW_PROFILE, userDataPath)
if (!existsSync(dataFile)) {
return null
}
return (JSON.parse(readFileSync(dataFile, 'utf-8')) as { settings: Partial<GlobalSettings> })
.settings
}
describe('seedNewOrcaProfileInheritedConsent', () => {
it("carries the parent's opt-out into the new profile", () => {
seedNewOrcaProfileInheritedConsent(
NEW_PROFILE,
{ telemetry: TELEMETRY, agentStatusHooksEnabled: false },
userDataPath
)
expect(seededSettings()).toEqual({ telemetry: TELEMETRY, agentStatusHooksEnabled: false })
})
it('carries an opt-out even when the parent has no telemetry block at all', () => {
// The old telemetry-only early return dropped the hook decision entirely on this path.
seedNewOrcaProfileInheritedConsent(
NEW_PROFILE,
{ agentStatusHooksEnabled: false },
userDataPath
)
expect(seededSettings()).toEqual({ agentStatusHooksEnabled: false })
})
it('carries an opt-in too, so the two profiles agree', () => {
seedNewOrcaProfileInheritedConsent(NEW_PROFILE, { agentStatusHooksEnabled: true }, userDataPath)
expect(seededSettings()).toEqual({ agentStatusHooksEnabled: true })
})
it('seeds telemetry alone when the parent never touched the hook switch', () => {
seedNewOrcaProfileInheritedConsent(NEW_PROFILE, { telemetry: TELEMETRY }, userDataPath)
expect(seededSettings()).toEqual({ telemetry: TELEMETRY })
})
it('writes nothing when there is no decision to inherit', () => {
seedNewOrcaProfileInheritedConsent(NEW_PROFILE, {}, userDataPath)
expect(seededSettings()).toBeNull()
})
it('never overwrites a profile that already has data', () => {
const dataFile = getOrcaProfileDataFile(NEW_PROFILE, userDataPath)
mkdirSync(join(userDataPath, 'profiles', NEW_PROFILE), { recursive: true })
writeFileSync(dataFile, JSON.stringify({ settings: { theme: 'dark' } }), 'utf-8')
seedNewOrcaProfileInheritedConsent(
NEW_PROFILE,
{ agentStatusHooksEnabled: false },
userDataPath
)
expect(seededSettings()).toEqual({ theme: 'dark' })
})
})
@@ -199,90 +199,6 @@ describe('Store.migrateTabSwitchKeybindings', () => {
})
})
describe('Store.migrateManagedAgentHookFirstRunGate', () => {
// Freezes the managed-hook first-run latch on first load so a wizard re-open can never re-arm it.
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-'))
})
afterEach(() => {
rmSync(testState.dir, { recursive: true, force: true })
})
it('arms the latch on a truly fresh install so startup defers the hook install', async () => {
const store = await createStore()
expect(store.getSettings().managedAgentHookFirstRunGate).toBe('pending')
})
it('retires the latch for a pre-existing install so behaviour is unchanged', async () => {
writeDataFile({
schemaVersion: 1,
repos: [makeRepo()],
worktreeMeta: {},
settings: { theme: 'dark' },
ui: {},
githubCache: { pr: {}, issue: {} },
workspaceSession: {}
})
const store = await createStore()
expect(store.getSettings().managedAgentHookFirstRunGate).toBe('done')
expect(store.getSettings().theme).toBe('dark')
})
it('treats a corrupt data file as a pre-existing install', async () => {
mkdirSync(testState.dir, { recursive: true })
writeFileSync(dataFile(), '{{{corrupt json', 'utf-8')
const store = await createStore()
expect(store.getSettings().managedAgentHookFirstRunGate).toBe('done')
})
it('treats surviving backups as a pre-existing install when the primary file is gone', async () => {
// The loader's own recovery path counts a backup as evidence the profile exists; classifying
// this user fresh would silently stop maintaining hooks they already have. The backup is
// deliberately unusable, so recovery cannot re-create the primary file and hide the question.
mkdirSync(testState.dir, { recursive: true })
writeFileSync(`${dataFile()}.bak.0`, '{{{corrupt json', 'utf-8')
const store = await createStore()
expect(store.getSettings().managedAgentHookFirstRunGate).toBe('done')
})
it('recovers a usable backup and still classifies the profile as pre-existing', async () => {
mkdirSync(testState.dir, { recursive: true })
writeFileSync(
`${dataFile()}.bak.0`,
JSON.stringify({
schemaVersion: 1,
repos: [makeRepo()],
worktreeMeta: {},
settings: { theme: 'dark' },
ui: {},
githubCache: { pr: {}, issue: {} },
workspaceSession: {}
}),
'utf-8'
)
const store = await createStore()
expect(store.getSettings().managedAgentHookFirstRunGate).toBe('done')
expect(store.getSettings().theme).toBe('dark')
})
it('preserves an already-frozen latch on subsequent launches', async () => {
writeDataFile({
schemaVersion: 1,
repos: [],
worktreeMeta: {},
settings: { managedAgentHookFirstRunGate: 'pending' },
ui: {},
githubCache: { pr: {}, issue: {} },
workspaceSession: {}
})
const store = await createStore()
// Existing file, latch already armed — must not flip to 'done' just because the file exists.
expect(store.getSettings().managedAgentHookFirstRunGate).toBe('pending')
})
})
describe('Store.migrateWorktreeIdentity', () => {
const OLD = 'repo1::/ws/cunner'
const NEW = 'repo1::/ws/worktree-creation-spinner'
@@ -25,27 +25,6 @@ export class LoadedCohortMigrationOperations {
}
}
/** `profileExistedOnLoad` must match the loader's recovery evidence: primary file OR any backup. */
migrateManagedAgentHookFirstRunGate(
state: PersistedState,
profileExistedOnLoad: boolean
): PersistedState {
const existing = state.settings?.managedAgentHookFirstRunGate
if (existing === 'pending' || existing === 'done') {
return state
}
// Why: mark dirty so the frozen verdict persists; else a fresh install re-reads as "existing" after its file lands.
this.runtime.loadNeedsSave = true
return {
...state,
settings: {
...state.settings,
// Only a genuinely fresh profile still has an onboarding step 1 to pass, so only it defers.
managedAgentHookFirstRunGate: profileExistedOnLoad ? 'done' : 'pending'
}
}
}
migrateTelemetry(state: PersistedState, fileExistedOnLoad: boolean): PersistedState {
const existing = state.settings?.telemetry
// Why: require all three invariants; keying on existedBeforeTelemetryRelease alone lets a partial block skip migration.
@@ -267,16 +267,9 @@ export class LoadedStateParsingOperations {
this.runtime.loadNeedsSave = true
}
// Why not `fileExistedOnLoad` alone: the recovery path above already counts a profile whose
// primary file is gone but whose backups survive as existing. Classifying it fresh would
// silently stop maintaining hooks it already has.
const profileExistedOnLoad = fileExistedOnLoad || hasStateBackup(dataFile)
const migrated = this.cohorts.migrateManagedAgentHookFirstRunGate(
this.cohorts.migrateTabSwitchKeybindings(
this.cohorts.migrateTelemetry(result, fileExistedOnLoad),
fileExistedOnLoad
),
profileExistedOnLoad
const migrated = this.cohorts.migrateTabSwitchKeybindings(
this.cohorts.migrateTelemetry(result, fileExistedOnLoad),
fileExistedOnLoad
)
// githubCache is a sidecar file now (see getGithubCacheFile); legacy in-file caches seed the session, then get stripped.
@@ -0,0 +1,232 @@
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, 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 { ManagedHookInstallationMarker } from '../agent-hooks/managed-hook-install-policy'
import {
establishManagedHookInstallationMarker,
getEstablishedManagedHookInstallationMarker,
hasExistingOrcaInstallationState,
isManagedHookOnboardingPending,
managedHookInstallationMarkerPath,
parseManagedHookInstallationMarker,
recordManagedHookOnboardingPassed,
resetEstablishedManagedHookInstallationMarkerForTests,
resolveManagedHookInstallationMarker
} from './managed-hook-installation-marker'
const PRE_CHANGE: ManagedHookInstallationMarker = {
installCohort: 'pre-change',
onboardingDecision: 'passed'
}
const FRESH: ManagedHookInstallationMarker = {
installCohort: 'post-change',
onboardingDecision: 'pending'
}
let userDataPath: string
beforeEach(() => {
userDataPath = mkdtempSync(join(tmpdir(), 'orca-hook-marker-'))
resetEstablishedManagedHookInstallationMarkerForTests()
})
afterEach(() => {
rmSync(userDataPath, { recursive: true, force: true })
vi.restoreAllMocks()
})
function writeMarkerRaw(raw: string): void {
writeFileSync(managedHookInstallationMarkerPath(userDataPath), raw, 'utf-8')
}
function readMarkerRaw(): string {
return readFileSync(managedHookInstallationMarkerPath(userDataPath), 'utf-8')
}
describe('parseManagedHookInstallationMarker', () => {
it('reads a complete record', () => {
expect(parseManagedHookInstallationMarker(JSON.stringify(FRESH))).toEqual(FRESH)
})
const malformed: [string, string][] = [
['unparseable JSON', '{{{'],
['an empty file', ''],
['a partial post-change record', '{"installCohort":"post-change"}'],
['a record with only a decision', '{"onboardingDecision":"pending"}'],
['an unknown cohort', '{"installCohort":"future","onboardingDecision":"pending"}'],
['an unknown decision', '{"installCohort":"post-change","onboardingDecision":"maybe"}'],
['a null body', 'null'],
['an array', '[{"installCohort":"post-change","onboardingDecision":"pending"}]'],
['a non-string cohort', '{"installCohort":1,"onboardingDecision":"pending"}']
]
for (const [name, raw] of malformed) {
it(`reads ${name} as absent, never as pending`, () => {
expect(parseManagedHookInstallationMarker(raw)).toBeNull()
})
}
})
describe('resolveManagedHookInstallationMarker', () => {
it('returns a recorded marker untouched and writes nothing', () => {
const writeMarker = vi.fn(() => true)
const resolved = resolveManagedHookInstallationMarker({
readMarker: () => FRESH,
hasExistingInstallationState: () => false,
writeMarker
})
expect(resolved).toEqual(FRESH)
expect(writeMarker).not.toHaveBeenCalled()
})
it('leaves an upgrading installation pre-change and persists nothing', () => {
const writeMarker = vi.fn(() => true)
const resolved = resolveManagedHookInstallationMarker({
readMarker: () => null,
hasExistingInstallationState: () => true,
writeMarker
})
expect(resolved).toEqual(PRE_CHANGE)
expect(writeMarker).not.toHaveBeenCalled()
})
it('mints a pending marker for an installation with nothing of Orca in it', () => {
const writeMarker = vi.fn(() => true)
const resolved = resolveManagedHookInstallationMarker({
readMarker: () => null,
hasExistingInstallationState: () => false,
writeMarker
})
expect(resolved).toEqual(FRESH)
expect(writeMarker).toHaveBeenCalledWith(FRESH)
})
it('falls back to pre-change when the establishing write fails', () => {
const resolved = resolveManagedHookInstallationMarker({
readMarker: () => null,
hasExistingInstallationState: () => false,
writeMarker: () => false
})
expect(resolved).toEqual(PRE_CHANGE)
})
})
describe('hasExistingOrcaInstallationState', () => {
it('is false for an empty user-data root', () => {
expect(hasExistingOrcaInstallationState(userDataPath)).toBe(false)
})
const artifacts: [string, () => void][] = [
['the profile index', () => writeFileSync(join(userDataPath, 'orca-profile-index.json'), '{}')],
[
'only the profile index backup',
() => writeFileSync(join(userDataPath, 'orca-profile-index.json.bak'), '{}')
],
['the profiles directory', () => mkdirSync(join(userDataPath, 'profiles'))],
['a legacy data file', () => writeFileSync(join(userDataPath, 'orca-data.json'), '{}')],
['only a legacy backup', () => writeFileSync(join(userDataPath, 'orca-data.json.bak.3'), '{}')]
]
for (const [name, create] of artifacts) {
it(`is true when ${name} is present`, () => {
create()
expect(hasExistingOrcaInstallationState(userDataPath)).toBe(true)
})
}
})
describe('establishManagedHookInstallationMarker on disk', () => {
it('records a fresh install durably and reports it pending', () => {
const marker = establishManagedHookInstallationMarker(userDataPath)
expect(marker).toEqual(FRESH)
expect(JSON.parse(readMarkerRaw())).toEqual(FRESH)
expect(isManagedHookOnboardingPending()).toBe(true)
})
it('leaves an existing installation pre-change with no file written', () => {
writeFileSync(join(userDataPath, 'orca-profile-index.json'), '{}')
const marker = establishManagedHookInstallationMarker(userDataPath)
expect(marker).toEqual(PRE_CHANGE)
expect(() => readMarkerRaw()).toThrow()
expect(isManagedHookOnboardingPending()).toBe(false)
})
it('treats a corrupt marker on an existing installation as pre-change', () => {
writeFileSync(join(userDataPath, 'orca-profile-index.json'), '{}')
writeMarkerRaw('{"installCohort":"post-ch')
expect(establishManagedHookInstallationMarker(userDataPath)).toEqual(PRE_CHANGE)
})
it('treats a malformed partial post-change record as pre-change, not pending', () => {
writeFileSync(join(userDataPath, 'orca-profile-index.json'), '{}')
writeMarkerRaw('{"installCohort":"post-change"}')
expect(establishManagedHookInstallationMarker(userDataPath)).toEqual(PRE_CHANGE)
expect(isManagedHookOnboardingPending()).toBe(false)
})
it('keeps a pending marker across relaunches without rewriting it', () => {
establishManagedHookInstallationMarker(userDataPath)
const first = readMarkerRaw()
resetEstablishedManagedHookInstallationMarkerForTests()
expect(establishManagedHookInstallationMarker(userDataPath)).toEqual(FRESH)
expect(readMarkerRaw()).toBe(first)
})
it('answers pre-change before any bootstrap has run', () => {
expect(getEstablishedManagedHookInstallationMarker()).toEqual(PRE_CHANGE)
expect(isManagedHookOnboardingPending()).toBe(false)
})
})
describe('recordManagedHookOnboardingPassed', () => {
it('flips a pending installation to passed, in memory and on disk', () => {
establishManagedHookInstallationMarker(userDataPath)
expect(recordManagedHookOnboardingPassed(userDataPath)).toBe(true)
expect(JSON.parse(readMarkerRaw())).toEqual({
installCohort: 'post-change',
onboardingDecision: 'passed'
})
expect(isManagedHookOnboardingPending()).toBe(false)
})
// Skipped on Windows only because chmod is a no-op there, not because the behaviour differs.
it.skipIf(process.platform === 'win32')(
'reports failure and stays pending when the write cannot land',
() => {
establishManagedHookInstallationMarker(userDataPath)
vi.spyOn(console, 'warn').mockImplementation(() => {})
// Read-only root: the marker is still readable, but no temp file can be created next to it.
chmodSync(userDataPath, 0o500)
try {
expect(recordManagedHookOnboardingPassed(userDataPath)).toBe(false)
expect(isManagedHookOnboardingPending()).toBe(true)
} finally {
chmodSync(userDataPath, 0o700)
}
}
)
it('is a no-op for a pre-change installation', () => {
writeFileSync(join(userDataPath, 'orca-profile-index.json'), '{}')
establishManagedHookInstallationMarker(userDataPath)
expect(recordManagedHookOnboardingPassed(userDataPath)).toBe(true)
expect(() => readMarkerRaw()).toThrow()
})
})
@@ -0,0 +1,197 @@
import { existsSync, mkdirSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { durableWriteTempPath, writeFileDurableSync } from '../durable-file-write'
import type { ManagedHookInstallationMarker } from '../agent-hooks/managed-hook-install-policy'
import {
getOrcaProfileIndexPath,
getOrcaProfilesDirectory,
LEGACY_BACKUP_COUNT,
legacyBackupPath,
legacyDataFilePath
} from '../orca-profiles/profile-storage-paths'
/**
* Installation-scoped record of which hook cohort this copy of Orca belongs to. Lives at the
* user-data root, outside every profile, because managed hooks are user-global: a second profile
* must inherit the answer, never mint its own.
*
* Every ambiguity here resolves to `pre-change`, which installs exactly as Orca always has. Absent,
* unreadable, malformed, partially written, or a failed establishment all mean "an installation we
* cannot prove is new", and stopping hook maintenance for someone who already has hooks is the one
* unrecoverable outcome.
*/
export const MANAGED_HOOK_INSTALLATION_MARKER_FILE = 'managed-hook-installation.json'
export type {
ManagedHookInstallCohort,
ManagedHookInstallationMarker,
ManagedHookOnboardingDecision
} from '../agent-hooks/managed-hook-install-policy'
/** `passed` rather than `pending` so even a policy that skipped the cohort branch still allows. */
export const PRE_CHANGE_MANAGED_HOOK_MARKER: ManagedHookInstallationMarker = Object.freeze({
installCohort: 'pre-change',
onboardingDecision: 'passed'
})
const FRESH_INSTALL_MANAGED_HOOK_MARKER: ManagedHookInstallationMarker = Object.freeze({
installCohort: 'post-change',
onboardingDecision: 'pending'
})
export function managedHookInstallationMarkerPath(userDataPath: string): string {
return join(userDataPath, MANAGED_HOOK_INSTALLATION_MARKER_FILE)
}
/**
* Strict by design: both fields must be known literals. A half-written `{"installCohort":
* "post-change"}` is malformed, and malformed reads as absent — reading it as pending would turn a
* torn write into a deferral.
*/
export function parseManagedHookInstallationMarker(
raw: string
): ManagedHookInstallationMarker | null {
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch {
return null
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
return null
}
const { installCohort, onboardingDecision } = parsed as Record<string, unknown>
if (installCohort !== 'pre-change' && installCohort !== 'post-change') {
return null
}
if (onboardingDecision !== 'pending' && onboardingDecision !== 'passed') {
return null
}
return { installCohort, onboardingDecision }
}
export type ManagedHookInstallationMarkerHost = {
readMarker: () => ManagedHookInstallationMarker | null
/** Whether this user-data root already holds Orca state, i.e. Orca ran here before this change. */
hasExistingInstallationState: () => boolean
/** Returns whether the record actually landed; a failed write must not be treated as recorded. */
writeMarker: (marker: ManagedHookInstallationMarker) => boolean
}
/**
* Resolve the marker for this launch, minting one only for an installation with nothing of Orca's
* in it yet. An upgrade persists nothing at all, so a later rollback sees the disk it left behind.
*/
export function resolveManagedHookInstallationMarker(
host: ManagedHookInstallationMarkerHost
): ManagedHookInstallationMarker {
const existing = host.readMarker()
if (existing) {
return existing
}
if (host.hasExistingInstallationState()) {
return PRE_CHANGE_MANAGED_HOOK_MARKER
}
return host.writeMarker(FRESH_INSTALL_MANAGED_HOOK_MARKER)
? FRESH_INSTALL_MANAGED_HOOK_MARKER
: PRE_CHANGE_MANAGED_HOOK_MARKER
}
/**
* The closed set of artifacts a used Orca leaves at its user-data root. Any of them — or any error
* reading for them — means "existing installation".
*/
export function hasExistingOrcaInstallationState(userDataPath: string): boolean {
try {
const candidates = [
getOrcaProfileIndexPath(userDataPath),
`${getOrcaProfileIndexPath(userDataPath)}.bak`,
getOrcaProfilesDirectory(userDataPath),
legacyDataFilePath(userDataPath),
...Array.from({ length: LEGACY_BACKUP_COUNT }, (_, index) =>
legacyBackupPath(userDataPath, index)
)
]
return candidates.some((path) => existsSync(path))
} catch {
// Unreadable root: treat as existing so an I/O fault can never mint a deferral.
return true
}
}
function readMarkerFile(userDataPath: string): ManagedHookInstallationMarker | null {
try {
return parseManagedHookInstallationMarker(
readFileSync(managedHookInstallationMarkerPath(userDataPath), 'utf-8')
)
} catch {
return null
}
}
function writeMarkerFile(userDataPath: string, marker: ManagedHookInstallationMarker): boolean {
const path = managedHookInstallationMarkerPath(userDataPath)
try {
// Why: this runs before `ready`, so Electron has not necessarily created userData yet.
mkdirSync(userDataPath, { recursive: true })
writeFileDurableSync(durableWriteTempPath(path), path, `${JSON.stringify(marker, null, 2)}\n`)
return true
} catch (error) {
console.warn('[managed-hooks] could not record the installation marker:', error)
return false
}
}
let establishedMarker: ManagedHookInstallationMarker | null = null
/** Bootstrap seam. Idempotent within a launch; the on-disk record is written at most once ever. */
export function establishManagedHookInstallationMarker(
userDataPath: string
): ManagedHookInstallationMarker {
establishedMarker = resolveManagedHookInstallationMarker({
readMarker: () => readMarkerFile(userDataPath),
hasExistingInstallationState: () => hasExistingOrcaInstallationState(userDataPath),
writeMarker: (marker) => writeMarkerFile(userDataPath, marker)
})
return establishedMarker
}
/** Pre-change until bootstrap says otherwise: a host that never establishes one installs as today. */
export function getEstablishedManagedHookInstallationMarker(): ManagedHookInstallationMarker {
return establishedMarker ?? PRE_CHANGE_MANAGED_HOOK_MARKER
}
/**
* Whether this installation still owes an answer to the first-run question. Deliberately not the
* policy decision: an unchecked box makes that `deny`, and keying the transition on it would leave
* the marker pending forever, so re-enabling later in Settings would never install.
*/
export function isManagedHookOnboardingPending(): boolean {
const marker = getEstablishedManagedHookInstallationMarker()
return marker.installCohort === 'post-change' && marker.onboardingDecision === 'pending'
}
/**
* Mark the onboarding question answered. Returns whether the installation is now recorded as
* passed; a caller must stay pending on `false`, or a crash could install against an unchecked box.
*/
export function recordManagedHookOnboardingPassed(userDataPath: string): boolean {
const current = readMarkerFile(userDataPath) ?? PRE_CHANGE_MANAGED_HOOK_MARKER
if (current.installCohort === 'pre-change' || current.onboardingDecision === 'passed') {
return true
}
const next: ManagedHookInstallationMarker = {
installCohort: 'post-change',
onboardingDecision: 'passed'
}
if (!writeMarkerFile(userDataPath, next)) {
return false
}
establishedMarker = next
return true
}
/** Test seam: drop the process-wide cache between cases. */
export function resetEstablishedManagedHookInstallationMarkerForTests(): void {
establishedMarker = null
}
@@ -1,86 +0,0 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
const { applyAgentStatusHooksEnabledMock } = vi.hoisted(() => ({
applyAgentStatusHooksEnabledMock: vi.fn()
}))
vi.mock('../agent-hooks/managed-agent-hook-controls', () => ({
applyAgentStatusHooksEnabled: applyAgentStatusHooksEnabledMock
}))
import {
RuntimeClientSettingsController,
type RuntimeClientSettingsUpdate
} from './runtime-client-settings'
import { SettingsUpdate } from './rpc/methods/client-settings-schemas'
import { createGlobalSettingsFixture } from '../../shared/global-settings-test-fixture'
import type { GlobalSettings } from '../../shared/global-settings-types'
function createStoreFake(overrides: Partial<GlobalSettings>) {
let settings = createGlobalSettingsFixture({ workspaceDir: '/w', ...overrides })
return {
getSettings: () => settings,
updateSettings: (updates: Partial<GlobalSettings>) => {
settings = { ...settings, ...updates }
return settings
}
}
}
describe('managedAgentHookFirstRunGate stays host-private', () => {
beforeEach(() => {
applyAgentStatusHooksEnabledMock.mockReset().mockResolvedValue([])
})
it('is never published in the paired-client projection', () => {
const store = createStoreFake({ managedAgentHookFirstRunGate: 'pending' })
const projected = new RuntimeClientSettingsController(store as never).get()
expect(Object.keys(projected)).not.toContain('managedAgentHookFirstRunGate')
})
it('is rejected by the strict client SettingsUpdate schema', () => {
expect(() => SettingsUpdate.parse({ managedAgentHookFirstRunGate: 'done' })).toThrow()
})
it('has no slot in the client update union', () => {
const update: RuntimeClientSettingsUpdate = {
// @ts-expect-error the latch is main-owned and must never be client-writable
managedAgentHookFirstRunGate: 'done'
}
expect(update).toBeTruthy()
})
})
describe('RuntimeClientSettingsController.update managed hook reconcile', () => {
beforeEach(() => {
applyAgentStatusHooksEnabledMock.mockReset().mockResolvedValue([])
})
it('suppresses the reconcile while the first-run latch is still armed', async () => {
const store = createStoreFake({
managedAgentHookFirstRunGate: 'pending',
agentStatusHooksEnabled: true
})
const controller = new RuntimeClientSettingsController(store as never)
const result = await controller.update({ agentStatusHooksEnabled: false })
expect(applyAgentStatusHooksEnabledMock).not.toHaveBeenCalled()
// The preference itself still persists; only the disk mutation is skipped.
expect(result.agentStatusHooksEnabled).toBe(false)
})
it('reconciles once the latch has been retired', async () => {
const store = createStoreFake({
managedAgentHookFirstRunGate: 'done',
agentStatusHooksEnabled: true
})
const controller = new RuntimeClientSettingsController(store as never)
await controller.update({ agentStatusHooksEnabled: false })
expect(applyAgentStatusHooksEnabledMock).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,55 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { applyAgentStatusHooksEnabledMock } = vi.hoisted(() => ({
applyAgentStatusHooksEnabledMock: vi.fn()
}))
vi.mock('../agent-hooks/managed-agent-hook-controls', () => ({
applyAgentStatusHooksEnabled: applyAgentStatusHooksEnabledMock
}))
import { RuntimeClientSettingsController } from './runtime-client-settings'
import { setManagedHookInstallDecisionResolver } from '../agent-hooks/managed-hook-install-policy'
import { createGlobalSettingsFixture } from '../../shared/global-settings-test-fixture'
import type { GlobalSettings } from '../../shared/global-settings-types'
function createStoreFake(overrides: Partial<GlobalSettings>) {
let settings = createGlobalSettingsFixture({ workspaceDir: '/w', ...overrides })
return {
getSettings: () => settings,
updateSettings: (updates: Partial<GlobalSettings>) => {
settings = { ...settings, ...updates }
return settings
}
}
}
describe('RuntimeClientSettingsController.update managed hook reconcile', () => {
beforeEach(() => {
applyAgentStatusHooksEnabledMock.mockReset().mockResolvedValue([])
setManagedHookInstallDecisionResolver(null)
})
afterEach(() => setManagedHookInstallDecisionResolver(null))
it('suppresses the reconcile while the install is deferred', async () => {
setManagedHookInstallDecisionResolver(() => ({ kind: 'defer', reason: 'onboarding-pending' }))
const store = createStoreFake({ agentStatusHooksEnabled: true })
const controller = new RuntimeClientSettingsController(store as never)
const result = await controller.update({ agentStatusHooksEnabled: false })
expect(applyAgentStatusHooksEnabledMock).not.toHaveBeenCalled()
// The preference itself still persists; only the disk mutation is skipped.
expect(result.agentStatusHooksEnabled).toBe(false)
})
it('reconciles for an installation that is not deferring', async () => {
const store = createStoreFake({ agentStatusHooksEnabled: true })
const controller = new RuntimeClientSettingsController(store as never)
await controller.update({ agentStatusHooksEnabled: false })
expect(applyAgentStatusHooksEnabledMock).toHaveBeenCalledTimes(1)
})
})
+4 -4
View File
@@ -17,7 +17,7 @@ import type { ExecutionHostId } from '../../shared/execution-host'
import type { TerminalQuickCommand } from '../../shared/terminal-quick-command-types'
import { recordManagedHookInstallFailure } from '../agent-hooks/install-telemetry'
import { applyAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls'
import { isManagedHookFirstRunGatePending } from '../agent-hooks/managed-hook-first-run-gate'
import { resolveManagedHookInstallDecision } from '../agent-hooks/managed-hook-install-policy'
import type { RuntimeStore } from './runtime-store-contract'
export type RuntimeClientSettings = Pick<
@@ -137,10 +137,10 @@ export class RuntimeClientSettingsController {
if (updates.worktreeVisibilityDefaults !== undefined) {
this.notifyReposChanged?.()
}
// Why the latch alone: RuntimeStore exposes no onboarding state, and the two retirement points
// make 'pending' equivalent to actively deferring.
// Why suppressed rather than reconciled: a deferred installation must write nothing, and a
// reconcile with the off switch set would remove hooks another Orca profile owns (STA-5679).
if (
!isManagedHookFirstRunGatePending(settings) &&
resolveManagedHookInstallDecision(settings).kind !== 'defer' &&
((typeof updates.agentStatusHooksEnabled === 'boolean' &&
before !== updates.agentStatusHooksEnabled) ||
(updates.disabledTuiAgents !== undefined &&
@@ -87,8 +87,6 @@ export type RuntimeStore = {
terminalWindowsShell?: GlobalSettings['terminalWindowsShell']
floatingTerminalEnabled?: GlobalSettings['floatingTerminalEnabled']
agentStatusHooksEnabled?: GlobalSettings['agentStatusHooksEnabled']
// Read-only here: the first-run latch is main-owned and never joins the client update union.
managedAgentHookFirstRunGate?: GlobalSettings['managedAgentHookFirstRunGate']
experimentalNativeChat?: GlobalSettings['experimentalNativeChat']
openAgentTabsInChatByDefault?: GlobalSettings['openAgentTabsInChatByDefault']
experimentalStructuredNativeChat?: GlobalSettings['experimentalStructuredNativeChat']
+8 -5
View File
@@ -23,7 +23,7 @@ import { SshFilesystemProvider } from '../providers/ssh-filesystem-provider'
import { isMethodNotFoundError } from './ssh-filesystem-stream-reader'
import { SshGitProvider } from '../providers/ssh-git-provider'
import { agentHookServer } from '../agent-hooks/server'
import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls'
import { resolveManagedHookInstallDecision } from '../agent-hooks/managed-hook-install-policy'
import {
buildManagedHookDetectionCommands,
detectedManagedHookAgents
@@ -1355,7 +1355,7 @@ export class SshRelaySession {
): Promise<void> {
if (
!isRemoteAgentHooksEnabled() ||
!this.areAgentStatusHooksEnabled() ||
!this.isManagedHookInstallAllowedHere() ||
(shouldContinue && !shouldContinue())
) {
return
@@ -1507,7 +1507,7 @@ export class SshRelaySession {
// Why: ship plugin/extension source from Orca so agent-event changes don't force a relay redeploy — the relay is versioned independently. Best-effort: failure only costs agent status on this host.
private async installPluginsOnRelay(mux: SshChannelMultiplexer): Promise<void> {
if (!isRemoteAgentHooksEnabled() || !this.areAgentStatusHooksEnabled()) {
if (!isRemoteAgentHooksEnabled() || !this.isManagedHookInstallAllowedHere()) {
return
}
try {
@@ -1534,9 +1534,12 @@ export class SshRelaySession {
}
}
private areAgentStatusHooksEnabled(): boolean {
// Why the policy and not the plain off switch: these write the REMOTE host's user-global agent
// configs, so a deferred first run must reach them too — and a `false` here would only skip,
// never sweep, exactly as the local chokepoint does (STA-5679).
private isManagedHookInstallAllowedHere(): boolean {
const store = this.store as { getSettings?: Store['getSettings'] }
return isAgentStatusHooksEnabled(store.getSettings?.())
return resolveManagedHookInstallDecision(store.getSettings?.()).kind === 'allow'
}
private wireUpRemoteWorkspaceEvents(mux: SshChannelMultiplexer): void {
@@ -0,0 +1,76 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { setManagedHookInstallDecisionResolver } from '../agent-hooks/managed-hook-install-policy'
import type { GlobalSettings } from '../../shared/global-settings-types'
import type { SshChannelMultiplexer } from './ssh-channel-multiplexer'
vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() }))
vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() }))
const { SshRelaySession } = await import('./ssh-relay-session')
/**
* These install into the REMOTE host's user-global agent configs, so they are a writer in their
* own right — `installManagedAgentHooks` is never on this path. Driven through the prototype
* because both methods are private and constructing a full session needs a live SSH connection.
*/
type RemoteHookSession = {
store: { getSettings: () => Partial<GlobalSettings> }
targetId: string
remoteCliBridgeEnv: undefined
installManagedHooksOnRemote: (mux: SshChannelMultiplexer) => Promise<void>
installPluginsOnRelay: (mux: SshChannelMultiplexer) => Promise<void>
}
function createSession(settings: Partial<GlobalSettings>): RemoteHookSession {
const session = Object.create(SshRelaySession.prototype) as RemoteHookSession
session.store = { getSettings: () => settings }
session.targetId = 'ssh:test-host'
session.remoteCliBridgeEnv = undefined
return session
}
let request: ReturnType<typeof vi.fn>
let mux: SshChannelMultiplexer
beforeEach(() => {
request = vi.fn(async () => ({ agents: [] }))
mux = { request, isDisposed: () => false } as unknown as SshChannelMultiplexer
process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1'
setManagedHookInstallDecisionResolver(null)
})
afterEach(() => {
delete process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS
setManagedHookInstallDecisionResolver(null)
})
describe('SSH remote managed hook install authorization', () => {
it('asks the remote host nothing while the first-run question is unanswered', async () => {
setManagedHookInstallDecisionResolver(() => ({ kind: 'defer', reason: 'onboarding-pending' }))
await createSession({}).installManagedHooksOnRemote(mux)
expect(request).not.toHaveBeenCalled()
})
it('asks the remote host nothing when hooks are turned off', async () => {
await createSession({ agentStatusHooksEnabled: false }).installManagedHooksOnRemote(mux)
expect(request).not.toHaveBeenCalled()
})
it('detects remote agents for an installation that is not deferring', async () => {
await createSession({}).installManagedHooksOnRemote(mux)
expect(request).toHaveBeenCalledWith('preflight.detectAgents', expect.anything())
})
it('ships no plugin overlay to the remote while deferred either', async () => {
setManagedHookInstallDecisionResolver(() => ({ kind: 'defer', reason: 'onboarding-pending' }))
await createSession({}).installPluginsOnRelay(mux)
expect(request).not.toHaveBeenCalled()
})
})
+12 -3
View File
@@ -4,7 +4,7 @@ import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-sele
import { markCodexProjectTrusted } from '../agent-trust-presets'
import { codexHookService } from '../codex/hook-service'
import { getDefaultWslDistro } from '../wsl'
import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls'
import { resolveManagedHookInstallDecision } from '../agent-hooks/managed-hook-install-policy'
import { ensureRealHomeCodexHookState } from '../codex/codex-real-home-hook-install'
import { mainProcessState as state } from './main-process-state'
@@ -29,16 +29,23 @@ export async function prepareCodexRuntimeHomeForLaunch(
console.warn('[codex-project-trust] failed to pre-mark launch workspace:', error)
}
}
// Why resolved once per launch: every branch below writes with the same authorization, and
// `deny` and `defer` are not interchangeable here — `deny` sweeps the real home, `defer` must
// leave it untouched because the user has not been asked yet.
const installDecision = resolveManagedHookInstallDecision(state.store?.getSettings())
const ensureRealHomeHooksIfSelected = async (): Promise<boolean> => {
if (target?.runtime === 'wsl' || !runtimeHome.isHostSystemDefaultRealHomeSelected(launchEnv)) {
return false
}
if (installDecision.kind === 'defer') {
return false
}
// Why (flag ON, system default): the hook entry must exist — appended last
// and trusted by codex's own app-server grant — in the real ~/.codex before
// the pane spawns. An incapable grant flips the lane gate so the launch
// below falls back to the managed home instead of a status-blind pane.
await ensureRealHomeCodexHookState({
hooksEnabled: isAgentStatusHooksEnabled(state.store?.getSettings()),
hooksEnabled: installDecision.kind === 'allow',
userDataPath: app.getPath('userData')
})
return true
@@ -70,7 +77,9 @@ export async function prepareCodexRuntimeHomeForLaunch(
target?.runtime === 'wsl'
? { runtime: 'wsl' as const, wslDistro: target.wslDistro?.trim() || getDefaultWslDistro() }
: target
const hooksEnabled = isAgentStatusHooksEnabled(state.store?.getSettings())
// Deferred behaves like off on Orca's own managed home: user hooks are refreshed, no Orca entry
// is added. That home is profile-local, so nothing user-global is written either way.
const hooksEnabled = installDecision.kind === 'allow'
try {
// Why: honor the persisted off switch so post-startup launches can't reinstall removed hooks.
const status = await codexHookService.prepareRuntimeHomeForLaunch(
@@ -7,7 +7,7 @@ import { prepareLegacySharedCodexSessionResume } from '../codex/codex-legacy-ses
import { ManagedCodexHomeTemporarilyUnavailableError } from '../codex-accounts/host-codex-managed-home-ownership'
import { codexHookService } from '../codex/hook-service'
import { ensureRealHomeCodexHookState } from '../codex/codex-real-home-hook-install'
import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls'
import { resolveManagedHookInstallDecision } from '../agent-hooks/managed-hook-install-policy'
import { markCodexProjectTrusted } from '../agent-trust-presets'
import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from '../codex/codex-home-paths'
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
@@ -90,8 +90,14 @@ export async function prepareCodexSessionResumeForLaunch(args: {
const isSystemHome =
normalizeRuntimePathForComparison(resumeHome) ===
normalizeRuntimePathForComparison(systemHomePath)
const hooksEnabled = isAgentStatusHooksEnabled(store.getSettings())
const installDecision = resolveManagedHookInstallDecision(store.getSettings())
const hooksEnabled = installDecision.kind === 'allow'
try {
// Why the real home is skipped rather than passed `false` while deferred: `false` sweeps
// it, and a user who has not been asked yet must find ~/.codex exactly as they left it.
if (isSystemHome && installDecision.kind === 'defer') {
return resumeHome
}
if (isSystemHome) {
await ensureRealHomeCodexHookState({
hooksEnabled,
@@ -64,6 +64,11 @@ import { setWorktreeWatcherRemoval } from '../ipc/worktree-watcher-removal'
import { desktopWorktreeWatcherRemoval } from '../ipc/filesystem-watcher'
import { setDefaultProxySessionResolver } from '../network/proxy-settings'
import { initDataPath, getCanonicalUserDataPath } from '../persistence'
import { establishManagedHookInstallationMarker } from '../persistence/managed-hook-installation-marker'
import {
getManagedHookInstallDecision,
setManagedHookInstallDecisionResolver
} from '../agent-hooks/managed-hook-install-policy'
import { applyMacPressAndHoldDefaultAtStartup } from '../macos-press-and-hold-default'
import { initSessionParseCachePersistence } from '../ai-vault/session-parse-cache-persistence'
import { initOrcaProfilePaths } from '../orca-profiles/profile-index-store'
@@ -261,6 +266,17 @@ export function runMainProcessPreflight(options: MainProcessPreflightOptions): b
// Safe to defer, and must stay synchronous: no 'disconnect' can be delivered until this module
// finishes evaluating, so moving this behind an await would open a real orphan window.
installServeSupervisorDisconnectQuit(state.isServeMode)
// Why here and not in the ready phase: this decides whether this installation predates the
// first-run hook question, and it must be settled before ensureActiveOrcaProfile() or any Store
// can create the very state it looks at. Serve hosts resolve to 'headless' and install as today.
const managedHookInstallation = establishManagedHookInstallationMarker(getCanonicalUserDataPath())
setManagedHookInstallDecisionResolver((settings) =>
getManagedHookInstallDecision({
settings,
installation: managedHookInstallation,
mode: state.isServeMode ? 'serve' : 'desktop'
})
)
// Why here: initDataPath above gives the canonical userData path for the record file; the write
// itself lands for the next launch (see macos-press-and-hold-default.ts).
applyMacPressAndHoldDefaultAtStartup(getCanonicalUserDataPath())
@@ -1,13 +1,13 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { GlobalSettings } from '../../shared/global-settings-types'
import type { OnboardingState } from '../../shared/onboarding-state-types'
import type * as AgentStatusHooksEnablement from '../agent-hooks/agent-status-hooks-enablement'
/**
* The behavioural half of the managed-hook first-run gate. `startup-managed-hook-plan.test.ts`
* tables the decision; this drives the real ready phase so that dropping the plan call, the
* `shouldReconcile` conjunct or the latch-retirement write turns red. Without it the whole startup
* gate can be reverted to origin/main with every other suite still green.
* The behavioural half of the managed-hook install gate. `managed-hook-install-policy.test.ts`
* tables the decision; this drives the real ready phase so that dropping the plan call or the
* `shouldReconcile` conjunct turns red. Without it the whole startup gate can be reverted to
* origin/main with every other suite still green.
*/
const {
installManagedAgentHooksMock,
@@ -15,7 +15,7 @@ const {
mainProcessStateFake,
runtimeFake
} = vi.hoisted(() => ({
installManagedAgentHooksMock: vi.fn(async () => []),
installManagedAgentHooksMock: vi.fn(async (_settings?: unknown, _options?: unknown) => []),
ensureRealHomeCodexHookStateMock: vi.fn(async () => undefined),
runtimeFake: {
setAgentBrowserBridge: vi.fn(),
@@ -116,16 +116,30 @@ vi.mock('./first-window-deferral', () => ({ runAfterFirstWindowShown: vi.fn() })
vi.mock('./startup-diagnostics', () => ({ logStartupMilestone: vi.fn() }))
import { initializeReadyRuntimeServices } from './main-process-ready-runtime'
import {
getManagedHookInstallDecision,
setManagedHookInstallDecisionResolver,
type ManagedHookInstallationMarker
} from '../agent-hooks/managed-hook-install-policy'
const PRE_CHANGE: ManagedHookInstallationMarker = {
installCohort: 'pre-change',
onboardingDecision: 'passed'
}
const FRESH_PENDING: ManagedHookInstallationMarker = {
installCohort: 'post-change',
onboardingDecision: 'pending'
}
const FRESH_PASSED: ManagedHookInstallationMarker = {
installCohort: 'post-change',
onboardingDecision: 'passed'
}
function createStoreFake(initial: {
onboarding?: Partial<OnboardingState>
settings?: Partial<GlobalSettings>
}) {
let settings = {
disabledTuiAgents: [],
managedAgentHookFirstRunGate: 'pending',
...initial.settings
} as GlobalSettings
let settings = { disabledTuiAgents: [], ...initial.settings } as GlobalSettings
const onboarding = {
flowVersion: 1,
closedAt: null,
@@ -144,10 +158,15 @@ function createStoreFake(initial: {
}
}
function latchWrites(store: ReturnType<typeof createStoreFake>): Partial<GlobalSettings>[] {
return store.updateSettings.mock.calls
.map(([updates]) => updates)
.filter((updates) => 'managedAgentHookFirstRunGate' in updates)
/** The marker the desktop bootstrap would have established for this launch. */
function establishInstallation(installation: ManagedHookInstallationMarker): void {
setManagedHookInstallDecisionResolver((settings) =>
getManagedHookInstallDecision({
settings,
installation,
mode: mainProcessStateFake.isServeMode ? 'serve' : 'desktop'
})
)
}
async function runReadyPhase(store: ReturnType<typeof createStoreFake>): Promise<void> {
@@ -159,53 +178,72 @@ async function runReadyPhase(store: ReturnType<typeof createStoreFake>): Promise
}
}
describe('managed hook first-run gate in the ready phase', () => {
describe('managed hook install gate in the ready phase', () => {
beforeEach(() => {
vi.clearAllMocks()
mainProcessStateFake.isServeMode = false
mainProcessStateFake.isQuitting = false
mainProcessStateFake.codexRuntimeHome = null
setManagedHookInstallDecisionResolver(null)
})
it('writes nothing user-global for a fresh profile that has not reached step 1', async () => {
const store = createStoreFake({})
afterEach(() => setManagedHookInstallDecisionResolver(null))
await runReadyPhase(store)
it('writes nothing user-global for a fresh install that has not answered yet', async () => {
establishInstallation(FRESH_PENDING)
await runReadyPhase(createStoreFake({}))
expect(installManagedAgentHooksMock).not.toHaveBeenCalled()
expect(ensureRealHomeCodexHookStateMock).not.toHaveBeenCalled()
// The latch must stay armed, or the next launch installs without ever having asked.
expect(latchWrites(store)).toEqual([])
})
it('reconciles once and retires the latch after the user passes step 1', async () => {
const store = createStoreFake({ onboarding: { lastCompletedStep: 1 } })
it('installs for an existing user upgrading, whose installation has no marker', async () => {
establishInstallation(PRE_CHANGE)
await runReadyPhase(store)
await runReadyPhase(createStoreFake({}))
expect(installManagedAgentHooksMock).toHaveBeenCalledTimes(1)
})
it('installs once the first-run question has been answered', async () => {
establishInstallation(FRESH_PASSED)
await runReadyPhase(createStoreFake({}))
expect(latchWrites(store)).toEqual([{ managedAgentHookFirstRunGate: 'done' }])
expect(installManagedAgentHooksMock).toHaveBeenCalledTimes(1)
})
it('never defers on a serve host, which never paints the wizard', async () => {
mainProcessStateFake.isServeMode = true
const store = createStoreFake({})
establishInstallation(FRESH_PENDING)
await runReadyPhase(store)
await runReadyPhase(createStoreFake({}))
expect(latchWrites(store)).toEqual([{ managedAgentHookFirstRunGate: 'done' }])
expect(installManagedAgentHooksMock).toHaveBeenCalledTimes(1)
})
it('still honours the off switch on a profile whose latch has already retired', async () => {
const store = createStoreFake({
onboarding: { lastCompletedStep: 1 },
settings: { managedAgentHookFirstRunGate: 'done', agentStatusHooksEnabled: false }
})
it('installs when no bootstrap ever established a marker', async () => {
await runReadyPhase(createStoreFake({}))
await runReadyPhase(store)
expect(installManagedAgentHooksMock).toHaveBeenCalledTimes(1)
})
it('still honours the off switch on an installation that has answered', async () => {
establishInstallation(FRESH_PASSED)
await runReadyPhase(createStoreFake({ settings: { agentStatusHooksEnabled: false } }))
expect(installManagedAgentHooksMock).not.toHaveBeenCalled()
expect(latchWrites(store)).toEqual([])
})
it('carries the startup decision into the installer, not just the reconcile flag', async () => {
establishInstallation(PRE_CHANGE)
await runReadyPhase(createStoreFake({}))
expect(installManagedAgentHooksMock.mock.calls[0]?.[1]).toMatchObject({
installDecision: { kind: 'allow', reason: 'pre-change' }
})
})
})
@@ -96,13 +96,8 @@ export async function initializeReadyRuntimeServices(): Promise<void> {
const startupManagedHookSettings = store.getSettings()
const startupManagedHookPlan = resolveStartupManagedHookPlan({
managedHooksInstallable: shouldInstallManagedHooks(is.dev),
isServeMode: state.isServeMode,
onboarding: store.getOnboarding(),
settings: startupManagedHookSettings
})
if (startupManagedHookPlan.shouldRetireFirstRunLatch) {
store.updateSettings({ managedAgentHookFirstRunGate: 'done' })
}
const shouldReconcileStartupManagedHooks = startupManagedHookPlan.shouldReconcile
const realHomeCodexHookState =
shouldReconcileStartupManagedHooks &&
@@ -123,6 +118,7 @@ export async function initializeReadyRuntimeServices(): Promise<void> {
void realHomeCodexHookState
.then(() =>
installManagedAgentHooks(managedHookStore.getSettings(), {
installDecision: startupManagedHookPlan.decision,
shouldHydrateShellPath: app.isPackaged,
onInstallError: recordManagedHookInstallFailure,
shouldContinue: (agent) =>
@@ -0,0 +1,64 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
/**
* The marker decides whether this installation predates the first-run hook question, and it decides
* it by looking for Orca's own state at the user-data root. Anything that creates a profile, a
* Store, or the legacy data file first would make a genuinely fresh install look pre-existing — the
* feature would silently never defer, and no unit test would notice.
*
* Source-level because that is the property: this runs once at module scope before `ready`, so
* there is no runtime seam to assert the ordering against.
*/
describe('managed hook installation marker bootstrap ordering', () => {
const preflight = readFileSync(
join(process.cwd(), 'src/main/startup/main-process-preflight.ts'),
'utf8'
)
const entry = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8')
const ESTABLISH = 'establishManagedHookInstallationMarker(getCanonicalUserDataPath())'
const INSTALL_RESOLVER = 'setManagedHookInstallDecisionResolver('
it('establishes the marker exactly once, in preflight', () => {
const preflightStart = preflight.indexOf('export function runMainProcessPreflight(')
const preflightReturn = preflight.indexOf('\n return true', preflightStart)
const establish = preflight.indexOf(ESTABLISH)
expect(preflight.split(ESTABLISH).length - 1).toBe(1)
expect(establish).toBeGreaterThan(preflightStart)
expect(establish).toBeLessThan(preflightReturn)
})
it('runs after the user-data path is captured, so it reads the right root', () => {
expect(preflight.indexOf(ESTABLISH)).toBeGreaterThan(preflight.indexOf(' initDataPath()'))
})
it('runs after the single-instance lock, so a losing launch mints nothing', () => {
expect(preflight.indexOf(ESTABLISH)).toBeGreaterThan(
preflight.indexOf('const hasLock = skip || bypass || acquireSingleInstanceLock(')
)
})
it('runs before anything that could create the state it looks for', () => {
const establish = preflight.indexOf(ESTABLISH)
// initOrcaProfilePaths only captures a path today, but it is the nearest profile-shaped step
// and the one a future change would most plausibly grow a mkdir into.
expect(establish).toBeLessThan(preflight.indexOf('initOrcaProfilePaths()'))
})
it('installs the policy resolver alongside it, before the ready phase runs', () => {
const resolver = preflight.indexOf(INSTALL_RESOLVER)
expect(preflight.split(INSTALL_RESOLVER).length - 1).toBe(1)
expect(resolver).toBeGreaterThan(preflight.indexOf(ESTABLISH))
expect(entry.indexOf('void app.whenReady()')).toBeGreaterThan(
entry.indexOf('runMainProcessPreflight({')
)
})
it('passes the serve host its own mode, so a serve launch never defers', () => {
expect(preflight).toContain("mode: state.isServeMode ? 'serve' : 'desktop'")
})
})
@@ -1,92 +1,56 @@
import { describe, expect, it } from 'vitest'
import type { GlobalSettings } from '../../shared/global-settings-types'
import { afterEach, describe, expect, it } from 'vitest'
import {
setManagedHookInstallDecisionResolver,
type ManagedHookInstallDecision
} from '../agent-hooks/managed-hook-install-policy'
import { resolveStartupManagedHookPlan } from './startup-managed-hook-plan'
type Latch = GlobalSettings['managedAgentHookFirstRunGate']
const ALLOW: ManagedHookInstallDecision = { kind: 'allow', reason: 'pre-change' }
const DEFER: ManagedHookInstallDecision = { kind: 'defer', reason: 'onboarding-pending' }
const DENY: ManagedHookInstallDecision = { kind: 'deny', reason: 'hooks-disabled' }
function plan(overrides: {
managedHooksInstallable?: boolean
isServeMode?: boolean
closedAt?: number | null
lastCompletedStep?: number
latch?: Latch
agentStatusHooksEnabled?: boolean
}) {
return resolveStartupManagedHookPlan({
managedHooksInstallable: overrides.managedHooksInstallable ?? true,
isServeMode: overrides.isServeMode ?? false,
onboarding: {
closedAt: overrides.closedAt ?? null,
lastCompletedStep: overrides.lastCompletedStep ?? -1
},
settings: {
managedAgentHookFirstRunGate: overrides.latch,
agentStatusHooksEnabled: overrides.agentStatusHooksEnabled ?? true
}
})
}
const cases: {
name: string
input: Parameters<typeof plan>[0]
expected: ReturnType<typeof resolveStartupManagedHookPlan>
}[] = [
{
name: 'fresh profile before step 1 defers and keeps the latch armed',
input: { latch: 'pending' },
expected: { deferForFirstRun: true, shouldRetireFirstRunLatch: false, shouldReconcile: false }
},
{
name: 'fresh profile still on step 0 defers',
input: { latch: 'pending', lastCompletedStep: 0 },
expected: { deferForFirstRun: true, shouldRetireFirstRunLatch: false, shouldReconcile: false }
},
{
name: 'a relaunch after step 1 reconciles and retires the latch',
input: { latch: 'pending', lastCompletedStep: 1 },
expected: { deferForFirstRun: false, shouldRetireFirstRunLatch: true, shouldReconcile: true }
},
{
name: 'a relaunch after the wizard was dismissed reconciles and retires the latch',
input: { latch: 'pending', closedAt: 1 },
expected: { deferForFirstRun: false, shouldRetireFirstRunLatch: true, shouldReconcile: true }
},
{
name: 'a serve host never defers, because it never paints the wizard',
input: { latch: 'pending', isServeMode: true },
expected: { deferForFirstRun: false, shouldRetireFirstRunLatch: true, shouldReconcile: true }
},
{
name: 'a retired latch reconciles even with the wizard rewound to the start',
input: { latch: 'done' },
expected: { deferForFirstRun: false, shouldRetireFirstRunLatch: false, shouldReconcile: true }
},
{
name: 'a pre-release profile with no latch is untouched',
input: { latch: undefined },
expected: { deferForFirstRun: false, shouldRetireFirstRunLatch: false, shouldReconcile: true }
},
{
name: 'the off switch still wins once the gate has lifted',
input: { latch: 'done', agentStatusHooksEnabled: false },
expected: { deferForFirstRun: false, shouldRetireFirstRunLatch: false, shouldReconcile: false }
},
{
name: 'a build that does not install managed hooks reconciles nothing',
input: { latch: 'done', managedHooksInstallable: false },
expected: { deferForFirstRun: false, shouldRetireFirstRunLatch: false, shouldReconcile: false }
},
{
name: 'a deferring launch retires nothing even when the build cannot install',
input: { latch: 'pending', managedHooksInstallable: false },
expected: { deferForFirstRun: true, shouldRetireFirstRunLatch: false, shouldReconcile: false }
}
]
afterEach(() => setManagedHookInstallDecisionResolver(null))
describe('resolveStartupManagedHookPlan', () => {
for (const testCase of cases) {
it(testCase.name, () => {
expect(plan(testCase.input)).toEqual(testCase.expected)
it('reconciles when the host allows and this build installs hooks', () => {
setManagedHookInstallDecisionResolver(() => ALLOW)
expect(resolveStartupManagedHookPlan({ managedHooksInstallable: true, settings: {} })).toEqual({
decision: ALLOW,
shouldReconcile: true
})
}
})
it('does not reconcile while the first-run question is unanswered', () => {
setManagedHookInstallDecisionResolver(() => DEFER)
expect(resolveStartupManagedHookPlan({ managedHooksInstallable: true, settings: {} })).toEqual({
decision: DEFER,
shouldReconcile: false
})
})
it('does not reconcile when hooks are turned off', () => {
setManagedHookInstallDecisionResolver(() => DENY)
expect(resolveStartupManagedHookPlan({ managedHooksInstallable: true, settings: {} })).toEqual({
decision: DENY,
shouldReconcile: false
})
})
it('carries the decision even when this build never reconciles', () => {
setManagedHookInstallDecisionResolver(() => ALLOW)
expect(resolveStartupManagedHookPlan({ managedHooksInstallable: false, settings: {} })).toEqual(
{ decision: ALLOW, shouldReconcile: false }
)
})
it('installs with no host resolver, because an unestablished installation is ambiguous', () => {
expect(resolveStartupManagedHookPlan({ managedHooksInstallable: true, settings: {} })).toEqual({
decision: ALLOW,
shouldReconcile: true
})
})
})
+14 -35
View File
@@ -1,49 +1,28 @@
import type { GlobalSettings } from '../../shared/global-settings-types'
import type { OnboardingState } from '../../shared/onboarding-state-types'
import { resolveStartupManagedHookAction } from '../agent-hooks/agent-status-hooks-enablement'
import {
isManagedHookFirstRunGatePending,
isManagedHookInstallDeferredForFirstRun
} from '../agent-hooks/managed-hook-first-run-gate'
type StartupManagedHookPlanSettings = Partial<
Pick<
GlobalSettings,
'agentStatusHooksEnabled' | 'disabledTuiAgents' | 'managedAgentHookFirstRunGate'
>
> | null
resolveManagedHookInstallDecision,
type ManagedHookInstallDecision,
type ManagedHookInstallPolicySettings
} from '../agent-hooks/managed-hook-install-policy'
export type StartupManagedHookPlan = {
/** Nothing user-global may be written yet: a fresh profile still has step 1 ahead of it. */
deferForFirstRun: boolean
/** Freeze the one-shot latch, so a later wizard re-open can never re-arm the deferral. */
shouldRetireFirstRunLatch: boolean
/** Carried to the installers so the startup pass and the chokepoint cannot disagree. */
decision: ManagedHookInstallDecision
/** Run the startup install/refresh pass over every enabled agent. */
shouldReconcile: boolean
}
/**
* Thin adapter: the host mode and the installation marker were settled in preflight, so startup
* only adds "does this build reconcile hooks at all".
*/
export function resolveStartupManagedHookPlan(input: {
/** `shouldInstallManagedHooks(is.dev)` — whether this build reconciles hooks at all. */
managedHooksInstallable: boolean
isServeMode: boolean
onboarding: Pick<OnboardingState, 'closedAt' | 'lastCompletedStep'>
settings: StartupManagedHookPlanSettings
settings: ManagedHookInstallPolicySettings
}): StartupManagedHookPlan {
// Why a serve host never defers: it never paints the wizard (paired clients keep onboarding in
// localStorage and there is no onboarding RPC), so the latch would stay armed forever.
const deferForFirstRun =
!input.isServeMode &&
isManagedHookInstallDeferredForFirstRun({
onboarding: input.onboarding,
settings: input.settings
})
const decision = resolveManagedHookInstallDecision(input.settings)
return {
deferForFirstRun,
shouldRetireFirstRunLatch:
!deferForFirstRun && isManagedHookFirstRunGatePending(input.settings),
shouldReconcile:
input.managedHooksInstallable &&
!deferForFirstRun &&
resolveStartupManagedHookAction(input.settings) === 'install'
decision,
shouldReconcile: input.managedHooksInstallable && decision.kind === 'allow'
}
}
@@ -50,6 +50,16 @@ describe('AgentStatusHooksControl', () => {
expect(onEnabledChange).toHaveBeenCalledWith(false)
})
it('states the filesystem effect before the disclosure is ever opened', () => {
// The material fact — Orca writes outside its own directory — must not be reachable only by
// expanding the collapsed section.
renderControl()
expect(disclosureTrigger()).toHaveAttribute('data-state', 'closed')
expect(screen.getByText(/edits each agent's own config file in your home folder/)).toBeVisible()
expect(screen.getByText(/outside Orca's own directory/)).toBeVisible()
})
it('starts with the disclosure collapsed', () => {
renderControl()
@@ -39,9 +39,11 @@ export function AgentStatusHooksControl({
</span>
</label>
<p className="text-xs text-muted-foreground">
{/* The filesystem effect stays in the always-visible line: collapsing the per-agent detail
is a product choice, hiding the fact that Orca writes outside its own directory is not. */}
{translate(
'auto.components.onboarding.AgentStatusHooksControl.why',
"Enables Orca to track your CLI agents' statuses, so it can inform you when each is working, needs you, or is done. Also powers your notifications."
"Enables Orca to track your CLI agents' statuses, so it can inform you when each is working, needs you, or is done. Also powers your notifications. To do it, Orca edits each agent's own config file in your home folder, outside Orca's own directory."
)}
</p>
{/* Sibling of the label, never nested inside it: a trigger under the label would toggle the checkbox. */}
+1 -1
View File
@@ -13565,7 +13565,7 @@
},
"AgentStatusHooksControl": {
"label": "Enable agent status hooks",
"why": "Enables Orca to track your CLI agents' statuses, so it can inform you when each is working, needs you, or is done. Also powers your notifications.",
"why": "Enables Orca to track your CLI agents' statuses, so it can inform you when each is working, needs you, or is done. Also powers your notifications. To do it, Orca edits each agent's own config file in your home folder, outside Orca's own directory.",
"disclosureSummary": "What Orca changes, and when",
"whenLabel": "When",
"whenBody": "For the agent CLIs found on your machine, kept current each time Orca starts. Agents you don't have are skipped, and nothing is written for them.",
+2
View File
@@ -28,6 +28,8 @@ export type AgentHookInstallSkipReason =
| 'cli_not_found'
| 'cli_presence_unknown'
| 'hooks_disabled'
/** Deferred, not declined: a fresh install has not reached the onboarding question yet. */
| 'onboarding_pending'
export type AgentHookInstallStatus = {
agent: AgentHookTarget
-5
View File
@@ -484,11 +484,6 @@ export type GlobalSettings = {
/** One-shot cohort marker for the tab-switch keybinding swap. 'pending' =
* pre-existing install (seed pins old chords, then flips to 'done'); 'done' = fresh install. */
tabSwitchKeybindingSeed?: 'pending' | 'done'
/** One-shot first-run latch for managed agent status hooks. 'pending' = fresh profile that has
* not yet passed onboarding step 1, so startup must not write user-global agent configs;
* 'done' = the gate has lifted (or never applied). Retired the first launch that does not
* defer, so a manual wizard re-open can never re-arm it. Main-owned; never sent to a client. */
managedAgentHookFirstRunGate?: 'pending' | 'done'
/** Local voice/dictation config. Optional for pre-voice profiles; getDefaultSettings() hydrates defaults via the persistence merge. */
voice?: VoiceSettings
}