From 9d1dfc314f7b186db51d8f7fbec5a6ddf7611a29 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:20:21 -0700 Subject: [PATCH 001/111] fix(cli): resolve host names across both kinds, and stop ssh: answering empty (#15449) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cli): resolve host names across both kinds, and stop ssh: answering empty `--host ssh:` was never validated. An unknown target filtered to nothing and returned ok:true with an empty list — the same silent wrong-machine answer that unknown `runtime:` ids gave before they were rejected. And because SSH target ids are machine-generated (`ssh--`) while the name anyone actually knows is the label, this fired on the ordinary spelling rather than a rare typo: every human-typed SSH name missed. The two kinds of remote machine are also reached on different axes. A paired Orca server is a connection (`--environment `); an SSH target is a machine the connected host reaches (`--host ssh:`). A caller only knows "the machine called X", so naming X on the wrong axis was the common failure and produced either an empty answer or a dead-end "unknown environment". Now: `ssh:` resolves labels as well as ids and rejects an unknown target with the known ones listed; `runtime:` accepts the environment name as well as its id, matching --environment, and canonicalizes to the id so stored host ids still compare; and when a name misses on one axis but exists on the other, the error says which and gives the exact flag. Candidates ride along in error.data so an agent can recover without parsing prose. `orca host list` is the discovery surface that was missing entirely — nothing in the CLI listed SSH targets, so a caller told to use one had nowhere to look. It prints this machine, the SSH targets registered on the connected host, and the paired servers, each with the selector to use. * fix(cli): give --environment the same cross-kind hint, and validate the ssh host on setup-create Two gaps a follow-up survey found in the first pass. `--environment openclaw` still dead-ended with a bare "Unknown environment" while an SSH target by that name sat right there — the inverse of the case just fixed, and the direction the report actually hit. The store's own error cannot carry the hint: translateStoreError forwards code and message and drops data. So the selector is resolved before the client is built, where the payload survives. Only the explicit flag is asserted eagerly; an ambient ORCA_ENVIRONMENT stays lazy, because failing local-only commands over stale background config would be a regression. `project setup-create` records independent metadata and, unlike the other setup paths, is not covered by the runtime's ssh rejection — so an unknown target persisted a row pointing at a machine that does not exist. It now resolves the host. `local` and `runtime:` still pass through untouched: this is also the provisioning path, where a runtime host legitimately may not exist yet when its metadata is written. `setup-existing-folder` and `setup-clone` deliberately keep the unresolved id. The runtime rejects every ssh host for those operations regardless of whether it exists, so resolving first would answer "no such target" and imply the command would have worked with the right id. * fix(cli): refuse an ambiguous host name instead of resolving the first match Name lookup took the first match while the environment store itself refuses an ambiguous name rather than guessing. That put the guess back, in the selector whose entire purpose is to stop a command reaching a machine the caller did not choose — and it applied to both spellings: two SSH targets sharing a label, and two paired servers sharing a name. Both now resolve to nothing and report every candidate with its id, so the caller picks. An exact id still resolves past a colliding name, since an id is never ambiguous. Also pins the property that makes accepting a name safe at all: `runtime:` is a persisted token that lands in ProjectHostSetup.hostId and is embedded in generated setup ids, so the name is canonicalized to the id before anything downstream sees it. A test now asserts a name never reaches the wire. * fix(cli): fall back to the older ssh listing so an old host is not read as having no targets Hosts predating ssh.listTargetSummaries still answer ssh.listTargets, and both are served by the same summariser. Swallowing the method_not_found made such a host indistinguishable from one with no SSH targets registered, which would reject a target id that is valid there — a new-client/old-host regression on a path that previously passed the id through unvalidated. --- src/cli/args.ts | 1 + src/cli/execution-host-flag.test.ts | 97 ++++++++++- src/cli/execution-host-flag.ts | 113 ++++++++++++- src/cli/format.ts | 20 +++ src/cli/handler-group-manifest.ts | 8 +- src/cli/handlers/environment.ts | 26 ++- src/cli/handlers/project.ts | 29 +++- src/cli/help.ts | 4 + src/cli/host-selector-alternatives.test.ts | 179 +++++++++++++++++++++ src/cli/host-selector-alternatives.ts | 159 ++++++++++++++++++ src/cli/index-project-setup.test.ts | 123 +++++++++++++- src/cli/index.ts | 24 ++- src/cli/runtime-client-deferral.test.ts | 12 +- src/cli/specs/environment.ts | 12 ++ src/cli/worktree-project-target.ts | 4 +- 15 files changed, 791 insertions(+), 20 deletions(-) create mode 100644 src/cli/host-selector-alternatives.test.ts create mode 100644 src/cli/host-selector-alternatives.ts diff --git a/src/cli/args.ts b/src/cli/args.ts index 68ae1cae913..c4c373a814f 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -206,6 +206,7 @@ export function isCommandGroup(commandPath: string[]): boolean { 'artifacts', 'automations', 'project', + 'host', 'repo', 'worktree', 'terminal', diff --git a/src/cli/execution-host-flag.test.ts b/src/cli/execution-host-flag.test.ts index a51b69f5d38..d0239b22d43 100644 --- a/src/cli/execution-host-flag.test.ts +++ b/src/cli/execution-host-flag.test.ts @@ -20,13 +20,19 @@ vi.mock('./runtime-client', () => ({ })) import { + assertEnvironmentSelectorResolvable, hostFilterMatchesHostId, parseHostFlag, resolveHostFlagEnvironmentId } from './execution-host-flag' import { parseExecutionHostId } from '../shared/execution-host' -const NO_SELECTION = { pairingCode: null, environmentSelector: null } +const listSshTargetsMock = vi.fn(async () => [] as { id: string; label: string }[]) +const NO_SELECTION = { + listSshTargets: listSshTargetsMock, + pairingCode: null, + environmentSelector: null +} function flags(entries: Record): Map { return new Map(Object.entries(entries)) @@ -84,15 +90,17 @@ describe('resolveHostFlagEnvironmentId', () => { await expect( resolveHostFlagEnvironmentId(flags({ host: 'runtime:env-missing' }), NO_SELECTION) - ).rejects.toThrow('no paired environment has id env-missing') + ).rejects.toThrow('no paired Orca server is named or has id env-missing') }) - it('matches by environment id only, never by environment name', async () => { + // Why: the name is what a person or agent actually knows; requiring the raw uuid made the + // obvious spelling fail against a server that is sitting right there. + it('accepts the environment name as well as its id', async () => { listEnvironmentsMock.mockReturnValue([environment('env-1', 'gpu')]) await expect( resolveHostFlagEnvironmentId(flags({ host: 'runtime:gpu' }), NO_SELECTION) - ).rejects.toThrow('no paired environment has id gpu') + ).resolves.toBe('env-1') }) it('decodes percent-encoded environment ids', async () => { @@ -108,6 +116,7 @@ describe('resolveHostFlagEnvironmentId', () => { await expect( resolveHostFlagEnvironmentId(flags({ host: 'runtime:env-1' }), { + listSshTargets: listSshTargetsMock, pairingCode: 'orca://pair?x', environmentSelector: null }) @@ -120,6 +129,7 @@ describe('resolveHostFlagEnvironmentId', () => { await expect( resolveHostFlagEnvironmentId(flags({ host: 'runtime:env-1' }), { + listSshTargets: listSshTargetsMock, pairingCode: null, environmentSelector: { value: 'other', label: '--environment' } }) @@ -132,6 +142,7 @@ describe('resolveHostFlagEnvironmentId', () => { await expect( resolveHostFlagEnvironmentId(flags({ host: 'runtime:env-1' }), { + listSshTargets: listSshTargetsMock, pairingCode: null, environmentSelector: { value: 'staging', label: 'ORCA_ENVIRONMENT' } }) @@ -142,7 +153,7 @@ describe('resolveHostFlagEnvironmentId', () => { listEnvironmentsMock.mockReturnValue([environment('env-1', 'gpu')]) await expect( - resolveHostFlagEnvironmentId(flags({ host: 'runtime:gpu' }), NO_SELECTION) + resolveHostFlagEnvironmentId(flags({ host: 'runtime:missing' }), NO_SELECTION) ).rejects.toMatchObject({ code: 'invalid_argument', data: { knownEnvironments: [{ id: 'env-1', name: 'gpu' }] } @@ -155,6 +166,7 @@ describe('resolveHostFlagEnvironmentId', () => { await expect( resolveHostFlagEnvironmentId(flags({ host: 'runtime:env-1' }), { + listSshTargets: listSshTargetsMock, pairingCode: null, environmentSelector: { value: 'gpu', label: '--environment' } }) @@ -187,3 +199,78 @@ describe('hostFilterMatchesHostId', () => { expect(hostFilterMatchesHostId(runtimeHost, null)).toBe(false) }) }) + +describe('ambiguous environment names', () => { + it('refuses --host runtime: when two servers share that name', async () => { + listEnvironmentsMock.mockReturnValue([ + environment('env-1', 'awin'), + environment('env-2', 'awin') + ]) + + await expect( + resolveHostFlagEnvironmentId(flags({ host: 'runtime:awin' }), NO_SELECTION) + ).rejects.toThrow('2 paired servers are named awin') + }) + + it('refuses --environment when two servers share that name', async () => { + listEnvironmentsMock.mockReturnValue([ + environment('env-1', 'awin'), + environment('env-2', 'awin') + ]) + + await expect(assertEnvironmentSelectorResolvable('awin', listSshTargetsMock)).rejects.toThrow( + '2 paired servers are named awin' + ) + }) + + it('still routes an exact id past a colliding name', async () => { + listEnvironmentsMock.mockReturnValue([ + environment('env-1', 'awin'), + environment('env-2', 'awin') + ]) + + await expect( + resolveHostFlagEnvironmentId(flags({ host: 'runtime:env-2' }), NO_SELECTION) + ).resolves.toBe('env-2') + }) +}) + +describe('assertEnvironmentSelectorResolvable', () => { + it('accepts a paired server by name or id', async () => { + listEnvironmentsMock.mockReturnValue([environment('env-1', 'awin')]) + listSshTargetsMock.mockClear() + + await expect( + assertEnvironmentSelectorResolvable('awin', listSshTargetsMock) + ).resolves.toBeUndefined() + await expect( + assertEnvironmentSelectorResolvable('env-1', listSshTargetsMock) + ).resolves.toBeUndefined() + expect(listSshTargetsMock).not.toHaveBeenCalled() + }) + + // Why: the inverse of the --host case, and the one the report actually hit — a name that is + // an SSH target dead-ended with a bare "unknown environment". + it('names the SSH target when the selector is one', async () => { + listEnvironmentsMock.mockReturnValue([environment('env-1', 'awin')]) + listSshTargetsMock.mockResolvedValue([{ id: 'ssh-1-a', label: 'openclaw' }]) + + await expect( + assertEnvironmentSelectorResolvable('openclaw', listSshTargetsMock) + ).rejects.toMatchObject({ + code: 'invalid_argument', + data: { + nextSteps: expect.arrayContaining([expect.stringContaining('--host ssh:ssh-1-a')]) + } + }) + }) + + it('still points at host list when the name is on neither axis', async () => { + listEnvironmentsMock.mockReturnValue([]) + listSshTargetsMock.mockResolvedValue([]) + + await expect( + assertEnvironmentSelectorResolvable('nowhere', listSshTargetsMock) + ).rejects.toThrow('no paired Orca server is named or has id nowhere') + }) +}) diff --git a/src/cli/execution-host-flag.ts b/src/cli/execution-host-flag.ts index 36dc080e329..8b30807b816 100644 --- a/src/cli/execution-host-flag.ts +++ b/src/cli/execution-host-flag.ts @@ -2,11 +2,23 @@ import { LOCAL_EXECUTION_HOST_ID, normalizeExecutionHostId, parseExecutionHostId, + toSshExecutionHostId, type ParsedExecutionHost } from '../shared/execution-host' +import { + ambiguousEnvironments, + crossKindNextSteps, + findEnvironmentByName, + resolveSshHostTargetId, + type SshTargetSummary +} from './host-selector-alternatives' +import type { RuntimeClient } from './runtime-client' import { RuntimeClientError } from './runtime/types' export type HostFlagRoutingSelection = { + // Why: SSH targets live in the running Orca host, not on disk, so enumerating them needs a + // client. Injected as a thunk so the lookup only happens on the error path we are explaining. + listSshTargets: () => Promise pairingCode: string | null // Why: an ambient ORCA_ENVIRONMENT counts as a selection too, so carry the label to // name the real source in the conflict message. @@ -51,19 +63,29 @@ export async function resolveHostFlagEnvironmentId( ]) const userDataPath = getDefaultUserDataPath() const known = listEnvironments(userDataPath) - const environment = known.find((candidate) => candidate.id === host.environmentId) + // Why: --environment has always taken a name or an id, and the name is what people and agents + // actually know. Requiring the raw uuid here made the obvious spelling fail; accept either and + // canonicalize to the id so stored host ids still compare correctly downstream. + const environment = findEnvironmentByName( + known.map((candidate) => ({ id: candidate.id, name: candidate.name })), + host.environmentId + ) if (!environment) { // Why: `runtime:` is a host id that also appears in stored rows, so it resolves by id // only — unlike --environment, which also accepts a name. Say so, and hand back the ids an // agent can retry with instead of making it scrape the sentence. + const environments = known.map((candidate) => ({ id: candidate.id, name: candidate.name })) + assertEnvironmentNameUnambiguous(environments, host.environmentId, `--host ${host.id}`) + const sshTargets = await selection.listSshTargets() throw new RuntimeClientError( 'invalid_argument', - `Unknown Orca server in --host ${host.id}: no paired environment has id ${host.environmentId}. --host runtime: matches environment ids only, never names. Run \`orca environment list\` to see paired servers, or pass --host local.`, + `Unknown Orca server in --host ${host.id}: no paired Orca server is named or has id ${host.environmentId}.`, { - knownEnvironments: known.map((candidate) => ({ id: candidate.id, name: candidate.name })), + knownEnvironments: environments, + knownSshTargets: sshTargets, nextSteps: [ + ...crossKindNextSteps(host.environmentId, { environments, sshTargets }, 'environment'), 'Run `orca environment list` to see paired Orca servers.', - 'Use the environment id, not its name: --host runtime:.', 'Use --host local to target this machine.' ] } @@ -100,3 +122,86 @@ export function hostFilterMatchesHostId( } return filter.kind === 'runtime' && candidate === LOCAL_EXECUTION_HOST_ID } + +// Why: `ssh:` reaches a machine the connected runtime owns, so it can only be checked against +// that runtime — unlike `runtime:`, which is resolved from this machine's pairing store before a +// client exists. Callers that act on a --host value run this so an unknown target fails loudly +// instead of quietly filtering to nothing. +export async function resolveHostFlagTarget( + flags: Map, + client: RuntimeClient +): Promise { + const host = parseHostFlag(flags) + if (host?.kind !== 'ssh') { + return host + } + const [{ listEnvironments }, { getDefaultUserDataPath }] = await Promise.all([ + import('./runtime/environments.js'), + import('./runtime-client.js') + ]) + const environments = listEnvironments(getDefaultUserDataPath()).map((candidate) => ({ + id: candidate.id, + name: candidate.name + })) + const targetId = await resolveSshHostTargetId(client, host.targetId, environments) + return parseExecutionHostId(toSshExecutionHostId(targetId)) ?? host +} + +// Why: the store refuses an ambiguous environment name rather than guessing which server was +// meant. Resolving one here would put the guess back, in the flag whose whole purpose is to stop +// a command reaching a machine the caller did not choose. +function assertEnvironmentNameUnambiguous( + environments: readonly { id: string; name: string }[], + name: string, + flag: string +): void { + const ambiguous = ambiguousEnvironments(environments, name) + if (ambiguous.length === 0) { + return + } + throw new RuntimeClientError( + 'invalid_argument', + `Ambiguous Orca server in ${flag}: ${ambiguous.length} paired servers are named ${name}. Use the environment id.`, + { + knownEnvironments: ambiguous, + nextSteps: ambiguous.map( + (candidate) => `Use --host runtime:${candidate.id} for the server named ${candidate.name}.` + ) + } + ) +} + +// Why: the inverse of the --host case. `--environment openclaw` failed with a bare "Unknown +// environment", when openclaw is very often an SSH target — a different axis, not a typo. The +// store's own error cannot carry the hint (translateStoreError forwards code and message only, +// dropping data), so resolve the selector here where the payload survives. +export async function assertEnvironmentSelectorResolvable( + selector: string, + listSshTargetsForSuggestion: () => Promise +): Promise { + const [{ listEnvironments }, { getDefaultUserDataPath }] = await Promise.all([ + import('./runtime/environments.js'), + import('./runtime-client.js') + ]) + const environments = listEnvironments(getDefaultUserDataPath()).map((candidate) => ({ + id: candidate.id, + name: candidate.name + })) + if (findEnvironmentByName(environments, selector)) { + return + } + assertEnvironmentNameUnambiguous(environments, selector, `--environment ${selector}`) + const sshTargets = await listSshTargetsForSuggestion() + throw new RuntimeClientError( + 'invalid_argument', + `Unknown Orca server in --environment ${selector}: no paired Orca server is named or has id ${selector}.`, + { + knownEnvironments: environments, + knownSshTargets: sshTargets, + nextSteps: [ + ...crossKindNextSteps(selector, { environments, sshTargets }, 'environment'), + 'Run `orca host list` to see every machine you can target and the flag for each.' + ] + } + ) +} diff --git a/src/cli/format.ts b/src/cli/format.ts index ebe30e68305..2b6b67f05e9 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -175,6 +175,26 @@ function localCliErrorData(error: unknown, context: CliErrorContext): unknown { return undefined } +export type HostListEntry = { + kind: 'local' | 'ssh' | 'environment' + name: string + id: string + selector: string +} + +// Why: the selector column is the point of this command — the name alone is what callers already +// had, and passing it on the wrong axis is the mistake this output exists to prevent. +export function formatHostList(result: { hosts: HostListEntry[] }): string { + const kindLabel: Record = { + local: 'local', + ssh: 'ssh target', + environment: 'orca server' + } + return result.hosts + .map((host) => `${kindLabel[host.kind].padEnd(11)} ${host.name} -> ${host.selector}`) + .join('\n') +} + export function formatCliStatus(status: CliStatusResult): string { return [ ...(status.target && status.target.kind === 'environment' diff --git a/src/cli/handler-group-manifest.ts b/src/cli/handler-group-manifest.ts index 7a5a24fbe99..0907905e5e3 100644 --- a/src/cli/handler-group-manifest.ts +++ b/src/cli/handler-group-manifest.ts @@ -194,7 +194,13 @@ export const HANDLER_GROUPS: readonly HandlerGroup[] = [ }, { name: 'environment', - keys: ['environment add', 'environment list', 'environment show', 'environment rm'], + keys: [ + 'host list', + 'environment add', + 'environment list', + 'environment show', + 'environment rm' + ], load: async () => (await import('./handlers/environment.js')).ENVIRONMENT_HANDLERS }, { diff --git a/src/cli/handlers/environment.ts b/src/cli/handlers/environment.ts index 7827f91347d..2a433021769 100644 --- a/src/cli/handlers/environment.ts +++ b/src/cli/handlers/environment.ts @@ -1,5 +1,6 @@ import type { CommandHandler } from '../dispatch' -import { formatEnvironment, formatEnvironmentList, printResult } from '../format' +import { formatEnvironment, formatEnvironmentList, formatHostList, printResult } from '../format' +import { listSshTargets } from '../host-selector-alternatives' import { getDefaultUserDataPath, RuntimeClientError } from '../runtime-client' import type { RuntimeRpcSuccess } from '../runtime-client' import { redactRuntimeEnvironment } from '../../shared/runtime-environments' @@ -29,6 +30,29 @@ export const ENVIRONMENT_HANDLERS: Record = { `Saved environment ${result.environment.name} (${result.environment.id}).` ) }, + // Why: an agent told "run it on " had nowhere to look. `orca environment list` showed + // paired servers only, and nothing in the CLI listed SSH targets at all, so the wrong-axis + // guess was the only move available. This is the one place that answers both. + 'host list': async ({ client, json }) => { + const environments = listEnvironments(getDefaultUserDataPath()).map((environment) => ({ + kind: 'environment' as const, + name: environment.name, + id: environment.id, + selector: `--environment ${environment.name}` + })) + const sshTargets = (await listSshTargets(client)).map((target) => ({ + kind: 'ssh' as const, + name: target.label, + id: target.id, + selector: `--host ssh:${target.id}` + })) + const hosts = [ + { kind: 'local' as const, name: 'this machine', id: 'local', selector: '--host local' }, + ...sshTargets, + ...environments + ] + printResult(localSuccess({ hosts }), json, formatHostList) + }, 'environment list': async ({ json }) => { const environments = listEnvironments(getDefaultUserDataPath()).map(redactRuntimeEnvironment) printResult(localSuccess({ environments }), json, formatEnvironmentList) diff --git a/src/cli/handlers/project.ts b/src/cli/handlers/project.ts index 83c13a93448..2b0a8d8ada2 100644 --- a/src/cli/handlers/project.ts +++ b/src/cli/handlers/project.ts @@ -22,7 +22,11 @@ import { formatProjectList, printResult } from '../format' -import { hostFilterMatchesHostId, parseHostFlag } from '../execution-host-flag' +import { + hostFilterMatchesHostId, + parseHostFlag, + resolveHostFlagTarget +} from '../execution-host-flag' import { getOptionalStringFlag, getRequiredStringFlag } from '../flags' import { resolveRepoPathArgument } from '../repo-path-arguments' import { RuntimeClientError, type RuntimeRpcSuccess } from '../runtime-client' @@ -53,6 +57,20 @@ async function callProjectHostSetup( } } +async function getResolvedHostId( + flags: Map, + client: HandlerContext['client'] +): Promise { + const host = await resolveHostFlagTarget(flags, client) + if (!host) { + throw new RuntimeClientError('invalid_argument', 'Missing required --host') + } + return host.id +} + +// Why: the setup paths keep the unresolved id on purpose. The runtime rejects every `ssh:` host +// for those operations regardless of whether it exists, so resolving first would answer "no such +// target" and imply the command would have worked with the right id. function getRequiredHostId(flags: Map): ExecutionHostId { const host = parseHostFlag(flags) if (!host) { @@ -79,7 +97,7 @@ export const PROJECT_HANDLERS: Record = { }, 'project setups': async ({ flags, client, json }) => { const projectFilter = getOptionalStringFlag(flags, 'project') - const hostFilter = parseHostFlag(flags) + const hostFilter = await resolveHostFlagTarget(flags, client) const result = await callProjectHostSetup<{ setups: ProjectHostSetup[] }>( client, 'projectHostSetup.list' @@ -132,7 +150,12 @@ export const PROJECT_HANDLERS: Record = { const path = getOptionalStringFlag(flags, 'path') const args: ProjectHostSetupCreateArgs = { projectId: getRequiredStringFlag(flags, 'project'), - hostId: getRequiredHostId(flags), + // Why: unlike the setup paths below, the runtime does not reject `ssh:` here — this records + // independent metadata — so an unknown target would persist a row pointing at a machine + // that does not exist. Resolving catches that. `local` and `runtime:` pass through + // untouched, because this is also the provisioning path and a runtime host legitimately + // may not exist yet when its metadata is written. + hostId: await getResolvedHostId(flags, client), setupId: getOptionalStringFlag(flags, 'setup-id'), path: path === undefined diff --git a/src/cli/help.ts b/src/cli/help.ts index 31a0eded615..afabbc7e155 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -30,6 +30,9 @@ Skills: skills install Install bundled Orca skills globally via the community skills CLI skills update Update already-installed Orca skills via the community skills CLI +Hosts: + host list List targetable machines and how to name each one + Environments: environment add Save a remote Orca runtime from a pairing code environment list List saved remote Orca runtimes @@ -225,6 +228,7 @@ Common Commands: orca agent-context [--json] orca account add [--agent claude|codex] [--json] orca account list [--json] + orca host list [--json] orca environment add --name --pairing-code [--json] orca environment list [--json] orca environment show --environment [--json] diff --git a/src/cli/host-selector-alternatives.test.ts b/src/cli/host-selector-alternatives.test.ts new file mode 100644 index 00000000000..bc4fadd05c1 --- /dev/null +++ b/src/cli/host-selector-alternatives.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it, vi } from 'vitest' +import { + ambiguousEnvironments, + ambiguousSshTargets, + crossKindNextSteps, + findEnvironmentByName, + findSshTargetByName, + listSshTargets, + resolveSshHostTargetId +} from './host-selector-alternatives' +import type { RuntimeClient } from './runtime-client' + +const SSH_TARGETS = [{ id: 'ssh-1755000000000-a1b2c3', label: 'openclaw' }] +const ENVIRONMENTS = [{ id: '03ef704c-b180-4b10-998d-e28fbd5de9a3', name: 'awin' }] + +function clientReturning(targets: { id: string; label: string }[]): RuntimeClient { + return { + call: vi.fn(async () => ({ result: { targets } })) + } as unknown as RuntimeClient +} + +describe('findSshTargetByName', () => { + // Why: ids are machine-generated `ssh--`, so the label is the only name a + // person or agent ever has. Matching ids alone reports "not found" for a target that exists. + it('matches the human label as well as the generated id', () => { + expect(findSshTargetByName(SSH_TARGETS, 'openclaw')?.id).toBe('ssh-1755000000000-a1b2c3') + expect(findSshTargetByName(SSH_TARGETS, 'ssh-1755000000000-a1b2c3')?.label).toBe('openclaw') + expect(findSshTargetByName(SSH_TARGETS, 'OpenClaw')?.label).toBe('openclaw') + }) + + it('does not invent a match', () => { + expect(findSshTargetByName(SSH_TARGETS, 'awin')).toBeUndefined() + }) +}) + +describe('findEnvironmentByName', () => { + it('matches a paired server by name or id', () => { + expect(findEnvironmentByName(ENVIRONMENTS, 'awin')?.id).toBe(ENVIRONMENTS[0]!.id) + expect(findEnvironmentByName(ENVIRONMENTS, ENVIRONMENTS[0]!.id)?.name).toBe('awin') + }) +}) + +describe('crossKindNextSteps', () => { + const alternatives = { sshTargets: SSH_TARGETS, environments: ENVIRONMENTS } + + it('points an SSH name at the SSH flag when a server was asked for', () => { + const steps = crossKindNextSteps('openclaw', alternatives, 'environment') + + expect(steps.join('\n')).toContain('--host ssh:ssh-1755000000000-a1b2c3') + expect(steps.join('\n')).toContain('is an SSH target') + }) + + it('points a server name at --environment when an SSH target was asked for', () => { + const steps = crossKindNextSteps('awin', alternatives, 'ssh') + + expect(steps.join('\n')).toContain('--environment awin') + expect(steps.join('\n')).toContain('is a paired Orca server') + }) + + it('says nothing when the name exists on neither axis', () => { + expect(crossKindNextSteps('nowhere', alternatives, 'ssh')).toEqual([]) + }) + + // Why: suggesting the axis the caller already used would be noise, not help. + it('does not suggest the axis that was already requested', () => { + expect(crossKindNextSteps('openclaw', alternatives, 'ssh')).toEqual([]) + expect(crossKindNextSteps('awin', alternatives, 'environment')).toEqual([]) + }) +}) + +describe('resolveSshHostTargetId', () => { + it('resolves a label to the target id', async () => { + await expect( + resolveSshHostTargetId(clientReturning(SSH_TARGETS), 'openclaw', ENVIRONMENTS) + ).resolves.toBe('ssh-1755000000000-a1b2c3') + }) + + // Why: this used to answer ok:true with an empty list — the same silent wrong-machine result + // that unknown runtime ids gave before they were rejected. + it('rejects an unknown target instead of letting it filter to nothing', async () => { + await expect( + resolveSshHostTargetId(clientReturning(SSH_TARGETS), 'nowhere', ENVIRONMENTS) + ).rejects.toThrow('no SSH target named or with id nowhere') + }) + + it('names the paired server when an SSH target was asked for by a server name', async () => { + await expect( + resolveSshHostTargetId(clientReturning(SSH_TARGETS), 'awin', ENVIRONMENTS) + ).rejects.toMatchObject({ + data: { + nextSteps: expect.arrayContaining([expect.stringContaining('--environment awin')]) + } + }) + }) + + it('says so plainly when the host has no SSH targets at all', async () => { + await expect( + resolveSshHostTargetId(clientReturning([]), 'openclaw', ENVIRONMENTS) + ).rejects.toMatchObject({ + data: { nextSteps: expect.arrayContaining(['This Orca host has no SSH targets registered.']) } + }) + }) +}) + +describe('listSshTargets', () => { + // Why: an older host answers listTargets but not listTargetSummaries. Treating that as "no + // targets" would reject an ssh id that is valid on that host. + it('falls back to the older listing when the newer method is absent', async () => { + const { RuntimeClientError } = await import('./runtime/types.js') + const call = vi.fn(async (method: string) => { + if (method === 'ssh.listTargetSummaries') { + throw new RuntimeClientError('method_not_found', 'Unknown method') + } + return { result: { targets: SSH_TARGETS } } + }) + + await expect(listSshTargets({ call } as unknown as RuntimeClient)).resolves.toEqual(SSH_TARGETS) + expect(call).toHaveBeenCalledWith('ssh.listTargets') + }) + + // Why: this only ever runs to enrich an error we are already reporting; a failure here must + // not replace that error with a confusing one about SSH enumeration. + it('returns nothing rather than masking the error it was enriching', async () => { + const client = { + call: vi.fn(async () => { + throw new Error('boom') + }) + } + + await expect(listSshTargets(client as unknown as RuntimeClient)).resolves.toEqual([]) + }) +}) + +describe('ambiguous names never resolve silently', () => { + const twoOpenclaw = [ + { id: 'ssh-1-a', label: 'openclaw' }, + { id: 'ssh-2-b', label: 'openclaw' } + ] + const twoAwin = [ + { id: 'env-1', name: 'awin' }, + { id: 'env-2', name: 'awin' } + ] + + // Why: picking the first would choose a machine on the caller's behalf — the exact failure the + // whole selector path exists to prevent. + it('refuses to guess between two ssh targets sharing a label', () => { + expect(findSshTargetByName(twoOpenclaw, 'openclaw')).toBeUndefined() + expect(ambiguousSshTargets(twoOpenclaw, 'openclaw')).toHaveLength(2) + }) + + it('refuses to guess between two servers sharing a name', () => { + expect(findEnvironmentByName(twoAwin, 'awin')).toBeUndefined() + expect(ambiguousEnvironments(twoAwin, 'awin')).toHaveLength(2) + }) + + // An exact id is never ambiguous, even when labels collide. + it('still resolves an exact id past a colliding label', () => { + expect(findSshTargetByName(twoOpenclaw, 'ssh-2-b')?.id).toBe('ssh-2-b') + expect(findEnvironmentByName(twoAwin, 'env-2')?.id).toBe('env-2') + }) + + it('reports no ambiguity for a unique name', () => { + expect(ambiguousSshTargets(SSH_TARGETS, 'openclaw')).toEqual([]) + expect(ambiguousEnvironments(ENVIRONMENTS, 'awin')).toEqual([]) + }) + + it('names both candidates when an ssh label is ambiguous', async () => { + await expect( + resolveSshHostTargetId(clientReturning(twoOpenclaw), 'openclaw', ENVIRONMENTS) + ).rejects.toMatchObject({ + data: { + nextSteps: expect.arrayContaining([ + expect.stringContaining('ssh-1-a'), + expect.stringContaining('ssh-2-b') + ]) + } + }) + }) +}) diff --git a/src/cli/host-selector-alternatives.ts b/src/cli/host-selector-alternatives.ts new file mode 100644 index 00000000000..fd5abecbc18 --- /dev/null +++ b/src/cli/host-selector-alternatives.ts @@ -0,0 +1,159 @@ +import type { RuntimeClient } from './runtime-client' + +export type SshTargetSummary = { id: string; label: string } +export type EnvironmentSummary = { id: string; name: string } + +export type HostAlternatives = { + sshTargets: readonly SshTargetSummary[] + environments: readonly EnvironmentSummary[] +} + +// Why: SSH target ids are machine-generated (`ssh--`), so the name a person +// or an agent actually knows is the label. Looking a name up as an id alone reports "not found" +// for a target that is sitting right there. +export function findSshTargetByName( + targets: readonly SshTargetSummary[], + name: string +): SshTargetSummary | undefined { + const byId = targets.find((target) => target.id === name) + if (byId) { + return byId + } + const byLabel = matchesByLabel(targets, name) + // Why: two targets can share a label. Picking the first would silently choose a machine for + // the caller — the failure this whole selector path exists to prevent — so an ambiguous name + // resolves to nothing and the caller is told to use an id. + return byLabel.length === 1 ? byLabel[0] : undefined +} + +function matchesByLabel(targets: readonly SshTargetSummary[], name: string): SshTargetSummary[] { + const wanted = name.trim().toLowerCase() + return targets.filter((target) => target.label.trim().toLowerCase() === wanted) +} + +export function ambiguousSshTargets( + targets: readonly SshTargetSummary[], + name: string +): SshTargetSummary[] { + const byLabel = matchesByLabel(targets, name) + return byLabel.length > 1 ? byLabel : [] +} + +export function findEnvironmentByName( + environments: readonly EnvironmentSummary[], + name: string +): EnvironmentSummary | undefined { + const byId = environments.find((environment) => environment.id === name) + if (byId) { + return byId + } + const byName = matchesByEnvironmentName(environments, name) + // Why: the environment store itself refuses an ambiguous name rather than guessing; resolving + // one here would quietly reintroduce the guess it exists to prevent. + return byName.length === 1 ? byName[0] : undefined +} + +function matchesByEnvironmentName( + environments: readonly EnvironmentSummary[], + name: string +): EnvironmentSummary[] { + const wanted = name.trim().toLowerCase() + return environments.filter((environment) => environment.name.trim().toLowerCase() === wanted) +} + +export function ambiguousEnvironments( + environments: readonly EnvironmentSummary[], + name: string +): EnvironmentSummary[] { + const byName = matchesByEnvironmentName(environments, name) + return byName.length > 1 ? byName : [] +} + +// Why: a paired Orca server and an SSH target are different machines reached different ways, but +// a caller only knows "the machine called X". When X misses on one axis, the useful answer is +// almost always that it exists on the other — so say which, and give the exact flag. +export function crossKindNextSteps( + name: string, + alternatives: HostAlternatives, + requested: 'environment' | 'ssh' +): string[] { + const steps: string[] = [] + const ssh = findSshTargetByName(alternatives.sshTargets, name) + const environment = findEnvironmentByName(alternatives.environments, name) + if (requested !== 'ssh' && ssh) { + steps.push( + `"${name}" is an SSH target on this Orca host, not a paired server. Use --host ssh:${ssh.id}.` + ) + } + if (requested !== 'environment' && environment) { + steps.push( + `"${name}" is a paired Orca server, not an SSH target. Use --environment ${environment.name}.` + ) + } + return steps +} + +// Why: only display identity crosses this boundary — the RPC deliberately withholds addresses +// and credentials — and an enumeration failure must never mask the error we are explaining. +export async function listSshTargets(client: RuntimeClient): Promise { + try { + const result = await client.call<{ targets: SshTargetSummary[] }>('ssh.listTargetSummaries') + return result.result.targets + } catch (error) { + // Why: hosts predating listTargetSummaries still answer listTargets, and both are served by + // the same summariser. Without this an old host looks like one with no SSH targets at all, + // which would reject a target id that is actually valid there. + if (error instanceof Error && 'code' in error && error.code === 'method_not_found') { + try { + const legacy = await client.call<{ targets: SshTargetSummary[] }>('ssh.listTargets') + return legacy.result.targets + } catch { + return [] + } + } + return [] + } +} + +// Why: `--host ssh:` was never validated, so an unknown target answered ok:true with an +// empty list — the same silent wrong-machine answer that `runtime:` ids used to give. And since +// target ids are machine-generated (`ssh--`), the label a caller actually +// knows never matches one, so this fires on the common spelling rather than a rare typo. +// Resolving the label and naming the alternative is what makes the failure recoverable. +export async function resolveSshHostTargetId( + client: RuntimeClient, + targetId: string, + environments: readonly EnvironmentSummary[] +): Promise { + const targets = await listSshTargets(client) + const matched = findSshTargetByName(targets, targetId) + if (matched) { + return matched.id + } + const { RuntimeClientError } = await import('./runtime/types.js') + const ambiguous = ambiguousSshTargets(targets, targetId) + if (ambiguous.length > 0) { + throw new RuntimeClientError( + 'invalid_argument', + `Ambiguous SSH target in --host ssh:${targetId}: ${ambiguous.length} targets share that label. Use the target id.`, + { + knownSshTargets: ambiguous, + nextSteps: ambiguous.map((target) => `Use --host ssh:${target.id} for ${target.label}.`) + } + ) + } + throw new RuntimeClientError( + 'invalid_argument', + `Unknown SSH target in --host ssh:${targetId}: this Orca host has no SSH target named or with id ${targetId}.`, + { + knownSshTargets: targets, + knownEnvironments: environments, + nextSteps: [ + ...crossKindNextSteps(targetId, { sshTargets: targets, environments }, 'ssh'), + ...(targets.length > 0 + ? [`Known SSH targets: ${targets.map((target) => target.label).join(', ')}.`] + : ['This Orca host has no SSH targets registered.']) + ] + } + ) +} diff --git a/src/cli/index-project-setup.test.ts b/src/cli/index-project-setup.test.ts index f5efead6a80..068c246ba7c 100644 --- a/src/cli/index-project-setup.test.ts +++ b/src/cli/index-project-setup.test.ts @@ -220,9 +220,10 @@ describe('orca cli worktree awareness', () => { await main(['project', 'setups', '--host', 'runtime:not-a-real-env', '--json'], '/tmp/repo') - expect(callMock).not.toHaveBeenCalled() + // The command itself never reached a runtime; only the suggestion lookup did. + expect(callMock).not.toHaveBeenCalledWith('projectHostSetup.list') const printed = [...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n') - expect(printed).toContain('no paired environment has id not-a-real-env') + expect(printed).toContain('no paired Orca server is named or has id not-a-real-env') // An agent reads the code and the retry candidates, not the prose. expect(JSON.parse(printed).error.code).toBe('invalid_argument') expect(JSON.parse(printed).error.data.knownEnvironments).toEqual([]) @@ -231,6 +232,124 @@ describe('orca cli worktree awareness', () => { process.exitCode = priorExitCode }) + // Why: ssh: was never validated, so an unknown target answered ok:true with an empty list — + // the same silent wrong-machine answer unknown runtime ids used to give. + it('rejects an unknown ssh host instead of answering empty', async () => { + queueFixtures(callMock, okFixture('req_ssh_targets', { targets: [] })) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main(['project', 'setups', '--host', 'ssh:openclaw', '--json'], '/tmp/repo') + + expect(callMock).not.toHaveBeenCalledWith('projectHostSetup.list') + expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain( + 'no SSH target named or with id openclaw' + ) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + + it('tells a caller reaching for a paired server by ssh that it is an environment', async () => { + pairRuntimeEnvironment(listEnvironmentsMock, 'awin') + queueFixtures(callMock, okFixture('req_ssh_targets', { targets: [] })) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main(['project', 'setups', '--host', 'ssh:awin', '--json'], '/tmp/repo') + + expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain( + '--environment awin' + ) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + + it('resolves an ssh label to its target id before filtering', async () => { + queueFixtures( + callMock, + okFixture('req_ssh_targets', { targets: [{ id: 'ssh-123-abc', label: 'openclaw' }] }), + okFixture('req_project_setups', { + setups: [ + { + id: 'setup-openclaw', + projectId: 'github:stablyai/orca', + hostId: 'ssh:ssh-123-abc', + repoId: 'repo-openclaw', + path: '/home/me/orca', + displayName: 'Orca', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + } + ] + }) + ) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['project', 'setups', '--host', 'ssh:openclaw'], '/tmp/repo') + + expect(logSpy.mock.calls[0]?.[0]).toContain('setup-openclaw') + }) + + // Why: `runtime:` is a persisted token — it lands in ProjectHostSetup.hostId and is + // embedded in generated setup ids. Accepting a name is only safe because it is canonicalized + // to the id before anything downstream sees it; this pins that. + it('never lets an environment name reach a persisted host id', async () => { + pairRuntimeEnvironment(listEnvironmentsMock, 'env-uuid-1', 'awin') + queueFixtures( + callMock, + okFixture('req_project_setup_create', { + result: { + project: { + id: 'github:stablyai/orca', + displayName: 'Orca', + badgeColor: '#7c3aed', + sourceRepoIds: [], + createdAt: 1, + updatedAt: 1 + }, + setup: { + id: 'setup-awin', + projectId: 'github:stablyai/orca', + hostId: 'local', + repoId: '', + path: '', + displayName: 'awin', + setupState: 'setting-up', + setupMethod: 'provisioned', + createdAt: 1, + updatedAt: 2 + } + } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + [ + 'project', + 'setup-create', + '--project', + 'github:stablyai/orca', + '--host', + 'runtime:awin', + '--json' + ], + '/tmp/repo' + ) + + expect(runtimeClientConstructorMock).toHaveBeenCalledWith(null, 'env-uuid-1') + expect(callMock).toHaveBeenCalledWith( + 'projectHostSetup.create', + expect.objectContaining({ hostId: 'runtime:env-uuid-1' }) + ) + }) + it('rejects a malformed --host value before contacting any runtime', async () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) diff --git a/src/cli/index.ts b/src/cli/index.ts index c6585e79247..26cfb8eea49 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -9,7 +9,11 @@ import { validateCommandAndFlags } from './args' import { dispatch } from './dispatch' -import { resolveHostFlagEnvironmentId } from './execution-host-flag' +import { + assertEnvironmentSelectorResolvable, + resolveHostFlagEnvironmentId +} from './execution-host-flag' +import { listSshTargets } from './host-selector-alternatives' import { reportCliError } from './format' import { printHelp } from './help' import type { RuntimeClient } from './runtime-client' @@ -90,6 +94,15 @@ export async function main( const ignoreRemoteSelection = shouldIgnoreRemoteSelection(parsed.commandPath) const pairingCode = ignoreRemoteSelection ? null : parsed.flags.get('pairing-code') const environmentSelector = ignoreRemoteSelection ? null : parsed.flags.get('environment') + // Why: only the explicit flag is asserted eagerly. An ambient ORCA_ENVIRONMENT is background + // config, and failing local-only commands because of a stale one would be a regression; the + // explicit flag means the caller named that machine, so a bad name should fail immediately + // with the cross-kind hint rather than a bare store error at first use. + const listSshTargetsForSuggestion = async (): Promise<{ id: string; label: string }[]> => + listSshTargets(new RuntimeClientClass(undefined, undefined, null, null)) + if (typeof environmentSelector === 'string') { + await assertEnvironmentSelectorResolvable(environmentSelector, listSshTargetsForSuggestion) + } // Why: --host runtime: names a paired server, not a filter over this // runtime's rows, so it has to pick the connection before the client exists. // An ambient ORCA_ENVIRONMENT is checked for disagreement too — silently @@ -99,6 +112,9 @@ export async function main( const hostEnvironmentId = ignoreRemoteSelection ? null : await resolveHostFlagEnvironmentId(parsed.flags, { + // Why: only consulted when the name missed, and against this machine's own runtime — + // SSH targets are registered there, not in the paired server we failed to find. + listSshTargets: listSshTargetsForSuggestion, pairingCode: typeof pairingCode === 'string' ? pairingCode : null, environmentSelector: typeof environmentSelector === 'string' @@ -107,6 +123,12 @@ export async function main( ? { value: process.env.ORCA_ENVIRONMENT, label: 'ORCA_ENVIRONMENT' } : null }) + // Why: --host runtime: is canonicalized to the environment's id so downstream host-id + // comparisons against stored rows still match; rewrite the flag once, here, rather than + // resolving the name again at every consumer. + if (hostEnvironmentId !== null) { + parsed.flags.set('host', `runtime:${hostEnvironmentId}`) + } // Why: pass `null` (not `undefined`) when remote selection is suppressed // so the RuntimeClient default parameter does not re-activate the // ORCA_PAIRING_CODE / ORCA_ENVIRONMENT env-var fallback for commands diff --git a/src/cli/runtime-client-deferral.test.ts b/src/cli/runtime-client-deferral.test.ts index d5a1f421213..9345283431c 100644 --- a/src/cli/runtime-client-deferral.test.ts +++ b/src/cli/runtime-client-deferral.test.ts @@ -11,6 +11,16 @@ const { constructorArgsMock, callMock, getCliStatusMock } = vi.hoisted(() => ({ // Why: `main` reaches RuntimeClient through `await import('./runtime-client.js')` // now. Mocking the same specifier the eager import used proves the dynamic // import still resolves to the module the 10 existing vi.mock suites target. +// Why: --environment is now resolved against the paired-environment store before the client is +// constructed, so a forwarding assertion needs an environment that actually resolves. +vi.mock('./runtime/environments', async (importOriginal) => { + const actual = await importOriginal>() + return { + ...actual, + listEnvironments: () => [{ id: 'env-1', name: 'env-1' }] + } +}) + vi.mock('./runtime-client', () => { class RuntimeClient { call = callMock @@ -21,7 +31,7 @@ vi.mock('./runtime-client', () => { constructorArgsMock(...args) } } - return { RuntimeClient } + return { RuntimeClient, getDefaultUserDataPath: () => '/tmp/orca-user-data' } }) import { main } from './index' diff --git a/src/cli/specs/environment.ts b/src/cli/specs/environment.ts index 7c4aecbc31a..d6ceb795028 100644 --- a/src/cli/specs/environment.ts +++ b/src/cli/specs/environment.ts @@ -2,6 +2,18 @@ import type { CommandSpec } from '../args' import { GLOBAL_FLAGS } from '../args' export const ENVIRONMENT_COMMAND_SPECS: CommandSpec[] = [ + { + path: ['host', 'list'], + summary: 'List every machine this Orca host can target, and how to name each one', + usage: 'orca host list [--json]', + allowedFlags: [...GLOBAL_FLAGS], + notes: [ + 'Answers "what can I target and what do I pass" in one place: this machine, the SSH targets registered on it, and the Orca servers paired with it.', + 'The three kinds are reached differently. A paired Orca server is a connection, selected with --environment . An SSH target is a machine the connected Orca host reaches, selected with --host ssh:. Passing one where the other belongs is the most common way to get an empty or missing-host answer.', + "SSH targets are read from the Orca host you are currently connected to, so this lists that host's targets and not another server's." + ], + examples: ['orca host list', 'orca host list --json'] + }, { path: ['environment', 'add'], summary: 'Save a remote Orca runtime environment from a pairing code', diff --git a/src/cli/worktree-project-target.ts b/src/cli/worktree-project-target.ts index 66d94aa0b1a..6dfd169fccc 100644 --- a/src/cli/worktree-project-target.ts +++ b/src/cli/worktree-project-target.ts @@ -1,6 +1,6 @@ import { normalizeExecutionHostId, type ParsedExecutionHost } from '../shared/execution-host' import type { ProjectHostSetup } from '../shared/project-types' -import { hostFilterMatchesHostId, parseHostFlag } from './execution-host-flag' +import { hostFilterMatchesHostId, resolveHostFlagTarget } from './execution-host-flag' import type { RuntimeClient } from './runtime-client' import { RuntimeClientError } from './runtime-client' @@ -73,7 +73,7 @@ export async function resolveProjectCreateTarget( ): Promise { const projectHostSetupId = getPresentStringFlag(flags, 'project-host-setup') const projectId = getPresentStringFlag(flags, 'project') - const host = parseHostFlag(flags) + const host = await resolveHostFlagTarget(flags, client) if (!projectHostSetupId && !projectId && !host) { return undefined } From 0e5b348414012af782d32eef9d837b8f49c8b24c Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:26:16 -0700 Subject: [PATCH 002/111] fix(repos): keep the desktop-owned manual project order authoritative across paired clients and SSH catalog publishes (STA-4850) (#15538) --- src/main/persistence-ui-state.test.ts | 25 +++ .../client-ui-pairing-local-fields.test.ts | 111 +++++++++++ .../runtime/rpc/methods/client-ui.test.ts | 16 +- src/main/runtime/rpc/methods/client-ui.ts | 17 +- .../hooks/direct-ssh-host-hydration.test.ts | 53 +++++ .../src/hooks/direct-ssh-host-hydration.ts | 11 +- .../src/web/web-preload-api-ui.test.ts | 181 ++++++++++++++++++ src/renderer/src/web/web-preload-api.ts | 39 ++-- src/shared/pairing-local-ui-fields.test.ts | 40 ++++ src/shared/pairing-local-ui-fields.ts | 28 +++ 10 files changed, 486 insertions(+), 35 deletions(-) create mode 100644 src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts create mode 100644 src/shared/pairing-local-ui-fields.test.ts create mode 100644 src/shared/pairing-local-ui-fields.ts diff --git a/src/main/persistence-ui-state.test.ts b/src/main/persistence-ui-state.test.ts index d6690712e72..ccafa4e3fab 100644 --- a/src/main/persistence-ui-state.test.ts +++ b/src/main/persistence-ui-state.test.ts @@ -95,6 +95,31 @@ describe('Store', () => { ]) }) + // The RPC now strips manualRepoOrder, so every paired-client ui.set reaches the store without + // the key. Absent has to mean preserve: if it read as clear, the strip would erase the desktop's + // order on the first unrelated setting a phone or web client changes. + // Absent-means-preserve is what makes the pairing-local strip safe: a client's ui.set arrives + // without these fields, so the desktop's own values must survive the update. + it('updateUI preserves the manual repo and host-section order when an update omits them', async () => { + const store = await createStore() + store.updateUI({ + manualRepoOrder: [ + { hostId: 'local', repoId: 'alpha' }, + { hostId: 'ssh:box', repoId: 'bravo' } + ] as never, + workspaceHostOrder: ['ssh:box', 'local'] as never + }) + + store.updateUI({ sidebarWidth: 400 }) + + expect(store.getUI().manualRepoOrder).toEqual([ + { hostId: 'local', repoId: 'alpha' }, + { hostId: 'ssh:box', repoId: 'bravo' } + ]) + expect(store.getUI().workspaceHostOrder).toEqual(['ssh:box', 'local']) + expect(store.getUI().sidebarWidth).toBe(400) + }) + it('updateUI persists sanitized per-worktree dotfile visibility', async () => { const store = await createStore() store.updateUI({ diff --git a/src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts b/src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts new file mode 100644 index 00000000000..d2bcec6a7f3 --- /dev/null +++ b/src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it, vi } from 'vitest' +import { getDefaultUIState } from '../../../../shared/constants' +import { PAIRING_LOCAL_UI_FIELDS } from '../../../../shared/pairing-local-ui-fields' +import type { OrcaRuntimeService } from '../../orca-runtime' +import type { RpcRequest } from '../core' +import { RpcDispatcher } from '../dispatcher' +import { CLIENT_UI_METHODS } from './client-ui' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +// Both directions of the pairing boundary for the fields in PAIRING_LOCAL_UI_FIELDS: a client's +// value must never be persisted by the host, and the host's must never be returned to a client. +describe('client UI RPC pairing-local field seams', () => { + it('drops a paired client manualRepoOrder while forwarding the rest of the payload', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + updateUIState: vi.fn(() => getDefaultUIState()) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS }) + + // A paired web client restamps every repo onto its own runtime:web-* pseudo-host, so its + // overlay names hosts this profile will never own; persisting it erases the desktop order. + const response = await dispatcher.dispatch( + makeRequest('ui.set', { + sidebarWidth: 280, + manualRepoOrder: [ + { hostId: 'runtime:web-11111111-2222-3333-4444-555555555555', repoId: 'repo-a' }, + { hostId: 'runtime:web-11111111-2222-3333-4444-555555555555', repoId: 'repo-b' } + ] + }) + ) + + expect(response).toMatchObject({ ok: true }) + expect(runtime.updateUIState).toHaveBeenCalledWith({ sidebarWidth: 280 }) + }) + + // Driven off the census so a field added to PAIRING_LOCAL_UI_FIELDS without wiring a seam + // fails here rather than shipping. Sample values are what a paired web client actually sends. + const pairingLocalSamples: Record<(typeof PAIRING_LOCAL_UI_FIELDS)[number], unknown> = { + hideWorkspacesFromOtherDevices: true, + manualRepoOrder: [ + { hostId: 'runtime:web-11111111-2222-3333-4444-555555555555', repoId: 'repo-a' } + ], + workspaceHostOrder: ['runtime:web-11111111-2222-3333-4444-555555555555', 'local'] + } + + it.each(PAIRING_LOCAL_UI_FIELDS.map((field) => [field] as const))( + 'ui.set never persists the pairing-local field %s', + async (field) => { + const runtime = { + getRuntimeId: () => 'test-runtime', + updateUIState: vi.fn(() => getDefaultUIState()) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('ui.set', { sidebarWidth: 280, [field]: pairingLocalSamples[field] }) + ) + + expect(response).toMatchObject({ ok: true }) + const forwarded = vi.mocked(runtime.updateUIState).mock.calls[0]?.[0] ?? {} + expect(Object.keys(forwarded)).not.toContain(field) + expect(forwarded).toMatchObject({ sidebarWidth: 280 }) + } + ) + + // The read path matters as much as the write path: a host that echoes these back overwrites the + // client's own value, and on a profile poisoned before the write strip the echoed + // runtime:web-* keys match the very client that minted them. + it.each(PAIRING_LOCAL_UI_FIELDS.map((field) => [field] as const))( + 'ui.get never returns the pairing-local field %s', + async (field) => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getUIState: vi.fn(() => ({ ...getDefaultUIState(), [field]: pairingLocalSamples[field] })) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS }) + + const response = await dispatcher.dispatch(makeRequest('ui.get')) + + const ui = (response as { result: { ui: Record } }).result.ui + expect(Object.keys(ui)).not.toContain(field) + expect(ui).toMatchObject({ sidebarWidth: getDefaultUIState().sidebarWidth }) + } + ) + + it.each(PAIRING_LOCAL_UI_FIELDS.map((field) => [field] as const))( + 'the ui.set and ui.recordFeatureInteraction responses omit the pairing-local field %s', + async (field) => { + const stored = { ...getDefaultUIState(), [field]: pairingLocalSamples[field] } + const runtime = { + getRuntimeId: () => 'test-runtime', + updateUIState: vi.fn(() => stored), + recordFeatureInteraction: vi.fn(() => stored) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS }) + + const setResponse = await dispatcher.dispatch(makeRequest('ui.set', { sidebarWidth: 280 })) + const interactionResponse = await dispatcher.dispatch( + makeRequest('ui.recordFeatureInteraction', 'tasks') + ) + + for (const response of [setResponse, interactionResponse]) { + const ui = (response as { result: { ui: Record } }).result.ui + expect(Object.keys(ui)).not.toContain(field) + } + } + ) +}) diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index a1f24c28a9f..3bfb7303bd0 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { getDefaultUIState } from '../../../../shared/constants' +import { omitPairingLocalUiFields } from '../../../../shared/pairing-local-ui-fields' import { MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH, MAX_QUICK_COMMAND_ID_LENGTH, @@ -375,7 +376,7 @@ describe('client UI RPC methods', () => { const response = await dispatcher.dispatch(makeRequest('ui.get')) expect(runtime.getUIState).toHaveBeenCalledTimes(1) - expect(response).toMatchObject({ ok: true, result: { ui } }) + expect(response).toMatchObject({ ok: true, result: { ui: omitPairingLocalUiFields(ui) } }) }) it('persists UI updates on the runtime host and returns the updated state', async () => { @@ -415,7 +416,7 @@ describe('client UI RPC methods', () => { hideAutomationGeneratedWorkspaces: true, filterRepoIds: ['repo-1'] }) - expect(response).toMatchObject({ ok: true, result: { ui: updated } }) + expect(response).toMatchObject({ ok: true, result: { ui: omitPairingLocalUiFields(updated) } }) }) it('lets a paired client clear the OSC 52 default-on notice', async () => { @@ -560,11 +561,12 @@ describe('client UI RPC methods', () => { } const response = await dispatcher.dispatch(makeRequest('ui.set', payload)) + const { manualRepoOrder: _desktopOwnedOrder, ...forwarded } = payload expect(runtime.updateUIState).toHaveBeenCalledWith({ - ...payload, + ...forwarded, worktreeCardProperties: ['status', 'unread', 'branch', 'automation', 'inline-agents'] }) - expect(response).toMatchObject({ ok: true, result: { ui: updated } }) + expect(response).toMatchObject({ ok: true, result: { ui: omitPairingLocalUiFields(updated) } }) }) // Why one case per field: the schema is strict, so a single unlisted key makes @@ -659,7 +661,7 @@ describe('client UI RPC methods', () => { const response = await dispatcher.dispatch(makeRequest('ui.recordFeatureInteraction', 'tasks')) expect(runtime.recordFeatureInteraction).toHaveBeenCalledWith('tasks') - expect(response).toMatchObject({ ok: true, result: { ui: updated } }) + expect(response).toMatchObject({ ok: true, result: { ui: omitPairingLocalUiFields(updated) } }) }) it('rejects unknown and malformed UI update fields', async () => { @@ -721,7 +723,7 @@ describe('client UI RPC methods', () => { expect(runtime.updateUIState).toHaveBeenCalledWith({ worktreeCardProperties: ['status', 'unread', 'jira-issue'] }) - expect(response).toMatchObject({ ok: true, result: { ui: updated } }) + expect(response).toMatchObject({ ok: true, result: { ui: omitPairingLocalUiFields(updated) } }) }) it('accepts every worktree card property the shared union defines', async () => { @@ -801,7 +803,7 @@ describe('client UI RPC methods', () => { expect(runtime.updateUIState).toHaveBeenCalledWith({ worktreeCardProperties: ['status', 'unread', 'ci', 'issue', 'pr'] }) - expect(response).toMatchObject({ ok: true, result: { ui: updated } }) + expect(response).toMatchObject({ ok: true, result: { ui: omitPairingLocalUiFields(updated) } }) }) it('rejects each star-nag persisted state mutation field from remote clients', async () => { diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index 70ad9e9df57..c62328ebc64 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -1,3 +1,4 @@ +import { omitPairingLocalUiFields } from '../../../../shared/pairing-local-ui-fields' import type { PersistedUIState } from '../../../../shared/persisted-ui-state-types' import { defineMethod, type RpcMethod } from '../core' import { @@ -50,22 +51,24 @@ export const CLIENT_UI_METHODS: RpcMethod[] = [ defineMethod({ name: 'ui.get', params: null, - handler: (_params, { runtime }) => ({ ui: runtime.getUIState() }) + handler: (_params, { runtime }) => ({ ui: omitPairingLocalUiFields(runtime.getUIState()) }) }), defineMethod({ name: 'ui.set', params: UiUpdate, - handler: (params, { runtime }) => { - const { hideWorkspacesFromOtherDevices: _clientLocalFilter, ...hostUpdates } = params - void _clientLocalFilter - return { ui: runtime.updateUIState(hostUpdates as Partial) } - } + // Why the fields are dropped here rather than removed from the schema: UiUpdate is strict, so + // an unlisted key would make the dispatcher reject an old client's ENTIRE payload. + handler: (params, { runtime }) => ({ + ui: omitPairingLocalUiFields( + runtime.updateUIState(omitPairingLocalUiFields(params) as Partial) + ) + }) }), defineMethod({ name: 'ui.recordFeatureInteraction', params: FeatureInteractionIdParam, handler: (params, { runtime }) => ({ - ui: runtime.recordFeatureInteraction(params) + ui: omitPairingLocalUiFields(runtime.recordFeatureInteraction(params)) }) }) ] diff --git a/src/renderer/src/hooks/direct-ssh-host-hydration.test.ts b/src/renderer/src/hooks/direct-ssh-host-hydration.test.ts index 4aa654cf33a..cc13aa983aa 100644 --- a/src/renderer/src/hooks/direct-ssh-host-hydration.test.ts +++ b/src/renderer/src/hooks/direct-ssh-host-hydration.test.ts @@ -113,6 +113,59 @@ describe('createDirectSshHostHydration', () => { ]) }) + it('keeps the manual cross-host order when the host catalog is republished', async () => { + const owner = authority('box') + const store = createStore(() => + state({ + repos: [repo('bravo', 'box'), repo('alpha', null), repo('delta', 'box')], + manualRepoOrder: [ + { hostId: 'ssh:box', repoId: 'bravo' }, + { hostId: 'local', repoId: 'alpha' }, + { hostId: 'ssh:box', repoId: 'delta' } + ] + }) + ) + const hydration = createDirectSshHostHydration({ + store, + listRepos: vi.fn(async () => + hostSnapshot(owner, [repo('bravo', 'box'), repo('delta', 'box')]) + ), + listLineage: vi.fn(), + isCurrentAuthority: () => true + }) + + await hydration.capturePreparationInput(owner, 'reconnect') + + expect(store.getState().repos.map((entry) => entry.id)).toEqual(['bravo', 'alpha', 'delta']) + }) + + // A repo added after the last drag has no overlay entry. Ranked rows must keep their order and + // the newcomer sinks to the tail, rather than the overlay being discarded for being incomplete. + it('keeps ranked rows in order and appends unranked ones when the overlay is partial', async () => { + const owner = authority('box') + const store = createStore(() => + state({ + repos: [repo('bravo', 'box'), repo('alpha', null), repo('delta', 'box')], + manualRepoOrder: [ + { hostId: 'ssh:box', repoId: 'delta' }, + { hostId: 'local', repoId: 'alpha' } + ] + }) + ) + const hydration = createDirectSshHostHydration({ + store, + listRepos: vi.fn(async () => + hostSnapshot(owner, [repo('bravo', 'box'), repo('delta', 'box')]) + ), + listLineage: vi.fn(), + isCurrentAuthority: () => true + }) + + await hydration.capturePreparationInput(owner, 'reconnect') + + expect(store.getState().repos.map((entry) => entry.id)).toEqual(['delta', 'alpha', 'bravo']) + }) + it('rejects a mismatched host response without publishing', async () => { const owner = authority() const store = createStore(() => state({ repos: [repo('cached', 'target-a')] })) diff --git a/src/renderer/src/hooks/direct-ssh-host-hydration.ts b/src/renderer/src/hooks/direct-ssh-host-hydration.ts index 069b3b3d323..419dc134075 100644 --- a/src/renderer/src/hooks/direct-ssh-host-hydration.ts +++ b/src/renderer/src/hooks/direct-ssh-host-hydration.ts @@ -2,6 +2,7 @@ import type { StoreApi } from 'zustand' import { getRepoExecutionHostId, toSshExecutionHostId } from '../../../shared/execution-host' import type { HostLineageSnapshot } from '../../../shared/host-lineage-contract' import type { HostRepoCatalogSnapshot } from '../../../shared/host-repo-catalog-contract' +import { applyManualRepoOrder } from '../../../shared/manual-repo-order' import type { DirectSshAuthority } from '../../../shared/ssh-types' import { isWorkspaceKey } from '../../../shared/workspace-scope' import type { AppState } from '../store/types' @@ -63,12 +64,14 @@ function mergeExactHostCatalog(state: AppState, snapshot: HostRepoCatalogSnapsho return state } const hostId = snapshot.authority.executionHostId + // Why re-apply the overlay: re-appending the host's rows puts them at the tail, which would + // undo the user's manual cross-host order on every connect. return { ...state, - repos: [ - ...state.repos.filter((repo) => getRepoExecutionHostId(repo) !== hostId), - ...snapshot.repos - ] + repos: applyManualRepoOrder( + [...state.repos.filter((repo) => getRepoExecutionHostId(repo) !== hostId), ...snapshot.repos], + state.manualRepoOrder + ) } } diff --git a/src/renderer/src/web/web-preload-api-ui.test.ts b/src/renderer/src/web/web-preload-api-ui.test.ts index e809210aac1..ce5e9572f9b 100644 --- a/src/renderer/src/web/web-preload-api-ui.test.ts +++ b/src/renderer/src/web/web-preload-api-ui.test.ts @@ -1,6 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { FeatureInteractionState } from '../../../shared/feature-interactions' +import { + PAIRING_LOCAL_UI_FIELDS, + type PairingLocalUiField +} from '../../../shared/pairing-local-ui-fields' +import type { PersistedUIState } from '../../../shared/persisted-ui-state-types' import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import type { ManualRepoOrderEntry } from '../../../shared/ui-chrome-types' import { installApi, installBrowserGlobals, @@ -327,6 +333,181 @@ describe('web UI preload API', () => { }) }) + it('keeps the manual repo order browser-local instead of overwriting the host order', async () => { + const runtimeCalls: { method: string; params: unknown }[] = [] + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string, params?: unknown): Promise> { + runtimeCalls.push({ method, params }) + return Promise.resolve({ + id: method, + ok: true, + result: { ui: {} }, + _meta: { runtimeId: 'runtime-1' } + }) + } + + close(): void {} + } + })) + + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + // The web client keys its order to its own runtime:web-* host, so a host that predates the + // RPC-side strip would persist it over the desktop's local/ssh-keyed order. + const manualRepoOrder: ManualRepoOrderEntry[] = [ + { hostId: 'runtime:web-11111111-2222-3333-4444-555555555555', repoId: 'repo-b' }, + { hostId: 'runtime:web-11111111-2222-3333-4444-555555555555', repoId: 'repo-a' } + ] + await globals.window.api.ui.set({ manualRepoOrder, sidebarWidth: 280 }) + + expect(runtimeCalls[0]).toEqual({ method: 'ui.set', params: { sidebarWidth: 280 } }) + expect(JSON.parse(globals.storage.getItem('orca.web.ui.v1') ?? '{}')).toMatchObject({ + manualRepoOrder, + sidebarWidth: 280 + }) + }) + + // Why cover a host that still sends the field: clients and hosts update independently, so a new + // web client meets hosts predating the response omission. On a profile an old web client + // poisoned, those echoed runtime:web-* entries match this browser's own repos and would beat + // every drag it has made since. + it.each([ + [ + 'this browser own stale runtime:web-* entries', + [ + { hostId: 'runtime:web-env-1', repoId: 'repo-c' }, + { hostId: 'runtime:web-env-1', repoId: 'repo-a' } + ] + ], + [ + 'the desktop local/ssh-keyed order', + [ + { hostId: 'local', repoId: 'repo-a' }, + { hostId: 'ssh:box', repoId: 'repo-b' } + ] + ], + ['an empty overlay from a desktop that never dragged', []] + ])('keeps the browser-local manual repo order when ui.get returns %s', async (_label, hosted) => { + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string): Promise> { + return Promise.resolve({ + id: method, + ok: true, + result: { ui: { manualRepoOrder: hosted } }, + _meta: { runtimeId: 'runtime-1' } + }) + } + + close(): void {} + } + })) + + const webOwnOrder: ManualRepoOrderEntry[] = [ + { hostId: 'runtime:web-env-1', repoId: 'repo-b' }, + { hostId: 'runtime:web-env-1', repoId: 'repo-a' } + ] + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + globals.storage.setItem('orca.web.ui.v1', JSON.stringify({ manualRepoOrder: webOwnOrder })) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + await expect(globals.window.api.ui.get()).resolves.toMatchObject({ + manualRepoOrder: webOwnOrder + }) + expect(JSON.parse(globals.storage.getItem('orca.web.ui.v1') ?? '{}')).toMatchObject({ + manualRepoOrder: webOwnOrder + }) + }) + + it('keeps the browser-local manual repo order when a new host omits it entirely', async () => { + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string): Promise> { + return Promise.resolve({ + id: method, + ok: true, + result: { ui: { sidebarWidth: 320 } }, + _meta: { runtimeId: 'runtime-1' } + }) + } + + close(): void {} + } + })) + + const webOwnOrder: ManualRepoOrderEntry[] = [ + { hostId: 'runtime:web-env-1', repoId: 'repo-b' }, + { hostId: 'runtime:web-env-1', repoId: 'repo-a' } + ] + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + globals.storage.setItem('orca.web.ui.v1', JSON.stringify({ manualRepoOrder: webOwnOrder })) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + await expect(globals.window.api.ui.get()).resolves.toMatchObject({ + manualRepoOrder: webOwnOrder, + sidebarWidth: 320 + }) + }) + + // Census-driven, matching the host-side seam tests: a field added to PAIRING_LOCAL_UI_FIELDS + // without wiring the web read seam fails here rather than shipping. The host sample differs from + // the browser's for every field, so only the pin makes this pass. + const browserLocalUiSamples: Record = { + hideWorkspacesFromOtherDevices: true, + manualRepoOrder: [{ hostId: 'runtime:web-env-1', repoId: 'repo-b' }], + workspaceHostOrder: ['runtime:web-env-1', 'local'] + } + const hostUiSamples: Record = { + hideWorkspacesFromOtherDevices: false, + manualRepoOrder: [{ hostId: 'local', repoId: 'repo-a' }], + workspaceHostOrder: ['local', 'ssh:box'] + } + + it.each(PAIRING_LOCAL_UI_FIELDS.map((field) => [field] as const))( + 'keeps the browser-local %s and never sends it to the host', + async (field) => { + const runtimeCalls: { method: string; params: unknown }[] = [] + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string, params?: unknown): Promise> { + runtimeCalls.push({ method, params }) + return Promise.resolve({ + id: method, + ok: true, + result: { ui: { [field]: hostUiSamples[field] } }, + _meta: { runtimeId: 'runtime-1' } + }) + } + + close(): void {} + } + })) + + const browserLocal = { [field]: browserLocalUiSamples[field] } as Partial + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + globals.storage.setItem('orca.web.ui.v1', JSON.stringify(browserLocal)) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + await globals.window.api.ui.set({ ...browserLocal, sidebarWidth: 280 }) + + expect(runtimeCalls[0]).toEqual({ method: 'ui.set', params: { sidebarWidth: 280 } }) + await expect(globals.window.api.ui.get()).resolves.toMatchObject(browserLocal) + expect(JSON.parse(globals.storage.getItem('orca.web.ui.v1') ?? '{}')).toMatchObject( + browserLocal + ) + } + ) + it('union-merges local contextual tour seen ids when ui.get returns stale host state', async () => { vi.doMock('./web-runtime-client', () => ({ WebRuntimeClient: class { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 57f5efacef3..894ce0157dc 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -32,6 +32,11 @@ import type { } from '../../../shared/global-settings-types' import type { OnboardingState } from '../../../shared/onboarding-state-types' import type { PersistedUIState } from '../../../shared/persisted-ui-state-types' +import { + omitPairingLocalUiFields, + type PairedUiState, + type PairingLocalUiField +} from '../../../shared/pairing-local-ui-fields' import type { MemorySnapshot, StatsSummary } from '../../../shared/process-stats-types' import type { Repo } from '../../../shared/repo-types' import type { @@ -2702,11 +2707,7 @@ function createWebUiApi(): NonNullable['ui']> { return { get: async () => { try { - const result = await callRuntimeResult<{ ui: PersistedUIState }>( - 'ui.get', - undefined, - 15_000 - ) + const result = await callRuntimeResult<{ ui: PairedUiState }>('ui.get', undefined, 15_000) const local = readLocalWebUIState() const next = { ...mergeHostWebUIState(local, result.ui), @@ -2731,9 +2732,9 @@ function createWebUiApi(): NonNullable['ui']> { const next = mergeWebUIState(readLocalWebUIState(), updates) writeJson(UI_STORAGE_KEY, next) zoomLevel = next.uiZoomLevel - const { hideWorkspacesFromOtherDevices: _clientLocalWorkspaceFilter, ...hostUpdates } = - updates - void _clientLocalWorkspaceFilter + // Why strip here too when the host also strips: an old host predating that strip would + // otherwise persist this browser's runtime:web-* keys over the desktop profile's order. + const hostUpdates = omitPairingLocalUiFields(updates) try { await callRuntimeResult('ui.set', hostUpdates, 15_000) } catch { @@ -2755,7 +2756,7 @@ function createWebUiApi(): NonNullable['ui']> { }) writeJson(UI_STORAGE_KEY, optimistic) try { - const result = await callRuntimeResult<{ ui: PersistedUIState }>( + const result = await callRuntimeResult<{ ui: PairedUiState }>( 'ui.recordFeatureInteraction', id, 15_000 @@ -4166,14 +4167,18 @@ function mergeWebUIState( } } -function mergeHostWebUIState( - local: PersistedUIState, - incoming: PersistedUIState -): PersistedUIState { - return { - ...mergeWebUIState(local, incoming), - hideWorkspacesFromOtherDevices: local.hideWorkspacesFromOtherDevices === true - } +// Why pin instead of trusting the host's strip: an old host still returns these fields, and for a +// profile a pre-fix drag poisoned they echo back the same runtime:web-* keys this browser minted, +// which then match its own repos and beat every newer drag. +function mergeHostWebUIState(local: PersistedUIState, incoming: PairedUiState): PersistedUIState { + // Why `satisfies Record<...>` rather than a `Pick<...>` annotation: every member is optional in + // PersistedUIState, so Pick would accept a literal that silently skipped a newly added member. + const pinned = { + hideWorkspacesFromOtherDevices: local.hideWorkspacesFromOtherDevices === true, + manualRepoOrder: local.manualRepoOrder, + workspaceHostOrder: local.workspaceHostOrder + } satisfies Record & Partial + return { ...mergeWebUIState(local, incoming), ...pinned } } function mergeFeatureInteractionState( diff --git a/src/shared/pairing-local-ui-fields.test.ts b/src/shared/pairing-local-ui-fields.test.ts new file mode 100644 index 00000000000..41545abb378 --- /dev/null +++ b/src/shared/pairing-local-ui-fields.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { omitPairingLocalUiFields, PAIRING_LOCAL_UI_FIELDS } from './pairing-local-ui-fields' + +describe('pairing-local UI fields', () => { + // Why a census: the seams that honor this set are spread across the host RPC and the web + // preload, so a field added here without wiring every seam would otherwise ship silently. + it('census: the set is exactly the fields no pairing may exchange', () => { + expect([...PAIRING_LOCAL_UI_FIELDS]).toEqual([ + 'hideWorkspacesFromOtherDevices', + 'manualRepoOrder', + 'workspaceHostOrder' + ]) + }) + + it('omits every member and keeps everything else', () => { + const state = { + hideWorkspacesFromOtherDevices: true, + manualRepoOrder: [{ hostId: 'local' as const, repoId: 'repo-a' }], + workspaceHostOrder: ['local' as const], + sidebarWidth: 280, + activeView: 'tasks' as const + } + + expect(omitPairingLocalUiFields(state)).toEqual({ sidebarWidth: 280, activeView: 'tasks' }) + }) + + it('leaves a payload that carries no member untouched', () => { + const state = { sidebarWidth: 280 } + + expect(omitPairingLocalUiFields(state)).toEqual(state) + }) + + // A member explicitly set to undefined must still be removed, not forwarded as a present key + // that a receiver would read as "cleared". + it('drops a member present with an undefined value', () => { + expect( + Object.keys(omitPairingLocalUiFields({ manualRepoOrder: undefined, sidebarWidth: 280 })) + ).toEqual(['sidebarWidth']) + }) +}) diff --git a/src/shared/pairing-local-ui-fields.ts b/src/shared/pairing-local-ui-fields.ts new file mode 100644 index 00000000000..453a16eb0ee --- /dev/null +++ b/src/shared/pairing-local-ui-fields.ts @@ -0,0 +1,28 @@ +import type { PersistedUIState } from './persisted-ui-state-types' + +// UI state each side of a pairing owns for itself. These fields describe a client's own view — +// which workspaces it hides, what order it puts repos and host sections in — and are keyed to hosts +// only that side knows about, so a value copied across the boundary in either direction overwrites +// the receiver's answer with one computed for somebody else. They must be stripped on the way to a +// host (ui.set) and pinned to the local value on the way back (ui.get). +export const PAIRING_LOCAL_UI_FIELDS = [ + 'hideWorkspacesFromOtherDevices', + 'manualRepoOrder', + 'workspaceHostOrder' +] as const satisfies readonly (keyof PersistedUIState)[] + +export type PairingLocalUiField = (typeof PAIRING_LOCAL_UI_FIELDS)[number] + +// What a paired client actually receives over the UI RPCs, so reading a pairing-local field off a +// host response is a compile error rather than a silent undefined. +export type PairedUiState = Omit + +const PAIRING_LOCAL_UI_FIELD_SET: ReadonlySet = new Set(PAIRING_LOCAL_UI_FIELDS) + +// Accepts any object, not just Partial: the ui.set seam passes the zod-inferred +// update type, whose optionality differs from the persisted shape. +export function omitPairingLocalUiFields(state: T): Omit { + return Object.fromEntries( + Object.entries(state).filter(([key]) => !PAIRING_LOCAL_UI_FIELD_SET.has(key)) + ) as Omit +} From cb95582cea7e74ef23138568593b2d214355bfc9 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:34:27 -0700 Subject: [PATCH 003/111] feat(release): build unsigned Windows artifacts for the dev channels (#15465) --- .github/workflows/adhoc-mac-build.yml | 44 ++- .github/workflows/daily-mac-build.yml | 41 ++- .github/workflows/dev-channel-win-build.yml | 304 ++++++++++++++++++ .github/workflows/hourly-mac-build.yml | 44 ++- config/electron-builder.config.cjs | 42 ++- ...channel-windows-workflow-contract.test.mjs | 176 ++++++++++ .../scripts/verify-dev-channel-packaging.mjs | 125 +++++++ .../verify-dev-channel-packaging.test.mjs | 174 ++++++++++ src/main/updater-release-builds.test.ts | 111 ++++++- src/main/updater-release-builds.ts | 39 ++- .../updater.build-channel-selection.test.ts | 79 ++++- src/main/updater.ts | 25 +- .../settings/ReleaseChannelSection.tsx | 79 +++-- ...ngsFormControls.segmented-control.test.tsx | 10 +- src/renderer/src/i18n/locales/en.json | 12 +- src/shared/release-channel.test.ts | 90 +++++- src/shared/release-channel.ts | 114 ++++++- 17 files changed, 1418 insertions(+), 91 deletions(-) create mode 100644 .github/workflows/dev-channel-win-build.yml create mode 100644 config/scripts/dev-channel-windows-workflow-contract.test.mjs create mode 100644 config/scripts/verify-dev-channel-packaging.mjs create mode 100644 config/scripts/verify-dev-channel-packaging.test.mjs diff --git a/.github/workflows/adhoc-mac-build.yml b/.github/workflows/adhoc-mac-build.yml index 03d2c76ccc0..147e43c19dc 100644 --- a/.github/workflows/adhoc-mac-build.yml +++ b/.github/workflows/adhoc-mac-build.yml @@ -92,6 +92,11 @@ jobs: # HOURLY_RELEASE_APP_PRIVATE_KEY into it, then pin its deployment branch # policy to main so only main's copy of this file can read them. environment: adhoc-mac-build + outputs: + tag: ${{ steps.release.outputs.tag }} + version: ${{ steps.adhoc.outputs.version }} + head_sha: ${{ steps.adhoc.outputs.head_sha }} + published: ${{ steps.publish_live.outcome == 'success' && 'true' || 'false' }} runs-on: blacksmith-6vcpu-macos-15 # Why 150: it must exceed the worst case the retry budgets below can produce # (install 3x10 + publish 2x45 = 120, plus ~25 for checkout/build/verify), or @@ -290,16 +295,23 @@ jobs: --repo "$ADHOC_REPO" \ --title "$NAME" \ --draft \ - --notes "Adhoc macOS dev build of \`$REF\` at commit \`$short_sha\`. + --notes "Adhoc macOS and Windows dev build of \`$REF\` at commit \`$short_sha\`. Built from [\`stablyai/orca@$short_sha\`](https://github.com/stablyai/orca/commit/$SHA), cut by @$ACTOR. **Unlanded and unvetted.** This is somebody's branch, not main. No tests - ran. Signed and notarized like a release, so it installs through Orca's - in-app updater and opens without a Gatekeeper prompt — but the branch may - never merge, and this build is deleted after $ADHOC_RETAIN_DAYS days." + ran. The macOS build is signed and notarized like a release, so it installs + through Orca's in-app updater and opens without a Gatekeeper prompt — but the + branch may never merge, and this build is deleted after $ADHOC_RETAIN_DAYS days. + + **Windows builds are unsigned.** They install and update normally once you + are on one, but a signed Stable or RC build cannot install one through the + in-app updater — download \`orca-windows-setup.exe\` below and run it once + (SmartScreen will warn about an unknown publisher). Every later switch, + including back to Stable, works in-app from there." echo "tag=$TAG" >>"$GITHUB_OUTPUT" + - name: Publish adhoc macOS artifacts uses: nick-fields/retry@v4 with: @@ -416,3 +428,27 @@ jobs: gh release delete "$tag" --repo "$ADHOC_REPO" --yes --cleanup-tag || \ echo "::warning::Could not prune $tag" done <<<"$stale" + + # Why this runs after the mac leg rather than beside it: the tag and version + # are computed inside that job, so until it has run nothing else can name the + # release to upload into. The cost is small enough not to matter — the Windows + # leg measures ~7.5 min (install 2m45, build 35s, NSIS package 3m) against a + # ~9.5 min mac run, which keeps a adhoc run far inside its interval. + # + # Why `./` rather than a pinned `@main`: `uses:` resolves against the ref this + # file itself came from, which for an ordinary dispatch is main. Someone who + # deliberately points the Actions "Use workflow from" picker at a branch + # already gets that branch's copy of this entire file, so this follows the same + # rule instead of inventing a second one. + build-adhoc-win: + needs: build-adhoc-mac + # Only once the mac release is actually live: there is no release to upload + # into otherwise, and the Windows workflow refuses to create one. + if: needs.build-adhoc-mac.outputs.published == 'true' + uses: ./.github/workflows/dev-channel-win-build.yml + secrets: inherit + with: + channel: adhoc + tag: ${{ needs.build-adhoc-mac.outputs.tag }} + ref: ${{ needs.build-adhoc-mac.outputs.head_sha }} + version: ${{ needs.build-adhoc-mac.outputs.version }} diff --git a/.github/workflows/daily-mac-build.yml b/.github/workflows/daily-mac-build.yml index 3a99577bf74..e445045e67c 100644 --- a/.github/workflows/daily-mac-build.yml +++ b/.github/workflows/daily-mac-build.yml @@ -73,6 +73,8 @@ jobs: build-daily-mac: if: github.repository == 'stablyai/orca' outputs: + tag: ${{ steps.release.outputs.tag }} + version: ${{ steps.daily.outputs.version }} head_sha: ${{ steps.freshness.outputs.head_sha }} published: ${{ steps.publish_live.outcome == 'success' && 'true' || 'false' }} runs-on: blacksmith-6vcpu-macos-15 @@ -274,13 +276,19 @@ jobs: # exactly. One source, written once, read twice. notes_file="$RUNNER_TEMP/daily-release-notes.md" cat >"$notes_file" <>"$GITHUB_OUTPUT" echo "notes_file=$notes_file" >>"$GITHUB_OUTPUT" + - name: Publish daily macOS artifacts if: steps.freshness.outputs.should_build == 'true' uses: nick-fields/retry@v4 @@ -505,3 +514,27 @@ jobs: [[ "$attempt" -eq 3 ]] || sleep "$((attempt * 5))" done echo "::warning::Failed to dispatch post-daily E2E for $SHA after 3 attempts." + + # Why this runs after the mac leg rather than beside it: the tag and version + # are computed inside that job, so until it has run nothing else can name the + # release to upload into. The cost is small enough not to matter — the Windows + # leg measures ~7.5 min (install 2m45, build 35s, NSIS package 3m) against a + # ~9.5 min mac run, which keeps a daily run far inside its interval. + # + # Why `./` rather than a pinned `@main`: `uses:` resolves against the ref this + # file itself came from, which for an ordinary dispatch is main. Someone who + # deliberately points the Actions "Use workflow from" picker at a branch + # already gets that branch's copy of this entire file, so this follows the same + # rule instead of inventing a second one. + build-daily-win: + needs: build-daily-mac + # Only once the mac release is actually live: there is no release to upload + # into otherwise, and the Windows workflow refuses to create one. + if: needs.build-daily-mac.outputs.published == 'true' + uses: ./.github/workflows/dev-channel-win-build.yml + secrets: inherit + with: + channel: daily + tag: ${{ needs.build-daily-mac.outputs.tag }} + ref: ${{ needs.build-daily-mac.outputs.head_sha }} + version: ${{ needs.build-daily-mac.outputs.version }} diff --git a/.github/workflows/dev-channel-win-build.yml b/.github/workflows/dev-channel-win-build.yml new file mode 100644 index 00000000000..efb35628a28 --- /dev/null +++ b/.github/workflows/dev-channel-win-build.yml @@ -0,0 +1,304 @@ +name: Dev Channel Windows Build + +# Why its own file rather than steps inlined into hourly/daily/adhoc: one copy of +# the Windows leg instead of three, and it stays dispatchable on its own so a +# Windows artifact can be rebuilt for an existing tag without paying for the mac +# leg's packaging and notarization again. +# +# Each mac workflow calls this as a `needs:`-gated job once its release is live, +# passing the tag it created. Both legs land in that one release, so a tag +# carries every platform it managed to build. Measured cost of the Windows leg is +# ~7.5 min (install 2m45, build 35s, NSIS package 3m) against a mac run of ~9.5 +# min, so running it after the mac job keeps an hourly well inside its cron. +# +# Why unsigned: Windows release installers are signed by SignPath *after* +# packaging, and release-cut budgets 1h + 4h for those approval waits. That does +# not fit an hourly cadence and it does not fit "dispatch an adhoc build and go +# get coffee". So dev-channel Windows builds ship unsigned, which has one real +# consequence, handled in `src/shared/release-channel.ts`: +# +# electron-updater Authenticode-verifies every installer it downloads against +# the publisherName baked into the *installed* app's app-update.yml. Stable and +# RC carry 'SignPath Foundation', so they reject an unsigned dev installer and +# no future build can fix the copies already installed. Dev builds omit the +# name (config/electron-builder.config.cjs), so verification is skipped there. +# +# Net effect: the way *into* a dev channel on Windows is a one-time manual +# installer run. Every way out — to another dev build, or back to Stable — works +# through the in-app updater. The picker offers a download for exactly that jump. +# +# Called as a job by each mac workflow once its release is live, and separately +# dispatchable by hand to rebuild a Windows artifact for an existing tag without +# re-running the mac leg's twenty minutes of packaging and notarization: +# +# gh workflow run dev-channel-win-build.yml --ref main \ +# -f channel=adhoc -f tag=v1.4.178-adhoc.20260819010203 \ +# -f ref= -f version=1.4.178-adhoc.20260819010203 + +on: + # Why the inputs are duplicated: workflow_call does not accept `type: choice`, + # and workflow_dispatch wants it so the Actions UI offers a menu instead of a + # free-text box. The channel allowlist below is what actually enforces the set, + # since a workflow_call caller can pass any string. + workflow_call: + inputs: + channel: + description: Dev channel whose release this build uploads into + required: true + type: string + tag: + description: Existing release tag in the channel repo + required: true + type: string + ref: + description: Commit SHA to build — must be the exact commit the mac leg built + required: true + type: string + version: + description: Version to package, without the leading v + required: true + type: string + workflow_dispatch: + inputs: + channel: + description: Dev channel whose release this build uploads into + required: true + type: choice + options: + - hourly + - daily + - adhoc + tag: + description: Existing release tag in the channel repo (e.g. v1.4.178-adhoc.20260819010203) + required: true + type: string + ref: + description: Commit SHA to build — must be the exact commit the mac leg built + required: true + type: string + version: + description: Version to package, without the leading v + required: true + type: string + +permissions: + contents: read + +concurrency: + # Keyed on the tag: re-dispatching the same tag must not race two uploads into + # one release, but two different channels (or two adhoc branches) are the + # ordinary case and must not wait on each other. + group: dev-channel-win-build-${{ inputs.tag }} + cancel-in-progress: false + +jobs: + build-win: + if: github.repository == 'stablyai/orca' + # Why the same environment as the mac workflows: this needs the App token + # that can write to the dev-channel repos, and it should be reachable from + # exactly the same place those secrets already live. + environment: adhoc-mac-build + # Why windows-2022 and not windows-latest: windows-latest moved to the + # Windows 2025 / VS 2026 image before node-gyp could detect VS 18, breaking + # native dependency install. release-cut pins the same image. + runs-on: windows-2022 + timeout-minutes: 90 + env: + NODE_OPTIONS: --max-old-space-size=4096 + CHANNEL: ${{ inputs.channel }} + TAG: ${{ inputs.tag }} + VERSION: ${{ inputs.version }} + + steps: + # Why vet before checkout: everything after this runs the checked-out code + # with a token that can write to a release repo. The mac leg already vetted + # the ref it resolved, but this workflow is dispatchable on its own, so it + # re-derives the same guarantee rather than trusting its caller. + - name: Vet the requested inputs + id: vetted + shell: bash + env: + REQUESTED_SHA: ${{ inputs.ref }} + REPO_URL: https://github.com/${{ github.repository }} + run: | + set -euo pipefail + # workflow_call takes channel as a free-text string, so the set is + # enforced here rather than by the input type. + case "$CHANNEL" in + hourly|daily|adhoc) ;; + *) + echo "::error::Unknown dev channel '$CHANNEL'; expected hourly, daily, or adhoc." + exit 1 + ;; + esac + if [[ ! "$REQUESTED_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::ref must be a full 40-character commit SHA, got '$REQUESTED_SHA'. The dispatching workflow passes the commit it resolved." + exit 1 + fi + # The tag must name the version being packaged, or the artifacts would + # land in a release describing a different build. + if [[ "$TAG" != "v$VERSION" ]]; then + echo "::error::tag '$TAG' does not match version '$VERSION'." + exit 1 + fi + # And the version must carry the channel's own prerelease identifier, + # so an hourly artifact can never be uploaded into an adhoc release. + if [[ "$VERSION" != *"-$CHANNEL."* ]]; then + echo "::error::version '$VERSION' is not a $CHANNEL version." + exit 1 + fi + # Reachability is the trust test: GitHub serves PR-only commits by SHA, + # so resolving the object is not proof a branch or tag of this repo + # reaches it. Bare + tree:0 keeps this to the commit graph. + scratch="$RUNNER_TEMP/vet-requested-ref" + git init -q --bare "$scratch" + git -C "$scratch" fetch -q --filter=tree:0 "$REPO_URL" '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' + if ! git -C "$scratch" rev-parse --verify --quiet "$REQUESTED_SHA^{commit}" >/dev/null; then + echo "::error::Commit $REQUESTED_SHA is not in stablyai/orca." + exit 1 + fi + if [[ -z "$(git -C "$scratch" for-each-ref --contains "$REQUESTED_SHA" refs/heads refs/tags | head -1)" ]]; then + echo "::error::Commit $REQUESTED_SHA is not reachable from any branch or tag of stablyai/orca; refusing to build it." + exit 1 + fi + echo "Vetted $CHANNEL $TAG at $REQUESTED_SHA" + + - name: Checkout the built commit + uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref }} + # This job only reads stablyai/orca and never pushes; every write goes + # to the dev-channel repo through a minted App token passed by env + # (zizmor: artipacked). + persist-credentials: false + + # Why a guard and not just a build: the workflow file comes from the + # dispatch ref, but the packaging config comes from the *built* commit. A + # branch cut before Windows dev builds landed has a config that ignores + # ORCA_WIN_*, which would resolve publish.repo to the main repo. Say that + # in one sentence here rather than failing deep inside electron-builder. + - name: Resolve the dev-channel packaging identity + shell: bash + run: | + set -euo pipefail + upper="$(printf '%s' "$CHANNEL" | tr '[:lower:]' '[:upper:]')" + echo "ORCA_WIN_${upper}=1" >>"$GITHUB_ENV" + echo "ORCA_${upper}_BUILD_VERSION=${VERSION}" >>"$GITHUB_ENV" + if [[ ! -f config/scripts/verify-dev-channel-packaging.mjs ]]; then + echo "::error::$TAG was built from a commit with no config/scripts/verify-dev-channel-packaging.mjs; that commit predates Windows dev builds, so it cannot produce one." + exit 1 + fi + + # pnpm must be on PATH before setup-node so setup-node can locate the store. + - name: Setup pnpm + uses: pnpm/action-setup@v6 + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: package.json + cache: pnpm + + # Caches the Electron binary and electron-builder's tool downloads (nsis, + # winCodeSign). Same key shape as release-cut's Windows leg. + - name: Cache electron-builder downloads + uses: actions/cache@v5 + with: + path: | + ~\AppData\Local\electron\Cache + ~\AppData\Local\electron-builder\Cache + key: electron-builder-win-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + electron-builder-win- + + # Why retried: pnpm install triggers electron's postinstall, which pulls the + # Electron binary from GitHub release assets, and that CDN returns transient + # 504s often enough to lose a build to it. + - name: Install dependencies + uses: nick-fields/retry@v4 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: pnpm install --frozen-lockfile + + # Why the packaging check runs before the 20-minute build: it only needs + # node_modules, and a stale config should cost seconds rather than a build. + - name: Verify dev-channel packaging identity + shell: bash + run: node config/scripts/verify-dev-channel-packaging.mjs --channel="$CHANNEL" --platform=win32 + + - name: Build app + shell: bash + run: pnpm build:release + env: + # Why unset ORCA_BUILD_IDENTITY: telemetry's transport gate accepts only + # 'stable' or 'rc', so leaving it unset keeps dev builds silent — which + # is correct for unvetted artifacts. Same as the mac dev channels. + ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token + + # Why the token is minted here and not at the top: installation tokens live + # one hour and nothing before this point writes anything. + - name: Mint dev channel repo token + id: app_token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.HOURLY_RELEASE_APP_ID }} + private-key: ${{ secrets.HOURLY_RELEASE_APP_PRIVATE_KEY }} + owner: stablyai + repositories: orca-${{ inputs.channel }} + + # Why: electron-builder's publisher creates a release when it cannot find + # the tag ("publish: always"). If the mac leg failed and discarded its draft + # while this was building, that would mint a fresh, untitled, Windows-only + # release. Check first and fail instead. + - name: Confirm the target release still exists + shell: bash + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + run: | + set -euo pipefail + if ! gh release view "$TAG" --repo "stablyai/orca-$CHANNEL" --json tagName >/dev/null 2>&1; then + echo "::error::Release $TAG no longer exists in stablyai/orca-$CHANNEL; the mac leg most likely failed and discarded it. Not creating a Windows-only release." + exit 1 + fi + + - name: Publish Windows artifacts + uses: nick-fields/retry@v4 + with: + # 30 is the pack + upload budget; there is no notary queue on this leg. + timeout_minutes: 30 + max_attempts: 2 + retry_wait_seconds: 30 + command: node config/scripts/ensure-native-runtime.mjs --runtime=electron; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish always + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + ORCA_BUILD_COMMIT: ${{ inputs.ref }} + # Why: electron-publish refuses to upload into a release published more + # than two hours ago (gitHubPublisher.getOrCreateRelease). The mac leg + # publishes the draft live as soon as *it* finishes, so a slow notary + # queue plus a slow Windows build can cross that line and silently drop + # every Windows asset. This is the documented escape hatch. + EP_GH_IGNORE_TIME: 'true' + + # Why: the updater resolves a tag, then fetches latest.yml from it. A + # release carrying the installer but not the manifest is one the picker + # offers and the update 404s on, so assert both. + - name: Verify Windows update manifest published + shell: bash + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + run: | + set -euo pipefail + assets="$(gh release view "$TAG" --repo "stablyai/orca-$CHANNEL" --json assets --jq '.assets[].name')" + echo "Assets on $TAG:" + echo "$assets" + for required in latest.yml orca-windows-setup.exe; do + if ! grep -qx "$required" <<<"$assets"; then + echo "::error::$TAG is missing $required; Windows could not install this build." + exit 1 + fi + done + echo "Windows artifacts verified on $TAG." diff --git a/.github/workflows/hourly-mac-build.yml b/.github/workflows/hourly-mac-build.yml index 7d4ecc44131..16e2f05579c 100644 --- a/.github/workflows/hourly-mac-build.yml +++ b/.github/workflows/hourly-mac-build.yml @@ -62,6 +62,11 @@ env: jobs: build-hourly-mac: if: github.repository == 'stablyai/orca' + outputs: + tag: ${{ steps.release.outputs.tag }} + version: ${{ steps.hourly.outputs.version }} + head_sha: ${{ steps.freshness.outputs.head_sha }} + published: ${{ steps.publish_live.outcome == 'success' && 'true' || 'false' }} runs-on: blacksmith-6vcpu-macos-15 # Why 150: it must exceed the worst case the retry budgets below can produce # (install 3x10 + publish 2x45 = 120, plus ~25 for checkout/build/verify), or @@ -270,15 +275,22 @@ jobs: --repo "$HOURLY_REPO" \ --title "$NAME" \ --draft \ - --notes "Automated hourly macOS dev build from commit \`$short_sha\`. + --notes "Automated hourly macOS and Windows dev build from commit \`$short_sha\`. Built from [\`stablyai/orca@$short_sha\`](https://github.com/stablyai/orca/commit/$SHA). - **Unvetted.** No tests ran. Signed and notarized like a release, so it - installs through Orca's in-app updater and opens from a manual download - without a Gatekeeper prompt — but nothing here has been reviewed." + **Unvetted.** No tests ran. The macOS build is signed and notarized like a + release, so it installs through Orca's in-app updater and opens from a manual + download without a Gatekeeper prompt — but nothing here has been reviewed. + + **Windows builds are unsigned.** They install and update normally once you + are on one, but a signed Stable or RC build cannot install one through the + in-app updater — download \`orca-windows-setup.exe\` below and run it once + (SmartScreen will warn about an unknown publisher). Every later switch, + including back to Stable, works in-app from there." echo "tag=$TAG" >>"$GITHUB_OUTPUT" + - name: Publish hourly macOS artifacts if: steps.freshness.outputs.should_build == 'true' uses: nick-fields/retry@v4 @@ -430,3 +442,27 @@ jobs: gh release delete "$tag" --repo "$HOURLY_REPO" --yes --cleanup-tag || \ echo "::warning::Could not prune $tag" done <<<"$stale" + + # Why this runs after the mac leg rather than beside it: the tag and version + # are computed inside that job, so until it has run nothing else can name the + # release to upload into. The cost is small enough not to matter — the Windows + # leg measures ~7.5 min (install 2m45, build 35s, NSIS package 3m) against a + # ~9.5 min mac run, which keeps a hourly run far inside its interval. + # + # Why `./` rather than a pinned `@main`: `uses:` resolves against the ref this + # file itself came from, which for an ordinary dispatch is main. Someone who + # deliberately points the Actions "Use workflow from" picker at a branch + # already gets that branch's copy of this entire file, so this follows the same + # rule instead of inventing a second one. + build-hourly-win: + needs: build-hourly-mac + # Only once the mac release is actually live: there is no release to upload + # into otherwise, and the Windows workflow refuses to create one. + if: needs.build-hourly-mac.outputs.published == 'true' + uses: ./.github/workflows/dev-channel-win-build.yml + secrets: inherit + with: + channel: hourly + tag: ${{ needs.build-hourly-mac.outputs.tag }} + ref: ${{ needs.build-hourly-mac.outputs.head_sha }} + version: ${{ needs.build-hourly-mac.outputs.version }} diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index f1a536bed5a..33e31aa2605 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -22,14 +22,27 @@ const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime. const isMacHourly = process.env.ORCA_MAC_HOURLY === '1' const isMacDaily = process.env.ORCA_MAC_DAILY === '1' const isMacAdhoc = process.env.ORCA_MAC_ADHOC === '1' +// Why a second set of variables rather than making the mac ones platform-neutral: +// the mac ones gate `isMacRelease` below, which turns on hardened runtime, +// notarization, and root-level `forceCodeSigning`. A Windows dev build that +// reused them would fail packaging outright for want of a cert it is +// deliberately not using. +const isWinHourly = process.env.ORCA_WIN_HOURLY === '1' +const isWinDaily = process.env.ORCA_WIN_DAILY === '1' +const isWinAdhoc = process.env.ORCA_WIN_ADHOC === '1' +const isWinDevChannel = isWinHourly || isWinDaily || isWinAdhoc const isMacRelease = process.env.ORCA_MAC_RELEASE === '1' || isMacHourly || isMacDaily || isMacAdhoc const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1' -const localBuildVersion = isMacRelease ? undefined : process.env.ORCA_LOCAL_BUILD_VERSION -const devChannelBuildVersion = isMacHourly +const localBuildVersion = + isMacRelease || isWinDevChannel ? undefined : process.env.ORCA_LOCAL_BUILD_VERSION +const isHourlyChannel = isMacHourly || isWinHourly +const isDailyChannel = isMacDaily || isWinDaily +const isAdhocChannel = isMacAdhoc || isWinAdhoc +const devChannelBuildVersion = isHourlyChannel ? process.env.ORCA_HOURLY_BUILD_VERSION - : isMacDaily + : isDailyChannel ? process.env.ORCA_DAILY_BUILD_VERSION - : isMacAdhoc + : isAdhocChannel ? process.env.ORCA_ADHOC_BUILD_VERSION : undefined // Why each dev channel gets its own repo rather than tagging into the main one: @@ -38,11 +51,11 @@ const devChannelBuildVersion = isMacHourly // to install. Keeping adhoc/daily separate from hourly too means a branch build // or a once-a-day cut cannot be picked up by someone who only meant to ride // main's hourlies. -const devChannelRepo = isMacHourly +const devChannelRepo = isHourlyChannel ? 'orca-hourly' - : isMacDaily + : isDailyChannel ? 'orca-daily' - : isMacAdhoc + : isAdhocChannel ? 'orca-adhoc' : null const appId = 'com.stablyai.orca' @@ -304,9 +317,18 @@ module.exports = { executableName: 'Orca', // Why: Windows installers are signed after electron-builder packaging by // SignPath, so the packager cannot infer the updater publisherName. - signtoolOptions: { - publisherName: 'SignPath Foundation' - }, + // + // Why dev channels drop it instead: they ship unsigned, because SignPath's + // approval waits are budgeted in hours and cannot fit an hourly cadence. + // electron-updater Authenticode-verifies every installer it downloads + // against the publisherName baked into the *installed* app's app-update.yml + // (NsisUpdater.verifySignature), and skips verification entirely when that + // name is absent. An unsigned build that still claimed 'SignPath Foundation' + // would therefore reject its own channel's next build — and its way back to + // stable with it. Dropping it is what makes dev→dev and dev→stable work. + ...(isWinDevChannel + ? { verifyUpdateCodeSignature: false } + : { signtoolOptions: { publisherName: 'SignPath Foundation' } }), extraResources: [ ...commonExtraResources, ...createPackagedRuntimeNodeModuleResources('win32'), diff --git a/config/scripts/dev-channel-windows-workflow-contract.test.mjs b/config/scripts/dev-channel-windows-workflow-contract.test.mjs new file mode 100644 index 00000000000..9133e1a9156 --- /dev/null +++ b/config/scripts/dev-channel-windows-workflow-contract.test.mjs @@ -0,0 +1,176 @@ +import { readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { parse } from 'yaml' + +const projectDir = resolve(import.meta.dirname, '../..') + +const readWorkflow = (relativePath) => parse(readFileSync(join(projectDir, relativePath), 'utf8')) + +const MAC_WORKFLOWS = [ + ['hourly', '.github/workflows/hourly-mac-build.yml', 'build-hourly-mac'], + ['daily', '.github/workflows/daily-mac-build.yml', 'build-daily-mac'], + ['adhoc', '.github/workflows/adhoc-mac-build.yml', 'build-adhoc-mac'] +] + +const winWorkflow = () => readWorkflow('.github/workflows/dev-channel-win-build.yml') +const winSteps = () => winWorkflow().jobs['build-win'].steps +const stepNamed = (steps, name) => steps.find((step) => step.name === name) + +describe('dev-channel Windows build wiring', () => { + it.each(MAC_WORKFLOWS)( + 'calls the shared Windows workflow from %s with its own channel', + (channel, path, jobName) => { + const jobs = readWorkflow(path).jobs + const winJob = jobs[`build-${channel}-win`] + + expect(winJob).toBeDefined() + expect(winJob.uses).toBe('./.github/workflows/dev-channel-win-build.yml') + expect(winJob.with.channel).toBe(channel) + // Why `./` and not a pinned @main: `uses:` resolves against the ref this + // workflow file came from, which for an ordinary dispatch is main. A branch + // deliberately selected in the Actions picker already supplies its own copy + // of the whole file, so this follows that rule rather than adding a second. + expect(winJob.uses.startsWith('./')).toBe(true) + expect(winJob.needs).toBe(jobName) + expect(winJob.secrets).toBe('inherit') + } + ) + + // Why gated on published: the Windows leg uploads into a release the mac leg + // created, and refuses to create one itself. Without this it would run for a + // tag that was never published — or, for hourly, for a run that skipped. + it.each(MAC_WORKFLOWS)( + 'only runs the %s Windows leg once the release is live', + (channel, path, jobName) => { + expect(readWorkflow(path).jobs[`build-${channel}-win`].if).toBe( + `needs.${jobName}.outputs.published == 'true'` + ) + } + ) + + // The Windows job names the release by consuming these; a renamed step id + // would silently resolve them to empty strings and upload nowhere. + it.each(MAC_WORKFLOWS)( + 'exposes the %s tag, version and commit as job outputs', + (_c, path, jobName) => { + const job = readWorkflow(path).jobs[jobName] + const stepIds = job.steps.map((step) => step.id).filter(Boolean) + + for (const key of ['tag', 'version', 'head_sha', 'published']) { + expect(job.outputs[key]).toBeTruthy() + } + // Every referenced step id must actually exist in the job. + for (const expression of Object.values(job.outputs)) { + const referenced = [...expression.matchAll(/steps\.([A-Za-z0-9_-]+)\./g)].map((m) => m[1]) + for (const id of referenced) { + expect(stepIds).toContain(id) + } + } + } + ) + + // Why this is asserted: the previous design dispatched a sibling run, which + // needed actions:write on a job holding macOS signing credentials. Calling the + // workflow directly removes that, and it must not creep back. + it.each(MAC_WORKFLOWS)( + 'does not widen the %s signing job to actions:write', + (_c, path, jobName) => { + expect(readWorkflow(path).jobs[jobName].permissions?.actions).toBeUndefined() + } + ) +}) + +describe('dev-channel Windows build workflow', () => { + it('is both callable by the mac workflows and dispatchable on its own', () => { + const triggers = winWorkflow().on ?? winWorkflow()[true] + + expect(Object.keys(triggers)).toEqual( + expect.arrayContaining(['workflow_call', 'workflow_dispatch']) + ) + // workflow_call cannot express `type: choice`, so the channel set has to be + // enforced in the job itself. + expect(stepNamed(winSteps(), 'Vet the requested inputs').run).toContain('hourly|daily|adhoc') + }) + + // Why windows-2022: windows-latest moved to the Windows 2025 / VS 2026 image + // before node-gyp could detect VS 18, breaking native dependency install. + it('pins the same Windows image release-cut builds on', () => { + expect(winWorkflow().jobs['build-win']['runs-on']).toBe('windows-2022') + }) + + // The guard against a branch whose electron-builder config predates Windows + // dev builds: without it, publish.repo resolves to the main repo. + it('verifies the dev-channel packaging identity before building', () => { + const steps = winSteps() + const names = steps.map((step) => step.name) + const verify = names.indexOf('Verify dev-channel packaging identity') + const build = names.indexOf('Build app') + + expect(verify).toBeGreaterThanOrEqual(0) + expect(build).toBeGreaterThan(verify) + expect(stepNamed(steps, 'Verify dev-channel packaging identity').run).toContain( + 'verify-dev-channel-packaging.mjs' + ) + }) + + // Why: electron-publish creates a release when it cannot find the tag under + // `--publish always`. If the mac leg discarded its draft while this was + // building, that would mint an untitled Windows-only release. + it('confirms the target release exists before publishing into it', () => { + const names = winSteps().map((step) => step.name) + const confirm = names.indexOf('Confirm the target release still exists') + const publish = names.indexOf('Publish Windows artifacts') + + expect(confirm).toBeGreaterThanOrEqual(0) + expect(publish).toBeGreaterThan(confirm) + }) + + // electron-publish refuses to upload into a release published more than two + // hours ago. The mac leg publishes as soon as it finishes, so a slow notary + // queue plus a slow Windows build crosses that line and drops every asset. + it('opts out of the publisher two-hour upload window', () => { + expect(stepNamed(winSteps(), 'Publish Windows artifacts').env.EP_GH_IGNORE_TIME).toBe('true') + }) + + it('packages Windows unsigned through the shared electron-builder config', () => { + const publish = stepNamed(winSteps(), 'Publish Windows artifacts') + + expect(publish.with.command).toContain( + 'electron-builder --config config/electron-builder.config.cjs --win --publish always' + ) + // No signing env: SignPath's approval waits cannot fit a dev cadence, which + // is the entire reason these builds are unsigned. + expect(Object.keys(publish.env)).not.toContain('SIGNPATH_API_TOKEN') + }) + + // Why both: a release carrying the installer but not the manifest is a row the + // picker offers and the in-app update 404s on. + it('requires the manifest and the installer before calling the build good', () => { + const verify = stepNamed(winSteps(), 'Verify Windows update manifest published') + + expect(verify.run).toContain('latest.yml') + expect(verify.run).toContain('orca-windows-setup.exe') + }) + + // Why: this is callable and separately dispatchable, and workflow_call takes + // its inputs as free text, so it re-derives the ref guarantee rather than + // trusting whoever called it. + it('vets the requested commit before checking it out', () => { + const names = winSteps().map((step) => step.name) + const vet = names.indexOf('Vet the requested inputs') + const checkout = names.indexOf('Checkout the built commit') + + expect(vet).toBe(0) + expect(checkout).toBeGreaterThan(vet) + expect(stepNamed(winSteps(), 'Vet the requested inputs').run).toContain('--contains') + }) + + // Telemetry's transport gate accepts only 'stable' or 'rc'; leaving the build + // identity unset is what keeps unvetted artifacts silent. + it('never stamps an official telemetry build identity', () => { + const build = stepNamed(winSteps(), 'Build app') + + expect(Object.keys(build.env ?? {})).not.toContain('ORCA_BUILD_IDENTITY') + }) +}) diff --git a/config/scripts/verify-dev-channel-packaging.mjs b/config/scripts/verify-dev-channel-packaging.mjs new file mode 100644 index 00000000000..7f2067552d5 --- /dev/null +++ b/config/scripts/verify-dev-channel-packaging.mjs @@ -0,0 +1,125 @@ +#!/usr/bin/env node +// Why this exists: the dev-channel workflows run from main, but they build (and +// therefore read `config/electron-builder.config.cjs` from) whatever ref was +// asked for. A branch cut before Windows dev builds landed has a config that +// ignores ORCA_WIN_*, which would resolve `publish.repo` to the *main* repo and +// leave the release identity signed-looking. Publishing would then fail deep +// inside electron-builder with a 404 from a token scoped to the dev repo — or, +// worse, succeed against a repo it was never meant to touch. +// +// So: load the config exactly as electron-builder will, and assert the identity +// it produced matches the channel and platform the workflow believes it is +// building. Runs before packaging, fails with a sentence someone can act on. + +import { createRequire } from 'node:module' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +const CHANNEL_REPOS = { + hourly: 'orca-hourly', + daily: 'orca-daily', + adhoc: 'orca-adhoc' +} + +const CHANNEL_VERSION_ENV = { + hourly: 'ORCA_HOURLY_BUILD_VERSION', + daily: 'ORCA_DAILY_BUILD_VERSION', + adhoc: 'ORCA_ADHOC_BUILD_VERSION' +} + +export function collectDevChannelPackagingProblems({ channel, platform, config, env }) { + const problems = [] + const expectedRepo = CHANNEL_REPOS[channel] + if (!expectedRepo) { + return [ + `Unknown dev channel "${channel}"; expected one of ${Object.keys(CHANNEL_REPOS).join(', ')}.` + ] + } + + if (config.publish?.repo !== expectedRepo) { + problems.push( + `publish.repo is "${config.publish?.repo}" but this ${channel} build must publish to "${expectedRepo}". ` + + `The checked-out ref's electron-builder config does not understand this channel on ${platform} — rebase it onto a main that does.` + ) + } + + if (config.publish?.releaseType !== 'prerelease') { + problems.push( + `publish.releaseType is "${config.publish?.releaseType}" but dev-channel builds must publish as "prerelease".` + ) + } + + // Why version too: `extraMetadata.version` is what stamps the tag the workflow + // already created. A config that dropped it would package package.json's + // version and upload into the wrong release entirely. + const expectedVersion = env[CHANNEL_VERSION_ENV[channel]] + if (expectedVersion && config.extraMetadata?.version !== expectedVersion) { + problems.push( + `extraMetadata.version is "${config.extraMetadata?.version}" but the workflow computed "${expectedVersion}".` + ) + } + + if (platform === 'win32') { + // The one that silently breaks updates rather than failing the build: a dev + // build that advertises a publisherName can never install its own channel's + // next build, because electron-updater verifies against the name baked into + // the installed app. + if (config.win?.verifyUpdateCodeSignature !== false) { + problems.push( + 'win.verifyUpdateCodeSignature must be false for unsigned dev builds, or electron-updater will Authenticode-verify every installer this build downloads and reject all of them.' + ) + } + if (config.win?.signtoolOptions?.publisherName != null) { + problems.push( + `win.signtoolOptions.publisherName is set to "${config.win.signtoolOptions.publisherName}" on an unsigned dev build; it must be absent.` + ) + } + } + + return problems +} + +function parseArgs(argv) { + const args = {} + for (const entry of argv) { + const match = /^--([^=]+)=(.*)$/.exec(entry) + if (match) { + args[match[1]] = match[2] + } + } + return args +} + +function main() { + const { channel, platform = process.platform } = parseArgs(process.argv.slice(2)) + if (!channel) { + console.error( + 'Usage: verify-dev-channel-packaging.mjs --channel= [--platform=win32]' + ) + process.exit(1) + } + const require = createRequire(import.meta.url) + const config = require(resolve(import.meta.dirname, '../electron-builder.config.cjs')) + const problems = collectDevChannelPackagingProblems({ + channel, + platform, + config, + env: process.env + }) + if (problems.length > 0) { + for (const problem of problems) { + console.error(`::error::${problem}`) + } + process.exit(1) + } + console.log( + `Dev-channel packaging verified: ${channel} on ${platform} → stablyai/${CHANNEL_REPOS[channel]} @ ${config.extraMetadata?.version}` + ) +} + +// Why the guard: the test imports the pure collector without running the CLI. +// pathToFileURL, not a `file://` template: this also runs on Windows, where a +// drive-letter path does not concatenate into a valid URL. +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main() +} diff --git a/config/scripts/verify-dev-channel-packaging.test.mjs b/config/scripts/verify-dev-channel-packaging.test.mjs new file mode 100644 index 00000000000..63e1c7d5b0c --- /dev/null +++ b/config/scripts/verify-dev-channel-packaging.test.mjs @@ -0,0 +1,174 @@ +import { createRequire } from 'node:module' +import { resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { collectDevChannelPackagingProblems } from './verify-dev-channel-packaging.mjs' + +const require = createRequire(import.meta.url) +const CONFIG_PATH = resolve(import.meta.dirname, '../electron-builder.config.cjs') + +/** The config reads process.env at require time, so each identity needs a fresh load. */ +function loadConfigWithEnv(env) { + const saved = { ...process.env } + for (const key of Object.keys(process.env)) { + if (key.startsWith('ORCA_')) { + delete process.env[key] + } + } + Object.assign(process.env, env) + try { + delete require.cache[require.resolve(CONFIG_PATH)] + return require(CONFIG_PATH) + } finally { + process.env = saved + delete require.cache[require.resolve(CONFIG_PATH)] + } +} + +const WIN_ADHOC_ENV = { + ORCA_WIN_ADHOC: '1', + ORCA_ADHOC_BUILD_VERSION: '1.4.178-adhoc.20260819010203' +} + +afterEach(() => { + delete require.cache[require.resolve(CONFIG_PATH)] +}) + +describe('electron-builder dev-channel identity', () => { + it('keeps the SignPath publisherName on stable Windows builds', () => { + const config = loadConfigWithEnv({}) + + expect(config.win.signtoolOptions.publisherName).toBe('SignPath Foundation') + expect(config.win.verifyUpdateCodeSignature).toBeUndefined() + expect(config.publish.repo).toBe('orca') + expect(config.publish.releaseType).toBe('release') + }) + + // The whole point of the change: an unsigned build that advertised a + // publisherName would Authenticode-verify — and reject — every installer it + // ever downloaded, including its own way back to stable. + it('drops the publisherName and disables update signature checks on Windows dev builds', () => { + const config = loadConfigWithEnv(WIN_ADHOC_ENV) + + expect(config.win.signtoolOptions?.publisherName).toBeUndefined() + expect(config.win.verifyUpdateCodeSignature).toBe(false) + }) + + it.each([ + ['hourly', { ORCA_WIN_HOURLY: '1' }, 'orca-hourly'], + ['daily', { ORCA_WIN_DAILY: '1' }, 'orca-daily'], + ['adhoc', { ORCA_WIN_ADHOC: '1' }, 'orca-adhoc'] + ])('publishes %s Windows builds to its own repo as a prerelease', (_channel, env, repo) => { + const config = loadConfigWithEnv(env) + + expect(config.publish.repo).toBe(repo) + expect(config.publish.releaseType).toBe('prerelease') + }) + + // Why: ORCA_MAC_* gates hardened runtime, notarization, and root-level + // forceCodeSigning. If the Windows variables leaked into that, the Windows job + // would fail packaging for want of a cert it deliberately does not use. + it('leaves mac release signing off for Windows dev builds', () => { + const config = loadConfigWithEnv(WIN_ADHOC_ENV) + + expect(config.forceCodeSigning).toBe(false) + expect(config.mac.notarize).toBe(false) + expect(config.mac.hardenedRuntime).toBe(false) + }) + + it('still notarizes mac dev builds', () => { + const config = loadConfigWithEnv({ + ORCA_MAC_ADHOC: '1', + ORCA_ADHOC_BUILD_VERSION: '1.4.178-adhoc.20260819010203' + }) + + expect(config.mac.notarize).toBe(true) + expect(config.publish.repo).toBe('orca-adhoc') + }) +}) + +describe('collectDevChannelPackagingProblems', () => { + const goodWinConfig = { + publish: { repo: 'orca-adhoc', releaseType: 'prerelease' }, + extraMetadata: { version: '1.4.178-adhoc.20260819010203' }, + win: { verifyUpdateCodeSignature: false } + } + const env = { ORCA_ADHOC_BUILD_VERSION: '1.4.178-adhoc.20260819010203' } + + it('accepts a correctly configured Windows dev build', () => { + expect( + collectDevChannelPackagingProblems({ + channel: 'adhoc', + platform: 'win32', + config: goodWinConfig, + env + }) + ).toEqual([]) + }) + + // The failure this script exists for: a branch predating Windows dev builds + // resolves publish.repo to the main repo. + it('rejects a config that resolved the main repo', () => { + const problems = collectDevChannelPackagingProblems({ + channel: 'adhoc', + platform: 'win32', + config: { ...goodWinConfig, publish: { repo: 'orca', releaseType: 'release' } }, + env + }) + + expect(problems.join('\n')).toContain('must publish to "orca-adhoc"') + expect(problems.join('\n')).toContain('rebase it onto a main that does') + }) + + it('rejects a Windows dev build that still advertises a publisherName', () => { + const problems = collectDevChannelPackagingProblems({ + channel: 'adhoc', + platform: 'win32', + config: { + ...goodWinConfig, + win: { signtoolOptions: { publisherName: 'SignPath Foundation' } } + }, + env + }) + + expect(problems.join('\n')).toContain('verifyUpdateCodeSignature must be false') + expect(problems.join('\n')).toContain('publisherName is set to "SignPath Foundation"') + }) + + it('rejects a build packaging a version other than the tag the workflow created', () => { + const problems = collectDevChannelPackagingProblems({ + channel: 'adhoc', + platform: 'win32', + config: { ...goodWinConfig, extraMetadata: { version: '1.4.178' } }, + env + }) + + expect(problems.join('\n')).toContain('but the workflow computed') + }) + + // macOS builds are signed, so the Windows-only assertions must not fire there. + it('does not apply Windows signature rules to a mac dev build', () => { + expect( + collectDevChannelPackagingProblems({ + channel: 'adhoc', + platform: 'darwin', + config: { + publish: { repo: 'orca-adhoc', releaseType: 'prerelease' }, + extraMetadata: { version: '1.4.178-adhoc.20260819010203' }, + win: { signtoolOptions: { publisherName: 'SignPath Foundation' } } + }, + env + }) + ).toEqual([]) + }) + + it('rejects an unknown channel', () => { + expect( + collectDevChannelPackagingProblems({ + channel: 'nightly', + platform: 'win32', + config: goodWinConfig, + env + }) + ).toEqual(['Unknown dev channel "nightly"; expected one of hourly, daily, adhoc.']) + }) +}) diff --git a/src/main/updater-release-builds.test.ts b/src/main/updater-release-builds.test.ts index 5e7b55e9f75..30188d73de2 100644 --- a/src/main/updater-release-builds.test.ts +++ b/src/main/updater-release-builds.test.ts @@ -13,11 +13,23 @@ function jsonResponse(body: unknown, init: { ok?: boolean; status?: number } = { } } +/** Every platform's manifest by default, so a case that is not about asset + * filtering stays readable and stays green whatever platform is passed. */ +const allPlatformAssets = [ + { name: 'latest-mac.yml' }, + { name: 'orca-macos-arm64.dmg' }, + { name: 'latest.yml' }, + { name: 'orca-windows-setup.exe' }, + { name: 'latest-linux.yml' }, + { name: 'orca-linux.AppImage' } +] + const release = (tag: string, extra: Record = {}) => ({ tag_name: tag, draft: false, published_at: '2026-07-28T14:00:00Z', html_url: `https://github.com/stablyai/orca/releases/tag/${tag}`, + assets: allPlatformAssets, ...extra }) @@ -35,7 +47,7 @@ describe('listReleaseBuilds', () => { ]) ) - const builds = await listReleaseBuilds('hourly') + const builds = await listReleaseBuilds('hourly', 'darwin') expect(fetchMock.mock.calls[0][0]).toContain('stablyai/orca-hourly') expect(builds.map((build) => build.version)).toEqual([ @@ -54,7 +66,7 @@ describe('listReleaseBuilds', () => { ]) ) - const builds = await listReleaseBuilds('daily') + const builds = await listReleaseBuilds('daily', 'darwin') expect(fetchMock.mock.calls[0][0]).toContain('stablyai/orca-daily') expect(builds.map((build) => build.version)).toEqual([ @@ -71,16 +83,16 @@ describe('listReleaseBuilds', () => { jsonResponse([release('v1.4.160-rc.2'), release('v1.4.159'), release('v1.4.158')]) ) - await expect(listReleaseBuilds('stable').then((b) => b.map((x) => x.version))).resolves.toEqual( - ['1.4.159', '1.4.158'] - ) + await expect( + listReleaseBuilds('stable', 'darwin').then((b) => b.map((x) => x.version)) + ).resolves.toEqual(['1.4.159', '1.4.158']) fetchMock.mockResolvedValue( jsonResponse([release('v1.4.160-rc.2'), release('v1.4.159'), release('v1.4.158')]) ) - await expect(listReleaseBuilds('rc').then((b) => b.map((x) => x.version))).resolves.toEqual([ - '1.4.160-rc.2' - ]) + await expect( + listReleaseBuilds('rc', 'darwin').then((b) => b.map((x) => x.version)) + ).resolves.toEqual(['1.4.160-rc.2']) }) // Why: a draft release has no downloadable assets; offering it makes the @@ -95,7 +107,7 @@ describe('listReleaseBuilds', () => { ]) ) - const builds = await listReleaseBuilds('stable') + const builds = await listReleaseBuilds('stable', 'darwin') expect(builds.map((build) => build.version)).toEqual(['1.4.159']) }) @@ -113,7 +125,7 @@ describe('listReleaseBuilds', () => { ]) ) - const builds = await listReleaseBuilds('hourly') + const builds = await listReleaseBuilds('hourly', 'darwin') expect(builds.map((build) => build.name)).toEqual([ '1.4.163 • 01 • 07-31 13:54 • e698241', null, @@ -122,14 +134,89 @@ describe('listReleaseBuilds', () => { ]) }) + // Why: the mac and Windows legs publish into one release independently, and + // either can fail. Offering a row the running platform has no artifact for + // sends the user into a download that 404s after they commit to it. + it('hides builds that published no artifact for this platform', async () => { + fetchMock.mockResolvedValue( + jsonResponse([ + release('v1.4.163-hourly.202607312054'), + release('v1.4.163-hourly.202607311933', { + assets: [{ name: 'latest-mac.yml' }, { name: 'orca-macos-arm64.dmg' }] + }) + ]) + ) + + await expect( + listReleaseBuilds('hourly', 'win32').then((builds) => builds.map((build) => build.version)) + ).resolves.toEqual(['1.4.163-hourly.202607312054']) + }) + + // The mac-only releases every dev channel published before Windows builds + // existed must simply not appear on Windows, rather than erroring. + it('returns an empty list when no build has this platform artifact', async () => { + fetchMock.mockResolvedValue( + jsonResponse([ + release('v1.4.163-hourly.202607312054', { assets: [{ name: 'latest-mac.yml' }] }) + ]) + ) + + await expect(listReleaseBuilds('hourly', 'win32')).resolves.toEqual([]) + }) + + it('keeps mac builds visible on macOS regardless of the Windows leg', async () => { + fetchMock.mockResolvedValue( + jsonResponse([ + release('v1.4.163-hourly.202607312054', { assets: [{ name: 'latest-mac.yml' }] }) + ]) + ) + + await expect( + listReleaseBuilds('hourly', 'darwin').then((builds) => builds.map((build) => build.version)) + ).resolves.toEqual(['1.4.163-hourly.202607312054']) + }) + + // Why: on Windows a signed stable cannot reach a dev channel through the + // updater, so the picker needs a direct download to hand the user instead. + it('resolves the platform installer download url', async () => { + fetchMock.mockResolvedValue(jsonResponse([release('v1.4.163-hourly.202607312054')])) + + const [build] = await listReleaseBuilds('hourly', 'win32') + + expect(build.installerUrl).toBe( + 'https://github.com/stablyai/orca-hourly/releases/download/v1.4.163-hourly.202607312054/orca-windows-setup.exe' + ) + }) + + it('leaves the installer url null when the release published no installer', async () => { + fetchMock.mockResolvedValue( + jsonResponse([release('v1.4.163-hourly.202607312054', { assets: [{ name: 'latest.yml' }] })]) + ) + + const [build] = await listReleaseBuilds('hourly', 'win32') + + expect(build.installerUrl).toBeNull() + }) + + it('tolerates a release whose assets are missing or malformed', async () => { + fetchMock.mockResolvedValue( + jsonResponse([ + release('v1.4.163-hourly.202607312054', { assets: undefined }), + release('v1.4.163-hourly.202607311933', { assets: [null, { name: 7 }] }) + ]) + ) + + await expect(listReleaseBuilds('hourly', 'win32')).resolves.toEqual([]) + }) + it('surfaces a rate limit as an actionable message', async () => { fetchMock.mockResolvedValue(jsonResponse(null, { ok: false, status: 403 })) - await expect(listReleaseBuilds('hourly')).rejects.toThrow(/rate limit/i) + await expect(listReleaseBuilds('hourly', 'darwin')).rejects.toThrow(/rate limit/i) }) it('reports a missing hourly repo distinctly', async () => { fetchMock.mockResolvedValue(jsonResponse(null, { ok: false, status: 404 })) - await expect(listReleaseBuilds('hourly')).rejects.toThrow(/No releases repository/i) + await expect(listReleaseBuilds('hourly', 'darwin')).rejects.toThrow(/No releases repository/i) }) }) diff --git a/src/main/updater-release-builds.ts b/src/main/updater-release-builds.ts index f4f694facf4..01478535018 100644 --- a/src/main/updater-release-builds.ts +++ b/src/main/updater-release-builds.ts @@ -1,7 +1,9 @@ import { net } from 'electron' import { + findInstallerAssetName, getReleaseRepoForChannel, getVersionChannel, + hasInstallableArtifactForPlatform, normalizeTagToVersion, sortReleaseBuildsNewestFirst, type ReleaseBuild, @@ -26,9 +28,23 @@ type GitHubReleaseEntry = { draft?: unknown published_at?: unknown html_url?: unknown + assets?: unknown } -function parseReleaseEntry(entry: GitHubReleaseEntry, repo: string): ReleaseBuild | null { +function readAssetNames(assets: unknown): string[] { + if (!Array.isArray(assets)) { + return [] + } + return assets + .map((asset) => (asset as { name?: unknown })?.name) + .filter((name): name is string => typeof name === 'string') +} + +function parseReleaseEntry( + entry: GitHubReleaseEntry, + repo: string, + platform: NodeJS.Platform +): ReleaseBuild | null { if (typeof entry.tag_name !== 'string' || entry.draft === true) { return null } @@ -38,6 +54,15 @@ function parseReleaseEntry(entry: GitHubReleaseEntry, repo: string): ReleaseBuil if (!isValidVersion(version) || !channel) { return null } + // Why filter on assets rather than on a per-channel platform table: a release + // is published as soon as one platform's leg finishes, and a leg can fail + // outright. Asking what the release actually carries covers both without the + // picker ever offering a row whose download 404s. + const assetNames = readAssetNames(entry.assets) + if (!hasInstallableArtifactForPlatform(platform, assetNames)) { + return null + } + const installerAsset = findInstallerAssetName(platform, assetNames) // Why null when it merely repeats the tag: GitHub titles an untitled release // with its tag name, and hourlies predating the naming change were created that // way too. Neither says anything the version beside it does not. @@ -51,7 +76,10 @@ function parseReleaseEntry(entry: GitHubReleaseEntry, repo: string): ReleaseBuil releaseUrl: typeof entry.html_url === 'string' ? entry.html_url - : `https://github.com/${repo}/releases/tag/${encodeURIComponent(tag)}` + : `https://github.com/${repo}/releases/tag/${encodeURIComponent(tag)}`, + installerUrl: installerAsset + ? `${getReleaseDownloadUrlForRepo(repo, tag)}/${encodeURIComponent(installerAsset)}` + : null } } @@ -64,7 +92,10 @@ function parseReleaseEntry(entry: GitHubReleaseEntry, repo: string): ReleaseBuil * yesterday's hourly". This runs only on explicit dev interaction, so its * unauthenticated rate limit never touches background checks. */ -export async function listReleaseBuilds(channel: ReleaseChannel): Promise { +export async function listReleaseBuilds( + channel: ReleaseChannel, + platform: NodeJS.Platform = process.platform +): Promise { const repo = getReleaseRepoForChannel(channel) const res = await net.fetch(getReleasesApiUrl(repo), { headers: { Accept: 'application/vnd.github+json' }, @@ -84,7 +115,7 @@ export async function listReleaseBuilds(channel: ReleaseChannel): Promise parseReleaseEntry(entry as GitHubReleaseEntry, repo)) + .map((entry) => parseReleaseEntry(entry as GitHubReleaseEntry, repo, platform)) .filter((build): build is ReleaseBuild => build !== null) // Why: the main repo serves both stable and rc, so filter to the asked-for channel. .filter((build) => build.channel === channel) diff --git a/src/main/updater.build-channel-selection.test.ts b/src/main/updater.build-channel-selection.test.ts index d4f85c419e1..9e036cc2b9e 100644 --- a/src/main/updater.build-channel-selection.test.ts +++ b/src/main/updater.build-channel-selection.test.ts @@ -33,11 +33,23 @@ describe('updater', () => { }) it.each([ - ['hourly', 'v1.4.160-hourly.202607281400', 'Hourly builds are produced only for macOS.'], - ['daily', 'v1.4.160-daily.202607281300', 'Daily builds are produced only for macOS.'], - ['adhoc', 'v1.4.160-adhoc.20260728140533', 'Adhoc builds are produced only for macOS.'] + [ + 'hourly', + 'v1.4.160-hourly.202607281400', + 'Hourly builds are produced only for macOS and Windows.' + ], + [ + 'daily', + 'v1.4.160-daily.202607281300', + 'Daily builds are produced only for macOS and Windows.' + ], + [ + 'adhoc', + 'v1.4.160-adhoc.20260728140533', + 'Adhoc builds are produced only for macOS and Windows.' + ] ] as const)( - 'uses the display label in the mac-only %s pinned-build error', + 'uses the display label in the unsupported-platform %s pinned-build error', async (channel, tag, message) => { const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') try { @@ -61,6 +73,65 @@ describe('updater', () => { } ) + // Why this refuses rather than trying: electron-updater would download the + // whole installer and then fail it with a raw ERR_UPDATER_INVALID_SIGNATURE, + // because a signed build verifies every installer against the publisherName + // baked into its own app-update.yml. The picker disables this, but IPC is + // reachable regardless. + it('refuses to pin a Windows dev build from a signed build, and says what to do', async () => { + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + try { + appMock.getVersion.mockReturnValue('1.4.160') + const send = vi.fn() + const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater') + setupAutoUpdater({ webContents: { send } } as never, { + getLastUpdateCheckAt: () => Date.now() + }) + + checkForUpdatesFromMenu({ channel: 'adhoc', targetTag: 'v1.4.160-adhoc.20260728140533' }) + + expect(send).toHaveBeenCalledWith('updater:status', { + state: 'error', + message: expect.stringContaining('Download the installer from the release page'), + userInitiated: true + }) + expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled() + } finally { + platformSpy.mockRestore() + } + }) + + // The way out of a dev channel must stay in-app: an unsigned build carries no + // publisherName, so electron-updater skips verification entirely. + it.each([ + ['another dev build', 'adhoc', 'v1.4.160-adhoc.20260728140533'], + ['back to stable', 'stable', 'v1.4.160'] + ] as const)( + 'still pins %s from an unsigned Windows dev build', + async (_label, channel, targetTag) => { + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + try { + appMock.getVersion.mockReturnValue('1.4.160-hourly.202607281400') + const send = vi.fn() + const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater') + setupAutoUpdater({ webContents: { send } } as never, { + getLastUpdateCheckAt: () => Date.now() + }) + + checkForUpdatesFromMenu({ channel, targetTag }) + + expect(send).not.toHaveBeenCalledWith('updater:status', { + state: 'error', + message: expect.stringContaining('Download the installer'), + userInitiated: true + }) + expect(autoUpdaterMock.allowDowngrade).toBe(true) + } finally { + platformSpy.mockRestore() + } + } + ) + it.runIf(process.platform === 'darwin')( 'allows a validated local build to downgrade through the normal updater lifecycle', async () => { diff --git a/src/main/updater.ts b/src/main/updater.ts index f89c00331a7..640906197c8 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -74,9 +74,12 @@ import { import type { LocalBuildFeed } from './local-builds/local-build-feed-server' import { listReleaseBuilds, resolveTargetBuild } from './updater-release-builds' import { + DEV_CHANNEL_PLATFORM_LABEL, + getVersionChannel, hasDedicatedReleaseRepo, isChannelSupportedOnPlatform, RELEASE_CHANNEL_LABELS, + requiresManualDevChannelInstall, type ReleaseBuild, type ReleaseChannel } from '../shared/release-channel' @@ -1737,11 +1740,29 @@ async function checkForPinnedBuild(channel: ReleaseChannel, tag: string): Promis return } // Why here as well as in the picker: the renderer disables the option, but IPC - // is reachable regardless, and there is no artifact to install off-macOS. + // is reachable regardless, and there is no artifact to install on a platform + // the dev workflows do not build for. if (!isChannelSupportedOnPlatform(channel, process.platform)) { sendStatus({ state: 'error', - message: `${RELEASE_CHANNEL_LABELS[channel]} builds are produced only for macOS.`, + message: `${RELEASE_CHANNEL_LABELS[channel]} builds are produced only for ${DEV_CHANNEL_PLATFORM_LABEL}.`, + userInitiated: true + }) + return + } + // Why: electron-updater would otherwise take this all the way to a download + // and fail it with a raw ERR_UPDATER_INVALID_SIGNATURE. Say what to do instead + // — the installer is run by hand once, and in-app updates work from there on. + if ( + requiresManualDevChannelInstall({ + platform: process.platform, + runningChannel: getVersionChannel(app.getVersion()), + targetChannel: channel + }) + ) { + sendStatus({ + state: 'error', + message: `${RELEASE_CHANNEL_LABELS[channel]} builds are unsigned, and this signed build only installs updates signed by Orca's publisher. Download the installer from the release page and run it once — updates work normally from there, including back to Stable.`, userInitiated: true }) return diff --git a/src/renderer/src/components/settings/ReleaseChannelSection.tsx b/src/renderer/src/components/settings/ReleaseChannelSection.tsx index bfe2321bd32..cd0a8484cfe 100644 --- a/src/renderer/src/components/settings/ReleaseChannelSection.tsx +++ b/src/renderer/src/components/settings/ReleaseChannelSection.tsx @@ -1,6 +1,6 @@ import type React from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { AlertTriangle, Apple, Loader2, RefreshCw } from 'lucide-react' +import { AlertTriangle, Download, Loader2, RefreshCw } from 'lucide-react' import { toast } from 'sonner' import { useAppStore } from '../../store' import { Button } from '../ui/button' @@ -10,12 +10,14 @@ import { Badge } from '../ui/badge' import { translate } from '@/i18n/i18n' import { getShortcutPlatform } from '@/lib/shortcut-platform' import { + DEV_CHANNEL_PLATFORM_LABEL, RELEASE_CHANNELS, RELEASE_CHANNEL_LABELS, getVersionChannel, hasDedicatedReleaseRepo, isChannelSupportedOnPlatform, parseDevBuildStamp, + requiresManualDevChannelInstall, type DedicatedRepoChannel, type ReleaseBuild, type ReleaseChannel @@ -24,10 +26,10 @@ import { const CHANNEL_DESCRIPTIONS: Record = { stable: 'Shipped releases. What everyone else is running.', rc: 'Release candidates cut ahead of each stable.', - hourly: 'macOS only. Unvetted builds from main, built every hour. No tests.', + hourly: 'macOS and Windows. Unvetted builds from main, built every hour. No tests.', daily: - 'macOS only. Unvetted builds from main, cut once a day at 14:15 UTC (early morning Pacific). No tests.', - adhoc: 'macOS only. One-off builds cut from a branch to try a feature before it lands.' + 'macOS and Windows. Unvetted builds from main, cut once a day at 14:15 UTC (early morning Pacific). No tests.', + adhoc: 'macOS and Windows. One-off builds cut from a branch to try a feature before it lands.' } const DEDICATED_CHANNEL_WARNINGS: Record = @@ -35,17 +37,17 @@ const DEDICATED_CHANNEL_WARNINGS: Record { + // Falls back to the release page: better to land somewhere with the assets + // listed than to do nothing because the artifact was named unexpectedly. + void window.api.shell.openUrl(build.installerUrl ?? build.releaseUrl) + } return (
@@ -215,28 +232,21 @@ export function ReleaseChannelSection(): React.JSX.Element { const supported = isChannelSupportedOnPlatform(channel, platform) return { value: channel, - label: supported ? ( - RELEASE_CHANNEL_LABELS[channel] - ) : ( - - {RELEASE_CHANNEL_LABELS[channel]} - - ), + label: RELEASE_CHANNEL_LABELS[channel], disabled: !supported, ariaLabel: supported ? undefined : translate( - 'auto.components.settings.ReleaseChannelSection.devChannelMacOnlyAria', - '{{value0}} (macOS only)', - { value0: RELEASE_CHANNEL_LABELS[channel] } + 'auto.components.settings.ReleaseChannelSection.devChannelUnsupportedAria', + '{{value0}} ({{value1}} only)', + { value0: RELEASE_CHANNEL_LABELS[channel], value1: DEV_CHANNEL_PLATFORM_LABEL } ), tooltip: supported ? undefined : translate( - 'auto.components.settings.ReleaseChannelSection.devChannelMacOnly', - '{{value0}} builds are produced only for macOS. Linux and Windows stay on Stable or RC.', - { value0: RELEASE_CHANNEL_LABELS[channel] } + 'auto.components.settings.ReleaseChannelSection.devChannelUnsupported', + '{{value0}} builds are produced only for {{value1}}. Linux stays on Stable or RC.', + { value0: RELEASE_CHANNEL_LABELS[channel], value1: DEV_CHANNEL_PLATFORM_LABEL } ) } })} @@ -310,10 +320,27 @@ export function ReleaseChannelSection(): React.JSX.Element { size="sm" type="button" disabled={!selectedBuild || busy || isRunningBuild} - onClick={() => selectedBuild && handleSwitchTo(selectedBuild)} + onClick={() => { + if (!selectedBuild) { + return + } + if (needsManualInstall) { + handleDownloadInstaller(selectedBuild) + return + } + handleSwitchTo(selectedBuild) + }} > {busy ? ( + ) : needsManualInstall ? ( + + ) : ( translate( 'auto.components.settings.ReleaseChannelSection.switchTo', @@ -325,6 +352,14 @@ export function ReleaseChannelSection(): React.JSX.Element { {loadError ? (

{loadError}

+ ) : needsManualInstall && selectedBuild ? ( +

+ {translate( + 'auto.components.settings.ReleaseChannelSection.manualInstallHint', + '{{value0}} builds are unsigned on Windows, so the in-app updater cannot install one over a signed build. Run the downloaded installer once — Windows will warn about an unknown publisher — and every later switch, including back to Stable, works from here.', + { value0: RELEASE_CHANNEL_LABELS[activeChannel] } + )} +

) : isRunningBuild ? (

{translate( diff --git a/src/renderer/src/components/settings/SettingsFormControls.segmented-control.test.tsx b/src/renderer/src/components/settings/SettingsFormControls.segmented-control.test.tsx index 00e1b728748..55fd12e9119 100644 --- a/src/renderer/src/components/settings/SettingsFormControls.segmented-control.test.tsx +++ b/src/renderer/src/components/settings/SettingsFormControls.segmented-control.test.tsx @@ -18,8 +18,8 @@ function renderControl(onChange = vi.fn()): { onChange: ReturnType value: 'hourly', label: 'Hourly', disabled: true, - ariaLabel: 'Hourly (macOS only)', - tooltip: 'Hourly builds are produced only for macOS.' + ariaLabel: 'Hourly (macOS and Windows only)', + tooltip: 'Hourly builds are produced only for macOS and Windows.' } ]} /> @@ -34,20 +34,20 @@ describe('SettingsSegmentedControl unavailable options', () => { it('marks unavailable options aria-disabled so their tooltip stays reachable', async () => { renderControl() - const hourly = screen.getByRole('radio', { name: 'Hourly (macOS only)' }) + const hourly = screen.getByRole('radio', { name: 'Hourly (macOS and Windows only)' }) expect(hourly.hasAttribute('disabled')).toBe(false) expect(hourly.getAttribute('aria-disabled')).toBe('true') fireEvent.focus(hourly) expect( - await screen.findAllByText('Hourly builds are produced only for macOS.') + await screen.findAllByText('Hourly builds are produced only for macOS and Windows.') ).not.toHaveLength(0) }) it('ignores clicks on unavailable options', () => { const { onChange } = renderControl() - fireEvent.click(screen.getByRole('radio', { name: 'Hourly (macOS only)' })) + fireEvent.click(screen.getByRole('radio', { name: 'Hourly (macOS and Windows only)' })) expect(onChange).not.toHaveBeenCalled() fireEvent.click(screen.getByRole('radio', { name: 'Stable' })) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 554c9a629e8..1f16389c1fa 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -11065,9 +11065,9 @@ "description": "Switch update channels or jump to any published build, including older ones. Downgrades are allowed and unvetted builds can be broken.", "devOnly": "Dev only", "channelAriaLabel": "Update channel", - "hourlyWarning": "Hourly builds are macOS-only and ship straight from main with no test gate. Keep a stable build handy.", - "dailyWarning": "Daily builds are macOS-only and ship straight from main with no test gate. Keep a stable build handy.", - "adhocWarning": "Adhoc builds are macOS-only and come from a branch that has not landed. Whoever cut one may abandon it — keep a stable build handy.", + "hourlyWarning": "Hourly builds ship straight from main with no test gate, and the Windows ones are unsigned. Keep a stable build handy.", + "dailyWarning": "Daily builds ship straight from main with no test gate, and the Windows ones are unsigned. Keep a stable build handy.", + "adhocWarning": "Adhoc builds come from a branch that has not landed, and the Windows ones are unsigned. Whoever cut one may abandon it — keep a stable build handy.", "loadingBuilds": "Loading builds…", "noBuilds": "No builds found", "refresh": "Refresh build list", @@ -11075,8 +11075,10 @@ "alreadyRunning": "This is the build you are running.", "willSwitch": "{{value0}} → {{value1}}", "webUnavailable": "Switching builds is only available in the desktop app.", - "devChannelMacOnlyAria": "{{value0}} (macOS only)", - "devChannelMacOnly": "{{value0}} builds are produced only for macOS. Linux and Windows stay on Stable or RC." + "devChannelUnsupportedAria": "{{value0}} ({{value1}} only)", + "devChannelUnsupported": "{{value0}} builds are produced only for {{value1}}. Linux stays on Stable or RC.", + "downloadInstaller": "Download installer", + "manualInstallHint": "{{value0}} builds are unsigned on Windows, so the in-app updater cannot install one over a signed build. Run the downloaded installer once — Windows will warn about an unknown publisher — and every later switch, including back to Stable, works from here." }, "VoiceMicrophoneSetting": { "systemDefault": "System default", diff --git a/src/shared/release-channel.test.ts b/src/shared/release-channel.test.ts index fc7172efbf6..a2d66242d93 100644 --- a/src/shared/release-channel.test.ts +++ b/src/shared/release-channel.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + findInstallerAssetName, formatAdhocVersion, formatDailyVersion, formatHourlyVersion, @@ -7,6 +8,7 @@ import { getReleaseRepoForChannel, getVersionChannel, hasDedicatedReleaseRepo, + hasInstallableArtifactForPlatform, isAdhocVersion, isChannelSupportedOnPlatform, isDailyVersion, @@ -16,8 +18,10 @@ import { parseDailyVersionStamp, parseDevBuildStamp, parseHourlyVersionStamp, + requiresManualDevChannelInstall, sortReleaseBuildsNewestFirst, - type ReleaseBuild + type ReleaseBuild, + type ReleaseChannel } from './release-channel' import { compareAppVersions } from './app-version' @@ -155,17 +159,85 @@ describe('release channel', () => { expect(parseDevBuildStamp('1.4.160')).toBeNull() }) - // Why: all dev workflows are macOS-only, so the channels have no artifact to - // offer elsewhere. Both the picker and the main-process check read this, so a - // regression here would silently re-expose an uninstallable channel. - it('offers the dev channels only on macOS', () => { + // Why: the dev workflows build macOS and Windows but not Linux, so a Linux + // install has no artifact to offer. Both the picker and the main-process check + // read this, so a regression here would silently expose an uninstallable + // channel. + it('offers the dev channels on macOS and Windows but not Linux', () => { for (const channel of ['hourly', 'daily', 'adhoc'] as const) { expect(isChannelSupportedOnPlatform(channel, 'darwin')).toBe(true) + expect(isChannelSupportedOnPlatform(channel, 'win32')).toBe(true) expect(isChannelSupportedOnPlatform(channel, 'linux')).toBe(false) - expect(isChannelSupportedOnPlatform(channel, 'win32')).toBe(false) } }) + // The whole Windows story in one test. electron-updater verifies a downloaded + // installer against the publisherName baked into the *installed* app, so a + // signed stable/RC rejects an unsigned dev installer and no future build can + // fix the copies already out there. Dev builds carry no publisherName, so + // everything leaving a dev channel — including the way back to stable — works. + it('requires a manual install only when entering a dev channel from a signed Windows build', () => { + const manual = (runningChannel: ReleaseChannel | null, targetChannel: ReleaseChannel) => + requiresManualDevChannelInstall({ platform: 'win32', runningChannel, targetChannel }) + + expect(manual('stable', 'adhoc')).toBe(true) + expect(manual('rc', 'hourly')).toBe(true) + expect(manual('stable', 'daily')).toBe(true) + // Unparseable version: assume signed, which sends the user to a download + // that works rather than an update that fails on a signature error. + expect(manual(null, 'adhoc')).toBe(true) + + // Already unsigned — the updater skips verification entirely from here. + expect(manual('adhoc', 'hourly')).toBe(false) + expect(manual('hourly', 'adhoc')).toBe(false) + expect(manual('hourly', 'stable')).toBe(false) + expect(manual('adhoc', 'rc')).toBe(false) + + // Not a dev channel at all. + expect(manual('stable', 'rc')).toBe(false) + expect(manual('rc', 'stable')).toBe(false) + }) + + // Why: macOS dev builds are signed and notarized like a release, so the + // updater installs them over a stable build with no manual step. + it('never requires a manual install off Windows', () => { + for (const platform of ['darwin', 'linux'] as const) { + expect( + requiresManualDevChannelInstall({ + platform, + runningChannel: 'stable', + targetChannel: 'adhoc' + }) + ).toBe(false) + } + }) + + it('detects an installable artifact from the platform update manifest', () => { + expect(hasInstallableArtifactForPlatform('win32', ['latest.yml'])).toBe(true) + expect(hasInstallableArtifactForPlatform('win32', ['latest-mac.yml'])).toBe(false) + expect(hasInstallableArtifactForPlatform('darwin', ['latest-mac.yml'])).toBe(true) + expect(hasInstallableArtifactForPlatform('darwin', ['latest.yml'])).toBe(false) + expect(hasInstallableArtifactForPlatform('linux', ['latest-linux-arm64.yml'])).toBe(true) + expect(hasInstallableArtifactForPlatform('linux', [])).toBe(false) + // An unknown platform must not hide every build; a download-time error is a + // better failure than an empty picker with no explanation. + expect(hasInstallableArtifactForPlatform('freebsd', [])).toBe(true) + }) + + it('finds the directly runnable installer for a platform', () => { + const assets = [ + 'latest.yml', + 'orca-windows-setup.exe', + 'orca-macos-arm64.dmg', + 'orca-linux.AppImage' + ] + expect(findInstallerAssetName('win32', assets)).toBe('orca-windows-setup.exe') + expect(findInstallerAssetName('darwin', assets)).toBe('orca-macos-arm64.dmg') + expect(findInstallerAssetName('linux', assets)).toBe('orca-linux.AppImage') + expect(findInstallerAssetName('win32', ['latest.yml'])).toBeNull() + expect(findInstallerAssetName('freebsd', assets)).toBeNull() + }) + it('offers stable and rc on every platform', () => { for (const platform of ['darwin', 'linux', 'win32'] as const) { expect(isChannelSupportedOnPlatform('stable', platform)).toBe(true) @@ -192,7 +264,8 @@ describe('release channel', () => { channel: 'hourly', name: null, publishedAt: null, - releaseUrl: `https://github.com/stablyai/orca-hourly/releases/tag/v${version}` + releaseUrl: `https://github.com/stablyai/orca-hourly/releases/tag/v${version}`, + installerUrl: null }) const sorted = sortReleaseBuildsNewestFirst([ build('1.4.160-hourly.202607280900'), @@ -237,7 +310,8 @@ describe('release channel', () => { channel: 'adhoc', name: null, publishedAt: null, - releaseUrl: `https://github.com/stablyai/orca-adhoc/releases/tag/v${version}` + releaseUrl: `https://github.com/stablyai/orca-adhoc/releases/tag/v${version}`, + installerUrl: null }) const sorted = sortReleaseBuildsNewestFirst([ build('1.4.160-adhoc.20260728140502'), diff --git a/src/shared/release-channel.ts b/src/shared/release-channel.ts index 74b08f8eea5..056152ffcc9 100644 --- a/src/shared/release-channel.ts +++ b/src/shared/release-channel.ts @@ -53,21 +53,65 @@ export function hasDedicatedReleaseRepo(channel: ReleaseChannel): channel is Ded return (DEDICATED_REPO_CHANNELS as readonly ReleaseChannel[]).includes(channel) } +/** + * The platforms each dev channel actually builds for. Split from + * `hasDedicatedReleaseRepo` because "published to its own repo" and "built for + * this OS" stopped coinciding once the dev workflows gained a Windows job — + * Linux has no dev-channel artifact yet, so it still falls back to stable/RC. + */ +const DEV_CHANNEL_PLATFORMS: Readonly> = { + hourly: ['darwin', 'win32'], + daily: ['darwin', 'win32'], + adhoc: ['darwin', 'win32'] +} + +/** Human-readable list of where dev builds exist, for picker copy that would + * otherwise have to restate the table above and drift from it. */ +export const DEV_CHANNEL_PLATFORM_LABEL = 'macOS and Windows' + /** * Shared so the picker, the main-process check, and any future surface cannot * drift on where a channel is available. - * - * Why this rides on the dev-channel list: all dev channels are produced only by - * macOS workflows, so none has an artifact to offer elsewhere. If one ever - * gains a Windows or Linux job, split the two concepts apart — they coincide - * today, but "published to its own repo" and "built for macOS only" are not the - * same claim. */ export function isChannelSupportedOnPlatform( channel: ReleaseChannel, platform: NodeJS.Platform ): boolean { - return !hasDedicatedReleaseRepo(channel) || platform === 'darwin' + if (!hasDedicatedReleaseRepo(channel)) { + return true + } + return DEV_CHANNEL_PLATFORMS[channel].includes(platform) +} + +/** + * True when the running build cannot reach `targetChannel` through the in-app + * updater and the installer has to be run by hand instead. + * + * Windows dev builds ship unsigned, because SignPath's approval waits are + * budgeted in hours and cannot fit an hourly cadence. electron-updater + * Authenticode-verifies every installer it downloads against the publisherName + * baked into the *installed* app's app-update.yml, so a signed stable or RC + * rejects an unsigned dev installer outright with ERR_UPDATER_INVALID_SIGNATURE + * — and no change to a future build can fix the copies already installed. + * + * Dev builds omit that name, so verification is skipped there and every route + * *out* of a dev channel, including back to stable, still works in-app. Only + * the way in is blocked, and only on Windows. + * + * A null `runningChannel` (an unparseable version) is treated as signed: the + * conservative answer sends someone to a working download rather than to an + * update that fails with a signature error. + */ +export function requiresManualDevChannelInstall(options: { + platform: NodeJS.Platform + runningChannel: ReleaseChannel | null + targetChannel: ReleaseChannel +}): boolean { + const { platform, runningChannel, targetChannel } = options + if (platform !== 'win32' || !hasDedicatedReleaseRepo(targetChannel)) { + return false + } + return runningChannel === null || !hasDedicatedReleaseRepo(runningChannel) } export function getReleaseRepoForChannel(channel: ReleaseChannel): string { @@ -206,6 +250,58 @@ export function getReleaseNotesUrlForVersion(version: string | null): string { : `https://github.com/${repo}/releases` } +/** + * The electron-updater manifest each platform's updater fetches before it can + * install anything. A release without one has nothing that platform can use, + * which is how a build whose Windows leg failed — or one whose Windows leg is + * still running — stays out of that platform's picker instead of becoming a row + * that 404s on download. + */ +const PLATFORM_UPDATE_MANIFESTS: Partial> = { + darwin: ['latest-mac.yml'], + win32: ['latest.yml'], + // Both, because one release carries x64 and arm64 and either makes it installable. + linux: ['latest-linux.yml', 'latest-linux-arm64.yml'] +} + +export function getUpdateManifestNamesForPlatform(platform: NodeJS.Platform): readonly string[] { + return PLATFORM_UPDATE_MANIFESTS[platform] ?? [] +} + +/** True when the release carries an artifact this platform's updater can install. */ +export function hasInstallableArtifactForPlatform( + platform: NodeJS.Platform, + assetNames: readonly string[] +): boolean { + const manifests = getUpdateManifestNamesForPlatform(platform) + // Why permissive on an unknown platform: a filter that hides every build is a + // worse failure than one that offers a build the download step will report on. + if (manifests.length === 0) { + return true + } + return manifests.some((manifest) => assetNames.includes(manifest)) +} + +/** Matches the electron-builder `artifactName` for each platform's directly + * runnable installer — the file someone downloads when the in-app updater + * cannot make the jump. */ +const PLATFORM_INSTALLER_PATTERNS: Partial> = { + darwin: /\.dmg$/i, + win32: /windows-setup\.exe$/i, + linux: /\.AppImage$/i +} + +export function findInstallerAssetName( + platform: NodeJS.Platform, + assetNames: readonly string[] +): string | null { + const pattern = PLATFORM_INSTALLER_PATTERNS[platform] + if (!pattern) { + return null + } + return assetNames.find((name) => pattern.test(name)) ?? null +} + export type ReleaseBuild = { tag: string version: string @@ -215,6 +311,10 @@ export type ReleaseBuild = { name: string | null publishedAt: string | null releaseUrl: string + /** Direct download for this platform's installer, or null when the release + * published none. Drives the Windows bootstrap path, where a signed build + * cannot reach a dev channel through the updater. */ + installerUrl: string | null } /** Newest first, so the picker's first row is always the channel's current tip. */ From 36d78e88afd214078521de883202ea851e86c4de Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:36:33 -0700 Subject: [PATCH 004/111] fix(agent-hooks): stop Antigravity's Windows hook from spawning PowerShell on every event (#15520) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Antigravity was the last agent posting hook status through Windows PowerShell 5.1. Every hook event — roughly one every 2-6s during an active session — paid a ~300ms interpreter cold start, which is what made the console the agent allocates for each hook last long enough to be seen as continuous flashing. Move the Windows POST to the shared curl.exe builder every other agent already uses, via the `extraFormLines` escape hatch for the `hook_event_name` field Antigravity uniquely needs. Measured on Windows 11: 326ms -> 134ms per event. Because the curl line percent-expands its arguments, the script also needs `setlocal DisableDelayedExpansion` (#9358/#9941) so a `!` in a pane key or worktree path is not eaten as a delayed reference. curl omits a `--data-urlencode name@-` field entirely when stdin is empty, so accept an absent or blank Antigravity payload as `{}` at the ingest boundary — the POSIX script substitutes `{}` before posting and PowerShell did the same, and without this a payload-less event lost the status transition its `hook_event_name` still carried. Scoped to that source; every other agent keeps rejecting a body it cannot parse. Adds a cross-agent guard asserting the invariant the original drift violated: a managed Windows .cmd hook posts through fully-qualified curl.exe and spawns no interpreter. Generated under a mocked win32 platform so the POSIX CI legs guard it too. Validated on a real Windows 11 host, not an emulated platform check. Fixes #15117 --- .../windows-hook-post-interpreter.test.ts | 149 +++++++++ src/main/antigravity/hook-script.ts | 21 +- src/main/antigravity/hook-service.test.ts | 18 +- .../windows-hook-payload-delivery.test.ts | 287 ++++++++++++++++++ .../agent-hook-listener-antigravity.test.ts | 75 +++++ src/shared/agent-hook-listener.ts | 12 +- 6 files changed, 543 insertions(+), 19 deletions(-) create mode 100644 src/main/agent-hooks/windows-hook-post-interpreter.test.ts create mode 100644 src/main/antigravity/windows-hook-payload-delivery.test.ts diff --git a/src/main/agent-hooks/windows-hook-post-interpreter.test.ts b/src/main/agent-hooks/windows-hook-post-interpreter.test.ts new file mode 100644 index 00000000000..0e79c125c2e --- /dev/null +++ b/src/main/agent-hooks/windows-hook-post-interpreter.test.ts @@ -0,0 +1,149 @@ +// Why (#15117): an agent holding a private copy of the shared post command missed the move to +// curl for three months, invisible to per-agent tests. Assert the invariant across every agent +// at once: a managed Windows .cmd hook posts through curl.exe and spawns no interpreter. +// Generated under a mocked win32 platform, not executed, so the POSIX CI legs guard it too. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type * as osModule from 'node:os' + +let isolatedUserDataDir = '' +let previousUserDataPath: string | undefined + +const { homedirMock } = vi.hoisted(() => ({ + homedirMock: vi.fn<() => string>() +})) + +vi.mock('electron', () => ({ + app: { + getPath: () => '/tmp/orca-user-data' + } +})) + +vi.mock('os', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + homedir: homedirMock.mockImplementation(actual.homedir) + } +}) + +import { AntigravityHookService } from '../antigravity/hook-service' +import { ClaudeHookService } from '../claude/hook-service' +import { CodexHookService } from '../codex/hook-service' +import { CommandCodeHookService } from '../command-code/hook-service' +import { CursorHookService } from '../cursor/hook-service' +import { DevinHookService } from '../devin/hook-service' +import { DroidHookService } from '../droid/hook-service' +import { GeminiHookService } from '../gemini/hook-service' +import { GrokHookService } from '../grok/hook-service' +import { openClaudeHookService } from '../openclaude/hook-service' + +// Why: only agents whose managed Windows script is a .cmd batch file. Copilot's hook is a +// `.ps1` — PowerShell is its interpreter, not a child process it spawns per event — and Kimi's +// is a Git Bash `.sh`, so neither is subject to this invariant. +const BATCH_SCRIPT_INSTALLERS = [ + { agent: 'antigravity', install: () => new AntigravityHookService().install() }, + { agent: 'claude', install: () => new ClaudeHookService().install() }, + { agent: 'openclaude', install: () => openClaudeHookService.install() }, + { agent: 'codex', install: () => new CodexHookService().install() }, + { agent: 'command-code', install: () => new CommandCodeHookService().install() }, + { agent: 'cursor', install: () => new CursorHookService().install() }, + { agent: 'devin', install: () => new DevinHookService().install() }, + { agent: 'droid', install: () => new DroidHookService().install() }, + { agent: 'gemini', install: () => new GeminiHookService().install() }, + { agent: 'grok', install: () => new GrokHookService().install() } +] as const + +function withPlatform(platform: NodeJS.Platform, run: () => T): T { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) + try { + return run() + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } +} + +describe('Windows managed hook post interpreter', () => { + let home = '' + + beforeEach(() => { + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + isolatedUserDataDir = mkdtempSync(join(tmpdir(), 'orca-hook-interpreter-user-data-')) + // Why: Orca-managed Codex hooks resolve through ORCA_USER_DATA_PATH before the mocked + // home; an inherited live path would let this test rewrite the developer's own hooks. + process.env.ORCA_USER_DATA_PATH = isolatedUserDataDir + home = mkdtempSync(join(tmpdir(), 'orca-hook-interpreter-')) + homedirMock.mockReturnValue(home) + }) + + afterEach(() => { + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + rmSync(isolatedUserDataDir, { recursive: true, force: true }) + homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir()) + rmSync(home, { recursive: true, force: true }) + home = '' + }) + + it('posts through curl.exe from every managed batch script, spawning no interpreter', () => { + const scripts = withPlatform('win32', () => { + for (const entry of BATCH_SCRIPT_INSTALLERS) { + expect(entry.install().state, `${entry.agent} install status`).toBe('installed') + } + const hooksDir = join(home, '.orca', 'agent-hooks') + return readdirSync(hooksDir) + .filter((name) => name.endsWith('.cmd')) + .map((name) => ({ name, body: readFileSync(join(hooksDir, name), 'utf8') })) + }) + + // Why: a silent zero would make this suite pass while asserting nothing. + expect(scripts.length).toBeGreaterThanOrEqual(BATCH_SCRIPT_INSTALLERS.length) + + // Why: assert this across every .cmd before narrowing. Filtering to posting scripts first + // hides the failure that matters — a script that swapped its curl POST for an interpreter + // no longer matches the filter, so the suite fails on an opaque count instead. + for (const script of scripts) { + // Why: PowerShell costs ~300ms of interpreter startup per event and recodes the UTF-8 + // payload through the console code page. curl.exe (Win10 1803+) does neither. + expect(script.body, `${script.name} must not spawn an interpreter`).not.toMatch( + /powershell(\.exe)?/i + ) + } + + // Why: `%~dp0` marks an event wrapper that only sets env and delegates to the core script. + const isWrapper = (body: string): boolean => body.includes('%~dp0') + const posts = (body: string): boolean => body.includes('127.0.0.1:%ORCA_AGENT_HOOK_PORT%') + + // Why: name the script that stopped posting rather than failing on a bare count. + expect( + scripts.filter((s) => !isWrapper(s.body) && !posts(s.body)).map((s) => s.name), + 'every non-wrapper script must post to the hook port' + ).toEqual([]) + + const posting = scripts.filter((script) => posts(script.body)) + expect(posting.length).toBeGreaterThanOrEqual(BATCH_SCRIPT_INSTALLERS.length) + + for (const script of posting) { + // Why: fully qualified so a repo-local curl.exe cannot intercept hook payloads. + expect(script.body, `${script.name} must post through curl.exe`).toContain( + '"%SystemRoot%\\System32\\curl.exe"' + ) + } + + // Why: hook events pipe via stdin to keep multi-KB tool output off the command line (EDR + // oversized-command-line rules). The statusline script stages a temp file instead. + for (const script of posting.filter((s) => s.body.includes('/hook/'))) { + expect(script.body, `${script.name} must pipe the payload via stdin`).toContain( + '--data-urlencode "payload@-"' + ) + } + }) +}) diff --git a/src/main/antigravity/hook-script.ts b/src/main/antigravity/hook-script.ts index 724bffbcd86..0645416c005 100644 --- a/src/main/antigravity/hook-script.ts +++ b/src/main/antigravity/hook-script.ts @@ -4,13 +4,23 @@ import { buildWindowsHookStdinDrainEpilogue, WINDOWS_HOOK_STDIN_DRAIN_COMMAND } from '../agent-hooks/hook-stdin-contract' +import { buildWindowsAgentHookPostCommand } from '../agent-hooks/installer-utils' import { ANTIGRAVITY_PRE_TOOL_USE_DECISION } from './hook-events' +// Why (#15117): PowerShell cost ~300ms of startup per event, which is what made the console +// the agent allocates for each hook last long enough to see. +const WINDOWS_ANTIGRAVITY_HOOK_POST_COMMAND = buildWindowsAgentHookPostCommand('antigravity', [ + // Why: Antigravity alone takes its event name from the wrapper's env, not the piped payload. + ' --data-urlencode "hook_event_name=%ORCA_ANTIGRAVITY_EVENT%" ^' +]) + export function getManagedScript(target: 'local' | 'posix' = 'local'): string { if (target === 'local' && process.platform === 'win32') { return [ '@echo off', - 'setlocal', + // Why (#9358/#9941): inherited delayed expansion eats `!` out of the percent-expanded + // curl args, mangling paneKey and dropping worktreeId. `!` is legal in a Windows path. + 'setlocal DisableDelayedExpansion', 'if /I "%ORCA_ANTIGRAVITY_EVENT%"=="Stop" (', ' echo {"decision":""}', ') else if /I "%ORCA_ANTIGRAVITY_EVENT%"=="PreToolUse" (', @@ -20,7 +30,7 @@ export function getManagedScript(target: 'local' | 'posix' = 'local'): string { ')', 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', ...buildWindowsHookEnvironmentGuardLines(), - buildWindowsAntigravityHookPostCommand(), + WINDOWS_ANTIGRAVITY_HOOK_POST_COMMAND, 'exit /b 0', ...buildWindowsHookStdinDrainEpilogue(), '' @@ -96,10 +106,3 @@ export function getWindowsWrapperScript(eventName: string): string { '' ].join('\r\n') } - -function buildWindowsAntigravityHookPostCommand(): string { - // Why: Antigravity hooks are best-effort status updates; do not let a stalled - // local listener hold the agent process open. Qualify PowerShell so a - // worktree-local powershell.exe cannot hijack hook payloads. - return `"%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -NoProfile -ExecutionPolicy Bypass -Command "$utf8=[System.Text.UTF8Encoding]::new($false); [Console]::InputEncoding=$utf8; [Console]::OutputEncoding=$utf8; $inputData=[Console]::In.ReadToEnd(); try { $payload=if ([string]::IsNullOrWhiteSpace($inputData)) { @{} } else { $inputData | ConvertFrom-Json }; $body=@{ paneKey=$env:ORCA_PANE_KEY; launchToken=$env:ORCA_AGENT_LAUNCH_TOKEN; tabId=$env:ORCA_TAB_ID; worktreeId=$env:ORCA_WORKTREE_ID; env=$env:ORCA_AGENT_HOOK_ENV; version=$env:ORCA_AGENT_HOOK_VERSION; hook_event_name=$env:ORCA_ANTIGRAVITY_EVENT; payload=$payload } | ConvertTo-Json -Depth 100 -Compress; $bodyBytes=$utf8.GetBytes($body); Invoke-WebRequest -UseBasicParsing -Method Post -Uri ('http://127.0.0.1:' + $env:ORCA_AGENT_HOOK_PORT + '/hook/antigravity') -ContentType 'application/json; charset=utf-8' -Headers @{ 'X-Orca-Agent-Hook-Token'=$env:ORCA_AGENT_HOOK_TOKEN } -Body $bodyBytes -TimeoutSec 2 | Out-Null } catch {}"` -} diff --git a/src/main/antigravity/hook-service.test.ts b/src/main/antigravity/hook-service.test.ts index e758f348a4b..b9f69b03cc1 100644 --- a/src/main/antigravity/hook-service.test.ts +++ b/src/main/antigravity/hook-service.test.ts @@ -98,10 +98,12 @@ describe('AntigravityHookService', () => { ) expect(script).toContain('/hook/antigravity') if (process.platform === 'win32') { - expect(script).toContain('%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe') - expect(script).toContain('hook_event_name=$env:ORCA_ANTIGRAVITY_EVENT') - expect(script).toContain('[string]::IsNullOrWhiteSpace($inputData)) { @{} }') - expect(script).not.toContain('[string]::IsNullOrWhiteSpace($inputData)) { exit 0 }') + expect(script).not.toContain('powershell.exe') + expect(script).toContain('%SystemRoot%\\System32\\curl.exe') + expect(script).toContain('hook_event_name=%ORCA_ANTIGRAVITY_EVENT%') + expect(script).toContain('--data-urlencode "payload@-"') + // Why (#9358/#9941): delayed expansion eats `!` out of percent-expanded curl args. + expect(script).toContain('setlocal DisableDelayedExpansion') } else { expect(script).toContain('hook_event_name=${ORCA_ANTIGRAVITY_EVENT}') expect(script).toContain(`payload=$(${POSIX_HOOK_STDIN_READER})`) @@ -275,10 +277,10 @@ describe('AntigravityHookService', () => { 'utf8' ) expect(script).toContain('/hook/antigravity') - expect(script).toContain('%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe') - expect(script).toContain('hook_event_name=$env:ORCA_ANTIGRAVITY_EVENT') - expect(script).toContain('[string]::IsNullOrWhiteSpace($inputData)) { @{} }') - expect(script).not.toContain('[string]::IsNullOrWhiteSpace($inputData)) { exit 0 }') + expect(script).not.toContain('powershell.exe') + expect(script).toContain('%SystemRoot%\\System32\\curl.exe') + expect(script).toContain('hook_event_name=%ORCA_ANTIGRAVITY_EVENT%') + expect(script).toContain('setlocal DisableDelayedExpansion') }) }) diff --git a/src/main/antigravity/windows-hook-payload-delivery.test.ts b/src/main/antigravity/windows-hook-payload-delivery.test.ts new file mode 100644 index 00000000000..8261cc3ce91 --- /dev/null +++ b/src/main/antigravity/windows-hook-payload-delivery.test.ts @@ -0,0 +1,287 @@ +// Why (#15117): shape assertions cannot catch a curl line that posts nothing, so this suite +// pipes a real payload through the installed wrappers and follows it to a live listener. +import { afterEach, describe, expect, it, vi } from 'vitest' +import { spawn } from 'node:child_process' +import { createServer, type Server } from 'node:http' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type * as osModule from 'node:os' + +const { homedirMock } = vi.hoisted(() => ({ + homedirMock: vi.fn<() => string>() +})) + +vi.mock('electron', () => ({ + app: { + getPath: () => '/tmp/orca-user-data' + } +})) + +vi.mock('os', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + homedir: homedirMock.mockImplementation(actual.homedir) + } +}) + +import { AntigravityHookService } from './hook-service' +import { ANTIGRAVITY_EVENTS, ANTIGRAVITY_PRE_TOOL_USE_DECISION } from './hook-events' +import { getManagedScript } from './hook-script' + +// Why (#9358/#9941): `!` is legal in a Windows path and in a pane key. Under inherited +// delayed expansion cmd eats it out of a percent-expanded curl argument, so bake one into +// every value the script forwards rather than asserting the `setlocal` line alone. +const PANE_KEY = 'tab-1:leaf-1!bang' +const WORKTREE_ID = 'repo-1::C:\\Users\\alice\\orca\\feature!branch' +const HOOK_TOKEN = 'antigravity-payload-delivery-token' +// Why: exercise the sizes and bytes a real hook carries — a multi-KB body crosses the pipe +// in several chunks, and non-ASCII catches a launcher that recodes stdin through a code page. +const PAYLOAD = JSON.stringify({ + session_id: 'a3f9c1e0-antigravity-delivery', + tool_name: 'Bash', + tool_input: { command: 'echo "café 日本語 😀" & echo %PATH% | more' }, + transcript: 'y'.repeat(4096) +}) + +type HookPost = { + payload: string | null + paneKey: string | null + worktreeId: string | null + hookEventName: string | null + token: string | null + contentType: string | null +} + +async function startHookListener(): Promise<{ + server: Server + port: number + posts: HookPost[] +}> { + const posts: HookPost[] = [] + const server = createServer((req, res) => { + let body = '' + req.setEncoding('utf8') + req.on('data', (chunk) => { + body += chunk + }) + req.on('end', () => { + const form = new URLSearchParams(body) + posts.push({ + payload: form.get('payload'), + paneKey: form.get('paneKey'), + worktreeId: form.get('worktreeId'), + hookEventName: form.get('hook_event_name'), + token: (req.headers['x-orca-agent-hook-token'] as string | undefined) ?? null, + contentType: (req.headers['content-type'] as string | undefined) ?? null + }) + res.writeHead(200, { 'content-type': 'application/json' }) + res.end('{}') + }) + }) + const port = await new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const address = server.address() + resolve(typeof address === 'object' && address ? address.port : 0) + }) + }) + return { server, port, posts } +} + +type HookRun = { exitCode: number | null; stdout: string; stderr: string; timedOut: boolean } + +function runWrapper( + wrapperPath: string, + env: NodeJS.ProcessEnv, + // Why: `null` abandons stdin instead of closing it — the shape a caller outside an Orca + // pane produces, and the only way to prove the env guard exits before reading (#11549). + stdinPayload: string | null = PAYLOAD +): Promise { + return new Promise((resolve, reject) => { + // Why: mirror how Antigravity spawns the hook — `cmd /c `, the exact + // chain in the bug report's process trace. + const child = spawn('cmd.exe', ['/d', '/c', wrapperPath], { + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + env + }) + let stdout = '' + let stderr = '' + let timedOut = false + const timer = setTimeout(() => { + timedOut = true + child.kill('SIGKILL') + }, 15_000) + child.on('error', (error) => { + clearTimeout(timer) + reject(error) + }) + child.stdout.on('data', (chunk: Buffer) => { + stdout += chunk.toString() + }) + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString() + }) + child.on('close', (exitCode) => { + clearTimeout(timer) + // Why: curl exits once the POST is written; give the listener a beat to finish reading it. + setTimeout(() => resolve({ exitCode, stdout, stderr, timedOut }), 250) + }) + // Why: an abandoned pipe raises EPIPE once the child exits; swallow it so the run still + // resolves on the child's own terms. + child.stdin.on('error', () => {}) + if (stdinPayload !== null) { + child.stdin.end(Buffer.from(stdinPayload, 'utf8')) + } + }) +} + +function hookEnvironment(extra: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const base = Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith('ORCA_')) + ) + return { ...base, ...extra } +} + +function expectedStdout(eventName: string): string { + if (eventName === 'PreToolUse') { + return ANTIGRAVITY_PRE_TOOL_USE_DECISION + } + return eventName === 'Stop' ? '{"decision":""}' : '{}' +} + +// Why: runs on every platform — the live delivery suite below is Windows-only, so this +// keeps a POSIX-only CI leg from letting the interpreter back into the hot path. +describe('Antigravity Windows hook post command', () => { + it('posts through curl.exe rather than a PowerShell interpreter', () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + const script = getManagedScript('local') + + expect(script).not.toMatch(/powershell/i) + expect(script).toContain('"%SystemRoot%\\System32\\curl.exe" -sS -X POST') + expect(script).toContain('http://127.0.0.1:%ORCA_AGENT_HOOK_PORT%/hook/antigravity') + expect(script).toContain('--data-urlencode "hook_event_name=%ORCA_ANTIGRAVITY_EVENT%"') + // Why: keep the payload off the command line so multi-KB tool output cannot trip an + // EDR oversized-command-line rule. + expect(script).toContain('--data-urlencode "payload@-"') + expect(script).toContain('setlocal DisableDelayedExpansion') + vi.restoreAllMocks() + }) +}) + +describe.skipIf(process.platform !== 'win32')('Antigravity Windows hook payload delivery', () => { + let home = '' + let server: Server | null = null + + afterEach(() => { + server?.close() + server = null + homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir()) + if (home) { + rmSync(home, { recursive: true, force: true }) + home = '' + } + }) + + it('delivers every event wrapper payload to the listener without spawning PowerShell', async () => { + home = mkdtempSync(join(tmpdir(), 'orca-antigravity-hook-')) + homedirMock.mockReturnValue(home) + expect(new AntigravityHookService().install().state).toBe('installed') + + const hooksDir = join(home, '.orca', 'agent-hooks') + const coreScript = readFileSync(join(hooksDir, 'antigravity-hook.cmd'), 'utf8') + expect(coreScript).not.toMatch(/powershell/i) + + const listener = await startHookListener() + server = listener.server + const env = hookEnvironment({ + USERPROFILE: home, + HOME: home, + ORCA_AGENT_HOOK_PORT: String(listener.port), + ORCA_AGENT_HOOK_TOKEN: HOOK_TOKEN, + ORCA_PANE_KEY: PANE_KEY, + ORCA_WORKTREE_ID: WORKTREE_ID + }) + + for (const event of ANTIGRAVITY_EVENTS) { + const label = event.eventName + const before = listener.posts.length + const result = await runWrapper(join(hooksDir, event.windowsWrapperFileName), env) + + expect(result.timedOut, `${label} timed out`).toBe(false) + expect(result.exitCode, `${label} exit code`).toBe(0) + expect(result.stderr, `${label} stderr`).toBe('') + // Why: Antigravity reads silence on PreToolUse as deny (#2426), so the gate answer + // must survive the transport change. + expect(result.stdout.trim(), `${label} stdout`).toBe(expectedStdout(label)) + + const posts = listener.posts.slice(before) + expect(posts, `${label} posted exactly one hook`).toHaveLength(1) + // Why: byte-exact, not "non-empty" — PowerShell recoded this body through the console + // code page, and a silently corrupted payload still looks posted. + expect(posts[0].payload, `${label} payload`).toBe(PAYLOAD) + expect(posts[0].hookEventName, `${label} hook_event_name`).toBe(label) + // Why: the `!` in both values is the delayed-expansion regression guard. + expect(posts[0].paneKey, `${label} paneKey`).toBe(PANE_KEY) + expect(posts[0].worktreeId, `${label} worktreeId`).toBe(WORKTREE_ID) + expect(posts[0].token, `${label} token`).toBe(HOOK_TOKEN) + expect(posts[0].contentType, `${label} content-type`).toContain( + 'application/x-www-form-urlencoded' + ) + } + // Why: five wrapper launches plus a real install can overrun the default under load. + }, 60_000) + + // Why (#15117): Antigravity fires some events with no stdin at all. PowerShell substituted + // `{}` before posting; curl forwards the empty body, so prove the post still happens — the + // listener's matching allowance is covered in agent-hook-listener-antigravity.test.ts. + it('still posts a status event when the agent supplies no payload', async () => { + home = mkdtempSync(join(tmpdir(), 'orca-antigravity-hook-')) + homedirMock.mockReturnValue(home) + expect(new AntigravityHookService().install().state).toBe('installed') + + const listener = await startHookListener() + server = listener.server + const result = await runWrapper( + join(home, '.orca', 'agent-hooks', 'antigravity-pre-invocation.cmd'), + hookEnvironment({ + USERPROFILE: home, + HOME: home, + ORCA_AGENT_HOOK_PORT: String(listener.port), + ORCA_AGENT_HOOK_TOKEN: HOOK_TOKEN, + ORCA_PANE_KEY: PANE_KEY + }), + '' + ) + + expect(result.timedOut).toBe(false) + expect(result.exitCode).toBe(0) + expect(listener.posts).toHaveLength(1) + // Why: curl drops a `--data-urlencode name@-` field entirely when stdin is empty, so the + // event reaches the listener with no `payload` key — not an empty one. + expect(listener.posts[0].payload).toBeNull() + expect(listener.posts[0].hookEventName).toBe('PreInvocation') + }, 30_000) + + it('exits without reading stdin when the pane env is missing', async () => { + home = mkdtempSync(join(tmpdir(), 'orca-antigravity-hook-')) + homedirMock.mockReturnValue(home) + expect(new AntigravityHookService().install().state).toBe('installed') + + const listener = await startHookListener() + server = listener.server + // Why (#11549): outside an Orca pane the caller may abandon stdin rather than close it, + // so the guard must exit before the read — otherwise the console lingers indefinitely. + const result = await runWrapper( + join(home, '.orca', 'agent-hooks', 'antigravity-pre-tool-use.cmd'), + hookEnvironment({ USERPROFILE: home, HOME: home }), + null + ) + + expect(result.timedOut).toBe(false) + expect(result.exitCode).toBe(0) + expect(result.stdout.trim()).toBe(ANTIGRAVITY_PRE_TOOL_USE_DECISION) + expect(listener.posts).toHaveLength(0) + }, 30_000) +}) diff --git a/src/shared/agent-hook-listener-antigravity.test.ts b/src/shared/agent-hook-listener-antigravity.test.ts index da3c813b3b1..9eac0d75593 100644 --- a/src/shared/agent-hook-listener-antigravity.test.ts +++ b/src/shared/agent-hook-listener-antigravity.test.ts @@ -23,6 +23,81 @@ describe('shared agent-hook-listener', () => { vi.unstubAllEnvs() }) + // Why (#15117): the Windows script posts stdin verbatim through curl, so an event that + // fires without a payload arrives as `payload=""` rather than the `{}` the POSIX script + // substitutes. Both transports must still produce the status transition. + it('accepts an Antigravity event whose payload is empty or whitespace-only', () => { + for (const payload of ['', ' ', '\r\n']) { + const started = normalizeHookPayload( + state, + 'antigravity', + { + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt', + hook_event_name: 'PreInvocation', + payload + }, + 'production' + ) + expect(started?.payload, `payload ${JSON.stringify(payload)}`).toMatchObject({ + state: 'working', + agentType: 'antigravity' + }) + } + }) + + // Why (#15117): this is the shape Windows actually produces — curl omits the form field + // entirely when the agent supplies no stdin, so the key is absent rather than empty. + it('accepts an Antigravity event that carries no payload key at all', () => { + const started = normalizeHookPayload( + state, + 'antigravity', + { + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt', + hook_event_name: 'PreInvocation' + }, + 'production' + ) + expect(started?.payload).toMatchObject({ state: 'working', agentType: 'antigravity' }) + }) + + it('still drops an Antigravity event whose payload is malformed rather than empty', () => { + expect( + normalizeHookPayload( + state, + 'antigravity', + { + paneKey: PANE_KEY, + hook_event_name: 'PreInvocation', + payload: '{"prompt":' + }, + 'production' + ) + ).toBeNull() + }) + + // Why: the empty-payload allowance is Antigravity's documented policy, not a global one — + // every other source must keep rejecting a body it cannot parse. + it('does not extend the empty-payload allowance to other sources', () => { + for (const payload of ['', undefined]) { + expect( + normalizeHookPayload( + state, + 'gemini', + { + paneKey: PANE_KEY, + hook_event_name: 'BeforeAgent', + ...(payload === undefined ? {} : { payload }) + }, + 'production' + ) + ).toBeNull() + } + }) + it('normalizes Antigravity invocation and tool hooks', () => { const started = normalizeHookPayload( state, diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index da80e171841..e6a7be4e2df 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -4366,8 +4366,16 @@ export function normalizeHookPayload( const paneKey = typeof record.paneKey === 'string' ? record.paneKey.trim() : '' const parsedPaneKey = parsePaneKey(paneKey) const rawPayload = record.payload - const hookPayload = - typeof rawPayload === 'string' + // Why (#15117): some Antigravity events fire with no stdin at all, yet still carry a status + // transition in `hook_event_name`. The POSIX script substitutes `{}` before posting; on + // Windows curl omits the form field entirely when stdin is empty, so the event arrives with + // no `payload` key at all. Accept both shapes rather than dropping the transition. + const antigravityPayloadAbsent = + source === 'antigravity' && + (rawPayload === undefined || (typeof rawPayload === 'string' && rawPayload.trim() === '')) + const hookPayload = antigravityPayloadAbsent + ? {} + : typeof rawPayload === 'string' ? (() => { try { return parseAgentHookJson(rawPayload) From 30bf2647fc29e76ddd03c1853f441e9d2f001282 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:10:39 -0700 Subject: [PATCH 005/111] fix(mobile): replay a delivery-ambiguous worktree.create instead of failing it (#15472) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mobile): replay a delivery-ambiguous worktree.create instead of failing it A socket close or response timeout rejects an in-flight worktree.create as delivery-unknown: the frame reached the wire, so the host may already have built the worktree. The client only replayed connection-migration cutovers, so every other ambiguity surfaced as a create failure for a create that may well have succeeded. Replay on the same clientMutationId — which the host already dedupes — after waiting for the transport to come back. * fix(mobile): bound the ambiguous worktree.create replay by the host's dedupe window The replay was bounded only by a retry count, but what makes a replay reconcile instead of building a second worktree is wall clock: the host drops a settled create's dedupe record 60s after it resolves, and past that the replay is just a fresh create that the host's suffix loop happily duplicates — for a folder workspace, into a second workspace with the very same name and no collision check at all. Two paths ran past that window: - The request-timeout path. A silently dropped response frame leaves the socket alive, so nothing rejects until WORKTREE_CREATE_TIMEOUT_MS — ten minutes, with no bound at all on when the host actually resolved. This was previously the path that replayed *soonest*, short-circuiting the reconnect wait because the transport still looked healthy. Invert it: every path that reports a real drop has already left 'connected' by the time the rejection surfaces, so still being 'connected' identifies the timeout and is now refused. - The reported-drop path. Worst-case detection is a full liveness idle period plus the missed-probe budget before the client even learns the socket is dead, and the old 20s wait on top of that overran the record. Derive the wait from the watchdog constants and the TTL instead of hardcoding it, and anchor a single deadline at the first ambiguity so a second wait gets the remainder rather than restarting. The TTL now has one definition shared by both processes, so the client asserts its budget against the host's real window instead of a copied literal. * fix(mobile): end the reconnect wait on a revoked pairing, and pin the wait's behavior waitForRpcClientReconnected resolves only on 'connected' or the timeout, but an 'auth-failed' client never reaches 'connected' — so a create interrupted by a revoked pairing sat out the full wait before surfacing the error it already had. Treat auth-failed as a terminal answer on both the fast path and the listener. The helper also shipped with no tests of its own: its already-connected fast path, its timeout path, and the synchronous-notification-during-subscribe teardown were only ever exercised indirectly through the retry suite, and neither RpcClient implementation notifies synchronously, so that branch had no coverage at all. Add a direct suite covering all of them, asserting listener and timer teardown rather than just the resolved value. Also give the fake-timer tests an explicit timeout. advanceTimersByTimeAsync yields through real macrotasks between ticks while vitest's own budget runs on real time, so on a loaded runner the default 5s is reachable — observed once as a spurious timeout in this suite. * fix(mobile): bound the ambiguous replay in wall clock, not timer time The replay window was derived from the liveness watchdog's own budget (idle + missed probes x probe timeout). That is a bound on how long the watchdog takes to *fire*, not on how much wall clock passed. iOS and Android suspend JS timers while the app is backgrounded, so across a background cycle the socket dies silently and the pending create rejects delivery-unknown minutes later with the timer-derived ceiling still reading ~44s. The replay then lands well past the host's 60s dedupe record and the suffix loop builds a SECOND worktree - for a folder workspace, one with the very same name and no collision check at all. Anchor the deadline on the watchdog's lastInboundAt instead: a wall-clock stamp of a frame that really arrived, so it stays honest across a suspension. Fall back to the send time when the transport can't vouch for one (relay sessions run with idleProbeMs: null), which errs toward refusing the replay. Also restore the delivery-unknown discrimination test that the still-connected guard had made vacuous, pin the still-connected guard itself against a live inbound stamp, and pin the deadline against being re-read from a fresher replacement session. --- .../src/tasks/worktree-create-retry.test.ts | 476 +++++++++++++++++- mobile/src/tasks/worktree-create-retry.ts | 99 +++- .../src/transport/mobile-relay-rpc-session.ts | 1 + .../rpc-client-reconnect-wait.test.ts | 108 ++++ .../transport/rpc-client-reconnect-wait.ts | 55 ++ mobile/src/transport/rpc-client.ts | 8 + .../rpc-session-liveness-watchdog.ts | 7 + .../transport/stable-logical-rpc-client.ts | 1 + src/main/runtime/orca-runtime.ts | 6 +- .../worktree-create-retry-policy.ts | 7 + 10 files changed, 753 insertions(+), 15 deletions(-) create mode 100644 mobile/src/transport/rpc-client-reconnect-wait.test.ts create mode 100644 mobile/src/transport/rpc-client-reconnect-wait.ts diff --git a/mobile/src/tasks/worktree-create-retry.test.ts b/mobile/src/tasks/worktree-create-retry.test.ts index 35fa050b620..52d6d7b9782 100644 --- a/mobile/src/tasks/worktree-create-retry.test.ts +++ b/mobile/src/tasks/worktree-create-retry.test.ts @@ -1,24 +1,87 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' +import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client' -import { createWorktreeWithNameRetry } from './worktree-create-retry' +import type { ConnectionState } from '../transport/types' +import { WORKTREE_CREATE_DEDUPE_TTL_MS } from '../../../src/shared/new-workspace/worktree-create-retry-policy' +import { + createWorktreeWithNameRetry, + WORKTREE_CREATE_AMBIGUOUS_RECONNECT_WAIT_MS, + WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS +} from './worktree-create-retry' +import { WORKTREE_CREATE_TIMEOUT_MS } from './workspace-create-timeout' type Attempt = { method: string; params: Record } +// Drives the transport state a replay has to wait on. Production couples the two: +// a socket close rejects the pending frame AND drops the client off 'connected'. +function connectionController(): { + getState: () => ConnectionState + onStateChange: (listener: (state: ConnectionState) => void) => () => void + set: (next: ConnectionState) => void +} { + let state: ConnectionState = 'connected' + const listeners = new Set<(next: ConnectionState) => void>() + return { + getState: () => state, + onStateChange: (listener) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + set: (next) => { + state = next + for (const listener of listeners) { + listener(next) + } + } + } +} + +// Lets a parked replay reach its state wait before the test resumes the transport. +async function flush(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)) +} + // A client whose per-call outcome is scripted: return an id, a server error // message, or throw (transport-level rejection, e.g. a connection-migration // cutover). Records every call so tests can assert on the clientMutationId. function scriptedClient( - outcomes: Array<{ id: string } | { errorMessage: string } | { throws: unknown }>, - attempts: Attempt[] + outcomes: Array< + | { id: string } + | { errorMessage: string } + // takesMs models how long the ambiguity took to SURFACE — a clean close is + // instant, a half-open socket waits out the liveness watchdog or the timeout. + // reconnectsAfterMs brings the transport back so the next replay can run. + | { throws: unknown; dropsConnection?: boolean; takesMs?: number; reconnectsAfterMs?: number } + >, + attempts: Attempt[], + connection?: ReturnType, + // Wall-clock stamp of the last frame that really arrived. Absent = a transport that + // can't vouch for one, which must fall back to the send; a function models a live + // socket whose stamp keeps advancing. + lastInboundAt?: number | (() => number) ): RpcClient { let call = 0 return { + getState: () => connection?.getState() ?? 'connected', + getLastInboundAt: () => + (typeof lastInboundAt === 'function' ? lastInboundAt() : lastInboundAt) ?? null, + onStateChange: (listener: (state: ConnectionState) => void) => + connection?.onStateChange(listener) ?? (() => {}), sendRequest: async (method: string, params?: unknown) => { attempts.push({ method, params: (params ?? {}) as Record }) const outcome = outcomes[Math.min(call, outcomes.length - 1)]! call += 1 if ('throws' in outcome) { + if (outcome.takesMs !== undefined) { + await new Promise((resolve) => setTimeout(resolve, outcome.takesMs)) + } + if (outcome.dropsConnection) { + connection?.set('reconnecting') + if (outcome.reconnectsAfterMs !== undefined) { + setTimeout(() => connection?.set('connected'), outcome.reconnectsAfterMs) + } + } throw outcome.throws } if ('errorMessage' in outcome) { @@ -117,6 +180,8 @@ describe('createWorktreeWithNameRetry', () => { expect(attempts).toHaveLength(6) }) + // Also the delivery-ambiguity guard: an unmarked error is a DEFINITE failure, so + // neither the cutover nor the replay path may re-issue it. it('does not treat an ordinary transport error as a cutover', async () => { const attempts: Attempt[] = [] const client = scriptedClient([{ throws: new Error('Request timed out') }], attempts) @@ -188,4 +253,407 @@ describe('createWorktreeWithNameRetry', () => { expect(attempts).toHaveLength(1) expect(attempts[0]!.params.clientMutationId).toBeUndefined() }) + + it('replays a delivery-ambiguous socket drop with the SAME key once the transport returns', async () => { + const attempts: Attempt[] = [] + const connection = connectionController() + const client = scriptedClient( + [ + { + throws: markRpcDeliveryUnknown(new Error('Connection interrupted')), + dropsConnection: true + }, + { id: 'wt-drop' } + ], + attempts, + connection + ) + + const pending = createWorktreeWithNameRetry({ + client, + baseName: 'urchin', + buildParams: (name) => ({ repo: 'id:r', name }), + supportsIdempotentCutoverRetry: true, + mintMutationId: () => 'key-drop' + }) + + await flush() + // Parked on the reconnect, not resent onto a dead socket. + expect(attempts).toHaveLength(1) + + connection.set('connected') + await expect(pending).resolves.toEqual({ worktreeId: 'wt-drop', name: 'urchin' }) + expect(attempts).toHaveLength(2) + // Idempotency: the host dedupes the replay against the create it may already + // have finished, instead of building a second worktree. + expect(attempts[1]!.params.clientMutationId).toBe('key-drop') + expect(attempts[1]!.params.name).toBe('urchin') + }) + + it('does not replay a delivery-ambiguous drop when the host lacks idempotency support', async () => { + const attempts: Attempt[] = [] + const client = scriptedClient( + [{ throws: markRpcDeliveryUnknown(new Error('Connection interrupted')) }], + attempts + ) + await expect( + createWorktreeWithNameRetry({ + client, + baseName: 'limpet', + buildParams: (name) => ({ repo: 'id:r', name }), + supportsIdempotentCutoverRetry: false, + mintMutationId: () => 'must-not-be-used' + }) + ).rejects.toThrow('Connection interrupted') + expect(attempts).toHaveLength(1) + expect(attempts[0]!.params.clientMutationId).toBeUndefined() + }) + + it('gives up after the delivery-ambiguity replay budget, still inside the dedupe TTL', async () => { + vi.useFakeTimers() + try { + const attempts: Attempt[] = [] + const connection = connectionController() + const client = scriptedClient( + [ + { + throws: markRpcDeliveryUnknown(new Error('Connection interrupted')), + dropsConnection: true, + reconnectsAfterMs: 1_000 + } + ], + attempts, + connection + ) + const startedAt = Date.now() + let settledAt = -1 + const pending = createWorktreeWithNameRetry({ + client, + baseName: 'barnacle', + buildParams: (name) => ({ repo: 'id:r', name }), + supportsIdempotentCutoverRetry: true, + mintMutationId: () => 'key-budget' + }).catch((error: unknown) => { + settledAt = Date.now() + throw error + }) + const settled = expect(pending).rejects.toThrow('Connection interrupted') + await vi.advanceTimersByTimeAsync(WORKTREE_CREATE_DEDUPE_TTL_MS) + await settled + // Initial attempt + 2 replays. + expect(attempts).toHaveLength(3) + // The budget is a count, but what keeps a replay reconciling instead of building + // a second worktree is wall clock: every attempt has to land while the host still + // holds the record, with the watchdog's detection latency already deducted. + expect(settledAt - startedAt).toBeLessThanOrEqual(WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS) + } finally { + vi.useRealTimers() + } + // Generous: advanceTimersByTimeAsync yields through REAL macrotasks between ticks, + // so vitest's default 5s real-time budget is reachable on a loaded runner. + }, 30_000) + + it('surfaces the original ambiguity when the transport never comes back', async () => { + vi.useFakeTimers() + try { + const attempts: Attempt[] = [] + const connection = connectionController() + const client = scriptedClient( + [ + { + throws: markRpcDeliveryUnknown(new Error('Connection interrupted')), + dropsConnection: true + } + ], + attempts, + connection + ) + + const pending = createWorktreeWithNameRetry({ + client, + baseName: 'anemone', + buildParams: (name) => ({ repo: 'id:r', name }), + supportsIdempotentCutoverRetry: true, + mintMutationId: () => 'key-stuck' + }) + const settled = expect(pending).rejects.toThrow('Connection interrupted') + // A phone that never reconnects must not leave the Create spinner parked. + await vi.advanceTimersByTimeAsync(60_000) + await settled + expect(attempts).toHaveLength(1) + } finally { + vi.useRealTimers() + } + // Generous: advanceTimersByTimeAsync yields through REAL macrotasks between ticks, + // so vitest's default 5s real-time budget is reachable on a loaded runner. + }, 30_000) + + it('does not replay an ambiguity that surfaced while the transport stayed connected', async () => { + vi.useFakeTimers() + try { + const attempts: Attempt[] = [] + const connection = connectionController() + const client = scriptedClient( + [ + { + // A silently dropped response frame leaves the socket alive, so nothing + // rejects until the request timeout — by then the host resolved at an + // unknowable point and its dedupe record is very likely gone. + throws: markRpcDeliveryUnknown(new Error('Request timed out: worktree.create')), + takesMs: WORKTREE_CREATE_TIMEOUT_MS + }, + { id: 'wt-duplicate' } + ], + attempts, + connection, + // Watchdog probes keep answering throughout, so the replay window looks wide + // open on arrival: only the still-connected guard can stop this replay. + () => Date.now() + ) + + const pending = createWorktreeWithNameRetry({ + client, + baseName: 'cuttlefish', + buildParams: (name) => ({ repo: 'id:r', name }), + supportsIdempotentCutoverRetry: true, + mintMutationId: () => 'key-expired' + }) + const settled = expect(pending).rejects.toThrow('Request timed out') + await vi.advanceTimersByTimeAsync(WORKTREE_CREATE_TIMEOUT_MS) + await settled + // Never left 'connected', so no drop was ever detected and the staleness of the + // ambiguity is unbounded. Re-sending would miss the record and build a SECOND + // worktree — for a folder workspace, one with the very same name. + expect(connection.getState()).toBe('connected') + expect(attempts).toHaveLength(1) + } finally { + vi.useRealTimers() + } + // Generous: advanceTimersByTimeAsync yields through REAL macrotasks between ticks, + // so vitest's default 5s real-time budget is reachable on a loaded runner. + }, 30_000) + + it('clamps a later reconnect wait to what is left of the replay window', async () => { + vi.useFakeTimers() + try { + const attempts: Attempt[] = [] + const connection = connectionController() + const client = scriptedClient( + [ + { + // Most of the window is already gone by the time the drop is reported, so the + // remainder — not the full wait — is what the second wait gets. + throws: markRpcDeliveryUnknown(new Error('Connection interrupted')), + dropsConnection: true, + takesMs: 30_000, + reconnectsAfterMs: 1_000 + }, + { + // Second drop, and this time the phone never comes back. + throws: markRpcDeliveryUnknown(new Error('Connection interrupted')), + dropsConnection: true + } + ], + attempts, + connection + ) + + const startedAt = Date.now() + let settledAt = -1 + const pending = createWorktreeWithNameRetry({ + client, + baseName: 'nautilus', + buildParams: (name) => ({ repo: 'id:r', name }), + supportsIdempotentCutoverRetry: true, + mintMutationId: () => 'key-clamped' + }).catch((error: unknown) => { + settledAt = Date.now() + throw error + }) + const settled = expect(pending).rejects.toThrow('Connection interrupted') + await vi.advanceTimersByTimeAsync(WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS) + await settled + expect(attempts).toHaveLength(2) + // The window is anchored at the FIRST ambiguity, so the second wait gets only the + // remainder — it must not restart a full wait and carry the replay past the record. + expect(settledAt - startedAt).toBe(WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS) + } finally { + vi.useRealTimers() + } + // Generous: advanceTimersByTimeAsync yields through REAL macrotasks between ticks, + // so vitest's default 5s real-time budget is reachable on a loaded runner. + }, 30_000) + + it('refuses a replay when the ambiguity surfaced long after the last inbound frame', async () => { + vi.useFakeTimers() + try { + const attempts: Attempt[] = [] + const connection = connectionController() + const startedAt = Date.now() + const client = scriptedClient( + [ + { + throws: markRpcDeliveryUnknown(new Error('Connection interrupted')), + dropsConnection: true, + reconnectsAfterMs: 1_000, + // The phone was backgrounded: the OS suspended JS and killed the socket, and + // nothing observed the drop until the app came back ten minutes later. + takesMs: 600_000 + } + ], + attempts, + connection, + startedAt + 2_000 + ) + const pending = createWorktreeWithNameRetry({ + client, + baseName: 'limpet', + buildParams: (name) => ({ repo: 'id:r', name }), + supportsIdempotentCutoverRetry: true, + mintMutationId: () => 'key-stale' + }) + const settled = expect(pending).rejects.toThrow('Connection interrupted') + await vi.advanceTimersByTimeAsync(700_000) + await settled + // The host forgot this create ~9 minutes ago. A replay would not reconcile — it + // would build a second worktree — so the create fails instead. + expect(attempts).toHaveLength(1) + } finally { + vi.useRealTimers() + } + // Generous: advanceTimersByTimeAsync yields through REAL macrotasks between ticks, + // so vitest's default 5s real-time budget is reachable on a loaded runner. + }, 30_000) + + it('still replays a long-running create whose socket stayed live until it dropped', async () => { + vi.useFakeTimers() + try { + const attempts: Attempt[] = [] + const connection = connectionController() + const startedAt = Date.now() + const client = scriptedClient( + [ + { + // Three minutes of clone/fetch with the socket answering probes throughout, + // then it drops and the watchdog reports it four seconds later. + throws: markRpcDeliveryUnknown(new Error('Connection interrupted')), + dropsConnection: true, + reconnectsAfterMs: 1_000, + takesMs: 184_000 + }, + { id: 'wt-long' } + ], + attempts, + connection, + startedAt + 180_000 + ) + const pending = createWorktreeWithNameRetry({ + client, + baseName: 'kelp', + buildParams: (name) => ({ repo: 'id:r', name }), + supportsIdempotentCutoverRetry: true, + mintMutationId: () => 'key-long' + }) + await vi.advanceTimersByTimeAsync(250_000) + // Anchoring at the send would refuse this — the create outran the TTL long before it + // went ambiguous — but the record only starts ticking when the host RESOLVES, and the + // live socket proves our knowledge was current. + expect(await pending).toEqual({ worktreeId: 'wt-long', name: 'kelp' }) + expect(attempts).toHaveLength(2) + expect(attempts[0]!.params.clientMutationId).toBe('key-long') + expect(attempts[1]!.params.clientMutationId).toBe('key-long') + } finally { + vi.useRealTimers() + } + // Generous: advanceTimersByTimeAsync yields through REAL macrotasks between ticks, + // so vitest's default 5s real-time budget is reachable on a loaded runner. + }, 30_000) + + it('does not extend the replay window when the replacement session reports fresher inbound activity', async () => { + vi.useFakeTimers() + try { + const attempts: Attempt[] = [] + const connection = connectionController() + const startedAt = Date.now() + // The replacement session starts answering probes, so its inbound stamp is far + // newer than anything that bears on when the ORIGINAL create resolved host-side. + let lastInboundAt = startedAt + 1_000 + setTimeout(() => { + lastInboundAt = startedAt + 45_000 + }, 45_000) + const client = scriptedClient( + [ + { + throws: markRpcDeliveryUnknown(new Error('Connection lost')), + dropsConnection: true, + takesMs: 40_000, + reconnectsAfterMs: 1_000 + }, + { + throws: markRpcDeliveryUnknown(new Error('Connection lost')), + dropsConnection: true, + takesMs: 20_000, + reconnectsAfterMs: 1_000 + }, + { id: 'wt-third' } + ], + attempts, + connection, + () => lastInboundAt + ) + + const pending = createWorktreeWithNameRetry({ + client, + baseName: 'urchin', + buildParams: (name) => ({ repo: 'id:r', name }), + supportsIdempotentCutoverRetry: true, + mintMutationId: () => 'key-anchored' + }) + const settled = expect(pending).rejects.toThrow('Connection lost') + await vi.advanceTimersByTimeAsync(120_000) + await settled + // The deadline is fixed at the FIRST ambiguity. Re-reading it here would buy another + // 50s of window off a stamp that says nothing about the original request. + expect(attempts).toHaveLength(2) + } finally { + vi.useRealTimers() + } + // Generous: advanceTimersByTimeAsync yields through REAL macrotasks between ticks, + // so vitest's default 5s real-time budget is reachable on a loaded runner. + }, 30_000) + + it('does not replay a dropped transport error that was never marked delivery-unknown', async () => { + const attempts: Attempt[] = [] + const connection = connectionController() + const client = scriptedClient( + // Unmarked: the frame is known NOT to have reached the wire, so the host never saw + // it and a replay would be a second create, not a reconciliation. The transport + // still leaves 'connected', so only the delivery-unknown check stops this. + [{ throws: new Error('Socket closed before send'), dropsConnection: true }], + attempts, + connection + ) + await expect( + createWorktreeWithNameRetry({ + client, + baseName: 'urchin', + buildParams: (name) => ({ repo: 'id:r', name }), + supportsIdempotentCutoverRetry: true, + mintMutationId: () => 'key-unmarked' + }) + ).rejects.toThrow('Socket closed before send') + expect(attempts).toHaveLength(1) + }) + + it('keeps the replay window strictly inside the host dedupe TTL', () => { + // The window is measured from a lower bound on when the host could have resolved, so + // it has to leave the record room for the replay to still be in flight. Widening it + // to the whole TTL would let the last replay land on a record that just expired. + expect(WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS).toBeGreaterThan(0) + expect(WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS).toBeLessThan(WORKTREE_CREATE_DEDUPE_TTL_MS) + // A single wait must not be able to consume the window on its own. + expect(WORKTREE_CREATE_AMBIGUOUS_RECONNECT_WAIT_MS).toBeLessThan( + WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS + ) + }) }) diff --git a/mobile/src/tasks/worktree-create-retry.ts b/mobile/src/tasks/worktree-create-retry.ts index 6cee659b89f..7bacc95aa72 100644 --- a/mobile/src/tasks/worktree-create-retry.ts +++ b/mobile/src/tasks/worktree-create-retry.ts @@ -1,11 +1,14 @@ import type { RpcClient } from '../transport/rpc-client' import type { RpcResponse, RpcSuccess } from '../transport/types' +import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' +import { waitForRpcClientReconnected } from '../transport/rpc-client-reconnect-wait' import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client' import { CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS, getClientWorktreeCreateCandidate, getGeneratedWorktreeCreateRetryCandidate, - isRetryableWorktreeCreateConflict + isRetryableWorktreeCreateConflict, + WORKTREE_CREATE_DEDUPE_TTL_MS } from '../../../src/shared/new-workspace/worktree-create-retry-policy' import { WORKTREE_CREATE_TIMEOUT_MS } from './workspace-create-timeout' @@ -24,6 +27,29 @@ export type WorktreeCreateResult = { worktreeId: string; name: string } | { erro // worktree silently created. const WORKTREE_CREATE_CUTOVER_MAX_RETRIES = 5 +// Why: a connection migration is not the only way a create goes delivery-ambiguous. +// A plain socket close (cellular flap, relay drop, the phone backgrounding and the +// supervisor suspending a billed relay splice) rejects the in-flight frame as +// delivery-unknown with no generation bump, and create holds the longest budget of +// any mobile RPC — 10 minutes of clone/fetch/setup — so it is the likeliest request +// to be caught by one. Surfacing that as a failure is wrong: the host may well have +// finished the worktree. Replay on the same clientMutationId instead. +const WORKTREE_CREATE_AMBIGUOUS_MAX_RETRIES = 2 + +// Why: the host keeps a settled create's dedupe record for WORKTREE_CREATE_DEDUPE_TTL_MS +// after it resolves; a replay that lands later misses it and the host's suffix loop +// builds a SECOND worktree — and for folder workspaces a second one with the SAME name +// and no collision check at all. So a replay is bounded by how stale our knowledge of the +// host is, and that has to be measured in WALL CLOCK: iOS/Android suspend JS timers while +// the app is backgrounded, so any ceiling derived from timer intervals — a watchdog probe +// budget, a request timeout — can be arbitrarily wrong across a background cycle, which is +// exactly when a create sits ambiguous for minutes. +const WORKTREE_CREATE_REPLAY_FLIGHT_MARGIN_MS = 10_000 +export const WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS = + WORKTREE_CREATE_DEDUPE_TTL_MS - WORKTREE_CREATE_REPLAY_FLIGHT_MARGIN_MS +// Bounded so the Create spinner doesn't sit for the whole window; the deadline still caps it. +export const WORKTREE_CREATE_AMBIGUOUS_RECONNECT_WAIT_MS = 20_000 + export type CreateWorktreeWithNameRetryArgs = { client: RpcClient baseName: string @@ -78,32 +104,87 @@ export async function createWorktreeWithNameRetry( return { error: lastError ?? 'Failed to create workspace' } } -// Sends worktree.create, re-issuing on a connection-migration cutover only. The -// shared clientMutationId in `params` keeps the retry idempotent host-side. +// Sends worktree.create, re-issuing whenever the request went delivery-ambiguous — +// the frame reached the wire but no response came back, so the host may already have +// built the worktree. The shared clientMutationId in `params` keeps the retry +// idempotent host-side. A definite failure (never sent, or a server error response) +// is returned to the caller untouched. async function sendWorktreeCreateResilient( client: RpcClient, params: Record, supportsIdempotentCutoverRetry: boolean ): Promise { - for (let migrationRetry = 0; ; migrationRetry += 1) { + let migrationRetry = 0 + let ambiguousRetry = 0 + const firstSentAt = Date.now() + let replayDeadlineAt: number | null = null + for (;;) { try { return await client.sendRequest('worktree.create', params, { timeoutMs: WORKTREE_CREATE_TIMEOUT_MS }) } catch (error) { + if (!supportsIdempotentCutoverRetry) { + throw error + } + if (isLogicalClientCutoverError(error)) { + if (migrationRetry >= WORKTREE_CREATE_CUTOVER_MAX_RETRIES) { + throw error + } + migrationRetry += 1 + // Why: LogicalClientCutoverError is raised only after migrateTo installs an + // authenticated replacement, so retry immediately instead of adding UI lag. + continue + } + if (!isRpcDeliveryUnknown(error) || ambiguousRetry >= WORKTREE_CREATE_AMBIGUOUS_MAX_RETRIES) { + throw error + } + // Why: every transport path that reports a *drop* leaves 'connected' before the + // rejection reaches us (rpc-client.ts:675/695/1213 set state first or reject via + // queueMicrotask; the relay's fail() publishes synchronously). So still being + // 'connected' here means the socket was healthy the whole time and only the + // response went missing — the request-timeout path, which surfaces after + // WORKTREE_CREATE_TIMEOUT_MS. That says nothing about when the host actually + // resolved, so the dedupe record may be long gone and a replay would build a + // second worktree instead of reconciling. Fail the create instead. + if (client.getState() === 'connected') { + throw error + } + // Computed once: a later ambiguity reads a fresher lastInboundAt from the + // replacement session, which would push the deadline past the record it respects. + replayDeadlineAt ??= resolveReplayDeadline(client, firstSentAt) + const remainingWindowMs = replayDeadlineAt - Date.now() + if (remainingWindowMs <= 0) { + throw error + } + ambiguousRetry += 1 + // Why: unlike a cutover, no replacement session exists yet — resending now + // would just hit the dead one, so wait for the transport to come back and + // surface the original ambiguity if it does not. Clamped to the window so the + // wait itself cannot carry the replay past the host's record. if ( - !supportsIdempotentCutoverRetry || - !isLogicalClientCutoverError(error) || - migrationRetry >= WORKTREE_CREATE_CUTOVER_MAX_RETRIES + !(await waitForRpcClientReconnected( + client, + Math.min(WORKTREE_CREATE_AMBIGUOUS_RECONNECT_WAIT_MS, remainingWindowMs) + )) ) { throw error } - // Why: LogicalClientCutoverError is raised only after migrateTo installs an - // authenticated replacement, so retry immediately instead of adding UI lag. } } } +// Latest instant the host's dedupe record is still guaranteed to exist, from the earliest +// point the create could have resolved. lastInboundAt stamps a frame that really arrived, +// so it stays honest across a suspension in a way a timer budget cannot; the send is the +// fallback, and a sound floor either way, because the host cannot resolve a create it has +// not yet received. +function resolveReplayDeadline(client: RpcClient, firstSentAt: number): number { + const lastInboundAt = client.getLastInboundAt?.() ?? null + const anchor = lastInboundAt !== null && lastInboundAt > firstSentAt ? lastInboundAt : firstSentAt + return anchor + WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS +} + function defaultWorktreeCreateMutationId(): string { const randomPart = Math.random().toString(36).slice(2, 10) return `worktree-create:${Date.now().toString(36)}:${randomPart}` diff --git a/mobile/src/transport/mobile-relay-rpc-session.ts b/mobile/src/transport/mobile-relay-rpc-session.ts index 753f6f8fe26..84b1e7cdbeb 100644 --- a/mobile/src/transport/mobile-relay-rpc-session.ts +++ b/mobile/src/transport/mobile-relay-rpc-session.ts @@ -111,6 +111,7 @@ export function connectMobileRelayRpcSession(args: { getState: () => state, getReconnectAttempt: () => 0, getLastConnectedAt: () => lastConnectedAt, + getLastInboundAt: () => livenessWatchdog.getLastInboundAt() || null, onStateChange(listener) { stateListeners.add(listener) return () => stateListeners.delete(listener) diff --git a/mobile/src/transport/rpc-client-reconnect-wait.test.ts b/mobile/src/transport/rpc-client-reconnect-wait.test.ts new file mode 100644 index 00000000000..db87817f821 --- /dev/null +++ b/mobile/src/transport/rpc-client-reconnect-wait.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcClient } from './rpc-client' +import type { ConnectionState } from './types' +import { waitForRpcClientReconnected } from './rpc-client-reconnect-wait' + +// notifyDuringSubscribe models a client that fires the listener synchronously from +// onStateChange — the branch that has to survive `unsubscribe` not being assigned yet. +function fakeClient( + initial: ConnectionState, + options: { notifyDuringSubscribe?: ConnectionState } = {} +): { client: RpcClient; set: (next: ConnectionState) => void; listenerCount: () => number } { + let state = initial + const listeners = new Set<(next: ConnectionState) => void>() + const client = { + getState: () => state, + onStateChange: (listener: (next: ConnectionState) => void) => { + listeners.add(listener) + if (options.notifyDuringSubscribe) { + state = options.notifyDuringSubscribe + listener(state) + } + return () => listeners.delete(listener) + } + } as unknown as RpcClient + return { + client, + set: (next) => { + state = next + // Safe to delete during iteration: finish() unsubscribes the current listener. + for (const listener of listeners) { + listener(next) + } + }, + listenerCount: () => listeners.size + } +} + +describe('waitForRpcClientReconnected', () => { + it('resolves immediately when the transport is already connected', async () => { + const { client, listenerCount } = fakeClient('connected') + await expect(waitForRpcClientReconnected(client, 10_000)).resolves.toBe(true) + // No subscription at all on the fast path, so nothing to leak. + expect(listenerCount()).toBe(0) + }) + + it('resolves once the transport comes back, and unsubscribes', async () => { + const { client, set, listenerCount } = fakeClient('reconnecting') + const pending = waitForRpcClientReconnected(client, 10_000) + expect(listenerCount()).toBe(1) + set('connected') + await expect(pending).resolves.toBe(true) + expect(listenerCount()).toBe(0) + }) + + it('gives up at the timeout and unsubscribes', async () => { + vi.useFakeTimers() + try { + const { client, listenerCount } = fakeClient('reconnecting') + const pending = waitForRpcClientReconnected(client, 10_000) + await vi.advanceTimersByTimeAsync(10_000) + await expect(pending).resolves.toBe(false) + expect(listenerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + // Generous: advanceTimersByTimeAsync yields through REAL macrotasks between ticks, + // so vitest's default 5s real-time budget is reachable on a loaded runner. + }, 30_000) + + it('does not wait out the timeout when the pairing is already revoked', async () => { + const { client, listenerCount } = fakeClient('auth-failed') + await expect(waitForRpcClientReconnected(client, 10_000)).resolves.toBe(false) + expect(listenerCount()).toBe(0) + }) + + it('does not wait out the timeout when the pairing is revoked mid-wait', async () => { + vi.useFakeTimers() + try { + const { client, set, listenerCount } = fakeClient('reconnecting') + const pending = waitForRpcClientReconnected(client, 10_000) + set('auth-failed') + await expect(pending).resolves.toBe(false) + expect(listenerCount()).toBe(0) + // Nothing left armed that could fire after the caller moved on. + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + // Generous: advanceTimersByTimeAsync yields through REAL macrotasks between ticks, + // so vitest's default 5s real-time budget is reachable on a loaded runner. + }, 30_000) + + it('tears down cleanly when the state change fires synchronously during subscribe', async () => { + vi.useFakeTimers() + try { + const { client, listenerCount } = fakeClient('reconnecting', { + notifyDuringSubscribe: 'connected' + }) + await expect(waitForRpcClientReconnected(client, 10_000)).resolves.toBe(true) + expect(listenerCount()).toBe(0) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + // Generous: advanceTimersByTimeAsync yields through REAL macrotasks between ticks, + // so vitest's default 5s real-time budget is reachable on a loaded runner. + }, 30_000) +}) diff --git a/mobile/src/transport/rpc-client-reconnect-wait.ts b/mobile/src/transport/rpc-client-reconnect-wait.ts new file mode 100644 index 00000000000..72702eae4e0 --- /dev/null +++ b/mobile/src/transport/rpc-client-reconnect-wait.ts @@ -0,0 +1,55 @@ +import type { RpcClient } from './rpc-client' + +// Why: after a socket drop the direct client parks a send in waitForConnected, but a +// relay session rejects it outright ('relay session not connected') until the supervisor +// migrates a replacement in. A replay that fires the instant the drop lands therefore +// burns its budget on the dead session, so callers that mean to re-issue an idempotent +// request wait for the transport to come back first. +// +// Unlike waitForAuthenticated, intermediate 'disconnected'/'reconnecting' states are the +// expected path here, not a failure. Only a revoked pairing and the timeout end the wait +// unsuccessfully. +export function waitForRpcClientReconnected( + client: RpcClient, + timeoutMs: number +): Promise { + const state = client.getState() + if (state === 'connected') { + return Promise.resolve(true) + } + if (state === 'auth-failed') { + return Promise.resolve(false) + } + return new Promise((resolve) => { + let settled = false + let unsubscribe: (() => void) | null = null + // Armed before subscribing so a synchronous notification during registration + // still finds a timer to clear. + const timer = setTimeout(() => finish(false), timeoutMs) + unsubscribe = client.onStateChange((state) => { + if (state === 'connected') { + finish(true) + } else if (state === 'auth-failed') { + // Revoked pairing never reaches 'connected', so waiting out the timeout only + // delays the error the caller is going to surface anyway. + finish(false) + } + }) + if (settled) { + // The notification fired inside onStateChange, before we held the handle. + unsubscribe() + unsubscribe = null + } + + function finish(reconnected: boolean): void { + if (settled) { + return + } + settled = true + clearTimeout(timer) + unsubscribe?.() + unsubscribe = null + resolve(reconnected) + } + }) +} diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index e9a4dcb72c9..b3c44504ae6 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -111,6 +111,10 @@ export type RpcClient = { getReconnectAttempt: () => number // Last 'connected' timestamp (ms epoch); null = never connected. Lets the UI tell "never reachable" from "transient blip". getLastConnectedAt: () => number | null + // Wall-clock stamp of the last inbound frame on the current session, or null when the + // transport can't vouch for one. Optional so older/foreign RpcClient shapes stay valid; + // callers must treat absent/null as "unknown" and fall back to a safe bound. + getLastInboundAt?: () => number | null onStateChange: (listener: (state: ConnectionState) => void) => () => void // Why: app-resume hook — iOS/Android can kill the TCP path while backgrounded; call on AppState 'active' to recover. // The reason routes relay handling (probe vs replace); the direct socket probes regardless. @@ -1150,6 +1154,10 @@ export function connect( return lastConnectedAt }, + getLastInboundAt(): number | null { + return livenessWatchdog.getLastInboundAt() || null + }, + onStateChange(listener: (state: ConnectionState) => void): () => void { stateListeners.add(listener) return () => stateListeners.delete(listener) diff --git a/mobile/src/transport/rpc-session-liveness-watchdog.ts b/mobile/src/transport/rpc-session-liveness-watchdog.ts index 2563f799819..e819dbb6edf 100644 --- a/mobile/src/transport/rpc-session-liveness-watchdog.ts +++ b/mobile/src/transport/rpc-session-liveness-watchdog.ts @@ -52,6 +52,13 @@ export class RpcSessionLivenessWatchdog { this.armIdle(identity) } + // Wall-clock stamp of the last frame that actually arrived; 0 before the first + // start(). Unlike a timer deadline this survives a JS suspension, so callers can + // tell how stale their knowledge of the peer really is. + getLastInboundAt(): number { + return this.lastInboundAt + } + noteAuthenticatedInbound(identity: RpcSessionIdentity): void { if (this.identity !== identity) { return diff --git a/mobile/src/transport/stable-logical-rpc-client.ts b/mobile/src/transport/stable-logical-rpc-client.ts index 6a3e622dd36..d1f1701aed9 100644 --- a/mobile/src/transport/stable-logical-rpc-client.ts +++ b/mobile/src/transport/stable-logical-rpc-client.ts @@ -160,6 +160,7 @@ export function createStableLogicalRpcClient( getState: () => state, getReconnectAttempt: () => connectionPath.reconnectAttempt(activeSession.getReconnectAttempt()), getLastConnectedAt: () => activeSession.getLastConnectedAt(), + getLastInboundAt: () => activeSession.getLastInboundAt?.() ?? null, onStateChange(listener) { stateListeners.add(listener) return () => stateListeners.delete(listener) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 83d3c138b97..8e2dad90671 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1077,6 +1077,7 @@ import { isGeneratedWorktreeCreateName, WORKTREE_CREATE_MAX_SUFFIX_ATTEMPTS } from '../worktree-create-candidates' +import { WORKTREE_CREATE_DEDUPE_TTL_MS } from '../../shared/new-workspace/worktree-create-retry-policy' import { failedWorktreeCreationNeedsRetirement, getRetiredNameRegistryForRepo, @@ -2015,8 +2016,9 @@ function getAgentLaunchPlatformForRepo( const MOBILE_TERMINAL_CREATE_RESULT_TTL_MS = 60_000 // Why: same idempotency window for worktree.create — a phone whose create was // interrupted by a connection migration retries with the same clientMutationId -// and reuses the just-created worktree instead of spawning a duplicate. -const WORKTREE_CREATE_RESULT_TTL_MS = 60_000 +// and reuses the just-created worktree instead of spawning a duplicate. Shared with +// the mobile client so its replay budget is sized against the real window. +const WORKTREE_CREATE_RESULT_TTL_MS = WORKTREE_CREATE_DEDUPE_TTL_MS const FOREGROUND_AGENT_WRAPPER_RETRY_INTERVAL_MS = 150 const FOREGROUND_AGENT_WRAPPER_RETRY_TIMEOUT_MS = 6_500 const BRACKETED_PASTE_BEGIN = '\x1b[200~' diff --git a/src/shared/new-workspace/worktree-create-retry-policy.ts b/src/shared/new-workspace/worktree-create-retry-policy.ts index e45da0d5530..2d3438b0218 100644 --- a/src/shared/new-workspace/worktree-create-retry-policy.ts +++ b/src/shared/new-workspace/worktree-create-retry-policy.ts @@ -65,3 +65,10 @@ export function getGeneratedWorktreeCreateRetryCandidate( export function isRetryableWorktreeCreateConflict(message: string): boolean { return RETRYABLE_WORKTREE_CREATE_CONFLICT_PATTERNS.some((pattern) => pattern.test(message)) } + +// Why: the host drops a *settled* worktree.create dedupe record this long after it +// resolves, so a client replaying an idempotent create must land inside the window +// or the replay misses the record and the host's suffix loop builds a second +// worktree. Shared so the client's replay budget can be asserted against it instead +// of assuming a value that lives in another process. +export const WORKTREE_CREATE_DEDUPE_TTL_MS = 60_000 From 6ac79ef0aac8aaae5167fcf012fdc1f3b9053b8c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:12:29 -0700 Subject: [PATCH 006/111] fix(workspaces): settle an orca.yaml trust prompt when the modal slot is taken (#15540) * fix(workspaces): settle an orca.yaml trust prompt when the modal slot is taken The app has one modal slot, so any openModal/closeModal evicts whatever held it. A pending orca.yaml trust prompt owns the promise that quick create awaits, and eviction dropped its resolver: that submit never settled, and because the trust prompts are serialized on a module-global chain, every later create/remove in the session silently did nothing. Modal data can now carry an onModalDismissed callback that the slot invokes when it evicts an entry; the trust prompt uses it to resolve as 'skip', which is what dismissing the dialog already means. * fix(workspaces): make trust prompt settlement one-shot --- .../src/lib/ensure-hooks-confirmed.ts | 13 +- ...ca-yaml-trust-prompt-slot-eviction.test.ts | 117 ++++++++++++++++++ .../src/store/slices/modal-slot-dismissal.ts | 9 ++ .../slices/ui-modal-slot-dismissal.test.ts | 96 ++++++++++++++ src/renderer/src/store/slices/ui.ts | 9 +- 5 files changed, 242 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/lib/orca-yaml-trust-prompt-slot-eviction.test.ts create mode 100644 src/renderer/src/store/slices/modal-slot-dismissal.ts create mode 100644 src/renderer/src/store/slices/ui-modal-slot-dismissal.test.ts diff --git a/src/renderer/src/lib/ensure-hooks-confirmed.ts b/src/renderer/src/lib/ensure-hooks-confirmed.ts index 8128e9ae8a0..0a24c776c62 100644 --- a/src/renderer/src/lib/ensure-hooks-confirmed.ts +++ b/src/renderer/src/lib/ensure-hooks-confirmed.ts @@ -8,6 +8,7 @@ import { type IssueCommandReadResult } from '@/runtime/runtime-hooks-client' import { getRuntimeEnvironmentIdForRepo } from './repo-runtime-owner' +import { MODAL_DISMISSED_KEY } from '@/store/slices/modal-slot-dismissal' import { getRepoExecutionHostId, parseExecutionHostId, @@ -128,6 +129,14 @@ async function confirmScriptContent( const previouslyApproved = Boolean(existingHash) return new Promise<'run' | 'skip'>((resolve) => { + let settled = false + const settle = (decision: 'run' | 'skip'): void => { + if (settled) { + return + } + settled = true + resolve(decision) + } state.openModal('confirm-orca-yaml-hooks', { repoId, repoName, @@ -135,7 +144,9 @@ async function confirmScriptContent( scriptContent, contentHash, previouslyApproved, - onResolve: (decision: 'run' | 'skip') => resolve(decision) + onResolve: settle, + // Why: eviction must fail closed without stranding the singleton trust queue. + [MODAL_DISMISSED_KEY]: () => settle('skip') }) }) } diff --git a/src/renderer/src/lib/orca-yaml-trust-prompt-slot-eviction.test.ts b/src/renderer/src/lib/orca-yaml-trust-prompt-slot-eviction.test.ts new file mode 100644 index 00000000000..56bd061af6b --- /dev/null +++ b/src/renderer/src/lib/orca-yaml-trust-prompt-slot-eviction.test.ts @@ -0,0 +1,117 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { StoreApi } from 'zustand/vanilla' +import type { AppState } from '@/store/types' +import { createUIStore } from '@/store/slices/ui-slice-test-harness' +import { __resetTrustPromptChainForTests, ensureHooksConfirmed } from './ensure-hooks-confirmed' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '@/runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client' + +const hooksCheckMock = vi.fn() +const readIssueCommandMock = vi.fn() +const runtimeEnvironmentTransportCallMock = vi.fn() + +// Why: a hand-rolled openModal double would decide the eviction outcome under test. +function createStateBackedByRealModalSlot(): { + store: StoreApi + state: AppState +} { + const store = createUIStore() + store.setState({ + repos: [{ id: 'repo-1', displayName: 'Repo One' }], + trustedOrcaHooks: {} + } as unknown as Partial) + return { store, state: store.getState() } +} + +async function settleOrReport(promise: Promise): Promise { + return Promise.race([ + promise, + new Promise<'never-settled'>((resolve) => setTimeout(() => resolve('never-settled'), 100)) + ]) +} + +describe('orca.yaml trust prompt evicted from the modal slot', () => { + beforeEach(() => { + hooksCheckMock.mockReset() + readIssueCommandMock.mockReset() + runtimeEnvironmentTransportCallMock.mockReset() + runtimeEnvironmentTransportCallMock.mockImplementation((args: RuntimeEnvironmentCallRequest) => + createCompatibleRuntimeStatusResponseIfNeeded(args) + ) + clearRuntimeCompatibilityCacheForTests() + vi.stubGlobal('window', { + api: { + hooks: { check: hooksCheckMock, readIssueCommand: readIssueCommandMock }, + runtimeEnvironments: { call: runtimeEnvironmentTransportCallMock } + } + }) + __resetTrustPromptChainForTests() + hooksCheckMock.mockResolvedValue({ + hasHooks: true, + hooks: { scripts: { setup: 'pnpm install' } }, + mayNeedUpdate: false + }) + }) + + it('resolves the pending decision as skip when another modal takes the slot', async () => { + const { store, state } = createStateBackedByRealModalSlot() + const runSetup = vi.fn() + + const decision = ensureHooksConfirmed(state, 'repo-1', 'setup') + await vi.waitFor(() => expect(store.getState().activeModal).toBe('confirm-orca-yaml-hooks')) + + store.getState().openModal('worktree-palette') + + const result = await settleOrReport(decision) + if (result === 'run') { + runSetup() + } + + expect(result).toBe('skip') + expect(runSetup).not.toHaveBeenCalled() + expect(store.getState().trustedOrcaHooks).toEqual({}) + }) + + it('resolves the pending decision as skip when the slot is closed outright', async () => { + const { store, state } = createStateBackedByRealModalSlot() + + const decision = ensureHooksConfirmed(state, 'repo-1', 'setup') + await vi.waitFor(() => expect(store.getState().activeModal).toBe('confirm-orca-yaml-hooks')) + + store.getState().closeModal() + + await expect(settleOrReport(decision)).resolves.toBe('skip') + }) + + it('keeps later trust prompts working after one is evicted', async () => { + const { store, state } = createStateBackedByRealModalSlot() + + const evicted = ensureHooksConfirmed(state, 'repo-1', 'setup') + await vi.waitFor(() => expect(store.getState().activeModal).toBe('confirm-orca-yaml-hooks')) + store.getState().openModal('worktree-palette') + await settleOrReport(evicted) + + const next = ensureHooksConfirmed(state, 'repo-1', 'setup') + await vi.waitFor(() => expect(store.getState().activeModal).toBe('confirm-orca-yaml-hooks')) + const onResolve = store.getState().modalData.onResolve as (d: 'run' | 'skip') => void + onResolve('run') + + await expect(settleOrReport(next)).resolves.toBe('run') + }) + + it('leaves the answered decision alone when the dialog closes the slot itself', async () => { + const { store, state } = createStateBackedByRealModalSlot() + + const decision = ensureHooksConfirmed(state, 'repo-1', 'setup') + await vi.waitFor(() => expect(store.getState().activeModal).toBe('confirm-orca-yaml-hooks')) + + // Mirrors the trust dialog: resolve first, then vacate the slot. + ;(store.getState().modalData.onResolve as (d: 'run' | 'skip') => void)('run') + store.getState().closeModal() + + await expect(settleOrReport(decision)).resolves.toBe('run') + }) +}) diff --git a/src/renderer/src/store/slices/modal-slot-dismissal.ts b/src/renderer/src/store/slices/modal-slot-dismissal.ts new file mode 100644 index 00000000000..d98a7e50f56 --- /dev/null +++ b/src/renderer/src/store/slices/modal-slot-dismissal.ts @@ -0,0 +1,9 @@ +// Modal owners with pending work use this hook to settle when the singleton slot evicts them. +export const MODAL_DISMISSED_KEY = 'onModalDismissed' + +export function settleEvictedModalData(evicted: Record): void { + const onDismissed = evicted[MODAL_DISMISSED_KEY] + if (typeof onDismissed === 'function') { + ;(onDismissed as () => void)() + } +} diff --git a/src/renderer/src/store/slices/ui-modal-slot-dismissal.test.ts b/src/renderer/src/store/slices/ui-modal-slot-dismissal.test.ts new file mode 100644 index 00000000000..c858e0d507e --- /dev/null +++ b/src/renderer/src/store/slices/ui-modal-slot-dismissal.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from 'vitest' +import { createUIStore } from './ui-slice-test-harness' +import { MODAL_DISMISSED_KEY } from './modal-slot-dismissal' + +function dismissalData(onDismissed: () => void): Record { + return { [MODAL_DISMISSED_KEY]: onDismissed } +} + +describe('UI modal slot dismissal', () => { + it('dismisses replaced and closed entries once', () => { + const store = createUIStore() + const firstDismissed = vi.fn() + const secondDismissed = vi.fn() + + store.getState().openModal('worktree-palette', dismissalData(firstDismissed)) + store.getState().openModal('quick-open', dismissalData(secondDismissed)) + store.getState().closeModal() + store.getState().closeModal() + + expect(firstDismissed).toHaveBeenCalledOnce() + expect(secondDismissed).toHaveBeenCalledOnce() + }) + + it('dismisses each entry across consecutive replacements', () => { + const store = createUIStore() + const firstDismissed = vi.fn() + const secondDismissed = vi.fn() + const thirdDismissed = vi.fn() + + store.getState().openModal('worktree-palette', dismissalData(firstDismissed)) + store.getState().openModal('quick-open', dismissalData(secondDismissed)) + store.getState().openModal('add-repo', dismissalData(thirdDismissed)) + store.getState().closeModal() + + expect(firstDismissed).toHaveBeenCalledOnce() + expect(secondDismissed).toHaveBeenCalledOnce() + expect(thirdDismissed).toHaveBeenCalledOnce() + }) + + it('updates the slot before notifying the evicted entry', () => { + const store = createUIStore() + const observedModal = vi.fn(() => store.getState().activeModal) + + store.getState().openModal('worktree-palette', dismissalData(observedModal)) + store.getState().openModal('quick-open') + + expect(observedModal).toHaveReturnedWith('quick-open') + }) + + it('ignores modal data without a callable dismissal hook', () => { + const store = createUIStore() + + store.getState().openModal('worktree-palette', { [MODAL_DISMISSED_KEY]: 'invalid' }) + + expect(() => store.getState().openModal('quick-open')).not.toThrow() + expect(store.getState().activeModal).toBe('quick-open') + }) + + it('settles reentrant replacement and close paths once', () => { + const store = createUIStore() + const replacementDismissed = vi.fn() + const reentrantDismissed = vi.fn() + const firstDismissed = vi.fn(() => { + store.getState().openModal('quick-open', dismissalData(reentrantDismissed)) + }) + + store.getState().openModal('worktree-palette', dismissalData(firstDismissed)) + store.getState().openModal('add-repo', dismissalData(replacementDismissed)) + store.getState().closeModal() + + expect(firstDismissed).toHaveBeenCalledOnce() + expect(replacementDismissed).toHaveBeenCalledOnce() + expect(reentrantDismissed).toHaveBeenCalledOnce() + expect(store.getState().activeModal).toBe('none') + }) + + it('performs one bounded notification per transition under repeated replacement', () => { + const store = createUIStore() + const dismissed = new Set() + const transitionCount = 1_000 + + for (let id = 0; id < transitionCount; id += 1) { + store.getState().openModal( + 'worktree-palette', + dismissalData(() => { + expect(dismissed.has(id)).toBe(false) + dismissed.add(id) + }) + ) + } + store.getState().closeModal() + + expect(dismissed.size).toBe(transitionCount) + expect(store.getState().modalData).toEqual({}) + }) +}) diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index e89e990719d..e06734e4923 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -2,6 +2,7 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import { normalizeRightSidebarRoute } from '../right-sidebar-route' +import { settleEvictedModalData } from './modal-slot-dismissal' import { findPrevLiveNonTaskStackHistoryIndex, findPrevLiveWorktreeHistoryIndex @@ -1621,12 +1622,18 @@ export const createUISlice: StateCreator = (set, get) if (modal === 'add-repo' || modal === 'create-worktree') { get().recordFeatureInteraction?.('workspace-creation') } + const evicted = get().modalData set({ activeModal: modal, modalData: data }) + settleEvictedModalData(evicted) + }, + closeModal: () => { + const evicted = get().modalData + set({ activeModal: 'none', modalData: {} }) + settleEvictedModalData(evicted) }, - closeModal: () => set({ activeModal: 'none', modalData: {} }), featureTipsSeenIds: [], markFeatureTipsSeen: (ids) => set((s) => { From 59c5624e146deb25ea5d1320385af00241a21c97 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:13:26 -0700 Subject: [PATCH 007/111] fix(terminal): require a proven exit before retiring a subscription's lease (#15470) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isPtyKnownExited read a PTY record as exited whenever `connected` was false: if (pty) { return !pty.connected } Its own leaf fallback, one line below, demands getTerminalState(leaf) === 'exited', which is only true once lastExitCode is set. So the same function proves absence on one path and infers it on the other, and the inferring path is the one that runs whenever a record exists. onPtyExit is the only writer of lastExitCode. The liveness sweep clears `connected` with no exit code for every PTY behind a dropped relay, so that state is a lost connection to a process that may still be running on the host — not an exit. Reading it as one makes subscribeToPtyExit fire its listener synchronously at subscribe time, which retires the lease and emits `end` for a live terminal. Mobile reads `end` as "PTY gone" and rearms; after three attempts it stops and leaves the composer on "Waiting for terminal…", which is where the 0.0.44 permanent lock comes from. Use the runtime's existing three-valued discriminator so both paths demand the same proof. 'unknown' now keeps watching, and the later real exit still fires the listener; a subscription that outlives its PTY is still bounded by the connection abort. Both callers are in subscribeToPtyExit — the subscribe-time fast path and the registration-race re-check — and both want proven-exited, so neither changes shape. --- src/main/runtime/orca-runtime.ts | 5 +- ...ubscribe-relay-drop-lease-survival.test.ts | 118 ++++++++++++++++++ .../runtime/terminal-pty-exit-waiter.test.ts | 54 ++++++++ 3 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 src/main/runtime/rpc/terminal-subscribe-relay-drop-lease-survival.test.ts diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 8e2dad90671..ae6980153f2 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -35330,7 +35330,10 @@ export class OrcaRuntimeService { private isPtyKnownExited(ptyId: string): boolean { const pty = this.ptysById.get(ptyId) if (pty) { - return !pty.connected + // Why: `!connected` is an inference, not proof. The liveness sweep clears it with no + // exit code for every PTY of a dropped relay, so reading that as an exit retires the + // lease of a process still running on the host — 'unknown' must keep watching. + return getPtyTerminalState(pty) === 'exited' } return this.getLeavesForPty(ptyId).some((leaf) => getTerminalState(leaf) === 'exited') } diff --git a/src/main/runtime/rpc/terminal-subscribe-relay-drop-lease-survival.test.ts b/src/main/runtime/rpc/terminal-subscribe-relay-drop-lease-survival.test.ts new file mode 100644 index 00000000000..5cfab233011 --- /dev/null +++ b/src/main/runtime/rpc/terminal-subscribe-relay-drop-lease-survival.test.ts @@ -0,0 +1,118 @@ +// Why: the stale-handle survival suite doubles subscribeToPtyExit, so it pins the wiring but +// not the predicate that decides whether a PTY counts as exited. This wires that one call to +// a real OrcaRuntimeService, because a relay drop reaches the phone through the predicate. +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from '../orca-runtime' +import type { RpcRequest } from './core' +import { RpcDispatcher } from './dispatcher' +import { TERMINAL_METHODS } from './methods/terminal' +import { createSubscriptionRegistryDouble } from './subscription-registry-test-double' + +const PTY_ID = 'pty-1' + +const subscriptionCases = [ + { + name: 'lease-only', + params: { + terminal: 'terminal-1', + client: { id: 'phone-1', type: 'mobile' }, + capabilities: { terminalBinaryStream: 1, mobileInputLeaseOnly: 1 } + } + }, + { + name: 'legacy JSON', + params: { terminal: 'terminal-1', client: { id: 'desktop-1', type: 'desktop' } } + }, + { + name: 'binary', + params: { + terminal: 'terminal-1', + client: { id: 'phone-1', type: 'mobile' }, + capabilities: { terminalBinaryStream: 1 } + } + } +] as const + +type PtyInternals = { + recordPtyWorktree: (ptyId: string, worktreeId: string, state?: { connected?: boolean }) => unknown + ptysById: Map +} + +/** A PTY whose relay dropped: connection lost, no exit code, process possibly still running. */ +function makeRelayDroppedRuntime(): OrcaRuntimeService { + const real = new OrcaRuntimeService() + const internals = real as unknown as PtyInternals + internals.recordPtyWorktree(PTY_ID, 'wt-1', { connected: true }) + const pty = internals.ptysById.get(PTY_ID)! + pty.connected = false + expect(pty.lastExitCode).toBeNull() + return real +} + +function createRuntime(real: OrcaRuntimeService): { + registry: ReturnType + runtime: OrcaRuntimeService +} { + const registry = createSubscriptionRegistryDouble() + const runtime = { + getRuntimeId: () => 'test-runtime', + requestRendererTerminalTabMount: () => false, + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: PTY_ID }), + handleMobileSubscribe: vi.fn().mockResolvedValue(true), + handleMobileUnsubscribe: vi.fn(), + registerRemoteTerminalViewSubscriber: vi.fn(() => vi.fn()), + subscribeToTerminalData: vi.fn(() => vi.fn()), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi + .fn() + .mockResolvedValue({ data: 'snapshot', cols: 80, rows: 24, seq: 1 }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + isTerminalAlternateScreen: vi.fn().mockReturnValue(false), + subscribeToTerminalResize: vi.fn(() => vi.fn()), + subscribeToFitOverrideChanges: vi.fn(() => vi.fn()), + registerSubscriptionCleanup: vi.fn(registry.registerSubscriptionCleanup), + registerOwnedSubscriptionCleanup: vi.fn(registry.registerOwnedSubscriptionCleanup), + cleanupSubscription: vi.fn(registry.cleanupSubscription), + waitForTerminal: vi.fn(() => Promise.reject(new Error('unexpected handle waiter'))), + // The one call under test: the real exit predicate, not a double's opinion of it. + subscribeToPtyExit: (ptyId: string, listener: () => void) => + real.subscribeToPtyExit(ptyId, listener) + } as unknown as OrcaRuntimeService + return { registry, runtime } +} + +function makeRequest(params: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method: 'terminal.subscribe', params } +} + +describe('terminal subscribe when the relay dropped without an exit code', () => { + it.each(subscriptionCases)( + 'does not end $name for a process that may still be running', + async ({ params }) => { + const real = makeRelayDroppedRuntime() + const { registry, runtime } = createRuntime(real) + const messages: string[] = [] + const types = (): unknown[] => messages.map((message) => JSON.parse(message).result?.type) + + void new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + .dispatchStreaming(makeRequest(params), (message) => messages.push(message), { + connectionId: 'conn-1', + sendBinary: vi.fn(), + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + }) + .catch(() => undefined) + + const subscriptionId = `terminal-1:${params.client.id}` + await vi.waitFor(() => expect(registry.peekCleanup(subscriptionId)).toBeDefined()) + expect(types()).not.toContain('end') + + // The host comes back and reports the real exit; only now may the lease retire. + real.onPtyExit(PTY_ID, 0) + + await vi.waitFor(() => expect(registry.peekCleanup(subscriptionId)).toBeUndefined()) + expect(types()).toContain('end') + } + ) +}) diff --git a/src/main/runtime/terminal-pty-exit-waiter.test.ts b/src/main/runtime/terminal-pty-exit-waiter.test.ts index 5f10449f34d..8b6daa3c2b0 100644 --- a/src/main/runtime/terminal-pty-exit-waiter.test.ts +++ b/src/main/runtime/terminal-pty-exit-waiter.test.ts @@ -4,6 +4,7 @@ import { OrcaRuntimeService } from './orca-runtime' type RuntimeInternals = { recordPtyWorktree: (ptyId: string, worktreeId: string, state?: { connected?: boolean }) => unknown ptyExitListenersByPtyId: Map> + ptysById: Map } function internals(runtime: OrcaRuntimeService): RuntimeInternals { @@ -56,3 +57,56 @@ describe('PTY exit subscription', () => { expect(internals(runtime).ptyExitListenersByPtyId.has('pty-1')).toBe(false) }) }) + +// Why: the liveness sweep clears `connected` with no exit code for every PTY behind a +// dropped relay, so a record in that state is a lost connection to a process that may +// still be running. Reading it as an exit retires a live lease and emits `end`, which the +// phone rearms against three times and then locks the composer on "Waiting for terminal…". +describe('PTY exit subscription demands proof of exit, not loss of connection', () => { + function dropTheConnection(runtime: OrcaRuntimeService): void { + const pty = internals(runtime).ptysById.get('pty-1')! + pty.connected = false + // Precondition: onPtyExit is the only writer of lastExitCode, and it never ran. + expect(pty.lastExitCode).toBeNull() + } + + it('does not fire for a disconnected PTY that never reported an exit code', () => { + const runtime = new OrcaRuntimeService() + registerLivePty(runtime) + dropTheConnection(runtime) + const listener = vi.fn() + + runtime.subscribeToPtyExit('pty-1', listener) + + expect(listener).not.toHaveBeenCalled() + expect(internals(runtime).ptyExitListenersByPtyId.get('pty-1')).toHaveLength(1) + }) + + it('still fires once the host comes back and reports the real exit', () => { + const runtime = new OrcaRuntimeService() + registerLivePty(runtime) + dropTheConnection(runtime) + const listener = vi.fn() + + runtime.subscribeToPtyExit('pty-1', listener) + expect(listener).not.toHaveBeenCalled() + + // Why: subscribing during the outage must not cost the subscriber the later exit. + runtime.onPtyExit('pty-1', 0) + + expect(listener).toHaveBeenCalledOnce() + }) + + it('fires immediately for a disconnected PTY that did report an exit code', () => { + const runtime = new OrcaRuntimeService() + registerLivePty(runtime) + const pty = internals(runtime).ptysById.get('pty-1')! + pty.connected = false + pty.lastExitCode = 0 + const listener = vi.fn() + + runtime.subscribeToPtyExit('pty-1', listener) + + expect(listener).toHaveBeenCalledOnce() + }) +}) From a76a95d111a2b32ae14a455e27d9701725d7c559 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:13:57 -0700 Subject: [PATCH 008/111] fix(terminal): make remote-host take-back release the phone-fit lock (#15473) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(terminal): make remote-host take-back release the phone-fit lock The remote-desktop branch of reclaimTerminalForDesktop was the one path that rolled the presence lock back when its reclaim resize did not converge. On a remote/SSH host that left the phone-fit banner stranded and made every subsequent "Take back all terminals" click a no-op. Its sibling (active mobile subscriber) released the lock but still reported the layout's `ok`, so the desktop renderer skipped its post-take-back refit and focus. Both now follow the guarantee the method already documents: an explicit desktop take-back always drops the lock, and the trailing remote layout is best-effort. * review: pin the driver flip and correct the applyMobileDisplayMode contract - Tighten the held-branch driver assertion to the exact post-release state so it pins releaseDesktopTakeBack's flip rather than merely "not mobile". - applyMobileDisplayMode's doc claimed reclaimTerminalForDesktop gates its transitions on the returned convergence flag. No branch does after this change; say so, so the gate is not reinstated. Hooks skipped (machine load); oxfmt --check and oxlint verified clean manually. * test(terminal): pin the converging remote take-back resize The two take-back tests both force the reclaim resize to fail, so nothing covered a take-back that converges. Deleting the `idle` driver flip left every suite green while applyRemoteDesktopLayout no-opped on a still-mobile driver — lock dropped, `true` returned, PTY stranded at the phone grid. --- src/main/runtime/orca-runtime.ts | 25 ++++--- .../runtime/remote-desktop-driver.test.ts | 72 ++++++++++++++++++- 2 files changed, 86 insertions(+), 11 deletions(-) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index ae6980153f2..62a1c9c96e0 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -15943,7 +15943,10 @@ export class OrcaRuntimeService { // in passive desktop-watch mode. this.setMobileDisplayMode(ptyId, 'auto') if (this.hasRemoteDesktopLayoutState(ptyId)) { - return this.applyRemoteDesktopLayout(ptyId) + // Why: the lock is already released above, so this re-layout is + // best-effort. Reporting its `ok` would tell the desktop "nothing was + // reclaimed" and cost the caller its post-take-back refit and focus. + await this.applyRemoteDesktopLayout(ptyId) } return true } @@ -15959,14 +15962,14 @@ export class OrcaRuntimeService { clearTimeout(softLeaver.timer) this.pendingSoftLeavers.delete(ptyId) } - const priorDriver = this.getDriver(ptyId) + // Why: applyRemoteDesktopLayout no-ops while the driver still reads mobile. this.setDriver(ptyId, { kind: 'idle' }) - const converged = await this.applyRemoteDesktopLayout(ptyId) - if (!converged) { - this.setDriver(ptyId, priorDriver) - return false - } - this.setDriver(ptyId, { kind: 'desktop' }) + // Why: best-effort, like the local held branch below. A host whose resize + // keeps failing (dropped SSH/WSL provider, exited PTY) would otherwise + // roll the lock back and leave the banner stranded, making every retry a + // no-op — the one branch that broke this method's release guarantee. + await this.applyRemoteDesktopLayout(ptyId) + this.releaseDesktopTakeBack(ptyId) this.setMobileDisplayMode(ptyId, 'auto') return true } @@ -16770,8 +16773,10 @@ export class OrcaRuntimeService { // Returns the post-condition "no fit-override remains held" (#7588): `true` // when it cleared a held override OR nothing was held to begin with, `false` // only when a restore was attempted and the resize failed (override rolled - // back, still held). reclaimTerminalForDesktop gates its driver/mode - // transitions on this; other callers ignore it. + // back, still held). Informational for every caller today — + // reclaimTerminalForDesktop deliberately does NOT gate on it, because an + // explicit take-back must drop the lock even when the resize cannot + // converge. Do not reinstate a convergence gate there. async applyMobileDisplayMode(ptyId: string): Promise { const mode = this.getMobileDisplayMode(ptyId) const inner = this.mobileSubscribers.get(ptyId) diff --git a/src/main/runtime/remote-desktop-driver.test.ts b/src/main/runtime/remote-desktop-driver.test.ts index 06eea32df00..bf2370ee924 100644 --- a/src/main/runtime/remote-desktop-driver.test.ts +++ b/src/main/runtime/remote-desktop-driver.test.ts @@ -84,11 +84,16 @@ function createRuntime(mobileAutoRestoreFitMs: number | null = 5_000) { const resizeCalls: { ptyId: string; cols: number; rows: number }[] = [] const driverEvents: { ptyId: string; driver: { kind: string; clientId?: string } }[] = [] const fitOverrideEvents: { ptyId: string; mode: string; cols: number; rows: number }[] = [] + let resizeSucceeds = true runtime.setPtyController({ write: () => true, kill: () => true, getForegroundProcess: async () => null, resize: (ptyId, cols, rows) => { + // Why: the production controller returns false when the provider throws — a dropped SSH/WSL provider or an exited PTY. + if (!resizeSucceeds) { + return false + } resizeCalls.push({ ptyId, cols, rows }) ptySizes.set(ptyId, { cols, rows }) return true @@ -116,7 +121,10 @@ function createRuntime(mobileAutoRestoreFitMs: number | null = 5_000) { runtime, driverEvents, fitOverrideEvents, - resizeCalls + resizeCalls, + setResizeSucceeds: (next: boolean) => { + resizeSucceeds = next + } } } @@ -519,4 +527,66 @@ describe('remote desktop viewer width driver', () => { await runtime.updateRemoteDesktopViewer('pty-1', 'sub-B', 'viewer-B', 100, 40) expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(true) }) + + it('held phone-fit take-back releases the lock when the remote reclaim resize fails', async () => { + // Why: the local held branch releases unconditionally (mobile-presence-lock.test.ts scenario 4). + // The remote branch rolled the lock back instead, so the banner survived and every retry was a no-op. + const { runtime, fitOverrideEvents, setResizeSucceeds } = createRuntime(null) + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 30) + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-A') + runtime.onClientDisconnected('phone-A') + await vi.advanceTimersByTimeAsync(10_000) + expect(runtime.getTerminalFitOverride('pty-1')).toMatchObject({ mode: 'mobile-fit' }) + + setResizeSucceeds(false) + const notifierBefore = fitOverrideEvents.length + + await expect(runtime.reclaimTerminalForDesktop('pty-1')).resolves.toBe(true) + + expect(runtime.getTerminalFitOverride('pty-1')).toBeNull() + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'desktop' }) + expect(fitOverrideEvents.slice(notifierBefore).some((e) => e.mode === 'desktop-fit')).toBe(true) + // A second click must not be needed, and must stay idempotent. + await expect(runtime.reclaimTerminalForDesktop('pty-1')).resolves.toBe(false) + }) + + it('driving take-back reports success when the trailing remote layout cannot converge', async () => { + // Why: the lock is already released here, so returning false told the desktop renderer + // "nothing was reclaimed" and it skipped the post-take-back refit and focus. + const { runtime, setResizeSucceeds } = createRuntime() + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 30) + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) + + setResizeSucceeds(false) + + await expect(runtime.reclaimTerminalForDesktop('pty-1')).resolves.toBe(true) + + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'desktop' }) + expect(runtime.getTerminalFitOverride('pty-1')).toBeNull() + }) + + it('take-back during the resubscribe grace still resizes the PTY off the phone grid', async () => { + // Why: both take-back tests above force the resize to fail, so nothing pinned that a + // converging remote reclaim reaches the host. A phone that unsubscribes cleanly holds + // driver=mobile for the 250ms resubscribe grace, and applyRemoteDesktopLayout no-ops on + // a mobile driver — so without the idle flip the lock drops and `true` is still reported + // while the PTY stays stranded at the phone grid. + const { runtime, resizeCalls } = createRuntime(null) + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 30) + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + runtime.handleMobileUnsubscribe('pty-1', 'phone-A') + // Inside the grace window on purpose: the driver still reads mobile here. + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) + expect(runtime.getTerminalFitOverride('pty-1')).toMatchObject({ mode: 'mobile-fit' }) + const resizesBefore = resizeCalls.length + + await expect(runtime.reclaimTerminalForDesktop('pty-1')).resolves.toBe(true) + + expect(runtime.getTerminalFitOverride('pty-1')).toBeNull() + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'desktop' }) + expect(resizeCalls.slice(resizesBefore)).toContainEqual({ ptyId: 'pty-1', cols: 100, rows: 30 }) + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 100, rows: 30 }) + }) }) From d541982b9c6d55504512442580a9639f181b5eee Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:46:45 -0700 Subject: [PATCH 009/111] Improve cmd j search usefulness (#15551) * Show last active time for workspace tabs in cmd+j palette Adds session-age formatting and activity tracking to help users find recently-used tabs. Replaces host badge display with last-active timestamps that reflect either agent activity or worktree PTY activity, whichever is more recent. * Improve cmd+j search ranking with direct fields and recency Prioritize results matching direct fields (titles, content) over container fields (worktree, branch, repo). Use tab focus time to break ranking ties. Makes search more useful for quick navigation. * Extract path flavor logic to cross-platform-path utility - Remove local pathFlavor function in favor of shared cross-platform utilities - Simplify buildExcludePathPrefixes to use relativePathInsideRoot and resolveRuntimePath - Ensures consistent path handling for both local and remote roots * Improve cmd+j search ranking with recency-based tiebreaking Track lastFocusedAt on tab creation/focus and use it to break ties between equally-ranked search results. This surfaces recently-used items first, improving search utility. Also fixes hasDirectHit to check field matches directly rather than evidence metadata. --- .../components/WorktreeJumpPalette.test.tsx | 25 +++ .../src/components/WorktreeJumpPalette.tsx | 106 ++++----- .../cmd-j/palette-session-age.test.ts | 40 ++++ .../components/cmd-j/palette-session-age.ts | 30 +++ .../tab-bar/open-tab-search-entries.ts | 1 + .../tab-bar/open-tab-search-retention.test.ts | 9 +- .../tab-bar/open-tab-search.test.ts | 9 +- src/renderer/src/i18n/locales/en.json | 11 +- .../lib/browser-palette-page-entries.test.ts | 48 ++++ .../src/lib/browser-palette-page-entries.ts | 13 ++ .../src/lib/browser-palette-search.test.ts | 31 +++ .../src/lib/browser-palette-search.ts | 24 +- .../src/lib/cmd-j-section-leadership.test.ts | 13 +- .../src/lib/cmd-j-section-leadership.ts | 11 +- .../src/lib/palette-match/indexed-field.ts | 6 +- .../src/lib/palette-match/match-document.ts | 19 +- .../src/lib/palette-match/match-quality.ts | 6 +- .../src/lib/palette-match/palette-document.ts | 9 +- .../palette-match/palette-match-core.test.ts | 32 +++ .../src/lib/palette-match/tab-document.ts | 24 +- .../src/lib/palette-match/tab-match.ts | 31 +-- .../src/lib/simulator-palette-search.test.ts | 36 +++ .../src/lib/simulator-palette-search.ts | 25 ++- .../lib/workspace-tab-agent-metadata.test.ts | 48 +++- .../src/lib/workspace-tab-agent-metadata.ts | 20 +- .../lib/workspace-tab-agent-snippet-match.ts | 1 + .../workspace-tab-palette-activation.test.ts | 1 + .../workspace-tab-palette-entry-builder.ts | 5 +- .../lib/workspace-tab-palette-results.test.ts | 210 ++++++++++++++++++ .../src/lib/workspace-tab-palette-results.ts | 33 ++- .../lib/worktree-palette-task-url-result.ts | 1 + .../slices/tabs-unread-and-focus.test.ts | 29 +++ src/renderer/src/store/slices/tabs.ts | 32 ++- src/shared/tab-types.ts | 2 + 34 files changed, 828 insertions(+), 113 deletions(-) create mode 100644 src/renderer/src/components/cmd-j/palette-session-age.test.ts create mode 100644 src/renderer/src/components/cmd-j/palette-session-age.ts create mode 100644 src/renderer/src/lib/workspace-tab-palette-results.test.ts diff --git a/src/renderer/src/components/WorktreeJumpPalette.test.tsx b/src/renderer/src/components/WorktreeJumpPalette.test.tsx index f42cdbb8d9c..5c57e6fad38 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.test.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.test.tsx @@ -435,4 +435,29 @@ describe('WorktreeJumpPalette', () => { expect(input?.value).toBe('😉') }) + + it('renders last active timestamp when worktree has lastActivityAt', async () => { + const twentyThreeDaysAgo = Date.now() - 23 * 24 * 60 * 60 * 1000 + const activeWorktree = makeWorktree('active-wt', 'Active workspace', { + lastActivityAt: twentyThreeDaysAgo + }) + const noActivityWorktree = makeWorktree('no-activity-wt', 'No activity workspace', { + lastActivityAt: 0 + }) + + await renderPalette({ + worktreesByRepo: { 'repo-1': [activeWorktree, noActivityWorktree] }, + showSleepingWorkspaces: true + }) + + const activeRow = testContainer.querySelector('[data-command-item="worktree:active-wt"]') + expect(activeRow?.textContent).toContain('23d') + const activeSpan = activeRow?.querySelector('span[aria-label="Last active 23d ago"]') + expect(activeSpan).not.toBeNull() + expect(activeSpan?.textContent).toBe('23d') + + const noActivityRow = testContainer.querySelector('[data-command-item="worktree:no-activity-wt"]') + expect(noActivityRow?.querySelector('span[aria-label*="Last active"]')).toBeNull() + }) }) + diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index a3333c3d2a2..61a190a8b2b 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -138,7 +138,7 @@ import { } from '@/components/browser-pane/host-guest/browser-focus' import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel' import { buildSidebarHostOptions } from '@/components/sidebar/sidebar-host-options' -import { getPaletteHostBadge, type PaletteHostBadge } from '@/components/cmd-j/palette-host-badge' +import { getPaletteHostBadge } from '@/components/cmd-j/palette-host-badge' import { composeWorktreeHostIdentity, getWorktreeHostIdentity @@ -151,6 +151,7 @@ import { type ExecutionHostId } from '../../../shared/execution-host' import { buildPaletteListEntryRenderKeys } from '@/components/cmd-j/palette-list-entry-render-keys' +import { formatPaletteSessionAge } from '@/components/cmd-j/palette-session-age' import PaletteFilterMenu from '@/components/cmd-j/PaletteFilterMenu' import PaletteFilterChips from '@/components/cmd-j/PaletteFilterChips' import { buildPaletteFilterModel } from '@/components/cmd-j/palette-filter-options' @@ -497,12 +498,14 @@ function PaletteOpenTabPrimaryLine({ titleRanges, secondaryText, secondaryRanges, + sessionAge, leadingBadges }: { title: string titleRanges: readonly MatchRange[] secondaryText: string secondaryRanges: readonly MatchRange[] + sessionAge?: string leadingBadges?: React.ReactNode }): React.JSX.Element { // Why gate on non-empty: empty secondaries (terminals/simulators) used to still @@ -517,6 +520,18 @@ function PaletteOpenTabPrimaryLine({ > + {sessionAge ? ( + + {sessionAge} + + ) : null} {leadingBadges} {showSecondary ? ( <> @@ -621,31 +636,6 @@ function FooterKey({ children }: { children: React.ReactNode }): React.JSX.Eleme ) } -function PaletteHostBadgeChip({ - badge -}: { - badge: PaletteHostBadge | null -}): React.JSX.Element | null { - if (!badge) { - return null - } - // Host labels come from the registry and are intentionally not translated. - return ( - - {badge.label} - - ) -} - function getSettingsTargetFromSectionId(sectionId: string): { pane: SettingsNavTarget repoId: string | null @@ -694,6 +684,9 @@ function WorktreeJumpPaletteContent({ }): React.JSX.Element | null { // Why: subscribe to language changes so translated memos recompute without a fake i18n.language dependency. useTranslation() + // Why frozen on open: recomputing every keystroke would tick the session-age badges mid-session. + // oxlint-disable-next-line react-hooks/exhaustive-deps -- visible is the freeze trigger, not a read value. + const paletteNowMs = useMemo(() => Date.now(), [visible]) const closeModal = useAppStore((s) => s.closeModal) const openModal = useAppStore((s) => s.openModal) const openSettingsPage = useAppStore((s) => s.openSettingsPage) @@ -1203,6 +1196,7 @@ function WorktreeJumpPaletteContent({ worktreeOrder, browserTabsByWorktree, browserPagesByWorkspace, + unifiedTabsByWorktree, activeBrowserTabId, activeWorktreeId, activeWorkspaceExecutionHostId, @@ -1219,6 +1213,7 @@ function WorktreeJumpPaletteContent({ browserSortedWorktrees, repoByHostIdentity, repoMap, + unifiedTabsByWorktree, worktreeOrder ]) @@ -1392,8 +1387,18 @@ function WorktreeJumpPaletteContent({ // An empty query leaves every rank null, so ordering falls through to those scores. return items.sort((a, b) => comparePaletteRankedItems( - { rank: a.result.rank, order: a.result.score, id: a.id }, - { rank: b.result.rank, order: b.result.score, id: b.id } + { + rank: a.result.rank, + order: a.result.score, + id: a.id, + lastActiveAt: a.result.lastActiveAt ?? undefined + }, + { + rank: b.result.rank, + order: b.result.score, + id: b.id, + lastActiveAt: b.result.lastActiveAt ?? undefined + } ) ) }, [browserItems, simulatorItems, workspaceTabItems]) @@ -3231,8 +3236,7 @@ function WorktreeJumpPaletteContent({ ? (sshConnectionStates.get(sshConnectionId)?.status ?? 'disconnected') : null const isSshDisconnected = sshStatus != null && sshStatus !== 'connected' - const hostBadge = getPaletteHostBadge(repo, hostOptions, hostFilterActive) - + const sessionAge = formatPaletteSessionAge(worktree.lastActivityAt, paletteNowMs) return ( + {sessionAge ? ( + + {sessionAge} + + ) : null} {isCurrentWorktree && ( {translate( @@ -3329,7 +3345,6 @@ function WorktreeJumpPaletteContent({ )}

- {repoName && ( @@ -3351,9 +3366,6 @@ function WorktreeJumpPaletteContent({ if (entry.type === 'project-target') { const result = entry.result const isProject = result.kind === 'project' - const hostBadge = isProject - ? getPaletteHostBadge(result.repo, hostOptions, hostFilterActive) - : null const badgeLabel = isProject ? translate('auto.components.WorktreeJumpPalette.projectBadge', 'Project') : translate('auto.components.WorktreeJumpPalette.repoGroupBadge', 'Repo group') @@ -3381,7 +3393,6 @@ function WorktreeJumpPaletteContent({
{isProject ? (
- {result.repo.displayName} @@ -3430,6 +3441,7 @@ function WorktreeJumpPaletteContent({ if (entry.type === 'workspace-tab') { const result = entry.result + const sessionAge = formatPaletteSessionAge(result.lastActiveAt, paletteNowMs) const workspaceTabWorktree = resolveWorktree( result.worktreeId, result.executionHostId @@ -3438,11 +3450,6 @@ function WorktreeJumpPaletteContent({ ? resolveRepoForWorktree(workspaceTabWorktree) : undefined const workspaceTabRepoName = workspaceTabRepo?.displayName ?? result.repoName - const workspaceTabHostBadge = getPaletteHostBadge( - workspaceTabRepo, - hostOptions, - hostFilterActive - ) const workspaceTabFallback = result.contentType === 'terminal' && result.occupantAgent ? ( {result.isCurrentTab && ( @@ -3508,7 +3516,6 @@ function WorktreeJumpPaletteContent({ worktree={workspaceTabWorktree} className="max-w-[280px] truncate text-[12px] font-medium text-muted-foreground" /> - {workspaceTabRepoName && ( @@ -3538,10 +3545,9 @@ function WorktreeJumpPaletteContent({ ? resolveRepoForWorktree(simulatorWorktree) : undefined const simulatorRepoName = simulatorRepo?.displayName ?? result.repoName - const simulatorHostBadge = getPaletteHostBadge( - simulatorRepo, - hostOptions, - hostFilterActive + const sessionAge = formatPaletteSessionAge( + result.lastActiveAt ?? null, + paletteNowMs ) return ( @@ -3562,6 +3568,7 @@ function WorktreeJumpPaletteContent({ titleRanges={result.titleRanges} secondaryText={result.secondaryText} secondaryRanges={result.secondaryRanges} + sessionAge={sessionAge} leadingBadges={ <> {result.isCurrentTab && ( @@ -3591,7 +3598,6 @@ function WorktreeJumpPaletteContent({ worktree={simulatorWorktree} className="max-w-[280px] truncate text-[12px] font-medium text-muted-foreground" /> - {simulatorRepoName && ( @@ -3620,11 +3626,7 @@ function WorktreeJumpPaletteContent({ ? resolveRepoForWorktree(browserWorktree) : undefined const browserRepoName = browserRepo?.displayName ?? result.repoName - const browserHostBadge = getPaletteHostBadge( - browserRepo, - hostOptions, - hostFilterActive - ) + const sessionAge = formatPaletteSessionAge(result.lastActiveAt ?? null, paletteNowMs) return ( {result.isCurrentPage && ( @@ -3673,7 +3676,6 @@ function WorktreeJumpPaletteContent({ worktree={browserWorktree} className="max-w-[280px] truncate text-[12px] font-medium text-muted-foreground" /> - {browserRepoName && ( diff --git a/src/renderer/src/components/cmd-j/palette-session-age.test.ts b/src/renderer/src/components/cmd-j/palette-session-age.test.ts new file mode 100644 index 00000000000..8c0e96f69c7 --- /dev/null +++ b/src/renderer/src/components/cmd-j/palette-session-age.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { formatPaletteSessionAge } from './palette-session-age' + +const NOW = 1_000_000_000 + +describe('formatPaletteSessionAge', () => { + it('returns undefined when there is no known activity', () => { + expect(formatPaletteSessionAge(null, NOW)).toBeUndefined() + }) + + it('buckets clock skew (negative delta) as "<1m"', () => { + expect(formatPaletteSessionAge(NOW + 5_000, NOW)).toBe('<1m') + }) + + it('stays "<1m" just under the one-minute boundary', () => { + expect(formatPaletteSessionAge(NOW - 59_000, NOW)).toBe('<1m') + }) + + it('switches to minutes at the one-minute boundary', () => { + expect(formatPaletteSessionAge(NOW - 60_000, NOW)).toBe('1m') + expect(formatPaletteSessionAge(NOW - 90_000, NOW)).toBe('1m') + }) + + it('stays in minutes just under the one-hour boundary', () => { + expect(formatPaletteSessionAge(NOW - 59 * 60_000, NOW)).toBe('59m') + }) + + it('switches to hours at the one-hour boundary', () => { + expect(formatPaletteSessionAge(NOW - 60 * 60_000, NOW)).toBe('1h') + }) + + it('stays in hours just under the two-day boundary', () => { + expect(formatPaletteSessionAge(NOW - 47 * 60 * 60_000, NOW)).toBe('47h') + }) + + it('switches to days at the two-day boundary', () => { + expect(formatPaletteSessionAge(NOW - 48 * 60 * 60_000, NOW)).toBe('2d') + expect(formatPaletteSessionAge(NOW - 72 * 60 * 60_000, NOW)).toBe('3d') + }) +}) diff --git a/src/renderer/src/components/cmd-j/palette-session-age.ts b/src/renderer/src/components/cmd-j/palette-session-age.ts new file mode 100644 index 00000000000..fc5924ea658 --- /dev/null +++ b/src/renderer/src/components/cmd-j/palette-session-age.ts @@ -0,0 +1,30 @@ +import { translate } from '@/i18n/i18n' + +/** Bare compact "how long ago" token for the Cmd+J session-age badge, e.g. "3h"/"2d". */ +export function formatPaletteSessionAge( + lastActiveAt: number | null, + now: number +): string | undefined { + if (!lastActiveAt) { + return undefined + } + const deltaMs = now - lastActiveAt + // Why not "now": the badge feeds `aria-label="Last active {{value}} ago"`, and + // "Last active now ago" reads wrong — keep the token numeric like the other buckets. + if (deltaMs < 60_000) { + return translate('components.cmd-j.paletteSessionAge.underOneMinute', '<1m') + } + const minutes = Math.floor(deltaMs / 60_000) + if (minutes < 60) { + return translate('components.cmd-j.paletteSessionAge.minutes', '{{value0}}m', { + value0: minutes + }) + } + const hours = Math.floor(minutes / 60) + if (hours < 48) { + return translate('components.cmd-j.paletteSessionAge.hours', '{{value0}}h', { value0: hours }) + } + return translate('components.cmd-j.paletteSessionAge.days', '{{value0}}d', { + value0: Math.floor(hours / 24) + }) +} diff --git a/src/renderer/src/components/tab-bar/open-tab-search-entries.ts b/src/renderer/src/components/tab-bar/open-tab-search-entries.ts index 5d1ab2c8242..e62e1289328 100644 --- a/src/renderer/src/components/tab-bar/open-tab-search-entries.ts +++ b/src/renderer/src/components/tab-bar/open-tab-search-entries.ts @@ -160,6 +160,7 @@ export function buildOpenTabSearchEntries( ...scope, browserTabsByWorktree: state.browserTabsByWorktree, browserPagesByWorkspace: state.browserPagesByWorkspace, + unifiedTabsByWorktree: state.unifiedTabsByWorktree, activeBrowserTabId: state.activeBrowserTabId, activeWorktreeId: state.activeWorktreeId, activeTabType: state.activeTabType diff --git a/src/renderer/src/components/tab-bar/open-tab-search-retention.test.ts b/src/renderer/src/components/tab-bar/open-tab-search-retention.test.ts index 399a23b1942..390a9a8fa14 100644 --- a/src/renderer/src/components/tab-bar/open-tab-search-retention.test.ts +++ b/src/renderer/src/components/tab-bar/open-tab-search-retention.test.ts @@ -87,7 +87,14 @@ function makeWorkspaceTab({ typeAliases: aliases }), agentMetadata: agentSnippets.length - ? [{ paneKey: `${id}-pane`, textParts: [], snippetCandidates: agentSnippets }] + ? [ + { + paneKey: `${id}-pane`, + textParts: [], + snippetCandidates: agentSnippets, + lastActivityAt: 0 + } + ] : [], occupantAgent: null, isCurrentTab: false, diff --git a/src/renderer/src/components/tab-bar/open-tab-search.test.ts b/src/renderer/src/components/tab-bar/open-tab-search.test.ts index e5d9071e2da..e2029811625 100644 --- a/src/renderer/src/components/tab-bar/open-tab-search.test.ts +++ b/src/renderer/src/components/tab-bar/open-tab-search.test.ts @@ -105,7 +105,14 @@ function makeWorkspaceTab({ repoName: REPO_NAME }), agentMetadata: agentSnippets.length - ? [{ paneKey: `${id}-pane`, textParts: [], snippetCandidates: agentSnippets }] + ? [ + { + paneKey: `${id}-pane`, + textParts: [], + snippetCandidates: agentSnippets, + lastActivityAt: 0 + } + ] : [], occupantAgent, isCurrentTab, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 1f16389c1fa..fd6362289a4 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2198,7 +2198,8 @@ "27f10cca63": "Search chats, terminals, worktrees, settings, and actions...", "2770f02910": "Search chats, terminals, worktrees, settings, and actions", "paletteOpenTabBranch": "Branch name", - "paletteOpenTabWorkspace": "Workspace name" + "paletteOpenTabWorkspace": "Workspace name", + "lastActiveTime": "Last active {{value0}} ago" }, "github": { "pr": { @@ -16246,6 +16247,14 @@ "otherTopLevelItems": "Other top-level items ({{value0}})" } } + }, + "cmd-j": { + "paletteSessionAge": { + "minutes": "{{value0}}m", + "hours": "{{value0}}h", + "days": "{{value0}}d", + "underOneMinute": "<1m" + } } }, "dashboardPopout": { diff --git a/src/renderer/src/lib/browser-palette-page-entries.test.ts b/src/renderer/src/lib/browser-palette-page-entries.test.ts index f367ac85e73..8c985587500 100644 --- a/src/renderer/src/lib/browser-palette-page-entries.test.ts +++ b/src/renderer/src/lib/browser-palette-page-entries.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import type { BrowserPage, BrowserWorkspace } from '../../../shared/browser-workspace-types' +import type { Tab } from '../../../shared/tab-types' import type { Worktree } from '../../../shared/worktree/types' import { buildSearchableBrowserPages } from './browser-palette-page-entries' import { searchBrowserPages } from './browser-palette-search' @@ -191,6 +192,53 @@ describe('buildSearchableBrowserPages', () => { expect(entries[0].worktreeSortIndex).toBe(Number.MAX_SAFE_INTEGER) }) + it('reads page recency from the owning workspace unified tab, clamped to page age', () => { + function browserTab(overrides: Partial): Tab { + return { + id: 'tab-ws-1', + entityId: 'ws-1', + groupId: 'group-1', + worktreeId: 'wt-1', + contentType: 'browser', + label: 'Example', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0, + ...overrides + } + } + + const [unfocused] = buildSearchableBrowserPages({ + worktrees: [worktreeA], + repoMap, + worktreeOrder, + browserTabsByWorktree: { 'wt-1': [makeWorkspace()] }, + browserPagesByWorkspace: { 'ws-1': [makePage()] }, + unifiedTabsByWorktree: { 'wt-1': [browserTab({})] }, + activeBrowserTabId: null, + activeWorktreeId: null, + activeTabType: 'browser' + }) + expect(unfocused.lastActiveAt).toBeNull() + + const entries = buildSearchableBrowserPages({ + worktrees: [worktreeA], + repoMap, + worktreeOrder, + browserTabsByWorktree: { 'wt-1': [makeWorkspace({ pageIds: ['page-1', 'page-2'] })] }, + browserPagesByWorkspace: { + 'ws-1': [makePage(), makePage({ id: 'page-2', createdAt: 9000 })] + }, + unifiedTabsByWorktree: { 'wt-1': [browserTab({ lastFocusedAt: 4000 })] }, + activeBrowserTabId: null, + activeWorktreeId: null, + activeTabType: 'browser' + }) + + expect(entries.map((entry) => entry.lastActiveAt)).toEqual([4000, 9000]) + }) + it('feeds Cmd+J browser search the same ranking as the inline builder did', () => { const results = searchBrowserPages(buildFixture(), 'docs') diff --git a/src/renderer/src/lib/browser-palette-page-entries.ts b/src/renderer/src/lib/browser-palette-page-entries.ts index d252769e02c..795df372dc2 100644 --- a/src/renderer/src/lib/browser-palette-page-entries.ts +++ b/src/renderer/src/lib/browser-palette-page-entries.ts @@ -1,5 +1,6 @@ import { getWorktreeHostIdentity } from '../../../shared/worktree/host-qualified-identity' import type { BrowserPage, BrowserWorkspace } from '../../../shared/browser-workspace-types' +import type { Tab } from '../../../shared/tab-types' import type { Worktree } from '../../../shared/worktree/types' import type { ExecutionHostId } from '../../../shared/execution-host' import { isPaletteCurrentWorktree, resolvePaletteRepoForWorktree } from './palette-repo-resolution' @@ -17,6 +18,8 @@ export type BuildSearchableBrowserPagesOptions = { worktreeOrder: ReadonlyMap browserTabsByWorktree: Record browserPagesByWorkspace: Record + /** Source of browser recency: focus lives on the workspace's unified tab, not the page. */ + unifiedTabsByWorktree?: Record activeBrowserTabId: string | null activeWorktreeId: string | null activeWorkspaceExecutionHostId?: ExecutionHostId | null @@ -30,6 +33,7 @@ export function buildSearchableBrowserPages({ worktreeOrder, browserTabsByWorktree, browserPagesByWorkspace, + unifiedTabsByWorktree, activeBrowserTabId, activeWorktreeId, activeWorkspaceExecutionHostId, @@ -43,7 +47,14 @@ export function buildSearchableBrowserPages({ worktreeOrder.get(getWorktreeHostIdentity(worktree)) ?? worktreeOrder.get(worktree.id) ?? Number.MAX_SAFE_INTEGER + const focusedAtByWorkspaceId = new Map() + for (const tab of unifiedTabsByWorktree?.[worktree.id] ?? []) { + if (tab.contentType === 'browser' && tab.lastFocusedAt) { + focusedAtByWorkspaceId.set(tab.entityId, tab.lastFocusedAt) + } + } for (const workspace of browserTabsByWorktree[worktree.id] ?? []) { + const workspaceFocusedAt = focusedAtByWorkspaceId.get(workspace.id) for (const page of browserPagesByWorkspace[workspace.id] ?? []) { entries.push({ page, @@ -61,6 +72,8 @@ export function buildSearchableBrowserPages({ activeWorktreeId, activeWorkspaceExecutionHostId ), + // Never older than the page itself: it was opened while the workspace was focused. + lastActiveAt: workspaceFocusedAt ? Math.max(workspaceFocusedAt, page.createdAt) : null, document: buildSearchableBrowserPageDocument({ page, workspace, worktree, repoName }) }) } diff --git a/src/renderer/src/lib/browser-palette-search.test.ts b/src/renderer/src/lib/browser-palette-search.test.ts index 224f6dbcd4b..41cfd58f54d 100644 --- a/src/renderer/src/lib/browser-palette-search.test.ts +++ b/src/renderer/src/lib/browser-palette-search.test.ts @@ -363,6 +363,37 @@ describe('browser-palette-search', () => { expect(results[1].pageId).toBe('page-other') }) + it('breaks a rank tie between equally-matching pages by recency', () => { + // Ids are ordered so an id-only tiebreak would flip this expectation. + const entries = [ + makeEntry({ + page: makePage({ id: 'page-a-older', title: 'React Docs' }), + workspace: makeWorkspace({ id: 'ws-1' }), + worktree: makeWorktree(), + repoName: 'repo', + worktreeSortIndex: 0, + isCurrentPage: false, + isCurrentWorktree: false, + lastActiveAt: 1000 + }), + makeEntry({ + page: makePage({ id: 'page-z-newer', title: 'React Docs' }), + workspace: makeWorkspace({ id: 'ws-2' }), + worktree: makeWorktree(), + repoName: 'repo', + worktreeSortIndex: 0, + isCurrentPage: false, + isCurrentWorktree: false, + lastActiveAt: 5000 + }) + ] + + const results = searchBrowserPages(entries, 'react') + + expect(results.map((result) => result.pageId)).toEqual(['page-z-newer', 'page-a-older']) + expect(results[0].lastActiveAt).toBe(5000) + }) + it('matches the visible workspace label in browser search', () => { const results = searchBrowserPages( [ diff --git a/src/renderer/src/lib/browser-palette-search.ts b/src/renderer/src/lib/browser-palette-search.ts index ec1adeda66e..5540713d526 100644 --- a/src/renderer/src/lib/browser-palette-search.ts +++ b/src/renderer/src/lib/browser-palette-search.ts @@ -28,6 +28,8 @@ export type SearchableBrowserPage = { worktreeSortIndex: number isCurrentPage: boolean isCurrentWorktree: boolean + /** Last time the owning browser workspace was focused; null when never focused. */ + lastActiveAt?: number | null /** Normalized field index, built once per entry rather than per keystroke. */ document: PaletteDocument } @@ -55,6 +57,7 @@ export type BrowserPaletteSearchResult = { score: number qualityClass: PaletteResultQualityClass | null rank: PaletteDocumentRank | null + lastActiveAt?: number | null } export const BROWSER_PALETTE_QUERY_MAX_BYTES = 2 * 1024 @@ -129,8 +132,8 @@ function compareEmptyQueryResults( return compareText(a.title, b.title) } -// Why: empty-query browser ordering is intentionally deterministic and context-first. -// The palette should not invent hidden browser recency semantics. +// Why: empty-query browser ordering is intentionally deterministic and context-first; +// lastActiveAt only breaks ties between equally-ranked query matches. function positionScore(entry: SearchableBrowserPage): number { if (entry.isCurrentPage) { return entry.worktreeSortIndex * 100 - 4000 @@ -162,7 +165,8 @@ function baseResult(entry: SearchableBrowserPage): BrowserPaletteSearchResult { isCurrentWorktree: entry.isCurrentWorktree, score: positionScore(entry), qualityClass: null, - rank: null + rank: null, + lastActiveAt: entry.lastActiveAt ?? null } } @@ -208,8 +212,18 @@ export function searchBrowserPages( return results.sort((a, b) => a.rank && b.rank ? comparePaletteTabResults( - { rank: a.rank, positionScore: a.score, id: a.pageId }, - { rank: b.rank, positionScore: b.score, id: b.pageId } + { + rank: a.rank, + positionScore: a.score, + id: a.pageId, + lastActiveAt: a.lastActiveAt ?? undefined + }, + { + rank: b.rank, + positionScore: b.score, + id: b.pageId, + lastActiveAt: b.lastActiveAt ?? undefined + } ) : compareEmptyQueryResults(a, b) ) diff --git a/src/renderer/src/lib/cmd-j-section-leadership.test.ts b/src/renderer/src/lib/cmd-j-section-leadership.test.ts index bf0d082a501..620f32869a5 100644 --- a/src/renderer/src/lib/cmd-j-section-leadership.test.ts +++ b/src/renderer/src/lib/cmd-j-section-leadership.test.ts @@ -12,6 +12,7 @@ import type { PaletteDocumentRank } from './palette-match/palette-document' function rank(overrides: Partial = {}): PaletteDocumentRank { return { exactIntent: 1, + matchedDirectField: 0, wholeQuery: 3, worstQuality: 5, usesSupportingEvidence: 0, @@ -114,9 +115,15 @@ describe('ranked item comparison', () => { expect(comparePaletteRankedItems(strong, weak)).toBeLessThan(0) }) - it('falls back to the section order when match rank ties', () => { - const first = { rank: rank(), order: 1, id: 'z' } - const second = { rank: rank(), order: 2, id: 'a' } + it('prefers recently active item when match rank ties', () => { + const recent = { rank: rank(), order: 10, id: 'z', lastActiveAt: 2000 } + const older = { rank: rank(), order: 0, id: 'a', lastActiveAt: 1000 } + expect(comparePaletteRankedItems(recent, older)).toBeLessThan(0) + }) + + it('falls back to the section order when match rank and recency tie', () => { + const first = { rank: rank(), order: 1, id: 'z', lastActiveAt: 1000 } + const second = { rank: rank(), order: 2, id: 'a', lastActiveAt: 1000 } expect(comparePaletteRankedItems(first, second)).toBeLessThan(0) }) diff --git a/src/renderer/src/lib/cmd-j-section-leadership.ts b/src/renderer/src/lib/cmd-j-section-leadership.ts index 372eb2aebf6..093cacce567 100644 --- a/src/renderer/src/lib/cmd-j-section-leadership.ts +++ b/src/renderer/src/lib/cmd-j-section-leadership.ts @@ -31,9 +31,11 @@ export type PaletteRankedItem = { /** Existing smart-recency / list position, used only after match rank ties. */ order: number id: string + /** Timestamp of most recent activity (focus or agent interaction). */ + lastActiveAt?: number } -/** Match rank first, then the section's own recency order, then a stable id. */ +/** Match rank first, then recent activity, then positional order, then stable id. */ export function comparePaletteRankedItems(a: PaletteRankedItem, b: PaletteRankedItem): number { if (a.rank && b.rank) { const byRank = comparePaletteDocumentRank(a.rank, b.rank) @@ -43,6 +45,13 @@ export function comparePaletteRankedItems(a: PaletteRankedItem, b: PaletteRanked } else if (a.rank !== b.rank) { return a.rank ? -1 : 1 } + if (a.lastActiveAt !== b.lastActiveAt) { + const aTime = a.lastActiveAt ?? 0 + const bTime = b.lastActiveAt ?? 0 + if (aTime !== bTime) { + return bTime - aTime + } + } if (a.order !== b.order) { return a.order - b.order } diff --git a/src/renderer/src/lib/palette-match/indexed-field.ts b/src/renderer/src/lib/palette-match/indexed-field.ts index 5d87776bf47..0e87ae37895 100644 --- a/src/renderer/src/lib/palette-match/indexed-field.ts +++ b/src/renderer/src/lib/palette-match/indexed-field.ts @@ -30,6 +30,8 @@ export type PaletteFieldSource = { /** null marks a visible identity field; identity fields combine freely. */ evidenceId?: string | null identifier?: PaletteIdentifierOptions + /** Container-level fields (e.g. worktree/branch for tabs) demote when matched alone. */ + isContainer?: boolean } export type PaletteIndexedField = { @@ -40,6 +42,7 @@ export type PaletteIndexedField = { words: readonly PaletteWord[] evidenceId: string | null identifier: PaletteIdentifierOptions | null + isContainer: boolean } const IDENTIFIER_PREFIX_KINDS: ReadonlySet = new Set([ @@ -125,7 +128,8 @@ export function indexPaletteField(source: PaletteFieldSource): PaletteIndexedFie atoms: segments.atoms, words: segments.words, evidenceId: source.evidenceId ?? null, - identifier: source.identifier ?? null + identifier: source.identifier ?? null, + isContainer: Boolean(source.isContainer) } } diff --git a/src/renderer/src/lib/palette-match/match-document.ts b/src/renderer/src/lib/palette-match/match-document.ts index b5f7647ef28..9d19fe38220 100644 --- a/src/renderer/src/lib/palette-match/match-document.ts +++ b/src/renderer/src/lib/palette-match/match-document.ts @@ -154,14 +154,17 @@ function buildAssignments( } function rankAssignments(args: { + document: PaletteDocument assignments: readonly PaletteTokenAssignment[] usesEvidence: boolean wholeQuery: number exactIntent: boolean -}): { rank: PaletteDocumentRank; worstQuality: PaletteMatchQuality } { +}): { rank: PaletteDocumentRank; worstQuality: PaletteMatchQuality; isContainerOnly: boolean } { let worstQuality: PaletteMatchQuality = 'field-exact' let fuzzyTokenCount = 0 const fields = new Set() + // Derive from field metadata only: an evidence field can itself be a container. + let hasDirectHit = false for (const assignment of args.assignments) { if (paletteMatchQualityRank(assignment.quality) > paletteMatchQualityRank(worstQuality)) { @@ -171,12 +174,20 @@ function rankAssignments(args: { fuzzyTokenCount += 1 } fields.add(assignment.fieldId) + const field = args.document.fieldById.get(assignment.fieldId) + if (field && !field.isContainer) { + hasDirectHit = true + } } + const isContainerOnly = !hasDirectHit + return { worstQuality, + isContainerOnly, rank: { exactIntent: args.exactIntent ? 0 : 1, + matchedDirectField: isContainerOnly ? 1 : 0, wholeQuery: args.wholeQuery, worstQuality: paletteMatchQualityRank(worstQuality), usesSupportingEvidence: args.usesEvidence ? 1 : 0, @@ -274,7 +285,8 @@ export function matchPaletteDocument(args: { continue } const usedEvidenceId = built.usesEvidence ? evidenceId : null - const { rank, worstQuality } = rankAssignments({ + const { rank, worstQuality, isContainerOnly } = rankAssignments({ + document, assignments: built.assignments, usesEvidence: built.usesEvidence, wholeQuery, @@ -288,7 +300,8 @@ export function matchPaletteDocument(args: { ? 'exact-intent' : resolvePaletteResultQualityClass({ worstQuality, - usesSupportingEvidence: built.usesEvidence + usesSupportingEvidence: built.usesEvidence, + isContainerOnly }), rank, assignments: built.assignments, diff --git a/src/renderer/src/lib/palette-match/match-quality.ts b/src/renderer/src/lib/palette-match/match-quality.ts index 77f3c7aa8df..d400489b6be 100644 --- a/src/renderer/src/lib/palette-match/match-quality.ts +++ b/src/renderer/src/lib/palette-match/match-quality.ts @@ -59,11 +59,15 @@ export function paletteResultQualityClassRank(value: PaletteResultQualityClass): export function resolvePaletteResultQualityClass(args: { worstQuality: PaletteMatchQuality usesSupportingEvidence: boolean + isContainerOnly?: boolean }): PaletteResultQualityClass { - const { worstQuality, usesSupportingEvidence } = args + const { worstQuality, usesSupportingEvidence, isContainerOnly } = args if (isFuzzyPaletteMatchQuality(worstQuality)) { return 'fuzzy-evidence' } + if (isContainerOnly) { + return isExactPaletteMatchQuality(worstQuality) ? 'exact-evidence' : 'partial-evidence' + } if (usesSupportingEvidence) { return isExactPaletteMatchQuality(worstQuality) ? 'exact-evidence' : 'partial-evidence' } diff --git a/src/renderer/src/lib/palette-match/palette-document.ts b/src/renderer/src/lib/palette-match/palette-document.ts index 0e92875d493..2400921f6be 100644 --- a/src/renderer/src/lib/palette-match/palette-document.ts +++ b/src/renderer/src/lib/palette-match/palette-document.ts @@ -40,6 +40,7 @@ export type PaletteDocument = { /** Visible identity fields, cached because they carry the whole-query check. */ visibleFields: readonly PaletteIndexedField[] fieldsByEvidenceId: ReadonlyMap + fieldById: ReadonlyMap } export type PaletteDocumentInput = { @@ -80,7 +81,9 @@ export function buildPaletteDocument(input: PaletteDocumentInput): PaletteDocume const fields = indexPaletteFields([...input.visibleFields, ...evidenceSources]) const fieldsByEvidenceId = new Map() const visibleFields: PaletteIndexedField[] = [] + const fieldById = new Map() for (const field of fields) { + fieldById.set(field.id, field) if (!field.evidenceId) { visibleFields.push(field) continue @@ -103,7 +106,8 @@ export function buildPaletteDocument(input: PaletteDocumentInput): PaletteDocume evidenceUnits, renderOffsetByFieldId, visibleFields, - fieldsByEvidenceId + fieldsByEvidenceId, + fieldById } } @@ -125,6 +129,8 @@ export type PaletteSupportingEvidence = { export type PaletteDocumentRank = { /** 0 when a recognized exact intent (such as a task URL) produced this row. */ exactIntent: number + /** 0 when query matched at least one direct field; 1 when all matched tokens are container-only. */ + matchedDirectField: number /** 0 equality, 1 prefix, 2 word boundary, 3 none — whole query in visible text. */ wholeQuery: number worstQuality: number @@ -144,6 +150,7 @@ export type PaletteDocumentMatch = { const RANK_KEYS: readonly (keyof PaletteDocumentRank)[] = [ 'exactIntent', + 'matchedDirectField', 'wholeQuery', 'worstQuality', 'usesSupportingEvidence', diff --git a/src/renderer/src/lib/palette-match/palette-match-core.test.ts b/src/renderer/src/lib/palette-match/palette-match-core.test.ts index 6fe7878513f..8862165bc79 100644 --- a/src/renderer/src/lib/palette-match/palette-match-core.test.ts +++ b/src/renderer/src/lib/palette-match/palette-match-core.test.ts @@ -381,3 +381,35 @@ describe('typo distance', () => { expect(isWithinOnePaletteEdit(a, b)).toBe(expected) }) }) + +describe('container field matching', () => { + it('marks matchedDirectField as 1 and demotes quality class when all tokens land on container fields', () => { + const tabDoc: PaletteDocumentInput = { + id: 'tab-1', + visibleFields: [ + { id: 'title', profile: 'structured-label', text: 'README.md' }, + { id: 'worktree', profile: 'structured-label', text: 'STA-4360-feature', isContainer: true } + ], + evidence: [] + } + const match = run(tabDoc, '4360') + expect(match).not.toBeNull() + expect(match?.rank.matchedDirectField).toBe(1) + expect(match?.qualityClass).toBe('exact-evidence') + }) + + it('marks matchedDirectField as 0 when at least one token lands on a direct field', () => { + const tabDoc: PaletteDocumentInput = { + id: 'tab-1', + visibleFields: [ + { id: 'title', profile: 'structured-label', text: 'wsl-transcript-4360.ts' }, + { id: 'worktree', profile: 'structured-label', text: 'STA-4360-feature', isContainer: true } + ], + evidence: [] + } + const match = run(tabDoc, '4360') + expect(match).not.toBeNull() + expect(match?.rank.matchedDirectField).toBe(0) + expect(match?.qualityClass).toBe('exact-visible') + }) +}) diff --git a/src/renderer/src/lib/palette-match/tab-document.ts b/src/renderer/src/lib/palette-match/tab-document.ts index d03c09a7706..8c289931afc 100644 --- a/src/renderer/src/lib/palette-match/tab-document.ts +++ b/src/renderer/src/lib/palette-match/tab-document.ts @@ -72,13 +72,29 @@ function dedupeSecondaryTexts( export function buildPaletteTabDocument(input: PaletteTabDocumentInput): PaletteDocument { const fields: PaletteFieldSource[] = [ { id: PALETTE_TAB_TITLE_FIELD_ID, profile: 'structured-label', text: input.title }, - { id: PALETTE_TAB_WORKTREE_FIELD_ID, profile: 'structured-label', text: input.worktreeName }, - { id: PALETTE_TAB_BRANCH_FIELD_ID, profile: 'structured-label', text: input.branch }, - { id: PALETTE_TAB_REPO_FIELD_ID, profile: 'structured-label', text: input.repoName }, + { + id: PALETTE_TAB_WORKTREE_FIELD_ID, + profile: 'structured-label', + text: input.worktreeName, + isContainer: true + }, + { + id: PALETTE_TAB_BRANCH_FIELD_ID, + profile: 'structured-label', + text: input.branch, + isContainer: true + }, + { + id: PALETTE_TAB_REPO_FIELD_ID, + profile: 'structured-label', + text: input.repoName, + isContainer: true + }, { id: PALETTE_TAB_WORKSPACE_FIELD_ID, profile: 'structured-label', - text: input.workspaceLabel ?? '' + text: input.workspaceLabel ?? '', + isContainer: true } ] diff --git a/src/renderer/src/lib/palette-match/tab-match.ts b/src/renderer/src/lib/palette-match/tab-match.ts index d9c65000fa0..f40dd6de03d 100644 --- a/src/renderer/src/lib/palette-match/tab-match.ts +++ b/src/renderer/src/lib/palette-match/tab-match.ts @@ -12,7 +12,11 @@ import { } from './tab-document' import type { MatchRange } from './normalized-text' import type { PaletteResultQualityClass } from './match-quality' -import type { PaletteDocument, PaletteDocumentRank } from './palette-document' +import { + comparePaletteDocumentRank, + type PaletteDocument, + type PaletteDocumentRank +} from './palette-document' const NO_RANGES: readonly MatchRange[] = [] @@ -93,22 +97,21 @@ export type PaletteTabRankInputs = { /** Existing positional score: current tab, current worktree, then list order. */ positionScore: number id: string + /** Timestamp of most recent activity (focus or agent interaction). */ + lastActiveAt?: number } -const RANK_KEYS: readonly (keyof PaletteDocumentRank)[] = [ - 'exactIntent', - 'wholeQuery', - 'worstQuality', - 'usesSupportingEvidence', - 'fuzzyTokenCount', - 'fieldHopCount' -] - -/** Lexicographic match rank first, then the section's existing recency order. */ +/** Lexicographic match rank first, then recent activity, then positional order. */ export function comparePaletteTabResults(a: PaletteTabRankInputs, b: PaletteTabRankInputs): number { - for (const key of RANK_KEYS) { - if (a.rank[key] !== b.rank[key]) { - return a.rank[key] - b.rank[key] + const byRank = comparePaletteDocumentRank(a.rank, b.rank) + if (byRank !== 0) { + return byRank + } + if (a.lastActiveAt !== b.lastActiveAt) { + const aTime = a.lastActiveAt ?? 0 + const bTime = b.lastActiveAt ?? 0 + if (aTime !== bTime) { + return bTime - aTime } } if (a.positionScore !== b.positionScore) { diff --git a/src/renderer/src/lib/simulator-palette-search.test.ts b/src/renderer/src/lib/simulator-palette-search.test.ts index ccb4e469124..e1db78b0a64 100644 --- a/src/renderer/src/lib/simulator-palette-search.test.ts +++ b/src/renderer/src/lib/simulator-palette-search.test.ts @@ -82,6 +82,42 @@ function makeEntry(entry: Omit): SearchableS } } +describe('simulator-palette-search lastActiveAt', () => { + function entryFor(overrides: Partial): SearchableSimulatorTab { + return makeEntry({ + tab: makeTab(overrides), + worktree: makeWorktree(), + repoName: 'repo/mobile', + worktreeSortIndex: 0, + isCurrentTab: false, + isCurrentWorktree: true + }) + } + + it('is null when the tab was never focused', () => { + expect(searchSimulatorTabs([entryFor({})], '')[0].lastActiveAt).toBeNull() + }) + + it('carries the tab focus timestamp, never older than the tab itself', () => { + expect( + searchSimulatorTabs([entryFor({ createdAt: 5000, lastFocusedAt: 9000 })], '')[0].lastActiveAt + ).toBe(9000) + expect( + searchSimulatorTabs([entryFor({ createdAt: 5000, lastFocusedAt: 1000 })], '')[0].lastActiveAt + ).toBe(5000) + }) + + it('breaks a rank tie between equally-matching tabs by recency', () => { + // Ids are ordered so an id-only tiebreak would flip this expectation. + const older = entryFor({ id: 'sim-a-older', lastFocusedAt: 1000 }) + const newer = entryFor({ id: 'sim-z-newer', lastFocusedAt: 5000 }) + + const results = searchSimulatorTabs([older, newer], 'emulator') + + expect(results.map((result) => result.tabId)).toEqual(['sim-z-newer', 'sim-a-older']) + }) +}) + describe('simulator-palette-search', () => { it('keeps empty-query ordering deterministic and context-first', () => { const results = searchSimulatorTabs( diff --git a/src/renderer/src/lib/simulator-palette-search.ts b/src/renderer/src/lib/simulator-palette-search.ts index f5d1faecde1..b2a8594474a 100644 --- a/src/renderer/src/lib/simulator-palette-search.ts +++ b/src/renderer/src/lib/simulator-palette-search.ts @@ -54,6 +54,7 @@ export type SimulatorPaletteSearchResult = { score: number qualityClass: PaletteResultQualityClass | null rank: PaletteDocumentRank | null + lastActiveAt?: number | null } type SimulatorPaletteActiveTabType = 'browser' | 'editor' | 'terminal' | 'simulator' @@ -114,8 +115,8 @@ function compareEmptyQueryResults( return compareText(a.title, b.title) } -// Why: simulator tabs follow browser-tab Cmd+J ordering — deterministic and -// context-first until Orca tracks per-tab recency for this surface. +// Why: empty-query simulator ordering stays deterministic and context-first; +// lastActiveAt only breaks ties between equally-ranked query matches. function positionScore(entry: SearchableSimulatorTab): number { if (entry.isCurrentTab) { return entry.worktreeSortIndex * 100 - 4000 @@ -149,7 +150,11 @@ function baseResult(entry: SearchableSimulatorTab): SimulatorPaletteSearchResult isCurrentWorktree: entry.isCurrentWorktree, score: positionScore(entry), qualityClass: null, - rank: null + rank: null, + // Never older than the tab itself: creation is a focus event too. + lastActiveAt: entry.tab.lastFocusedAt + ? Math.max(entry.tab.lastFocusedAt, entry.tab.createdAt) + : null } } @@ -290,8 +295,18 @@ export function searchSimulatorTabs( return results.sort((a, b) => a.rank && b.rank ? comparePaletteTabResults( - { rank: a.rank, positionScore: a.score, id: a.tabId }, - { rank: b.rank, positionScore: b.score, id: b.tabId } + { + rank: a.rank, + positionScore: a.score, + id: a.tabId, + lastActiveAt: a.lastActiveAt ?? undefined + }, + { + rank: b.rank, + positionScore: b.score, + id: b.tabId, + lastActiveAt: b.lastActiveAt ?? undefined + } ) : compareEmptyQueryResults(a, b) ) diff --git a/src/renderer/src/lib/workspace-tab-agent-metadata.test.ts b/src/renderer/src/lib/workspace-tab-agent-metadata.test.ts index ab4658d5e20..7ad4526bd54 100644 --- a/src/renderer/src/lib/workspace-tab-agent-metadata.test.ts +++ b/src/renderer/src/lib/workspace-tab-agent-metadata.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest' import type { AgentStatusEntry } from '../../../shared/agent-status-types' -import { collectAgentMetadataForTerminal } from './workspace-tab-agent-metadata' +import { + collectAgentMetadataForTerminal, + maxAgentActivityAt, + type AgentMetadata +} from './workspace-tab-agent-metadata' function makeEntry(overrides: Partial = {}): AgentStatusEntry { return { @@ -39,4 +43,46 @@ describe('collectAgentMetadataForTerminal', () => { expect(metadata?.snippetCandidates).toContain('Fix checkout race') expect(metadata?.snippetCandidates).toContain('Checkout race') }) + + it('carries the live entry updatedAt through as lastActivityAt', () => { + const [metadata] = collectAgentMetadataForTerminal({ + terminalTabId: 'tab-1', + worktreeId: 'wt-1', + agentStatusByPaneKey: { 'tab-1:leaf-1': makeEntry({ updatedAt: 5000 }) }, + retainedAgentsByPaneKey: {}, + sleepingAgentSessionsByPaneKey: {} + }) + + expect(metadata?.lastActivityAt).toBe(5000) + }) +}) + +function makeMetadata(overrides: Partial = {}): AgentMetadata { + return { + paneKey: 'pane-1', + textParts: [], + snippetCandidates: [], + lastActivityAt: 0, + ...overrides + } +} + +describe('maxAgentActivityAt', () => { + it('returns null when there is no metadata', () => { + expect(maxAgentActivityAt([])).toBeNull() + }) + + it('returns the highest lastActivityAt across panes', () => { + const metadata = [ + makeMetadata({ paneKey: 'pane-1', lastActivityAt: 1000 }), + makeMetadata({ paneKey: 'pane-2', lastActivityAt: 3000 }), + makeMetadata({ paneKey: 'pane-3', lastActivityAt: 2000 }) + ] + + expect(maxAgentActivityAt(metadata)).toBe(3000) + }) + + it('ignores non-positive lastActivityAt values', () => { + expect(maxAgentActivityAt([makeMetadata({ lastActivityAt: 0 })])).toBeNull() + }) }) diff --git a/src/renderer/src/lib/workspace-tab-agent-metadata.ts b/src/renderer/src/lib/workspace-tab-agent-metadata.ts index c93c8d33a4e..a2a91838191 100644 --- a/src/renderer/src/lib/workspace-tab-agent-metadata.ts +++ b/src/renderer/src/lib/workspace-tab-agent-metadata.ts @@ -6,6 +6,18 @@ export type AgentMetadata = { paneKey: string textParts: string[] snippetCandidates: string[] + lastActivityAt: number +} + +/** Latest agent activity across a tab's panes, or null if it has none. */ +export function maxAgentActivityAt(metadata: readonly AgentMetadata[]): number | null { + let max: number | null = null + for (const entry of metadata) { + if (entry.lastActivityAt > 0 && (max === null || entry.lastActivityAt > max)) { + max = entry.lastActivityAt + } + } + return max } export type WorkspaceTabAgentMetadataState = { @@ -68,7 +80,7 @@ function agentRecordMatchesTab({ function collectLiveMetadata( entry: AgentStatusEntry -): Pick { +): Pick { const textParts: string[] = [] const snippetCandidates: string[] = [] addText(textParts, entry.orchestration?.displayName) @@ -86,12 +98,12 @@ function collectLiveMetadata( addText(textParts, historyEntry.prompt) addText(snippetCandidates, historyEntry.prompt) } - return { textParts, snippetCandidates } + return { textParts, snippetCandidates, lastActivityAt: entry.updatedAt } } function collectSleepingMetadata( record: SleepingAgentSessionRecord -): Pick { +): Pick { const textParts: string[] = [] const snippetCandidates: string[] = [] addText(textParts, record.prompt) @@ -101,7 +113,7 @@ function collectSleepingMetadata( addText(textParts, record.terminalTitle) addText(snippetCandidates, record.terminalTitle) addProviderSession(textParts, record.providerSession) - return { textParts, snippetCandidates } + return { textParts, snippetCandidates, lastActivityAt: record.updatedAt } } export function collectAgentMetadataForTerminal({ diff --git a/src/renderer/src/lib/workspace-tab-agent-snippet-match.ts b/src/renderer/src/lib/workspace-tab-agent-snippet-match.ts index f5b47aa1871..3a67ed6a11b 100644 --- a/src/renderer/src/lib/workspace-tab-agent-snippet-match.ts +++ b/src/renderer/src/lib/workspace-tab-agent-snippet-match.ts @@ -21,6 +21,7 @@ import type { AgentMetadata } from './workspace-tab-agent-metadata' */ const AGENT_SNIPPET_RANK: PaletteDocumentRank = { exactIntent: 1, + matchedDirectField: 0, wholeQuery: 3, worstQuality: paletteMatchQualityRank(PALETTE_MATCH_QUALITIES.at(-1) as PaletteMatchQuality) + 1, usesSupportingEvidence: 1, diff --git a/src/renderer/src/lib/workspace-tab-palette-activation.test.ts b/src/renderer/src/lib/workspace-tab-palette-activation.test.ts index 59116fd8243..3ba4cf97afd 100644 --- a/src/renderer/src/lib/workspace-tab-palette-activation.test.ts +++ b/src/renderer/src/lib/workspace-tab-palette-activation.test.ts @@ -118,6 +118,7 @@ function makeResult( score: 0, qualityClass: null, rank: null, + lastActiveAt: null, ...overrides } } diff --git a/src/renderer/src/lib/workspace-tab-palette-entry-builder.ts b/src/renderer/src/lib/workspace-tab-palette-entry-builder.ts index 2b70345904d..4eea1c3b9d6 100644 --- a/src/renderer/src/lib/workspace-tab-palette-entry-builder.ts +++ b/src/renderer/src/lib/workspace-tab-palette-entry-builder.ts @@ -92,9 +92,12 @@ function isCurrentWorkspaceTab({ ) { return false } + if (activeUnifiedTabId) { + return activeUnifiedTabId === tab.id + } const visibleType = tab.contentType === 'terminal' ? 'terminal' : 'editor' const storedType = activeTabTypeByWorktree[tab.worktreeId] ?? activeTabType - if (storedType !== visibleType || activeUnifiedTabId !== tab.id) { + if (storedType !== visibleType) { return false } return visibleType === 'terminal' diff --git a/src/renderer/src/lib/workspace-tab-palette-results.test.ts b/src/renderer/src/lib/workspace-tab-palette-results.test.ts new file mode 100644 index 00000000000..a62c6870ae8 --- /dev/null +++ b/src/renderer/src/lib/workspace-tab-palette-results.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, it } from 'vitest' +import type { Tab, TabContentType } from '../../../shared/tab-types' +import type { Worktree } from '../../../shared/worktree/types' +import { buildPaletteTabDocument } from './palette-match/tab-document' +import { searchWorkspaceTabs } from './workspace-tab-palette-results' +import type { SearchableWorkspaceTab } from './workspace-tab-palette-search' + +const REPO_NAME = 'octo/rocket' +const WORKTREE_NAME = 'Aurora Workspace' +const BRANCH_NAME = 'main' + +function makeWorktree(overrides: Partial = {}): Worktree { + return { + id: 'wt-1', + repoId: 'repo-1', + path: '/tmp/wt-1', + head: 'abc123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: false, + displayName: WORKTREE_NAME, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + ...overrides + } +} + +function makeTab(id: string, contentType: TabContentType, createdAt: number): Tab { + return { + id, + entityId: `${id}-entity`, + groupId: 'group-1', + worktreeId: 'wt-1', + contentType, + label: id, + customLabel: null, + color: null, + sortOrder: 0, + createdAt + } +} + +function makeEntry({ + id = 'tab-1', + contentType = 'terminal', + createdAt = 0, + worktree = makeWorktree(), + agentLastActivityAt +}: { + id?: string + contentType?: 'terminal' | 'editor' + createdAt?: number + worktree?: Worktree + agentLastActivityAt?: number +} = {}): SearchableWorkspaceTab { + const title = id + return { + tab: makeTab(id, contentType, createdAt) as SearchableWorkspaceTab['tab'], + worktree, + repoName: REPO_NAME, + worktreeSortIndex: 0, + groupSortIndex: 0, + tabSortIndex: 0, + occupantAgent: null, + title, + secondaryText: '', + titleSearchText: title, + secondarySearchTexts: [], + document: buildPaletteTabDocument({ + id, + title, + secondaryTexts: [], + worktreeName: WORKTREE_NAME, + branch: BRANCH_NAME, + repoName: REPO_NAME + }), + agentMetadata: + agentLastActivityAt === undefined + ? [] + : [ + { + paneKey: `${id}-pane`, + textParts: [], + snippetCandidates: [], + lastActivityAt: agentLastActivityAt + } + ], + isCurrentTab: false, + isCurrentWorktree: true + } +} + +describe('searchWorkspaceTabs lastActiveAt', () => { + it('is null when neither agent activity nor worktree activity is known', () => { + const [result] = searchWorkspaceTabs([makeEntry()], '') + expect(result.lastActiveAt).toBeNull() + }) + + it('falls back to worktree PTY activity for editor tabs with no agent metadata', () => { + const entry = makeEntry({ + contentType: 'editor', + worktree: makeWorktree({ lastActivityAt: 5000 }) + }) + const [result] = searchWorkspaceTabs([entry], '') + expect(result.lastActiveAt).toBe(5000) + }) + + it('prefers agent activity over worktree activity when agent activity is newer', () => { + const entry = makeEntry({ + worktree: makeWorktree({ lastActivityAt: 1000 }), + agentLastActivityAt: 9000 + }) + const [result] = searchWorkspaceTabs([entry], '') + expect(result.lastActiveAt).toBe(9000) + }) + + it('prefers agent activity even when it is older than worktree activity', () => { + const entry = makeEntry({ + worktree: makeWorktree({ lastActivityAt: 9000 }), + agentLastActivityAt: 1000 + }) + const [result] = searchWorkspaceTabs([entry], '') + expect(result.lastActiveAt).toBe(1000) + }) + + it('clamps to the tab creation time when the activity signal predates it', () => { + const entry = makeEntry({ + createdAt: 4000, + worktree: makeWorktree({ lastActivityAt: 1000 }) + }) + const [result] = searchWorkspaceTabs([entry], '') + expect(result.lastActiveAt).toBe(4000) + }) + + it('uses tab lastFocusedAt when no agent metadata is present', () => { + const entry = makeEntry({ + id: 'tab-focused', + createdAt: 1000 + }) + entry.tab.lastFocusedAt = 6000 + const [result] = searchWorkspaceTabs([entry], '') + expect(result.lastActiveAt).toBe(6000) + }) +}) + +describe('searchWorkspaceTabs ranking', () => { + it('ranks direct tab title matches ahead of container-only worktree matches', () => { + const directEntry = makeEntry({ id: 'README-4360' }) + const containerEntry = makeEntry({ id: 'unrelated-file' }) + // Both are in WORKTREE_NAME 'Aurora Workspace', but suppose worktree has 4360 + directEntry.document = buildPaletteTabDocument({ + id: 'tab-direct', + title: 'STA-4360-fix.ts', + secondaryTexts: [], + worktreeName: 'STA-4360', + branch: 'main', + repoName: 'repo' + }) + containerEntry.document = buildPaletteTabDocument({ + id: 'tab-container', + title: 'other-file.ts', + secondaryTexts: [], + worktreeName: 'STA-4360', + branch: 'main', + repoName: 'repo' + }) + + const results = searchWorkspaceTabs([containerEntry, directEntry], '4360') + expect(results).toHaveLength(2) + expect(results[0].tabId).toBe('README-4360') + expect(results[0].rank?.matchedDirectField).toBe(0) + expect(results[1].tabId).toBe('unrelated-file') + expect(results[1].rank?.matchedDirectField).toBe(1) + }) + + it('breaks tie between two container-matching tabs using lastActiveAt recency', () => { + const olderTab = makeEntry({ id: 'older-tab' }) + const newerTab = makeEntry({ id: 'newer-tab' }) + olderTab.document = buildPaletteTabDocument({ + id: 'older', + title: 'file-a.ts', + secondaryTexts: [], + worktreeName: 'STA-4360', + branch: 'main', + repoName: 'repo' + }) + newerTab.document = buildPaletteTabDocument({ + id: 'newer', + title: 'file-b.ts', + secondaryTexts: [], + worktreeName: 'STA-4360', + branch: 'main', + repoName: 'repo' + }) + olderTab.tab.lastFocusedAt = 1000 + newerTab.tab.lastFocusedAt = 5000 + + const results = searchWorkspaceTabs([olderTab, newerTab], '4360') + expect(results).toHaveLength(2) + expect(results[0].tabId).toBe('newer-tab') + expect(results[1].tabId).toBe('older-tab') + }) +}) diff --git a/src/renderer/src/lib/workspace-tab-palette-results.ts b/src/renderer/src/lib/workspace-tab-palette-results.ts index 26ad64b16c5..8e6989b7754 100644 --- a/src/renderer/src/lib/workspace-tab-palette-results.ts +++ b/src/renderer/src/lib/workspace-tab-palette-results.ts @@ -10,6 +10,7 @@ import { resolveWorktreeDisplayName } from './worktree-default-display-name' import { matchWorkspaceTabAgentSnippet } from './workspace-tab-agent-snippet-match' +import { maxAgentActivityAt } from './workspace-tab-agent-metadata' import type { ExecutionHostId } from '../../../shared/execution-host' import type { MatchRange } from './palette-match/normalized-text' import type { PaletteDocumentRank } from './palette-match/palette-document' @@ -47,6 +48,8 @@ export type WorkspaceTabPaletteSearchResult = { score: number qualityClass: PaletteResultQualityClass | null rank: PaletteDocumentRank | null + /** Most recent activity for this tab, or null when nothing is known. */ + lastActiveAt: number | null } function compareText(a: string, b: string): number { @@ -82,6 +85,19 @@ function positionScore(entry: SearchableWorkspaceTab): number { return entry.isCurrentWorktree ? base - 1000 : base } +function resolveWorkspaceTabLastActiveAt(entry: SearchableWorkspaceTab): number | null { + // Agent activity wins when present; then explicit tab focus; otherwise fall back to worktree. + const candidate = + maxAgentActivityAt(entry.agentMetadata) ?? + entry.tab.lastFocusedAt ?? + (entry.worktree.lastActivityAt || null) + if (candidate == null) { + return null + } + // Never report activity older than the tab itself (e.g. a stale worktree signal). + return Math.max(candidate, entry.tab.createdAt) +} + function baseResult(entry: SearchableWorkspaceTab): WorkspaceTabPaletteSearchResult { return { ...(entry.worktree.hostId ? { executionHostId: entry.worktree.hostId } : {}), @@ -106,7 +122,8 @@ function baseResult(entry: SearchableWorkspaceTab): WorkspaceTabPaletteSearchRes isCurrentWorktree: entry.isCurrentWorktree, score: positionScore(entry), qualityClass: null, - rank: null + rank: null, + lastActiveAt: resolveWorkspaceTabLastActiveAt(entry) } } @@ -177,8 +194,18 @@ export function searchWorkspaceTabs( return results.sort((a, b) => a.rank && b.rank ? comparePaletteTabResults( - { rank: a.rank, positionScore: a.score, id: a.tabId }, - { rank: b.rank, positionScore: b.score, id: b.tabId } + { + rank: a.rank, + positionScore: a.score, + id: a.tabId, + lastActiveAt: a.lastActiveAt ?? undefined + }, + { + rank: b.rank, + positionScore: b.score, + id: b.tabId, + lastActiveAt: b.lastActiveAt ?? undefined + } ) : compareEmptyQueryResults(a, b) ) diff --git a/src/renderer/src/lib/worktree-palette-task-url-result.ts b/src/renderer/src/lib/worktree-palette-task-url-result.ts index 54c6e265c2d..303083dff92 100644 --- a/src/renderer/src/lib/worktree-palette-task-url-result.ts +++ b/src/renderer/src/lib/worktree-palette-task-url-result.ts @@ -38,6 +38,7 @@ export function buildWorktreePaletteTaskUrlResult(args: { qualityClass: 'exact-intent', rank: { exactIntent: 0, + matchedDirectField: 0, wholeQuery: 0, worstQuality: 0, usesSupportingEvidence: 1, diff --git a/src/renderer/src/store/slices/tabs-unread-and-focus.test.ts b/src/renderer/src/store/slices/tabs-unread-and-focus.test.ts index 09e03855e7f..f27a091e622 100644 --- a/src/renderer/src/store/slices/tabs-unread-and-focus.test.ts +++ b/src/renderer/src/store/slices/tabs-unread-and-focus.test.ts @@ -79,6 +79,35 @@ describe('TabsSlice', () => { }) // Ghostty "show until interact": BEL always marks unread (even focused/visible tabs); only user interaction via clearTerminalTabUnread dismisses it. + describe('lastFocusedAt', () => { + it('stamps a newly created tab that activates', () => { + const before = Date.now() + const tab = store.getState().createUnifiedTab(WT, 'terminal') + + const stored = store.getState().unifiedTabsByWorktree[WT].find((t) => t.id === tab.id) + expect(stored?.lastFocusedAt).toBeGreaterThanOrEqual(before) + }) + + it('leaves a background-created tab unstamped', () => { + const tab = store.getState().createUnifiedTab(WT, 'terminal', { activate: false }) + + const stored = store.getState().unifiedTabsByWorktree[WT].find((t) => t.id === tab.id) + expect(stored?.lastFocusedAt).toBeUndefined() + }) + + it('stamps a tab created into a new split group', () => { + const source = store.getState().createUnifiedTab(WT, 'terminal') + const before = Date.now() + const split = store.getState().createUnifiedTabInSplit(WT, 'terminal', { + sourceGroupId: source.groupId, + splitDirection: 'right' + }) + + const stored = store.getState().unifiedTabsByWorktree[WT].find((t) => t.id === split?.id) + expect(stored?.lastFocusedAt).toBeGreaterThanOrEqual(before) + }) + }) + describe('markTerminalTabUnread', () => { it('marks the tab even when it is active in a visible split group of the active worktree', () => { // Group A: the worktree's root group (implicit from createUnifiedTab), populated with tabA. diff --git a/src/renderer/src/store/slices/tabs.ts b/src/renderer/src/store/slices/tabs.ts index b1f1392d82f..9b81774a1df 100644 --- a/src/renderer/src/store/slices/tabs.ts +++ b/src/renderer/src/store/slices/tabs.ts @@ -854,6 +854,8 @@ export const createTabsSlice: StateCreator = (set, } } + const shouldActivate = init?.activate ?? true + const createdAt = Date.now() created = { id, entityId: init?.entityId ?? id, @@ -869,13 +871,14 @@ export const createTabsSlice: StateCreator = (set, customLabel: init?.customLabel ?? null, color: init?.color ?? null, sortOrder: nextOrder.length, - createdAt: Date.now(), + createdAt, + // Why: creating an active tab is a focus event; Cmd+J recency reads lastFocusedAt. + ...(shouldActivate ? { lastFocusedAt: createdAt } : {}), isPreview: init?.isPreview, isPinned: init?.isPinned } nextOrder = dedupeTabOrder([...nextOrder, created.id]) - const shouldActivate = init?.activate ?? true const nextActiveTabId = shouldActivate ? created.id : (group.activeTabId ?? created.id) const sanitizedRecent = sanitizeRecentTabIds(group.recentTabIds, nextOrder) // Why: automation-created browser tabs must paint without stealing the visible group selection from the user's current tab. @@ -925,6 +928,7 @@ export const createTabsSlice: StateCreator = (set, const currentLayout = state.layoutByWorktree[worktreeId] ?? ({ type: 'leaf', groupId: target.sourceGroupId } as const) + const createdAt = Date.now() const createdTab: Tab = { id, entityId: init?.entityId ?? id, @@ -940,7 +944,9 @@ export const createTabsSlice: StateCreator = (set, customLabel: init?.customLabel ?? null, color: init?.color ?? null, sortOrder: 0, - createdAt: Date.now(), + createdAt, + // Why: creating an active tab is a focus event; Cmd+J recency reads lastFocusedAt. + ...(shouldActivate ? { lastFocusedAt: createdAt } : {}), isPreview: init?.isPreview, isPinned: init?.isPinned } @@ -1059,14 +1065,18 @@ export const createTabsSlice: StateCreator = (set, })() : state.unreadTerminalTabs return { - unifiedTabsByWorktree: opts?.preservePreview - ? state.unifiedTabsByWorktree - : { - ...state.unifiedTabsByWorktree, - [worktreeId]: (state.unifiedTabsByWorktree[worktreeId] ?? []).map((item) => - item.id === tabId ? { ...item, isPreview: false } : item - ) - }, + unifiedTabsByWorktree: { + ...state.unifiedTabsByWorktree, + [worktreeId]: (state.unifiedTabsByWorktree[worktreeId] ?? []).map((item) => + item.id === tabId + ? { + ...item, + isPreview: opts?.preservePreview ? item.isPreview : false, + lastFocusedAt: Date.now() + } + : item + ) + }, groupsByWorktree: { ...state.groupsByWorktree, [worktreeId]: (state.groupsByWorktree[worktreeId] ?? []).map((group) => diff --git a/src/shared/tab-types.ts b/src/shared/tab-types.ts index e456c1df412..2211668e615 100644 --- a/src/shared/tab-types.ts +++ b/src/shared/tab-types.ts @@ -58,6 +58,8 @@ export type Tab = { * underneath; `'terminal'` (the default for legacy/missing) shows the raw * xterm. Optional so sessions persisted before this field hydrate cleanly. */ viewMode?: 'terminal' | 'chat' + /** Timestamp when the tab was last focused / activated by the user. */ + lastFocusedAt?: number } export type TabGroup = { From d7a23c84a9f68c88167bc68736562686fa2a53b0 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:46:12 -0700 Subject: [PATCH 010/111] fix(pty): keep a CPR reply from overtaking a deferred colour reply (#15559) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A background-colour probe writes `OSC 11 ;? ST` then `CSI 6n` and reads exactly one response, using the CPR as its sentinel: a non-OSC first response means "unsupported" and it stops draining. #13309 routed live cooked-echo-risk replies through the ECHO-probe deferral while CPR kept the immediate path, so the CPR overtook the colour reply, the prober gave up, and the stray `ESC ]` was left in the tty for the next program — `gh auth login` died on it with an escape-sequence error. Queue a reply that needs no echo containment behind ones that do, FIFO, and only while something is actually deferred, so latency-critical replies stay immediate on every other path. Windows is unaffected: only posix-pty defers, so the queue is always empty there. Also: an in-flight echo probe is already the write continuation, so re-arming the timer for a queued reply would fork a second stty and throw away the first verdict; and teardown now hands queued uncontained writes to the pty best-effort instead of dropping bytes the caller was told were sent. Known scope limit, pinned by tests and tracked for follow-up: the guarantee is FIFO among recognised query replies, not over every byte — a reply coalesced with a keystroke, and ordinary typed input, still bypass the queue. Both were unordered before this change too. --- src/main/daemon/session.ts | 7 +- src/main/providers/local-pty-provider.ts | 12 +- src/relay/pty-handler.ts | 7 +- ...y-startup-ingress-live-query-reply.test.ts | 160 ++++++++++++++++++ src/shared/pty-startup-ingress.ts | 17 +- src/shared/pty-startup-reply-delivery.ts | 66 +++++++- src/shared/terminal-query-reply.test.ts | 103 ++++++++++- src/shared/terminal-query-reply.ts | 35 +++- 8 files changed, 368 insertions(+), 39 deletions(-) diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index a6e5d4a3210..3d15af8c902 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -12,7 +12,7 @@ import type { SessionOptions } from './session-options' import type { TuiAgent } from '../../shared/tui-agent' import { randomUUID } from 'node:crypto' import { PtyStartupIngress } from '../../shared/pty-startup-ingress' -import { extractOnlyCookedEchoSafeQueryReplies } from '../../shared/terminal-query-reply' +import { takeLiveQueryReply } from '../../shared/terminal-query-reply' import type { SessionState, ShellReadyState, @@ -132,10 +132,7 @@ export class Session { } // Daemon POSIX PTYs need the local provider's cooked-echo containment (#13137). - if ( - extractOnlyCookedEchoSafeQueryReplies(data) && - this.startupIngress.answerLiveQueryReply(data) - ) { + if (takeLiveQueryReply(this.startupIngress, data)) { return } diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 0ed81da8293..f7da3438b8e 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -72,7 +72,7 @@ import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../shared/hermes-startup-query import { PhysicalExitTracker } from '../../shared/physical-exit-tracker' import { mergeGitConfigEnvProtocol } from '../../shared/git-credential-prompt-env' import { PtyStartupIngress, type PtyIngressEmission } from '../../shared/pty-startup-ingress' -import { extractOnlyCookedEchoSafeQueryReplies } from '../../shared/terminal-query-reply' +import { takeLiveQueryReply } from '../../shared/terminal-query-reply' import { resolvePtyOwnerBackend } from '../../shared/pty-owner-backend' import { signalPosixPtyForegroundGroup } from '../pty/posix-pty-foreground-group' import { readPtsName } from '../pty/node-pty-pts-name' @@ -1192,12 +1192,10 @@ export class LocalPtyProvider implements IPtyProvider { return ptyProcesses.has(id) } write(id: string, data: string): boolean { - // Cooked PTYs echo private DSR/OSC replies; CPR/DA remain immediate (#13137, #7329). - if (extractOnlyCookedEchoSafeQueryReplies(data)) { - const ingress = startupIngressByPty.get(id) - if (ingress?.answerLiveQueryReply(data)) { - return true - } + // Cooked PTYs echo private DSR/OSC replies; CPR/DA stay immediate unless one of + // those is still deferred, which they must not overtake (#13137, #7329). + if (takeLiveQueryReply(startupIngressByPty.get(id), data)) { + return true } const proc = ptyProcesses.get(id) if (!proc) { diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 52401a65476..de9cc799b3d 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -58,7 +58,7 @@ import { parsePtyStartupIngressIntent, type PtyIngressEmission } from '../shared/pty-startup-ingress' -import { extractOnlyCookedEchoSafeQueryReplies } from '../shared/terminal-query-reply' +import { takeLiveQueryReply } from '../shared/terminal-query-reply' import { resolvePtyOwnerBackend, type PtyOwnerBackend } from '../shared/pty-owner-backend' import { RecentPtyOutputBuffer } from '../main/runtime/recent-pty-output-buffer' import { expandWindowsPathEnvironmentVariables } from '../shared/windows-environment-expansion' @@ -1881,10 +1881,7 @@ export class PtyHandler { this.lastInputAtByPty.set(id, performance.now()) this.interactiveOutputCharsByPty.set(id, 0) // Relay PTYs need the local provider's cooked-echo containment (#13137). - if ( - extractOnlyCookedEchoSafeQueryReplies(data) && - managed.startupIngress?.answerLiveQueryReply(data) - ) { + if (takeLiveQueryReply(managed.startupIngress, data)) { return } managed.pty.write(data) diff --git a/src/shared/pty-startup-ingress-live-query-reply.test.ts b/src/shared/pty-startup-ingress-live-query-reply.test.ts index 299bf73f214..779c1c57fb2 100644 --- a/src/shared/pty-startup-ingress-live-query-reply.test.ts +++ b/src/shared/pty-startup-ingress-live-query-reply.test.ts @@ -15,6 +15,8 @@ const COLOR_SCHEME_REPLY = mode2031SequenceFor('dark') // CSI has only a caret echo; OSC also has readline's rewritten echo. const POSIX_CSI_COOKED_ECHO = (reply: string): string => reply.replaceAll('\x1b', '^[') const OSC_COLOR_REPLY = '\x1b]11;rgb:00/00/00\x07' +// Latency-critical, so it stays off the contained path — but must not overtake one. +const CPR_REPLY = '\x1b[6;1R' const POSIX_OSC_COOKED_ECHOES = [ (reply: string): string => reply.replaceAll('\x1b', '^['), (reply: string): string => reply.replaceAll('\x1b]', '\x07').replaceAll('\x1b\\', '') @@ -162,6 +164,164 @@ describe('PtyStartupIngress live query replies (#13137)', () => { ingress.drainAndClose() }) + // termenv writes `OSC 11 ;? ST` then `CSI 6n` and stops reading at the CPR, so a + // CPR that overtakes the still-deferred color reply strands it for the next program + // (`gh auth login` -> "unexpected escape sequence from terminal: ['\x1b' ']']"). + it('holds a CPR reply behind a still-deferred color reply', async () => { + vi.useFakeTimers() + const writes: string[] = [] + const probe = scriptedEchoProbe('echoing', 'quiet') + const ingress = new PtyStartupIngress({ + ownerBackend: 'posix-pty', + echoProbe: probe, + write: (data) => writes.push(data), + onEmission: () => {} + }) + + expect(ingress.answerLiveQueryReply(OSC_COLOR_REPLY)).toBe(true) + await vi.advanceTimersByTimeAsync(0) + expect(writes).toEqual([]) + expect(ingress.writeQueryReplyInOrder(CPR_REPLY)).toBe(true) + await vi.advanceTimersByTimeAsync(20) + expect(writes).toEqual([OSC_COLOR_REPLY, CPR_REPLY]) + ingress.drainAndClose() + }) + + it('leaves a CPR reply on the caller immediate path when nothing is deferred', async () => { + vi.useFakeTimers() + const writes: string[] = [] + const ingress = new PtyStartupIngress({ + ownerBackend: 'posix-pty', + echoProbe: scriptedEchoProbe('quiet'), + write: (data) => writes.push(data), + onEmission: () => {} + }) + + expect(ingress.writeQueryReplyInOrder(CPR_REPLY)).toBe(false) + expect(ingress.answerLiveQueryReply(OSC_COLOR_REPLY)).toBe(true) + await vi.advanceTimersByTimeAsync(0) + expect(writes).toEqual([OSC_COLOR_REPLY]) + expect(ingress.writeQueryReplyInOrder(CPR_REPLY)).toBe(false) + ingress.drainAndClose() + }) + + it('does not project an ordered CPR reply as an echo to swallow', async () => { + vi.useFakeTimers() + const emissions: PtyIngressEmission[] = [] + const ingress = new PtyStartupIngress({ + ownerBackend: 'posix-pty', + // Never settles, so the budget-expiry flush runs with kernelEchoImpossible + // false — the only verdict under which the caret shape IS armed, so this is + // what discriminates the uncontained branch from the contained one. + echoProbe: () => new Promise(() => {}), + write: () => {}, + onEmission: (emission) => emissions.push(emission) + }) + + expect(ingress.answerLiveQueryReply(OSC_COLOR_REPLY)).toBe(true) + await vi.advanceTimersByTimeAsync(0) + expect(ingress.writeQueryReplyInOrder(CPR_REPLY)).toBe(true) + await vi.advanceTimersByTimeAsync(200) + // The CPR rode the queue for order only, so its caret shape is ordinary output. + ingress.accept(POSIX_CSI_COOKED_ECHO(CPR_REPLY)) + expect(visible(emissions)).toBe(POSIX_CSI_COOKED_ECHO(CPR_REPLY)) + ingress.drainAndClose() + }) + + // An ordered reply must not restart the ECHO probe: attemptPendingWrites nulls + // writeTimer on entry, so re-arming forks a second stty and discards the in-flight + // verdict, delaying the contained reply this queue exists to deliver first. + it('queues ordered replies without spawning extra echo probes', async () => { + vi.useFakeTimers() + const counts: number[] = [] + for (const orderedCount of [0, 8]) { + const writes: string[] = [] + let calls = 0 + // Never settles, so the probe stays in flight across the whole queueing window. + const probe = (): Promise => { + calls += 1 + return new Promise(() => {}) + } + const ingress = new PtyStartupIngress({ + ownerBackend: 'posix-pty', + echoProbe: probe, + write: (data) => writes.push(data), + onEmission: () => {} + }) + + expect(ingress.answerLiveQueryReply(OSC_COLOR_REPLY)).toBe(true) + await vi.advanceTimersByTimeAsync(0) + for (let i = 0; i < orderedCount; i += 1) { + ingress.writeQueryReplyInOrder(CPR_REPLY) + } + await vi.advanceTimersByTimeAsync(200) + expect(writes[0]).toBe(OSC_COLOR_REPLY) + expect(writes).toHaveLength(1 + orderedCount) + counts.push(calls) + ingress.drainAndClose() + } + expect(counts).toEqual([1, 1]) + }) + + it('keeps holding a contained reply to its budget while the probe reports echoing', async () => { + vi.useFakeTimers() + const writes: string[] = [] + const probe = scriptedEchoProbe('echoing') + const ingress = new PtyStartupIngress({ + ownerBackend: 'posix-pty', + echoProbe: probe, + write: (data) => writes.push(data), + onEmission: () => {} + }) + + expect(ingress.answerLiveQueryReply(OSC_COLOR_REPLY)).toBe(true) + await vi.advanceTimersByTimeAsync(0) + for (let i = 0; i < 8; i += 1) { + ingress.writeQueryReplyInOrder(CPR_REPLY) + } + // A burst of ordered replies must not exhaust the probe-start budget and force an + // early unprobed flush, which would paint the reply onto a still-cooked prompt. + await vi.advanceTimersByTimeAsync(199) + expect(writes).toEqual([]) + await vi.advanceTimersByTimeAsync(2) + expect(writes[0]).toBe(OSC_COLOR_REPLY) + ingress.drainAndClose() + }) + + it('hands a queued ordered reply to the pty at teardown instead of dropping it', async () => { + vi.useFakeTimers() + const writes: string[] = [] + const ingress = new PtyStartupIngress({ + ownerBackend: 'posix-pty', + echoProbe: () => new Promise(() => {}), + write: (data) => writes.push(data), + onEmission: () => {} + }) + + expect(ingress.answerLiveQueryReply(OSC_COLOR_REPLY)).toBe(true) + await vi.advanceTimersByTimeAsync(0) + expect(ingress.writeQueryReplyInOrder(CPR_REPLY)).toBe(true) + // Daemon dispose drains before the kill, so the child can still be blocked on this. + ingress.drainAndClose() + expect(writes).toEqual([CPR_REPLY]) + }) + + it('never takes the ordered path on backends that do not defer', async () => { + vi.useFakeTimers() + for (const ownerBackend of ['windows-conpty', 'windows-wsl'] as const) { + const writes: string[] = [] + const ingress = new PtyStartupIngress({ + ownerBackend, + write: (data) => writes.push(data), + onEmission: () => {} + }) + expect(ingress.answerLiveQueryReply(OSC_COLOR_REPLY)).toBe(true) + expect(ingress.writeQueryReplyInOrder(CPR_REPLY)).toBe(false) + expect(writes).toEqual([OSC_COLOR_REPLY]) + ingress.drainAndClose() + } + }) + it('does not route ordinary keystrokes through the echo-safe reply path', () => { expect(needsCookedEchoSafeQueryReply('y')).toBe(false) expect(needsCookedEchoSafeQueryReply('yes\r')).toBe(false) diff --git a/src/shared/pty-startup-ingress.ts b/src/shared/pty-startup-ingress.ts index fe1aa041752..bc6b055c8a2 100644 --- a/src/shared/pty-startup-ingress.ts +++ b/src/shared/pty-startup-ingress.ts @@ -100,6 +100,11 @@ export class PtyStartupIngress { : false } + /** Order-only: holds an uncontained reply behind a deferred one, else leaves it. */ + writeQueryReplyInOrder(reply: string): boolean { + return !this.closed && this.delivery.writeBehindDeferredReplies(reply) + } + drainAndClose(): number { this.enqueue({ kind: 'teardown' }) return this.rawHighWater @@ -322,12 +327,11 @@ export class PtyStartupIngress { } private releaseQueryPending(): void { - if (!this.queryPending) { - return - } const pending = this.queryPending this.queryPending = null - this.emit(pending, false) + if (pending) { + this.emit(pending, false) + } } /** @@ -377,10 +381,7 @@ export class PtyStartupIngress { } private clearDeadline(): void { - if (!this.deadlineTimer) { - return - } - clearTimeout(this.deadlineTimer) + clearTimeout(this.deadlineTimer ?? undefined) this.deadlineTimer = null } } diff --git a/src/shared/pty-startup-reply-delivery.ts b/src/shared/pty-startup-reply-delivery.ts index c8b38e055bd..0ec118ff800 100644 --- a/src/shared/pty-startup-reply-delivery.ts +++ b/src/shared/pty-startup-reply-delivery.ts @@ -74,7 +74,7 @@ const MAX_TRACKED_REPLIES = 64 const ECHO_PROBE_MAX_STARTS_PER_SECOND = 10 type ExpectedEcho = { projections: readonly string[]; remainingBytes: number } -type PendingWrite = { reply: string; onFailed: (() => void) | undefined } +type PendingWrite = { reply: string; onFailed: (() => void) | undefined; contained: boolean } type ActiveEchoProbe = { timer: ReturnType | null } /** Only a POSIX tty both echoes the reply and still delivers a deferred write. */ @@ -212,7 +212,31 @@ export class PtyStartupReplyDelivery { if (this.pendingWrites.length === 0) { this.echoPollDeadline = Date.now() + ECHO_POLL_BUDGET_MS } - this.pendingWrites.push({ reply, onFailed }) + this.pendingWrites.push({ reply, onFailed, contained: true }) + this.armWriteTimer() + return true + } + + /** + * Queues a reply that needs no echo containment behind ones that do, so it cannot + * overtake them. termenv writes `OSC 11 ;? ST` then `CSI 6n` and stops reading at + * the CPR, treating it as proof the terminal answered: a CPR that jumps a still- + * deferred color reply strands that reply in the tty for whatever runs next to read + * (`gh auth login` -> "unexpected escape sequence from terminal"). + * + * False means nothing is deferred and the caller owns the write, which keeps the + * latency-critical replies immediate on every path that is not mid-deferral. + */ + writeBehindDeferredReplies(reply: string): boolean { + if (this.closed || this.pendingWrites.length === 0) { + return false + } + if (this.pendingWrites.length >= MAX_TRACKED_REPLIES) { + // Flushing keeps order without growing the queue; the caller writes after it. + this.flushPendingWrites() + return false + } + this.pendingWrites.push({ reply, onFailed: undefined, contained: false }) this.armWriteTimer() return true } @@ -264,17 +288,32 @@ export class PtyStartupReplyDelivery { } } - /** Teardown: the pty is gone, so an unwritten reply has nowhere left to go. */ + /** + * Teardown. A contained reply has nowhere left to go — its reader is gone. An + * uncontained one is an ordinary write this queue only borrowed for ordering, and the + * caller was already told it was sent, so it is handed to the pty best-effort: the + * child can still be alive here (daemon dispose drains before the kill). + */ close(): void { this.closed = true this.clearWriteTimer() this.clearActiveEchoProbe() - this.pendingWrites.length = 0 + for (const pending of this.pendingWrites.splice(0)) { + if (!pending.contained) { + try { + this.writeProvider(pending.reply) + } catch { + /* pty already torn down */ + } + } + } this.expectedEchoes.length = 0 } private armWriteTimer(delayMs = 0): void { - if (this.writeTimer) { + // A probe in flight is already the continuation: re-arming forks a second stty and + // makes the first one's verdict unusable (the identity bail below discards it). + if (this.writeTimer || this.activeEchoProbe) { return } this.writeTimer = setTimeout(() => this.attemptPendingWrites(), delayMs) @@ -332,7 +371,7 @@ export class PtyStartupReplyDelivery { this.clearWriteTimer() this.clearActiveEchoProbe() for (const pending of this.pendingWrites.splice(0)) { - this.writeReply(pending.reply, pending.onFailed, kernelEchoImpossible) + this.writeReply(pending.reply, pending.onFailed, kernelEchoImpossible, pending.contained) } } @@ -371,11 +410,22 @@ export class PtyStartupReplyDelivery { return true } - private writeReply(reply: string, onFailed?: () => void, kernelEchoImpossible = false): boolean { + private writeReply( + reply: string, + onFailed?: () => void, + kernelEchoImpossible = false, + contained = true + ): boolean { if (this.closed) { return false } - const projections = replyEchoProjections(reply, this.ownerBackend, kernelEchoImpossible) + // Not a safety rule: the caret form of a short CSI reply (`^[[6;1R`) is far likelier + // to occur verbatim in ordinary output than a long OSC colour string, so arming it + // is not worth the false swallow. These replies were unprojected before they rode + // this queue, and stay that way. + const projections = contained + ? replyEchoProjections(reply, this.ownerBackend, kernelEchoImpossible) + : [] // Why: register before write because node-pty can synchronously re-enter onData. const expected: ExpectedEcho | null = projections.length > 0 ? { projections, remainingBytes: ECHO_SEARCH_BUDGET_BYTES } : null diff --git a/src/shared/terminal-query-reply.test.ts b/src/shared/terminal-query-reply.test.ts index c8bf50268ad..4b61e6dd696 100644 --- a/src/shared/terminal-query-reply.test.ts +++ b/src/shared/terminal-query-reply.test.ts @@ -1,10 +1,12 @@ import { Terminal } from '@xterm/headless' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { extractOnlyCookedEchoSafeQueryReplies, isTerminalQueryReply, - needsCookedEchoSafeQueryReply + needsCookedEchoSafeQueryReply, + takeLiveQueryReply } from './terminal-query-reply' +import { PtyStartupIngress } from './pty-startup-ingress' describe('isTerminalQueryReply', () => { it('matches synthetic query replies that must be sent immediately', () => { @@ -125,3 +127,100 @@ describe('isTerminalQueryReply', () => { expect(isTerminalQueryReply('\x1bP1$r2 q')).toBe(false) }) }) + +// Regression for the `gh auth login` report: termenv writes `OSC 11 ;? ST` then +// `CSI 6n` and reads exactly one response, treating a CPR-first answer as "no OSC +// support" without draining further. Orca defers the color reply behind an ECHO +// probe, so a CPR taken straight to the PTY overtakes it and leaves `ESC ]` in the +// tty for the next program — bubbletea then dies with +// "unexpected escape sequence from terminal: ['\x1b' ']']". +describe('takeLiveQueryReply ordering (termenv OSC-then-CPR)', () => { + const OSC_11_REPLY = '\x1b]11;rgb:1e1e/1e1e/1e1e\x1b\\' + const CPR_REPLY = '\x1b[1;1R' + + function hostWrites(ingress: PtyStartupIngress, pty: string[]) { + return (data: string): void => { + if (!takeLiveQueryReply(ingress, data)) { + pty.push(data) + } + } + } + + it('delivers the color reply before the CPR the querying program stops at', async () => { + vi.useFakeTimers() + const pty: string[] = [] + const ingress = new PtyStartupIngress({ + ownerBackend: 'posix-pty', + echoProbe: async () => 'quiet', + write: (data) => pty.push(data), + onEmission: () => {} + }) + const write = hostWrites(ingress, pty) + + write(OSC_11_REPLY) + write(CPR_REPLY) + await vi.advanceTimersByTimeAsync(200) + + expect(pty).toEqual([OSC_11_REPLY, CPR_REPLY]) + // Nothing survives the CPR, so the next program's stdin opens clean. + expect(pty.slice(1).join('')).not.toContain('\x1b]') + ingress.drainAndClose() + vi.useRealTimers() + }) + + it('keeps a CPR immediate when no color reply is deferred', () => { + vi.useFakeTimers() + const pty: string[] = [] + const ingress = new PtyStartupIngress({ + ownerBackend: 'posix-pty', + echoProbe: async () => 'quiet', + write: (data) => pty.push(data), + onEmission: () => {} + }) + + hostWrites(ingress, pty)(CPR_REPLY) + expect(pty).toEqual([CPR_REPLY]) + ingress.drainAndClose() + vi.useRealTimers() + }) + + it('never takes ordinary typed input', () => { + const pty: string[] = [] + const ingress = new PtyStartupIngress({ + ownerBackend: 'posix-pty', + write: (data) => pty.push(data), + onEmission: () => {} + }) + + for (const keystroke of ['y', 'gh auth login\r', '\x1b[A', '\x1b', '\x03']) { + expect(takeLiveQueryReply(ingress, keystroke)).toBe(false) + } + ingress.drainAndClose() + }) +}) + +// Known limitation, tracked for follow-up: the guarantee is FIFO among recognised +// query replies, not over every byte written to the pty. Both branches of +// takeLiveQueryReply match whole strings, so a coalesced payload is not a reply, and +// ordinary input never rides the queue at all. Pinned so the boundary is explicit — +// if a change makes these take the ordered path, that is an improvement, not a break. +describe('takeLiveQueryReply: writes that still bypass the ordered queue', () => { + const BYPASSING = [ + { what: 'reply coalesced with a keystroke', data: '\x1b[6;1Ry' }, + { what: 'keystroke coalesced with a reply', data: 'y\x1b[6;1R' }, + { what: 'ordinary typed input', data: 'ls\r' } + ] + + it.each(BYPASSING)('does not take $what', ({ data }) => { + const ingress = new PtyStartupIngress({ + ownerBackend: 'posix-pty', + echoProbe: () => new Promise(() => {}), + write: () => {}, + onEmission: () => {} + }) + // Deferral is open, so an ordered write WOULD be queued here. + expect(ingress.answerLiveQueryReply('\x1b]11;rgb:00/00/00\x07')).toBe(true) + expect(takeLiveQueryReply(ingress, data)).toBe(false) + ingress.drainAndClose() + }) +}) diff --git a/src/shared/terminal-query-reply.ts b/src/shared/terminal-query-reply.ts index e83f0dbcc94..5d68acff9f5 100644 --- a/src/shared/terminal-query-reply.ts +++ b/src/shared/terminal-query-reply.ts @@ -23,10 +23,12 @@ const ESC = String.fromCharCode(0x1b) /* oxlint-disable no-control-regex -- grammars match terminal ESC/BEL sequences by definition */ // Known accepted collision: xterm.js encodes MODIFIED F3 (Shift/Ctrl/Alt+F3) as // `CSI 1 ; R`, which is indistinguishable from a CPR report (a classic -// VT ambiguity). Such a keystroke is sent immediately instead of debounced — -// byte order is still preserved (the immediate path flushes pending input -// first), it just skips input-intent/activity bookkeeping. Harmless, so we -// keep the reply grammar complete rather than special-casing it. +// VT ambiguity). Such a keystroke skips input-intent/activity bookkeeping, and +// takeLiveQueryReply will hold it behind a deferred reply like any CPR — so +// within that window (bounded by the host's echo budget) a keystroke typed +// after it can reach the pty first. Accepted: it needs the chord and a +// following keypress inside a live colour-query deferral, and we keep the reply +// grammar complete rather than special-casing an unresolvable ambiguity. const CPR_OR_DSR_RE = new RegExp('^\\u001b\\[\\??[0-9;]*[Rn]$') const DEVICE_ATTRIBUTES_RE = new RegExp('^\\u001b\\[[?>=]?[0-9;]*c$') // 4/6 = pixel-size reports, 8 = text-area size in characters (answer to CSI 18t). @@ -144,3 +146,28 @@ export function answerEachCookedEchoSafeQueryReply( } return accepted } + +/** Host-side ingress surface for replies the PTY owner may take ownership of. */ +export type LiveQueryReplyIngress = { + answerLiveQueryReply: (reply: string) => boolean + writeQueryReplyInOrder: (reply: string) => boolean +} + +/** + * True when `ingress` owns this write. A reply a cooked prompt would paint is deferred + * behind an ECHO probe (#13137); a latency-critical reply (CPR/DA) is taken only while + * one of those is still deferred, because termenv stops reading at the CPR it sends + * after its color query — an early CPR strands the color reply in the tty for whatever + * runs next (`gh auth login` -> "unexpected escape sequence from terminal"). + */ +export function takeLiveQueryReply( + ingress: LiveQueryReplyIngress | undefined, + data: string +): boolean { + if (!ingress) { + return false + } + return extractOnlyCookedEchoSafeQueryReplies(data) + ? ingress.answerLiveQueryReply(data) + : isTerminalQueryReply(data) && ingress.writeQueryReplyInOrder(data) +} From fdd4091ebd3daab948910947bee4aa18f638d10f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:37:02 -0700 Subject: [PATCH 011/111] fix(hooks): isolate lint-staged backups per worktree (#15388) --- config/patches/lint-staged@16.4.0.patch | 125 ++++++++++++++++++ .../lint-staged-worktree-backup.test.mjs | 97 ++++++++++++++ package.json | 3 +- pnpm-lock.yaml | 7 +- src/shared/git-binary-compatibility.test.ts | 32 +++++ 5 files changed, 261 insertions(+), 3 deletions(-) create mode 100644 config/patches/lint-staged@16.4.0.patch create mode 100644 config/scripts/lint-staged-worktree-backup.test.mjs diff --git a/config/patches/lint-staged@16.4.0.patch b/config/patches/lint-staged@16.4.0.patch new file mode 100644 index 00000000000..cd5bcf57740 --- /dev/null +++ b/config/patches/lint-staged@16.4.0.patch @@ -0,0 +1,125 @@ +diff --git a/lib/gitWorkflow.js b/lib/gitWorkflow.js +index 3a32d6a9cd65c7ecb6b3cbccfce5a15c96530435..363513128bf7302eab73ea775102bb8a633a3b8b 100644 +--- a/lib/gitWorkflow.js ++++ b/lib/gitWorkflow.js +@@ -50,6 +50,9 @@ export const STASH = 'lint-staged automatic backup' + + const PATCH_UNSTAGED = 'lint-staged_unstaged.patch' + ++const BACKUP_REF_PREFIX = 'refs/worktree/lint-staged-backups' ++const ZERO_OID = '0000000000000000000000000000000000000000' ++ + const GIT_DIFF_ARGS = [ + '--binary', // support binary files + '--unified=0', // do not add lines around diff for consistent behaviour +@@ -100,6 +103,8 @@ export class GitWorkflow { + /** @type {import('./getStagedFiles.js').StagedFile[][]} */ + this.matchedFileChunks = matchedFileChunks + this.topLevelDir = topLevelDir ++ this.backupOid = null ++ this.backupRef = null + + /** + * These three files hold state about an ongoing git merge +@@ -122,6 +127,17 @@ export class GitWorkflow { + * Get name of backup stash + */ + async getBackupStash(ctx) { ++ if (this.backupRef) { ++ const backupOid = await this.execGit(['rev-parse', '--verify', this.backupRef]) ++ ++ if (backupOid !== this.backupOid) { ++ ctx.errors.add(GetBackupStashError) ++ throw new Error('lint-staged automatic backup is missing!') ++ } ++ ++ return this.backupRef ++ } ++ + /** Print stash list with short hash and subject */ + const stashes = await this.execGit(['stash', 'list', '--format="%h %s"', '-z']) + .then(parseGitZOutput) +@@ -270,11 +286,14 @@ export class GitWorkflow { + } else { + /** Save stash of all changes, keeping all files as-is */ + const stashHash = await this.execGit(['stash', 'create']) ++ this.backupOid = stashHash ++ this.backupRef = `${BACKUP_REF_PREFIX}/${crypto.randomUUID()}` + ctx.backupHash = await this.execGit(['rev-parse', '--short', stashHash]) +- await this.execGit(['stash', 'store', '--quiet', '--message', STASH, ctx.backupHash]) ++ await this.execGit(['update-ref', this.backupRef, this.backupOid, ZERO_OID]) ++ ctx.backupRef = this.backupRef + } + +- task.title = `Backed up original state in git stash (${ctx.backupHash})` ++ task.title = `Backed up original state (${ctx.backupHash})` + debugLog(task.title) + } + } catch (error) { +@@ -425,7 +444,11 @@ export class GitWorkflow { + async cleanup(ctx) { + try { + debugLog('Dropping backup stash...') +- await this.execGit(['stash', 'drop', '--quiet', await this.getBackupStash(ctx)]) ++ if (this.backupRef) { ++ await this.execGit(['update-ref', '-d', this.backupRef, this.backupOid]) ++ } else { ++ await this.execGit(['stash', 'drop', '--quiet', await this.getBackupStash(ctx)]) ++ } + debugLog('Done dropping backup stash!') + } catch (error) { + handleError(error, ctx) +diff --git a/lib/index.js b/lib/index.js +index 75eeacee48759ef90249df5524639d61bce10918..24b4852eba9b14e05e10c3df8a2b2dae75af19f3 100644 +--- a/lib/index.js ++++ b/lib/index.js +@@ -157,7 +157,7 @@ const lintStaged = async ( + logger.warn(PREVENTED_EMPTY_COMMIT) + } else if (ctx.errors.has(FailOnChangesError)) { + logger.warn(PREVENTED_TASK_MODIFICATIONS + '\n') +- logger.warn(restoreStashExample(ctx.backupHash)) ++ logger.warn(restoreStashExample(ctx.backupHash, ctx.backupRef)) + } else if (ctx.errors.has(RestoreUnstagedChangesError)) { + logger.warn(UNSTAGED_CHANGES_BACKUP_STASH_LOCATION) + logger.warn(ctx.unstagedPatch) +@@ -168,7 +168,7 @@ const lintStaged = async ( + logger.error(GIT_ERROR) + if (ctx.shouldBackup) { + // No sense to show this if the backup stash itself is missing. +- logger.error(restoreStashExample(ctx.backupHash) + '\n') ++ logger.error(restoreStashExample(ctx.backupHash, ctx.backupRef) + '\n') + } + } + +diff --git a/lib/messages.js b/lib/messages.js +index 993f8d81cac48132b5ae2ebdc8ca452c7217caad..2dc2291bc82451eacc5babfc8ce408051e1101e3 100644 +--- a/lib/messages.js ++++ b/lib/messages.js +@@ -66,9 +66,12 @@ export const PREVENTED_EMPTY_COMMIT = ` + Use the --allow-empty option to continue, or check your task configuration`)} + ` + +-export const restoreStashExample = ( +- hash = 'h0a0s0h0' +-) => `Any lost modifications can be restored from a git stash: ++export const restoreStashExample = (hash = 'h0a0s0h0', backupRef) => ++ backupRef ++ ? `Any lost modifications can be restored from the worktree backup: ++ ++ > git stash apply --index ${backupRef}` ++ : `Any lost modifications can be restored from a git stash: + + > git stash list --format="%h %s" + ${hash} On main: lint-staged automatic backup +diff --git a/lib/state.js b/lib/state.js +index da30e6f639d31307ebf503691aa991bb93948f8a..4a38799fa7773a8e8629c7c3a6b72287e6b22f4b 100644 +--- a/lib/state.js ++++ b/lib/state.js +@@ -15,6 +15,7 @@ export const getInitialState = ({ + revert = true, + } = {}) => ({ + backupHash: null, ++ backupRef: null, + errors: new Set([]), + shouldFailOnChanges: failOnChanges, + hasFilesToHide: null, diff --git a/config/scripts/lint-staged-worktree-backup.test.mjs b/config/scripts/lint-staged-worktree-backup.test.mjs new file mode 100644 index 00000000000..97ee56b7295 --- /dev/null +++ b/config/scripts/lint-staged-worktree-backup.test.mjs @@ -0,0 +1,97 @@ +import { execFileSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import lintStaged from 'lint-staged' +import { expect, it } from 'vitest' + +const BACKUP_REFS = 'refs/worktree/lint-staged-backups' +const silentLogger = { error() {}, log() {}, warn() {} } + +it('keeps lint-staged backups isolated to the current worktree', async () => { + const root = mkdtempSync(join(tmpdir(), 'orca-lint-staged-worktree-')) + + try { + const repo = join(root, 'repo') + const worktree = join(root, 'linked worktree') + const trackedFile = join(worktree, 'tracked.txt') + mkdirSync(repo) + initializeRepo(repo) + + writeFileSync(join(repo, 'tracked.txt'), 'base-one\nbase-two\n') + git(repo, ['add', 'tracked.txt']) + git(repo, ['commit', '--quiet', '-m', 'initial']) + writeFileSync(join(repo, 'tracked.txt'), 'user stash\nbase-two\n') + git(repo, ['stash', 'push', '--quiet', '--message', 'user backup']) + git(repo, ['worktree', 'add', '--quiet', '-b', 'linked', worktree]) + + writeFileSync(trackedFile, 'staged-change\nbase-two\n') + git(worktree, ['add', 'tracked.txt']) + writeFileSync(trackedFile, 'staged-change\nunstaged-change\n') + + const expectedStash = gitTrim(worktree, ['rev-parse', 'refs/stash']) + const stashBefore = git(worktree, ['stash', 'list', '--format=%H%x00%gs']) + const stagedBefore = git(worktree, ['diff', '--cached', '--binary']) + const unstagedBefore = git(worktree, ['diff', '--binary']) + const contentBefore = readFileSync(trackedFile, 'utf8') + const observation = join(root, 'task-observation.json') + const probe = join(root, 'failing-task.cjs') + writeProbe(probe) + + const task = [process.execPath, probe, expectedStash, observation].map(quote).join(' ') + const passed = await lintStaged( + { config: { '*.txt': task }, cwd: worktree, quiet: true }, + silentLogger + ) + + expect(passed).toBe(false) + expect(JSON.parse(readFileSync(observation, 'utf8'))).toEqual({ + backupRefs: [expect.stringMatching(`^${BACKUP_REFS}/`)], + sharedStash: expectedStash + }) + expect(git(worktree, ['for-each-ref', '--format=%(refname)', BACKUP_REFS])).toBe('') + expect(git(worktree, ['stash', 'list', '--format=%H%x00%gs'])).toBe(stashBefore) + expect(git(worktree, ['diff', '--cached', '--binary'])).toBe(stagedBefore) + expect(git(worktree, ['diff', '--binary'])).toBe(unstagedBefore) + expect(readFileSync(trackedFile, 'utf8')).toBe(contentBefore) + expect(git(worktree, ['ls-files', '--unmerged'])).toBe('') + } finally { + rmSync(root, { force: true, recursive: true }) + } +}) + +function initializeRepo(repo) { + git(repo, ['init', '--quiet']) + git(repo, ['config', 'user.email', 'test@example.invalid']) + git(repo, ['config', 'user.name', 'Test']) + git(repo, ['config', 'core.autocrlf', 'false']) + git(repo, ['config', 'core.hooksPath', join(repo, '.git', 'no-hooks')]) + git(repo, ['config', 'commit.gpgsign', 'false']) +} + +function git(cwd, args) { + return execFileSync('git', args, { cwd, encoding: 'utf8' }) +} + +function gitTrim(cwd, args) { + return git(cwd, args).trim() +} + +function quote(value) { + return JSON.stringify(value) +} + +function writeProbe(path) { + writeFileSync( + path, + [ + "const { execFileSync } = require('node:child_process')", + "const { writeFileSync } = require('node:fs')", + "const git = (args) => execFileSync('git', args, { encoding: 'utf8' }).trim()", + "const backupRefs = git(['for-each-ref', '--format=%(refname)', 'refs/worktree/lint-staged-backups'])", + "writeFileSync(process.argv[3], JSON.stringify({ backupRefs: backupRefs.split('\\n').filter(Boolean), sharedStash: git(['rev-parse', 'refs/stash']) }))", + "writeFileSync(process.argv[4], 'task-output\\n')", + 'process.exit(1)' + ].join('\n') + ) +} diff --git a/package.json b/package.json index aabf6ab08d6..ceac925f0b2 100644 --- a/package.json +++ b/package.json @@ -313,7 +313,8 @@ "@xterm/addon-ligatures@0.11.0-beta.287": "config/patches/@xterm__addon-ligatures@0.11.0-beta.287.patch", "@xterm/addon-webgl@0.20.0-beta.286": "config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch", "@xterm/addon-serialize@0.15.0-beta.287": "config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch", - "@xterm/xterm@6.1.0-beta.287": "config/patches/@xterm__xterm@6.1.0-beta.287.patch" + "@xterm/xterm@6.1.0-beta.287": "config/patches/@xterm__xterm@6.1.0-beta.287.patch", + "lint-staged@16.4.0": "config/patches/lint-staged@16.4.0.patch" } }, "reactDoctor": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac78a681963..1c0d6b0d31b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ patchedDependencies: '@xterm/xterm@6.1.0-beta.287': hash: 46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7 path: config/patches/@xterm__xterm@6.1.0-beta.287.patch + lint-staged@16.4.0: + hash: 7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673 + path: config/patches/lint-staged@16.4.0.patch node-pty@1.1.0: hash: 8fc49f17011b6611a5b8c00e83a6f12e14e75aada2b0ef26dc5393f8376d20e8 path: config/patches/node-pty@1.1.0.patch @@ -274,7 +277,7 @@ importers: version: 0.16.45 lint-staged: specifier: ^16.4.0 - version: 16.4.0 + version: 16.4.0(patch_hash=7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673) lowlight: specifier: ^3.3.0 version: 3.3.0 @@ -11374,7 +11377,7 @@ snapshots: linkifyjs@4.3.2: {} - lint-staged@16.4.0: + lint-staged@16.4.0(patch_hash=7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673): dependencies: commander: 14.0.3 listr2: 9.0.5 diff --git a/src/shared/git-binary-compatibility.test.ts b/src/shared/git-binary-compatibility.test.ts index c8fe65d53bb..a33adcd7d76 100644 --- a/src/shared/git-binary-compatibility.test.ts +++ b/src/shared/git-binary-compatibility.test.ts @@ -200,6 +200,38 @@ describeBinaryCompatibility('real Git binary compatibility', () => { }) }) + it('supports isolated worktree backup refs', async () => { + const worktree = 'compat-lint-staged' + const backupRef = 'refs/worktree/lint-staged-backups/compat' + await runGit(['worktree', 'add', '-b', 'compat-lint-staged', worktree]) + await writeFile(join(repoPath, worktree, 'tracked.txt'), 'staged\n') + await runGit(['-C', worktree, 'add', 'tracked.txt']) + await writeFile(join(repoPath, worktree, 'tracked.txt'), 'staged\nunstaged\n') + + const backupOid = (await runGit(['-C', worktree, 'stash', 'create'])).stdout.trim() + await runGit([ + '-C', + worktree, + 'update-ref', + backupRef, + backupOid, + '0000000000000000000000000000000000000000' + ]) + await expect( + runGit(['-C', worktree, 'rev-parse', '--verify', backupRef]) + ).resolves.toMatchObject({ stdout: `${backupOid}\n` }) + await expect(runGit(['rev-parse', '--verify', backupRef])).rejects.toBeDefined() + + await runGit(['-C', worktree, 'reset', '--hard', 'HEAD']) + await expect( + runGit(['-C', worktree, 'stash', 'apply', '--quiet', '--index', backupRef]) + ).resolves.toBeDefined() + await expect(runGit(['-C', worktree, 'status', '--short'])).resolves.toMatchObject({ + stdout: 'MM tracked.txt\n' + }) + await runGit(['-C', worktree, 'update-ref', '-d', backupRef, backupOid]) + }) + it('degrades indexed credential config safely at the Git 2.31 boundary', async () => { const guardEnv = gitCredentialPromptGuardEnv({}, 'linux') await expect(runGit(['status', '--short'], guardEnv)).resolves.toBeDefined() From c72a4eecddeb1c0b575bef8643e8162255dbd4c2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:37:42 -0700 Subject: [PATCH 012/111] refactor(shell): collapse the zsh wrapper to one .zshenv and a precmd hook (STA-4786) (#15391) * refactor(shell): collapse the zsh wrapper to one .zshenv and a precmd hook (STA-4786) Orca needs to run code after the user's own zsh startup files. It bought that by keeping ZDOTDIR pointed at its own wrapper dir for the whole of startup and sourcing each user file by hand -- four generated files per transport, with a fake ZDOTDIR live while /etc/zshrc ran. That single decision is the root of a whole bug family: - /etc/zshrc assigns HISTFILE=${ZDOTDIR:-$HOME}/.zsh_history unconditionally, so history landed inside Orca's own dir (#11044), and an epilogue had to repair it. - zsh's sourcehome() ignores ZDOTDIR once the shell is in sh/ksh emulation, so a user .zshenv or .zprofile ending in `emulate sh` hid every later wrapper file. The emulation degrade blocks and their forked $(emulate) probes exist for that. - One wrapper dir shared by two installed builds could mix files from both, so every generated file had to redefine the helpers it called. - The baked generation-time ZDOTDIR literal is unusable when a Windows-generated wrapper is sourced inside WSL via /mnt/c (#8003), so the runtime path had to be re-derived from %x. The wrapper now hands ZDOTDIR back on its first lines and defers Orca's work to a precmd hook that runs at the first prompt -- after .zprofile, /etc/zshrc, .zshrc and .zlogin, every one of which zsh reads from the user's own directory exactly as in an unwrapped shell. Each bug above stops being reachable rather than being repaired, and their machinery goes with them: eight of thirteen exported blocks in shell-templates.ts, both drifted discovery bodies (unifying them closes the "reconciling the two is a follow-up" note the file carried), and the relay's separate ORCA_USER_ZDOTDIR shape. Generated zsh drops from 819 lines across twelve files to 143 in three. Two things the design has to get right, both found by running it rather than reasoning about it: - Every function is defined ABOVE the source of the user's .zshenv. A user file ending in `emulate sh` puts the rest of the wrapper under sh parsing rules, and the first prototype died there with `parse error near '\n'` -- silently, leaving the pane unwrapped. Function bodies are parsed at definition time. - ORCA_ORIG_ZDOTDIR is vetted, not trusted. The launch config only sets it when it resolved a usable dir, but a pane inherits its parent's environment too, so a stale value from an older build can arrive on its own and would point ZDOTDIR back at a wrapper dir. The ownership check Node applies now also runs in the shell, where that route is visible. Orca also stops inventing a ZDOTDIR: where the user has none, ORCA_ORIG_ZDOTDIR is absent and the pane ends with ZDOTDIR unset, as an unwrapped login zsh does. Verified on real zsh over a real PTY -- necessary, because a precmd hook never runs in a shell started with -c, so the existing `zsh -i -c` probes could not have exercised this design at all. src/main/zsh-startup-hook-pty-harness.ts drives the shell to a prompt and reports through a file rather than stdout, which a PTY echoes. * test(shell): cover the relay variant of the zsh hook in a real shell The relay writes its own variant -- no OSC 133, remote CLI bin dir instead of the agent-teams shim -- and it had no live coverage. It used to carry a second ZDOTDIR shape as well, which is how it drifted from the desktop template in the first place; now the spec flags are the only difference, and this pins that. * fix(shell): rebase the single-file hook onto content-addressed wrapper trees #15285 landed content-addressed wrapper roots and a per-transport fileset module while this branch was in flight. The fileset modules are now the single place the tree is described, so 'only .zshenv' is stated once per transport and the required-paths check follows from it rather than repeating the list. * test(shell): point the mixed-build proof at the relay, the one fixed wrapper path #15285 content-addressed the desktop and daemon trees, so two builds can no longer write the same directory there and the scenario this file covers became unreachable on those paths. The relay still writes a fixed ~/.orca-relay/ shell-ready, so that is where the hazard survives and where the proof belongs. * fix(test): make the zsh PTY harness survive a startup that stops to ask Two CI-only failures, both from driving a real PTY where the old probes drove a pipe: - A host whose global zshrc runs `compinit` over directories it considers insecure stops startup and ASKS. A pipe-backed `zsh -i -c` never saw the question; a PTY sits at it until the timeout. The harness now answers it. ZSH_DISABLE_COMPFIX does not help -- that is an oh-my-zsh convention and plain compinit ignores it, which I confirmed by reproducing the prompt locally. - The PS1 line was typed at t=0, so on such a host the question consumed it as its answer. The harness now waits for the shell to fall quiet first, which also stops a slow prompt framework racing the same write. Also merges a duplicate vitest import the native code-quality audit flagged. * fix(test): stop the live-shell assertions assuming macOS host behaviour Two of them hardcoded what my machine does rather than what Orca owes: - LINEINIT was pinned to 'none'. A host whose global zsh config installs its own zle-line-init widget has one either way; the contract is that it looks the same wrapped as unwrapped, which the assertion beside it already states. - The dropped-precmd_functions case asserted HISTFILE was no longer the scoped path. Whether the scoped value survives at all is the host's call: macOS /etc/zshrc overwrites HISTFILE so it does not, and a host with no such assignment keeps whatever the spawn env set. Now compared against an unwrapped pane given the same env, which is the real contract on both. Also notes, where the emulation cases live, that they only discriminate on a host whose system zshrc clobbers HISTFILE -- on CI's Ubuntu the load-bearing assertion is ORCA_HISTFILE having been consumed. * test(shell): re-pin the fixes the four-file wrapper was built for Archaeology over the removed blocks: each existed for a bug, so each needs the bug shown to be unreachable rather than just the code gone. Six restored or added, each naming the change that introduced the behaviour. - #8003, twice: the wrapper sourced from a relocated root, and from a non-ASCII (token-range) one. The old file baked its generation-time path in and had to re-derive the runtime one from %x to avoid using it; this one bakes nothing. Both runs assert ORCA_SHELL_FEATURES came back consumed, so 'the user's .zshrc loaded' cannot pass on a pane that never read the wrapper at all. - #4667: user startup files must see their OWN ZDOTDIR while they run, or plugin and theme lookups resolve into Orca's dir. The old wrapper swapped ZDOTDIR around each source; this one never takes it away, and the values now have to match an unwrapped pane's. - #1947: a user .zshenv that returns early. - #15258: an inherited ZDOTDIR that is an Orca wrapper dir must be refused. CI proved this route is live -- the launch config only sets ORCA_ORIG_ZDOTDIR when it resolved a usable dir, but a pane inherits its parent's environment too. - #11044/#11146: a nested Orca inherits neither cross-process channel and no ZDOTDIR of Orca's, which is what makes #11044's plain shape unreachable rather than repaired. Verified the child-env probe detects a real leak before trusting it to report the absence of one. --- .github/workflows/pr.yml | 8 +- .../scripts/pr-workflow-parallelism.test.mjs | 13 +- .../daemon-zsh-zlogin.txt | 21 - .../daemon-zsh-zprofile.txt | 32 - .../daemon-zsh-zshenv.txt | 211 ++---- .../daemon-zsh-zshrc.txt | 23 - .../local-zsh-zlogin.txt | 21 - .../local-zsh-zprofile.txt | 32 - .../local-zsh-zshenv.txt | 211 ++---- .../local-zsh-zshrc.txt | 23 - .../relay-zsh-zlogin.txt | 21 - .../relay-zsh-zprofile.txt | 32 - .../relay-zsh-zshenv.txt | 111 ++-- .../relay-zsh-zshrc.txt | 23 - .../daemon-shell-ready-wrapper-fileset.ts | 11 +- .../daemon-zsh-shell-ready-wrapper-spec.ts | 7 +- src/main/daemon/shell-ready.test.ts | 94 +-- src/main/daemon/shell-ready.ts | 8 +- src/main/ipc/pty-wsl-cwd-validation.test.ts | 5 +- .../shell-ready-framework-example.test.ts | 3 +- .../local-pty-shell-ready-wrapper-fileset.ts | 19 +- ...pty-shell-ready-wrapper-generation.test.ts | 141 ++-- ...ocal-pty-shell-ready-wrapper-generation.ts | 9 +- ...shell-ready-zsh-launch-environment.test.ts | 28 +- ...ll-ready-zsh-startup-file-behavior.test.ts | 350 ---------- ...-shell-ready-zsh-zdotdir-discovery.test.ts | 335 ---------- ...ll-ready-zsh-zdotdir-normalization.test.ts | 344 ---------- src/main/providers/local-pty-shell-ready.ts | 8 +- .../shell-startup-feature-channel.test.ts | 139 ++-- src/main/shell-templates.ts | 327 ++------- ...ll-wrapper-generated-file-snapshot.test.ts | 9 +- .../zsh-scoped-histfile.live-shell.test.ts | 627 +++++++++--------- src/main/zsh-startup-hook-pty-harness.ts | 220 ++++++ ...user-config-equivalence.live-shell.test.ts | 492 ++++++++++++++ src/main/zsh-startup-wrapper-builder.ts | 258 +++---- src/main/zsh-wrapper-dir-ownership.ts | 21 +- ...rapper-version-mismatch.live-shell.test.ts | 122 ++-- src/relay/pty-shell-launch.test.ts | 38 +- src/relay/pty-shell-launch.ts | 4 +- src/relay/pty-shell-overlay-wrappers.ts | 32 +- 40 files changed, 1691 insertions(+), 2742 deletions(-) delete mode 100644 src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zlogin.txt delete mode 100644 src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zprofile.txt delete mode 100644 src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshrc.txt delete mode 100644 src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zlogin.txt delete mode 100644 src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zprofile.txt delete mode 100644 src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshrc.txt delete mode 100644 src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zlogin.txt delete mode 100644 src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zprofile.txt delete mode 100644 src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshrc.txt delete mode 100644 src/main/providers/local-pty-shell-ready-zsh-startup-file-behavior.test.ts delete mode 100644 src/main/providers/local-pty-shell-ready-zsh-zdotdir-discovery.test.ts delete mode 100644 src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts create mode 100644 src/main/zsh-startup-hook-pty-harness.ts create mode 100644 src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index d0f6e1fbf61..4717da652fc 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -359,14 +359,12 @@ jobs: src/main/daemon/shell-ready.test.ts \ src/main/daemon/node-pty-fd-leak.test.ts \ src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \ - src/main/providers/local-pty-shell-ready-zsh-startup-file-behavior.test.ts \ - src/main/providers/local-pty-shell-ready-zsh-zdotdir-discovery.test.ts \ - src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts \ src/main/providers/__tests__/shell-ready-framework-example.test.ts \ src/main/pty/omp-shell-wrapper.node-pty.test.ts \ src/main/shell-startup-feature-channel.test.ts \ src/main/terminal-history-fish-session.node-pty.test.ts \ src/main/zsh-scoped-histfile.live-shell.test.ts \ + src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \ src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \ src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \ src/shared/startup-shell-portability.live-shell.test.ts \ @@ -403,14 +401,12 @@ jobs: --exclude=src/main/daemon/shell-ready.test.ts \ --exclude=src/main/daemon/node-pty-fd-leak.test.ts \ --exclude=src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \ - --exclude=src/main/providers/local-pty-shell-ready-zsh-startup-file-behavior.test.ts \ - --exclude=src/main/providers/local-pty-shell-ready-zsh-zdotdir-discovery.test.ts \ - --exclude=src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts \ --exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \ --exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \ --exclude=src/main/shell-startup-feature-channel.test.ts \ --exclude=src/main/terminal-history-fish-session.node-pty.test.ts \ --exclude=src/main/zsh-scoped-histfile.live-shell.test.ts \ + --exclude=src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \ --exclude=src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \ --exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \ --exclude=src/shared/startup-shell-portability.live-shell.test.ts \ diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index 507b5396650..5b9ab226502 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -11,12 +11,10 @@ const shellContractFiles = [ 'src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts', 'src/main/daemon/shell-ready.test.ts', 'src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts', - 'src/main/providers/local-pty-shell-ready-zsh-startup-file-behavior.test.ts', - 'src/main/providers/local-pty-shell-ready-zsh-zdotdir-discovery.test.ts', - 'src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts', 'src/main/providers/__tests__/shell-ready-framework-example.test.ts', 'src/main/shell-startup-feature-channel.test.ts', 'src/main/zsh-scoped-histfile.live-shell.test.ts', + 'src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts', 'src/main/zsh-wrapper-version-mismatch.live-shell.test.ts', 'src/shared/posix-command-path-lookup.test.ts' ] @@ -31,8 +29,12 @@ const testFilePatterns = [ 'tests/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}', 'tests/tools/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}' ] +// Why the harness import counts: the zsh startup hook runs from a `precmd`, so +// its tests drive a real zsh through a PTY in zsh-startup-hook-pty-harness +// rather than calling spawnSync('zsh') themselves. Without this branch the rule +// silently stops noticing the very tests that need the lane's zsh install. const realZshUsage = - /(?:spawnSync|execFileSync|spawn)\(\s*['"](?:\/(?:usr\/)?bin\/)?zsh['"]|spawnSync\(\s*['"]which['"]\s*,\s*\[\s*['"]zsh['"]|name:\s*['"]zsh['"]\s*,\s*path:\s*executablePath/ + /(?:spawnSync|execFileSync|spawn)\(\s*['"](?:\/(?:usr\/)?bin\/)?zsh['"]|spawnSync\(\s*['"]which['"]\s*,\s*\[\s*['"]zsh['"]|name:\s*['"]zsh['"]\s*,\s*path:\s*executablePath|from '[^']*zsh-startup-hook-pty-harness'/ describe('PR workflow parallelism', () => { it('cancels superseded runs for the same pull request', () => { @@ -154,6 +156,9 @@ describe('PR workflow parallelism', () => { it('keeps every real-zsh test in the dedicated shell lane', () => { const discoveredFiles = globSync(testFilePatterns) + // Why this file is excluded: it carries the detector pattern as a literal + // and would otherwise match itself. + .filter((testFile) => testFile !== 'config/scripts/pr-workflow-parallelism.test.mjs') .filter((testFile) => realZshUsage.test(readFileSync(testFile, 'utf8'))) .sort() diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zlogin.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zlogin.txt deleted file mode 100644 index 2c84163caa7..00000000000 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zlogin.txt +++ /dev/null @@ -1,21 +0,0 @@ -# Orca daemon zsh shell-ready wrapper -__orca_resolve_user_config_dir() { - typeset -g _orca_resolved_config_dir="${1:-}" - while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done - if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then - _orca_resolved_config_dir="$HOME" - fi -} -__orca_resolve_user_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" -_orca_home="$_orca_resolved_config_dir" -if [[ -o interactive && -f "$_orca_home/.zlogin" ]]; then - _orca_wrapper_zdotdir="$ZDOTDIR" - # Why: user startup files resolve plugin/config paths from their own ZDOTDIR; - # Orca restores its wrapper dir afterward so zsh still loads wrapper files. - export ZDOTDIR="$_orca_home" - source "$_orca_home/.zlogin" - export ZDOTDIR="$_orca_wrapper_zdotdir" - unset _orca_wrapper_zdotdir -fi - -(( ${+functions[__orca_shell_epilogue]} )) && __orca_shell_epilogue diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zprofile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zprofile.txt deleted file mode 100644 index 7b275404b38..00000000000 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zprofile.txt +++ /dev/null @@ -1,32 +0,0 @@ -# Orca daemon zsh shell-ready wrapper -__orca_resolve_user_config_dir() { - typeset -g _orca_resolved_config_dir="${1:-}" - while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done - if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then - _orca_resolved_config_dir="$HOME" - fi -} -__orca_resolve_user_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" -_orca_home="$_orca_resolved_config_dir" -if [[ -f "$_orca_home/.zprofile" ]]; then - _orca_wrapper_zdotdir="$ZDOTDIR" - # Why: user startup files resolve plugin/config paths from their own ZDOTDIR; - # Orca restores its wrapper dir afterward so zsh still loads wrapper files. - export ZDOTDIR="$_orca_home" - source "$_orca_home/.zprofile" - export ZDOTDIR="$_orca_wrapper_zdotdir" - unset _orca_wrapper_zdotdir -fi - -if [[ -f "$_orca_home/.zprofile" ]] && [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null; then - case "$(emulate 2>/dev/null)" in - sh|ksh) - export ZDOTDIR="$_orca_home" - # Why unset: an ORCA_HISTFILE no wrapper file will ever consume is - # inherited by everything this pane spawns, including a nested Orca. - builtin unset ORCA_HISTFILE _orca_shell_features _orca_home _orca_resolved_config_dir _orca_wrapper_zdotdir_self - unfunction __orca_shell_epilogue __orca_has_feature __orca_resolve_user_config_dir __orca_resolve_inherited_config_dir 2>/dev/null - return 0 - ;; - esac -fi diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt index fdd43798f72..1b89c740f2b 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt @@ -1,119 +1,65 @@ # Orca daemon zsh shell-ready wrapper -typeset -ga _orca_shell_features -_orca_shell_features=(${(s:,:)${ORCA_SHELL_FEATURES:-}}) -builtin unset ORCA_SHELL_FEATURES -__orca_has_feature() { (( ${_orca_shell_features[(Ie)$1]} )) } -__orca_has_feature identity && printf "\033]777;orca-shell-start:%s\007" "$$" -__orca_resolve_user_config_dir() { - typeset -g _orca_resolved_config_dir="${1:-}" - while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done - if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then - _orca_resolved_config_dir="$HOME" - fi -} -# Why stricter for an inherited value: Orca can be launched from a terminal that -# already pointed ZDOTDIR at its own wrapper dir, and a directory holding no zsh -# startup file at all is not the user's config root whoever wrote it. -__orca_resolve_inherited_config_dir() { - __orca_resolve_user_config_dir "${1:-}" - [[ "$_orca_resolved_config_dir" == "$HOME" ]] && return 0 +__orca_usable_zdotdir() { + [[ -n "${1:-}" ]] || return 1 + # Orca's own dir, by marker file or by the shape older builds wrote. + [[ "$1" != */shell-ready/zsh ]] || return 1 + [[ ! -f "$1/.orca-shell-wrapper" ]] || return 1 + # A directory holding no zsh startup file at all is not a config root, + # whoever wrote it — and a stale value pointing at one would stop zsh from + # ever reading the user's real .zshenv. local _orca_startup_file for _orca_startup_file in .zshenv .zshrc .zprofile .zlogin; do - [[ -r "$_orca_resolved_config_dir/$_orca_startup_file" ]] && return 0 + [[ -r "$1/$_orca_startup_file" ]] && return 0 done - _orca_resolved_config_dir="$HOME" + return 1 } -# Why: capture the runtime wrapper dir before it is unset below. On WSL this -# file is generated with a Windows path but sourced via /mnt/c, so the baked -# literal is unusable there and ZDOTDIR must be restored from this value. -# Derive it from the file being sourced (%x, zsh's internal script name) rather -# than the env-imported $ZDOTDIR: zsh corrupts environment values whose UTF-8 -# bytes fall in its 0x84-0x9D token range (e.g. a non-ASCII Windows username -# such as a Korean login), which would make the self-check below fail and fall -# back to the unusable baked literal, so the user's .zshrc never loads (#8003). -# %x is not subject to that corruption; keep $ZDOTDIR as a fallback for the -# rare shell where %x prompt expansion yields nothing. -_orca_wrapper_zdotdir_self="${${(%):-%x}:h}" -if [[ -z "${_orca_wrapper_zdotdir_self:-}" ]]; then - _orca_wrapper_zdotdir_self="${ZDOTDIR:-}" -fi -while [[ "${_orca_wrapper_zdotdir_self:-}" == */ ]]; do - _orca_wrapper_zdotdir_self="${_orca_wrapper_zdotdir_self%/}" -done -_orca_zshenv_path="" - -# Normalize fallback and source roots before reading user .zshenv so nested -# Orca PTYs never source another Orca wrapper recursively. -__orca_resolve_inherited_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" -_orca_user_zdotdir="$_orca_resolved_config_dir" -__orca_resolve_inherited_config_dir "${ORCA_ZSHENV_SOURCE_DIR:-$HOME}" -_orca_zshenv_source_dir="$_orca_resolved_config_dir" -unset ORCA_ZSHENV_SOURCE_DIR - -# Why: source at wrapper top level, not in a function/subshell, so .zshenv -# exports, functions, path/fpath typesets, and zsh options keep normal scope. -unset ZDOTDIR -if [[ -n "${_orca_zshenv_source_dir:-}" && -f "${_orca_zshenv_source_dir}/.zshenv" ]]; then - _orca_zshenv_path="${_orca_zshenv_source_dir}/.zshenv" -fi -if [[ -n "${_orca_zshenv_path:-}" ]]; then - source "${_orca_zshenv_path}" -fi - -_orca_discovered_zdotdir="${ZDOTDIR:-}" - -while [[ "${_orca_discovered_zdotdir}" == */ ]]; do - _orca_discovered_zdotdir="${_orca_discovered_zdotdir%/}" -done - -case "${_orca_discovered_zdotdir}" in - *[![:space:]]*) ;; - *) _orca_discovered_zdotdir="" ;; -esac - -if [[ -n "${_orca_discovered_zdotdir}" && ! -d "${_orca_discovered_zdotdir}" ]]; then - [[ "${ORCA_DEBUG:-0}" == "1" ]] && echo "[orca-shell-ready] Discovered ZDOTDIR '${_orca_discovered_zdotdir}' does not exist, falling back" >&2 - _orca_discovered_zdotdir="" -fi - -# Why only the ownership check here: a ZDOTDIR the user's own .zshenv just -# exported is the user's by construction, whatever it happens to contain. -__orca_resolve_user_config_dir "${_orca_discovered_zdotdir:-${_orca_user_zdotdir:-$HOME}}" -export ORCA_ORIG_ZDOTDIR="$_orca_resolved_config_dir" -unset _orca_user_zdotdir _orca_zshenv_source_dir _orca_discovered_zdotdir - -if [[ -n "${_orca_zshenv_path:-}" ]] && [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null; then - case "$(emulate 2>/dev/null)" in - sh|ksh) - export ZDOTDIR="$ORCA_ORIG_ZDOTDIR" - # Why unset: an ORCA_HISTFILE no wrapper file will ever consume is - # inherited by everything this pane spawns, including a nested Orca. - builtin unset ORCA_HISTFILE _orca_shell_features _orca_home _orca_resolved_config_dir _orca_wrapper_zdotdir_self - unfunction __orca_shell_epilogue __orca_has_feature __orca_resolve_user_config_dir __orca_resolve_inherited_config_dir 2>/dev/null - return 0 - ;; - esac -fi -unset _orca_zshenv_path - -# Why: use :- after user .zshenv — a pathological unset under set -u must not -# abort the wrapper; empty falls through to the baked-literal branch. -if [[ -n "${_orca_wrapper_zdotdir_self:-}" && -f "${_orca_wrapper_zdotdir_self:-}/.zshenv" ]]; then - export ZDOTDIR="${_orca_wrapper_zdotdir_self:-}" +if __orca_usable_zdotdir "${ORCA_ORIG_ZDOTDIR:-}"; then + builtin export ZDOTDIR="$ORCA_ORIG_ZDOTDIR" else - export ZDOTDIR='/zsh' + builtin unset ZDOTDIR fi -unset _orca_wrapper_zdotdir_self - -__orca_shell_epilogue() { +builtin unset ORCA_ORIG_ZDOTDIR ORCA_ZSHENV_SOURCE_DIR +builtin unfunction __orca_usable_zdotdir +builtin typeset -ga _orca_shell_features +_orca_shell_features=(${(s:,:)${ORCA_SHELL_FEATURES:-}}) +builtin unset ORCA_SHELL_FEATURES +# Why ORCA_HISTFILE is consumed HERE and not in the deferred hook: a user config +# that replaces precmd_functions wholesale drops the hook, and an exported value +# nothing will ever consume is then inherited by every child of this pane, +# including a nested Orca (#11146). Captured non-exported, it cannot escape. +builtin typeset -g _orca_histfile="${ORCA_HISTFILE:-}" +builtin unset ORCA_HISTFILE +__orca_has_feature() { (( ${_orca_shell_features[(Ie)$1]} )) } +__orca_has_feature identity && printf "\033]777;orca-shell-start:%s\007" "$$" +__orca_osc133_precmd() { + local exit_code=$? + if [[ -n "${__orca_in_command:-}" ]]; then + builtin printf "\033]133;D;%s\007" "$exit_code" + builtin unset __orca_in_command + fi + builtin printf "\033]133;A\007" +} +__orca_osc133_preexec() { + builtin printf "\033]133;C\007" + # Why typeset -g: a plain assignment here creates a global inside a function, + # which prints a warning above every command under warn_create_global. + builtin typeset -g __orca_in_command=1 +} +__orca_deferred_init() { # Why first: this body runs after the user's own config, so it would otherwise # inherit whatever options that config left set. Under NO_UNSET an unset - # precmd_functions is a fatal error that returns from the whole epilogue - # (skipping the ready widget and the ZDOTDIR restore), and KSH_ARRAYS makes - # the 1-based feature lookup drop the first selected feature. - emulate -L zsh - (( $+_orca_epilogue_done )) && return 0 - typeset -g _orca_epilogue_done=1 + # precmd_functions is fatal, and KSH_ARRAYS makes the 1-based feature lookup + # drop whichever feature is listed first. + builtin emulate -L zsh + (( $+_orca_deferred_init_done )) && return 0 + builtin typeset -g _orca_deferred_init_done=1 + builtin typeset -g precmd_functions + if __orca_has_feature markers; then + precmd_functions=(${precmd_functions:/__orca_deferred_init/__orca_osc133_precmd}) + preexec_functions=(__orca_osc133_preexec ${preexec_functions[@]}) + else + precmd_functions=(${precmd_functions:#__orca_deferred_init}) + fi if __orca_has_feature overlay; then # Why: ~/.zshrc can export the user's default OpenCode config after spawn. __orca_restore_agent_teams_path() { @@ -166,35 +112,8 @@ __orca_shell_epilogue() { fi unset __orca_codex_binary fi - if [[ -n "${ORCA_HISTFILE:-}" ]]; then - HISTFILE="$ORCA_HISTFILE" - builtin unset ORCA_HISTFILE - elif [[ "${HISTFILE:-}" == "$ZDOTDIR/.zsh_history" ]]; then - # Why also when Orca injected nothing: /etc/zshrc derived this from Orca's - # wrapper ZDOTDIR, so history would accumulate INSIDE the wrapper dir and the - # user's real history would be invisible — the plain #11044 bug, with no - # per-worktree scoping involved. Matching the exact clobbered value means a - # HISTFILE the user set deliberately is never touched. - HISTFILE="${ORCA_ORIG_ZDOTDIR:-$HOME}/.zsh_history" - fi - if __orca_has_feature markers; then - __orca_osc133_precmd() { - local exit_code=$? - if [[ -n "${__orca_in_command:-}" ]]; then - printf "\033]133;D;%s\007" "$exit_code" - unset __orca_in_command - fi - printf "\033]133;A\007" - } - __orca_osc133_preexec() { - printf "\033]133;C\007" - # Why typeset -g: a plain assignment here creates a global inside a function, - # which prints a warning above every command under warn_create_global. - typeset -g __orca_in_command=1 - } - # Why: prepend so Orca captures $? before user prompt hooks can overwrite it. - precmd_functions=(__orca_osc133_precmd ${precmd_functions[@]}) - preexec_functions=(__orca_osc133_preexec ${preexec_functions[@]}) + if [[ -n "${_orca_histfile:-}" ]]; then + HISTFILE="$_orca_histfile" fi if __orca_has_feature ready; then # Why: capture the prior zle-line-init so the marker chains to it. On a @@ -220,11 +139,17 @@ __orca_shell_epilogue() { } zle -N zle-line-init __orca_prompt_mark fi - __orca_resolve_user_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" - # Why: after Orca's last wrapper file has loaded, the interactive shell should - # expose the same ZDOTDIR a normal zsh startup would expose. - export ZDOTDIR="$_orca_resolved_config_dir" - unset _orca_home _orca_resolved_config_dir - unset _orca_shell_features - unfunction __orca_shell_epilogue __orca_has_feature __orca_resolve_user_config_dir __orca_resolve_inherited_config_dir + # Why called here: we were appended during this prompt's own precmd sweep, so + # the permanent hook has not run yet and the first prompt would lose its mark. + __orca_has_feature markers && __orca_osc133_precmd + builtin unset _orca_shell_features _orca_histfile + builtin unfunction __orca_deferred_init __orca_has_feature +} +{ + builtin typeset _orca_user_zshenv="${ZDOTDIR-$HOME}/.zshenv" + [[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv" +} always { + builtin unset _orca_user_zshenv + builtin typeset -ag precmd_functions + (( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshrc.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshrc.txt deleted file mode 100644 index 8a4e38c3aa3..00000000000 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshrc.txt +++ /dev/null @@ -1,23 +0,0 @@ -# Orca daemon zsh shell-ready wrapper -__orca_resolve_user_config_dir() { - typeset -g _orca_resolved_config_dir="${1:-}" - while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done - if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then - _orca_resolved_config_dir="$HOME" - fi -} -__orca_resolve_user_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" -_orca_home="$_orca_resolved_config_dir" -if [[ "$_orca_home" != "$ZDOTDIR" && -o interactive && -f "$_orca_home/.zshrc" ]]; then - _orca_wrapper_zdotdir="$ZDOTDIR" - # Why: user startup files resolve plugin/config paths from their own ZDOTDIR; - # Orca restores its wrapper dir afterward so zsh still loads wrapper files. - export ZDOTDIR="$_orca_home" - source "$_orca_home/.zshrc" - export ZDOTDIR="$_orca_wrapper_zdotdir" - unset _orca_wrapper_zdotdir -fi - -if [[ ! -o login ]] || { [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null && [[ "$(emulate 2>/dev/null)" != zsh ]]; }; then - (( ${+functions[__orca_shell_epilogue]} )) && __orca_shell_epilogue -fi diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zlogin.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zlogin.txt deleted file mode 100644 index a7556c7f006..00000000000 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zlogin.txt +++ /dev/null @@ -1,21 +0,0 @@ -# Orca zsh shell-ready wrapper -__orca_resolve_user_config_dir() { - typeset -g _orca_resolved_config_dir="${1:-}" - while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done - if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then - _orca_resolved_config_dir="$HOME" - fi -} -__orca_resolve_user_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" -_orca_home="$_orca_resolved_config_dir" -if [[ -o interactive && -f "$_orca_home/.zlogin" ]]; then - _orca_wrapper_zdotdir="$ZDOTDIR" - # Why: user startup files resolve plugin/config paths from their own ZDOTDIR; - # Orca restores its wrapper dir afterward so zsh still loads wrapper files. - export ZDOTDIR="$_orca_home" - source "$_orca_home/.zlogin" - export ZDOTDIR="$_orca_wrapper_zdotdir" - unset _orca_wrapper_zdotdir -fi - -(( ${+functions[__orca_shell_epilogue]} )) && __orca_shell_epilogue diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zprofile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zprofile.txt deleted file mode 100644 index ee1befa1429..00000000000 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zprofile.txt +++ /dev/null @@ -1,32 +0,0 @@ -# Orca zsh shell-ready wrapper -__orca_resolve_user_config_dir() { - typeset -g _orca_resolved_config_dir="${1:-}" - while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done - if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then - _orca_resolved_config_dir="$HOME" - fi -} -__orca_resolve_user_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" -_orca_home="$_orca_resolved_config_dir" -if [[ -f "$_orca_home/.zprofile" ]]; then - _orca_wrapper_zdotdir="$ZDOTDIR" - # Why: user startup files resolve plugin/config paths from their own ZDOTDIR; - # Orca restores its wrapper dir afterward so zsh still loads wrapper files. - export ZDOTDIR="$_orca_home" - source "$_orca_home/.zprofile" - export ZDOTDIR="$_orca_wrapper_zdotdir" - unset _orca_wrapper_zdotdir -fi - -if [[ -f "$_orca_home/.zprofile" ]] && [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null; then - case "$(emulate 2>/dev/null)" in - sh|ksh) - export ZDOTDIR="$_orca_home" - # Why unset: an ORCA_HISTFILE no wrapper file will ever consume is - # inherited by everything this pane spawns, including a nested Orca. - builtin unset ORCA_HISTFILE _orca_shell_features _orca_home _orca_resolved_config_dir _orca_wrapper_zdotdir_self - unfunction __orca_shell_epilogue __orca_has_feature __orca_resolve_user_config_dir __orca_resolve_inherited_config_dir 2>/dev/null - return 0 - ;; - esac -fi diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt index 33d4d04d2a6..d73f2d4c4c9 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt @@ -1,119 +1,65 @@ # Orca zsh shell-ready wrapper -typeset -ga _orca_shell_features -_orca_shell_features=(${(s:,:)${ORCA_SHELL_FEATURES:-}}) -builtin unset ORCA_SHELL_FEATURES -__orca_has_feature() { (( ${_orca_shell_features[(Ie)$1]} )) } -__orca_has_feature identity && printf "\033]777;orca-shell-start:%s\007" "$$" -__orca_resolve_user_config_dir() { - typeset -g _orca_resolved_config_dir="${1:-}" - while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done - if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then - _orca_resolved_config_dir="$HOME" - fi -} -# Why stricter for an inherited value: Orca can be launched from a terminal that -# already pointed ZDOTDIR at its own wrapper dir, and a directory holding no zsh -# startup file at all is not the user's config root whoever wrote it. -__orca_resolve_inherited_config_dir() { - __orca_resolve_user_config_dir "${1:-}" - [[ "$_orca_resolved_config_dir" == "$HOME" ]] && return 0 +__orca_usable_zdotdir() { + [[ -n "${1:-}" ]] || return 1 + # Orca's own dir, by marker file or by the shape older builds wrote. + [[ "$1" != */shell-ready/zsh ]] || return 1 + [[ ! -f "$1/.orca-shell-wrapper" ]] || return 1 + # A directory holding no zsh startup file at all is not a config root, + # whoever wrote it — and a stale value pointing at one would stop zsh from + # ever reading the user's real .zshenv. local _orca_startup_file for _orca_startup_file in .zshenv .zshrc .zprofile .zlogin; do - [[ -r "$_orca_resolved_config_dir/$_orca_startup_file" ]] && return 0 + [[ -r "$1/$_orca_startup_file" ]] && return 0 done - _orca_resolved_config_dir="$HOME" + return 1 } -# Why: capture the runtime wrapper dir before it is unset below. On WSL this -# file is generated with a Windows path but sourced via /mnt/c, so the baked -# literal is unusable there and ZDOTDIR must be restored from this value. -# Derive it from the file being sourced (%x, zsh's internal script name) rather -# than the env-imported $ZDOTDIR: zsh corrupts environment values whose UTF-8 -# bytes fall in its 0x84-0x9D token range (e.g. a non-ASCII Windows username -# such as a Korean login), which would make the self-check below fail and fall -# back to the unusable baked literal, so the user's .zshrc never loads (#8003). -# %x is not subject to that corruption; keep $ZDOTDIR as a fallback for the -# rare shell where %x prompt expansion yields nothing. -_orca_wrapper_zdotdir_self="${${(%):-%x}:h}" -if [[ -z "${_orca_wrapper_zdotdir_self:-}" ]]; then - _orca_wrapper_zdotdir_self="${ZDOTDIR:-}" -fi -while [[ "${_orca_wrapper_zdotdir_self:-}" == */ ]]; do - _orca_wrapper_zdotdir_self="${_orca_wrapper_zdotdir_self%/}" -done -_orca_zshenv_path="" - -# Normalize fallback and source roots before reading user .zshenv so nested -# Orca PTYs never source another Orca wrapper recursively. -__orca_resolve_inherited_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" -_orca_user_zdotdir="$_orca_resolved_config_dir" -__orca_resolve_inherited_config_dir "${ORCA_ZSHENV_SOURCE_DIR:-$HOME}" -_orca_zshenv_source_dir="$_orca_resolved_config_dir" -unset ORCA_ZSHENV_SOURCE_DIR - -# Why: source at wrapper top level, not in a function/subshell, so .zshenv -# exports, functions, path/fpath typesets, and zsh options keep normal scope. -unset ZDOTDIR -if [[ -n "${_orca_zshenv_source_dir:-}" && -f "${_orca_zshenv_source_dir}/.zshenv" ]]; then - _orca_zshenv_path="${_orca_zshenv_source_dir}/.zshenv" -fi -if [[ -n "${_orca_zshenv_path:-}" ]]; then - source "${_orca_zshenv_path}" -fi - -_orca_discovered_zdotdir="${ZDOTDIR:-}" - -while [[ "${_orca_discovered_zdotdir}" == */ ]]; do - _orca_discovered_zdotdir="${_orca_discovered_zdotdir%/}" -done - -case "${_orca_discovered_zdotdir}" in - *[![:space:]]*) ;; - *) _orca_discovered_zdotdir="" ;; -esac - -if [[ -n "${_orca_discovered_zdotdir}" && ! -d "${_orca_discovered_zdotdir}" ]]; then - [[ "${ORCA_DEBUG:-0}" == "1" ]] && echo "[orca-shell-ready] Discovered ZDOTDIR '${_orca_discovered_zdotdir}' does not exist, falling back" >&2 - _orca_discovered_zdotdir="" -fi - -# Why only the ownership check here: a ZDOTDIR the user's own .zshenv just -# exported is the user's by construction, whatever it happens to contain. -__orca_resolve_user_config_dir "${_orca_discovered_zdotdir:-${_orca_user_zdotdir:-$HOME}}" -export ORCA_ORIG_ZDOTDIR="$_orca_resolved_config_dir" -unset _orca_user_zdotdir _orca_zshenv_source_dir _orca_discovered_zdotdir - -if [[ -n "${_orca_zshenv_path:-}" ]] && [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null; then - case "$(emulate 2>/dev/null)" in - sh|ksh) - export ZDOTDIR="$ORCA_ORIG_ZDOTDIR" - # Why unset: an ORCA_HISTFILE no wrapper file will ever consume is - # inherited by everything this pane spawns, including a nested Orca. - builtin unset ORCA_HISTFILE _orca_shell_features _orca_home _orca_resolved_config_dir _orca_wrapper_zdotdir_self - unfunction __orca_shell_epilogue __orca_has_feature __orca_resolve_user_config_dir __orca_resolve_inherited_config_dir 2>/dev/null - return 0 - ;; - esac -fi -unset _orca_zshenv_path - -# Why: use :- after user .zshenv — a pathological unset under set -u must not -# abort the wrapper; empty falls through to the baked-literal branch. -if [[ -n "${_orca_wrapper_zdotdir_self:-}" && -f "${_orca_wrapper_zdotdir_self:-}/.zshenv" ]]; then - export ZDOTDIR="${_orca_wrapper_zdotdir_self:-}" +if __orca_usable_zdotdir "${ORCA_ORIG_ZDOTDIR:-}"; then + builtin export ZDOTDIR="$ORCA_ORIG_ZDOTDIR" else - export ZDOTDIR='/zsh' + builtin unset ZDOTDIR fi -unset _orca_wrapper_zdotdir_self - -__orca_shell_epilogue() { +builtin unset ORCA_ORIG_ZDOTDIR ORCA_ZSHENV_SOURCE_DIR +builtin unfunction __orca_usable_zdotdir +builtin typeset -ga _orca_shell_features +_orca_shell_features=(${(s:,:)${ORCA_SHELL_FEATURES:-}}) +builtin unset ORCA_SHELL_FEATURES +# Why ORCA_HISTFILE is consumed HERE and not in the deferred hook: a user config +# that replaces precmd_functions wholesale drops the hook, and an exported value +# nothing will ever consume is then inherited by every child of this pane, +# including a nested Orca (#11146). Captured non-exported, it cannot escape. +builtin typeset -g _orca_histfile="${ORCA_HISTFILE:-}" +builtin unset ORCA_HISTFILE +__orca_has_feature() { (( ${_orca_shell_features[(Ie)$1]} )) } +__orca_has_feature identity && printf "\033]777;orca-shell-start:%s\007" "$$" +__orca_osc133_precmd() { + local exit_code=$? + if [[ -n "${__orca_in_command:-}" ]]; then + builtin printf "\033]133;D;%s\007" "$exit_code" + builtin unset __orca_in_command + fi + builtin printf "\033]133;A\007" +} +__orca_osc133_preexec() { + builtin printf "\033]133;C\007" + # Why typeset -g: a plain assignment here creates a global inside a function, + # which prints a warning above every command under warn_create_global. + builtin typeset -g __orca_in_command=1 +} +__orca_deferred_init() { # Why first: this body runs after the user's own config, so it would otherwise # inherit whatever options that config left set. Under NO_UNSET an unset - # precmd_functions is a fatal error that returns from the whole epilogue - # (skipping the ready widget and the ZDOTDIR restore), and KSH_ARRAYS makes - # the 1-based feature lookup drop the first selected feature. - emulate -L zsh - (( $+_orca_epilogue_done )) && return 0 - typeset -g _orca_epilogue_done=1 + # precmd_functions is fatal, and KSH_ARRAYS makes the 1-based feature lookup + # drop whichever feature is listed first. + builtin emulate -L zsh + (( $+_orca_deferred_init_done )) && return 0 + builtin typeset -g _orca_deferred_init_done=1 + builtin typeset -g precmd_functions + if __orca_has_feature markers; then + precmd_functions=(${precmd_functions:/__orca_deferred_init/__orca_osc133_precmd}) + preexec_functions=(__orca_osc133_preexec ${preexec_functions[@]}) + else + precmd_functions=(${precmd_functions:#__orca_deferred_init}) + fi if __orca_has_feature overlay; then # Why: ~/.zshrc can export the user's default OpenCode config after spawn. __orca_restore_agent_teams_path() { @@ -166,35 +112,8 @@ __orca_shell_epilogue() { fi unset __orca_codex_binary fi - if [[ -n "${ORCA_HISTFILE:-}" ]]; then - HISTFILE="$ORCA_HISTFILE" - builtin unset ORCA_HISTFILE - elif [[ "${HISTFILE:-}" == "$ZDOTDIR/.zsh_history" ]]; then - # Why also when Orca injected nothing: /etc/zshrc derived this from Orca's - # wrapper ZDOTDIR, so history would accumulate INSIDE the wrapper dir and the - # user's real history would be invisible — the plain #11044 bug, with no - # per-worktree scoping involved. Matching the exact clobbered value means a - # HISTFILE the user set deliberately is never touched. - HISTFILE="${ORCA_ORIG_ZDOTDIR:-$HOME}/.zsh_history" - fi - if __orca_has_feature markers; then - __orca_osc133_precmd() { - local exit_code=$? - if [[ -n "${__orca_in_command:-}" ]]; then - printf "\033]133;D;%s\007" "$exit_code" - unset __orca_in_command - fi - printf "\033]133;A\007" - } - __orca_osc133_preexec() { - printf "\033]133;C\007" - # Why typeset -g: a plain assignment here creates a global inside a function, - # which prints a warning above every command under warn_create_global. - typeset -g __orca_in_command=1 - } - # Why: prepend so Orca captures $? before user prompt hooks can overwrite it. - precmd_functions=(__orca_osc133_precmd ${precmd_functions[@]}) - preexec_functions=(__orca_osc133_preexec ${preexec_functions[@]}) + if [[ -n "${_orca_histfile:-}" ]]; then + HISTFILE="$_orca_histfile" fi if __orca_has_feature ready; then # Why: capture the prior zle-line-init so the marker chains to it. On a @@ -220,11 +139,17 @@ __orca_shell_epilogue() { } zle -N zle-line-init __orca_prompt_mark fi - __orca_resolve_user_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" - # Why: after Orca's last wrapper file has loaded, the interactive shell should - # expose the same ZDOTDIR a normal zsh startup would expose. - export ZDOTDIR="$_orca_resolved_config_dir" - unset _orca_home _orca_resolved_config_dir - unset _orca_shell_features - unfunction __orca_shell_epilogue __orca_has_feature __orca_resolve_user_config_dir __orca_resolve_inherited_config_dir + # Why called here: we were appended during this prompt's own precmd sweep, so + # the permanent hook has not run yet and the first prompt would lose its mark. + __orca_has_feature markers && __orca_osc133_precmd + builtin unset _orca_shell_features _orca_histfile + builtin unfunction __orca_deferred_init __orca_has_feature +} +{ + builtin typeset _orca_user_zshenv="${ZDOTDIR-$HOME}/.zshenv" + [[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv" +} always { + builtin unset _orca_user_zshenv + builtin typeset -ag precmd_functions + (( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshrc.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshrc.txt deleted file mode 100644 index 4776da936c3..00000000000 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshrc.txt +++ /dev/null @@ -1,23 +0,0 @@ -# Orca zsh shell-ready wrapper -__orca_resolve_user_config_dir() { - typeset -g _orca_resolved_config_dir="${1:-}" - while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done - if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then - _orca_resolved_config_dir="$HOME" - fi -} -__orca_resolve_user_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" -_orca_home="$_orca_resolved_config_dir" -if [[ "$_orca_home" != "$ZDOTDIR" && -o interactive && -f "$_orca_home/.zshrc" ]]; then - _orca_wrapper_zdotdir="$ZDOTDIR" - # Why: user startup files resolve plugin/config paths from their own ZDOTDIR; - # Orca restores its wrapper dir afterward so zsh still loads wrapper files. - export ZDOTDIR="$_orca_home" - source "$_orca_home/.zshrc" - export ZDOTDIR="$_orca_wrapper_zdotdir" - unset _orca_wrapper_zdotdir -fi - -if [[ ! -o login ]] || { [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null && [[ "$(emulate 2>/dev/null)" != zsh ]]; }; then - (( ${+functions[__orca_shell_epilogue]} )) && __orca_shell_epilogue -fi diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zlogin.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zlogin.txt deleted file mode 100644 index c76f4f6973d..00000000000 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zlogin.txt +++ /dev/null @@ -1,21 +0,0 @@ -# Orca relay zsh overlay wrapper -__orca_resolve_user_config_dir() { - typeset -g _orca_resolved_config_dir="${1:-}" - while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done - if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then - _orca_resolved_config_dir="$HOME" - fi -} -__orca_resolve_user_config_dir "${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}" -_orca_home="$_orca_resolved_config_dir" -if [[ -o interactive && -f "$_orca_home/.zlogin" ]]; then - _orca_wrapper_zdotdir="$ZDOTDIR" - # Why: user startup files resolve plugin/config paths from their own ZDOTDIR; - # Orca restores its wrapper dir afterward so zsh still loads wrapper files. - export ZDOTDIR="$_orca_home" - source "$_orca_home/.zlogin" - export ZDOTDIR="$_orca_wrapper_zdotdir" - unset _orca_wrapper_zdotdir -fi - -(( ${+functions[__orca_shell_epilogue]} )) && __orca_shell_epilogue diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zprofile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zprofile.txt deleted file mode 100644 index 79540246999..00000000000 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zprofile.txt +++ /dev/null @@ -1,32 +0,0 @@ -# Orca relay zsh overlay wrapper -__orca_resolve_user_config_dir() { - typeset -g _orca_resolved_config_dir="${1:-}" - while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done - if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then - _orca_resolved_config_dir="$HOME" - fi -} -__orca_resolve_user_config_dir "${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}" -_orca_home="$_orca_resolved_config_dir" -if [[ -f "$_orca_home/.zprofile" ]]; then - _orca_wrapper_zdotdir="$ZDOTDIR" - # Why: user startup files resolve plugin/config paths from their own ZDOTDIR; - # Orca restores its wrapper dir afterward so zsh still loads wrapper files. - export ZDOTDIR="$_orca_home" - source "$_orca_home/.zprofile" - export ZDOTDIR="$_orca_wrapper_zdotdir" - unset _orca_wrapper_zdotdir -fi - -if [[ -f "$_orca_home/.zprofile" ]] && [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null; then - case "$(emulate 2>/dev/null)" in - sh|ksh) - export ZDOTDIR="$_orca_home" - # Why unset: an ORCA_HISTFILE no wrapper file will ever consume is - # inherited by everything this pane spawns, including a nested Orca. - builtin unset ORCA_HISTFILE _orca_shell_features _orca_home _orca_resolved_config_dir _orca_wrapper_zdotdir_self - unfunction __orca_shell_epilogue __orca_has_feature __orca_resolve_user_config_dir __orca_resolve_inherited_config_dir 2>/dev/null - return 0 - ;; - esac -fi diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt index 750978b1cc9..40e10d17210 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt @@ -1,58 +1,46 @@ # Orca relay zsh overlay wrapper -typeset -ga _orca_shell_features -_orca_shell_features=(${(s:,:)${ORCA_SHELL_FEATURES:-}}) -builtin unset ORCA_SHELL_FEATURES -__orca_has_feature() { (( ${_orca_shell_features[(Ie)$1]} )) } -__orca_has_feature identity && printf "\033]777;orca-shell-start:%s\007" "$$" -__orca_resolve_user_config_dir() { - typeset -g _orca_resolved_config_dir="${1:-}" - while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done - if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then - _orca_resolved_config_dir="$HOME" - fi -} -# Why stricter for an inherited value: Orca can be launched from a terminal that -# already pointed ZDOTDIR at its own wrapper dir, and a directory holding no zsh -# startup file at all is not the user's config root whoever wrote it. -__orca_resolve_inherited_config_dir() { - __orca_resolve_user_config_dir "${1:-}" - [[ "$_orca_resolved_config_dir" == "$HOME" ]] && return 0 +__orca_usable_zdotdir() { + [[ -n "${1:-}" ]] || return 1 + # Orca's own dir, by marker file or by the shape older builds wrote. + [[ "$1" != */shell-ready/zsh ]] || return 1 + [[ ! -f "$1/.orca-shell-wrapper" ]] || return 1 + # A directory holding no zsh startup file at all is not a config root, + # whoever wrote it — and a stale value pointing at one would stop zsh from + # ever reading the user's real .zshenv. local _orca_startup_file for _orca_startup_file in .zshenv .zshrc .zprofile .zlogin; do - [[ -r "$_orca_resolved_config_dir/$_orca_startup_file" ]] && return 0 + [[ -r "$1/$_orca_startup_file" ]] && return 0 done - _orca_resolved_config_dir="$HOME" + return 1 } -__orca_resolve_inherited_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}" -export ORCA_ORIG_ZDOTDIR="$_orca_resolved_config_dir" -[[ -f "$ORCA_ORIG_ZDOTDIR/.zshenv" ]] && source "$ORCA_ORIG_ZDOTDIR/.zshenv" -__orca_resolve_user_config_dir "${ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}" -export ORCA_USER_ZDOTDIR="$_orca_resolved_config_dir" - -if [[ -f "$ORCA_ORIG_ZDOTDIR/.zshenv" ]] && [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null; then - case "$(emulate 2>/dev/null)" in - sh|ksh) - export ZDOTDIR="$ORCA_USER_ZDOTDIR" - # Why unset: an ORCA_HISTFILE no wrapper file will ever consume is - # inherited by everything this pane spawns, including a nested Orca. - builtin unset ORCA_HISTFILE _orca_shell_features _orca_home _orca_resolved_config_dir _orca_wrapper_zdotdir_self - unfunction __orca_shell_epilogue __orca_has_feature __orca_resolve_user_config_dir __orca_resolve_inherited_config_dir 2>/dev/null - return 0 - ;; - esac +if __orca_usable_zdotdir "${ORCA_ORIG_ZDOTDIR:-}"; then + builtin export ZDOTDIR="$ORCA_ORIG_ZDOTDIR" +else + builtin unset ZDOTDIR fi - -export ZDOTDIR='/zsh' - -__orca_shell_epilogue() { +builtin unset ORCA_ORIG_ZDOTDIR ORCA_ZSHENV_SOURCE_DIR +builtin unfunction __orca_usable_zdotdir +builtin typeset -ga _orca_shell_features +_orca_shell_features=(${(s:,:)${ORCA_SHELL_FEATURES:-}}) +builtin unset ORCA_SHELL_FEATURES +# Why ORCA_HISTFILE is consumed HERE and not in the deferred hook: a user config +# that replaces precmd_functions wholesale drops the hook, and an exported value +# nothing will ever consume is then inherited by every child of this pane, +# including a nested Orca (#11146). Captured non-exported, it cannot escape. +builtin typeset -g _orca_histfile="${ORCA_HISTFILE:-}" +builtin unset ORCA_HISTFILE +__orca_has_feature() { (( ${_orca_shell_features[(Ie)$1]} )) } +__orca_has_feature identity && printf "\033]777;orca-shell-start:%s\007" "$$" +__orca_deferred_init() { # Why first: this body runs after the user's own config, so it would otherwise # inherit whatever options that config left set. Under NO_UNSET an unset - # precmd_functions is a fatal error that returns from the whole epilogue - # (skipping the ready widget and the ZDOTDIR restore), and KSH_ARRAYS makes - # the 1-based feature lookup drop the first selected feature. - emulate -L zsh - (( $+_orca_epilogue_done )) && return 0 - typeset -g _orca_epilogue_done=1 + # precmd_functions is fatal, and KSH_ARRAYS makes the 1-based feature lookup + # drop whichever feature is listed first. + builtin emulate -L zsh + (( $+_orca_deferred_init_done )) && return 0 + builtin typeset -g _orca_deferred_init_done=1 + builtin typeset -g precmd_functions + precmd_functions=(${precmd_functions:#__orca_deferred_init}) if __orca_has_feature overlay; then # Why: remote startup files can re-export user defaults after relay spawn. [[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}" @@ -86,16 +74,8 @@ __orca_shell_epilogue() { omp() { __orca_omp "$@"; } fi fi - if [[ -n "${ORCA_HISTFILE:-}" ]]; then - HISTFILE="$ORCA_HISTFILE" - builtin unset ORCA_HISTFILE - elif [[ "${HISTFILE:-}" == "$ZDOTDIR/.zsh_history" ]]; then - # Why also when Orca injected nothing: /etc/zshrc derived this from Orca's - # wrapper ZDOTDIR, so history would accumulate INSIDE the wrapper dir and the - # user's real history would be invisible — the plain #11044 bug, with no - # per-worktree scoping involved. Matching the exact clobbered value means a - # HISTFILE the user set deliberately is never touched. - HISTFILE="${ORCA_ORIG_ZDOTDIR:-$HOME}/.zsh_history" + if [[ -n "${_orca_histfile:-}" ]]; then + HISTFILE="$_orca_histfile" fi if __orca_has_feature ready; then # Why: capture the prior zle-line-init so the marker chains to it. On a @@ -121,11 +101,14 @@ __orca_shell_epilogue() { } zle -N zle-line-init __orca_prompt_mark fi - __orca_resolve_user_config_dir "${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}" - # Why: after Orca's last wrapper file has loaded, the interactive shell should - # expose the same ZDOTDIR a normal zsh startup would expose. - export ZDOTDIR="$_orca_resolved_config_dir" - unset _orca_home _orca_resolved_config_dir - unset _orca_shell_features - unfunction __orca_shell_epilogue __orca_has_feature __orca_resolve_user_config_dir __orca_resolve_inherited_config_dir + builtin unset _orca_shell_features _orca_histfile + builtin unfunction __orca_deferred_init __orca_has_feature +} +{ + builtin typeset _orca_user_zshenv="${ZDOTDIR-$HOME}/.zshenv" + [[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv" +} always { + builtin unset _orca_user_zshenv + builtin typeset -ag precmd_functions + (( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshrc.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshrc.txt deleted file mode 100644 index 9c84f4df2ec..00000000000 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshrc.txt +++ /dev/null @@ -1,23 +0,0 @@ -# Orca relay zsh overlay wrapper -__orca_resolve_user_config_dir() { - typeset -g _orca_resolved_config_dir="${1:-}" - while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done - if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then - _orca_resolved_config_dir="$HOME" - fi -} -__orca_resolve_user_config_dir "${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}" -_orca_home="$_orca_resolved_config_dir" -if [[ -o interactive && -f "$_orca_home/.zshrc" ]]; then - _orca_wrapper_zdotdir="$ZDOTDIR" - # Why: user startup files resolve plugin/config paths from their own ZDOTDIR; - # Orca restores its wrapper dir afterward so zsh still loads wrapper files. - export ZDOTDIR="$_orca_home" - source "$_orca_home/.zshrc" - export ZDOTDIR="$_orca_wrapper_zdotdir" - unset _orca_wrapper_zdotdir -fi - -if [[ ! -o login ]] || { [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null && [[ "$(emulate 2>/dev/null)" != zsh ]]; }; then - (( ${+functions[__orca_shell_epilogue]} )) && __orca_shell_epilogue -fi diff --git a/src/main/daemon/daemon-shell-ready-wrapper-fileset.ts b/src/main/daemon/daemon-shell-ready-wrapper-fileset.ts index 2e6b3e19613..43eda0f148b 100644 --- a/src/main/daemon/daemon-shell-ready-wrapper-fileset.ts +++ b/src/main/daemon/daemon-shell-ready-wrapper-fileset.ts @@ -6,18 +6,17 @@ import { join } from 'node:path' import { ZSH_WRAPPER_DIR_MARKER_CONTENT, ZSH_WRAPPER_DIR_MARKER_FILE } from '../shell-templates' import type { ShellWrapperFile } from '../shell-wrapper-file-writer' -import { buildZshStartupWrapperFiles } from '../zsh-startup-wrapper-builder' +import { buildZshStartupHook } from '../zsh-startup-wrapper-builder' import { getDaemonBashShellReadyRcfileContent } from './daemon-bash-shell-ready-rcfile' import { getDaemonZshWrapperSpec } from './daemon-zsh-shell-ready-wrapper-spec' +// Why only .zshenv: the hook hands ZDOTDIR back on its first lines, so zsh reads +// .zprofile, .zshrc and .zlogin from the user's own directory. Nothing Orca +// writes is read after this file. export function buildDaemonShellReadyWrapperFiles(root: string): readonly ShellWrapperFile[] { const zshDir = join(root, 'zsh') - const zsh = buildZshStartupWrapperFiles(getDaemonZshWrapperSpec(zshDir)) return [ - [join(zshDir, '.zshenv'), zsh.zshenv], - [join(zshDir, '.zprofile'), zsh.zprofile], - [join(zshDir, '.zshrc'), zsh.zshrc], - [join(zshDir, '.zlogin'), zsh.zlogin], + [join(zshDir, '.zshenv'), buildZshStartupHook(getDaemonZshWrapperSpec())], [join(zshDir, ZSH_WRAPPER_DIR_MARKER_FILE), ZSH_WRAPPER_DIR_MARKER_CONTENT], [join(root, 'bash', 'rcfile'), getDaemonBashShellReadyRcfileContent()] ] diff --git a/src/main/daemon/daemon-zsh-shell-ready-wrapper-spec.ts b/src/main/daemon/daemon-zsh-shell-ready-wrapper-spec.ts index b23201ee04c..3d9d0e7c045 100644 --- a/src/main/daemon/daemon-zsh-shell-ready-wrapper-spec.ts +++ b/src/main/daemon/daemon-zsh-shell-ready-wrapper-spec.ts @@ -1,15 +1,12 @@ -import type { ZshStartupWrapperSpec } from '../zsh-startup-wrapper-builder' +import type { ZshStartupHookSpec } from '../zsh-startup-wrapper-builder' import { SHELL_READY_MARKER } from './daemon-shell-ready-marker' /** The zsh wrapper the daemon (local fork and SSH host) launches shells with. */ -export function getDaemonZshWrapperSpec(zshDir: string): ZshStartupWrapperSpec { +export function getDaemonZshWrapperSpec(): ZshStartupHookSpec { return { headerLabel: 'Orca daemon zsh shell-ready wrapper', - zshDir, - zshenvStrategy: 'discover-user-zdotdir', readyMarkerEscaped: SHELL_READY_MARKER, osc133CommandMarkers: true, - skipUserZshrcWhenHomeIsWrapperDir: true, overlayRestoreComment: "# Why: ~/.zshrc can export the user's default OpenCode config after spawn.", restores: { diff --git a/src/main/daemon/shell-ready.test.ts b/src/main/daemon/shell-ready.test.ts index b680cb06954..6772a52d46e 100644 --- a/src/main/daemon/shell-ready.test.ts +++ b/src/main/daemon/shell-ready.test.ts @@ -132,17 +132,6 @@ async function runInteractiveZshRc(args: { return output } -function expectZdotdirSourceContext(content: string, fileName: '.zprofile' | '.zshrc' | '.zlogin') { - expect(content).toContain('export ZDOTDIR="$_orca_home"') - expect(content).toContain(`source "$_orca_home/${fileName}"`) - expect(content).toContain('export ZDOTDIR="$_orca_wrapper_zdotdir"') -} - -function expectFinalZdotdirRestoreContext(content: string) { - expect(content).toContain("after Orca's last wrapper file has loaded") - expect(content).toContain('export ZDOTDIR="$_orca_resolved_config_dir"') -} - describePosix('daemon shell-ready launch config', () => { // Always runs, so the CI lane cannot report green with every live fish test skipped. it('has the fish the live tests need when CI requires one', () => { @@ -353,7 +342,7 @@ describePosix('daemon shell-ready launch config', () => { 15_000 ) - it('falls back to HOME for ORCA_ORIG_ZDOTDIR when inherited ZDOTDIR points at a wrapper dir', async () => { + it('sets no ORCA_ORIG_ZDOTDIR when the inherited ZDOTDIR points at a wrapper dir', async () => { // Why: an Orca-PTY parent has ZDOTDIR=.../shell-ready/zsh; propagating it makes the wrapper source itself (recursion loop). const previousZdotdir = process.env.ZDOTDIR const previousHome = process.env.HOME @@ -362,8 +351,7 @@ describePosix('daemon shell-ready launch config', () => { try { const { getShellReadyLaunchConfig } = await importFreshShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') - expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice') - expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe('/Users/alice') + expect(config.env.ORCA_ORIG_ZDOTDIR).toBeUndefined() } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -390,7 +378,6 @@ describePosix('daemon shell-ready launch config', () => { const { getShellReadyLaunchConfig } = await importFreshShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') expect(config.env.ORCA_ORIG_ZDOTDIR).toBe(userZdotdir) - expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe(userDataPath) } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -410,7 +397,7 @@ describePosix('daemon shell-ready launch config', () => { } }) - it('falls back to HOME when inherited ORCA_ORIG_ZDOTDIR points at a wrapper dir', async () => { + it('sets no ORCA_ORIG_ZDOTDIR when the inherited one points at a wrapper dir', async () => { const previousZdotdir = process.env.ZDOTDIR const previousOrigZdotdir = process.env.ORCA_ORIG_ZDOTDIR const previousHome = process.env.HOME @@ -420,8 +407,7 @@ describePosix('daemon shell-ready launch config', () => { try { const { getShellReadyLaunchConfig } = await importFreshShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') - expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice') - expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe('/Users/alice') + expect(config.env.ORCA_ORIG_ZDOTDIR).toBeUndefined() } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -441,34 +427,23 @@ describePosix('daemon shell-ready launch config', () => { } }) - it('writes zsh wrappers that guard against ORCA_ORIG_ZDOTDIR self-loops', async () => { + it('writes a zsh hook that hands ZDOTDIR back before any user file loads', async () => { const { getShellReadyLaunchConfig } = await importFreshShellReady() getShellReadyLaunchConfig('/bin/zsh') const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') - const zprofile = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zprofile'), 'utf8') - const zshrc = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshrc'), 'utf8') - const zlogin = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zlogin'), 'utf8') - expect(zshenv).toContain('__orca_resolve_inherited_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}"') + expect(zshenv).toContain('builtin export ZDOTDIR="$ORCA_ORIG_ZDOTDIR"') + expect(zshenv).toContain('builtin unset ORCA_ORIG_ZDOTDIR ORCA_ZSHENV_SOURCE_DIR') expect(zshenv).toContain('printf "\\033]777;orca-shell-start:%s\\007" "$$"') - expect(zshenv).toContain('"$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then') - expect(zshenv).toContain('export ORCA_ORIG_ZDOTDIR="$_orca_resolved_config_dir"') - expectZdotdirSourceContext(zprofile, '.zprofile') - expectZdotdirSourceContext(zshrc, '.zshrc') - expectZdotdirSourceContext(zlogin, '.zlogin') - // Why .zshenv: the final restore is the last step of the single epilogue, - // which .zshrc (non-login) and .zlogin (login) each invoke once. - expectFinalZdotdirRestoreContext(zshenv) - // Why the emulation probe: sh/ksh emulation makes zsh read $HOME/.zlogin - // rather than the wrapper's, so the epilogue has to run from here instead. - // Why the option test in front of it: the probe forks, and all-off proves - // zsh emulation without one. - expect(zshrc).toContain( - 'if [[ ! -o login ]] || { [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null && ' + - '[[ "$(emulate 2>/dev/null)" != zsh ]]; }; then' + expect(zshenv.indexOf('builtin export ZDOTDIR=')).toBeLessThan( + zshenv.indexOf('builtin source -- "$_orca_user_zshenv"') ) - expect(zshrc).toContain('(( ${+functions[__orca_shell_epilogue]} )) && __orca_shell_epilogue') + // Why nothing else: zsh reads .zprofile, .zshrc and .zlogin through ZDOTDIR, + // which is the user's own again by the time it looks for them. + for (const name of ['.zprofile', '.zshrc', '.zlogin']) { + expect(existsSync(join(getShellReadyWrapperRoot(), 'zsh', name))).toBe(false) + } }) it('owns zle-line-init for the shell-ready marker instead of an azhw hook', async () => { @@ -476,8 +451,8 @@ describePosix('daemon shell-ready launch config', () => { getShellReadyLaunchConfig('/bin/zsh') - // Why .zshenv: the widget registration lives in the epilogue, which .zlogin - // (login) and .zshrc (non-login) both call exactly once. + // Why .zshenv: the widget registration lives in the deferred hook, which the + // first prompt's precmd sweep calls exactly once. const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') expect(zshenv).toContain('zle -N zle-line-init __orca_prompt_mark') expect(zshenv).toContain('__orca_prev_line_init_fn="${widgets[zle-line-init]#user:}"') @@ -652,7 +627,6 @@ describePosix('daemon shell-ready launch config', () => { const { getShellReadyLaunchConfig } = await importFreshShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') expect(config.env.ORCA_ORIG_ZDOTDIR).toBe(userZdotdir) - expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe(userZdotdir) } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -671,7 +645,7 @@ describePosix('daemon shell-ready launch config', () => { try { const { getShellReadyLaunchConfig } = await importFreshShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') - expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice') + expect(config.env.ORCA_ORIG_ZDOTDIR).toBeUndefined() } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -695,7 +669,7 @@ describePosix('daemon shell-ready launch config', () => { try { const { getShellReadyLaunchConfig } = await importFreshShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') - expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice') + expect(config.env.ORCA_ORIG_ZDOTDIR).toBeUndefined() } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -728,34 +702,20 @@ describePosix('daemon shell-ready launch config', () => { } }) - it('sources user .zshenv at wrapper top level before repinning ZDOTDIR', async () => { - // Why: PR #1737 sourced .zshenv in a wrapper function, breaking "typeset -U path"; keep it at zsh top level. + it('sources the user .zshenv at wrapper top level, not inside a function', async () => { + // Why: PR #1737 sourced .zshenv in a wrapper function, breaking `typeset -U + // path`. Top-level sourcing is still the contract. const { getShellReadyLaunchConfig } = await importFreshShellReady() getShellReadyLaunchConfig('/bin/zsh') const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') - expect(zshenv).toContain('unset ZDOTDIR') - expect(zshenv).toContain('__orca_resolve_inherited_config_dir "${ORCA_ZSHENV') - expect(zshenv).toContain('source "${_orca_zshenv_path}"') - expect(zshenv).toContain('_orca_discovered_zdotdir="${ZDOTDIR:-}"') - expect(zshenv).toContain('${_orca_discovered_zdotdir:-${_orca_user_zdotdir:-$HOME}}') - expect(zshenv).toContain('export ZDOTDIR=') - }) - - it('preserves spawn-env ORCA_ORIG_ZDOTDIR as fallback when discovery yields nothing', async () => { - // Why: when user .zshenv sets no ZDOTDIR, the wrapper falls back to spawn-env ORCA_ORIG_ZDOTDIR, then HOME. - const { getShellReadyLaunchConfig } = await importFreshShellReady() - - getShellReadyLaunchConfig('/bin/zsh') - - const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') - - // Save spawn-env value before sourcing user .zshenv - expect(zshenv).toContain('_orca_user_zdotdir="$_orca_resolved_config_dir"') - - // Fallback chain: discovered → normalized spawn-env path → HOME - expect(zshenv).toContain('${_orca_discovered_zdotdir:-${_orca_user_zdotdir:-$HOME}}') + expect(zshenv).toContain('builtin source -- "$_orca_user_zshenv"') + // Every function the hook needs is defined above the source, so a user + // `emulate sh` cannot leave the rest of this file unparseable. + expect(zshenv.indexOf('__orca_deferred_init() {')).toBeLessThan( + zshenv.indexOf('builtin source -- "$_orca_user_zshenv"') + ) }) }) diff --git a/src/main/daemon/shell-ready.ts b/src/main/daemon/shell-ready.ts index 277817b9c61..dcc3aed3d75 100644 --- a/src/main/daemon/shell-ready.ts +++ b/src/main/daemon/shell-ready.ts @@ -16,10 +16,7 @@ import { import { resolveShellWrapperRoot } from '../shell-wrapper-content-address' import { writeShellWrapperFiles } from '../shell-wrapper-file-writer' import { buildDaemonShellReadyWrapperFiles } from './daemon-shell-ready-wrapper-fileset' -import { - resolveInheritedZdotdir, - resolveInheritedZshenvSourceDir -} from '../zsh-wrapper-dir-ownership' +import { inheritedZdotdirEnv, resolveInheritedZdotdir } from '../zsh-wrapper-dir-ownership' import { SHELL_READY_MARKER } from './daemon-shell-ready-marker' const ORCA_USER_DATA_PATH_ENV = 'ORCA_USER_DATA_PATH' @@ -149,8 +146,7 @@ export function getShellLaunchConfig( return { args: ['-l'], env: { - ORCA_ORIG_ZDOTDIR: resolveInheritedZdotdir(process.env), - ORCA_ZSHENV_SOURCE_DIR: resolveInheritedZshenvSourceDir(process.env), + ...inheritedZdotdirEnv(resolveInheritedZdotdir(process.env)), ZDOTDIR: join(getShellReadyWrapperRoot(), 'zsh'), [SHELL_STARTUP_FEATURE_ENV]: encodeShellStartupFeatures(features) }, diff --git a/src/main/ipc/pty-wsl-cwd-validation.test.ts b/src/main/ipc/pty-wsl-cwd-validation.test.ts index 93cd869bee0..cfdb693d228 100644 --- a/src/main/ipc/pty-wsl-cwd-validation.test.ts +++ b/src/main/ipc/pty-wsl-cwd-validation.test.ts @@ -355,7 +355,10 @@ describe('registerPtyHandlers', () => { expect(shell).toBe('/bin/zsh') expect(args).toEqual(['-l']) expect(options.env.ZDOTDIR).toBe(join(getShellReadyWrapperRoot(), 'zsh')) - expect(options.env.ORCA_ORIG_ZDOTDIR).toBe(process.env.HOME) + // Why absent: this HOME holds no zsh startup file, so there is no user + // config dir to hand back and Orca must not invent one — the wrapper + // leaves ZDOTDIR unset, exactly as an unwrapped login zsh would. + expect(options.env.ORCA_ORIG_ZDOTDIR).toBeUndefined() } finally { Object.defineProperty(process, 'platform', { configurable: true, diff --git a/src/main/providers/__tests__/shell-ready-framework-example.test.ts b/src/main/providers/__tests__/shell-ready-framework-example.test.ts index bf2a425ed7c..d130f2cb9de 100644 --- a/src/main/providers/__tests__/shell-ready-framework-example.test.ts +++ b/src/main/providers/__tests__/shell-ready-framework-example.test.ts @@ -85,8 +85,7 @@ EOF ) expect(stdout).toMatchInlineSnapshot(` - "ORCA_ORIG_ZDOTDIR= - ZDOTDIR=/.config/zsh-remote + "ZDOTDIR=/.config/zsh-remote " `) }) diff --git a/src/main/providers/local-pty-shell-ready-wrapper-fileset.ts b/src/main/providers/local-pty-shell-ready-wrapper-fileset.ts index 1851a42e1a8..afad9569340 100644 --- a/src/main/providers/local-pty-shell-ready-wrapper-fileset.ts +++ b/src/main/providers/local-pty-shell-ready-wrapper-fileset.ts @@ -5,21 +5,15 @@ */ import { ZSH_WRAPPER_DIR_MARKER_CONTENT, ZSH_WRAPPER_DIR_MARKER_FILE } from '../shell-templates' import type { ShellWrapperFile } from '../shell-wrapper-file-writer' -import { - buildZshStartupWrapperFiles, - type ZshStartupWrapperSpec -} from '../zsh-startup-wrapper-builder' +import { buildZshStartupHook, type ZshStartupHookSpec } from '../zsh-startup-wrapper-builder' import { getBashShellReadyRcfileContent } from './local-pty-shell-ready-bash-rcfile' import { SHELL_READY_MARKER_ESCAPED } from './local-pty-shell-ready-marker' -export function getLocalZshWrapperSpec(zshDir: string): ZshStartupWrapperSpec { +export function getLocalZshWrapperSpec(): ZshStartupHookSpec { return { headerLabel: 'Orca zsh shell-ready wrapper', - zshDir, - zshenvStrategy: 'discover-user-zdotdir', readyMarkerEscaped: SHELL_READY_MARKER_ESCAPED, osc133CommandMarkers: true, - skipUserZshrcWhenHomeIsWrapperDir: true, overlayRestoreComment: "# Why: ~/.zshrc can export the user's default OpenCode config after spawn.", restores: { @@ -36,14 +30,13 @@ export function getLocalZshWrapperSpec(zshDir: string): ZshStartupWrapperSpec { // config builds the matching values the same way (local-pty-shell-ready.ts). // path.join would emit backslashes on Windows, where a shell literal reads them // as escapes -- and would desync the written path from the launched one. +// Why only .zshenv: the hook hands ZDOTDIR back on its first lines, so zsh reads +// .zprofile, .zshrc and .zlogin from the user's own directory. Nothing Orca +// writes is read after this file. export function buildLocalShellReadyWrapperFiles(root: string): readonly ShellWrapperFile[] { const zshDir = `${root}/zsh` - const zsh = buildZshStartupWrapperFiles(getLocalZshWrapperSpec(zshDir)) return [ - [`${zshDir}/.zshenv`, zsh.zshenv], - [`${zshDir}/.zprofile`, zsh.zprofile], - [`${zshDir}/.zshrc`, zsh.zshrc], - [`${zshDir}/.zlogin`, zsh.zlogin], + [`${zshDir}/.zshenv`, buildZshStartupHook(getLocalZshWrapperSpec())], [`${zshDir}/${ZSH_WRAPPER_DIR_MARKER_FILE}`, ZSH_WRAPPER_DIR_MARKER_CONTENT], [`${root}/bash/rcfile`, getBashShellReadyRcfileContent()] ] diff --git a/src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts b/src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts index 96e8c901f29..62fd806a4e4 100644 --- a/src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts +++ b/src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts @@ -11,7 +11,7 @@ import { } from './local-pty-shell-ready-test-harness' // Why: rcfile content is pure, so a static import is equivalent to the fresh-module import used for wrapper writing. import { getBashShellReadyRcfileContent } from './local-pty-shell-ready-bash-rcfile' -import { getZshShellReadyWrapperFiles } from './local-pty-shell-ready-wrapper-generation' +import { getZshShellReadyWrapperFile } from './local-pty-shell-ready-wrapper-generation' import { makeUserZdotdir } from '../zsh-user-config-dir-fixture' // Why resolved rather than hardcoded: the wrapper tree is content-addressed. import { getShellReadyWrapperRoot } from './local-pty-shell-ready-wrapper-root' @@ -136,17 +136,6 @@ function expectBashOsc133Lifecycle(output: string): void { ]) } -function expectZdotdirSourceContext(content: string, fileName: '.zprofile' | '.zshrc' | '.zlogin') { - expect(content).toContain('export ZDOTDIR="$_orca_home"') - expect(content).toContain(`source "$_orca_home/${fileName}"`) - expect(content).toContain('export ZDOTDIR="$_orca_wrapper_zdotdir"') -} - -function expectFinalZdotdirRestoreContext(content: string) { - expect(content).toContain("after Orca's last wrapper file has loaded") - expect(content).toContain('export ZDOTDIR="$_orca_resolved_config_dir"') -} - describePosix('local PTY shell-ready launch config', () => { let userDataPath: string let previousOrcaOrigZdotdir: string | undefined @@ -204,8 +193,7 @@ describePosix('local PTY shell-ready launch config', () => { try { const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') - expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice') - expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe('/Users/alice') + expect(config.env.ORCA_ORIG_ZDOTDIR).toBeUndefined() } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -232,7 +220,6 @@ describePosix('local PTY shell-ready launch config', () => { const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') expect(config.env.ORCA_ORIG_ZDOTDIR).toBe(userZdotdir) - expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe(userDataPath) } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -262,8 +249,7 @@ describePosix('local PTY shell-ready launch config', () => { try { const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') - expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice') - expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe('/Users/alice') + expect(config.env.ORCA_ORIG_ZDOTDIR).toBeUndefined() } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -283,35 +269,28 @@ describePosix('local PTY shell-ready launch config', () => { } }) - it('writes zsh wrappers that guard against ORCA_ORIG_ZDOTDIR self-loops', async () => { + it('writes a zsh hook that hands ZDOTDIR back before any user file loads', async () => { const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() getShellReadyLaunchConfig('/bin/zsh') const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') - const zprofile = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zprofile'), 'utf8') - const zshrc = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshrc'), 'utf8') - const zlogin = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zlogin'), 'utf8') - expect(zshenv).toContain('__orca_resolve_inherited_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}"') + expect(zshenv).toContain('builtin export ZDOTDIR="$ORCA_ORIG_ZDOTDIR"') expect(zshenv).toContain('printf "\\033]777;orca-shell-start:%s\\007" "$$"') - expect(zshenv).toContain('"$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then') - expect(zshenv).toContain('export ORCA_ORIG_ZDOTDIR="$_orca_resolved_config_dir"') - expectZdotdirSourceContext(zprofile, '.zprofile') - expectZdotdirSourceContext(zshrc, '.zshrc') - expectZdotdirSourceContext(zlogin, '.zlogin') - // Why .zshenv: the final restore is the last step of the single epilogue, - // which .zshrc (non-login) and .zlogin (login) each invoke once. - expectFinalZdotdirRestoreContext(zshenv) - // Why the emulation probe: sh/ksh emulation makes zsh read $HOME/.zlogin - // rather than the wrapper's, so the epilogue has to run from here instead. - // Why the option test in front of it: the probe forks, and all-off proves - // zsh emulation without one. - expect(zshrc).toContain( - 'if [[ ! -o login ]] || { [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null && ' + - '[[ "$(emulate 2>/dev/null)" != zsh ]]; }; then' + // The handback is what makes a nested Orca unable to inherit this dir, and + // what stops /etc/zshrc deriving HISTFILE from it. + expect(zshenv).toContain('builtin unset ORCA_ORIG_ZDOTDIR ORCA_ZSHENV_SOURCE_DIR') + expect(zshenv.indexOf('builtin export ZDOTDIR=')).toBeLessThan( + zshenv.indexOf('builtin source -- "$_orca_user_zshenv"') ) - expect(zshrc).toContain('(( ${+functions[__orca_shell_epilogue]} )) && __orca_shell_epilogue') - expect(zlogin).toContain('(( ${+functions[__orca_shell_epilogue]} )) && __orca_shell_epilogue') + // Why nothing else is written: zsh reads .zprofile, .zshrc and .zlogin + // through ZDOTDIR, which is the user's again by the time it looks. + for (const name of ['.zprofile', '.zshrc', '.zlogin']) { + expect(existsSync(join(getShellReadyWrapperRoot(), 'zsh', name))).toBe(false) + } + // No emulation probe survives: nothing after this file is read via ZDOTDIR, + // so sh/ksh emulation entered by a user file can no longer hide anything. + expect(zshenv).not.toContain('$(emulate') }) it('owns zle-line-init for the shell-ready marker instead of an azhw hook', async () => { @@ -319,8 +298,8 @@ describePosix('local PTY shell-ready launch config', () => { getShellReadyLaunchConfig('/bin/zsh') - // Why .zshenv: the widget registration lives in the epilogue, which .zlogin - // (login) and .zshrc (non-login) both call exactly once. + // Why .zshenv: the widget registration lives in the deferred hook, which the + // first prompt's precmd sweep calls exactly once. const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') expect(zshenv).toContain('zle -N zle-line-init __orca_prompt_mark') expect(zshenv).toContain('__orca_prev_line_init_fn="${widgets[zle-line-init]#user:}"') @@ -329,9 +308,7 @@ describePosix('local PTY shell-ready launch config', () => { expect(zshenv).not.toContain('add-zle-hook-widget line-init') // Why: re-source guard — skip re-capturing when already the bound widget so the prior chain survives a second source. expect(zshenv).toContain('== "user:__orca_prompt_mark"') - expect(readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zlogin'), 'utf8')).toContain( - '__orca_shell_epilogue' - ) + expect(zshenv).toContain('__orca_deferred_init') }) it('writes wrappers without restoring Pi/OMP homes after user startup files', async () => { @@ -384,8 +361,8 @@ describePosix('local PTY shell-ready launch config', () => { // Why: issue #2422 — without OSC 133 C/D markers, bash sessions kept the worktree spinner "working" ~30min after the agent exited. it('emits OSC 133 C/D markers in the bash wrapper so agent exit cleanup fires', async () => { const bashRc = getBashShellReadyRcfileContent() - // Why .zshenv: the zsh markers live in the epilogue, behind `markers`. - const zshRc = getZshShellReadyWrapperFiles().zshenv + // Why .zshenv: the zsh markers live in the deferred hook, behind `markers`. + const zshRc = getZshShellReadyWrapperFile() // The exact escape sequences terminal-command-lifecycle parses (133;D = finished, 133;C = start). expect(bashRc).toContain('printf "\\033]133;D;%s\\007"') @@ -489,7 +466,6 @@ describePosix('local PTY shell-ready launch config', () => { const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') expect(config.env.ORCA_ORIG_ZDOTDIR).toBe(userZdotdir) - expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe(userZdotdir) } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -507,7 +483,7 @@ describePosix('local PTY shell-ready launch config', () => { try { const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') - expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice') + expect(config.env.ORCA_ORIG_ZDOTDIR).toBeUndefined() } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -530,7 +506,7 @@ describePosix('local PTY shell-ready launch config', () => { try { const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') - expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice') + expect(config.env.ORCA_ORIG_ZDOTDIR).toBeUndefined() } finally { if (previousZdotdir === undefined) { delete process.env.ZDOTDIR @@ -562,70 +538,37 @@ describePosix('local PTY shell-ready launch config', () => { } }) - it('sources user .zshenv at wrapper top level before repinning ZDOTDIR', async () => { - // Why: PR #1737 sourced .zshenv in a wrapper function, breaking "typeset -U path"; keep it at top level. + it('sources the user .zshenv at wrapper top level, not inside a function', async () => { + // Why: PR #1737 sourced .zshenv in a wrapper function, breaking `typeset -U + // path`. Top-level sourcing is still the contract; only the surrounding + // machinery went away. const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() getShellReadyLaunchConfig('/bin/zsh') const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') - expect(zshenv).toContain('unset ZDOTDIR') - expect(zshenv).toContain( - '__orca_resolve_inherited_config_dir "${ORCA_ZSHENV_SOURCE_DIR:-$HOME}"' + expect(zshenv).toContain('builtin source -- "$_orca_user_zshenv"') + // Every function the hook needs is defined above the source, so a user + // `emulate sh` cannot leave the rest of this file unparseable. + expect(zshenv.indexOf('__orca_deferred_init() {')).toBeLessThan( + zshenv.indexOf('builtin source -- "$_orca_user_zshenv"') ) - expect(zshenv).toContain('source "${_orca_zshenv_path}"') - expect(zshenv).toContain('_orca_discovered_zdotdir="${ZDOTDIR:-}"') - expect(zshenv).toContain( - '__orca_resolve_user_config_dir "${_orca_discovered_zdotdir:-${_orca_user_zdotdir:-$HOME}}"' - ) - expect(zshenv).toContain('export ZDOTDIR=') }) - it('preserves spawn-env ORCA_ORIG_ZDOTDIR as fallback when discovery yields nothing', async () => { - // Why: if user .zshenv returns early or doesn't set ZDOTDIR, fall back to spawn-env ORCA_ORIG_ZDOTDIR, then HOME. + it('bakes no generation-time path into the zsh hook', async () => { + // Why: issue #8003 — a wrapper generated on Windows is sourced inside WSL + // via /mnt/c, where the generation-time path does not exist. The old file + // baked that path as a ZDOTDIR fallback and re-derived the runtime one from + // `%x` to avoid using it. Nothing re-points ZDOTDIR at the wrapper dir any + // more, so there is no path to bake and the whole class is gone. const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() getShellReadyLaunchConfig('/bin/zsh') const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') - // Save spawn-env value before sourcing user .zshenv - expect(zshenv.indexOf('_orca_user_zdotdir="$_orca_resolved_config_dir"')).toBeLessThan( - zshenv.indexOf('source "${_orca_zshenv_path}"') - ) - - // Fallback chain: discovered → normalized spawn-env path → HOME - expect(zshenv).toContain('${_orca_discovered_zdotdir:-${_orca_user_zdotdir:-$HOME}}') - }) - - it('restores wrapper ZDOTDIR from the runtime sourced path, not the baked literal', async () => { - // Why: issue #8003 — WSL sources Windows-generated wrappers via /mnt/c, so the baked generation-time path is absent. - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - - getShellReadyLaunchConfig('/bin/zsh') - - const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') - - // Why: derive wrapper dir from %x, not env $ZDOTDIR — zsh corrupts non-ASCII usernames in its 0x84-0x9D token range. - expect(zshenv).toContain('_orca_wrapper_zdotdir_self="${${(%):-%x}:h}"') - // Keep $ZDOTDIR only as a fallback when %x yields nothing; the final restore re-validates with -f, so no stat here. - expect(zshenv).toContain( - 'if [[ -z "${_orca_wrapper_zdotdir_self:-}" ]]; then\n' + - ' _orca_wrapper_zdotdir_self="${ZDOTDIR:-}"\n' + - 'fi' - ) - // Trust the runtime path only when it still holds a wrapper .zshenv; else fall back to the generation-time literal. - expect(zshenv).toContain( - 'if [[ -n "${_orca_wrapper_zdotdir_self:-}" && -f "${_orca_wrapper_zdotdir_self:-}/.zshenv" ]]; then\n' + - ' export ZDOTDIR="${_orca_wrapper_zdotdir_self:-}"\n' + - 'else\n' + - ` export ZDOTDIR='${join(getShellReadyWrapperRoot(), 'zsh')}'\n` + - 'fi' - ) - // Capture must happen before the wrapper unsets ZDOTDIR to source user files. - expect(zshenv.indexOf('_orca_wrapper_zdotdir_self="${${(%):-%x}:h}"')).toBeLessThan( - zshenv.indexOf('unset ZDOTDIR') - ) + expect(zshenv).not.toContain(getShellReadyWrapperRoot()) + expect(zshenv).not.toContain('%x') }) }) diff --git a/src/main/providers/local-pty-shell-ready-wrapper-generation.ts b/src/main/providers/local-pty-shell-ready-wrapper-generation.ts index 8d39bcc028a..eb65e58437c 100644 --- a/src/main/providers/local-pty-shell-ready-wrapper-generation.ts +++ b/src/main/providers/local-pty-shell-ready-wrapper-generation.ts @@ -4,10 +4,7 @@ * Why: the wrappers emit an OSC 777 marker after startup files finish, which the * readiness scanner watches for before a startup command is written. */ -import { - buildZshStartupWrapperFiles, - type ZshStartupWrapperFiles -} from '../zsh-startup-wrapper-builder' +import { buildZshStartupHook } from '../zsh-startup-wrapper-builder' import { writeShellWrapperFiles } from '../shell-wrapper-file-writer' import { buildLocalShellReadyWrapperFiles, @@ -18,8 +15,8 @@ import { shellReadyWrappersExist } from './local-pty-shell-ready-wrapper-root' -export function getZshShellReadyWrapperFiles(): ZshStartupWrapperFiles { - return buildZshStartupWrapperFiles(getLocalZshWrapperSpec(`${getShellReadyWrapperRoot()}/zsh`)) +export function getZshShellReadyWrapperFile(): string { + return buildZshStartupHook(getLocalZshWrapperSpec()) } /** True when every wrapper file is present and non-empty afterwards. */ diff --git a/src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts b/src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts index 3d435b6731f..4c0237c19c5 100644 --- a/src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts +++ b/src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts @@ -48,13 +48,13 @@ describePosix('live zsh subprocess tests', () => { delete cleanEnv.ORCA_ORIG_ZDOTDIR cleanEnv.ZDOTDIR = config.env.ZDOTDIR - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + const result = spawnSync('zsh', ['-c', 'echo "ZDOTDIR=${ZDOTDIR}"'], { env: cleanEnv as NodeJS.ProcessEnv, encoding: 'utf8' }) expect(result.status).toBe(0) - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${xdgZshDir}`) + expect(result.stdout).toContain(`ZDOTDIR=${xdgZshDir}`) }) it('discovers ZDOTDIR when launched from SSH session', async () => { @@ -76,13 +76,13 @@ describePosix('live zsh subprocess tests', () => { delete cleanEnv.ORCA_ORIG_ZDOTDIR cleanEnv.ZDOTDIR = config.env.ZDOTDIR - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + const result = spawnSync('zsh', ['-c', 'echo "ZDOTDIR=${ZDOTDIR}"'], { env: cleanEnv as NodeJS.ProcessEnv, encoding: 'utf8' }) expect(result.status).toBe(0) - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${xdgZshDir}`) + expect(result.stdout).toContain(`ZDOTDIR=${xdgZshDir}`) }) it('handles sudo -E where HOME and ZDOTDIR mismatch', async () => { @@ -137,14 +137,14 @@ describePosix('live zsh subprocess tests', () => { delete cleanEnv.ZDOTDIR cleanEnv.ZDOTDIR = config.env.ZDOTDIR - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + const result = spawnSync('zsh', ['-c', 'echo "ZDOTDIR=${ZDOTDIR}"'], { env: cleanEnv as NodeJS.ProcessEnv, encoding: 'utf8' }) expect(result.status).toBe(0) // Should discover fresh value from .zshenv, not use stale wrapper path - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${currentZdotdir}`) + expect(result.stdout).toContain(`ZDOTDIR=${currentZdotdir}`) } finally { if (previousOrcaZdotdir === undefined) { delete process.env.ORCA_ORIG_ZDOTDIR @@ -175,14 +175,14 @@ describePosix('live zsh subprocess tests', () => { delete cleanEnv.ZDOTDIR cleanEnv.ZDOTDIR = config.env.ZDOTDIR - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { + const result = spawnSync('zsh', ['-c', 'echo "ZDOTDIR=${ZDOTDIR}"'], { env: cleanEnv as NodeJS.ProcessEnv, encoding: 'utf8' }) expect(result.status).toBe(0) // Should use fresh discovery (user updated .zshenv) - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${freshZdotdir}`) + expect(result.stdout).toContain(`ZDOTDIR=${freshZdotdir}`) } finally { if (previousOrcaZdotdir === undefined) { delete process.env.ORCA_ORIG_ZDOTDIR @@ -214,7 +214,10 @@ describePosix('live zsh subprocess tests', () => { try { const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() const config = getShellReadyLaunchConfig('/bin/zsh') - expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe(inheritedZdotdir) + // One channel now does what ORCA_ORIG_ZDOTDIR and ORCA_ZSHENV_SOURCE_DIR + // split between them: the wrapper hands this back as ZDOTDIR, and zsh + // reads .zshenv through it. + expect(config.env.ORCA_ORIG_ZDOTDIR).toBe(inheritedZdotdir) const cleanEnv: Record = { ...process.env, @@ -224,10 +227,7 @@ describePosix('live zsh subprocess tests', () => { const result = spawnSync( 'zsh', - [ - '-c', - 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}" && echo "SOURCE_MARKER=${SOURCE_MARKER:-unset}"' - ], + ['-c', 'echo "ZDOTDIR=${ZDOTDIR}" && echo "SOURCE_MARKER=${SOURCE_MARKER:-unset}"'], { env: cleanEnv as NodeJS.ProcessEnv, encoding: 'utf8' @@ -235,7 +235,7 @@ describePosix('live zsh subprocess tests', () => { ) expect(result.status).toBe(0) - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${inheritedZdotdir}`) + expect(result.stdout).toContain(`ZDOTDIR=${inheritedZdotdir}`) expect(result.stdout).toContain('SOURCE_MARKER=inherited') } finally { if (previousZdotdir === undefined) { diff --git a/src/main/providers/local-pty-shell-ready-zsh-startup-file-behavior.test.ts b/src/main/providers/local-pty-shell-ready-zsh-startup-file-behavior.test.ts deleted file mode 100644 index fde7b49bf46..00000000000 --- a/src/main/providers/local-pty-shell-ready-zsh-startup-file-behavior.test.ts +++ /dev/null @@ -1,350 +0,0 @@ -import { afterEach, beforeEach, expect, it } from 'vitest' -import { spawnSync } from 'node:child_process' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs' -import { - describeIfZsh, - describePosix, - importFreshLocalPtyShellReady, - restoreUserDataPathAfterEach, - setTestUserDataPath -} from './local-pty-shell-ready-test-harness' - -restoreUserDataPathAfterEach() - -// End-to-end validation that wrapper ZDOTDIR discovery preserves top-level zsh semantics (spawns real zsh; gated on availability). -describePosix('live zsh subprocess tests', () => { - describeIfZsh('automation and edge cases', () => { - let testHome: string - let userDataPath: string - - beforeEach(async () => { - testHome = mkdtempSync(join(tmpdir(), 'orca-auto-')) - userDataPath = mkdtempSync(join(tmpdir(), 'orca-auto-userdata-')) - setTestUserDataPath(userDataPath) - }) - - afterEach(() => { - rmSync(testHome, { recursive: true, force: true }) - rmSync(userDataPath, { recursive: true, force: true }) - }) - - it('matches normal zsh when user .zshenv calls exit', async () => { - writeFileSync(join(testHome, '.zshenv'), 'export ZDOTDIR="$HOME/.config/zsh"\nexit 42\n') - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "survived"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(42) - expect(result.stdout).not.toContain('survived') - }) - - it('survives user .zshenv with set -e and failing command', async () => { - writeFileSync( - join(testHome, '.zshenv'), - 'set -e\nfalse\nexport ZDOTDIR="$HOME/.config/zsh"\n' - ) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - // No ZDOTDIR was reached after the failing command, so we fall back. - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) - }) - - it('survives user .zshenv with set -u before ZDOTDIR is set', async () => { - const xdgZshDir = join(testHome, '.config', 'zsh') - mkdirSync(xdgZshDir, { recursive: true }) - writeFileSync(join(testHome, '.zshenv'), 'set -u\nexport ZDOTDIR="$HOME/.config/zsh"\n') - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - // Should work because wrapper uses ${ZDOTDIR:-} which is safe with set -u - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${xdgZshDir}`) - }) - - it('survives user .zshenv with nullglob set', async () => { - const xdgZshDir = join(testHome, '.config', 'zsh') - mkdirSync(xdgZshDir, { recursive: true }) - writeFileSync( - join(testHome, '.zshenv'), - 'setopt nullglob\nexport ZDOTDIR="$HOME/.config/zsh"\n' - ) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${xdgZshDir}`) - }) - - it('survives user .zshenv with extendedglob set', async () => { - const xdgZshDir = join(testHome, '.config', 'zsh') - mkdirSync(xdgZshDir, { recursive: true }) - writeFileSync( - join(testHome, '.zshenv'), - 'setopt extendedglob\nexport ZDOTDIR="$HOME/.config/zsh"\n' - ) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${xdgZshDir}`) - }) - - it('preserves exported .zshenv environment changes in the wrapper shell', async () => { - writeFileSync( - join(testHome, '.zshenv'), - 'export MY_VAR=from-zshenv\nexport ZDOTDIR="$HOME/.config/zsh"\n' - ) - - const xdgZshDir = join(testHome, '.config', 'zsh') - mkdirSync(xdgZshDir, { recursive: true }) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - delete cleanEnv.MY_VAR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "MY_VAR=${MY_VAR:-unset}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - expect(result.stdout).toContain('MY_VAR=from-zshenv') - }) - - it('handles empty HOME gracefully', async () => { - // When HOME is empty, wrapper should not attempt to source /.zshenv - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { HOME: '' } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - // Empty HOME falls back to empty ORCA_ORIG_ZDOTDIR - expect(result.stdout).toContain('ORCA_ORIG_ZDOTDIR=\n') - }) - - it('handles unset HOME gracefully', async () => { - // Why: zsh initializes HOME from /etc/passwd when unset at spawn, so the wrapper can still discover ZDOTDIR. - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = {} - delete cleanEnv.HOME - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - // zsh initializes HOME from passwd, wrapper discovers ZDOTDIR normally - expect(result.stdout).toMatch(/ORCA_ORIG_ZDOTDIR=.+/) - }) - - it('handles ZDOTDIR containing only "/"', async () => { - writeFileSync(join(testHome, '.zshenv'), 'export ZDOTDIR="/"\n') - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - // Single slash normalizes to empty after %/, falls back to HOME - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) - }) - - it('handles ZDOTDIR containing only slashes "///"', async () => { - writeFileSync(join(testHome, '.zshenv'), 'export ZDOTDIR="///"\n') - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - // Multiple slashes normalize to "/" then to empty after %/, falls back to HOME - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) - }) - - it('handles user .zshenv that unsets HOME', async () => { - const xdgZshDir = join(testHome, '.config', 'zsh') - mkdirSync(xdgZshDir, { recursive: true }) - writeFileSync(join(testHome, '.zshenv'), `unset HOME\nexport ZDOTDIR="${xdgZshDir}"\n`) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - // Subshell unsets HOME but wrapper HOME is in parent scope - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${xdgZshDir}`) - }) - - it('handles user .zshenv that sets ZDOTDIR to empty string', async () => { - writeFileSync(join(testHome, '.zshenv'), 'export ZDOTDIR=""\n') - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - // Empty string should be normalized away, fall back to HOME - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) - }) - - it('handles conditional unset of ZDOTDIR', async () => { - const xdgZshDir = join(testHome, '.config', 'zsh') - mkdirSync(xdgZshDir, { recursive: true }) - writeFileSync( - join(testHome, '.zshenv'), - `export ZDOTDIR="${xdgZshDir}"\nif [[ "\${TERM}" == "dumb" ]]; then\n unset ZDOTDIR\nfi\n` - ) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - // Test with TERM=dumb - let cleanEnv: Record = { - ...process.env, - HOME: testHome, - TERM: 'dumb' - } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - let result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - // ZDOTDIR unset conditionally, falls back to HOME - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) - - // Test with TERM=xterm - cleanEnv = { ...process.env, HOME: testHome, TERM: 'xterm-256color' } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - // ZDOTDIR not unset, uses discovered value - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${xdgZshDir}`) - }) - }) -}) diff --git a/src/main/providers/local-pty-shell-ready-zsh-zdotdir-discovery.test.ts b/src/main/providers/local-pty-shell-ready-zsh-zdotdir-discovery.test.ts deleted file mode 100644 index 40ceb8333d7..00000000000 --- a/src/main/providers/local-pty-shell-ready-zsh-zdotdir-discovery.test.ts +++ /dev/null @@ -1,335 +0,0 @@ -import { afterEach, beforeEach, expect, it } from 'vitest' -import { spawnSync } from 'node:child_process' -import { tmpdir } from 'node:os' -import { join, dirname } from 'node:path' -import { mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync, mkdirSync } from 'node:fs' -import { - describeIfZsh, - describePosix, - importFreshLocalPtyShellReady, - restoreUserDataPathAfterEach, - setTestUserDataPath -} from './local-pty-shell-ready-test-harness' -// Why resolved rather than hardcoded: the wrapper tree is content-addressed. -import { getShellReadyWrapperRoot } from './local-pty-shell-ready-wrapper-root' - -restoreUserDataPathAfterEach() - -// End-to-end validation that wrapper ZDOTDIR discovery preserves top-level zsh semantics (spawns real zsh; gated on availability). -describePosix('live zsh subprocess tests', () => { - describeIfZsh('ZDOTDIR discovery with real zsh', () => { - let testHome: string - let userDataPath: string - - beforeEach(async () => { - testHome = mkdtempSync(join(tmpdir(), 'orca-zsh-test-home-')) - userDataPath = mkdtempSync(join(tmpdir(), 'orca-zsh-test-userdata-')) - setTestUserDataPath(userDataPath) - }) - - afterEach(() => { - rmSync(testHome, { recursive: true, force: true }) - rmSync(userDataPath, { recursive: true, force: true }) - }) - - it('preserves typeset -U path scoping when user .zshrc uses it', async () => { - // Why: PR #1737's function-wrapper made "typeset -U path" function-scoped; user rcfiles must source at top level. - - // Create XDG-style config: .zshenv sets ZDOTDIR, .zshrc modifies PATH - const xdgZshDir = join(testHome, '.config', 'zsh') - mkdirSync(xdgZshDir, { recursive: true }) - writeFileSync( - join(testHome, '.zshenv'), - `export ZDOTDIR="$HOME/.config/zsh" -` - ) - writeFileSync( - join(xdgZshDir, '.zshrc'), - `typeset -U path -path=(/custom/bin $path) -` - ) - - // Generate the Orca wrapper - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - // Verify the wrapper discovered XDG ZDOTDIR, sourced user .zshrc, and kept typeset -U path (proves top-level scoping). - const cleanEnv: Record = { - ...process.env, - HOME: testHome, - PATH: '/usr/bin:/bin' - } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR // Point to Orca wrapper dir - - const result = spawnSync( - 'zsh', - [ - '-i', - '-c', - 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}" && echo "PATH_HAS_CUSTOM=${PATH%%:*}"' - ], - { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - } - ) - - expect(result.status).toBe(0) - const output = result.stdout - expect(output).toContain(`ORCA_ORIG_ZDOTDIR=${xdgZshDir}`) - expect(output).toContain('PATH_HAS_CUSTOM=/custom/bin') - }) - - it('loads user .zshrc when wrappers are sourced from a different runtime path (WSL simulation)', async () => { - // Why: issue #8003 — WSL sources Windows-generated wrappers via /mnt/c where the baked path is absent; renaming userData reproduces that split. - writeFileSync(join(testHome, '.zshrc'), 'export USER_ZSHRC_LOADED=yes\n') - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - getShellReadyLaunchConfig('/bin/zsh') - - const movedUserData = `${userDataPath}-wsl-view` - renameSync(userDataPath, movedUserData) - try { - const cleanEnv: Record = { - ...process.env, - HOME: testHome, - PATH: '/usr/bin:/bin' - } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - delete cleanEnv.USER_ZSHRC_LOADED - // Why the prefix swap: the tree is content-addressed under the user data - // dir, so the relocated ZDOTDIR has to follow the resolved root. - cleanEnv.ZDOTDIR = join( - getShellReadyWrapperRoot().replace(userDataPath, movedUserData), - 'zsh' - ) - - // Cover both the WSL login shell (`exec zsh -l`) and the non-login local-pane flow so both restore paths stay pinned. - for (const args of [['-i'], ['-l', '-i']] as const) { - const result = spawnSync( - 'zsh', - [ - ...args, - '-c', - 'echo "USER_ZSHRC_LOADED=${USER_ZSHRC_LOADED:-no}" && echo "FINAL_ZDOTDIR=${ZDOTDIR:-unset}" && echo "IS_LOGIN=$([[ -o login ]] && echo yes || echo no)"' - ], - { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - } - ) - - expect(result.status, `zsh ${args.join(' ')} failed: ${result.stderr}`).toBe(0) - expect(result.stdout).toContain('USER_ZSHRC_LOADED=yes') - expect(result.stdout).toContain(`FINAL_ZDOTDIR=${testHome}`) - // Why: `as const` makes .includes('-l') reject the tuple union type; check by position instead. - expect(result.stdout).toContain(args[0] === '-l' ? 'IS_LOGIN=yes' : 'IS_LOGIN=no') - } - } finally { - rmSync(movedUserData, { recursive: true, force: true }) - } - }) - - it('loads user .zshrc when the wrapper dir contains a non-ASCII (token-range) path', async () => { - // Why: issue #8003 — non-ASCII usernames put UTF-8 bytes in zsh's 0x84-0x9D token range, corrupting env-imported $ZDOTDIR; derive from %x instead. - writeFileSync(join(testHome, '.zshrc'), 'export USER_ZSHRC_LOADED=yes\n') - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - getShellReadyLaunchConfig('/bin/zsh') - - // Move wrappers under a non-ASCII root so the baked literal is unusable and runtime $ZDOTDIR corrupts on import. - const nonAsciiUserData = join(dirname(userDataPath), '홍길동-wsl-view') - renameSync(userDataPath, nonAsciiUserData) - try { - const cleanEnv: Record = { - ...process.env, - HOME: testHome, - PATH: '/usr/bin:/bin' - } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - delete cleanEnv.USER_ZSHRC_LOADED - // Why the prefix swap: the tree is content-addressed under the user data - // dir, so the relocated ZDOTDIR has to follow the resolved root. - cleanEnv.ZDOTDIR = join( - getShellReadyWrapperRoot().replace(userDataPath, nonAsciiUserData), - 'zsh' - ) - - for (const args of [['-i'], ['-l', '-i']] as const) { - const result = spawnSync( - 'zsh', - [...args, '-c', 'echo "USER_ZSHRC_LOADED=${USER_ZSHRC_LOADED:-no}"'], - { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - } - ) - - expect(result.status, `zsh ${args.join(' ')} failed: ${result.stderr}`).toBe(0) - expect(result.stdout).toContain('USER_ZSHRC_LOADED=yes') - } - } finally { - rmSync(nonAsciiUserData, { recursive: true, force: true }) - } - }) - - it('preserves top-level .zshenv path and function side effects', async () => { - // Why: .zshenv is the normal place for always-on env/path setup; dropping side effects regresses zsh startup. - const xdgZshDir = join(testHome, '.config', 'zsh') - mkdirSync(xdgZshDir, { recursive: true }) - writeFileSync( - join(testHome, '.zshenv'), - `typeset -U path -path=(/env/bin $path) -export MY_VAR=from-zshenv -orca_zshenv_func() { echo "from-zshenv-function"; } -export ZDOTDIR="$HOME/.config/zsh" -` - ) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { - ...process.env, - HOME: testHome, - PATH: '/usr/bin:/bin' - } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - delete cleanEnv.MY_VAR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync( - 'zsh', - [ - '-c', - 'echo "PATH_HEAD=${PATH%%:*}" && echo "MY_VAR=${MY_VAR:-unset}" && orca_zshenv_func' - ], - { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - } - ) - - expect(result.status).toBe(0) - expect(result.stdout).toContain('PATH_HEAD=/env/bin') - expect(result.stdout).toContain('MY_VAR=from-zshenv') - expect(result.stdout).toContain('from-zshenv-function') - }) - - it('sources user startup files with their own ZDOTDIR in scope', async () => { - // Why: plugin managers such as Antidote resolve files from $ZDOTDIR while startup files are sourced. - const xdgZshDir = join(testHome, '.config', 'zsh') - const zdotdirLog = join(testHome, 'zdotdir.log') - mkdirSync(xdgZshDir, { recursive: true }) - writeFileSync(join(testHome, '.zshenv'), 'export ZDOTDIR="$HOME/.config/zsh"\n') - writeFileSync( - join(xdgZshDir, '.zprofile'), - 'printf "zprofile=%s\\n" "$ZDOTDIR" >> "$HOME/zdotdir.log"\n' - ) - writeFileSync( - join(xdgZshDir, '.zshrc'), - 'printf "zshrc=%s\\n" "$ZDOTDIR" >> "$HOME/zdotdir.log"\n' - ) - writeFileSync( - join(xdgZshDir, '.zlogin'), - 'printf "zlogin=%s\\n" "$ZDOTDIR" >> "$HOME/zdotdir.log"\n' - ) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync( - 'zsh', - ['-l', '-i', '-c', 'printf "command=%s\\n" "$ZDOTDIR" >> "$HOME/zdotdir.log"'], - { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8', - timeout: 5000 - } - ) - - expect(result.status).toBe(0) - expect(readFileSync(zdotdirLog, 'utf8')).toBe( - [ - `zprofile=${xdgZshDir}`, - `zshrc=${xdgZshDir}`, - `zlogin=${xdgZshDir}`, - `command=${xdgZshDir}`, - '' - ].join('\n') - ) - }) - - it('survives early return in user .zshenv without crashing', async () => { - // Why: early return is a common non-interactive-skip pattern; top-level sourcing must keep the wrapper running. - writeFileSync( - join(testHome, '.zshenv'), - `[[ -o interactive ]] || return 0 -export ZDOTDIR="$HOME/.config/zsh" -` - ) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - // Build clean env: use wrapper ZDOTDIR but let wrapper discover ORCA_ORIG_ZDOTDIR at runtime - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR // Point to Orca wrapper dir - - const result = spawnSync( - 'zsh', - ['-c', 'echo "survived" && echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], - { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - } - ) - - expect(result.status).toBe(0) - expect(result.stdout).toContain('survived') - // ZDOTDIR discovery yields nothing (early return before export), fallback to HOME - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) - }) - - it('falls back to HOME when user .zshenv does not set ZDOTDIR', async () => { - // Why: vanilla zsh users don't set ZDOTDIR, so the fallback chain must land on HOME. - writeFileSync( - join(testHome, '.zshenv'), - `# Vanilla zsh config, no ZDOTDIR -export MY_VAR=foo -` - ) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - // Build clean env: use wrapper ZDOTDIR but let wrapper discover ORCA_ORIG_ZDOTDIR at runtime - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR // Point to Orca wrapper dir - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) - }) - }) -}) diff --git a/src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts b/src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts deleted file mode 100644 index 32b162ae7a6..00000000000 --- a/src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts +++ /dev/null @@ -1,344 +0,0 @@ -import { afterEach, beforeEach, expect, it } from 'vitest' -import { spawnSync } from 'node:child_process' -import { tmpdir } from 'node:os' -import { join, dirname } from 'node:path' -import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs' -// Why resolved rather than hardcoded: the wrapper tree is content-addressed. -import { getShellReadyWrapperRoot } from './local-pty-shell-ready-wrapper-root' -import { - describeIfZsh, - describePosix, - importFreshLocalPtyShellReady, - restoreUserDataPathAfterEach, - setTestUserDataPath -} from './local-pty-shell-ready-test-harness' - -restoreUserDataPathAfterEach() - -// End-to-end validation that wrapper ZDOTDIR discovery preserves top-level zsh semantics (spawns real zsh; gated on availability). -describePosix('live zsh subprocess tests', () => { - describeIfZsh('high-priority edge cases', () => { - let testHome: string - let userDataPath: string - - beforeEach(async () => { - testHome = mkdtempSync(join(tmpdir(), 'orca-zsh-edge-')) - userDataPath = mkdtempSync(join(tmpdir(), 'orca-zsh-userdata-')) - setTestUserDataPath(userDataPath) - }) - - afterEach(() => { - rmSync(testHome, { recursive: true, force: true }) - rmSync(userDataPath, { recursive: true, force: true }) - }) - - it('discovers ZDOTDIR when .zshenv sources another file that sets it', async () => { - // Multi-file sourcing pattern - const commonSh = join(testHome, '.config', 'shell', 'common.sh') - mkdirSync(dirname(commonSh), { recursive: true }) - writeFileSync(commonSh, 'export ZDOTDIR="$HOME/.config/zsh"\n') - writeFileSync(join(testHome, '.zshenv'), 'source ~/.config/shell/common.sh\n') - - const xdgZshDir = join(testHome, '.config', 'zsh') - mkdirSync(xdgZshDir, { recursive: true }) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${xdgZshDir}`) - }) - - it('preserves ZDOTDIR with spaces in path', async () => { - const spacePath = join(testHome, 'My Config', 'zsh') - mkdirSync(spacePath, { recursive: true }) - writeFileSync(join(testHome, '.zshenv'), `export ZDOTDIR="${spacePath}"\n`) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${spacePath}`) - }) - - it('falls back when .zshenv has syntax error', async () => { - writeFileSync(join(testHome, '.zshenv'), 'syntax error {{{\nexport ZDOTDIR=broken\n') - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - // Syntax error causes discovery to fail, falls back to HOME - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) - }) - - it('handles framework pattern with ${ZDOTDIR:-$HOME}', async () => { - writeFileSync( - join(testHome, '.zshenv'), - 'export ZDOTDIR="${ZDOTDIR:-$HOME}"\n# prezto-style pattern\n' - ) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - // Framework pattern defaults to HOME when ZDOTDIR unset - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) - }) - - it('captures last ZDOTDIR value when set multiple times', async () => { - const firstPath = join(testHome, '.config', 'zsh') - const lastPath = join(testHome, '.local', 'zsh') - mkdirSync(firstPath, { recursive: true }) - mkdirSync(lastPath, { recursive: true }) - - writeFileSync( - join(testHome, '.zshenv'), - `export ZDOTDIR="${firstPath}"\nexport ZDOTDIR="${lastPath}"\n` - ) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${lastPath}`) - }) - - it('handles conditional ZDOTDIR based on environment', async () => { - const localPath = join(testHome, '.config', 'zsh') - const remotePath = join(testHome, '.config', 'zsh-remote') - mkdirSync(localPath, { recursive: true }) - mkdirSync(remotePath, { recursive: true }) - - writeFileSync( - join(testHome, '.zshenv'), - `if [[ -n "$SSH_CONNECTION" ]]; then\n export ZDOTDIR="${remotePath}"\nelse\n export ZDOTDIR="${localPath}"\nfi\n` - ) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - // Test without SSH_CONNECTION - let cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - delete cleanEnv.SSH_CONNECTION - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - let result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${localPath}`) - - // Test with SSH_CONNECTION - cleanEnv = { ...process.env, HOME: testHome, SSH_CONNECTION: '10.0.0.1 12345 10.0.0.2 22' } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${remotePath}`) - }) - - it('preserves explicit ZDOTDIR="$HOME" from user .zshenv', async () => { - writeFileSync(join(testHome, '.zshenv'), 'export ZDOTDIR="$HOME"\n') - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) - }) - - it('falls back when discovered ZDOTDIR does not exist', async () => { - const nonexistent = join(testHome, '.config', 'zsh-missing') - writeFileSync(join(testHome, '.zshenv'), `export ZDOTDIR="${nonexistent}"\n`) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - // Validation rejects non-existent path, falls back to HOME - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) - }) - - it('does not source /.zshenv when HOME is empty', async () => { - // Can't create /.zshenv in the test, so verify the wrapper logic guards against it. - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - getShellReadyLaunchConfig('/bin/zsh') - - const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') - - // Verify wrapper checks the resolved source root is non-empty before sourcing - expect(zshenv).toContain('if [[ -n "${_orca_zshenv_source_dir:-}"') - }) - - it('handles ZDOTDIR with single quote in path', async () => { - const quotePath = join(testHome, "config'zsh") - mkdirSync(quotePath, { recursive: true }) - writeFileSync(join(testHome, '.zshenv'), `export ZDOTDIR="${quotePath}"\n`) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${quotePath}`) - }) - - it('does not evaluate command substitution in ZDOTDIR', async () => { - const safePath = join(testHome, '.config', 'zsh') - mkdirSync(safePath, { recursive: true }) - // Attempt command substitution - should be treated as literal path component - writeFileSync(join(testHome, '.zshenv'), `export ZDOTDIR="${safePath}"\n`) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - // Should contain the safe path, not any command-substituted value - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${safePath}`) - }) - - it('handles whitespace-only ZDOTDIR (tabs and newlines)', async () => { - writeFileSync(join(testHome, '.zshenv'), 'export ZDOTDIR="\t\t\n\n"\n') - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - // Whitespace-only should be normalized to empty, fall back to HOME - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${testHome}`) - }) - - it('handles ZDOTDIR with multiple trailing slashes', async () => { - const cleanPath = join(testHome, '.config', 'zsh') - mkdirSync(cleanPath, { recursive: true }) - writeFileSync(join(testHome, '.zshenv'), `export ZDOTDIR="${cleanPath}///"\n`) - - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - - const cleanEnv: Record = { ...process.env, HOME: testHome } - delete cleanEnv.ZDOTDIR - delete cleanEnv.ORCA_ORIG_ZDOTDIR - cleanEnv.ZDOTDIR = config.env.ZDOTDIR - - const result = spawnSync('zsh', ['-c', 'echo "ORCA_ORIG_ZDOTDIR=${ORCA_ORIG_ZDOTDIR}"'], { - env: cleanEnv as NodeJS.ProcessEnv, - encoding: 'utf8' - }) - - expect(result.status).toBe(0) - // Should normalize to path without trailing slashes - expect(result.stdout).toContain(`ORCA_ORIG_ZDOTDIR=${cleanPath}`) - }) - }) -}) diff --git a/src/main/providers/local-pty-shell-ready.ts b/src/main/providers/local-pty-shell-ready.ts index 3deff780625..b6ae89b1a62 100644 --- a/src/main/providers/local-pty-shell-ready.ts +++ b/src/main/providers/local-pty-shell-ready.ts @@ -17,10 +17,7 @@ import { SHELL_STARTUP_FEATURE_ENV, type ShellStartupFeature } from '../shell-startup-features' -import { - resolveInheritedZdotdir, - resolveInheritedZshenvSourceDir -} from '../zsh-wrapper-dir-ownership' +import { inheritedZdotdirEnv, resolveInheritedZdotdir } from '../zsh-wrapper-dir-ownership' import { ensureShellReadyWrappers } from './local-pty-shell-ready-wrapper-generation' import { getShellReadyWrapperRoot, @@ -82,8 +79,7 @@ export function getShellLaunchConfig( return { args: ['-l'], env: { - ORCA_ORIG_ZDOTDIR: resolveInheritedZdotdir(process.env), - ORCA_ZSHENV_SOURCE_DIR: resolveInheritedZshenvSourceDir(process.env), + ...inheritedZdotdirEnv(resolveInheritedZdotdir(process.env)), ZDOTDIR: `${getShellReadyWrapperRoot()}/zsh`, [SHELL_STARTUP_FEATURE_ENV]: encodeShellStartupFeatures(features) }, diff --git a/src/main/shell-startup-feature-channel.test.ts b/src/main/shell-startup-feature-channel.test.ts index 75aed3d2f47..97fee6a6fde 100644 --- a/src/main/shell-startup-feature-channel.test.ts +++ b/src/main/shell-startup-feature-channel.test.ts @@ -16,6 +16,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { selectShellStartupFeatures } from './shell-startup-features' +import { runZshPty } from './zsh-startup-hook-pty-harness' import { ZSH_WRAPPER_DIR_MARKER_FILE } from './shell-templates' import { importFreshLocalPtyShellReady, @@ -160,15 +161,19 @@ describePosix('zsh launch config', () => { try { const { getShellLaunchConfig } = await importFreshLocalPtyShellReady() - // Empty dir: not a config root whoever wrote it. - expect(getShellLaunchConfig('/bin/zsh', ['history']).env.ORCA_ORIG_ZDOTDIR).toBe(home) + // Empty dir: not a config root whoever wrote it. Absent rather than $HOME + // because the wrapper hands this value straight back to the shell, and a + // user with no ZDOTDIR must end up with none. + expect(getShellLaunchConfig('/bin/zsh', ['history']).env.ORCA_ORIG_ZDOTDIR).toBeUndefined() // Stamped as Orca-owned: rejected by positive identification, even though // the path shape is not one of Orca's. writeFileSync(join(foreignWrapper, '.zshrc'), '') writeFileSync(join(foreignWrapper, ZSH_WRAPPER_DIR_MARKER_FILE), '') const stamped = await importFreshLocalPtyShellReady() - expect(stamped.getShellLaunchConfig('/bin/zsh', ['history']).env.ORCA_ORIG_ZDOTDIR).toBe(home) + expect( + stamped.getShellLaunchConfig('/bin/zsh', ['history']).env.ORCA_ORIG_ZDOTDIR + ).toBeUndefined() // A real user config dir still round-trips. rmSync(join(foreignWrapper, ZSH_WRAPPER_DIR_MARKER_FILE)) @@ -238,35 +243,26 @@ describePosix('epilogue under hostile user shell options', () => { const { getShellLaunchConfig } = await importFreshLocalPtyShellReady() const launch = getShellLaunchConfig(ZSH_PATH, features) - const output = execFileSync( - ZSH_PATH, - [ - ...(launch.args ?? ['-l']), - '-i', - '-c', - 'print -r -- "LINEINIT=${widgets[zle-line-init]:-none}"; ' + - 'print -r -- "PRECMD=${precmd_functions[*]:-none}"; ' + - 'print -r -- "OPENCODE=${OPENCODE_CONFIG_DIR:-none}"; ' + - 'print -r -- "ZDOTDIR=${ZDOTDIR:-}"; print -r -- "HISTFILE=${HISTFILE:-}"' - ], - { - encoding: 'utf8', - timeout: 20_000, - env: { - PATH: '/usr/bin:/bin', - ...spawnEnv, - ...launch.env, - ORCA_ORIG_ZDOTDIR: home, - ORCA_ZSHENV_SOURCE_DIR: home - } - } - ) + // Why a PTY: every feature is delivered from a precmd hook, and a shell + // started with -c never reaches a prompt to run one. + const { values } = await runZshPty({ + env: { + PATH: '/usr/bin:/bin', + ...spawnEnv, + ...launch.env, + ORCA_ORIG_ZDOTDIR: home + }, + report: ['LINEINIT', 'PRECMD', 'OPENCODE_CONFIG_DIR', 'ZDOTDIR', 'HISTFILE'], + commands: [ + 'LINEINIT="${widgets[zle-line-init]:-none}"; PRECMD="${precmd_functions[*]:-none}"' + ] + }) - expect(output).toContain('LINEINIT=user:__orca_prompt_mark') - expect(output).toContain('PRECMD=__orca_osc133_precmd') - expect(output).toContain(`OPENCODE=${opencodeDir}`) - expect(output).toContain(`ZDOTDIR=${home}`) - expect(output).toContain(`HISTFILE=${scoped}`) + expect(values.LINEINIT).toBe('user:__orca_prompt_mark') + expect(values.PRECMD).toContain('__orca_osc133_precmd') + expect(values.OPENCODE_CONFIG_DIR).toBe(opencodeDir) + expect(values.ZDOTDIR).toBe(home) + expect(values.HISTFILE).toBe(scoped) } ) }) @@ -295,13 +291,10 @@ describePosix('history-only pane in a real zsh', () => { rmSync(home, { recursive: true, force: true }) }) - const OBSERVABLES = - 'print -r -- "PRECMD=${precmd_functions[*]}"; ' + - 'print -r -- "PREEXEC=${preexec_functions[*]}"; ' + - 'print -r -- "LINEINIT=${widgets[zle-line-init]:-none}"; ' + - 'print -r -- "ZDOTDIR=$ZDOTDIR"; ' + - 'print -r -- "FEATURES=[${ORCA_SHELL_FEATURES:-}]"; ' + - 'print -r -- "ORCA_HISTFILE=[${ORCA_HISTFILE:-}]"' + function withoutInheritedZdotdir(env: Record): Record { + const { ORCA_ORIG_ZDOTDIR: _inherited, ...rest } = env + return rest + } async function launchHistoryOnly(): Promise<{ args: string[] @@ -327,11 +320,11 @@ describePosix('history-only pane in a real zsh', () => { env: { PATH: '/usr/bin:/bin', ...spawnEnv, - ...launch.env, - // Why override: the launch config reads the real process env, and this - // run must resolve the user's config against the sandbox home. - ORCA_ORIG_ZDOTDIR: home, - ORCA_ZSHENV_SOURCE_DIR: home + // Why ORCA_ORIG_ZDOTDIR is stripped: the launch config computes it from + // the real process env, which would leak the developer's own ZDOTDIR + // into the run. This sandbox home has none, so the pane must end up with + // none — which is also what makes it comparable to an unwrapped pane. + ...withoutInheritedZdotdir(launch.env) }, zdotdir: launch.env.ZDOTDIR } @@ -357,34 +350,38 @@ describePosix('history-only pane in a real zsh', () => { expect(output).not.toContain('history') }) - itWithZsh('emits no OSC 133 and matches an unwrapped pane', async () => { - const { args, env } = await launchHistoryOnly() + itWithZsh( + 'emits no OSC 133 and leaves a pane observably identical to an unwrapped one', + async () => { + // Why a PTY: the hook runs from the first prompt's precmd sweep, so a shell + // started with -c would report a pane Orca had not finished setting up. + const { env } = await launchHistoryOnly() + const capture = [ + 'PRECMD="${precmd_functions[*]}"; PREEXEC="${preexec_functions[*]}"', + 'LINEINIT="${widgets[zle-line-init]:-none}"' + ] + const report = ['PRECMD', 'PREEXEC', 'LINEINIT', 'ZDOTDIR', 'ORCA_SHELL_FEATURES'] - const wrapped = execFileSync(ZSH_PATH, [...args, '-i', '-c', OBSERVABLES], { - encoding: 'utf8', - timeout: 20_000, - env - }) - const unwrapped = execFileSync(ZSH_PATH, ['-l', '-i', '-c', OBSERVABLES], { - encoding: 'utf8', - timeout: 20_000, - env: { PATH: '/usr/bin:/bin', HOME: home } - }) + const wrapped = await runZshPty({ env, commands: capture, report }) + const unwrapped = await runZshPty({ + env: { PATH: '/usr/bin:/bin', HOME: home }, + commands: capture, + report + }) - expect(wrapped).not.toContain('\x1b]133;') - expect(wrapped).toContain('PRECMD=\n') - expect(wrapped).toContain('LINEINIT=none') - // Startup files run in the same order with the same hooks, so the pane is - // observably what it was before it got wrapped. - const comparable = (output: string): string[] => - output - .split('\n') - .filter((line) => !line.startsWith('ORCA_HISTFILE=') && !line.startsWith('ZDOTDIR=')) - expect(comparable(wrapped)).toEqual(comparable(unwrapped)) - // The one carried-over difference, unchanged from every pane Orca already - // wrapped: ZDOTDIR ends up explicitly set to the user's config dir, which - // is the value zsh itself defaults to when it is unset. - expect(wrapped).toContain(`ZDOTDIR=${home}`) - expect(unwrapped).toContain('ZDOTDIR=\n') - }) + expect(wrapped.output).not.toContain('\x1b]133;') + // The whole point of removing the hook rather than parking a no-op in its + // place: a history-only pane leaves no Orca name in the user's hook arrays. + expect(wrapped.values.PRECMD).toBe(unwrapped.values.PRECMD) + expect(wrapped.values.PREEXEC).toBe(unwrapped.values.PREEXEC) + // Why compared and not pinned to 'none': a host whose global zsh config + // installs its own zle-line-init widget has one either way, and what Orca + // owes is that it looks the same wrapped as unwrapped. + expect(wrapped.values.LINEINIT).toBe(unwrapped.values.LINEINIT) + expect(wrapped.values.PRECMD).not.toContain('orca') + // ZDOTDIR matches too, because the wrapper hands back exactly what it found. + expect(wrapped.values.ZDOTDIR).toBe(unwrapped.values.ZDOTDIR) + expect(wrapped.values.ORCA_SHELL_FEATURES).toBe('UNSET') + } + ) }) diff --git a/src/main/shell-templates.ts b/src/main/shell-templates.ts index 6bcfde6bfb7..8e498b11de1 100644 --- a/src/main/shell-templates.ts +++ b/src/main/shell-templates.ts @@ -2,10 +2,6 @@ // small drift here breaks different terminal transports in different ways. import { SHELL_STARTUP_FEATURE_ENV } from './shell-startup-features' -function quotePosixSingle(value: string): string { - return `'${value.replace(/'/g, `'\\''`)}'` -} - /** Basename of the file every Orca-generated zsh wrapper dir is stamped with. */ export const ZSH_WRAPPER_DIR_MARKER_FILE = '.orca-shell-wrapper' @@ -24,9 +20,15 @@ export const ZSH_WRAPPER_DIR_MARKER_CONTENT = `# Orca-generated zsh startup wrap * .zshenv is sourced means nothing the user's config spawns can see or inherit * Orca's feature selection. */ -export const ZSH_FEATURE_CHANNEL_BLOCK = `typeset -ga _orca_shell_features +export const ZSH_FEATURE_CHANNEL_BLOCK = `builtin typeset -ga _orca_shell_features _orca_shell_features=(\${(s:,:)\${${SHELL_STARTUP_FEATURE_ENV}:-}}) builtin unset ${SHELL_STARTUP_FEATURE_ENV} +# Why ORCA_HISTFILE is consumed HERE and not in the deferred hook: a user config +# that replaces precmd_functions wholesale drops the hook, and an exported value +# nothing will ever consume is then inherited by every child of this pane, +# including a nested Orca (#11146). Captured non-exported, it cannot escape. +builtin typeset -g _orca_histfile="\${ORCA_HISTFILE:-}" +builtin unset ORCA_HISTFILE __orca_has_feature() { (( \${_orca_shell_features[(Ie)$1]} )) }` /** The bash rcfile equivalent of ZSH_FEATURE_CHANNEL_BLOCK. */ @@ -39,238 +41,81 @@ __orca_has_feature() { [[ "$_orca_shell_features" == *",$1,"* ]]; }` export const SHELL_STARTUP_IDENTITY_MARKER_BLOCK = `__orca_has_feature identity && printf "\\033]777;orca-shell-start:%s\\007" "$$"` /** - * Resolves the directory Orca should treat as the user's zsh config root. + * The first executable lines of the wrapper: give ZDOTDIR back to the user. * - * Why positive identification: Orca may only reject a config dir it can prove is - * its own. A stamped marker file (or Orca's own wrapper path shape, for - * wrappers written by older builds) is that proof. Guessing at other - * terminals' wrapper dirs by name never can be, so this never tries. + * Why before anything else: every later startup file — the user's .zprofile, + * the system /etc/zshrc, .zshrc, .zlogin — is found through ZDOTDIR. Handing it + * back here means zsh reads all of them from the user's own directory, exactly + * as it would with no wrapper at all. In particular /etc/zshrc's unguarded + * `HISTFILE=${ZDOTDIR:-$HOME}/.zsh_history` then derives the user's own path + * instead of one inside Orca's wrapper dir, so #11044 cannot happen rather than + * having to be repaired afterwards. * - * Why every generated file redefines it instead of relying on .zshenv: one - * wrapper dir can be rewritten by two concurrently installed builds, so a shell - * can read one build's .zshenv and another's .zshrc. A .zshrc that called a - * function only the newer .zshenv defines printed `command not found` AND left - * the out-parameter empty, which skipped sourcing the user's own startup file. + * ORCA_ORIG_ZDOTDIR is consumed: it has done its job, and leaving it exported + * would hand a stale value to everything this pane launches. * - * Why an Orca-private out-parameter and not `REPLY`: `REPLY` is zsh's shared - * scratch global, so a user config is entitled to constrain it. `typeset -r - * REPLY` made the very first assignment a fatal error and aborted the wrapper - * file; `typeset -i REPLY` silently turned every resolved path into `0`. Both - * left HISTFILE inside Orca's wrapper dir. Harmless while Orca wrapped a few - * percent of panes; not harmless now that it wraps every zsh pane. - * - * Why `typeset -g`: .zshrc/.zlogin call this AFTER the user's own config, so - * creating a plain global inside a function would print a warning per call - * under `setopt warn_create_global`. + * Why the value is vetted rather than trusted: the launch config only sets this + * when it resolved a usable dir, but a pane also inherits its parent's + * environment, so a stale ORCA_ORIG_ZDOTDIR written by an older build can arrive + * on its own. Handing that back would point ZDOTDIR at an Orca wrapper dir — the + * self-loop the Node-side ownership check exists to prevent, arriving by a route + * that check never sees. Identification stays positive, as it is in Node: a + * stamped marker file, or Orca's own path shape for wrappers older builds wrote. */ -export const ZSH_USER_CONFIG_DIR_RESOLVER_BLOCK = `__orca_resolve_user_config_dir() { - typeset -g _orca_resolved_config_dir="\${1:-}" - while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="\${_orca_resolved_config_dir%/}"; done - if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/${ZSH_WRAPPER_DIR_MARKER_FILE}" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then - _orca_resolved_config_dir="$HOME" - fi -}` - -/** - * The stricter resolver .zshenv uses for a ZDOTDIR this shell INHERITED. - * - * Why only .zshenv needs it: nothing after .zshenv re-reads an inherited value — - * the later files resolve ORCA_ORIG_ZDOTDIR, which .zshenv already vetted. - */ -export const ZSH_INHERITED_CONFIG_DIR_RESOLVER_BLOCK = `# Why stricter for an inherited value: Orca can be launched from a terminal that -# already pointed ZDOTDIR at its own wrapper dir, and a directory holding no zsh -# startup file at all is not the user's config root whoever wrote it. -__orca_resolve_inherited_config_dir() { - __orca_resolve_user_config_dir "\${1:-}" - [[ "$_orca_resolved_config_dir" == "$HOME" ]] && return 0 +export const ZSH_ZDOTDIR_HANDBACK_BLOCK = `__orca_usable_zdotdir() { + [[ -n "\${1:-}" ]] || return 1 + # Orca's own dir, by marker file or by the shape older builds wrote. + [[ "$1" != */shell-ready/zsh ]] || return 1 + [[ ! -f "$1/${ZSH_WRAPPER_DIR_MARKER_FILE}" ]] || return 1 + # A directory holding no zsh startup file at all is not a config root, + # whoever wrote it — and a stale value pointing at one would stop zsh from + # ever reading the user's real .zshenv. local _orca_startup_file for _orca_startup_file in .zshenv .zshrc .zprofile .zlogin; do - [[ -r "$_orca_resolved_config_dir/$_orca_startup_file" ]] && return 0 + [[ -r "$1/$_orca_startup_file" ]] && return 0 done - _orca_resolved_config_dir="$HOME" + return 1 +} +if __orca_usable_zdotdir "\${ORCA_ORIG_ZDOTDIR:-}"; then + builtin export ZDOTDIR="$ORCA_ORIG_ZDOTDIR" +else + builtin unset ZDOTDIR +fi +builtin unset ORCA_ORIG_ZDOTDIR ORCA_ZSHENV_SOURCE_DIR +builtin unfunction __orca_usable_zdotdir` + +/** + * Sources the user's own .zshenv, then arms the deferred hook. + * + * Why `{ } always { }`: the whole compound command is parsed before any of it + * runs, so the registration below is already parsed as zsh even if the user's + * .zshenv switches the shell into sh emulation. Sourcing at wrapper top level + * (not in a function or subshell) keeps the user's exports, functions, fpath + * typesets and options in their normal scope. + * + * Why guarded on absence: the hook is idempotent, but appending it twice would + * still leave a dead name in the user's precmd_functions. + */ +export const ZSH_USER_ZSHENV_SOURCE_BLOCK = `{ + builtin typeset _orca_user_zshenv="\${ZDOTDIR-$HOME}/.zshenv" + [[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv" +} always { + builtin unset _orca_user_zshenv + builtin typeset -ag precmd_functions + (( \${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) }` // Why: daemon, local, and relay wrappers must preserve one Bash prompt-hook contract. export { BASH_PROMPT_COMMAND_COMPOSITION_BLOCK } from './bash-prompt-command-composition' -/** - * Fork-free precondition for the `$(emulate)` probe: options that both - * `emulate sh` and `emulate ksh` turn on, so all-off proves zsh emulation. - * - * Why: the probe is a command substitution, which forks a zsh carrying every - * function, alias and completion the user's config has loaded by that point — - * the most expensive line in the wrapper, and one every zsh pane pays now that - * wrapping widened to all of them. Measured on zsh 5.9 / macOS, 150 login - * startups per arm, with a user .zshenv + .zprofile + .zshrc present: 9.97 - * ms/run unwrapped, 14.20 ms/run wrapped, 12.27 ms/run wrapped once these three - * probes are skipped — about half of what wrapping costs. - * - * Why a hint in front of the real probe and not a replacement for it: these - * options say nothing about `emulation`, which is what zsh's `sourcehome()` - * branches on, so a config that sets one by hand must still get the exact - * answer. OR, not AND, so the only way past it is to enter emulation and then - * unset all three; every real `emulate sh`/`emulate ksh` sets them. A false - * positive costs exactly the fork this saves. - * - * Why `2>/dev/null`: `[[ -o ]]` prints `no such option` to stderr and - * returns false rather than aborting, so on a zsh too old for one of these - * names the only symptom would be that text in the user's pane. All three - * predate every zsh Orca supports, so this is belt and braces, not a fallback. - */ -export const ZSH_BOURNE_EMULATION_OPTION_HINT = - '[[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null' - -/** - * Hands the pane back to the user unwrapped when zsh has entered sh/ksh - * emulation, and stops reading the rest of the current wrapper file. - * - * Why: zsh's `sourcehome()` ignores ZDOTDIR entirely once the shell is in sh or - * ksh emulation, so a user .zshenv (or .zprofile) ending in `emulate sh` means - * NO later wrapper file is ever read — the epilogue runs zero times, while the - * user's own $HOME startup files load normally. The pane looks fine and writes - * its history inside Orca's wrapper dir, where the user will never find it. - * - * Nothing can repair that from here (`/etc/zshrc` assigns HISTFILE after - * .zshenv and .zprofile both), so the wrapper does the next best thing: it - * restores the user's own ZDOTDIR and consumes Orca's variables, leaving - * exactly the shell an unwrapped pane would have produced. Degrading to the - * pre-wrapping behaviour is the bar; degrading to something worse is not. - * - * Why a positive `sh|ksh` match rather than `!= zsh`: a zsh too old for the - * query form of `emulate` prints nothing, and must not unwrap every pane. - * - * Why `sourcedUserFileTest` gates the probe rather than it running - * unconditionally: `$(emulate)` forks, and every zsh pane now pays for it. The - * gate loses no coverage — this wrapper file is itself read through ZDOTDIR, so - * anything that had already entered emulation (a system /etc/zshenv or - * /etc/zprofile) would have hidden this very file too. The only thing that can - * have entered emulation by this line is the user file this file just sourced. - * - * Why ZSH_BOURNE_EMULATION_OPTION_HINT gates it further: see that constant. - */ -export function getZshEmulationDegradeBlock(options: { - userZdotdirExpression: string - sourcedUserFileTest: string -}): string { - return `if [[ ${options.sourcedUserFileTest} ]] && ${ZSH_BOURNE_EMULATION_OPTION_HINT}; then - case "$(emulate 2>/dev/null)" in - sh|ksh) - export ZDOTDIR=${options.userZdotdirExpression} - # Why unset: an ORCA_HISTFILE no wrapper file will ever consume is - # inherited by everything this pane spawns, including a nested Orca. - builtin unset ORCA_HISTFILE _orca_shell_features _orca_home _orca_resolved_config_dir _orca_wrapper_zdotdir_self - unfunction __orca_shell_epilogue __orca_has_feature __orca_resolve_user_config_dir __orca_resolve_inherited_config_dir 2>/dev/null - return 0 - ;; - esac -fi` -} - -/** The ZDOTDIR-discovery body of the wrapper .zshenv (no header, no epilogue). */ -export function getZshEnvDiscoveryBody(zshDir: string): string { - return `# Why: capture the runtime wrapper dir before it is unset below. On WSL this -# file is generated with a Windows path but sourced via /mnt/c, so the baked -# literal is unusable there and ZDOTDIR must be restored from this value. -# Derive it from the file being sourced (%x, zsh's internal script name) rather -# than the env-imported $ZDOTDIR: zsh corrupts environment values whose UTF-8 -# bytes fall in its 0x84-0x9D token range (e.g. a non-ASCII Windows username -# such as a Korean login), which would make the self-check below fail and fall -# back to the unusable baked literal, so the user's .zshrc never loads (#8003). -# %x is not subject to that corruption; keep $ZDOTDIR as a fallback for the -# rare shell where %x prompt expansion yields nothing. -_orca_wrapper_zdotdir_self="\${\${(%):-%x}:h}" -if [[ -z "\${_orca_wrapper_zdotdir_self:-}" ]]; then - _orca_wrapper_zdotdir_self="\${ZDOTDIR:-}" -fi -while [[ "\${_orca_wrapper_zdotdir_self:-}" == */ ]]; do - _orca_wrapper_zdotdir_self="\${_orca_wrapper_zdotdir_self%/}" -done -_orca_zshenv_path="" - -# Normalize fallback and source roots before reading user .zshenv so nested -# Orca PTYs never source another Orca wrapper recursively. -__orca_resolve_inherited_config_dir "\${ORCA_ORIG_ZDOTDIR:-$HOME}" -_orca_user_zdotdir="$_orca_resolved_config_dir" -__orca_resolve_inherited_config_dir "\${ORCA_ZSHENV_SOURCE_DIR:-$HOME}" -_orca_zshenv_source_dir="$_orca_resolved_config_dir" -unset ORCA_ZSHENV_SOURCE_DIR - -# Why: source at wrapper top level, not in a function/subshell, so .zshenv -# exports, functions, path/fpath typesets, and zsh options keep normal scope. -unset ZDOTDIR -if [[ -n "\${_orca_zshenv_source_dir:-}" && -f "\${_orca_zshenv_source_dir}/.zshenv" ]]; then - _orca_zshenv_path="\${_orca_zshenv_source_dir}/.zshenv" -fi -if [[ -n "\${_orca_zshenv_path:-}" ]]; then - source "\${_orca_zshenv_path}" -fi - -_orca_discovered_zdotdir="\${ZDOTDIR:-}" - -while [[ "\${_orca_discovered_zdotdir}" == */ ]]; do - _orca_discovered_zdotdir="\${_orca_discovered_zdotdir%/}" -done - -case "\${_orca_discovered_zdotdir}" in - *[![:space:]]*) ;; - *) _orca_discovered_zdotdir="" ;; -esac - -if [[ -n "\${_orca_discovered_zdotdir}" && ! -d "\${_orca_discovered_zdotdir}" ]]; then - [[ "\${ORCA_DEBUG:-0}" == "1" ]] && echo "[orca-shell-ready] Discovered ZDOTDIR '\${_orca_discovered_zdotdir}' does not exist, falling back" >&2 - _orca_discovered_zdotdir="" -fi - -# Why only the ownership check here: a ZDOTDIR the user's own .zshenv just -# exported is the user's by construction, whatever it happens to contain. -__orca_resolve_user_config_dir "\${_orca_discovered_zdotdir:-\${_orca_user_zdotdir:-$HOME}}" -export ORCA_ORIG_ZDOTDIR="$_orca_resolved_config_dir" -unset _orca_user_zdotdir _orca_zshenv_source_dir _orca_discovered_zdotdir - -${getZshEmulationDegradeBlock({ - userZdotdirExpression: '"$ORCA_ORIG_ZDOTDIR"', - sourcedUserFileTest: '-n "${_orca_zshenv_path:-}"' -})} -unset _orca_zshenv_path - -# Why: use :- after user .zshenv — a pathological unset under set -u must not -# abort the wrapper; empty falls through to the baked-literal branch. -if [[ -n "\${_orca_wrapper_zdotdir_self:-}" && -f "\${_orca_wrapper_zdotdir_self:-}/.zshenv" ]]; then - export ZDOTDIR="\${_orca_wrapper_zdotdir_self:-}" -else - export ZDOTDIR=${quotePosixSingle(zshDir)} -fi -unset _orca_wrapper_zdotdir_self -` -} - -/** - * The relay variant of the discovery body: it trusts the ZDOTDIR the remote - * shell already inherited instead of re-deriving one, and republishes it as - * ORCA_USER_ZDOTDIR for the later wrapper files. - * - * Why separate: this diverged from the discovery template before unification - * and is preserved here — reconciling the two is a follow-up. - */ -export function getZshOverlayEnvBody(zshDir: string): string { - return `__orca_resolve_inherited_config_dir "\${ORCA_ORIG_ZDOTDIR:-$HOME}" -export ORCA_ORIG_ZDOTDIR="$_orca_resolved_config_dir" -[[ -f "$ORCA_ORIG_ZDOTDIR/.zshenv" ]] && source "$ORCA_ORIG_ZDOTDIR/.zshenv" -__orca_resolve_user_config_dir "\${ZDOTDIR:-\${ORCA_ORIG_ZDOTDIR:-$HOME}}" -export ORCA_USER_ZDOTDIR="$_orca_resolved_config_dir" - -${getZshEmulationDegradeBlock({ - userZdotdirExpression: '"$ORCA_USER_ZDOTDIR"', - sourcedUserFileTest: '-f "$ORCA_ORIG_ZDOTDIR/.zshenv"' -})} - -export ZDOTDIR=${quotePosixSingle(zshDir)} -` -} - /** * Restores the worktree-scoped HISTFILE that macOS `/etc/zshrc` destroys. * + * Bash only. The zsh wrapper no longer needs this: it hands ZDOTDIR back before + * /etc/zshrc runs, so the value that file derives is already the user's own, and + * the scoped path is re-applied from a non-exported variable in the deferred + * hook. The `elif` below is inert under bash, where ZDOTDIR is normally unset. + * * That file assigns `HISTFILE=${ZDOTDIR:-$HOME}/.zsh_history` with no * check-before-set, and it runs before any wrapper file Orca controls — so the * injected value is already gone, and because ZDOTDIR still points at Orca's @@ -281,7 +126,7 @@ export ZDOTDIR=${quotePosixSingle(zshDir)} * cannot be inherited by anything the shell later spawns if it no longer exists * once it has been consumed. HISTFILE itself stays exported. */ -export const ZSH_HISTFILE_RESTORE_BLOCK = `if [[ -n "\${ORCA_HISTFILE:-}" ]]; then +export const BASH_HISTFILE_RESTORE_BLOCK = `if [[ -n "\${ORCA_HISTFILE:-}" ]]; then HISTFILE="$ORCA_HISTFILE" builtin unset ORCA_HISTFILE elif [[ "\${HISTFILE:-}" == "$ZDOTDIR/.zsh_history" ]]; then @@ -293,33 +138,6 @@ elif [[ "\${HISTFILE:-}" == "$ZDOTDIR/.zsh_history" ]]; then HISTFILE="\${ORCA_ORIG_ZDOTDIR:-$HOME}/.zsh_history" fi` -export function getZshStartupFileSourceBlock(options: { - fileName: '.zprofile' | '.zshrc' | '.zlogin' - homeExpression?: string - interactiveOnly?: boolean - skipWhenHomeIsCurrentZdotdir?: boolean -}): string { - const homeExpression = options.homeExpression ?? '"${ORCA_ORIG_ZDOTDIR:-$HOME}"' - const checks = [ - options.skipWhenHomeIsCurrentZdotdir ? '"$_orca_home" != "$ZDOTDIR"' : null, - options.interactiveOnly ? '-o interactive' : null, - `-f "$_orca_home/${options.fileName}"` - ].filter(Boolean) - - return `__orca_resolve_user_config_dir ${homeExpression} -_orca_home="$_orca_resolved_config_dir" -if [[ ${checks.join(' && ')} ]]; then - _orca_wrapper_zdotdir="$ZDOTDIR" - # Why: user startup files resolve plugin/config paths from their own ZDOTDIR; - # Orca restores its wrapper dir afterward so zsh still loads wrapper files. - export ZDOTDIR="$_orca_home" - source "$_orca_home/${options.fileName}" - export ZDOTDIR="$_orca_wrapper_zdotdir" - unset _orca_wrapper_zdotdir -fi -` -} - // Why: zsh precmd fires before zle switches the PTY into line-editing mode, // so the marker must be emitted from zle-line-init. Registering it through // add-zle-hook-widget is unsafe: the azhw dispatcher aborts its hook chain @@ -369,12 +187,3 @@ export function getFishShellReadyInitCommand(escapedMarker: string): string { functions -e __orca_shell_ready_marker end` } - -export function getZshFinalZdotdirRestoreBlock(homeExpression = '"${ORCA_ORIG_ZDOTDIR:-$HOME}"') { - return `__orca_resolve_user_config_dir ${homeExpression} -# Why: after Orca's last wrapper file has loaded, the interactive shell should -# expose the same ZDOTDIR a normal zsh startup would expose. -export ZDOTDIR="$_orca_resolved_config_dir" -unset _orca_home _orca_resolved_config_dir -` -} diff --git a/src/main/shell-wrapper-generated-file-snapshot.test.ts b/src/main/shell-wrapper-generated-file-snapshot.test.ts index 6e6d10ab0ff..0fa1eadbe14 100644 --- a/src/main/shell-wrapper-generated-file-snapshot.test.ts +++ b/src/main/shell-wrapper-generated-file-snapshot.test.ts @@ -31,18 +31,17 @@ const STARTUP_COMMAND_FEATURES = selectShellStartupFeatures({ emitsStartupIdentity: true }) +// Why one zsh file: the wrapper hands ZDOTDIR back on its first lines, so zsh +// reads .zprofile, .zshrc and .zlogin from the user's own directory. const WRAPPER_FILES = [ ['zsh-zshenv', join('zsh', '.zshenv')], - ['zsh-zprofile', join('zsh', '.zprofile')], - ['zsh-zshrc', join('zsh', '.zshrc')], - ['zsh-zlogin', join('zsh', '.zlogin')], ['bash-rcfile', join('bash', 'rcfile')] ] as const const SNAPSHOT_DIR = join(__dirname, '__fixtures__', 'shell-wrapper-snapshots') -// Why: the wrapper root is a temp dir per run, and the baked ZDOTDIR literal is -// the only path-dependent byte in the output; pin it to a stable placeholder. +// Why still normalized: the zsh hook no longer bakes a wrapper path at all, but +// the bash rcfile can still carry one, and a temp root differs per run. function withStableRoot(content: string, root: string): string { return content.split(root).join('') } diff --git a/src/main/zsh-scoped-histfile.live-shell.test.ts b/src/main/zsh-scoped-histfile.live-shell.test.ts index 741f892d734..566ce8ae4b1 100644 --- a/src/main/zsh-scoped-histfile.live-shell.test.ts +++ b/src/main/zsh-scoped-histfile.live-shell.test.ts @@ -1,63 +1,34 @@ /** - * Real-zsh proof that a worktree-scoped HISTFILE survives shell startup. + * Real-zsh proof that a worktree-scoped HISTFILE survives shell startup, and + * that the rest of Orca's startup features arrive with it. * * macOS `/etc/zshrc` assigns `HISTFILE=${ZDOTDIR:-$HOME}/.zsh_history` with no - * check-before-set, and it runs before every wrapper file Orca controls. So the - * value `injectHistoryEnv` put in the spawn env is already gone by the time the - * user reaches a prompt — and because ZDOTDIR still points at Orca's wrapper - * dir, the replacement lands inside it. Per-worktree history was therefore a - * silent no-op on the primary platform (#11044). + * check-before-set. Orca used to fight that by keeping its own ZDOTDIR in place + * across `/etc/zshrc` and repairing the damage afterwards (#11044). It now hands + * ZDOTDIR back before that file runs, so the value `/etc/zshrc` derives is the + * user's own path and the scoped one is re-applied from the deferred hook. * - * Only a real zsh can show this: the string the wrapper emits looks correct - * either way, and the whole bug lives in what /etc/zshrc does between the spawn - * env and the first prompt. + * Only a real zsh on a real PTY can show any of this: the wrapper text looks + * correct either way, the whole question is what `/etc/zshrc` does between the + * spawn env and the first prompt, and the hook is a `precmd` — which a shell + * started with `-c` never reaches. * * These tests drive the REAL launch decision (`selectShellStartupFeatures` + * `getShellLaunchConfig`) rather than an inline copy of the gate, so a pane that * Orca would not wrap cannot pass here by construction. */ -import { execFileSync, spawnSync } from 'node:child_process' -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { execFileSync } from 'node:child_process' +import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' +import { ensureOverlayRestoreWrappers } from '../relay/pty-shell-overlay-wrappers' import { getShellLaunchConfig } from './providers/local-pty-shell-ready' import { selectShellStartupFeatures } from './shell-startup-features' +import { hasZsh, makeZshHome, MARKERS, runZshPty, ZSH_PATH } from './zsh-startup-hook-pty-harness' -// Why probe and execute the same binary: guarding on `zsh` from PATH but then -// running a hardcoded `/bin/zsh` lets the guard pass on a host that installs zsh -// elsewhere, and the test fails for a missing binary rather than a wrapper -// defect. The absolute path is resolved once so the sandboxed PATH below cannot -// lose it. -const hasZsh = process.platform !== 'win32' && spawnSync('zsh', ['--version']).status === 0 -const ZSH_PATH = hasZsh - ? (spawnSync('sh', ['-c', 'command -v zsh'], { encoding: 'utf8' }).stdout || '').trim() - : '' const itWithZsh = hasZsh ? it : it.skip -function runZsh( - args: string[], - env: Record, - probe = 'echo "RESULT=$HISTFILE"' -): string { - // -o noglobalrcs is deliberately NOT passed: /etc/zshrc is the thing under test. - return execFileSync(ZSH_PATH, [...args, '-i', '-c', probe], { - encoding: 'utf8', - timeout: 20_000, - env: { PATH: '/usr/bin:/bin', ...env } - }) -} - -/** Both streams, because a wrapper that breaks does it on stderr. */ -function runZshCapturingStderr(args: string[], env: Record, probe: string): string { - const result = spawnSync(ZSH_PATH, [...args, '-i', '-c', probe], { - encoding: 'utf8', - timeout: 20_000, - env: { PATH: '/usr/bin:/bin', ...env } - }) - return `${result.stdout ?? ''}${result.stderr ?? ''}` -} - /** * True when this host's system zshrc is the thing that destroys HISTFILE. * @@ -75,11 +46,7 @@ const systemZshrcClobbersHistfile = (() => { const output = execFileSync(ZSH_PATH, ['-l', '-i', '-c', 'echo "RESULT=$HISTFILE"'], { encoding: 'utf8', timeout: 20_000, - env: { - PATH: '/usr/bin:/bin', - HOME: home, - HISTFILE: join(home, 'injected-history') - } + env: { PATH: '/usr/bin:/bin', HOME: home, HISTFILE: join(home, 'injected-history') } }) return !output.includes(join(home, 'injected-history')) } catch { @@ -91,314 +58,372 @@ const systemZshrcClobbersHistfile = (() => { const itWithClobber = systemZshrcClobbersHistfile ? itWithZsh : it.skip -/** The launch config Orca produces for a plain pane whose only Orca-owned - * concern is a worktree-scoped HISTFILE. */ -function launchPlainHistoryPane(home: string, scopedHistfile: string) { +/** The launch config Orca produces for a pane with exactly these features. */ +function launchPane( + home: string, + scopedHistfile: string | null, + overrides: Partial[0]> = {} +) { const env: Record = { HOME: home, - HISTFILE: scopedHistfile, - ORCA_HISTFILE: scopedHistfile + ...(scopedHistfile ? { HISTFILE: scopedHistfile, ORCA_HISTFILE: scopedHistfile } : {}) } const features = selectShellStartupFeatures({ shellPath: ZSH_PATH, env, hasStartupCommand: false, waitsForShellReady: false, - emitsStartupIdentity: false + emitsStartupIdentity: false, + ...overrides }) const launch = getShellLaunchConfig(ZSH_PATH, features) return { features, launch, - env: { ...env, ...launch.env, ...sandboxConfigDir(home) } + // Why ORCA_ORIG_ZDOTDIR overridden: the launch config resolves the user's + // config dir from the real process env, and these runs must resolve against + // the sandbox home instead. + env: { PATH: '/usr/bin:/bin', ...env, ...launch.env, ORCA_ORIG_ZDOTDIR: home } } } -// Why override: the launch config resolves the user's config dir from the real -// process env, and these runs must resolve against the sandbox home instead. -function sandboxConfigDir(home: string): Record { - return { ORCA_ORIG_ZDOTDIR: home, ORCA_ZSHENV_SOURCE_DIR: home } +const USER_FILES = { + '.zshenv': 'export ORCA_TEST_USER_ZSHENV=1\n', + '.zprofile': 'export ORCA_TEST_USER_ZPROFILE=1\n', + '.zshrc': 'export ORCA_TEST_USER_ZSHRC=1\n' } -function withTempHome(run: (home: string) => void): void { - const home = mkdtempSync(join(tmpdir(), 'orca-scoped-histfile-')) - try { - run(home) - } finally { - rmSync(home, { recursive: true, force: true }) +function withHome(files: Record, run: (home: string) => Promise) { + return async () => { + const home = makeZshHome(files) + try { + await run(home) + } finally { + rmSync(home, { recursive: true, force: true }) + } } } -const histfileOf = (output: string): string => - /^RESULT=(.*)$/m.exec(output)?.[1]?.trim() ?? '' +describe.skipIf(process.platform === 'win32')( + 'worktree-scoped HISTFILE survives zsh startup', + () => { + itWithZsh( + 'wraps a plain pane once Orca injected a worktree HISTFILE', + withHome(USER_FILES, async (home) => { + const { features, launch } = launchPane(home, join(home, 'zsh_history')) -describe('worktree-scoped HISTFILE survives zsh startup', () => { - itWithZsh('wraps a plain pane once Orca injected a worktree HISTFILE', () => { - withTempHome((home) => { - const { features, launch } = launchPlainHistoryPane(home, join(home, 'zsh_history')) + // The whole reason wrapping widened: this pane has no overlay env and no + // startup command, so before #15258 nothing pointed it at a wrapper. + expect(features).toEqual(['history']) + expect(launch.env.ZDOTDIR).toBeTruthy() + expect(launch.env.ORCA_SHELL_FEATURES).toBe('history') + }) + ) - // The whole bug: this pane has no overlay env and no startup command, so - // before this change nothing pointed it at a wrapper at all. - expect(features).toEqual(['history']) - expect(launch.env.ZDOTDIR).toBeTruthy() - expect(launch.env.ORCA_SHELL_FEATURES).toBe('history') - }) - }) + itWithClobber( + 'keeps the injected path that the system zshrc would otherwise clobber', + withHome(USER_FILES, async (home) => { + const scoped = join(home, 'orca-history', 'zsh_history') + const { env } = launchPane(home, scoped) - itWithClobber('keeps the injected path that the system zshrc would otherwise clobber', () => { - withTempHome((home) => { - const scoped = join(home, 'orca-history', 'zsh_history') - const { launch, env } = launchPlainHistoryPane(home, scoped) + const { values } = await runZshPty({ env, report: ['HISTFILE'] }) - const output = runZsh(launch.args ?? ['-l'], env) + expect(values.HISTFILE).toBe(scoped) + }) + ) - expect(histfileOf(output)).toBe(scoped) - }) - }) + itWithZsh( + 'hands ZDOTDIR back before the user’s own startup files load', + withHome(USER_FILES, async (home) => { + const { env, launch } = launchPane(home, join(home, 'orca-history', 'zsh_history')) - itWithClobber('never leaves history inside Orca’s own wrapper directory', () => { - withTempHome((home) => { - const scoped = join(home, 'orca-history', 'zsh_history') - const { launch, env } = launchPlainHistoryPane(home, scoped) - - const output = runZsh(launch.args ?? ['-l'], env) - - // The exact failure mode of #11044: history written into shell-ready/zsh. - expect(output).not.toContain(launch.env.ZDOTDIR) - }) - }) - - itWithClobber( - 'repairs the clobber for a shell that re-enters the wrapper with no features', - () => { - // Why: a non-interactive zsh runs .zshenv (which exports Orca's wrapper - // ZDOTDIR) but never the epilogue that restores it, so an interactive zsh - // started from there re-enters the wrapper with the feature channel already - // consumed. /etc/zshrc still lands HISTFILE inside the wrapper dir — #11044 - // with no per-worktree scoping involved — so the repair cannot be gated on - // a feature that no longer exists by then. - withTempHome((home) => { - const { launch } = launchPlainHistoryPane(home, join(home, 'orca-history', 'zsh_history')) - - const output = runZsh(launch.args ?? ['-l'], { - HOME: home, - ZDOTDIR: launch.env.ZDOTDIR, - ORCA_ORIG_ZDOTDIR: home + const { values } = await runZshPty({ + env, + report: [ + 'ZDOTDIR', + 'ORCA_TEST_USER_ZSHENV', + 'ORCA_TEST_USER_ZPROFILE', + 'ORCA_TEST_USER_ZSHRC' + ] }) - expect(histfileOf(output)).toBe(join(home, '.zsh_history')) + // Each user file is read from the user's own dir, exactly as unwrapped. + expect(values.ORCA_TEST_USER_ZSHENV).toBe('1') + expect(values.ORCA_TEST_USER_ZPROFILE).toBe('1') + expect(values.ORCA_TEST_USER_ZSHRC).toBe('1') + expect(values.ZDOTDIR).toBe(home) + expect(values.ZDOTDIR).not.toBe(launch.env.ZDOTDIR) }) - } - ) + ) - itWithZsh('runs the epilogue for a login shell whose .zshrc ends in `emulate sh`', () => { - // Why: zsh's sourcehome() ignores ZDOTDIR once the shell is in sh/ksh - // emulation, so such a login shell reads $HOME/.zlogin instead of the - // wrapper's. Everything the epilogue owns — the HISTFILE repair, the OSC 133 - // hooks, the readiness widget every startup command waits on — would be - // skipped entirely. - withTempHome((home) => { - const scoped = join(home, 'orca-history', 'zsh_history') - const env: Record = { - HOME: home, - HISTFILE: scoped, - ORCA_HISTFILE: scoped - } - const launch = getShellLaunchConfig( - ZSH_PATH, - selectShellStartupFeatures({ - shellPath: ZSH_PATH, + itWithZsh( + 'never leaves history inside Orca’s own wrapper directory', + withHome(USER_FILES, async (home) => { + const { env, launch } = launchPane(home, join(home, 'orca-history', 'zsh_history')) + + const { values } = await runZshPty({ env, report: ['HISTFILE'] }) + + // #11044's exact failure mode. It is now unreachable rather than repaired: + // /etc/zshrc derives HISTFILE from a ZDOTDIR that is already the user's. + expect(values.HISTFILE).not.toContain(launch.env.ZDOTDIR) + }) + ) + + itWithZsh( + 'consumes ORCA_HISTFILE so nothing the shell spawns can inherit it', + withHome(USER_FILES, async (home) => { + const scoped = join(home, 'orca-history', 'zsh_history') + const { env } = launchPane(home, scoped) + + const { values } = await runZshPty({ env, - hasStartupCommand: true, - waitsForShellReady: true, - // Why off: the identity marker is printed with no trailing newline, so - // it would prefix the first probe line and defeat the parser. + report: ['ORCA_HISTFILE', 'ORCA_SHELL_FEATURES', 'HISTFILE'] + }) + + // Root-cause fix for #11146: the variables no longer exist after use. + expect(values.ORCA_HISTFILE).toBe('UNSET') + expect(values.ORCA_SHELL_FEATURES).toBe('UNSET') + expect(values.HISTFILE).toBe(scoped) + }) + ) + + itWithZsh( + 'leaves HISTFILE exactly as an unwrapped zsh would when Orca injects nothing', + withHome(USER_FILES, async (home) => { + // Why compared against an unwrapped run rather than asserted non-empty: + // what zsh defaults to is platform-specific. macOS /etc/zshrc assigns + // HISTFILE, so it is always set there; a stock Ubuntu zsh leaves it EMPTY. + // The contract is that Orca's wrapper does not change it either way. + const overlayEnv = { ORCA_CODEX_HOME: join(home, 'codex') } + const features = selectShellStartupFeatures({ + shellPath: ZSH_PATH, + env: { HOME: home, ...overlayEnv }, + hasStartupCommand: false, + waitsForShellReady: false, emitsStartupIdentity: false }) - ) - writeFileSync(join(home, '.zshrc'), 'emulate sh\n') + const launch = getShellLaunchConfig(ZSH_PATH, features) - const output = runZsh( - launch.args ?? ['-l'], - { ...env, ...launch.env, ...sandboxConfigDir(home) }, - 'echo "RESULT=$HISTFILE"; echo "WIDGET=[${widgets[zle-line-init]:-none}]"' - ) + const wrapped = await runZshPty({ + env: { + PATH: '/usr/bin:/bin', + HOME: home, + ...overlayEnv, + ...launch.env, + ORCA_ORIG_ZDOTDIR: home + }, + report: ['HISTFILE'] + }) + const unwrapped = await runZshPty({ + env: { PATH: '/usr/bin:/bin', HOME: home }, + report: ['HISTFILE'] + }) - expect(histfileOf(output)).toBe(scoped) - expect(output).toContain('WIDGET=[user:__orca_prompt_mark]') - }) - }) + expect(wrapped.values.HISTFILE).toBe(unwrapped.values.HISTFILE) + }) + ) + } +) - itWithZsh('consumes ORCA_HISTFILE so nothing the shell spawns can inherit it', () => { - withTempHome((home) => { +describe.skipIf(process.platform === 'win32')('the deferred hook delivers every feature', () => { + itWithZsh( + 'emits the identity, readiness and OSC 133 markers a startup command waits on', + withHome(USER_FILES, async (home) => { const scoped = join(home, 'orca-history', 'zsh_history') - const { launch, env } = launchPlainHistoryPane(home, scoped) + const { env, features } = launchPane(home, scoped, { + hasStartupCommand: true, + waitsForShellReady: true, + emitsStartupIdentity: true + }) + expect(features).toEqual(expect.arrayContaining(['markers', 'ready', 'identity'])) - const output = execFileSync( - ZSH_PATH, - [...(launch.args ?? ['-l']), '-i', '-c', 'echo "LEAK=[${ORCA_HISTFILE:-}]"'], - { - encoding: 'utf8', - timeout: 20_000, - env: { PATH: '/usr/bin:/bin', ...env } + const { output, values } = await runZshPty({ + env, + commands: ['true'], + report: ['HISTFILE'] + }) + + expect(output).toMatch(MARKERS.identity) + expect(output).toContain(MARKERS.ready) + expect(output).toContain(MARKERS.promptStart) + expect(output).toContain(MARKERS.commandStart) + expect(output).toMatch(MARKERS.commandDone) + expect(values.HISTFILE).toBe(scoped) + }) + ) + + itWithZsh( + 'restores Orca’s overlay values after the user’s config overwrites them', + withHome( + { + ...USER_FILES, + '.zshrc': 'export CODEX_HOME=/user/codex\nexport PATH=/user/bin:$PATH\n' + }, + async (home) => { + const overlayEnv = { + ORCA_CODEX_HOME: '/orca/codex', + ORCA_AGENT_TEAMS_SHIM_DIR: '/orca/shim' } - ) + const features = selectShellStartupFeatures({ + shellPath: ZSH_PATH, + env: { HOME: home, ...overlayEnv }, + hasStartupCommand: false, + waitsForShellReady: false, + emitsStartupIdentity: false + }) + const launch = getShellLaunchConfig(ZSH_PATH, features) - // Root-cause fix for #11146: the variable no longer exists after use. - expect(output).toContain('LEAK=[]') - }) - }) + const { values } = await runZshPty({ + env: { + PATH: '/usr/bin:/bin', + HOME: home, + ...overlayEnv, + ...launch.env, + ORCA_ORIG_ZDOTDIR: home + }, + report: ['CODEX_HOME', 'PATH'] + }) - itWithZsh('leaves HISTFILE exactly as an unwrapped zsh would when Orca injects nothing', () => { - // Why compared against an unwrapped run rather than asserted non-empty: what - // zsh defaults to is platform-specific. macOS `/etc/zshrc` assigns HISTFILE, - // so it is always set there; a stock Ubuntu zsh has no such file and leaves - // it EMPTY. The contract is that Orca's wrapper does not change it either - // way, which is the same assertion on both. - withTempHome((home) => { - const features = selectShellStartupFeatures({ - shellPath: ZSH_PATH, - env: { HOME: home, ORCA_CODEX_HOME: join(home, 'codex') }, - hasStartupCommand: false, - waitsForShellReady: false, - emitsStartupIdentity: false - }) - const launch = getShellLaunchConfig(ZSH_PATH, features) - const wrapped = runZsh(launch.args ?? ['-l'], { - HOME: home, - ORCA_CODEX_HOME: join(home, 'codex'), - ...launch.env, - ...sandboxConfigDir(home) - }) - const unwrapped = runZsh(['-l'], { HOME: home }) - - expect(histfileOf(wrapped)).toBe(histfileOf(unwrapped)) - expect(wrapped).not.toContain('ORCA_HISTFILE') - }) - }) + // The point of running last: the user's .zshrc set both of these after + // the spawn env did, and Orca's values still win. + expect(values.CODEX_HOME).toBe('/orca/codex') + expect(values.PATH.startsWith('/orca/shim:')).toBe(true) + } + ) + ) }) -const PROBE = - 'echo "RESULT=$HISTFILE"; print -r -- "ZDOTDIR=[$ZDOTDIR]"; ' + - 'print -r -- "ORCA_HISTFILE=[${ORCA_HISTFILE:-unset}]"' - -describe('the wrapper survives a hostile user zsh config', () => { - // Why both forms: `REPLY` is zsh's shared scratch global, and the two ways a - // user config constrains it fail differently. `typeset -r` makes the - // wrapper's first assignment fatal and prints into the pane; `typeset -i` - // turns every resolved path into 0 with no error text at all. Both aborted - // the wrapper before the epilogue, leaving history inside Orca's own wrapper - // dir — on 100% of zsh panes, not the few percent wrapped before. - it.each(['typeset -r REPLY', 'typeset -i REPLY'])( - 'resolves the user config dir when the user .zshrc runs `%s`', - (declaration) => { +describe.skipIf(process.platform === 'win32')( + 'the wrapper survives a hostile user zsh config', + () => { + /** + * Why these three cases and not the old degrade matrix: zsh's `sourcehome()` + * ignores ZDOTDIR once the shell is in sh/ksh emulation, which used to hide + * every wrapper file after the one that entered it — so emulation from + * `.zshenv` or `.zprofile` cost the pane all of Orca's features. Only one + * wrapper file is read now, and it is read before any user file can change + * modes, so these are wins rather than degradations. + */ + it.each([ + ['.zshenv', 'emulate sh'], + ['.zshenv', 'emulate ksh'], + ['.zprofile', 'emulate sh'], + ['.zshrc', 'emulate sh'], + ['.zshrc', 'setopt no_unset'], + ['.zshrc', 'setopt ksharrays'] + ])('still scopes history when the user %s runs `%s`', async (file, statement) => { if (!hasZsh) { return } - withTempHome((home) => { - const scoped = join(home, 'orca-history', 'zsh_history') - const { launch, env } = launchPlainHistoryPane(home, scoped) - writeFileSync(join(home, '.zshrc'), `${declaration}\n`) - - const output = runZshCapturingStderr(launch.args ?? ['-l'], env, PROBE) - - expect(histfileOf(output)).toBe(scoped) - expect(output).toContain(`ZDOTDIR=[${home}]`) - expect(output).toContain('ORCA_HISTFILE=[unset]') - expect(output).not.toContain('__orca_resolve_user_config_dir:') + const home = makeZshHome({ + ...USER_FILES, + [file]: `${USER_FILES[file as keyof typeof USER_FILES]}${statement}\n` }) - } - ) + try { + const scoped = join(home, 'orca-history', 'zsh_history') + const { env } = launchPane(home, scoped) - // Why these two files and not .zshrc: zsh's sourcehome() ignores ZDOTDIR once - // the shell is in sh/ksh emulation, so emulation entered from .zshenv or - // .zprofile hides EVERY later wrapper file — the epilogue runs zero times and - // can repair nothing. A .zshrc that does it is already covered by running the - // epilogue from the wrapper .zshrc, which /etc/zshrc has run before. - it.each([ - ['.zshenv', 'emulate sh'], - ['.zshenv', 'emulate ksh'], - ['.zprofile', 'emulate sh'] - ])('degrades to an unwrapped pane when the user %s runs `%s`', (file, statement) => { - if (!hasZsh) { - return - } - withTempHome((home) => { - const scoped = join(home, 'orca-history', 'zsh_history') - const { launch, env } = launchPlainHistoryPane(home, scoped) - writeFileSync(join(home, file), `${statement}\n`) + const { values } = await runZshPty({ env, report: ['HISTFILE', 'ORCA_HISTFILE'] }) - const wrapped = runZshCapturingStderr(launch.args ?? ['-l'], env, PROBE) - const unwrapped = runZsh(['-l'], { HOME: home, HISTFILE: scoped }) - - // The bar: never worse off than the pane Orca did not wrap. What that is - // differs per host (macOS /etc/zshrc clobbers HISTFILE, stock Ubuntu does - // not), so it is read from an unwrapped run rather than hardcoded. - expect(histfileOf(wrapped)).toBe(histfileOf(unwrapped)) - expect(histfileOf(wrapped)).not.toContain(launch.env.ZDOTDIR) - expect(wrapped).toContain(`ZDOTDIR=[${home}]`) - // Nothing this pane spawns may inherit a history path no wrapper file - // will ever consume. - expect(wrapped).toContain('ORCA_HISTFILE=[unset]') + expect(values.HISTFILE).toBe(scoped) + expect(values.ORCA_HISTFILE).toBe('UNSET') + } finally { + rmSync(home, { recursive: true, force: true }) + } }) - }) -}) + + itWithZsh( + 'degrades to an unwrapped pane, leaking nothing, when a config drops precmd_functions', + withHome({ ...USER_FILES, '.zshrc': 'precmd_functions=()\n' }, async (home) => { + const scoped = join(home, 'orca-history', 'zsh_history') + const { env, launch } = launchPane(home, scoped) + + const report = ['HISTFILE', 'ORCA_HISTFILE', 'ZDOTDIR'] + const { values } = await runZshPty({ env, report }) + // Why compared against an unwrapped run rather than asserted to differ + // from the scoped path: whether the scoped value survives at all is the + // host's call, not Orca's. macOS /etc/zshrc overwrites HISTFILE, so it + // does not; a host with no such assignment keeps whatever the spawn env + // set. The contract on both is the same — this pane is the pane the user + // would have had unwrapped. + const unwrapped = await runZshPty({ + env: { PATH: '/usr/bin:/bin', HOME: home, HISTFILE: scoped }, + report + }) + + expect(values.HISTFILE).toBe(unwrapped.values.HISTFILE) + expect(values.HISTFILE).not.toContain(launch.env.ZDOTDIR) + // ORCA_HISTFILE was consumed in .zshenv precisely so a dropped hook + // leaks nothing to the pane's children. + expect(values.ORCA_HISTFILE).toBe('UNSET') + expect(values.ZDOTDIR).toBe(home) + }) + ) + + itWithZsh( + 'loads the user config even when a startup file writes an unrelated ZDOTDIR', + withHome( + { ...USER_FILES, '.zshenv': `${USER_FILES['.zshenv']}export ZDOTDIR="$HOME"\n` }, + async (home) => { + const { env } = launchPane(home, join(home, 'orca-history', 'zsh_history')) + + const { values } = await runZshPty({ + env, + report: ['ZDOTDIR', 'ORCA_TEST_USER_ZSHRC'] + }) + + // A ZDOTDIR the user's own .zshenv exports is theirs by construction and + // needs no discovery machinery: zsh reads .zshrc through it directly. + expect(values.ZDOTDIR).toBe(home) + expect(values.ORCA_TEST_USER_ZSHRC).toBe('1') + } + ) + ) + } +) /** - * Records every argument-less `emulate` — i.e. exactly the wrapper's emulation - * probe, and not the epilogue's `emulate -L zsh` — into a file the test reads. - * - * Why a shadowing function and not a timing assertion: the cost being removed - * is a fork, and a fork is countable where milliseconds are flaky. Placed in - * the user .zshenv so it is defined before all three probe sites. + * The relay writes its own variant of the hook: no OSC 133 (its bash rcfile owns + * those on remote hosts) and a remote CLI bin dir on PATH instead of the + * agent-teams shim. It used to have a whole second ZDOTDIR shape too, which is + * why it drifted from the desktop template; now the only differences are the + * spec flags, and this pins that the variant still works in a real shell. */ -function probeCounterConfig(logPath: string): string { - return [ - 'emulate() {', - ` (( $# == 0 )) && print -r -- probe >> ${JSON.stringify(logPath)}`, - ' builtin emulate "$@"', - '}' - ].join('\n') -} +describe.skipIf(process.platform === 'win32')('the relay variant of the hook', () => { + itWithZsh( + 'scopes history and restores the remote CLI path without emitting OSC 133', + withHome(USER_FILES, async (home) => { + const relayRoot = mkdtempSync(join(tmpdir(), 'orca-relay-wrapper-')) + try { + expect(ensureOverlayRestoreWrappers(relayRoot)).toBe(true) + const scoped = join(home, 'orca-history', 'zsh_history') -const probeCount = (logPath: string): number => - existsSync(logPath) ? readFileSync(logPath, 'utf8').split('\n').filter(Boolean).length : 0 + const { output, values } = await runZshPty({ + env: { + PATH: '/usr/bin:/bin', + HOME: home, + ZDOTDIR: join(relayRoot, 'zsh'), + ORCA_ORIG_ZDOTDIR: home, + ORCA_SHELL_FEATURES: 'overlay,history,ready', + ORCA_HISTFILE: scoped, + ORCA_REMOTE_CLI_BIN_DIR: '/orca/remote-bin' + }, + commands: ['true'], + report: ['HISTFILE', 'ZDOTDIR', 'PATH', 'ORCA_HISTFILE'] + }) -describe('the emulation probe forks only when it can change the answer', () => { - itWithZsh('never forks for a pane whose config stays in zsh emulation', () => { - withTempHome((home) => { - const scoped = join(home, 'orca-history', 'zsh_history') - const log = join(home, 'probe.log') - const { launch, env } = launchPlainHistoryPane(home, scoped) - // All three probe sites are gated on having sourced the matching user - // file, so every one of them has to exist for this to mean anything. - writeFileSync(join(home, '.zshenv'), probeCounterConfig(log)) - writeFileSync(join(home, '.zprofile'), '') - writeFileSync(join(home, '.zshrc'), '') - - const output = runZshCapturingStderr(launch.args ?? ['-l'], env, PROBE) - - expect(probeCount(log)).toBe(0) - // The pane still has to work: the saving is a fork, not a feature. - expect(histfileOf(output)).toBe(scoped) - expect(output).toContain(`ZDOTDIR=[${home}]`) + expect(values.HISTFILE).toBe(scoped) + expect(values.ZDOTDIR).toBe(home) + expect(values.ORCA_HISTFILE).toBe('UNSET') + expect(values.PATH.startsWith('/orca/remote-bin:')).toBe(true) + expect(output).toContain(MARKERS.ready) + // Remote panes get their command lifecycle from the bash rcfile, so the + // zsh variant must stay silent here. + expect(output).not.toContain(MARKERS.promptStart) + expect(output).not.toContain(MARKERS.commandStart) + } finally { + rmSync(relayRoot, { recursive: true, force: true }) + } }) - }) - - // Why kept exact rather than replaced by the option test: those options say - // nothing about `emulation`, which is what zsh's sourcehome() branches on. - itWithZsh('still forks to get the exact answer once a Bourne option is set', () => { - withTempHome((home) => { - const scoped = join(home, 'orca-history', 'zsh_history') - const log = join(home, 'probe.log') - const { launch, env } = launchPlainHistoryPane(home, scoped) - writeFileSync(join(home, '.zshenv'), `${probeCounterConfig(log)}\nsetopt shwordsplit\n`) - - const output = runZshCapturingStderr(launch.args ?? ['-l'], env, PROBE) - - expect(probeCount(log)).toBeGreaterThan(0) - // shwordsplit alone is not emulation, so the pane stays wrapped. - expect(histfileOf(output)).toBe(scoped) - }) - }) + ) }) diff --git a/src/main/zsh-startup-hook-pty-harness.ts b/src/main/zsh-startup-hook-pty-harness.ts new file mode 100644 index 00000000000..a4de4e3bba9 --- /dev/null +++ b/src/main/zsh-startup-hook-pty-harness.ts @@ -0,0 +1,220 @@ +/** + * Drives a real zsh through a real PTY, to the first prompt and beyond. + * + * Why a PTY and not `zsh -i -c ''`: everything Orca owns now runs from a + * `precmd` hook, and `-c` never reaches a prompt, so `precmd` never fires. A + * probe run that way would report the wrapper doing nothing at all — for the + * right reason, at the wrong question. These tests have to reach a prompt to + * mean anything. + * + * Results come back through a file rather than stdout because a PTY echoes the + * command being typed, so matching on stdout matches the echo of the probe as + * readily as its output. + */ +import { spawnSync } from 'node:child_process' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import * as pty from 'node-pty' + +export const hasZsh = process.platform !== 'win32' && spawnSync('zsh', ['--version']).status === 0 + +export const ZSH_PATH = hasZsh + ? (spawnSync('sh', ['-c', 'command -v zsh'], { encoding: 'utf8' }).stdout || '').trim() + : '' + +/** OSC sequences the wrapper emits, as the terminal would receive them. */ +export const MARKERS = { + identity: /\]777;orca-shell-start:\d+/, + ready: ']777;orca-shell-ready', + promptStart: ']133;A', + commandStart: ']133;C', + commandDone: /\]133;D;\d+/ +} as const + +export type ZshPtyRun = { + /** Everything the terminal received, escape sequences intact. */ + output: string + /** `name=` pairs the probe wrote, parsed. */ + values: Record + /** + * True when the shell exited before reaching a prompt — what a user `.zshenv` + * that calls `exit` produces. A real outcome to compare, not a failure. + */ + exitedBeforePrompt: boolean +} + +export type ZshPtyOptions = { + env: Record + /** Shell variables to report, e.g. `['HISTFILE', 'ZDOTDIR']`. */ + report?: readonly string[] + /** Commands to run at the prompt before reporting. */ + commands?: readonly string[] + cwd?: string + timeoutMs?: number +} + +/** + * Why the value syntax is `NAME=<...>`: an unset variable and an empty one must + * be distinguishable, and the delimiters survive a terminal's line wrapping. + * + * Why the trailing `;` before `}`: a config that leaves the shell in sh + * emulation needs it, and without it zsh sits in a `cursh>` continuation + * waiting for the group to close — which reads exactly like a hung wrapper. + */ +function buildProbe(report: readonly string[], resultPath: string): string { + const prints = report.map((name) => `print -r -- "${name}=<\${${name}:-UNSET}>";`).join(' ') + return `{ ${prints} } > ${JSON.stringify(resultPath)}` +} + +function parseValues(resultPath: string): Record { + if (!existsSync(resultPath)) { + return {} + } + const values: Record = {} + for (const line of readFileSync(resultPath, 'utf8').split('\n')) { + const match = /^(\w+)=<(.*)>$/.exec(line.trim()) + if (match) { + values[match[1]] = match[2] + } + } + return values +} + +/** + * Runs an interactive login zsh under a PTY, waits for its first prompt, runs + * the requested commands, and reports the requested shell variables. + * + * Readiness is detected by a sentinel baked into PS1 rather than a fixed sleep, + * so a slow prompt framework makes the run slower, never flaky. + */ +export async function runZshPty(options: ZshPtyOptions): Promise { + const sentinel = '@@ORCA-PTY-READY@@' + const workDir = mkdtempSync(join(tmpdir(), 'orca-zsh-pty-')) + const resultPath = join(workDir, 'probe.txt') + const timeoutMs = options.timeoutMs ?? 20_000 + + const proc = pty.spawn(ZSH_PATH, ['-l', '-i'], { + name: 'xterm-256color', + cols: 200, + rows: 40, + cwd: options.cwd ?? workDir, + env: { + ...options.env, + // Why the sentinel is env-borne: the prompt is overwritten from the PTY + // below, and it has to survive whatever prompt the user's config installs. + ORCA_PTY_SENTINEL: sentinel + } + }) + + let output = '' + let answeredCompinit = false + let lastDataAt = Date.now() + let resolveReady: (() => void) | undefined + const ready = new Promise((resolve) => { + resolveReady = resolve + }) + proc.onData((data) => { + output += data + lastDataAt = Date.now() + // Why this is answered rather than configured away: a host whose global + // zshrc runs `compinit` over directories it considers insecure — CI runners + // do — stops startup and ASKS, and a real PTY will sit at that question + // until the timeout. A pipe-backed `zsh -i -c` never saw it. ZSH_DISABLE_COMPFIX + // does not help: that is an oh-my-zsh convention, and plain `compinit` (which + // is what asks) ignores it. Answering keeps the shell on the path a user + // pressing `y` would take, and both arms of a comparison get the same + // treatment, so it cannot tilt one against the other. + if (!answeredCompinit && output.includes('Ignore insecure directories')) { + answeredCompinit = true + proc.write('y\r') + } + if (resolveReady && output.includes(sentinel)) { + resolveReady() + resolveReady = undefined + } + }) + let hasExited = false + const exited = new Promise((resolve) => { + proc.onExit(() => { + hasExited = true + resolve() + }) + }) + + let timer: ReturnType | null = null + const timedOut = new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new Error(`timed out waiting for the zsh prompt:\n${output}`)), + timeoutMs + ) + }) + + /** + * Resolves once the shell has produced nothing for `quietMs`, or exited. + * + * Why quiescence and not a fixed sleep before typing: startup output has to + * finish before the PS1 line is typed, or a shell still asking a question + * (compinit, above) eats it as the answer. A fast host waits milliseconds; a + * slow prompt framework waits as long as it needs. + */ + async function waitForQuiet(quietMs: number): Promise { + while (!hasExited) { + const idleFor = Date.now() - lastDataAt + if (idleFor >= quietMs) { + return + } + await new Promise((resolve) => setTimeout(resolve, quietMs - idleFor)) + } + } + + try { + await Promise.race([waitForQuiet(250), exited, timedOut]) + if (hasExited) { + return { output, values: parseValues(resultPath), exitedBeforePrompt: true } + } + // Why the prompt is replaced rather than parsed: it only has to carry the + // sentinel from the SECOND prompt on — the first is where the deferred hook + // does its work, and that has already happened by now. + // + // Why PS1 and not PROMPT: a config that leaves the shell in sh emulation + // renders PS1, where PROMPT is just an ordinary variable. In zsh's own mode + // the two name the same parameter, so PS1 covers both. + proc.write(`PS1="$ORCA_PTY_SENTINEL"\r`) + // Why `exited` is raced here too: a user .zshenv that calls `exit` never + // reaches a prompt, and that is an outcome worth comparing rather than a + // twenty-second timeout. + await Promise.race([ready, exited, timedOut]) + if (hasExited) { + return { output, values: parseValues(resultPath), exitedBeforePrompt: true } + } + for (const command of options.commands ?? []) { + proc.write(`${command}\r`) + } + if (options.report?.length) { + proc.write(`${buildProbe(options.report, resultPath)}\r`) + } + proc.write('exit\r') + await Promise.race([exited, timedOut]) + return { output, values: parseValues(resultPath), exitedBeforePrompt: false } + } finally { + if (timer !== null) { + clearTimeout(timer) + } + try { + proc.kill() + } catch { + // Already exited normally. + } + rmSync(workDir, { recursive: true, force: true }) + } +} + +/** Writes a throwaway $HOME with the given zsh startup files. */ +export function makeZshHome(files: Record): string { + const home = mkdtempSync(join(tmpdir(), 'orca-zsh-home-')) + for (const [name, content] of Object.entries(files)) { + writeFileSync(join(home, name), content) + } + return home +} diff --git a/src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts b/src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts new file mode 100644 index 00000000000..e5e45d2c22a --- /dev/null +++ b/src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts @@ -0,0 +1,492 @@ +/** + * Real-zsh proof that a wrapped pane resolves the user's zsh config to exactly + * what an unwrapped pane would, for every odd or hostile `.zshenv` shape. + * + * These cases were previously asserted one expected value at a time against + * `ORCA_ORIG_ZDOTDIR` — the output of Orca's own shell-side ZDOTDIR discovery. + * That discovery is gone: the wrapper hands ZDOTDIR back on its first lines and + * zsh resolves the rest natively, so there is no Orca-computed value left to + * assert on. The contract those tests were really protecting is the one below, + * and stated as an equivalence it is stricter — it pins the wrapped pane to + * whatever the host's own zsh does, including on hosts where that differs, + * rather than to a value hardcoded here. + * + * Each case runs twice on a real PTY, once wrapped and once not, and the two + * must agree on where the config came from and what it exported. + */ +import { existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { getShellLaunchConfig } from './providers/local-pty-shell-ready' +import { selectShellStartupFeatures } from './shell-startup-features' +import { ZSH_WRAPPER_DIR_MARKER_FILE } from './shell-templates' +import { hasZsh, makeZshHome, runZshPty, ZSH_PATH } from './zsh-startup-hook-pty-harness' + +const itWithZsh = hasZsh ? it : it.skip + +/** What both arms must agree on: where config came from, and what it exported. */ +const REPORTED = ['ZDOTDIR', 'ORCA_TEST_MARK', 'ORCA_TEST_FROM_ZSHRC', 'PATH'] as const + +/** + * Every case writes `$HOME/.zshenv`. `.zshrc` is written into whichever dir the + * case points ZDOTDIR at, so "did the right .zshrc load" is observable. + */ +type ConfigCase = { + name: string + /** Builds `$HOME/.zshenv` and any extra files. Returns the dir holding .zshrc. */ + setup: (home: string) => string +} + +const CASES: ConfigCase[] = [ + { + name: 'no ZDOTDIR at all', + setup: (home) => { + writeFileSync(join(home, '.zshenv'), 'export ORCA_TEST_MARK=plain\n') + return home + } + }, + { + name: 'ZDOTDIR exported to an XDG dir', + setup: (home) => { + const dir = join(home, '.config', 'zsh') + mkdirSync(dir, { recursive: true }) + writeFileSync(join(home, '.zshenv'), `export ORCA_TEST_MARK=xdg\nexport ZDOTDIR="${dir}"\n`) + return dir + } + }, + { + name: 'ZDOTDIR set by a file the .zshenv sources', + setup: (home) => { + const dir = join(home, '.config', 'zsh') + mkdirSync(dir, { recursive: true }) + const common = join(home, '.config', 'shell', 'common.sh') + mkdirSync(dirname(common), { recursive: true }) + writeFileSync(common, `export ZDOTDIR="${dir}"\n`) + writeFileSync(join(home, '.zshenv'), `export ORCA_TEST_MARK=sourced\nsource "${common}"\n`) + return dir + } + }, + { + name: 'ZDOTDIR with spaces in the path', + setup: (home) => { + const dir = join(home, 'My Config', 'zsh') + mkdirSync(dir, { recursive: true }) + writeFileSync( + join(home, '.zshenv'), + `export ORCA_TEST_MARK=spaces\nexport ZDOTDIR="${dir}"\n` + ) + return dir + } + }, + { + name: 'ZDOTDIR set more than once', + setup: (home) => { + const first = join(home, 'first') + const dir = join(home, 'second') + mkdirSync(first, { recursive: true }) + mkdirSync(dir, { recursive: true }) + writeFileSync( + join(home, '.zshenv'), + `export ORCA_TEST_MARK=twice\nexport ZDOTDIR="${first}"\nexport ZDOTDIR="${dir}"\n` + ) + return dir + } + }, + { + name: 'ZDOTDIR written with a trailing slash', + setup: (home) => { + const dir = join(home, 'trailing') + mkdirSync(dir, { recursive: true }) + writeFileSync( + join(home, '.zshenv'), + `export ORCA_TEST_MARK=trailing\nexport ZDOTDIR="${dir}/"\n` + ) + return dir + } + }, + { + name: 'ZDOTDIR pointing at a directory that does not exist', + setup: (home) => { + writeFileSync( + join(home, '.zshenv'), + `export ORCA_TEST_MARK=missing\nexport ZDOTDIR="${join(home, 'nope')}"\n` + ) + return home + } + }, + { + name: 'ZDOTDIR set to the empty string', + setup: (home) => { + writeFileSync(join(home, '.zshenv'), 'export ORCA_TEST_MARK=empty\nexport ZDOTDIR=""\n') + return home + } + }, + { + name: 'ZDOTDIR explicitly set to $HOME', + setup: (home) => { + writeFileSync(join(home, '.zshenv'), 'export ORCA_TEST_MARK=home\nexport ZDOTDIR="$HOME"\n') + return home + } + }, + { + name: 'a .zshenv with a syntax error', + setup: (home) => { + writeFileSync(join(home, '.zshenv'), 'export ORCA_TEST_MARK=broken\nif [ ; then\n') + return home + } + }, + { + name: 'a .zshenv running set -u before anything else', + setup: (home) => { + writeFileSync(join(home, '.zshenv'), 'set -u\nexport ORCA_TEST_MARK=nounset\n') + return home + } + }, + { + name: 'a .zshenv running set -e with a failing command', + setup: (home) => { + writeFileSync(join(home, '.zshenv'), 'set -e\nexport ORCA_TEST_MARK=errexit\nfalse\n') + return home + } + }, + { + name: 'a .zshenv setting extendedglob and nullglob', + setup: (home) => { + writeFileSync( + join(home, '.zshenv'), + 'setopt extendedglob nullglob\nexport ORCA_TEST_MARK=globs\n' + ) + return home + } + }, + { + name: 'a .zshenv that unsets HOME', + setup: (home) => { + writeFileSync(join(home, '.zshenv'), 'export ORCA_TEST_MARK=nohome\nunset HOME\n') + return home + } + }, + { + name: 'ZDOTDIR containing only slashes', + setup: (home) => { + writeFileSync(join(home, '.zshenv'), 'export ORCA_TEST_MARK=slashes\nexport ZDOTDIR="///"\n') + return home + } + }, + { + name: 'a whitespace-only ZDOTDIR', + setup: (home) => { + writeFileSync( + join(home, '.zshenv'), + 'export ORCA_TEST_MARK=blank\nexport ZDOTDIR="$(printf \'\\t\\n\')"\n' + ) + return home + } + }, + { + name: 'ZDOTDIR with a single quote in the path', + setup: (home) => { + const dir = join(home, "it's zsh") + mkdirSync(dir, { recursive: true }) + writeFileSync( + join(home, '.zshenv'), + `export ORCA_TEST_MARK=quote\nexport ZDOTDIR=${JSON.stringify(dir)}\n` + ) + return dir + } + }, + { + name: 'a .zshenv that conditionally unsets ZDOTDIR', + setup: (home) => { + writeFileSync( + join(home, '.zshenv'), + 'export ORCA_TEST_MARK=conditional\nexport ZDOTDIR="$HOME/x"\nunset ZDOTDIR\n' + ) + return home + } + }, + { + name: 'a .zshenv using typeset -U path at top level', + setup: (home) => { + // Why this one matters: `path` is a top-level-only construct, so it also + // proves the user's .zshenv is sourced in the wrapper's own scope rather + // than inside a function or subshell. + writeFileSync( + join(home, '.zshenv'), + 'typeset -U path\npath=(/usr/bin /bin /usr/bin)\nexport ORCA_TEST_MARK=uniqpath\n' + ) + return home + } + }, + { + name: 'a .zshenv defining a function and extending fpath', + setup: (home) => { + const fns = join(home, 'fns') + mkdirSync(fns, { recursive: true }) + writeFileSync( + join(home, '.zshenv'), + `fpath=(${JSON.stringify(fns)} $fpath)\norca_test_fn() { : }\nexport ORCA_TEST_MARK=fnscope\n` + ) + return home + } + }, + { + name: 'a .zshenv that calls exit', + setup: (home) => { + writeFileSync(join(home, '.zshenv'), 'export ORCA_TEST_MARK=exiting\nexit 0\n') + return home + } + } +] + +function wrappedEnv(home: string): Record { + const features = selectShellStartupFeatures({ + shellPath: ZSH_PATH, + env: { HOME: home, ORCA_HISTFILE: join(home, 'scoped_history') }, + hasStartupCommand: false, + waitsForShellReady: false, + emitsStartupIdentity: false + }) + const launch = getShellLaunchConfig(ZSH_PATH, features) + // Why ORCA_ORIG_ZDOTDIR is dropped rather than pinned to the sandbox home: + // these cases are about a user who has no inherited ZDOTDIR, so the pane must + // resolve purely from HOME — and Orca must not invent a ZDOTDIR for it. The + // launch config computes this one from the real process env, which would + // otherwise leak the developer's own ZDOTDIR into the run. + const { ORCA_ORIG_ZDOTDIR: _dropped, ...env } = launch.env + return { + PATH: '/usr/bin:/bin', + HOME: home, + ORCA_HISTFILE: join(home, 'scoped_history'), + ...env + } +} + +describe.skipIf(process.platform === 'win32')( + 'a wrapped pane resolves the user config exactly as an unwrapped one does', + () => { + it.each(CASES.map((testCase) => [testCase.name, testCase] as const))( + 'matches unwrapped zsh for %s', + async (_name, testCase) => { + if (!hasZsh) { + return + } + const home = makeZshHome({}) + try { + const zshrcDir = testCase.setup(home) + mkdirSync(zshrcDir, { recursive: true }) + writeFileSync(join(zshrcDir, '.zshrc'), 'export ORCA_TEST_FROM_ZSHRC=1\n') + + const wrapped = await runZshPty({ env: wrappedEnv(home), report: REPORTED }) + const unwrapped = await runZshPty({ + env: { PATH: '/usr/bin:/bin', HOME: home }, + report: REPORTED + }) + + expect(wrapped.exitedBeforePrompt).toBe(unwrapped.exitedBeforePrompt) + for (const key of REPORTED) { + expect( + wrapped.values[key], + `${key} differs between a wrapped and an unwrapped pane` + ).toBe(unwrapped.values[key]) + } + } finally { + rmSync(home, { recursive: true, force: true }) + } + } + ) + } +) + +/** + * Regressions the four-file wrapper was built to fix, re-pinned against the one + * that replaced it. Each names the change that introduced the behaviour, because + * "the machinery is gone" is only a good answer if the reason it existed is gone + * with it. + */ +describe.skipIf(process.platform === 'win32')('the fixes the old wrapper was built for', () => { + let userDataPath = '' + let previousUserDataPath: string | undefined + + beforeAll(() => { + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + userDataPath = mkdtempSync(join(tmpdir(), 'orca-hook-regression-')) + process.env.ORCA_USER_DATA_PATH = userDataPath + }) + + afterAll(() => { + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + rmSync(userDataPath, { recursive: true, force: true }) + }) + + /** + * Generates the tree, then points ZDOTDIR at it under a different root. + * + * Why the full spawn env and not a bare ZDOTDIR: "the user's .zshrc loaded" is + * equally true of a pane that never read the wrapper at all, so the run has to + * be able to show the wrapper ran. ORCA_SHELL_FEATURES coming back consumed is + * that proof — only the wrapper's own .zshenv unsets it. + */ + async function runFromRelocatedRoot(home: string, movedRoot: string) { + const env = wrappedEnv(home) + const relocated = env.ZDOTDIR.replace(userDataPath, movedRoot) + expect(relocated, 'the relocated ZDOTDIR should differ from the generated one').not.toBe( + env.ZDOTDIR + ) + renameSync(userDataPath, movedRoot) + try { + return await runZshPty({ + env: { ...env, ZDOTDIR: relocated }, + report: ['ORCA_TEST_FROM_ZSHRC', 'ORCA_SHELL_FEATURES', 'HISTFILE'] + }) + } finally { + if (existsSync(movedRoot)) { + renameSync(movedRoot, userDataPath) + } + } + } + + itWithZsh( + 'loads the user .zshrc when the wrapper is sourced from a relocated path (#8003)', + async () => { + // Why relocation: on Windows+WSL the wrappers are generated with a Windows + // path but sourced via /mnt/c, so the generation-time path is absent at + // runtime. The old wrapper baked that path in as a ZDOTDIR fallback and had + // to re-derive the real one from `%x` to avoid using it; this one bakes no + // path, so the split cannot arise. Renaming the root reproduces it. + const home = makeZshHome({ '.zshrc': 'export ORCA_TEST_FROM_ZSHRC=1\n' }) + try { + const { values } = await runFromRelocatedRoot(home, `${userDataPath}-wsl-view`) + + expect(values.ORCA_TEST_FROM_ZSHRC).toBe('1') + // The wrapper really was read from the relocated path. + expect(values.ORCA_SHELL_FEATURES).toBe('UNSET') + expect(values.HISTFILE).toBe(join(home, 'scoped_history')) + } finally { + rmSync(home, { recursive: true, force: true }) + } + } + ) + + itWithZsh('loads the user .zshrc from a non-ASCII wrapper path (#8003)', async () => { + // Why non-ASCII: a Korean Windows login puts UTF-8 bytes in zsh's 0x84-0x9D + // token range, and zsh corrupts environment values containing them while + // processing startup files. That corrupted the env-imported $ZDOTDIR, the + // wrapper's self-check failed, and it fell back to the unusable baked path — + // a bare prompt with none of the user's config. Nothing is baked now, and a + // value this wrapper cannot use degrades to $HOME, where zsh itself looks. + const home = makeZshHome({ '.zshrc': 'export ORCA_TEST_FROM_ZSHRC=1\n' }) + try { + const { values } = await runFromRelocatedRoot( + home, + join(dirname(userDataPath), '홍길동-wsl-view') + ) + + expect(values.ORCA_TEST_FROM_ZSHRC).toBe('1') + expect(values.ORCA_SHELL_FEATURES).toBe('UNSET') + expect(values.HISTFILE).toBe(join(home, 'scoped_history')) + } finally { + rmSync(home, { recursive: true, force: true }) + } + }) + + itWithZsh('gives the user’s startup files their own ZDOTDIR while they run (#4667)', async () => { + // Why it mattered: user startup files resolve plugin and theme paths from + // $ZDOTDIR, so sourcing them with Orca's dir in place sent those lookups into + // the wrapper. The old wrapper swapped ZDOTDIR around each source; this one + // never takes it away, so each file sees what it would see unwrapped. + const home = makeZshHome({}) + const xdg = join(home, '.config', 'zsh') + mkdirSync(xdg, { recursive: true }) + writeFileSync(join(home, '.zshenv'), `export ZDOTDIR=${JSON.stringify(xdg)}\n`) + writeFileSync(join(xdg, '.zshrc'), 'export ORCA_TEST_IN_ZSHRC="$ZDOTDIR"\n') + writeFileSync(join(xdg, '.zprofile'), 'export ORCA_TEST_IN_ZPROFILE="$ZDOTDIR"\n') + try { + const report = ['ORCA_TEST_IN_ZSHRC', 'ORCA_TEST_IN_ZPROFILE'] + const wrapped = await runZshPty({ env: wrappedEnv(home), report }) + const unwrapped = await runZshPty({ env: { PATH: '/usr/bin:/bin', HOME: home }, report }) + + expect(wrapped.values.ORCA_TEST_IN_ZSHRC).toBe(xdg) + expect(wrapped.values.ORCA_TEST_IN_ZPROFILE).toBe(xdg) + expect(wrapped.values).toEqual(unwrapped.values) + } finally { + rmSync(home, { recursive: true, force: true }) + } + }) + + itWithZsh('refuses an inherited ZDOTDIR that is an Orca wrapper dir (#15258)', async () => { + // Why the shell checks this and not only Node: the launch config sets + // ORCA_ORIG_ZDOTDIR when it resolved a usable dir, but a pane also inherits + // its parent's environment, so a stale value written by an older build can + // arrive on its own — a route the Node-side check never sees. Handing that + // back would point ZDOTDIR at a wrapper dir, which is the self-loop the + // ownership check exists to prevent. Identification stays positive: a stamped + // marker file, or Orca's own path shape for wrappers older builds wrote. + const home = makeZshHome({ '.zshrc': 'export ORCA_TEST_FROM_ZSHRC=1\n' }) + const foreign = join(home, 'other-terminal', 'zsh') + mkdirSync(foreign, { recursive: true }) + writeFileSync(join(foreign, '.zshrc'), 'export ORCA_TEST_FROM_WRAPPER_DIR=1\n') + writeFileSync(join(foreign, ZSH_WRAPPER_DIR_MARKER_FILE), '') + try { + const { values } = await runZshPty({ + env: { ...wrappedEnv(home), ORCA_ORIG_ZDOTDIR: foreign }, + report: ['ZDOTDIR', 'ORCA_TEST_FROM_ZSHRC', 'ORCA_TEST_FROM_WRAPPER_DIR'] + }) + + // Rejected, so zsh falls back to $HOME and the user's own config loads. + expect(values.ZDOTDIR).toBe('UNSET') + expect(values.ORCA_TEST_FROM_ZSHRC).toBe('1') + expect(values.ORCA_TEST_FROM_WRAPPER_DIR).toBe('UNSET') + } finally { + rmSync(home, { recursive: true, force: true }) + } + }) + + itWithZsh('leaves a nested Orca nothing of its own to inherit (#11044, #11146)', async () => { + // Why this closes #11044's plain shape rather than repairing it: that bug was + // a nested zsh inheriting Orca's ZDOTDIR, so /etc/zshrc derived HISTFILE + // inside the wrapper dir. A pane can no longer hand any child a ZDOTDIR that + // is Orca's, because it does not have one itself past the first few lines. + const home = makeZshHome({ '.zshrc': 'export ORCA_TEST_FROM_ZSHRC=1\n' }) + try { + const { values } = await runZshPty({ + env: wrappedEnv(home), + commands: [ + 'ORCA_CHILD_ENV="$(env | grep -cE \'^(ORCA_SHELL_FEATURES|ORCA_HISTFILE)=\' || true)"', + 'ORCA_CHILD_ZDOTDIR="$(env | sed -n \'s/^ZDOTDIR=//p\')"' + ], + report: ['ORCA_CHILD_ENV', 'ORCA_CHILD_ZDOTDIR'] + }) + + // Neither channel survives into a child, and no ZDOTDIR of Orca's does. + // `UNSET` here is the probe's rendering of an empty capture, i.e. `env` + // printed no ZDOTDIR line at all. + expect(values.ORCA_CHILD_ENV).toBe('0') + expect(values.ORCA_CHILD_ZDOTDIR).toBe('UNSET') + } finally { + rmSync(home, { recursive: true, force: true }) + } + }) + + itWithZsh('survives a user .zshenv that returns early (#1947)', async () => { + const home = makeZshHome({ + '.zshenv': 'export ORCA_TEST_MARK=early\nreturn 0\nexport ORCA_TEST_MARK=late\n', + '.zshrc': 'export ORCA_TEST_FROM_ZSHRC=1\n' + }) + try { + const report = ['ORCA_TEST_MARK', 'ORCA_TEST_FROM_ZSHRC', 'ZDOTDIR'] + const wrapped = await runZshPty({ env: wrappedEnv(home), report }) + const unwrapped = await runZshPty({ env: { PATH: '/usr/bin:/bin', HOME: home }, report }) + + expect(wrapped.values).toEqual(unwrapped.values) + expect(wrapped.values.ORCA_TEST_FROM_ZSHRC).toBe('1') + } finally { + rmSync(home, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/zsh-startup-wrapper-builder.ts b/src/main/zsh-startup-wrapper-builder.ts index a337b186a4d..0a2bb9c8026 100644 --- a/src/main/zsh-startup-wrapper-builder.ts +++ b/src/main/zsh-startup-wrapper-builder.ts @@ -1,41 +1,42 @@ /** - * The single source of the zsh startup wrapper files (.zshenv/.zprofile/.zshrc/ - * .zlogin) Orca writes for every transport: local PTY, daemon/SSH, and relay. + * The single `.zshenv` Orca writes for every transport: local PTY, daemon/SSH, + * and relay. * - * Why: the three generators were copies that drifted apart, so a fix landed in - * one transport and silently missed the other two. Everything they genuinely - * disagree on is a field on ZshStartupWrapperSpec, so the disagreements are - * visible in one place instead of spread across three template literals. + * Orca needs to run code AFTER the user's own zsh startup files. The old shape + * bought that by keeping ZDOTDIR pointed at Orca's wrapper dir for the whole of + * startup and sourcing each user file by hand — four generated files, and a + * fake ZDOTDIR live while `/etc/zshrc` ran. That one decision was the root of a + * whole bug family: `/etc/zshrc` assigns `HISTFILE=${ZDOTDIR:-$HOME}/.zsh_history` + * unconditionally, so history landed inside Orca's own dir (#11044); zsh's + * `sourcehome()` ignores ZDOTDIR once the shell enters sh/ksh emulation, so a + * user file ending in `emulate sh` hid every later wrapper file; and one wrapper + * dir shared by two installed builds could mix files from both. * - * Every generated file redefines the helpers it calls, so the epilogue is the - * only thing one file needs another to have defined — see EPILOGUE_CALL. + * This shape gives ZDOTDIR back before anything else can observe it, then defers + * Orca's work to a `precmd` hook that runs at the first prompt — after + * `.zprofile`, `/etc/zshrc`, `.zshrc` and `.zlogin`, all of which zsh now reads + * from the user's own directory exactly as in an unwrapped shell. #11044 becomes + * unreachable rather than repaired, and the emulation and mixed-build classes + * stop existing. * - * All order-sensitive Orca work lives in ONE `__orca_shell_epilogue` defined in - * .zshenv and invoked exactly once — from .zshrc for a non-login shell, from - * .zlogin for a login shell. Each feature inside it is an independent guard on - * the allowlist snapshotted (and destroyed) at the top of .zshenv, so a pane - * wrapped only for one feature runs only that feature's code — except the - * HISTFILE repair, which undoes damage the wrapper's own ZDOTDIR caused and so - * must also run for a shell that re-entered the wrapper with no allowlist. + * ORDER IS LOAD-BEARING: every function is defined ABOVE the `source` of the + * user's `.zshenv`. A user `.zshenv` ending in `emulate sh` puts the rest of + * this file under sh parsing rules, and zsh-only syntax then fails to parse, + * taking the whole wrapper with it. Function bodies are parsed at definition + * time, so defining them first makes them immune; `emulate -L zsh` inside the + * hook restores zsh option semantics for the body at call time. */ import { getPosixOmpShellWrapper } from './pty/omp-shell-wrapper' import { getPosixCodexShellLaunchPreflight } from './pty/codex-shell-launch-preflight' import { - getZshEnvDiscoveryBody, - getZshEmulationDegradeBlock, - getZshFinalZdotdirRestoreBlock, - getZshOverlayEnvBody, getZshShellReadyMarkerRegistrationBlock, - getZshStartupFileSourceBlock, SHELL_STARTUP_IDENTITY_MARKER_BLOCK, - ZSH_BOURNE_EMULATION_OPTION_HINT, ZSH_FEATURE_CHANNEL_BLOCK, - ZSH_HISTFILE_RESTORE_BLOCK, - ZSH_INHERITED_CONFIG_DIR_RESOLVER_BLOCK, - ZSH_USER_CONFIG_DIR_RESOLVER_BLOCK + ZSH_USER_ZSHENV_SOURCE_BLOCK, + ZSH_ZDOTDIR_HANDBACK_BLOCK } from './shell-templates' -/** Runtime values the wrapper re-exports after the user's own startup files ran. */ +/** Runtime values the hook re-exports after the user's own startup files ran. */ export type ZshWrapperRestoreSpec = { /** Orca's agent-teams shim dir back onto PATH. */ agentTeamsPath: boolean @@ -47,36 +48,17 @@ export type ZshWrapperRestoreSpec = { codexLaunchPreflight: boolean } -export type ZshStartupWrapperSpec = { - /** First line of every generated file, e.g. `# Orca zsh shell-ready wrapper`. */ +export type ZshStartupHookSpec = { + /** First line of the generated file, e.g. `# Orca zsh shell-ready wrapper`. */ headerLabel: string - /** Wrapper ZDOTDIR baked into .zshenv as the fallback literal. */ - zshDir: string - /** - * How .zshenv finds the user's real ZDOTDIR. `discover-user-zdotdir` sources - * the user .zshenv and reads what it exported; `overlay-user-zdotdir` trusts - * the inherited ZDOTDIR and republishes it as ORCA_USER_ZDOTDIR. - */ - zshenvStrategy: 'discover-user-zdotdir' | 'overlay-user-zdotdir' - /** zsh expression the wrapper resolves the user's startup-file dir from. */ - homeExpression?: string readyMarkerEscaped: string /** OSC 133 command-lifecycle hooks (behind the `markers` feature). */ osc133CommandMarkers: boolean - /** Skip the user .zshrc when its dir is already the wrapper ZDOTDIR. */ - skipUserZshrcWhenHomeIsWrapperDir: boolean - /** Comment heading the overlay restores inside the epilogue. */ + /** Comment heading the overlay restores inside the hook. */ overlayRestoreComment: string restores: ZshWrapperRestoreSpec } -export type ZshStartupWrapperFiles = { - zshenv: string - zprofile: string - zshrc: string - zlogin: string -} - const AGENT_TEAMS_PATH_RESTORE_BLOCK = `__orca_restore_agent_teams_path() { [[ -n "\${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0 case "$PATH" in @@ -92,29 +74,25 @@ const REMOTE_CLI_BIN_DIR_RESTORE = `[[ -n "\${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && const CODEX_HOME_RESTORE = `# Why: Codex must keep using Orca's runtime CODEX_HOME after rc files. [[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}"` -const ZSH_OSC133_COMMAND_MARKER_BLOCK = `__orca_osc133_precmd() { +/** + * The OSC 133 hooks, defined at top level so their bodies are parsed before the + * user's `.zshenv` can change the parsing mode. + */ +const ZSH_OSC133_FUNCTION_BLOCK = `__orca_osc133_precmd() { local exit_code=$? if [[ -n "\${__orca_in_command:-}" ]]; then - printf "\\033]133;D;%s\\007" "$exit_code" - unset __orca_in_command + builtin printf "\\033]133;D;%s\\007" "$exit_code" + builtin unset __orca_in_command fi - printf "\\033]133;A\\007" + builtin printf "\\033]133;A\\007" } __orca_osc133_preexec() { - printf "\\033]133;C\\007" + builtin printf "\\033]133;C\\007" # Why typeset -g: a plain assignment here creates a global inside a function, # which prints a warning above every command under warn_create_global. - typeset -g __orca_in_command=1 -} -# Why: prepend so Orca captures $? before user prompt hooks can overwrite it. -precmd_functions=(__orca_osc133_precmd \${precmd_functions[@]}) -preexec_functions=(__orca_osc133_preexec \${preexec_functions[@]})` + builtin typeset -g __orca_in_command=1 +}` -/** - * Blocks already carrying a trailing newline (the omp wrapper, the codex - * preflight, the shared source/restore templates) keep it, so joining on a - * single newline reproduces the blank-line spacing of the originals. - */ function joinBlocks(blocks: (string | null)[]): string { return blocks.filter((block): block is string => block !== null).join('\n') } @@ -126,7 +104,7 @@ function indentBlock(block: string, indent: string): string { .join('\n') } -/** One epilogue feature: `if __orca_has_feature ; then ... fi`. */ +/** One hook feature: `if __orca_has_feature ; then ... fi`. */ function featureGuard(name: string, body: (string | null)[]): string | null { const blocks = body.filter((block): block is string => block !== null) if (blocks.length === 0) { @@ -136,7 +114,7 @@ function featureGuard(name: string, body: (string | null)[]): string | null { } /** The env/PATH restores that must outlast the user's own startup files. */ -function getOverlayRestoreBlocks(spec: ZshStartupWrapperSpec): (string | null)[] { +function getOverlayRestoreBlocks(spec: ZshStartupHookSpec): (string | null)[] { return [ spec.overlayRestoreComment, spec.restores.agentTeamsPath ? AGENT_TEAMS_PATH_RESTORE_BLOCK : null, @@ -150,122 +128,66 @@ function getOverlayRestoreBlocks(spec: ZshStartupWrapperSpec): (string | null)[] } /** - * Every Orca-owned, order-sensitive step, in one function called once. - * - * Why a function in .zshenv rather than inline code in .zshrc/.zlogin: login and - * non-login shells load a different last file, and duplicating the body in both - * is how the two copies drifted before. The once-flag makes a double invocation - * (re-sourced rc files) a no-op rather than a double-registered prompt hook. + * Everything Orca owns that must run after the user's config, in one function + * invoked from the first prompt's precmd sweep and then retired. */ -function buildEpilogue(spec: ZshStartupWrapperSpec): string { - return `__orca_shell_epilogue() { +function buildDeferredInit(spec: ZshStartupHookSpec): string { + // Why substitute in the markers case and remove otherwise: OSC 133 needs a + // permanent precmd, so swapping this hook for it keeps the array position the + // user's own hooks were registered around. With no permanent hook to leave + // behind, removing is what keeps a history-only pane observably identical to + // the unwrapped pane it was — no stray Orca name in `precmd_functions`. + // Verified on zsh 5.9 that self-removal mid-sweep skips no later hook, from + // the head, the middle and the tail of the array. + const permanentPrecmd = spec.osc133CommandMarkers + ? ` if __orca_has_feature markers; then + precmd_functions=(\${precmd_functions:/__orca_deferred_init/__orca_osc133_precmd}) + preexec_functions=(__orca_osc133_preexec \${preexec_functions[@]}) + else + precmd_functions=(\${precmd_functions:#__orca_deferred_init}) + fi` + : ` precmd_functions=(\${precmd_functions:#__orca_deferred_init})` + + return `__orca_deferred_init() { # Why first: this body runs after the user's own config, so it would otherwise # inherit whatever options that config left set. Under NO_UNSET an unset - # precmd_functions is a fatal error that returns from the whole epilogue - # (skipping the ready widget and the ZDOTDIR restore), and KSH_ARRAYS makes - # the 1-based feature lookup drop the first selected feature. - emulate -L zsh - (( $+_orca_epilogue_done )) && return 0 - typeset -g _orca_epilogue_done=1 + # precmd_functions is fatal, and KSH_ARRAYS makes the 1-based feature lookup + # drop whichever feature is listed first. + builtin emulate -L zsh + (( $+_orca_deferred_init_done )) && return 0 + builtin typeset -g _orca_deferred_init_done=1 + builtin typeset -g precmd_functions +${permanentPrecmd} ${joinBlocks([ featureGuard('overlay', getOverlayRestoreBlocks(spec)), - // Why ungated: this repairs damage Orca's own ZDOTDIR caused, so it must also - // run for a shell that re-enters the wrapper with no feature channel left - // (a nested zsh under an inherited wrapper ZDOTDIR) — the plain #11044 shape. - indentBlock(ZSH_HISTFILE_RESTORE_BLOCK, ' '), - featureGuard('markers', [spec.osc133CommandMarkers ? ZSH_OSC133_COMMAND_MARKER_BLOCK : null]), - featureGuard('ready', [getZshShellReadyMarkerRegistrationBlock(spec.readyMarkerEscaped)]) + // Why no /etc/zshrc repair branch: ZDOTDIR was handed back before that file + // ran, so the value it derives is the user's own path. #11044 is unreachable. + ` if [[ -n "\${_orca_histfile:-}" ]]; then + HISTFILE="$_orca_histfile" + fi`, + featureGuard('ready', [ + indentBlock(getZshShellReadyMarkerRegistrationBlock(spec.readyMarkerEscaped), '') + ]) ])} -${indentBlock(getZshFinalZdotdirRestoreBlock(spec.homeExpression).replace(/\n$/, ''), ' ')} - unset _orca_shell_features - unfunction __orca_shell_epilogue __orca_has_feature __orca_resolve_user_config_dir __orca_resolve_inherited_config_dir +${ + spec.osc133CommandMarkers + ? ` # Why called here: we were appended during this prompt's own precmd sweep, so + # the permanent hook has not run yet and the first prompt would lose its mark. + __orca_has_feature markers && __orca_osc133_precmd\n` + : '' +} builtin unset _orca_shell_features _orca_histfile + builtin unfunction __orca_deferred_init __orca_has_feature }` } -function buildZshenv(spec: ZshStartupWrapperSpec): string { +export function buildZshStartupHook(spec: ZshStartupHookSpec): string { return `${joinBlocks([ `# ${spec.headerLabel}`, + ZSH_ZDOTDIR_HANDBACK_BLOCK, ZSH_FEATURE_CHANNEL_BLOCK, SHELL_STARTUP_IDENTITY_MARKER_BLOCK, - ZSH_USER_CONFIG_DIR_RESOLVER_BLOCK, - ZSH_INHERITED_CONFIG_DIR_RESOLVER_BLOCK, - spec.zshenvStrategy === 'discover-user-zdotdir' - ? getZshEnvDiscoveryBody(spec.zshDir) - : getZshOverlayEnvBody(spec.zshDir), - buildEpilogue(spec) + spec.osc133CommandMarkers ? ZSH_OSC133_FUNCTION_BLOCK : null, + buildDeferredInit(spec), + ZSH_USER_ZSHENV_SOURCE_BLOCK ])}\n` } - -/** - * Why guarded: one wrapper dir can be shared by two concurrently installed Orca - * builds, so a shell can read one build's .zshenv and another's .zshrc/.zlogin. - * The epilogue is the only cross-file dependency left — every other function - * these files call is redefined at the top of each of them — so an older .zshenv - * costs this shell the epilogue's features and nothing else, without printing - * "command not found" into the user's pane. Braced subscript because sh/ksh - * emulation (KSH_ARRAYS) rejects the unbraced `$+functions[...]` form outright. - */ -const EPILOGUE_CALL = '(( ${+functions[__orca_shell_epilogue]} )) && __orca_shell_epilogue' - -/** - * A login shell normally runs the epilogue from .zlogin, after the user's own - * .zlogin. The exception: zsh's `sourcehome()` ignores ZDOTDIR once the shell is - * in sh/ksh emulation, so a user .zshrc ending in `emulate sh` makes zsh read - * $HOME/.zlogin and never the wrapper's — no OSC 133 hooks, no ready widget, and - * HISTFILE left pointing inside the wrapper dir. Running the epilogue here - * instead means the user's own .zlogin can undo its overlay restores, which - * beats not running it at all. An older zsh whose `emulate` has no query form - * prints nothing, fails the comparison, and runs it here too; the epilogue's - * once-flag makes the later .zlogin call a no-op. - * - * Of the wrapper's three emulation probes this is the costliest — it forks a - * zsh that has just finished loading the user's whole .zshrc — so - * ZSH_BOURNE_EMULATION_OPTION_HINT skips it for any shell not in Bourne - * emulation. Braces because `A || B && C` groups as `(A || B) && C` in a shell. - */ -const ZSHRC_EPILOGUE_INVOCATION = `if [[ ! -o login ]] || { ${ZSH_BOURNE_EMULATION_OPTION_HINT} && [[ "$(emulate 2>/dev/null)" != zsh ]]; }; then - ${EPILOGUE_CALL} -fi` - -export function buildZshStartupWrapperFiles(spec: ZshStartupWrapperSpec): ZshStartupWrapperFiles { - return { - zshenv: buildZshenv(spec), - zprofile: `${joinBlocks([ - `# ${spec.headerLabel}`, - ZSH_USER_CONFIG_DIR_RESOLVER_BLOCK, - getZshStartupFileSourceBlock({ - fileName: '.zprofile', - homeExpression: spec.homeExpression - }), - // Why here too: a user .zprofile ending in `emulate sh` hides .zshrc and - // .zlogin from the wrapper exactly as a user .zshenv does, and .zprofile - // is the last wrapper file that still runs before /etc/zshrc clobbers - // HISTFILE — so this is the last point where degrading cleanly is possible. - getZshEmulationDegradeBlock({ - userZdotdirExpression: '"$_orca_home"', - sourcedUserFileTest: '-f "$_orca_home/.zprofile"' - }) - ])}\n`, - zshrc: `${joinBlocks([ - `# ${spec.headerLabel}`, - ZSH_USER_CONFIG_DIR_RESOLVER_BLOCK, - getZshStartupFileSourceBlock({ - fileName: '.zshrc', - homeExpression: spec.homeExpression, - interactiveOnly: true, - skipWhenHomeIsCurrentZdotdir: spec.skipUserZshrcWhenHomeIsWrapperDir - }), - ZSHRC_EPILOGUE_INVOCATION - ])}\n`, - zlogin: `${joinBlocks([ - `# ${spec.headerLabel}`, - ZSH_USER_CONFIG_DIR_RESOLVER_BLOCK, - getZshStartupFileSourceBlock({ - fileName: '.zlogin', - homeExpression: spec.homeExpression, - interactiveOnly: true - }), - EPILOGUE_CALL - ])}\n` - } -} diff --git a/src/main/zsh-wrapper-dir-ownership.ts b/src/main/zsh-wrapper-dir-ownership.ts index 6eebe5b6132..0567212d516 100644 --- a/src/main/zsh-wrapper-dir-ownership.ts +++ b/src/main/zsh-wrapper-dir-ownership.ts @@ -43,15 +43,22 @@ function usableInheritedZdotdir(value: string | undefined): string | null { return value } -export function resolveInheritedZdotdir(env: EnvLike, homeFallback = ''): string { +/** + * The ZDOTDIR the user genuinely has, or null. + * + * Why null rather than a $HOME fallback: the wrapper hands this value straight + * back to the shell, and a user with no ZDOTDIR must end up with none — not with + * one Orca invented. `ZDOTDIR=$HOME` and an unset ZDOTDIR look identical to zsh + * when it reads startup files, but they are different environments for + * everything the pane goes on to launch. + */ +export function resolveInheritedZdotdir(env: EnvLike): string | null { return ( - usableInheritedZdotdir(env.ZDOTDIR) ?? - usableInheritedZdotdir(env.ORCA_ORIG_ZDOTDIR) ?? - env.HOME ?? - homeFallback + usableInheritedZdotdir(env.ZDOTDIR) ?? usableInheritedZdotdir(env.ORCA_ORIG_ZDOTDIR) ?? null ) } -export function resolveInheritedZshenvSourceDir(env: EnvLike, homeFallback = ''): string { - return usableInheritedZdotdir(env.ZDOTDIR) ?? env.HOME ?? homeFallback +/** Spawn-env entry for the wrapper's ZDOTDIR handback; absent when there is none. */ +export function inheritedZdotdirEnv(inherited: string | null): Record { + return inherited ? { ORCA_ORIG_ZDOTDIR: inherited } : {} } diff --git a/src/main/zsh-wrapper-version-mismatch.live-shell.test.ts b/src/main/zsh-wrapper-version-mismatch.live-shell.test.ts index af88295c7e6..796dc290b37 100644 --- a/src/main/zsh-wrapper-version-mismatch.live-shell.test.ts +++ b/src/main/zsh-wrapper-version-mismatch.live-shell.test.ts @@ -1,67 +1,107 @@ /** - * Real-zsh proof that a wrapper dir written by two different Orca builds still + * Real-zsh proof that a wrapper dir holding files from two Orca builds still * loads the user's own zsh config. * - * One wrapper dir (`/shell-ready/zsh`, or `~/.orca-relay/shell-ready/ - * zsh` for a remote host) is shared by every concurrently installed build, and - * each rewrites it on spawn. A shell can therefore read one build's `.zshenv` - * and another's `.zprofile`/`.zshrc`/`.zlogin`. Only a real zsh shows the cost: - * a helper the newer files call but the older `.zshenv` never defined both - * prints `command not found` into the pane AND leaves `$REPLY` empty, which - * silently skips sourcing the user's own startup files. + * A shared dir used to mean a shell could read one build's `.zshenv` and + * another's `.zprofile`/`.zshrc`/`.zlogin`, which is why every generated file + * redefined the helpers it called. #15285 removed the hazard for the desktop and + * daemon trees by naming each one after a hash of its contents, so two builds + * never write the same directory. + * + * The relay is the one writer left on a fixed path — `~/.orca-relay/shell-ready` + * — so this is where the scenario is still reachable, and it is now much smaller: + * Orca writes one file, and that file hands ZDOTDIR back before anything else + * runs. Both halves are pinned here: + * + * 1. Files an older build left beside the hook are inert — zsh reads .zprofile, + * .zshrc and .zlogin from the user's own directory and never from here. + * 2. Generation does not delete them. An older build checks for all four before + * calling its tree complete; deleting them makes it rewrite its own `.zshenv` + * over ours, and an older `.zshenv` points ZDOTDIR at a directory that would + * then hold no `.zshrc` at all — the user losing their entire config, which is + * the failure this file exists to prevent. */ -import { spawnSync } from 'node:child_process' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' -import { ensureShellReadyWrappersAt } from './providers/local-pty-shell-ready-wrapper-generation' +import { ensureOverlayRestoreWrappers } from '../relay/pty-shell-overlay-wrappers' +import { hasZsh, makeZshHome, runZshPty } from './zsh-startup-hook-pty-harness' -const hasZsh = process.platform !== 'win32' && spawnSync('zsh', ['--version']).status === 0 -const ZSH_PATH = hasZsh - ? (spawnSync('sh', ['-c', 'command -v zsh'], { encoding: 'utf8' }).stdout || '').trim() - : '' const itWithZsh = hasZsh ? it : it.skip -/** - * A `.zshenv` from a build that predates everything the other three files now - * call: it exports the wrapper ZDOTDIR and nothing else. - */ -function olderBuildZshenv(zshDir: string): string { - return `# Orca zsh shell-ready wrapper -export ORCA_ORIG_ZDOTDIR="$HOME" -export ZDOTDIR=${JSON.stringify(zshDir)} -` +/** The three files an older build wrote alongside its own `.zshenv`. */ +const OLDER_BUILD_FILES = { + '.zprofile': 'export ORCA_TEST_STALE_ZPROFILE=1\n', + '.zshrc': 'export ORCA_TEST_STALE_ZSHRC=1\n', + '.zlogin': 'export ORCA_TEST_STALE_ZLOGIN=1\n' } describe.skipIf(process.platform === 'win32')('zsh wrapper dir written by mixed builds', () => { - itWithZsh('still sources the user config when the .zshenv is from an older build', () => { + itWithZsh('ignores an older build’s files and loads the user’s config instead', async () => { const root = mkdtempSync(join(tmpdir(), 'orca-wrapper-mismatch-')) - const home = mkdtempSync(join(tmpdir(), 'orca-wrapper-mismatch-home-')) + const home = makeZshHome({ + '.zshenv': 'export ORCA_TEST_USER_ZSHENV=1\n', + '.zprofile': 'export ORCA_TEST_USER_ZPROFILE=1\n', + '.zshrc': 'export ORCA_TEST_USER_ZSHRC=1\n' + }) try { - expect(ensureShellReadyWrappersAt(root)).toBe(true) + expect(ensureOverlayRestoreWrappers(root)).toBe(true) const zshDir = join(root, 'zsh') - writeFileSync(join(zshDir, '.zshenv'), olderBuildZshenv(zshDir)) - for (const file of ['.zprofile', '.zshrc', '.zlogin']) { - writeFileSync(join(home, file), `echo "RAN ${file}"\n`) + for (const [name, content] of Object.entries(OLDER_BUILD_FILES)) { + writeFileSync(join(zshDir, name), content) } - const result = spawnSync(ZSH_PATH, ['-l', '-i', '-c', 'exit 0'], { - encoding: 'utf8', - timeout: 20_000, - env: { PATH: '/usr/bin:/bin', HOME: home, ZDOTDIR: zshDir } + const { values } = await runZshPty({ + env: { + PATH: '/usr/bin:/bin', + HOME: home, + ZDOTDIR: zshDir, + ORCA_ORIG_ZDOTDIR: home, + ORCA_SHELL_FEATURES: 'history', + ORCA_HISTFILE: join(home, 'scoped_history') + }, + report: [ + 'ORCA_TEST_USER_ZPROFILE', + 'ORCA_TEST_USER_ZSHRC', + 'ORCA_TEST_STALE_ZPROFILE', + 'ORCA_TEST_STALE_ZSHRC', + 'ORCA_TEST_STALE_ZLOGIN', + 'HISTFILE' + ] }) - // Why both streams: zsh prints `command not found` on stderr and the - // fixture files echo on stdout, and this asserts about each. - const output = `${result.stdout}${result.stderr}` - expect(output).not.toContain('command not found') - expect(output).toContain('RAN .zprofile') - expect(output).toContain('RAN .zshrc') - expect(output).toContain('RAN .zlogin') + // The user's own files loaded; the older build's leftovers did not. + expect(values.ORCA_TEST_USER_ZPROFILE).toBe('1') + expect(values.ORCA_TEST_USER_ZSHRC).toBe('1') + expect(values.ORCA_TEST_STALE_ZPROFILE).toBe('UNSET') + expect(values.ORCA_TEST_STALE_ZSHRC).toBe('UNSET') + expect(values.ORCA_TEST_STALE_ZLOGIN).toBe('UNSET') + expect(values.HISTFILE).toBe(join(home, 'scoped_history')) } finally { rmSync(root, { recursive: true, force: true }) rmSync(home, { recursive: true, force: true }) } }) + + it('leaves an older build’s files in place so that build can still use them', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-wrapper-mismatch-keep-')) + try { + expect(ensureOverlayRestoreWrappers(root)).toBe(true) + const zshDir = join(root, 'zsh') + for (const [name, content] of Object.entries(OLDER_BUILD_FILES)) { + writeFileSync(join(zshDir, name), content) + } + + // A second generation pass is what an older build's launch would race. + expect(ensureOverlayRestoreWrappers(root)).toBe(true) + + for (const [name, content] of Object.entries(OLDER_BUILD_FILES)) { + expect(existsSync(join(zshDir, name))).toBe(true) + expect(readFileSync(join(zshDir, name), 'utf8')).toBe(content) + } + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) }) diff --git a/src/relay/pty-shell-launch.test.ts b/src/relay/pty-shell-launch.test.ts index c1bd4365a50..92ae2437f76 100644 --- a/src/relay/pty-shell-launch.test.ts +++ b/src/relay/pty-shell-launch.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { spawnSync } from 'node:child_process' @@ -59,17 +59,6 @@ function expectBashOsc133Lifecycle(output: string): void { ]) } -function expectZdotdirSourceContext(content: string, fileName: '.zprofile' | '.zshrc' | '.zlogin') { - expect(content).toContain('export ZDOTDIR="$_orca_home"') - expect(content).toContain(`source "$_orca_home/${fileName}"`) - expect(content).toContain('export ZDOTDIR="$_orca_wrapper_zdotdir"') -} - -function expectFinalZdotdirRestoreContext(content: string) { - expect(content).toContain("after Orca's last wrapper file has loaded") - expect(content).toContain('export ZDOTDIR="$_orca_resolved_config_dir"') -} - describe('isRelayWslShell', () => { it.each(['wsl.exe', 'WSL.EXE', 'C:\\Windows\\System32\\wsl.exe', 'wsl'])( 'recognizes %s on a Windows relay', @@ -111,20 +100,17 @@ describe('getRelayShellLaunchConfig', () => { expect(config.args).toEqual(['-l']) expect(config.env.ZDOTDIR).toBe(zshRoot) const zshenv = readFileSync(join(zshRoot, '.zshenv'), 'utf8') - const userZdotdirResolution = - '__orca_resolve_user_config_dir "${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"' - expect(zshenv).toContain('export ORCA_USER_ZDOTDIR="$_orca_resolved_config_dir"') - const zprofile = readFileSync(join(zshRoot, '.zprofile'), 'utf8') - const zshrc = readFileSync(join(zshRoot, '.zshrc'), 'utf8') - const zlogin = readFileSync(join(zshRoot, '.zlogin'), 'utf8') - expect(zprofile).toContain(userZdotdirResolution) - expect(zshrc).toContain(userZdotdirResolution) - expect(zlogin).toContain(userZdotdirResolution) - expectZdotdirSourceContext(zprofile, '.zprofile') - expectZdotdirSourceContext(zshrc, '.zshrc') - expectZdotdirSourceContext(zlogin, '.zlogin') + // Why no ORCA_USER_ZDOTDIR: the relay used to republish the inherited + // ZDOTDIR under that name so its three later wrapper files could prefer it + // over the spawn-time value. There are no later wrapper files, and a + // ZDOTDIR the user's own .zshenv exports simply stands. + expect(zshenv).not.toContain('ORCA_USER_ZDOTDIR') + expect(zshenv).toContain('builtin export ZDOTDIR="$ORCA_ORIG_ZDOTDIR"') + expect(zshenv).toContain('builtin source -- "$_orca_user_zshenv"') + for (const name of ['.zprofile', '.zshrc', '.zlogin']) { + expect(existsSync(join(zshRoot, name))).toBe(false) + } // Why .zshenv: the final restore is the last step of the one epilogue. - expectFinalZdotdirRestoreContext(zshenv) } ) @@ -222,7 +208,7 @@ describe('getRelayShellLaunchConfig', () => { }) expect(readFileSync(join(zshRoot, '.zshenv'), 'utf8')).toContain( - 'export ORCA_USER_ZDOTDIR="$_orca_resolved_config_dir"' + 'builtin export ZDOTDIR="$ORCA_ORIG_ZDOTDIR"' ) }) diff --git a/src/relay/pty-shell-launch.ts b/src/relay/pty-shell-launch.ts index 58ab9ab75e4..f617903056b 100644 --- a/src/relay/pty-shell-launch.ts +++ b/src/relay/pty-shell-launch.ts @@ -6,7 +6,7 @@ import { SHELL_STARTUP_FEATURE_ENV, type ShellStartupFeature } from '../main/shell-startup-features' -import { resolveInheritedZdotdir } from '../main/zsh-wrapper-dir-ownership' +import { inheritedZdotdirEnv, resolveInheritedZdotdir } from '../main/zsh-wrapper-dir-ownership' import { ensureOverlayRestoreWrappers } from './pty-shell-overlay-wrappers' const RELAY_SHELL_READY_DIR = '.orca-relay/shell-ready' const POSIX_LOGIN_ARGS = ['-l'] @@ -134,7 +134,7 @@ export function getRelayShellLaunchConfig( return { args: POSIX_LOGIN_ARGS, env: { - ORCA_ORIG_ZDOTDIR: resolveInheritedZdotdir(env, process.env.HOME ?? ''), + ...inheritedZdotdirEnv(resolveInheritedZdotdir(env)), ZDOTDIR: join(root, 'zsh'), ...featureEnv }, diff --git a/src/relay/pty-shell-overlay-wrappers.ts b/src/relay/pty-shell-overlay-wrappers.ts index 197f900268b..ca7867c98e2 100644 --- a/src/relay/pty-shell-overlay-wrappers.ts +++ b/src/relay/pty-shell-overlay-wrappers.ts @@ -5,15 +5,12 @@ import { BASH_FEATURE_CHANNEL_BLOCK, BASH_PROMPT_COMMAND_COMPOSITION_BLOCK, SHELL_STARTUP_IDENTITY_MARKER_BLOCK, - ZSH_HISTFILE_RESTORE_BLOCK, + BASH_HISTFILE_RESTORE_BLOCK, ZSH_WRAPPER_DIR_MARKER_CONTENT, ZSH_WRAPPER_DIR_MARKER_FILE } from '../main/shell-templates' import { writeShellWrapperFiles } from '../main/shell-wrapper-file-writer' -import { - buildZshStartupWrapperFiles, - type ZshStartupWrapperSpec -} from '../main/zsh-startup-wrapper-builder' +import { buildZshStartupHook, type ZshStartupHookSpec } from '../main/zsh-startup-wrapper-builder' /** Writes the zsh/bash overlay wrapper files a relay-spawned shell sources. * Split from pty-shell-launch.ts so the launch-config decisions stay readable @@ -21,19 +18,16 @@ import { const SHELL_READY_MARKER_ESCAPED = '\\033]777;orca-shell-ready\\007' -// Why: the relay .zshenv republishes the inherited ZDOTDIR as ORCA_USER_ZDOTDIR, -// so later wrapper files prefer it over the spawn-time ORCA_ORIG_ZDOTDIR. -const RELAY_HOME_EXPRESSION = '"${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"' - -function getRelayZshWrapperSpec(zshDir: string): ZshStartupWrapperSpec { +// Why the relay no longer needs its own ZDOTDIR shape: it used to republish the +// inherited value as ORCA_USER_ZDOTDIR so the later wrapper files could prefer +// it over the spawn-time ORCA_ORIG_ZDOTDIR. There are no later wrapper files +// now, and ZDOTDIR itself carries the answer, so the relay and desktop bodies +// are one template again. +function getRelayZshWrapperSpec(): ZshStartupHookSpec { return { headerLabel: 'Orca relay zsh overlay wrapper', - zshDir, - zshenvStrategy: 'overlay-user-zdotdir', - homeExpression: RELAY_HOME_EXPRESSION, readyMarkerEscaped: SHELL_READY_MARKER_ESCAPED, osc133CommandMarkers: false, - skipUserZshrcWhenHomeIsWrapperDir: false, overlayRestoreComment: '# Why: remote startup files can re-export user defaults after relay spawn.', restores: { @@ -50,7 +44,7 @@ export function ensureOverlayRestoreWrappers(root: string): boolean { const zshDir = join(root, 'zsh') const bashDir = join(root, 'bash') - const zsh = buildZshStartupWrapperFiles(getRelayZshWrapperSpec(zshDir)) + const zshenv = buildZshStartupHook(getRelayZshWrapperSpec()) const bashRc = `# Orca relay bash overlay wrapper ${BASH_FEATURE_CHANNEL_BLOCK} ${SHELL_STARTUP_IDENTITY_MARKER_BLOCK} @@ -78,7 +72,7 @@ fi [[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" [[ -n "\${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"\${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="\${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac ${getPosixOmpShellWrapper()} -${ZSH_HISTFILE_RESTORE_BLOCK} +${BASH_HISTFILE_RESTORE_BLOCK} # Why: SSH bash sessions need the same command lifecycle markers as local # bash so agent rows stop showing "working" when the foreground command exits. __orca_initializing_wrapper=1 @@ -162,11 +156,9 @@ trap '__orca_osc133_preexec' DEBUG unset __orca_initializing_wrapper ` + // Only .zshenv: see local-pty-shell-ready-wrapper-generation.ts. const files = [ - [join(zshDir, '.zshenv'), zsh.zshenv], - [join(zshDir, '.zprofile'), zsh.zprofile], - [join(zshDir, '.zshrc'), zsh.zshrc], - [join(zshDir, '.zlogin'), zsh.zlogin], + [join(zshDir, '.zshenv'), zshenv], [join(zshDir, ZSH_WRAPPER_DIR_MARKER_FILE), ZSH_WRAPPER_DIR_MARKER_CONTENT], [join(bashDir, 'rcfile'), bashRc] ] as const From acbcb477a1d7880e99cb924c5035e3f165761f9c Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:39:48 -0700 Subject: [PATCH 013/111] Auto e2e tests autofix scheduled ci 1h run 1 20260818T2143 (#15379) * fix: update E2E tests for API changes and selector robustness - Improve source control file locator specificity to avoid flakiness - Fix board test to use correct worktree ID attribute - Update removeWorktree calls to pass host ID parameter - Simplify git status polling with timeout expectation * fix: increase packaged-watchdog launch timeout and await git-status rows Extract hardcoded 15s launch timeout to a 30s constant for better reliability under load. E2E test now waits for all git-status rows to render before asserting absence of status messages, preventing flaky passes when the list is still loading. --- ...ged-hang-watchdog-worker-contract.test.mjs | 2 ++ .../smoke-packaged-hang-watchdog-worker.mjs | 3 +- ...ource-control-pr-generation-switch.spec.ts | 8 +++-- ...orkspace-board-lane-virtualization.spec.ts | 2 +- tests/e2e/workspace-space-git-status.spec.ts | 29 +++++++++---------- tests/e2e/worktree-lifecycle.spec.ts | 12 ++++++-- 6 files changed, 33 insertions(+), 23 deletions(-) diff --git a/config/scripts/packaged-hang-watchdog-worker-contract.test.mjs b/config/scripts/packaged-hang-watchdog-worker-contract.test.mjs index 1ae558c7691..a4998760b67 100644 --- a/config/scripts/packaged-hang-watchdog-worker-contract.test.mjs +++ b/config/scripts/packaged-hang-watchdog-worker-contract.test.mjs @@ -19,6 +19,8 @@ describe('packaged hang watchdog worker contract', () => { expect(smokeSource).toContain( "process.platform === 'linux' ? ['--no-sandbox', launcherDir] : [launcherDir]" ) + expect(smokeSource).toContain('const LAUNCH_TIMEOUT_MS = 30_000') + expect(smokeSource).toContain('timeout: LAUNCH_TIMEOUT_MS') }) // Why: Electron ignores process.exitCode, so the gate needs app.exit plus a stdout assertion. diff --git a/config/scripts/smoke-packaged-hang-watchdog-worker.mjs b/config/scripts/smoke-packaged-hang-watchdog-worker.mjs index ae9b340785a..a8c4e578a8c 100644 --- a/config/scripts/smoke-packaged-hang-watchdog-worker.mjs +++ b/config/scripts/smoke-packaged-hang-watchdog-worker.mjs @@ -11,6 +11,7 @@ const ASAR_ENV = 'ORCA_PACKAGED_WATCHDOG_SMOKE_ASAR' const TIMEOUT_MS = 100 const CHECK_INTERVAL_MS = 20 const POLL_TIMEOUT_MS = 5_000 +const LAUNCH_TIMEOUT_MS = 30_000 const SUCCESS_LINE = '[packaged-watchdog-smoke] app.asar worker detected and recovered a stall' function sleep(ms) { @@ -144,7 +145,7 @@ function runSmoke() { const result = spawnSync(executable, electronArgs, { env, encoding: 'utf8', - timeout: 15_000 + timeout: LAUNCH_TIMEOUT_MS }) if (result.status !== 0) { throw new Error( diff --git a/tests/e2e/source-control-pr-generation-switch.spec.ts b/tests/e2e/source-control-pr-generation-switch.spec.ts index 283b47416eb..58091cd3cf8 100644 --- a/tests/e2e/source-control-pr-generation-switch.spec.ts +++ b/tests/e2e/source-control-pr-generation-switch.spec.ts @@ -401,9 +401,11 @@ test.describe('Source Control AI PR generation worktree switching', () => { await installDelayedCommitMessageGenerator(orcaPage, generatorScriptPath, callLogPath) await openSourceControl(orcaPage, commitWorktreeId) - await expect(orcaPage.getByText('e2e-commit-message-generation.txt')).toBeVisible({ - timeout: 10_000 - }) + await expect( + orcaPage + .getByTestId('source-control-entry') + .getByText('e2e-commit-message-generation.txt', { exact: true }) + ).toBeVisible({ timeout: 10_000 }) const generate = orcaPage.getByRole('button', { name: 'Generate commit message with AI' }) diff --git a/tests/e2e/workspace-board-lane-virtualization.spec.ts b/tests/e2e/workspace-board-lane-virtualization.spec.ts index a6c847a5504..69a18e06937 100644 --- a/tests/e2e/workspace-board-lane-virtualization.spec.ts +++ b/tests/e2e/workspace-board-lane-virtualization.spec.ts @@ -278,7 +278,7 @@ test.describe('Workspace board lane virtualization', () => { const sourceCard = board .locator('[data-workspace-status="state-20"] [data-workspace-board-card-id]') .first() - const sourceId = await sourceCard.getAttribute('data-workspace-board-card-id') + const sourceId = await sourceCard.getAttribute('data-workspace-board-worktree-id') const sourceBox = await sourceCard.boundingBox() const targetBox = await finalLane .locator('[data-workspace-board-lane-scroll]') diff --git a/tests/e2e/workspace-space-git-status.spec.ts b/tests/e2e/workspace-space-git-status.spec.ts index 45b756a662f..57b0e6dc136 100644 --- a/tests/e2e/workspace-space-git-status.spec.ts +++ b/tests/e2e/workspace-space-git-status.spec.ts @@ -47,7 +47,7 @@ test.describe('Workspace Space git status checks', () => { // cache TTL until every path registers before deriving the space rows. await loadWorktreesUntilPathsPresent(orcaPage, repoId, registeredWorktreePaths) - await orcaPage.evaluate( + const rowDisplayNames = await orcaPage.evaluate( async ({ testRepoPath, worktreePaths }) => { const store = window.__store if (!store) { @@ -129,27 +129,24 @@ test.describe('Workspace Space git status checks', () => { } }) store.getState().openSpacePage() + return rows.map((row) => row.displayName) }, { testRepoPath, worktreePaths: registeredWorktreePaths } ) - await expect - .poll( - () => - orcaPage.evaluate(() => { - const state = window.__store?.getState() - if (!state?.workspaceSpaceAnalysis) { - return 60 - } - return state.workspaceSpaceAnalysis.worktrees.filter( - (row) => state.gitStatusByWorktree[row.worktreeId] === undefined - ).length - }), - { timeout: 30_000 } + // Why: `toHaveCount(0)` passes trivially while the list is still empty, so + // every row has to be on screen before the absent-status assertion means + // anything. + const rowCheckboxes = orcaPage.getByRole('checkbox', { + name: new RegExp( + `^Select (?:${rowDisplayNames + .map((displayName) => displayName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('|')})$` ) - .toBe(0) + }) + await expect(rowCheckboxes).toHaveCount(rowDisplayNames.length, { timeout: 30_000 }) - await expect(orcaPage.getByText('Keep: git not checked')).toHaveCount(0) + await expect(orcaPage.getByText('Keep: git not checked')).toHaveCount(0, { timeout: 30_000 }) } finally { for (const worktreePath of worktreePaths) { try { diff --git a/tests/e2e/worktree-lifecycle.spec.ts b/tests/e2e/worktree-lifecycle.spec.ts index 1f5bed38ac6..07f4da9fbd9 100644 --- a/tests/e2e/worktree-lifecycle.spec.ts +++ b/tests/e2e/worktree-lifecycle.spec.ts @@ -73,7 +73,11 @@ async function removeWorktreeViaStore( return { ok: false as const, error: 'store unavailable' } } - const result = await store.getState().removeWorktree(id, true) + const state = store.getState() + const result = await state.removeWorktree( + { id, executionHostId: state.getKnownWorktreeById(id)?.hostId ?? null }, + true + ) return result }, worktreeId) } @@ -101,7 +105,11 @@ test.describe('Worktree Lifecycle', () => { await orcaPage .evaluate(async (id) => { try { - await window.__store?.getState().removeWorktree(id, true) + const state = window.__store?.getState() + await state?.removeWorktree( + { id, executionHostId: state.getKnownWorktreeById(id)?.hostId ?? null }, + true + ) } catch { /* best-effort cleanup */ } From ef096d539db1260d858dfb350b792983c93c0779 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:42:39 -0700 Subject: [PATCH 014/111] fix(terminal): refuse a cursor on a screen read, and correct the source docs (#15563) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on #15380. The RPC accepted `cursor` and `screen` together. The CLI refuses the pair, but terminal.read is reachable without it, and honoring both answered with rendered lines carrying the stream's pagination metadata — two frames of reference in one payload, which is the confusion `source` exists to remove. The guard beside it, withVisibleSnapshotFallback, already declines to substitute rendered lines when a cursor is present; the screen path now agrees, at the RPC boundary where every remote caller passes. Nothing could previously send both, since `screen` did not exist, so rejecting breaks no existing caller. The command notes and the runtime comment both still described the fallback as `source: stream`, left over from renaming that value to `screen-unavailable` during implementation. The spec text is surfaced through `orca help` and the agent-context schema, so a caller following it would test for a value the code never emits. Both now describe all four states, including that an absent source means the host predates the field. --- src/cli/specs/core.ts | 2 +- src/main/runtime/orca-runtime.ts | 7 ++- .../terminal-read-screen-cursor.test.ts | 43 +++++++++++++++++++ src/main/runtime/rpc/methods/terminal.ts | 6 +++ 4 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 src/main/runtime/rpc/methods/terminal-read-screen-cursor.test.ts diff --git a/src/cli/specs/core.ts b/src/cli/specs/core.ts index 7aa503fad36..a3cab5dfe70 100644 --- a/src/cli/specs/core.ts +++ b/src/cli/specs/core.ts @@ -204,7 +204,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ 'Omit --terminal to target the active terminal in the current worktree.', 'By default this returns accumulated terminal output with escape sequences stripped, not the rendered screen. Any program that repaints a line — shells, progress bars, TUIs — comes back as stacked fragments, so one `clear` keystroke by keystroke reads as `cclclecleaclear`, and spaces a prompt draws by moving the cursor are absent.', 'Use --screen to read what the terminal actually renders. Prefer it whenever the answer depends on how output looks rather than what was emitted over time; the default is unsuitable for verifying rendered output.', - 'The result reports source: stream or screen, so a caller can tell which question was answered. A --screen read falls back to source: stream when no rendered state exists rather than passing the stream off as a screen.', + 'The result reports source: stream when it is accumulated output, screen when it is the rendered screen, and screen-unavailable when a screen was asked for but none could be rendered and the accumulated output is being returned instead. An absent source means the host predates the field.', '--screen and --cursor are mutually exclusive: a screen read is the current frame and has no history to page.', 'Use --cursor with the nextCursor value from a previous read to get only new output since that read.', 'Use --limit to request more retained lines for long agent responses; output reports oldestCursor when older lines were dropped.', diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 62a1c9c96e0..5e48785e74e 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -18480,8 +18480,11 @@ export class OrcaRuntimeService { // ("cclclecleaclear" for one `clear`) and drops spaces a prompt draws with cursor-forward. // That is the right answer for "what happened over time" and the wrong one for "what is on // screen", so an explicit screen read goes to the emulator state instead. When no rendered - // state exists the stream is still returned, but labelled `stream` rather than passed off as - // a screen — silently answering the other question is the defect this exists to stop. + // state exists the stream is still returned, but labelled `screen-unavailable` rather than + // passed off as a screen — silently answering the other question is the defect this exists to + // stop, and that label is what separates it from a stream the caller actually asked for. + // A cursor cannot reach here: pairing one with a screen read is refused at the RPC boundary, + // because rendered lines carrying the stream's pagination metadata would mix both frames. private async readRenderedScreen( ptyId: string, read: RuntimeTerminalRead, diff --git a/src/main/runtime/rpc/methods/terminal-read-screen-cursor.test.ts b/src/main/runtime/rpc/methods/terminal-read-screen-cursor.test.ts new file mode 100644 index 00000000000..bd870e0f886 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal-read-screen-cursor.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { TERMINAL_METHODS } from './terminal' + +function readParams() { + const method = TERMINAL_METHODS.find((candidate) => candidate.name === 'terminal.read') + if (!method?.params) { + throw new Error('Missing params schema for terminal.read') + } + return method.params +} + +describe('terminal.read screen and cursor', () => { + // Why: the CLI refuses this pair, but terminal.read is an RPC and any other caller can send + // both. Honoring them would answer with rendered lines carrying the stream's pagination + // metadata — two frames of reference in one payload. + it('rejects a cursor combined with a screen read', () => { + expect(() => readParams().parse({ terminal: 'term_abc', screen: true, cursor: 42 })).toThrow( + /Cursor cannot be combined with a screen read/ + ) + }) + + it('still accepts each on its own', () => { + expect(readParams().parse({ terminal: 'term_abc', screen: true })).toMatchObject({ + screen: true + }) + expect(readParams().parse({ terminal: 'term_abc', cursor: 42 })).toMatchObject({ cursor: 42 }) + }) + + // Why: cursor 0 is a real cursor, not an absent one — an off-by-one here would let the + // combination through on the first page. + it('rejects cursor zero with a screen read', () => { + expect(() => readParams().parse({ terminal: 'term_abc', screen: true, cursor: 0 })).toThrow( + /Cursor cannot be combined with a screen read/ + ) + }) + + it('leaves an ordinary paginated read untouched', () => { + expect(readParams().parse({ terminal: 'term_abc', cursor: 0, limit: 100 })).toMatchObject({ + cursor: 0, + limit: 100 + }) + }) +}) diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index bdbeb53cd6b..5dee92a7676 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -925,6 +925,12 @@ const TerminalRead = TerminalHandle.extend({ // Why: optional so an older host that does not understand it simply drops the key and answers // with its usual stream read; the response's `source` is what tells the caller which it got. screen: z.literal(true).optional() +}).refine((params) => !(params.screen === true && params.cursor !== undefined), { + // Why: a cursor pages through accumulated output; a screen is the current frame with nothing + // behind it. Honoring both would answer with rendered lines carrying the stream's pagination + // metadata — two frames of reference in one payload, which is the confusion `source` exists to + // remove. The CLI already refuses the pair, but the RPC is reachable without it. + message: 'Cursor cannot be combined with a screen read' }) // Why: preserve the legacy contract — `title: string | null` only, `undefined` rejected, so the CLI's "reset" signal stays distinct. From 4daace62511c15cb8ba87686e1b8faf7142c7344 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:54:03 -0700 Subject: [PATCH 015/111] fix(tabs): stop split workspaces multiplying columns on return (#15482) --- ...-tabs-sync-layout-duplicate-groups.test.ts | 113 ++++++++++++++++++ .../src/runtime/web-session-tabs-sync.ts | 27 ++++- .../tabs-hydration-group-validation.test.ts | 36 ++++++ .../src/store/slices/tabs-hydration.ts | 21 +++- .../slices/tabs-model-reconciliation.test.ts | 63 ++++++++++ 5 files changed, 256 insertions(+), 4 deletions(-) create mode 100644 src/renderer/src/runtime/web-session-tabs-sync-layout-duplicate-groups.test.ts diff --git a/src/renderer/src/runtime/web-session-tabs-sync-layout-duplicate-groups.test.ts b/src/renderer/src/runtime/web-session-tabs-sync-layout-duplicate-groups.test.ts new file mode 100644 index 00000000000..b0045bd658b --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync-layout-duplicate-groups.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, beforeEach, vi } from 'vitest' +import type { Tab, TabGroup } from '../../../shared/tab-types' +import { applyWebSessionTabsSnapshot, type WebSessionTabsSyncState } from './web-session-tabs-sync' +import { + ENV, + NOW, + WT, + makeSnapshot, + makeState, + resetWebSessionTabsSyncTestState +} from './web-session-tabs-sync-test-harness' + +vi.mock('../store', () => ({ + useAppStore: { + setState: vi.fn() + } +})) + +const LOCAL_GROUP_ID = 'local-group-1' + +function localEditorTab(): Tab { + return { + id: 'local-editor-tab', + entityId: '/repo/NOTES.md', + groupId: LOCAL_GROUP_ID, + worktreeId: WT, + contentType: 'editor', + label: 'NOTES.md', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: NOW, + isPreview: false, + isPinned: false + } +} + +function localGroup(): TabGroup { + return { + id: LOCAL_GROUP_ID, + worktreeId: WT, + activeTabId: 'local-editor-tab', + tabOrder: ['local-editor-tab'], + recentTabIds: ['local-editor-tab'] + } +} + +function stateWithLocalGroup(): WebSessionTabsSyncState { + return makeState({ + unifiedTabsByWorktree: { [WT]: [localEditorTab()] }, + groupsByWorktree: { [WT]: [localGroup()] }, + activeGroupIdByWorktree: { [WT]: LOCAL_GROUP_ID }, + layoutByWorktree: { [WT]: { type: 'leaf', groupId: LOCAL_GROUP_ID } } + }) +} + +// Host group whose tabs are absent from this snapshot: the host names groups but +// publishes no layout for them, which is the shape that used to duplicate leaves. +function snapshotWithUnmappedHostGroup(): ReturnType { + return makeSnapshot([], { + activeGroupId: 'host-group-1', + activeTabId: null, + tabGroups: [{ id: 'host-group-1', activeTabId: 'host-tab-1', tabOrder: ['host-tab-1'] }] + }) +} + +function collectLeafGroupIds(layout: unknown, out: string[] = []): string[] { + const node = layout as { type: string; groupId?: string; first?: unknown; second?: unknown } + if (!node) { + return out + } + if (node.type === 'leaf') { + out.push(node.groupId!) + return out + } + collectLeafGroupIds(node.first, out) + collectLeafGroupIds(node.second, out) + return out +} + +describe('applyWebSessionTabsSnapshot layout composition', () => { + beforeEach(resetWebSessionTabsSyncTestState) + + it('never places one tab group in two layout leaves', () => { + const patch = applyWebSessionTabsSnapshot( + stateWithLocalGroup(), + snapshotWithUnmappedHostGroup(), + ENV, + NOW + ) as Partial + + const leaves = collectLeafGroupIds( + patch.layoutByWorktree?.[WT] ?? { type: 'leaf', groupId: LOCAL_GROUP_ID } + ) + expect(leaves).toEqual([LOCAL_GROUP_ID]) + }) + + it('does not grow the layout when the same snapshot is applied twice', () => { + let state = stateWithLocalGroup() + for (let i = 0; i < 3; i++) { + resetWebSessionTabsSyncTestState() + const patch = applyWebSessionTabsSnapshot( + state, + snapshotWithUnmappedHostGroup(), + ENV, + NOW + ) as Partial + state = { ...state, ...patch } + } + + expect(collectLeafGroupIds(state.layoutByWorktree[WT])).toEqual([LOCAL_GROUP_ID]) + }) +}) diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 14028c25c38..3cbd2243027 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -1762,6 +1762,24 @@ function pruneTabGroupLayout( return first ?? second } +function dropTabGroupLayoutGroups( + layout: TabGroupLayoutNode | null, + excludedGroupIds: ReadonlySet +): TabGroupLayoutNode | null { + if (!layout) { + return null + } + if (layout.type === 'leaf') { + return excludedGroupIds.has(layout.groupId) ? null : layout + } + const first = dropTabGroupLayoutGroups(layout.first, excludedGroupIds) + const second = dropTabGroupLayoutGroups(layout.second, excludedGroupIds) + if (first && second) { + return { ...layout, first, second } + } + return first ?? second +} + function appendTabGroupLayout( first: TabGroupLayoutNode | null, second: TabGroupLayoutNode | null @@ -1772,11 +1790,18 @@ function appendTabGroupLayout( if (!second) { return first } + // Why: a group already placed by `first` must not gain a second leaf — two + // leaves for one group render the same tab strip in two columns, and each + // later snapshot appends another copy. + const appended = dropTabGroupLayoutGroups(second, collectLayoutGroupIds(first)) + if (!appended) { + return first + } return { type: 'split', direction: 'horizontal', first, - second + second: appended } } diff --git a/src/renderer/src/store/slices/tabs-hydration-group-validation.test.ts b/src/renderer/src/store/slices/tabs-hydration-group-validation.test.ts index 70d81deb851..7a9b88ac78e 100644 --- a/src/renderer/src/store/slices/tabs-hydration-group-validation.test.ts +++ b/src/renderer/src/store/slices/tabs-hydration-group-validation.test.ts @@ -213,4 +213,40 @@ describe('buildHydratedTabState referential repair', () => { expect(leafGroupIds(result.layoutByWorktree.w1)).toEqual(['g1', 'g2']) }) + + it('collapses a persisted layout that repeats one group across leaves', () => { + const result = buildHydratedTabState( + { + ...makeBaseSession(), + unifiedTabs: { w1: [tab('t1', 'g1', 0), tab('t2', 'g2', 1)] }, + tabGroups: { + w1: [ + { id: 'g1', worktreeId: 'w1', activeTabId: 't1', tabOrder: ['t1'] }, + { id: 'g2', worktreeId: 'w1', activeTabId: 't2', tabOrder: ['t2'] } + ] + }, + tabGroupLayouts: { + w1: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: 'g1' }, + second: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: 'g1' }, + second: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: 'g2' }, + second: { type: 'leaf', groupId: 'g1' } + } + } + } + } + }, + new Set(['w1']) + ) + + expect(leafGroupIds(result.layoutByWorktree.w1)).toEqual(['g1', 'g2']) + }) }) diff --git a/src/renderer/src/store/slices/tabs-hydration.ts b/src/renderer/src/store/slices/tabs-hydration.ts index 753286e1142..c115c431539 100644 --- a/src/renderer/src/store/slices/tabs-hydration.ts +++ b/src/renderer/src/store/slices/tabs-hydration.ts @@ -19,16 +19,31 @@ type HydratedTabState = { layoutByWorktree: Record } +/** Drops leaves whose group is gone, and repeat leaves for a group already + * placed earlier in the tree — one column per group is the render invariant, + * and a repeated leaf paints the same tab strip in every one of its columns. */ export function pruneTabGroupLayoutForGroups( root: TabGroupLayoutNode, validGroupIds: Set +): TabGroupLayoutNode | null { + return pruneLayoutNodeForGroups(root, validGroupIds, new Set()) +} + +function pruneLayoutNodeForGroups( + root: TabGroupLayoutNode, + validGroupIds: Set, + placedGroupIds: Set ): TabGroupLayoutNode | null { if (root.type === 'leaf') { - return validGroupIds.has(root.groupId) ? root : null + if (!validGroupIds.has(root.groupId) || placedGroupIds.has(root.groupId)) { + return null + } + placedGroupIds.add(root.groupId) + return root } - const first = pruneTabGroupLayoutForGroups(root.first, validGroupIds) - const second = pruneTabGroupLayoutForGroups(root.second, validGroupIds) + const first = pruneLayoutNodeForGroups(root.first, validGroupIds, placedGroupIds) + const second = pruneLayoutNodeForGroups(root.second, validGroupIds, placedGroupIds) if (first === null) { return second diff --git a/src/renderer/src/store/slices/tabs-model-reconciliation.test.ts b/src/renderer/src/store/slices/tabs-model-reconciliation.test.ts index e943f2a20f0..0869b720ec0 100644 --- a/src/renderer/src/store/slices/tabs-model-reconciliation.test.ts +++ b/src/renderer/src/store/slices/tabs-model-reconciliation.test.ts @@ -108,6 +108,69 @@ describe('TabsSlice', () => { expect(result.renderableTabCount).toBe(1) }) + // A session mirrored from a runtime host used to append a fresh leaf for a + // group the layout already held, so returning to a split workspace showed the + // same tab strip in several columns. Reconciliation collapses the repeats. + it('collapses a layout that repeats one group across leaves', () => { + const groupId = 'g-1' + store.setState({ + unifiedTabsByWorktree: { + [WT]: [ + { + id: 'terminal-1', + entityId: 'terminal-1', + groupId, + worktreeId: WT, + contentType: 'terminal', + label: 'Terminal 1', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + tabsByWorktree: { + [WT]: [ + { + id: 'terminal-1', + ptyId: 'pty-1', + worktreeId: WT, + title: 'Terminal 1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + ptyIdsByTabId: { 'terminal-1': ['pty-1'] }, + groupsByWorktree: { + [WT]: [ + { id: groupId, worktreeId: WT, activeTabId: 'terminal-1', tabOrder: ['terminal-1'] } + ] + }, + activeGroupIdByWorktree: { [WT]: groupId }, + layoutByWorktree: { + [WT]: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId }, + second: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId }, + second: { type: 'leaf', groupId } + } + } + } + }) + + store.getState().reconcileWorktreeTabModel(WT) + + expect(store.getState().layoutByWorktree[WT]).toEqual({ type: 'leaf', groupId }) + }) + it('keeps simulator tabs because they reconnect their own backing stream', () => { const terminalGroupId = 'g-terminal' const simulatorGroupId = 'g-simulator' From 9a898a84ccfb2b1d6aebd1cd915750c396a61e26 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:25:47 -0700 Subject: [PATCH 016/111] fix(terminal): keep xterm's render pause latched for a pane with no layout box (#15555) * fix(terminal): keep xterm's render pause latched for a pane with no layout box resetWebglTextureAtlas() released xterm's paused-render gate for every pane of a visible manager, including panes that are display:none (a collapsed sibling of an expanded pane, a restore that stays display:none for its whole reattach). forceRepaintThroughRenderPause exists for a pane that is already DOM-visible while xterm's IntersectionObserver lags a frame. On a pane with no box it paints the freshly cleared render model into nothing, and because the observer only fires on a state change it never re-pauses the service. It also clears _needsFullRefresh, which is the only thing that makes _handleIntersectionChange repaint on reveal and flush the deferred _pausedResizeTask. Latch instead for those panes: terminal.refresh() re-arms _needsFullRefresh and xterm repaints from it on reveal. * test(terminal): type the render-service repaint mock --- .../pane-manager/pane-webgl-renderer.test.ts | 68 ++++++++++++++++++- .../lib/pane-manager/pane-webgl-renderer.ts | 13 +++- 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts index b3990f20400..11076608027 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest' import { WebglAddon } from '@xterm/addon-webgl' import type { ManagedPaneInternal } from './pane-manager-types' import { @@ -63,6 +63,42 @@ function createFittablePane(): ManagedPaneInternal { return pane } +type FakeRenderService = { + _isPaused: boolean + _needsFullRefresh: boolean + refreshRows: Mock<(start: number, end: number, sync?: boolean) => void> +} + +/** A pane whose xterm render service is paused, as it is whenever the pane has + * no layout box: `display` decides whether the element is one xterm's + * IntersectionObserver would report as intersecting. */ +function createPausedPane(display: 'block' | 'none'): { + pane: ManagedPaneInternal + renderService: FakeRenderService +} { + const pane = createPane() + const renderService: FakeRenderService = { + _isPaused: true, + _needsFullRefresh: false, + // Mirrors xterm: a refresh while paused only latches the pending full repaint. + refreshRows: vi.fn<(start: number, end: number, sync?: boolean) => void>(() => { + if (renderService._isPaused) { + renderService._needsFullRefresh = true + } + }) + } + const view = { getComputedStyle: () => ({ display }) } + const element = { ownerDocument: { defaultView: view }, parentElement: null } + pane.container = element as never + pane.xtermContainer = element as never + pane.terminal = { + ...pane.terminal, + refresh: vi.fn(() => renderService.refreshRows(0, pane.terminal.rows - 1)), + _core: { _renderService: renderService } + } as never + return { pane, renderService } +} + describe('terminal WebGL addon lifecycle', () => { beforeEach(() => { resetTerminalWebglSuggestion() @@ -132,6 +168,36 @@ describe('terminal WebGL addon lifecycle', () => { expect(pane.terminal.refresh).toHaveBeenCalledWith(0, 23) }) + it('keeps the render pause latched when resetting a pane that has no layout box', () => { + // Regression: the atlas reset released xterm's pause for every pane of a + // visible manager, including a collapsed sibling of an expanded pane. That + // painted the freshly cleared model into an element with no box, and left + // the service unpaused for good — the IntersectionObserver only fires on a + // change, so it never re-pauses. Clearing _needsFullRefresh with it drops + // the repaint the observer owes the pane on reveal, and the deferred + // _pausedResizeTask that rides along with it. + const { pane, renderService } = createPausedPane('none') + + resetWebglTextureAtlas(pane) + + expect(renderService._isPaused).toBe(true) + expect(renderService._needsFullRefresh).toBe(true) + expect(pane.terminal.refresh).toHaveBeenCalledWith(0, 23) + }) + + it('still forces the paused render through for a pane that has a layout box', () => { + // The reveal case the release exists for: DOM-visible, but xterm's observer + // has not caught up, so a plain refresh() would be swallowed. + const { pane, renderService } = createPausedPane('block') + + resetWebglTextureAtlas(pane) + + expect(renderService._isPaused).toBe(false) + expect(renderService._needsFullRefresh).toBe(false) + expect(renderService.refreshRows).toHaveBeenCalledWith(0, 23, true) + expect(pane.terminal.refresh).not.toHaveBeenCalled() + }) + it('skips the reset while WebGL is latched off after a context loss', () => { const pane = createPane() pane.webglDisabledAfterContextLoss = true diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts index f162052cebe..9d05e6b876b 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts @@ -2,6 +2,7 @@ import { WebglAddon } from '@xterm/addon-webgl' import type { ManagedPaneInternal } from './pane-manager-types' import { recordTerminalWebglDiagnostic } from '../../../../shared/terminal-webgl-diagnostics' import { getLivePaneCensus } from './pane-manager-registry' +import { isManagedPaneDisplayNone } from './pane-display-visibility' import { forceRepaintThroughRenderPause } from './terminal-render-pause-release' import { getTerminalWebglAutoDecision, @@ -151,7 +152,17 @@ export function resetWebglTextureAtlas(pane: ManagedPaneInternal): void { // paused-render gate and the cleared model never repaints (stale bottom rows // until a drag-select forces a redraw). Force the paused render through // first; only fall back to refresh() when the terminal was not gated. - if (!forceRepaintThroughRenderPause(pane.terminal)) { + // + // Why the display check: that release is only right for a pane that is + // DOM-visible. A pane with no box at all (collapsed sibling of an expanded + // pane, a restore that stays display:none for its whole reattach) is + // legitimately paused, and releasing it paints the just-cleared model into + // nothing and then leaves the service unpaused for good — the observer only + // fires on a change, so it never re-pauses. Clearing _needsFullRefresh with + // it also drops the full repaint the observer owes the pane on reveal, and + // the deferred _pausedResizeTask that flushes alongside it. Latching is what + // xterm's own gate does, and the reveal repaints from the latch. + if (isManagedPaneDisplayNone(pane) || !forceRepaintThroughRenderPause(pane.terminal)) { // Why: refresh even without a WebGL addon so recovery never silently // no-ops — a DOM-rendered pane can hold stale pixels after reveal too. pane.terminal.refresh(0, pane.terminal.rows - 1) From 9d06b3ba938d3c5467de5b82b0bec322af2620a8 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:33:05 -0700 Subject: [PATCH 017/111] ci: stop docs-only commits from starting the skill-roundtrip matrix (#15474) A cancelled Skill update round trip on a README merge painted main red because push to main had no path filter. Share the PR path list on push, and skip expensive PR Checks when every changed file is docs. --- .github/workflows/pr.yml | 88 +++++++++++++- .github/workflows/skill-update-roundtrip.yml | 10 +- config/scripts/pr-code-change-scope.mjs | 41 +++++++ config/scripts/pr-code-change-scope.test.mjs | 107 ++++++++++++++++++ config/scripts/pr-e2e-gate-contract.test.mjs | 10 +- .../scripts/pr-workflow-parallelism.test.mjs | 1 + .../skill-update-roundtrip-workflow.test.mjs | 35 ++++++ 7 files changed, 288 insertions(+), 4 deletions(-) create mode 100644 config/scripts/pr-code-change-scope.mjs create mode 100644 config/scripts/pr-code-change-scope.test.mjs create mode 100644 config/scripts/skill-update-roundtrip-workflow.test.mjs diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 4717da652fc..48b545ef3ea 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -16,8 +16,46 @@ permissions: contents: read jobs: + # Why: a README/docs-only PR used to start the full matrix (32 test shards, + # two package jobs, typecheck, git compat, xterm, shell contracts). Path + # filters on `on.pull_request` would drop the `verify` check entirely; this + # detector keeps verify as the required aggregate and skips the expensive jobs. + code_paths: + name: detect code-relevant changes + runs-on: ubuntu-latest + outputs: + should_run: ${{ steps.filter.outputs.should_run }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + # Why blob:none: full history is needed for the merge-base diff, but historical + # file contents are not. Blobs are ~89% of this repo's pack, and Git fetches the + # few this job actually reads on demand. + fetch-depth: 0 + filter: blob:none + persist-credentials: false + + - name: Classify changed paths + id: filter + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + # Why --no-renames: name-only rename detection can report only the destination. + # A code file moved under docs/ must still expose its code-side deletion. + CHANGED="$(git diff --name-only --no-renames --diff-filter=ACDMR --merge-base "$BASE_SHA" "$HEAD_SHA")" + echo "Changed paths:" + printf '%s\n' "$CHANGED" + SHOULD_RUN="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-code-change-scope.mjs)" + echo "should_run=$SHOULD_RUN" >> "$GITHUB_OUTPUT" + echo "should_run=$SHOULD_RUN" + static_analysis: name: static analysis + needs: [code_paths] + if: needs.code_paths.outputs.should_run == 'true' runs-on: ubuntu-latest steps: @@ -140,6 +178,8 @@ jobs: run: node .github/scripts/check-root-directory-entries.mjs "$BASE_SHA" "$HEAD_SHA" typecheck: + needs: [code_paths] + if: needs.code_paths.outputs.should_run == 'true' runs-on: ubuntu-latest steps: @@ -166,6 +206,8 @@ jobs: git_compatibility: name: Git compatibility + needs: [code_paths] + if: needs.code_paths.outputs.should_run == 'true' runs-on: ubuntu-latest steps: @@ -231,6 +273,8 @@ jobs: xterm_patch_sync: name: xterm patch sync + needs: [code_paths] + if: needs.code_paths.outputs.should_run == 'true' runs-on: ubuntu-latest steps: @@ -259,6 +303,8 @@ jobs: shell_contracts: name: shell contracts + needs: [code_paths] + if: needs.code_paths.outputs.should_run == 'true' runs-on: ubuntu-latest # Why: this job's cost is almost entirely package download, and a stalled mirror has # no wall-clock bound of its own. A successful run finishes in ~4.5 minutes, so this @@ -372,6 +418,8 @@ jobs: test: name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }} + needs: [code_paths] + if: needs.code_paths.outputs.should_run == 'true' runs-on: ubuntu-latest strategy: fail-fast: false @@ -416,6 +464,8 @@ jobs: cross-version-wire: name: cross-version wire compatibility + needs: [code_paths] + if: needs.code_paths.outputs.should_run == 'true' runs-on: ubuntu-latest steps: @@ -443,6 +493,8 @@ jobs: managed_hook_node18: name: managed hooks on Node 18 + needs: [code_paths] + if: needs.code_paths.outputs.should_run == 'true' runs-on: ubuntu-latest steps: @@ -466,6 +518,8 @@ jobs: package: name: package + needs: [code_paths] + if: needs.code_paths.outputs.should_run == 'true' runs-on: ubuntu-latest steps: @@ -527,6 +581,8 @@ jobs: package_windows: name: package (windows) + needs: [code_paths] + if: needs.code_paths.outputs.should_run == 'true' runs-on: windows-2022 timeout-minutes: 30 @@ -592,8 +648,9 @@ jobs: # release runs retain full-suite coverage. e2e-paths: name: detect changed e2e specs + needs: [code_paths] runs-on: ubuntu-latest - if: github.event.pull_request.draft != true + if: github.event.pull_request.draft != true && needs.code_paths.outputs.should_run == 'true' # Why: detector only needs to read the checkout; do not inherit repo defaults. permissions: contents: read @@ -673,6 +730,7 @@ jobs: verify: if: always() needs: + - code_paths - static_analysis - root_directory_guard - typecheck @@ -696,6 +754,8 @@ jobs: # checked outside the loop or it would excuse the jobs above. - name: Require successful checks env: + CODE_PATHS: ${{ needs.code_paths.result }} + SHOULD_RUN: ${{ needs.code_paths.outputs.should_run }} STATIC_ANALYSIS: ${{ needs.static_analysis.result }} ROOT_DIRECTORY_GUARD: ${{ needs.root_directory_guard.result }} TYPECHECK: ${{ needs.typecheck.result }} @@ -707,7 +767,33 @@ jobs: PACKAGE: ${{ needs.package.result }} PACKAGE_WINDOWS: ${{ needs.package_windows.result }} run: | + if [ "$CODE_PATHS" != "success" ]; then + exit 1 + fi + if [ "$SHOULD_RUN" != "true" ]; then + echo "Docs-only change; expensive PR checks skipped." + if [ "$ROOT_DIRECTORY_GUARD" != "success" ]; then + exit 1 + fi + for result in \ + "$STATIC_ANALYSIS" \ + "$TYPECHECK" \ + "$GIT_COMPATIBILITY" \ + "$XTERM_PATCH_SYNC" \ + "$SHELL_CONTRACTS" \ + "$TEST" \ + "$MANAGED_HOOK_NODE18" \ + "$PACKAGE" \ + "$PACKAGE_WINDOWS"; do + if [ "$result" != "skipped" ]; then + exit 1 + fi + done + exit 0 + fi + # Require success when the PR has code-relevant changes for result in \ + "$CODE_PATHS" \ "$STATIC_ANALYSIS" \ "$ROOT_DIRECTORY_GUARD" \ "$TYPECHECK" \ diff --git a/.github/workflows/skill-update-roundtrip.yml b/.github/workflows/skill-update-roundtrip.yml index 6c9418e1f64..71fcf264f69 100644 --- a/.github/workflows/skill-update-roundtrip.yml +++ b/.github/workflows/skill-update-roundtrip.yml @@ -1,18 +1,26 @@ name: Skill update round trip +# Why the same paths on push as pull_request: GitHub evaluates each event's +# filters independently. The PR already skipped README-only changes, but the +# unfiltered `push` to main still started the 13-job matrix. Cancelling that +# run marks the default-branch tip failed, so the repo shows a red X. on: pull_request: - paths: + paths: &skill-roundtrip-paths - 'skills/**' - 'resources/skills/**' - 'config/scripts/verify-skill-update-roundtrip.mjs' + - 'config/scripts/skill-update-roundtrip-workflow.test.mjs' - 'src/main/skills/skill-freshness-eligibility.ts' - 'src/shared/skill-freshness.ts' - '.github/workflows/skill-update-roundtrip.yml' + # Why unfiltered: GitHub ignores `paths` on merge_group. We do not run a merge + # queue today; if one is added, gate the matrix on a path job first. merge_group: push: branches: - main + paths: *skill-roundtrip-paths jobs: roundtrip: diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs new file mode 100644 index 00000000000..d19eee52454 --- /dev/null +++ b/config/scripts/pr-code-change-scope.mjs @@ -0,0 +1,41 @@ +import { readFileSync } from 'node:fs' +import process from 'node:process' +import { pathToFileURL } from 'node:url' + +const DOCS_ONLY_FILES = new Set([ + 'README.md', + 'LICENSE', + 'AGENTS.md', + 'CLAUDE.md', + 'Agents.md', + 'Claude.md', + '.github/CONTRIBUTING.md', + '.github/pull_request_template.md', + '.github/CODEOWNERS' +]) + +const DOCS_ONLY_PREFIXES = ['docs/', '.github/ISSUE_TEMPLATE/'] + +export function isDocsOnlyPath(file) { + if (DOCS_ONLY_FILES.has(file)) { + return true + } + if (DOCS_ONLY_PREFIXES.some((prefix) => file.startsWith(prefix))) { + return true + } + return /^README\.[^/]+\.md$/.test(file) +} + +export function shouldRunPrChecks(changedFiles) { + // Why empty-run: a silent empty diff is more likely a detector bug than a + // genuine no-op PR, so fail closed and keep the expensive jobs. + if (changedFiles.length === 0) { + return true + } + return changedFiles.some((file) => !isDocsOnlyPath(file)) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const files = readFileSync(0, 'utf8').split('\n').filter(Boolean) + process.stdout.write(shouldRunPrChecks(files) ? 'true\n' : 'false\n') +} diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs new file mode 100644 index 00000000000..f71d6c42934 --- /dev/null +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -0,0 +1,107 @@ +import { readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { parse } from 'yaml' +import { isDocsOnlyPath, shouldRunPrChecks } from './pr-code-change-scope.mjs' + +const projectDir = resolve(import.meta.dirname, '../..') +const prWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8')) + +const gatedIf = "needs.code_paths.outputs.should_run == 'true'" +const expensiveJobs = [ + 'static_analysis', + 'typecheck', + 'git_compatibility', + 'xterm_patch_sync', + 'shell_contracts', + 'test', + 'cross-version-wire', + 'managed_hook_node18', + 'package', + 'package_windows' +] + +describe('docs-only path classification', () => { + it('treats the WeChat README PR files as docs-only', () => { + expect( + shouldRunPrChecks([ + 'README.md', + 'docs/assets/wechat-qr-group8.jpg', + 'docs/readme/README.zh-CN.md' + ]) + ).toBe(false) + }) + + it('skips root instruction files and GitHub markdown templates', () => { + expect(isDocsOnlyPath('AGENTS.md')).toBe(true) + expect(isDocsOnlyPath('CLAUDE.md')).toBe(true) + expect(isDocsOnlyPath('LICENSE')).toBe(true) + expect(isDocsOnlyPath('.github/CONTRIBUTING.md')).toBe(true) + expect(isDocsOnlyPath('.github/pull_request_template.md')).toBe(true) + expect(isDocsOnlyPath('.github/ISSUE_TEMPLATE/bug_report.yml')).toBe(true) + expect(isDocsOnlyPath('.github/CODEOWNERS')).toBe(true) + }) + + it('still runs PR Checks for product markdown and CI', () => { + expect(isDocsOnlyPath('skills/computer-use/SKILL.md')).toBe(false) + expect(isDocsOnlyPath('skill-guides/orca-cli.md')).toBe(false) + expect(isDocsOnlyPath('.github/workflows/pr.yml')).toBe(false) + expect(isDocsOnlyPath('src/main/index.ts')).toBe(false) + expect(isDocsOnlyPath('config/scripts/pr-code-change-scope.mjs')).toBe(false) + expect(shouldRunPrChecks(['README.md', 'src/main/index.ts'])).toBe(true) + }) + + it('runs PR Checks when the diff is empty rather than skipping by accident', () => { + expect(shouldRunPrChecks([])).toBe(true) + }) +}) + +describe('PR Checks docs-only skip wiring', () => { + it('classifies the PR range with a tested script and expands renames', () => { + const classify = prWorkflow.jobs.code_paths.steps.find( + (step) => step.name === 'Classify changed paths' + ) + expect(classify.run).toContain('--diff-filter=ACDMR') + expect(classify.run).toContain('--no-renames') + expect(classify.run).toContain('--merge-base "$BASE_SHA" "$HEAD_SHA"') + expect(classify.run).toContain('node config/scripts/pr-code-change-scope.mjs') + expect(prWorkflow.jobs.code_paths.outputs.should_run).toBe( + '${{ steps.filter.outputs.should_run }}' + ) + }) + + it('keeps the cheap root-directory guard on docs-only PRs', () => { + expect(prWorkflow.jobs.root_directory_guard.if).toBeUndefined() + expect(prWorkflow.jobs.root_directory_guard.needs).toBeUndefined() + }) + + it('skips expensive jobs unless the detector says the PR has code', () => { + for (const jobName of expensiveJobs) { + expect(prWorkflow.jobs[jobName].needs, jobName).toEqual(['code_paths']) + expect(prWorkflow.jobs[jobName].if, jobName).toBe(gatedIf) + } + }) + + it('skips e2e detection on docs-only PRs without dropping the draft gate', () => { + expect(prWorkflow.jobs['e2e-paths'].needs).toEqual(['code_paths']) + expect(prWorkflow.jobs['e2e-paths'].if).toBe( + "github.event.pull_request.draft != true && needs.code_paths.outputs.should_run == 'true'" + ) + }) + + it('lets verify pass when expensive jobs are skipped for docs-only PRs', () => { + const verifyStep = prWorkflow.jobs.verify.steps.find( + (step) => step.name === 'Require successful checks' + ) + expect(prWorkflow.jobs.verify.needs[0]).toBe('code_paths') + expect(verifyStep.env.SHOULD_RUN).toBe('${{ needs.code_paths.outputs.should_run }}') + expect(verifyStep.run).toContain('"$SHOULD_RUN" != "true"') + const docsOnlyBranch = verifyStep.run.slice( + 0, + verifyStep.run.indexOf('# Require success when the PR has code-relevant changes') + ) + expect(docsOnlyBranch).toContain('if [ "$result" != "skipped" ]') + expect(docsOnlyBranch).not.toContain('"$result" != "success"') + expect(verifyStep.run).toContain('"$ROOT_DIRECTORY_GUARD" != "success"') + }) +}) diff --git a/config/scripts/pr-e2e-gate-contract.test.mjs b/config/scripts/pr-e2e-gate-contract.test.mjs index 46de7b39fcc..92faae907a1 100644 --- a/config/scripts/pr-e2e-gate-contract.test.mjs +++ b/config/scripts/pr-e2e-gate-contract.test.mjs @@ -53,11 +53,17 @@ describe('PR E2E gate contract', () => { // job without adding it to the strict loop fails here instead of silently // leaving that job unenforced. This is what caught GIT_COMPATIBILITY and // SHELL_CONTRACTS being absent from an earlier hardcoded list. - const strictLoop = verifyStep.run.slice(0, verifyStep.run.indexOf('done')) + // Why lastIndexOf: the docs-only branch has its own loop that allows skipped. + const successMarker = '# Require success when the PR has code-relevant changes' + const successLoop = verifyStep.run.slice( + verifyStep.run.indexOf(successMarker), + verifyStep.run.lastIndexOf('done') + ) + expect(successLoop.length).toBeGreaterThan(0) for (const job of prWorkflow.jobs.verify.needs) { const envVar = job.toUpperCase() expect(verifyStep.env[envVar]).toBe(`\${{ needs.${job}.result }}`) - expect(strictLoop).toContain(`"$${envVar}"`) + expect(successLoop).toContain(`"$${envVar}"`) } }) diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index 5b9ab226502..59ea62b1fc2 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -334,6 +334,7 @@ describe('PR workflow parallelism', () => { it('keeps verify as the aggregate required check', () => { expect(workflow.jobs.verify.needs).toEqual([ + 'code_paths', 'static_analysis', 'root_directory_guard', 'typecheck', diff --git a/config/scripts/skill-update-roundtrip-workflow.test.mjs b/config/scripts/skill-update-roundtrip-workflow.test.mjs new file mode 100644 index 00000000000..f7bea277ed9 --- /dev/null +++ b/config/scripts/skill-update-roundtrip-workflow.test.mjs @@ -0,0 +1,35 @@ +import { readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { parse } from 'yaml' + +const projectDir = resolve(import.meta.dirname, '../..') +const workflow = parse( + readFileSync(join(projectDir, '.github/workflows/skill-update-roundtrip.yml'), 'utf8') +) + +const expectedPaths = [ + 'skills/**', + 'resources/skills/**', + 'config/scripts/verify-skill-update-roundtrip.mjs', + 'config/scripts/skill-update-roundtrip-workflow.test.mjs', + 'src/main/skills/skill-freshness-eligibility.ts', + 'src/shared/skill-freshness.ts', + '.github/workflows/skill-update-roundtrip.yml' +] + +describe('skill-update-roundtrip workflow triggers', () => { + it('uses the same path filter on push to main as on pull_request', () => { + // Why: GitHub evaluates each event independently. An unfiltered `push` to + // main started the 13-job matrix on README-only merges; cancelling that + // run paints the default-branch tip red. + expect(workflow.on.pull_request.paths).toEqual(expectedPaths) + expect(workflow.on.push.branches).toEqual(['main']) + expect(workflow.on.push.paths).toEqual(workflow.on.pull_request.paths) + }) + + it('does not claim a path filter on merge_group that GitHub would ignore', () => { + expect(workflow.on.merge_group).toBeDefined() + expect(workflow.on.merge_group?.paths).toBeUndefined() + }) +}) From 471bc9d8ceec7f84070a169fcac9268a46907c40 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Thu, 20 Aug 2026 00:16:04 -0700 Subject: [PATCH 018/111] Ship the WSL transcript helper with the Windows relay (STA-4831) (#15529) --- config/scripts/build-relay.mjs | 102 +++++++++++++----- ...package-electron-runtime-contract.test.mjs | 14 +-- .../scripts/relay-artifact-manifest.test.mjs | 81 ++++++++++++++ package.json | 2 +- src/main/ssh/ssh-remote-commands.test.ts | 66 ++++++++++++ src/main/ssh/ssh-remote-commands.ts | 40 +++---- .../ssh-system-transport.integration.test.ts | 11 +- src/shared/relay-artifacts.ts | 57 ++++++++++ 8 files changed, 315 insertions(+), 58 deletions(-) create mode 100644 config/scripts/relay-artifact-manifest.test.mjs create mode 100644 src/shared/relay-artifacts.ts diff --git a/config/scripts/build-relay.mjs b/config/scripts/build-relay.mjs index 23ad74ab47a..125538a7236 100644 --- a/config/scripts/build-relay.mjs +++ b/config/scripts/build-relay.mjs @@ -10,8 +10,22 @@ */ import { build } from 'esbuild' import { createHash } from 'node:crypto' -import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { + copyFileSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' import { join } from 'node:path' +import { + RELAY_BUILD_PLATFORMS, + RELAY_VERSION_FILENAME, + isWindowsRelayPlatform, + relayArtifactFilenames +} from '../../src/shared/relay-artifacts.ts' const __dirname = import.meta.dirname // Why: the script lives under config/scripts, so go two levels up to reach the repo root. @@ -19,6 +33,13 @@ const ROOT = join(__dirname, '..', '..') const RELAY_ENTRY = join(ROOT, 'src', 'relay', 'relay.ts') const WATCHER_ENTRY = join(ROOT, 'src', 'main', 'ipc', 'parcel-watcher-process-entry.ts') const AI_VAULT_SERVICE_ENTRY = join(ROOT, 'src', 'relay', 'ai-vault-service-entry.ts') +const WSL_TRANSCRIPT_FS_PROCESS_ENTRY = join( + ROOT, + 'src', + 'main', + 'native-chat', + 'wsl-transcript-fs-process-entry.ts' +) const MANAGED_HOOK_RUNTIME_ENTRY = join( ROOT, 'src', @@ -35,19 +56,17 @@ const NODE_PTY_CONSOLE_LIST_PATCH_SOURCE = join( NODE_PTY_CONSOLE_LIST_PATCH_FILENAME ) -const PLATFORMS = [ - 'linux-x64', - 'linux-arm64', - 'darwin-x64', - 'darwin-arm64', - 'win32-x64', - 'win32-arm64' -] +// Why: lets the packaging contract test build into a temp tree instead of +// clobbering a developer's out/relay or racing tests that read it. +const OUT_ROOT = process.env.ORCA_RELAY_OUT_ROOT ?? join(ROOT, 'out', 'relay') const RELAY_VERSION = '0.1.0' -for (const platform of PLATFORMS) { - const outDir = join(ROOT, 'out', 'relay', platform) +for (const platform of RELAY_BUILD_PLATFORMS) { + const outDir = join(OUT_ROOT, platform) + // Why: a stale companion left by an earlier build would otherwise satisfy the + // manifest check and be hashed into .version, shipping mixed-generation bytes. + rmSync(outDir, { recursive: true, force: true }) mkdirSync(outDir, { recursive: true }) await build({ @@ -67,7 +86,7 @@ for (const platform of PLATFORMS) { } }) - if (platform.startsWith('win32-')) { + if (isWindowsRelayPlatform(platform)) { copyFileSync( NODE_PTY_CONSOLE_LIST_PATCH_SOURCE, join(outDir, NODE_PTY_CONSOLE_LIST_PATCH_FILENAME) @@ -104,6 +123,23 @@ for (const platform of PLATFORMS) { } }) + // Why beside the service: the spawn resolves this child next to its own + // bundle, and a relay host has no desktop out/main to fall back to. + await build({ + entryPoints: [WSL_TRANSCRIPT_FS_PROCESS_ENTRY], + bundle: true, + platform: 'node', + target: 'node18', + format: 'cjs', + outfile: join(outDir, 'wsl-transcript-fs-process-entry.js'), + external: ['electron'], + sourcemap: false, + minify: true, + define: { + 'process.env.NODE_ENV': '"production"' + } + }) + await build({ entryPoints: [MANAGED_HOOK_RUNTIME_ENTRY], bundle: true, @@ -121,24 +157,34 @@ for (const platform of PLATFORMS) { } }) - // Why: include a content hash so the deploy check detects code changes - // even when RELAY_VERSION hasn't been bumped. Hash every executable module - // so a companion-only change always deploys beside the matching relay host. - const relayContent = readFileSync(join(outDir, 'relay.js')) - const watcherContent = readFileSync(join(outDir, 'relay-watcher.js')) - const aiVaultServiceContent = readFileSync(join(outDir, 'relay-ai-vault-service.js')) - const managedHookRuntimeContent = readFileSync(join(outDir, 'managed-hook-runtime.js')) + // Why: include a content hash so the deploy check detects code changes even + // when RELAY_VERSION hasn't been bumped. Hashing the whole manifest means a + // companion-only change still selects a fresh immutable relay directory. + const expected = relayArtifactFilenames(isWindowsRelayPlatform(platform)) const hash = createHash('sha256') - .update(relayContent) - .update(watcherContent) - .update(aiVaultServiceContent) - .update(managedHookRuntimeContent) - // Why: changing the remote node-pty patch must select a fresh immutable Windows relay directory. - if (platform.startsWith('win32-')) { - hash.update(readFileSync(join(outDir, NODE_PTY_CONSOLE_LIST_PATCH_FILENAME))) + for (const filename of expected) { + const artifactPath = join(outDir, filename) + if (!existsSync(artifactPath)) { + throw new Error( + `Relay ${platform} declares ${filename} in RELAY_ARTIFACTS but never emitted it. ` + + 'Add the build step, or drop it from src/shared/relay-artifacts.ts.' + ) + } + hash.update(readFileSync(artifactPath)) } const contentHash = hash.digest('hex').slice(0, 12) - writeFileSync(join(outDir, '.version'), `${RELAY_VERSION}+${contentHash}`) + + // Close the loop: an artifact emitted here but absent from the manifest would + // ship unhashed and unprobed — exactly how the WSL helper went missing. + const emitted = readdirSync(outDir).filter((name) => name !== RELAY_VERSION_FILENAME) + const undeclared = emitted.filter((name) => !expected.includes(name)) + if (undeclared.length > 0) { + throw new Error( + `Relay ${platform} emitted undeclared artifacts: ${undeclared.join(', ')}. ` + + 'Add them to RELAY_ARTIFACTS in src/shared/relay-artifacts.ts.' + ) + } + writeFileSync(join(outDir, RELAY_VERSION_FILENAME), `${RELAY_VERSION}+${contentHash}`) console.log(`Built relay for ${platform} → ${outDir}/relay.js`) } @@ -149,7 +195,7 @@ for (const platform of PLATFORMS) { // Windows app via the same out/relay extraResources mapping. { const wslEntry = join(ROOT, 'src', 'relay', 'wsl-agent-hook-relay.ts') - const outDir = join(ROOT, 'out', 'relay', 'wsl') + const outDir = join(OUT_ROOT, 'wsl') mkdirSync(outDir, { recursive: true }) await build({ entryPoints: [wslEntry], diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index cb54f25031e..a539dc2b717 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -3,6 +3,7 @@ import { createRequire } from 'node:module' import { join, resolve } from 'node:path' import { describe, expect, it } from 'vitest' import { parse } from 'yaml' +import { relayArtifactFilenames } from '../../src/shared/relay-artifacts.ts' const projectDir = resolve(import.meta.dirname, '../..') const require = createRequire(import.meta.url) @@ -201,14 +202,15 @@ describe('Electron runtime package contract', () => { expect(relayBuild).toContain("'parcel-watcher-process-entry.ts'") expect(relayBuild).toContain("outfile: join(outDir, 'relay-watcher.js')") - expect(relayBuild).toContain("readFileSync(join(outDir, 'relay-watcher.js'))") expect(relayBuild).toContain("outfile: join(outDir, 'relay-ai-vault-service.js')") - expect(relayBuild).toContain("readFileSync(join(outDir, 'relay-ai-vault-service.js'))") expect(builderConfig).toContain("from: 'out/relay'") - expect(remoteCommands).toContain("joinRemotePath(host, remoteRelayDir, 'relay-watcher.js')") - expect(remoteCommands).toContain( - "joinRemotePath(host, remoteRelayDir, 'relay-ai-vault-service.js')" - ) + + // Hashing and remote install probing are manifest-driven, so the contract + // is that both companions are declared once and that both sites read it. + expect(relayArtifactFilenames(true)).toContain('relay-watcher.js') + expect(relayArtifactFilenames(true)).toContain('relay-ai-vault-service.js') + expect(relayBuild).toContain('relayArtifactFilenames(') + expect(remoteCommands).toContain('relayArtifactFilenames(') const assertRelayGate = (steps, publishStepName) => { const names = steps.map((step) => step.name) diff --git a/config/scripts/relay-artifact-manifest.test.mjs b/config/scripts/relay-artifact-manifest.test.mjs new file mode 100644 index 00000000000..2796a361778 --- /dev/null +++ b/config/scripts/relay-artifact-manifest.test.mjs @@ -0,0 +1,81 @@ +// Packaged-relay contract: what `build:relay` actually writes to disk. +// +// Asserts against a real build, not the source tree — the unit suites cannot see +// this gap, because the WSL transcript dispatcher runs in-process under vitest +// and never forks. +import { execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { + RELAY_BUILD_PLATFORMS, + RELAY_VERSION_FILENAME, + isWindowsRelayPlatform, + relayArtifactFilenames +} from '../../src/shared/relay-artifacts.ts' + +const projectDir = resolve(import.meta.dirname, '../..') +// Its own tree: building into out/relay would clobber a developer's build and +// race the suites that read it. +const relayOutDir = mkdtempSync(join(tmpdir(), 'orca-relay-contract-')) + +beforeAll(() => { + execFileSync('node', [join(projectDir, 'config', 'scripts', 'build-relay.mjs')], { + cwd: projectDir, + stdio: 'pipe', + env: { ...process.env, ORCA_RELAY_OUT_ROOT: relayOutDir } + }) +}, 120_000) + +afterAll(() => { + rmSync(relayOutDir, { recursive: true, force: true }) +}) + +describe('packaged relay artifact manifest', () => { + it.each([...RELAY_BUILD_PLATFORMS])('emits exactly the declared artifacts for %s', (platform) => { + const outDir = join(relayOutDir, platform) + const expected = relayArtifactFilenames(isWindowsRelayPlatform(platform)) + + for (const filename of expected) { + expect(existsSync(join(outDir, filename)), `${platform}/${filename} missing`).toBe(true) + } + // Exactly, not merely at least: an undeclared artifact ships unhashed and + // unprobed, which is the same gap in the other direction. + const emitted = readdirSync(outDir) + .filter((name) => name !== RELAY_VERSION_FILENAME) + .sort() + expect(emitted).toEqual([...expected].sort()) + }) + + it.each([...RELAY_BUILD_PLATFORMS])('hashes every declared artifact for %s', (platform) => { + const outDir = join(relayOutDir, platform) + const hash = createHash('sha256') + for (const filename of relayArtifactFilenames(isWindowsRelayPlatform(platform))) { + hash.update(readFileSync(join(outDir, filename))) + } + const version = readFileSync(join(outDir, RELAY_VERSION_FILENAME), 'utf8') + + // A companion left out of the hash lets a changed relay reuse an existing + // immutable remote directory, serving a mixed-generation install forever. + expect(version.split('+')[1]).toBe(hash.digest('hex').slice(0, 12)) + }) + + it('ships the WSL transcript helper beside the service that forks it', () => { + for (const platform of RELAY_BUILD_PLATFORMS) { + const outDir = join(relayOutDir, platform) + const service = readFileSync(join(outDir, 'relay-ai-vault-service.js'), 'utf8') + + // The bundled service reaches the fork, so the entry must sit beside it: + // the spawn resolves the child relative to its own bundle directory. + expect(service, `${platform} service no longer forks the helper`).toContain( + 'wsl-transcript-fs-process-entry.js' + ) + expect( + existsSync(join(outDir, 'wsl-transcript-fs-process-entry.js')), + `${platform} forks a helper it does not ship` + ).toBe(true) + } + }) +}) diff --git a/package.json b/package.json index ceac925f0b2..3fd332f5fe1 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "dev": "pnpm run ensure:electron-runtime && node config/scripts/run-electron-vite-dev.mjs", "dev-stable-name": "pnpm run ensure:electron-runtime && node config/scripts/run-electron-vite-dev.mjs --stable-name", "dev:web": "vite --config vite.web.config.ts --host 127.0.0.1", - "build:relay": "node config/scripts/build-relay.mjs", + "build:relay": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON config/scripts/build-relay.mjs", "build:computer-macos": "node config/scripts/build-computer-macos.mjs", "build:keyboard-layout-macos": "node config/scripts/build-keyboard-layout-macos.mjs", "build:notification-status-macos": "node config/scripts/build-notification-status-macos.mjs", diff --git a/src/main/ssh/ssh-remote-commands.test.ts b/src/main/ssh/ssh-remote-commands.test.ts index 18738a930c6..4e75720121b 100644 --- a/src/main/ssh/ssh-remote-commands.test.ts +++ b/src/main/ssh/ssh-remote-commands.test.ts @@ -4,6 +4,7 @@ import { mkdirSync, mkdtempSync, readdirSync, + writeFileSync, rmSync, statSync, utimesSync @@ -30,6 +31,10 @@ import { relayLivenessProbeCommand } from './ssh-remote-commands' import { getRemoteHostPlatform } from './ssh-remote-platform' +import { + RELAY_INSTALL_COMPLETE_FILENAME, + relayArtifactFilenames +} from '../../shared/relay-artifacts' const posix = getRemoteHostPlatform('linux-x64') const windows = getRemoteHostPlatform('win32-x64') @@ -120,6 +125,67 @@ describe('ssh remote command builders', () => { expect(probe).toContain('relay-ai-vault-service.js') }) + it('requires every declared relay artifact before calling an install complete', () => { + const posixProbe = probeRelayInstalledCommand(posix, '/home/me/relay') + for (const filename of relayArtifactFilenames(false)) { + expect(posixProbe, `POSIX probe ignores ${filename}`).toContain(filename) + } + expect(posixProbe).toContain(RELAY_INSTALL_COMPLETE_FILENAME) + // A POSIX relay must not be asked for the Windows-only node-pty patch. + expect(posixProbe).not.toContain('node-pty-1.1.0-console-list-agent-patch.cjs') + + const windowsProbe = decodePowerShellCommand( + probeRelayInstalledCommand(windows, 'C:/Users/me/relay') + ) + for (const filename of relayArtifactFilenames(true)) { + expect(windowsProbe, `Windows probe ignores ${filename}`).toContain(filename) + } + expect(windowsProbe).toContain(RELAY_INSTALL_COMPLETE_FILENAME) + }) + + // Stage a complete install, then remove one companion: the probe must flip. + function stageRelayInstall(isWindows: boolean): string { + const dir = mkdtempSync(join(tmpdir(), 'orca-relay-probe-')) + for (const filename of relayArtifactFilenames(isWindows)) { + writeFileSync(join(dir, filename), '') + } + writeFileSync(join(dir, RELAY_INSTALL_COMPLETE_FILENAME), '') + return dir + } + + it.skipIf(!powerShellExecutable)( + 'rejects a Windows install missing only the WSL transcript helper', + async () => { + const dir = stageRelayInstall(true) + const probe = (): string => decodePowerShellCommand(probeRelayInstalledCommand(windows, dir)) + try { + expect((await runPowerShellCommand(powerShellExecutable!, probe())).trim()).toBe('OK') + + // The exact shape a pre-STA-4831 relay left behind: everything but this. + rmSync(join(dir, 'wsl-transcript-fs-process-entry.js')) + expect((await runPowerShellCommand(powerShellExecutable!, probe())).trim()).toBe('MISSING') + } finally { + rmSync(dir, { recursive: true, force: true }) + } + } + ) + + it.skipIf(process.platform === 'win32')( + 'rejects a POSIX install missing only the WSL transcript helper', + async () => { + const dir = stageRelayInstall(false) + const probe = (): string => probeRelayInstalledCommand(posix, dir) + try { + expect((await runShellCommand(probe())).trim()).toBe('OK') + + rmSync(join(dir, 'wsl-transcript-fs-process-entry.js')) + expect((await runShellCommand(probe())).trim()).toBe('MISSING') + } finally { + rmSync(dir, { recursive: true, force: true }) + } + } + ) + it('uses encoded PowerShell for Windows deploy commands', () => { expect(readRemoteHomeCommand(windows)).toContain('powershell.exe') expect(makeRemoteDirectoryCommand(windows, 'C:/Users/me/.orca-remote')).toContain( diff --git a/src/main/ssh/ssh-remote-commands.ts b/src/main/ssh/ssh-remote-commands.ts index 603b6356a1f..4fa1445969d 100644 --- a/src/main/ssh/ssh-remote-commands.ts +++ b/src/main/ssh/ssh-remote-commands.ts @@ -1,3 +1,7 @@ +import { + RELAY_INSTALL_COMPLETE_FILENAME, + relayArtifactFilenames +} from '../../shared/relay-artifacts' import type { RemoteHostPlatform } from './ssh-remote-platform' import { isWindowsRemoteHost, joinRemotePath, remoteDirname } from './ssh-remote-platform' import { powerShellCommand, powerShellLiteral, powerShellNativeArg } from './ssh-remote-powershell' @@ -85,35 +89,31 @@ export function writeRemoteEmptyFileCommand(host: RemoteHostPlatform, remotePath ) } +/** + * A partial install must read as MISSING, so every file the manifest ships is + * probed — not a hand-kept subset. A relay that advertises the AI Vault title + * service but lacks the WSL transcript helper would otherwise pass this probe + * and then answer WSL title requests with silence. + */ export function probeRelayInstalledCommand( host: RemoteHostPlatform, remoteRelayDir: string ): string { - const relayJs = joinRemotePath(host, remoteRelayDir, 'relay.js') - const relayWatcherJs = joinRemotePath(host, remoteRelayDir, 'relay-watcher.js') - const relayAiVaultServiceJs = joinRemotePath(host, remoteRelayDir, 'relay-ai-vault-service.js') - const managedHookRuntimeJs = joinRemotePath(host, remoteRelayDir, 'managed-hook-runtime.js') - const installComplete = joinRemotePath(host, remoteRelayDir, '.install-complete') + const required = [ + ...relayArtifactFilenames(isWindowsRemoteHost(host)), + RELAY_INSTALL_COMPLETE_FILENAME + ].map((filename) => joinRemotePath(host, remoteRelayDir, filename)) if (!isWindowsRemoteHost(host)) { - return ( - `test -d ${shellEscape(remoteRelayDir)} ` + - `&& test -f ${shellEscape(relayJs)} ` + - `&& test -f ${shellEscape(relayWatcherJs)} ` + - `&& test -f ${shellEscape(relayAiVaultServiceJs)} ` + - `&& test -f ${shellEscape(managedHookRuntimeJs)} ` + - `&& test -f ${shellEscape(installComplete)} ` + - `&& echo OK || echo MISSING` - ) + const fileTests = required.map((path) => `&& test -f ${shellEscape(path)} `).join('') + return `test -d ${shellEscape(remoteRelayDir)} ${fileTests}&& echo OK || echo MISSING` } return powerShellCommand( [ `$dir = ${powerShellLiteral(remoteRelayDir)}`, - `$relay = ${powerShellLiteral(relayJs)}`, - `$watcher = ${powerShellLiteral(relayWatcherJs)}`, - `$aiVaultService = ${powerShellLiteral(relayAiVaultServiceJs)}`, - `$managedHooks = ${powerShellLiteral(managedHookRuntimeJs)}`, - `$complete = ${powerShellLiteral(installComplete)}`, - "if ((Test-Path -LiteralPath $dir -PathType Container) -and (Test-Path -LiteralPath $relay -PathType Leaf) -and (Test-Path -LiteralPath $watcher -PathType Leaf) -and (Test-Path -LiteralPath $aiVaultService -PathType Leaf) -and (Test-Path -LiteralPath $managedHooks -PathType Leaf) -and (Test-Path -LiteralPath $complete -PathType Leaf)) { 'OK' } else { 'MISSING' }" + `$required = @(${required.map((path) => powerShellLiteral(path)).join(', ')})`, + '$ok = Test-Path -LiteralPath $dir -PathType Container', + 'foreach ($f in $required) { if (-not (Test-Path -LiteralPath $f -PathType Leaf)) { $ok = $false } }', + "if ($ok) { 'OK' } else { 'MISSING' }" ].join('; ') ) } diff --git a/src/main/ssh/ssh-system-transport.integration.test.ts b/src/main/ssh/ssh-system-transport.integration.test.ts index 6199dfeef52..6e45077698a 100644 --- a/src/main/ssh/ssh-system-transport.integration.test.ts +++ b/src/main/ssh/ssh-system-transport.integration.test.ts @@ -11,6 +11,7 @@ import { deployAndLaunchRelay } from './ssh-relay-deploy' import { SshChannelMultiplexer } from './ssh-channel-multiplexer' import { uploadDirectoryViaSystemSsh } from './ssh-system-fallback' import type { SshTarget } from '../../shared/ssh-types' +import { relayArtifactFilenames } from '../../shared/relay-artifacts' const RELAY_VERSION = '0.1.0+systemtransport' @@ -54,9 +55,13 @@ exec /bin/sh -c "$cmd" } function writeFakeRelay(dir: string): void { - writeFileSync(join(dir, 'relay-watcher.js'), '') - writeFileSync(join(dir, 'relay-ai-vault-service.js'), '') - writeFileSync(join(dir, 'managed-hook-runtime.js'), '') + // Stage every companion the manifest declares — relay.js gets real content + // below — so adding one cannot silently fail the completeness probe here. + for (const filename of relayArtifactFilenames(false)) { + if (filename !== 'relay.js') { + writeFileSync(join(dir, filename), '') + } + } writeFileSync( join(dir, 'relay.js'), ` diff --git a/src/shared/relay-artifacts.ts b/src/shared/relay-artifacts.ts new file mode 100644 index 00000000000..1801667364c --- /dev/null +++ b/src/shared/relay-artifacts.ts @@ -0,0 +1,57 @@ +/** + * What a packaged relay directory must contain, declared once. The build, the + * content hash, and the remote install probe all read this list; they used to + * keep three of their own, which is how the WSL helper reached the desktop app + * but never a relay. + * + * Order is load-bearing: the hash concatenates these files in sequence. + * + * Keep this file erasable-only TypeScript — build-relay.mjs imports it directly + * under Node's type stripping, which rejects enums, namespaces, and parameter + * properties. + */ + +/** Every platform the relay is bundled for; each gets the full artifact set. */ +export const RELAY_BUILD_PLATFORMS = [ + 'linux-x64', + 'linux-arm64', + 'darwin-x64', + 'darwin-arm64', + 'win32-x64', + 'win32-arm64' +] as const + +export type RelayBuildPlatform = (typeof RELAY_BUILD_PLATFORMS)[number] + +export function isWindowsRelayPlatform(platform: string): boolean { + return platform.startsWith('win32-') +} + +export type RelayArtifact = { + filename: string + /** Only Windows relays ship it; other hosts must neither receive nor probe it. */ + windowsOnly?: boolean +} + +export const RELAY_ARTIFACTS: readonly RelayArtifact[] = [ + { filename: 'relay.js' }, + { filename: 'relay-watcher.js' }, + { filename: 'relay-ai-vault-service.js' }, + { filename: 'managed-hook-runtime.js' }, + // Forked by the AI Vault title reader; without it a relay answers every WSL + // title request with no title and no error. + { filename: 'wsl-transcript-fs-process-entry.js' }, + { filename: 'node-pty-1.1.0-console-list-agent-patch.cjs', windowsOnly: true } +] + +/** Written after the artifacts, so it is never an input to its own hash. */ +export const RELAY_VERSION_FILENAME = '.version' + +/** Written last by the installer; its absence means a torn install. */ +export const RELAY_INSTALL_COMPLETE_FILENAME = '.install-complete' + +export function relayArtifactFilenames(isWindows: boolean): string[] { + return RELAY_ARTIFACTS.filter((artifact) => !artifact.windowsOnly || isWindows).map( + (artifact) => artifact.filename + ) +} From 2e895da937171dbaa8b9ff697ddff180336f1b42 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:22:59 -0700 Subject: [PATCH 019/111] fix(browser): name the requesting frame and the permission in denial notices (#15542) * fix(browser): name the requesting frame and the permission in denial notices Two defects in the same notice, both found by the review of #15481 and left out of it deliberately. The notice named the wrong site. setPermissionRequestHandler passed webContents.getURL(), which is the top-level document, so a permission request from a cross-origin sub-frame was attributed to the embedder. Every PermissionRequest variant carries requestingUrl, so all three call sites now use it and fall back to the top-level document only when it is absent. The notice also showed raw Chromium permission names. humanizePermission mapped two permissions and returned the raw token for the rest. That now matters more: #15481 granted ordinary storage-access and left top-level-storage-access denied, making it the storage denial a user can still hit - rendered as its raw token. The default still returns the raw token. Inventing prose for a permission nobody has seen is worse than showing its real name. Does not change any permission verdict, and does not fix Google sign-in (#15221). * fix(browser): keep permission denial attribution accurate Capture fallback URLs before asynchronous media permission handling and treat opaque requesters as unknown rather than blaming the top-level page. Clarify permission descriptions and cover origin normalization, navigation races, and mapped copy. --- .../browser/browser-manager-downloads.test.ts | 10 ++++ .../browser-session-partition-policies.ts | 24 +++++++- ...owser-session-registry.persistence.test.ts | 53 ++++++++++++++++++ .../navigate/browser-notices.test.ts | 55 +++++++++++++++++++ .../browser-pane/navigate/browser-notices.ts | 33 +++++++++++ 5 files changed, 172 insertions(+), 3 deletions(-) diff --git a/src/main/browser/browser-manager-downloads.test.ts b/src/main/browser/browser-manager-downloads.test.ts index 981002c727b..e79d510ff62 100644 --- a/src/main/browser/browser-manager-downloads.test.ts +++ b/src/main/browser/browser-manager-downloads.test.ts @@ -123,6 +123,16 @@ describe('browserManager', () => { permission: 'media', origin: 'https://example.com' }) + browserManager.notifyPermissionDenied({ + guestWebContentsId: guest.id, + permission: 'geolocation', + rawUrl: '' + }) + expect(rendererSendMock).toHaveBeenCalledWith('browser:permission-denied', { + browserPageId: 'browser-1', + permission: 'geolocation', + origin: 'unknown' + }) expect(rendererSendMock).toHaveBeenCalledWith( 'browser:download-requested', expect.objectContaining({ diff --git a/src/main/browser/browser-session-partition-policies.ts b/src/main/browser/browser-session-partition-policies.ts index e31691b9646..2118c62ed68 100644 --- a/src/main/browser/browser-session-partition-policies.ts +++ b/src/main/browser/browser-session-partition-policies.ts @@ -22,6 +22,21 @@ const handleWillDownload = ( browserManager.handleGuestWillDownload({ guestWebContentsId: webContents.id, item }) } +function resolvePermissionNoticeUrl( + webContents: Electron.WebContents, + details: Electron.PermissionRequest | undefined +): string { + const requestingUrl = details?.requestingUrl + if (!requestingUrl) { + return webContents.getURL() + } + try { + return new URL(requestingUrl).origin === 'null' ? '' : requestingUrl + } catch { + return '' + } +} + export function installBrowserSessionPartitionPolicies(profile: BrowserSessionProfile): void { const { partition } = profile const sess = session.fromPartition(partition) @@ -39,6 +54,8 @@ export function installBrowserSessionPartitionPolicies(profile: BrowserSessionPr sess.setPermissionRequestHandler((webContents, permission, callback, details) => { // Why: defer media to macOS TCC; denying at the session layer throws NotAllowedError even after the user granted Camera/Mic to the OS. if (permission === 'media') { + // Capture before async handling; opaque frames cannot be attributed to a named site. + const rawUrl = resolvePermissionNoticeUrl(webContents, details) void requestSystemMediaAccess( details as Electron.MediaAccessPermissionRequest | undefined ).then( @@ -47,7 +64,7 @@ export function installBrowserSessionPartitionPolicies(profile: BrowserSessionPr browserManager.notifyPermissionDenied({ guestWebContentsId: webContents.id, permission, - rawUrl: webContents.getURL() + rawUrl }) } callback(granted) @@ -57,7 +74,7 @@ export function installBrowserSessionPartitionPolicies(profile: BrowserSessionPr browserManager.notifyPermissionDenied({ guestWebContentsId: webContents.id, permission, - rawUrl: webContents.getURL() + rawUrl }) callback(false) } @@ -66,10 +83,11 @@ export function installBrowserSessionPartitionPolicies(profile: BrowserSessionPr } const allowed = isAutoGrantedBrowserSessionPermission(permission) if (!allowed) { + const rawUrl = resolvePermissionNoticeUrl(webContents, details) browserManager.notifyPermissionDenied({ guestWebContentsId: webContents.id, permission, - rawUrl: webContents.getURL() + rawUrl }) } callback(allowed) diff --git a/src/main/browser/browser-session-registry.persistence.test.ts b/src/main/browser/browser-session-registry.persistence.test.ts index cb9229174ed..a3caa67b19e 100644 --- a/src/main/browser/browser-session-registry.persistence.test.ts +++ b/src/main/browser/browser-session-registry.persistence.test.ts @@ -564,6 +564,58 @@ describe('BrowserSessionRegistry persistence', () => { permission: 'geolocation', rawUrl: 'https://example.com/account' }) + + // A subframe denial must name the requester, not its top-level embedder. + browserManagerNotifyPermissionDeniedMock.mockClear() + requestHandler(guestWc, 'geolocation', permissionCallback, { + requestingUrl: 'https://widget.example.net/embed', + isMainFrame: false + }) + await vi.waitFor(() => + expect(browserManagerNotifyPermissionDeniedMock).toHaveBeenCalledWith({ + guestWebContentsId: 401, + permission: 'geolocation', + rawUrl: 'https://widget.example.net/embed' + }) + ) + + // Missing or empty frame URLs fall back to the visible top-level page. + browserManagerNotifyPermissionDeniedMock.mockClear() + requestHandler(guestWc, 'geolocation', permissionCallback, { isMainFrame: true }) + await vi.waitFor(() => + expect(browserManagerNotifyPermissionDeniedMock).toHaveBeenCalledWith({ + guestWebContentsId: 401, + permission: 'geolocation', + rawUrl: 'https://example.com/account' + }) + ) + + browserManagerNotifyPermissionDeniedMock.mockClear() + requestHandler(guestWc, 'geolocation', permissionCallback, { + requestingUrl: '', + isMainFrame: false + }) + await vi.waitFor(() => + expect(browserManagerNotifyPermissionDeniedMock).toHaveBeenCalledWith({ + guestWebContentsId: 401, + permission: 'geolocation', + rawUrl: 'https://example.com/account' + }) + ) + + // Opaque frame URLs have no site Orca can name accurately. + browserManagerNotifyPermissionDeniedMock.mockClear() + requestHandler(guestWc, 'geolocation', permissionCallback, { + requestingUrl: 'about:blank', + isMainFrame: false + }) + await vi.waitFor(() => + expect(browserManagerNotifyPermissionDeniedMock).toHaveBeenCalledWith({ + guestWebContentsId: 401, + permission: 'geolocation', + rawUrl: '' + }) + ) expect( browserManagerNotifyPermissionDeniedMock.mock.calls.map(([args]) => args.permission) ).toEqual(['geolocation']) @@ -735,6 +787,7 @@ describe('BrowserSessionRegistry persistence', () => { const callback = vi.fn() requestHandler(guestWc, 'media', callback, { mediaTypes: ['video'] }) + guestWc.getURL.mockReturnValue('https://example.com/after-navigation') await vi.waitFor(() => expect(callback).toHaveBeenCalledWith(false)) expect(browserManagerNotifyPermissionDeniedMock).toHaveBeenCalledWith({ diff --git a/src/renderer/src/components/browser-pane/navigate/browser-notices.test.ts b/src/renderer/src/components/browser-pane/navigate/browser-notices.test.ts index 509dc8a08a2..f812d5fe02f 100644 --- a/src/renderer/src/components/browser-pane/navigate/browser-notices.test.ts +++ b/src/renderer/src/components/browser-pane/navigate/browser-notices.test.ts @@ -19,6 +19,61 @@ describe('browser notice formatting', () => { origin: 'https://example.com' }) ).toBe('https://example.com asked for camera or microphone access, and Orca denied it.') + expect( + formatPermissionNotice({ + browserPageId: 'browser-1', + permission: 'geolocation', + origin: 'unknown' + }) + ).toBe('this page asked for your location, and Orca denied it.') + }) + + it('names the storage permission in words rather than its raw token', () => { + const notice = formatPermissionNotice({ + browserPageId: 'browser-1', + permission: 'top-level-storage-access', + origin: 'https://example.com' + }) + expect(notice).not.toContain('top-level-storage-access') + expect(notice).toBe( + 'https://example.com asked for cookie access on behalf of an embedded site, and Orca denied it.' + ) + }) + + it.each([ + ['storage-access', 'access to its own cookies and storage while embedded on this page'], + ['idle-detection', 'permission to detect when you are idle'], + ['display-capture', 'permission to capture your screen'], + ['window-management', 'screen information and multi-screen window placement'], + ['keyboardLock', 'permission to capture keyboard input'], + ['openExternal', 'permission to open a link outside Orca'], + ['fileSystem', 'access to your files or folders'], + ['hid', 'access to a connected human interface device'], + ['usb', 'access to a USB device'], + ['serial', 'access to a serial device'], + ['midi', 'access to your MIDI devices'], + ['midiSysex', 'access to system-exclusive MIDI messages'], + ['mediaKeySystem', 'access to protected media playback'], + ['speaker-selection', 'permission to choose an audio output device'] + ])('formats the %s permission as readable copy', (permission, description) => { + expect( + formatPermissionNotice({ + browserPageId: 'browser-1', + permission, + origin: 'https://example.com' + }) + ).toBe(`https://example.com asked for ${description}, and Orca denied it.`) + }) + + // Pin the raw-token fallback for permissions Chromium adds later. + it('falls back to the raw permission name for anything unmapped', () => { + expect( + formatPermissionNotice({ + browserPageId: 'browser-1', + permission: 'some-future-permission', + origin: 'https://example.com' + }) + ).toBe('https://example.com asked for some-future-permission, and Orca denied it.') }) it('formats popup outcomes', () => { diff --git a/src/renderer/src/components/browser-pane/navigate/browser-notices.ts b/src/renderer/src/components/browser-pane/navigate/browser-notices.ts index 76888fc7b94..ca5e6aa209a 100644 --- a/src/renderer/src/components/browser-pane/navigate/browser-notices.ts +++ b/src/renderer/src/components/browser-pane/navigate/browser-notices.ts @@ -15,12 +15,45 @@ export type LoadFailureMeta = { type BrowserLoadErrorLike = BrowserLoadError | null +// Unknown Chromium permissions keep their raw name instead of disappearing behind invented copy. function humanizePermission(permission: string): string { switch (permission) { case 'media': return 'camera or microphone access' case 'pointerLock': return 'pointer lock' + case 'storage-access': + return 'access to its own cookies and storage while embedded on this page' + case 'top-level-storage-access': + return 'cookie access on behalf of an embedded site' + case 'geolocation': + return 'your location' + case 'idle-detection': + return 'permission to detect when you are idle' + case 'display-capture': + return 'permission to capture your screen' + case 'window-management': + return 'screen information and multi-screen window placement' + case 'keyboardLock': + return 'permission to capture keyboard input' + case 'openExternal': + return 'permission to open a link outside Orca' + case 'fileSystem': + return 'access to your files or folders' + case 'hid': + return 'access to a connected human interface device' + case 'usb': + return 'access to a USB device' + case 'serial': + return 'access to a serial device' + case 'midi': + return 'access to your MIDI devices' + case 'midiSysex': + return 'access to system-exclusive MIDI messages' + case 'mediaKeySystem': + return 'access to protected media playback' + case 'speaker-selection': + return 'permission to choose an audio output device' default: return permission } From 5ca747dad0d0583f4a1ac91c2655b345ba6c07eb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:47:22 -0700 Subject: [PATCH 020/111] docs(ssh): state the SSH execution boundary and pin the liveness vocabulary (#14971) * docs(ssh): state the SSH execution boundary and pin the liveness vocabulary Nothing under docs/ described how work splits between the client and an SSH host, so agents and humans inferred it from error strings and got it wrong: loss of contact was repeatedly reported as process death, which orphaned live remote agents and cold-started duplicates over the same worktree. Pins the vocabulary to the incumbent live/unverifiable/exited verdict from unstopped-pty-verification so no synonym is introduced, records the one real discriminator (all of a host's terminals drop together on link loss; one alone means process exit), and lists the outstanding gaps with citations. Tracked via the docs allow-list and linked from AGENTS.md, per the convention in .gitignore. * docs(ssh): cite the live restoreRequired site after it moved The throw now lives in reattachSshPtySessionForSpawn; ssh-pty-provider.ts no longer contains it. Caught by the worker fixing it, against a newer main than the audit ran on. * docs(ssh): require host evidence for liveness verdicts * docs(ssh): keep boundary references stable * docs(ssh): fence liveness evidence to its host identity * docs(ssh): state replay and environment boundaries precisely * docs(ssh): correct replay and platform boundary claims * docs(ssh): describe headless runtime continuity accurately * docs(ssh): distinguish authority from client metadata * docs(ssh): describe pending fixes accurately * docs(ssh): date the gap list and name the PR that closes each entry The Known gaps section was accurate when written and becomes actively misleading as its fixes land: it told a reader to go fix restoreRequired, the missing unverifiable verdict, and the absent terminal-list host field, three things now addressed by #14974, #14977 and #14973. Mark the section as dated, require verification against current code before acting on any entry, name the PR per entry, and move landed items out. Also correct the two body claims that the landed fixes invalidated. The rules above are durable; only this section rots. * docs(ssh): make the boundary doc a durable ruleset, not an incident record The Known gaps section was 18 of 93 lines enumerating specific defects from one investigation, several already fixed by sibling PRs in the same batch. A reference doc that needs a 'this section rots' warning is telling you the section belongs somewhere else; those entries belong in issues. Replace the six-row table of currently-lying signals with the method that outlands any particular bug: ask whether the owning host produced the signal, whether every PTY on the target went quiet together, whether the termination event matches the current incarnation and generation, and whether a returned status is actually a claim. Same for artifacts - state what ls-remote and a PR head each do and do not prove, rather than listing which command is currently wrong. Nothing here goes stale when the open fixes land. --- .gitignore | 1 + AGENTS.md | 2 +- docs/reference/ssh-execution-boundary.md | 77 ++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 docs/reference/ssh-execution-boundary.md diff --git a/.gitignore b/.gitignore index 321fe5beb21..d8e4f224baa 100644 --- a/.gitignore +++ b/.gitignore @@ -105,6 +105,7 @@ docs/** !docs/reference/relay-grace-time-reconfiguration.md !docs/reference/remote-wire-compatibility.md !docs/reference/renderer-agent-status-performance.md +!docs/reference/ssh-execution-boundary.md !docs/reference/ssh-host-key-verification.md !docs/reference/ssh-reconnect-source-recovery.md !docs/reference/windows-setup-shell.md diff --git a/AGENTS.md b/AGENTS.md index b70868c462f..ba14ab6a967 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,7 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh ## SSH Use Case -All changes must consider the SSH use case. Don't assume local-only execution. +All changes must consider the SSH use case. Don't assume local-only execution. Before changing anything that reports on, stops, or lists remote work, follow [`docs/reference/ssh-execution-boundary.md`](./docs/reference/ssh-execution-boundary.md): the execution host owns everything that touches execution, and loss of contact is never evidence of process death — the verdict vocabulary is `live` / `unverifiable` / `exited`, with no synonyms. ## Folder Workspace Use Case diff --git a/docs/reference/ssh-execution-boundary.md b/docs/reference/ssh-execution-boundary.md new file mode 100644 index 00000000000..d24cdc38e8e --- /dev/null +++ b/docs/reference/ssh-execution-boundary.md @@ -0,0 +1,77 @@ +# SSH Execution Boundary + +How Orca splits work between your machine and an SSH host, what survives a disconnect, and how to keep `unverifiable` distinct from `exited`. Nothing under `docs/` stated this before; agents and humans were inferring it from error strings and getting it wrong. + +## The rule + +**The execution host owns everything that touches execution** — tools, credentials, identity, environment, processes, and artifacts. The client owns the UI, transport, and Orca control-plane state, but is not authoritative for execution state. + +Two consequences, both non-negotiable: + +1. **No silent substitution.** An operation on a remote `repoPath` must never fall back to running on the client. A missing SSH provider is not permission to answer locally — a local run can answer for the _wrong repository_. +2. **No asserting what you cannot observe.** Loss of contact is not evidence of `exited`. Report `unverifiable`, never `exited`. + +The vocabulary is fixed: **`live` / `unverifiable` / `exited`**, taken from the incumbent `UnstoppedPtyVerdict`. Do not introduce synonyms, and never collapse `unverifiable` into either neighbour. `exited` requires positive evidence of absence from the host that owns the process; a transport failure can only ever produce `unverifiable`. + +Rule 1 is stated at `src/main/source-control/repo-default-branch.ts:76-78`, `src/main/repo-worktrees.ts:45-48`, `OrcaRuntimeService.probeWorktreeDrift` in `src/main/runtime/orca-runtime.ts`, and `src/renderer/src/lib/connection-context.ts:22-24`. It is enforced throughout `src/main/runtime/orca-runtime-git.ts` by the guard that throws `SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE` whenever `target.connectionId` is set and no provider is registered — grep that constant for the current call sites rather than trusting a count. + +`src/main/runtime/unstopped-pty-verification.ts:12-16` is the reference implementation of rule 2: it keeps `live` / `unverifiable` / `exited` as three distinct verdicts, and treats "we could not ask" as its own answer. + +## What runs where + +| Concern | Executes on | Notes | +| -------------------------------------------------------------- | ------------------ | -------------------------------------------------------------------- | +| PTYs, agent CLIs | **remote** | children of the detached relay daemon, not of the ssh channel | +| git (status, diff, log, fetch, push, commit, branch, worktree) | **remote** | via `src/relay/git-handler.ts` | +| filesystem, watching, search | **remote** | | +| repo setup hooks (`--setup`) | **remote** | identical policy to local | +| commit-message / PR-field AI generation | **remote** | uses the remote agent CLI and its auth | +| `gh` / GitHub API, `glab` / GitLab | **client** | inconsistent with the rule; PRs carry the client's identity | +| the `orca` CLI inside a remote terminal | **client runtime** | control plane only — your files and processes stay remote; see below | + +## Survival: what a disconnect does _not_ do + +By default, remote work survives your machine going away. The relay is a detached daemon (`nohup … When the client disconnects, every `orca …` command run on the SSH host fails with `No owning Orca client is connected to the relay`. The PTY stays `live`; its control plane does not. + +Orchestration state (Runs, Tasks, Dispatches, mailboxes) is client-resident for the same reason. An agent on an SSH host should not depend on `orca` for anything it must finish while you are away. **Commit and push early** — unpushed work on a remote box is unavailable to the client until it reconnects. + +## Distinguishing `unverifiable` from `exited` + +A verdict needs evidence from the host that owns the process. Apply these tests in order. + +**Was the signal produced by the owning host, or by the client's own bookkeeping?** Absence from a client-side set, a lookup that threw, a socket that closed, a command that timed out — none of these observe the process. They are `unverifiable` by construction, whatever the field is named. + +**Did every remote PTY on that target go quiet at once?** A transport drop takes them all together. Simultaneous silence across a host indicates a lost link, not simultaneous death. + +**Does the termination event match the current identity?** A host-delivered exit for the live PTY incarnation and provider generation, while its siblings still report, establishes `exited`. A stale event, an event for a superseded incarnation, or one quiet terminal with no host evidence does not. + +**Is a returned status actually a claim of success?** An operation that reports failure may have succeeded, and one that reports success may not have run — check the durable state it should have changed rather than trusting the return. + +Anything short of positive host evidence is `unverifiable`. Reporting it as `exited` is the error this document exists to prevent: it orphans live work and can cold-start a duplicate over the same worktree. + +## Reading artifacts instead of process state + +Artifacts are stronger evidence than liveness signals, but they answer a narrower question than they appear to. + +A matching commit from `git ls-remote --heads origin ` or a PR head lookup proves **that commit reached the remote** — not that the current run pushed it, and not that the latest work was included. An absent result proves nothing was found, not that nothing was pushed: the ref may have been deleted, the PR closed, or the query may simply have failed. + +A listing is only evidence about the hosts it actually covered. When a result does not name its scope, an empty answer is not evidence that nothing is running elsewhere. A clean **local** worktree says nothing at all about the remote one. + +## One host, one model + +An SSH host and a paired runtime (`orca environment`) imply opposite boundaries: the first is a dumb execution host driven by your client, the second is a peer that owns its own control plane. Registering the same machine both ways splits its worktrees across two identities, makes `terminal list` return different sets depending on `--environment`, and reliably confuses both humans and agents. Pick one per machine. + +For work that must continue while you are offline, use the peer/headless-runtime model on the remote host instead of the direct-SSH model. Its control plane is host-local, and its daemon-backed PTYs stay `live` across a normal runtime restart so the runtime can reattach; an explicit daemon shutdown can still make them `exited`. Do not register the same machine through both models. A detached agent process outside Orca can also survive a control-plane outage, but it has no stdin, so its instructions cannot be amended mid-run. From 4b2ed5ddd470fc69cf836d840748afae0908eeb4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 20 Aug 2026 01:07:56 -0700 Subject: [PATCH 021/111] fix(terminal): apply pane padding on all four edges (#15544) * fix(terminal): apply pane padding on all four edges Move the configured inset onto xterm so the terminal fills its pane while the fit calculation accounts for both sides of each axis. Add a geometry golden that forces cell remainders and verifies dynamic padding without relying on renderer pixels. * fix(terminal): normalize imported padding for fitting * fix(terminal): align stored and fitted padding --- package.json | 2 +- src/main/ghostty/mapper-extended.test.ts | 4 +- src/main/ghostty/numeric-config-values.ts | 9 +- .../terminal-container-geometry.test.ts | 12 +- src/renderer/src/assets/terminal.css | 17 +- .../TerminalWindowSection.padding.test.tsx | 81 +++++++ .../settings/TerminalWindowSection.tsx | 13 +- .../terminal-pane/terminal-appearance.test.ts | 66 ++++++ .../terminal-pane/terminal-appearance.ts | 47 ++-- src/shared/terminal-padding-settings.test.ts | 14 ++ src/shared/terminal-padding-settings.ts | 6 + .../terminal-padding-geometry-golden.spec.ts | 222 ++++++++++++++++++ 12 files changed, 456 insertions(+), 37 deletions(-) create mode 100644 src/renderer/src/components/settings/TerminalWindowSection.padding.test.tsx create mode 100644 src/shared/terminal-padding-settings.test.ts create mode 100644 src/shared/terminal-padding-settings.ts create mode 100644 tests/e2e/terminal-padding-geometry-golden.spec.ts diff --git a/package.json b/package.json index 3fd332f5fe1..2dd9706e681 100644 --- a/package.json +++ b/package.json @@ -91,7 +91,7 @@ "test:e2e:workspace-session-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-quit-relaunch-session.spec.ts tests/e2e/golden-terminal-file-link.spec.ts tests/e2e/golden-worktree-create-switch.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:multi-client-navigation": "node config/scripts/run-multi-client-navigation-e2e.mjs", "test:e2e:floating-mobile-emulator": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/floating-mobile-emulator-tab.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "test:e2e:terminal-rendering-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts tests/e2e/terminal-webgl-atlas-budget.spec.ts --grep @terminal-rendering-golden --config tests/playwright.config.ts --project electron-headless --workers=1", + "test:e2e:terminal-rendering-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts tests/e2e/terminal-webgl-atlas-budget.spec.ts tests/e2e/terminal-padding-geometry-golden.spec.ts --grep @terminal-rendering-golden --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:source-control-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-file-open-edit-save.spec.ts tests/e2e/golden-source-control-commit.spec.ts tests/e2e/golden-source-control-open-diff.spec.ts --grep @golden --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:posix-profile-index-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-posix-fresh-startup.spec.ts tests/e2e/golden-posix-profile-index-fsync.spec.ts --grep @posix-profile-index-golden --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:windows-fresh-startup-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-windows-fresh-startup.spec.ts --grep @windows-fresh-startup-golden --config tests/playwright.config.ts --project electron-headless --workers=1", diff --git a/src/main/ghostty/mapper-extended.test.ts b/src/main/ghostty/mapper-extended.test.ts index 402e9253f21..9584ac75017 100644 --- a/src/main/ghostty/mapper-extended.test.ts +++ b/src/main/ghostty/mapper-extended.test.ts @@ -90,9 +90,9 @@ describe('mapGhosttyToOrca — window-padding', () => { expect(result.unsupportedKeys).toEqual([]) }) - it('preserves odd-sum dual-value padding as a fractional average', () => { + it('rounds odd-sum dual-value padding to the integer used for fitting', () => { const result = mapGhosttyToOrca({ 'window-padding-x': '1,2' }) - expect(result.diff).toEqual({ terminalPaddingX: 1.5 }) + expect(result.diff).toEqual({ terminalPaddingX: 2 }) expect(result.unsupportedKeys).toEqual([]) }) diff --git a/src/main/ghostty/numeric-config-values.ts b/src/main/ghostty/numeric-config-values.ts index f8a7ce7b468..9a20f040aee 100644 --- a/src/main/ghostty/numeric-config-values.ts +++ b/src/main/ghostty/numeric-config-values.ts @@ -1,3 +1,5 @@ +import { normalizeTerminalPadding } from '../../shared/terminal-padding-settings' + // Why: `Number("1e10")` succeeds and passes `Number.isInteger`, so a Ghostty // config with `window-padding-x = 1e9` would sail through the mapper and land // an absurd value in the store. Restrict to plain decimal integers. @@ -10,9 +12,8 @@ export const parseStrictInt = (v: string): number | null => { return Number.isFinite(num) ? num : null } -// Why: Ghostty accepts "top,bottom" / "left,right" pairs for window paddings, -// but Orca stores a single value per axis — average the pair so the total -// padding along the axis stays the same. +// Why: Orca stores one integer per axis, so collapse paired paddings to the +// nearest value that xterm's integer-based fit calculation will also use. export const parsePaddingValue = (v: string): number | null => { const parts = v.split(',') if (parts.length > 2) { @@ -26,5 +27,5 @@ export const parsePaddingValue = (v: string): number | null => { } nums.push(num) } - return nums.reduce((sum, num) => sum + num, 0) / nums.length + return normalizeTerminalPadding(nums.reduce((sum, num) => sum + num, 0) / nums.length) } diff --git a/src/renderer/src/assets/terminal-container-geometry.test.ts b/src/renderer/src/assets/terminal-container-geometry.test.ts index db079c9095f..28eb858fcdd 100644 --- a/src/renderer/src/assets/terminal-container-geometry.test.ts +++ b/src/renderer/src/assets/terminal-container-geometry.test.ts @@ -5,9 +5,7 @@ const terminalCss = fs.readFileSync(new URL('./terminal.css', import.meta.url), describe('terminal container geometry', () => { it('keeps the hidden link tooltip out of the fitted terminal height', () => { - expect(terminalCss).toMatch( - /\.xterm-container\s*{[^}]*height:\s*calc\(100% - var\(--pane-padding-y, 4px\)\);/s - ) + expect(terminalCss).toMatch(/\.xterm-container\s*{[^}]*height:\s*100%;/s) expect(terminalCss).toMatch( /\.pane\[data-has-title\] \.xterm-container\s*{[^}]*height:\s*calc\(100% - var\(--orca-pane-title-height\)\);/s ) @@ -15,4 +13,12 @@ describe('terminal container geometry', () => { /\.pane-link-tooltip\s*{[^}]*height:\s*var\(--orca-terminal-link-tooltip-height\);/s ) }) + + it('insets the xterm grid on all four edges', () => { + expect(terminalCss).toMatch( + /\.xterm-container \.xterm\s*{[^}]*padding:\s*var\(--pane-padding-y, 4px\) var\(--pane-padding-x, 4px\);/s + ) + expect(terminalCss).toMatch(/\.xterm-container\s*{[^}]*width:\s*100%;/s) + expect(terminalCss).not.toMatch(/\.xterm-container\s*{[^}]*margin-left:/s) + }) }) diff --git a/src/renderer/src/assets/terminal.css b/src/renderer/src/assets/terminal.css index ee5e0af01f5..16b3e36ec5e 100644 --- a/src/renderer/src/assets/terminal.css +++ b/src/renderer/src/assets/terminal.css @@ -25,9 +25,11 @@ outline: none; } -.pane-manager-root .xterm { +.xterm-container .xterm { + box-sizing: border-box; height: 100%; width: 100%; + padding: var(--pane-padding-y, 4px) var(--pane-padding-x, 4px); } .pane-manager-root .xterm-viewport { @@ -177,7 +179,7 @@ .pane[data-terminal-attention] .xterm-container::after { content: ''; position: absolute; - inset: 0 4px 4px 0; + inset: 0; z-index: 5; pointer-events: none; border-radius: 2px; @@ -498,16 +500,13 @@ border-color: var(--ring); } -/* Baseline xterm-container layout — padding is driven by CSS custom properties - so Ghostty-imported window-padding-x/y can be applied without inline styles. - The fallback 4px preserves the legacy default. */ +/* The container fills the pane; xterm owns the four-edge grid inset so its fit + calculation subtracts the configured padding before choosing cols/rows. */ .xterm-container { box-sizing: border-box; position: relative; - width: calc(100% - var(--pane-padding-x, 4px)); - height: calc(100% - var(--pane-padding-y, 4px)); - margin-top: var(--pane-padding-y, 4px); - margin-left: var(--pane-padding-x, 4px); + width: 100%; + height: 100%; } /* When a pane has a title, shift the terminal content down to make room. diff --git a/src/renderer/src/components/settings/TerminalWindowSection.padding.test.tsx b/src/renderer/src/components/settings/TerminalWindowSection.padding.test.tsx new file mode 100644 index 00000000000..5f54fb38a9e --- /dev/null +++ b/src/renderer/src/components/settings/TerminalWindowSection.padding.test.tsx @@ -0,0 +1,81 @@ +// @vitest-environment happy-dom + +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { getDefaultSettings } from '../../../../shared/constants' +import { TerminalWindowSection } from './TerminalWindowSection' + +type NumberFieldProps = { + label: string + value: number + onChange: (value: number) => void +} + +const numberFields = vi.hoisted(() => new Map()) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, defaultValue: string) => defaultValue +})) + +vi.mock('./SettingsFormControls', () => ({ + ColorField: function ColorField() { + return null + }, + NumberField: function NumberField(props: NumberFieldProps) { + numberFields.set(props.label, props) + return null + } +})) + +vi.mock('./SearchableSetting', () => ({ + SearchableSetting: function SearchableSetting({ children }: { children?: ReactNode }) { + return children ?? null + } +})) + +vi.mock('../ui/button', () => ({ Button: () => null })) +vi.mock('../ui/label', () => ({ Label: () => null })) +vi.mock('../ui/switch', () => ({ Switch: () => null })) + +describe('TerminalWindowSection padding', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + numberFields.clear() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + document.body.replaceChildren() + }) + + it('displays and persists the same integer padding used by terminal fitting', () => { + const updateSettings = vi.fn() + const settings = { + ...getDefaultSettings('/tmp'), + terminalPaddingX: 1.5, + terminalPaddingY: 2.5 + } as GlobalSettings + + act(() => + root.render() + ) + + const horizontal = numberFields.get('Horizontal Padding') + const vertical = numberFields.get('Vertical Padding') + expect(horizontal?.value).toBe(2) + expect(vertical?.value).toBe(3) + + act(() => horizontal?.onChange(3.5)) + act(() => vertical?.onChange(4.5)) + expect(updateSettings).toHaveBeenNthCalledWith(1, { terminalPaddingX: 4 }) + expect(updateSettings).toHaveBeenNthCalledWith(2, { terminalPaddingY: 5 }) + }) +}) diff --git a/src/renderer/src/components/settings/TerminalWindowSection.tsx b/src/renderer/src/components/settings/TerminalWindowSection.tsx index 863d7fa11b0..e9efb2c4396 100644 --- a/src/renderer/src/components/settings/TerminalWindowSection.tsx +++ b/src/renderer/src/components/settings/TerminalWindowSection.tsx @@ -1,5 +1,6 @@ import { useRef, useState } from 'react' import { RotateCw } from 'lucide-react' +import { normalizeTerminalPadding } from '../../../../shared/terminal-padding-settings' import type { GlobalSettings } from '../../../../shared/global-settings-types' import { Button } from '../ui/button' import { Label } from '../ui/label' @@ -185,13 +186,15 @@ export function TerminalWindowSection({ 'Horizontal Padding' )} description="" - value={settings.terminalPaddingX ?? 4} + value={normalizeTerminalPadding(settings.terminalPaddingX ?? 4)} defaultValue={4} min={0} max={512} step={1} suffix="px" - onChange={(value) => updateSettings({ terminalPaddingX: Math.max(0, value) })} + onChange={(value) => + updateSettings({ terminalPaddingX: normalizeTerminalPadding(value) }) + } /> @@ -212,13 +215,15 @@ export function TerminalWindowSection({ 'Vertical Padding' )} description="" - value={settings.terminalPaddingY ?? 4} + value={normalizeTerminalPadding(settings.terminalPaddingY ?? 4)} defaultValue={4} min={0} max={512} step={1} suffix="px" - onChange={(value) => updateSettings({ terminalPaddingY: Math.max(0, value) })} + onChange={(value) => + updateSettings({ terminalPaddingY: normalizeTerminalPadding(value) }) + } /> diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts index 6f7bec8592b..2bee7878d0b 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts @@ -3,6 +3,7 @@ import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager' import { getDefaultSettings } from '../../../../shared/constants' import { applyTerminalAppearance, + composeRendererTerminalTheme, hexToRgba, publishTerminalViewAttributesAtAppStart } from './terminal-appearance' @@ -216,6 +217,54 @@ describe('applyTerminalAppearance theme assignment', () => { expect(pane.terminal.options.theme?.background).toBe('#102030') }) + it('lets the pane paint a fractional background exactly once', () => { + const pane = makePane(1) + const settings = getDefaultSettings('/tmp') + const manager = makeManager([pane]) + + applyTerminalAppearance( + manager, + { ...settings, terminalBackgroundOpacity: 0.5 }, + true, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + + expect(pane.terminal.options.theme?.background).toMatch(/^rgba\(.+, 0\)$/) + expect(pane.terminal.options.allowTransparency).toBe(true) + expect(manager.setPaneStyleOptions).toHaveBeenCalledWith( + expect.objectContaining({ paneBackground: expect.stringMatching(/^rgba\(.+, 0\.5\)$/) }) + ) + }) + + it('stamps four-edge padding before fitting', () => { + const pane = makePane(1) + pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 79, rows: 23 })) + const manager = makeManager([pane]) + const settings = getDefaultSettings('/tmp') + + applyTerminalAppearance( + manager, + { ...settings, terminalPaddingX: 11, terminalPaddingY: 7 }, + true, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + + expect(manager.setPaneStyleOptions).toHaveBeenCalledWith( + expect.objectContaining({ paddingX: 11, paddingY: 7 }) + ) + expect(vi.mocked(manager.setPaneStyleOptions).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(pane.fitAddon.proposeDimensions).mock.invocationCallOrder[0]! + ) + }) + // #7934: contrast correction rescues invisible white text on light backgrounds but over-corrects on dark; // gate by the composed theme's background luminance (either theme slot can hold either kind of theme). it('keeps xterm contrast correction on light themes', () => { @@ -362,6 +411,23 @@ describe('applyTerminalAppearance theme assignment', () => { }) }) +describe('composeRendererTerminalTheme', () => { + it('preserves background RGB while removing fractional renderer alpha', () => { + const theme = { background: 'rgba(16, 32, 48, 0.5)', foreground: '#ffffff' } + + expect(composeRendererTerminalTheme(theme, 0.5)).toEqual({ + ...theme, + background: 'rgba(16, 32, 48, 0)' + }) + }) + + it('keeps the opaque fast path unchanged', () => { + const theme = { background: '#102030' } + + expect(composeRendererTerminalTheme(theme, 1)).toBe(theme) + }) +}) + describe('publishTerminalViewAttributesAtAppStart', () => { // Hidden-at-launch PTYs query OSC 10/11 before any pane mounts; publish with no pane manager (terminal-query-authority.md). it('publishes composed attributes without any pane mount and dedupes repeats', () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.ts index bb61a50fbbc..c7a1156500f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.ts @@ -29,6 +29,7 @@ import { publishTerminalViewAttributes } from './terminal-view-attributes-publis import { normalizeTerminalLineHeight } from '../../../../shared/terminal-line-height-settings' import { maybePushMode2031Flip } from './terminal-mode-2031-replies' import { resolveTerminalMinimumContrastRatio } from '@/lib/terminal-contrast-correction' +import { normalizeTerminalPadding } from '../../../../shared/terminal-padding-settings' export function hexToRgba(hex: string, alpha: number): string { let clean = hex.replace('#', '') @@ -132,6 +133,22 @@ function composedTerminalThemesEqual(a: ITheme | undefined, b: ITheme): boolean return extA.length === extB.length && extA.every((value, i) => value === extB[i]) } +export function composeRendererTerminalTheme( + theme: ITheme | null, + backgroundOpacity: number | undefined +): ITheme | null { + if (!theme?.background || backgroundOpacity === undefined || backgroundOpacity >= 1) { + return theme + } + const transparentBackground = theme.background.replace( + /^rgba\(([^,]+),\s*([^,]+),\s*([^,]+),\s*[^)]+\)$/, + 'rgba($1, $2, $3, 0)' + ) + return transparentBackground === theme.background + ? theme + : { ...theme, background: transparentBackground } +} + export function applyTerminalAppearance( manager: PaneManager, settings: GlobalSettings, @@ -149,6 +166,7 @@ export function applyTerminalAppearance( // Publish composed appearance to main's hidden-PTY query responder — the only point it exists; deduped in the publisher. publishTerminalViewAttributes(theme, appearance.mode, settings) const paneBackground = theme?.background ?? '#000000' + const rendererTheme = composeRendererTerminalTheme(theme, settings.terminalBackgroundOpacity) const terminalFontWeights = resolveTerminalFontWeights( settings.terminalFontWeight, @@ -159,10 +177,23 @@ export function applyTerminalAppearance( settings.terminalFontFamily ) + // Why before fit: FitAddon reads live .xterm padding when proposing cols/rows. + manager.setPaneStyleOptions({ + splitBackground: paneBackground, + paneBackground, + inactivePaneOpacity: paneStyles.inactivePaneOpacity, + activePaneOpacity: paneStyles.activePaneOpacity, + opacityTransitionMs: paneStyles.opacityTransitionMs, + dividerThicknessPx: paneStyles.dividerThicknessPx, + focusFollowsMouse: paneStyles.focusFollowsMouse, + paddingX: normalizeTerminalPadding(settings.terminalPaddingX ?? 4), + paddingY: normalizeTerminalPadding(settings.terminalPaddingY ?? 4) + }) + for (const pane of manager.getPanes()) { // Why value-gated: writing options.theme rebuilds the palette, discarding TUI OSC 4/10/11/12 mutations; skip on no-op change. - if (theme && !composedTerminalThemesEqual(pane.terminal.options.theme, theme)) { - pane.terminal.options.theme = theme + if (rendererTheme && !composedTerminalThemesEqual(pane.terminal.options.theme, rendererTheme)) { + pane.terminal.options.theme = rendererTheme } // Gate off the configured theme background; the live OSC-11 background is deliberately preserved by the // theme write above, so a TUI that repaints its background at runtime won't re-gate (known limitation). @@ -227,16 +258,4 @@ export function applyTerminalAppearance( safeFit(pane) } } - - manager.setPaneStyleOptions({ - splitBackground: paneBackground, - paneBackground, - inactivePaneOpacity: paneStyles.inactivePaneOpacity, - activePaneOpacity: paneStyles.activePaneOpacity, - opacityTransitionMs: paneStyles.opacityTransitionMs, - dividerThicknessPx: paneStyles.dividerThicknessPx, - focusFollowsMouse: paneStyles.focusFollowsMouse, - paddingX: settings.terminalPaddingX, - paddingY: settings.terminalPaddingY - }) } diff --git a/src/shared/terminal-padding-settings.test.ts b/src/shared/terminal-padding-settings.test.ts new file mode 100644 index 00000000000..ee7ac432f46 --- /dev/null +++ b/src/shared/terminal-padding-settings.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { normalizeTerminalPadding } from './terminal-padding-settings' + +describe('normalizeTerminalPadding', () => { + it('rounds fractional padding to the nearest fitted pixel', () => { + expect(normalizeTerminalPadding(1.5)).toBe(2) + expect(normalizeTerminalPadding(2.49)).toBe(2) + }) + + it('keeps padding inside the settings range', () => { + expect(normalizeTerminalPadding(-1)).toBe(0) + expect(normalizeTerminalPadding(513)).toBe(512) + }) +}) diff --git a/src/shared/terminal-padding-settings.ts b/src/shared/terminal-padding-settings.ts new file mode 100644 index 00000000000..1f18a46973f --- /dev/null +++ b/src/shared/terminal-padding-settings.ts @@ -0,0 +1,6 @@ +const MIN_TERMINAL_PADDING = 0 +const MAX_TERMINAL_PADDING = 512 + +export function normalizeTerminalPadding(value: number): number { + return Math.min(MAX_TERMINAL_PADDING, Math.max(MIN_TERMINAL_PADDING, Math.round(value))) +} diff --git a/tests/e2e/terminal-padding-geometry-golden.spec.ts b/tests/e2e/terminal-padding-geometry-golden.spec.ts new file mode 100644 index 00000000000..89b744f73b9 --- /dev/null +++ b/tests/e2e/terminal-padding-geometry-golden.spec.ts @@ -0,0 +1,222 @@ +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { waitForActiveTerminalManager } from './helpers/terminal' + +type TerminalGeometry = { + cellWidth: number + cellHeight: number + cols: number + rows: number + scrollbarWidth: number + pane: { top: number; right: number; bottom: number; left: number; width: number; height: number } + terminal: { top: number; right: number; bottom: number; left: number } + screen: { + top: number + right: number + bottom: number + left: number + width: number + height: number + } +} + +const FIRST_PADDING = { x: 11, y: 7 } +const SECOND_PADDING = { x: 17, y: 13 } +const TARGET_COLS = 70 +const TARGET_ROWS = 20 +const MIN_REMAINDER = 2 + +async function readTerminalGeometry(page: Page): Promise { + return page.evaluate(() => { + const terminalElement = document.querySelector('.pane .xterm') + const paneElement = terminalElement?.closest('.pane') + const screenElement = terminalElement?.querySelector('.xterm-screen') + const manager = [...(window.__paneManagers?.values() ?? [])].find((candidate) => + candidate.getPanes().some((pane) => pane.container === paneElement) + ) + const pane = manager?.getPanes().find((candidate) => candidate.container === paneElement) + const cell = pane?.terminal.dimensions?.css.cell + if (!terminalElement || !paneElement || !screenElement || !pane || !cell) { + throw new Error('Active terminal geometry is unavailable') + } + const paneRect = paneElement.getBoundingClientRect() + const terminalRect = terminalElement.getBoundingClientRect() + const screenRect = screenElement.getBoundingClientRect() + return { + cellWidth: cell.width, + cellHeight: cell.height, + cols: pane.terminal.cols, + rows: pane.terminal.rows, + scrollbarWidth: pane.terminal.options.scrollbar?.width ?? 14, + pane: { + top: paneRect.top, + right: paneRect.right, + bottom: paneRect.bottom, + left: paneRect.left, + width: paneRect.width, + height: paneRect.height + }, + terminal: { + top: terminalRect.top, + right: terminalRect.right, + bottom: terminalRect.bottom, + left: terminalRect.left + }, + screen: { + top: screenRect.top, + right: screenRect.right, + bottom: screenRect.bottom, + left: screenRect.left, + width: screenRect.width, + height: screenRect.height + } + } + }) +} + +function findNonDivisibleSize(cellSize: number, cells: number, chrome: number): number { + const floor = Math.ceil(cellSize * cells + chrome) + for (let size = floor; size < floor + Math.ceil(cellSize); size += 1) { + const available = size - chrome + const remainder = available - Math.floor(available / cellSize) * cellSize + if (remainder > MIN_REMAINDER && remainder < cellSize - MIN_REMAINDER) { + return size + } + } + throw new Error(`Could not force a non-divisible ${cellSize}px cell geometry`) +} + +async function applyPaddingAndPaneSize( + page: Page, + padding: { x: number; y: number }, + size: { width: number; height: number } +): Promise { + await page.evaluate( + async ({ nextPadding, nextSize }) => { + const store = window.__store + const terminal = document.querySelector('.pane .xterm') + const pane = terminal?.closest('.pane') + if (!store || !pane) { + throw new Error('Terminal settings surface is unavailable') + } + await store.getState().updateSettings({ + terminalPaddingX: nextPadding.x, + terminalPaddingY: nextPadding.y + }) + pane.style.setProperty('width', `${nextSize.width}px`, 'important') + pane.style.setProperty('height', `${nextSize.height}px`, 'important') + }, + { nextPadding: padding, nextSize: size } + ) + + await page.waitForFunction( + ({ expectedPadding, expectedSize }) => { + const store = window.__store + const terminal = document.querySelector('.pane .xterm') + const pane = terminal?.closest('.pane') + if (!store || !pane) { + return false + } + const settings = store.getState().settings + const rect = pane.getBoundingClientRect() + return ( + settings?.terminalPaddingX === expectedPadding.x && + settings?.terminalPaddingY === expectedPadding.y && + Math.abs(rect.width - expectedSize.width) < 0.5 && + Math.abs(rect.height - expectedSize.height) < 0.5 + ) + }, + { expectedPadding: padding, expectedSize: size } + ) + + await page.evaluate(() => { + for (const manager of window.__paneManagers?.values() ?? []) { + for (const pane of manager.getPanes()) { + pane.fitAddon.fit() + } + } + }) + await page.waitForTimeout(100) +} + +function expectClose(actual: number, expected: number): void { + expect(Math.abs(actual - expected)).toBeLessThan(0.75) +} + +function assertFourEdgeGeometry( + geometry: TerminalGeometry, + padding: { x: number; y: number } +): { horizontalRemainder: number; verticalRemainder: number } { + const verticalRemainder = geometry.pane.height - padding.y * 2 - geometry.screen.height + + // The old layout only exposed its trailing gap when cell fitting left a remainder. + expect(verticalRemainder).toBeGreaterThan(MIN_REMAINDER) + expect(verticalRemainder).toBeLessThan(geometry.cellHeight) + + expect({ + left: geometry.terminal.left - geometry.pane.left, + top: geometry.terminal.top - geometry.pane.top, + right: geometry.pane.right - geometry.terminal.right, + bottom: geometry.pane.bottom - geometry.terminal.bottom + }).toEqual({ left: 0, top: 0, right: 0, bottom: 0 }) + + const horizontalRemainder = + geometry.pane.width - padding.x * 2 - geometry.scrollbarWidth - geometry.screen.width + + expect(horizontalRemainder).toBeGreaterThan(0) + expect(horizontalRemainder).toBeLessThan(geometry.cellWidth) + + expectClose(geometry.screen.left - geometry.pane.left, padding.x) + expectClose(geometry.screen.top - geometry.pane.top, padding.y) + expectClose( + geometry.pane.right - geometry.screen.right, + padding.x + geometry.scrollbarWidth + horizontalRemainder + ) + expectClose(geometry.pane.bottom - geometry.screen.bottom, padding.y + verticalRemainder) + + return { horizontalRemainder, verticalRemainder } +} + +test.describe('terminal padding geometry', () => { + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage) + }) + + // #13252: the old start-edge margin left the grid flush right/bottom. The + // #14583 re-land was reverted, so this guard checks geometry, not its paint mechanism. + test('keeps configured padding on all four edges with a forced cell remainder @terminal-rendering-golden', async ({ + orcaPage + }) => { + const baseline = await readTerminalGeometry(orcaPage) + const firstSize = { + width: findNonDivisibleSize( + baseline.cellWidth, + TARGET_COLS, + FIRST_PADDING.x * 2 + baseline.scrollbarWidth + ), + height: findNonDivisibleSize(baseline.cellHeight, TARGET_ROWS, FIRST_PADDING.y * 2) + } + + await applyPaddingAndPaneSize(orcaPage, FIRST_PADDING, firstSize) + const first = await readTerminalGeometry(orcaPage) + const firstRemainders = assertFourEdgeGeometry(first, FIRST_PADDING) + + // Keep usable dimensions constant while changing both setting values. + const secondSize = { + width: firstSize.width + (SECOND_PADDING.x - FIRST_PADDING.x) * 2, + height: firstSize.height + (SECOND_PADDING.y - FIRST_PADDING.y) * 2 + } + await applyPaddingAndPaneSize(orcaPage, SECOND_PADDING, secondSize) + const second = await readTerminalGeometry(orcaPage) + const secondRemainders = assertFourEdgeGeometry(second, SECOND_PADDING) + + expect(second.cols).toBe(first.cols) + expect(second.rows).toBe(first.rows) + expectClose(secondRemainders.horizontalRemainder, firstRemainders.horizontalRemainder) + expectClose(secondRemainders.verticalRemainder, firstRemainders.verticalRemainder) + }) +}) From 3e079debec65aa958fc69bbf3ffa8979662688ef Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 20 Aug 2026 01:38:28 -0700 Subject: [PATCH 022/111] fix(sidebar): host-qualify discovery notice rows on multi-host projects (#15546) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(sidebar): host-qualify discovery notice rows and collapse one checkout's twins A project checked out on several hosts emits one discovery-notice row per checkout, and those rows only named the project. A sidebar with paired remote hosts therefore showed several identical "N hidden worktrees" buttons under one project header, with no way to tell which machine each belonged to — or that one of them was another machine's worktree inbox entirely. Two causes, both fixed here: - Notice rows carried no host context, unlike worktree rows, which have been host-labelled since STA-4343. Both notice rows now take a host label, applied per project (not per rendered section, since a card can land in the pinned fallback) and only when that project spans hosts. The label also lands in the review, expand, and dismiss accessible names, so the actions that write to a specific host's repo record say which host that is. - One machine registered as a direct SSH target *and* paired as a runtime environment gives a single on-disk checkout two repo records with independent hidden-worktree state, so it emitted two rows for one directory. Repos now resolve to a (hostname, path) checkout key, and twins collapse to the record this client persists itself — its visibility state is the user's own and survives the paired runtime going away. The key is deliberately conservative: an unresolved hostname, or a tunnelled environment answering on loopback, yields no key and never collapses anything. Renderer-only; no wire or persistence change. * fix(sidebar): drop the machine-identity collapse, gate notice labels on host ids Replaces this branch's second change after a plan review found it has no precedent and eight concrete failure modes. Deleted: the (hostname, path) checkout key that collapsed two repo records believed to be one machine. Orca models a direct SSH target and a paired runtime environment as different execution hosts everywhere else; that change asserted sameness by resolving strings a user typed in two places. It also dropped rows (a differing count vanished with the shadowed record), flipped with the sidebar host filter, ignored port and user so a host and a container on it could merge, tie-broke on repo-store order, was disabled in the one case Orca can prove (a tunnelled pairing answers on loopback) and fired only on coincidence, and left the visibility dialog showing state the sidebar had hidden. Its module also carried a literal NUL byte, so git classified the file as binary and the diff was unreviewable. Kept, with two corrections: notice rows still carry a host label, but the gate now counts distinct host ids rather than distinct label strings — two hosts sharing one user-facing label is exactly when the rows are hardest to tell apart — and membership is read from the unfiltered repo universe rather than the host-filtered notice candidates, so a label no longer appears and disappears with the filter. Two hosts that share a label still render the same label. Disambiguating that is a shared concern across worktree badges, host headers, and host-filter options, and needs its own design; three verification passes each found a different hole in doing it here. Follow-ups: general host-label collision, and the repo-record duplication that produces the twin rows in the first place. * fix(i18n): catalog notice host scope copy * feat(sidebar): show each notice row's host with the project-on-host glyph Notice rows on a multi-host project already carried a host label, but two hosts can share one user-facing name, and the label truncates first in a narrow sidebar. Each row now also carries its host's glyph. Deliberately the same indicator worktree cards use (worktree-card-header): a Server glyph, ServerOff when a paired runtime has no live status, and a "Project on ..." tooltip naming the host — SSH and runtime keep their distinct tooltip wording. Local hosts draw nothing, as on the cards. The glyph is shrink-0, so unlike the text label it survives the sidebar narrowing, and the row keeps an identifying mark either way. Rows now carry the host id alongside the label, since the label alone cannot select a glyph or its tooltip. Catalog entries for the new copy ship with the change rather than relying on inline fallbacks. * refactor(sidebar): draw notice-row hosts with the shared host glyph Follow-up to the notice-row host indicator: use the one glyph vocabulary the app already has instead of a second copy of it. HostRowIcon — a monitor for this computer, a server for anything remote — was private to the composer's run-target rows. Moved to a shared home and reused, so the sidebar and the composer cannot drift apart. The run-target module re-exports it, leaving its own call sites untouched. Every notice row now gets a glyph, local included, so no row is the odd one out; the tooltip still names the host and says when a paired runtime has no live status. Same size and tone tokens across kinds, so no row reads as decorated relative to its neighbours. * fix(sidebar): make notice host glyphs accessible --- src/renderer/src/components/host-row-icon.tsx | 16 ++ .../new-workspace/RunTargetComboboxRow.tsx | 11 +- .../ImportedWorktreesVisibilityLine.test.tsx | 20 ++ .../ImportedWorktreesVisibilityLine.tsx | 32 ++- .../NewExternalWorktreesInboxLine.test.tsx | 34 +++ .../sidebar/NewExternalWorktreesInboxLine.tsx | 37 ++- .../sidebar/NoticeHostGlyph.test.tsx | 157 +++++++++++++ .../components/sidebar/NoticeHostGlyph.tsx | 86 +++++++ ...ree-list-groups-notice-host-labels.test.ts | 220 ++++++++++++++++++ .../worktree-list/grouping/build-rows.ts | 18 +- .../worktree-list/grouping/group-sections.ts | 17 +- .../worktree-list/grouping/host-labels.ts | 75 +++++- .../grouping/pinned-group-rows.ts | 22 +- .../worktree-list/grouping/row-builders.ts | 17 +- .../worktree-list/grouping/row-types.ts | 6 + .../worktree-list/rows/notice-rows.tsx | 4 + src/renderer/src/i18n/locales/en.json | 9 +- src/renderer/src/i18n/locales/es.json | 6 + src/renderer/src/i18n/locales/ja.json | 6 + src/renderer/src/i18n/locales/ko.json | 6 + src/renderer/src/i18n/locales/zh.json | 6 + .../i18n/worktree-visibility-locales.test.ts | 3 + 22 files changed, 782 insertions(+), 26 deletions(-) create mode 100644 src/renderer/src/components/host-row-icon.tsx create mode 100644 src/renderer/src/components/sidebar/NoticeHostGlyph.test.tsx create mode 100644 src/renderer/src/components/sidebar/NoticeHostGlyph.tsx create mode 100644 src/renderer/src/components/sidebar/worktree-list-groups-notice-host-labels.test.ts diff --git a/src/renderer/src/components/host-row-icon.tsx b/src/renderer/src/components/host-row-icon.tsx new file mode 100644 index 00000000000..7e7d519f60a --- /dev/null +++ b/src/renderer/src/components/host-row-icon.tsx @@ -0,0 +1,16 @@ +import React from 'react' +import { Monitor, Server } from 'lucide-react' + +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host' + +/** The local machine isn't a server — a monitor glyph reads as "this computer". */ +export function HostRowIcon({ + hostId, + className +}: { + hostId: ExecutionHostId + className?: string +}): React.JSX.Element { + const Icon = hostId === LOCAL_EXECUTION_HOST_ID ? Monitor : Server + return +} diff --git a/src/renderer/src/components/new-workspace/RunTargetComboboxRow.tsx b/src/renderer/src/components/new-workspace/RunTargetComboboxRow.tsx index 4d7cb3df50f..dde3ae66b28 100644 --- a/src/renderer/src/components/new-workspace/RunTargetComboboxRow.tsx +++ b/src/renderer/src/components/new-workspace/RunTargetComboboxRow.tsx @@ -1,17 +1,14 @@ import React from 'react' -import { AlertTriangle, ChevronRight, LoaderCircle, Monitor, Server } from 'lucide-react' +import { AlertTriangle, ChevronRight, LoaderCircle } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' -import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host' +import type { ExecutionHostId } from '../../../../shared/execution-host' import { ProjectOptionDetail } from './ProjectComboboxRow' import { translate } from '@/i18n/i18n' +import { HostRowIcon } from '../host-row-icon' -/** The local machine isn't a server — a monitor glyph reads as "this computer". */ -export function HostRowIcon({ hostId }: { hostId: ExecutionHostId }): React.JSX.Element { - const Icon = hostId === LOCAL_EXECUTION_HOST_ID ? Monitor : Server - return -} +export { HostRowIcon } /** * One run-target row. Shares the Project picker's shape — 32px, label and diff --git a/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.test.tsx b/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.test.tsx index 527c7496a41..0dc3f7c3ce2 100644 --- a/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.test.tsx +++ b/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.test.tsx @@ -73,6 +73,26 @@ describe('ImportedWorktreesVisibilityLine', () => { expect(markup).not.toContain('/worktrees/demo-project') }) + it('names the host when the project is checked out on more than one', () => { + const markup = renderLine({ hostContextLabel: 'openclaw' }) + + expect(markup).toContain('openclaw') + expect(markup).toContain('Expand hidden worktrees for orca on openclaw') + expect(markup).toContain( + 'Keep 4 discovered worktrees hidden for orca on openclaw; recover from the project menu' + ) + }) + + it('folds the host into pinned fallback copy, which already names the repo', () => { + const markup = renderLine({ + hostContextLabel: 'openclaw', + placement: 'pinned-fallback', + onKeepHidden: undefined + }) + + expect(markup).toContain('Hiding 4 discovered worktrees in orca on openclaw') + }) + it('scopes pinned fallback copy to the repo name without a dismiss action', () => { const markup = renderLine({ placement: 'pinned-fallback', onKeepHidden: undefined }) diff --git a/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.tsx b/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.tsx index 3c82858579e..9418b29b5ce 100644 --- a/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.tsx +++ b/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.tsx @@ -4,6 +4,8 @@ import { ChevronRight, X } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' +import NoticeHostGlyph from './NoticeHostGlyph' +import type { ExecutionHostId } from '../../../../shared/execution-host' import { getExternalWorktreeParentPath } from '../../../../shared/external-worktree-visibility' import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' import { translate } from '@/i18n/i18n' @@ -19,6 +21,10 @@ export type ImportedWorktreeVisibilityPreview = { type ImportedWorktreesVisibilityLineProps = { repoDisplayName: string + /** Host this checkout lives on. Set only when the project is checked out on + * more than one host, where the line alone cannot identify the row. */ + hostContextLabel?: string + hostContextHostId?: ExecutionHostId hiddenWorktrees: readonly ImportedWorktreeVisibilityPreview[] placement: ImportedWorktreesVisibilityPlacement pending: boolean @@ -74,6 +80,8 @@ export function groupWorktreesByParentPath( export default function ImportedWorktreesVisibilityLine({ repoDisplayName, + hostContextLabel, + hostContextHostId, hiddenWorktrees, placement, pending, @@ -89,7 +97,11 @@ export default function ImportedWorktreesVisibilityLine({ const worktreeGroups = groupWorktreesByParentPath(hiddenWorktrees) const visibleWorktreeGroups = worktreeGroups.slice(0, GROUP_LIMIT) const remainingGroupCount = Math.max(0, worktreeGroups.length - visibleWorktreeGroups.length) - const keepHiddenAriaLabel = `Keep ${hiddenCount} discovered ${worktreeNoun} hidden for ${repoDisplayName}; recover from the project menu` + // Why: two hosts checking out one project render two identical lines. + const repoScopeLabel = hostContextLabel + ? `${repoDisplayName} on ${hostContextLabel}` + : repoDisplayName + const keepHiddenAriaLabel = `Keep ${hiddenCount} discovered ${worktreeNoun} hidden for ${repoScopeLabel}; recover from the project menu` if (hiddenCount === 0) { return null @@ -97,7 +109,7 @@ export default function ImportedWorktreesVisibilityLine({ const lineText = placement === 'pinned-fallback' - ? `Hiding ${hiddenCount} discovered ${worktreeNoun} in ${repoDisplayName}` + ? `Hiding ${hiddenCount} discovered ${worktreeNoun} in ${repoScopeLabel}` : `Hiding ${hiddenCount} discovered ${worktreeNoun}` const toggleGroupExpanded = (path: string): void => { @@ -133,7 +145,7 @@ export default function ImportedWorktreesVisibilityLine({ aria-label={translate( 'auto.components.sidebar.ImportedWorktreesVisibilityLine.f54f2bec5d', '{{value0}} hidden worktrees for {{value1}}', - { value0: isExpanded ? 'Collapse' : 'Expand', value1: repoDisplayName } + { value0: isExpanded ? 'Collapse' : 'Expand', value1: repoScopeLabel } )} onClick={() => setIsExpanded((value) => !value)} className="shrink-0 rounded-[4px] text-muted-foreground hover:bg-worktree-sidebar-accent hover:text-worktree-sidebar-accent-foreground" @@ -144,6 +156,20 @@ export default function ImportedWorktreesVisibilityLine({ /> {lineText} + {hostContextLabel && placement !== 'pinned-fallback' ? ( + + {hostContextHostId ? ( + + ) : null} + + {hostContextLabel} + + + ) : null} {onKeepHidden ? ( diff --git a/src/renderer/src/components/sidebar/NewExternalWorktreesInboxLine.test.tsx b/src/renderer/src/components/sidebar/NewExternalWorktreesInboxLine.test.tsx index 38554945f12..426f9adf02d 100644 --- a/src/renderer/src/components/sidebar/NewExternalWorktreesInboxLine.test.tsx +++ b/src/renderer/src/components/sidebar/NewExternalWorktreesInboxLine.test.tsx @@ -17,6 +17,7 @@ vi.mock('@/components/ui/tooltip', () => ({ const roots: Root[] = [] type RenderOverrides = { + hostContextLabel?: string inboxCount?: number pending?: boolean error?: string | null @@ -34,6 +35,7 @@ async function renderLine(overrides: RenderOverrides = {}): Promise { ) }) + it('names the host so two checkouts of one project are distinguishable', async () => { + // Both rows read "N hidden worktrees"; only the host tells them apart. + const local = await renderLine({ hostContextLabel: 'Local Mac', inboxCount: 61 }) + const remote = await renderLine({ hostContextLabel: 'openclaw', inboxCount: 134 }) + + expect(local.textContent).toContain('Local Mac') + expect(getReviewButton(local)?.getAttribute('aria-label')).toBe( + 'Review 61 hidden worktrees in orca on Local Mac' + ) + expect(getReviewButton(remote)?.getAttribute('aria-label')).toBe( + 'Review 134 hidden worktrees in orca on openclaw' + ) + }) + + it('host-qualifies the suppress control, which writes to that host alone', async () => { + const container = await renderLine({ hostContextLabel: 'openclaw', onSuppress: vi.fn() }) + + expect( + container.querySelector( + 'button[aria-label="Hide external worktrees permanently for orca on openclaw"]' + ) + ).not.toBeNull() + }) + + it('stays unqualified when the project has a single checkout', async () => { + const container = await renderLine() + + expect(getReviewButton(container)?.getAttribute('aria-label')).toBe( + 'Review 24 hidden worktrees in orca' + ) + }) + it('keeps suppress as a hover-revealed control that does not trigger review', async () => { const onReview = vi.fn() const onSuppress = vi.fn() diff --git a/src/renderer/src/components/sidebar/NewExternalWorktreesInboxLine.tsx b/src/renderer/src/components/sidebar/NewExternalWorktreesInboxLine.tsx index 657b3b254e0..33fe674575f 100644 --- a/src/renderer/src/components/sidebar/NewExternalWorktreesInboxLine.tsx +++ b/src/renderer/src/components/sidebar/NewExternalWorktreesInboxLine.tsx @@ -4,10 +4,16 @@ import { ChevronRight, X } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' +import NoticeHostGlyph from './NoticeHostGlyph' +import type { ExecutionHostId } from '../../../../shared/execution-host' import { translate } from '@/i18n/i18n' type NewExternalWorktreesInboxLineProps = { repoDisplayName: string + /** Host this checkout lives on. Set only when the project is checked out on + * more than one host, where the count alone cannot identify the row. */ + hostContextLabel?: string + hostContextHostId?: ExecutionHostId inboxCount: number pending: boolean error: string | null @@ -18,6 +24,8 @@ type NewExternalWorktreesInboxLineProps = { export default function NewExternalWorktreesInboxLine({ repoDisplayName, + hostContextLabel, + hostContextHostId, inboxCount, pending, error, @@ -29,10 +37,19 @@ export default function NewExternalWorktreesInboxLine({ 'auto.components.sidebar.NewExternalWorktreesInboxLine.c3e8a1f4b2', "Don't show again" ) + // Why: the same project on two hosts renders two identical rows, so every + // accessible name has to name the host as well as the project. + const repoScopeLabel = hostContextLabel + ? translate( + 'auto.components.sidebar.NewExternalWorktreesInboxLine.6c07f3a91e', + '{{value0}} on {{value1}}', + { value0: repoDisplayName, value1: hostContextLabel } + ) + : repoDisplayName const suppressAriaLabel = translate( 'auto.components.sidebar.NewExternalWorktreesInboxLine.9f2d4c8b17', 'Hide external worktrees permanently for {{value0}}', - { value0: repoDisplayName } + { value0: repoScopeLabel } ) const isSingular = inboxCount === 1 const countLabel = isSingular @@ -48,12 +65,12 @@ export default function NewExternalWorktreesInboxLine({ ? translate( 'auto.components.sidebar.NewExternalWorktreesInboxLine.7f18c5b0d3', 'Review {{value0}} hidden worktree in {{value1}}', - { value0: inboxCount, value1: repoDisplayName } + { value0: inboxCount, value1: repoScopeLabel } ) : translate( 'auto.components.sidebar.NewExternalWorktreesInboxLine.4e2b7a9c05', 'Review {{value0}} hidden worktrees in {{value1}}', - { value0: inboxCount, value1: repoDisplayName } + { value0: inboxCount, value1: repoScopeLabel } ) if (inboxCount === 0) { @@ -83,6 +100,20 @@ export default function NewExternalWorktreesInboxLine({ {inboxCount} {countLabel} + {hostContextLabel ? ( + + {hostContextHostId ? ( + + ) : null} + + {hostContextLabel} + + + ) : null}