diff --git a/config/i18n-translation-source.md b/config/i18n-translation-source.md new file mode 100644 index 00000000000..490c7800965 --- /dev/null +++ b/config/i18n-translation-source.md @@ -0,0 +1,524 @@ +# I18n Translation Source and Automation + +Date: 2026-07-29 (revised 2026-07-30) + +Status: accepted architecture decision; PR A +([#8512](https://github.com/stablyai/orca/pull/8512)) merged; PR B not yet +implemented + +Revision note: the original decision selected a constrained XLIFF 2.0 profile +at 80% confidence, with gettext PO as the named fallback. A tooling, +contributor-workflow, and repository-evidence review on 2026-07-30 resolved +that uncertainty against XLIFF 2.0 before PR B was built. The architecture is +unchanged; the canonical format is now gettext PO. The evidence is recorded +under "Alternatives considered". + +## Goal + +Reduce the recurring engineering and human work required to localize Orca +without presenting missing, copied-English, or stale values as completed +translations. + +The format is not the goal. The intended steady-state workflow is: + +- a feature developer adds a stable message ID and current English copy once; +- extraction discovers new and changed messages automatically; +- feature work does not wait for every target locale; +- translation automation processes only missing and stale units; +- humans review a small, risk-prioritized delta rather than whole catalogs; +- missing or stale translations safely fall back to current English; and +- deterministic, offline runtime bundles ship with the app. + +## Decision + +Use gettext PO as Orca's canonical bilingual translation source, under a +constrained profile: + +- `msgctxt` — the stable Orca message ID; +- `msgid` — the English source, current as of the entry's last + reconciliation; +- `msgstr` — the target translation; +- `#, fuzzy` — not approved for runtime, whether stale or newly + machine-translated; never compiled into runtime bundles; +- `#| msgid` — the prior English source associated with the retained + translation; +- `#.` — translator context and notes; +- `#:` — source references (optional, informational); and +- `#~` — retired entries, kept only under the retention policy below. + +Product source owns stable message IDs and current English defaults. Maintained +extraction produces the current English translation source. Each target locale +owns a sparse PO file containing only translations that exist, each carrying +the English source it is keyed against. + +Responsibilities are split so target catalogs never change in feature work: + +- **Feature PRs** change English only: source code, inline defaults, and the + extracted English source. +- **A read-only compiler** joins the current English source with each target + PO file by `msgctxt` and generates the existing i18next JSON bundle shape. + An entry whose stored `msgid` differs from current English is stale; one + whose `msgid` is current but fuzzy is pending approval. Both are omitted so + i18next falls back to current English. + The compiler never writes to PO files. Generated runtime JSON is disposable + and must not be hand-edited. +- **Post-merge localization automation** owns all PO mutations. Reconciliation + finds entries affected by merged English changes, updates `msgid` to current + English, preserves the old source in `#| msgid`, and sets `#, fuzzy` — so + translators and tools see the current source, the previous source, and the + prior translation in one unit. Reconciliation lands in localization-only + changes, never in the originating feature change. + +The reconciler — not gettext's own merge tooling — owns the join. `msgmerge` +keys entries on the `(msgctxt, msgid)` pair, so under stable IDs a source +change orphans the entry instead of marking it fuzzy. Reconciliation must join +by `msgctxt` alone. + +Absence of `#, fuzzy` means approved to ship, whether approval came from a +human or an explicitly configured low-risk automation policy. Machine or human +provenance is recorded separately in a documented comment or flag; it must not +be conflated with runtime eligibility. + +PO is an authoring and translation-workflow boundary, not a runtime +dependency. Orca must not require network access or a translation service to +display localized UI. + +## Why this solves the work problem + +The system is organized around translating only the delta: + +```text +feature change: stable ID + English + | + v + extraction and validation + | + v + missing/changed translation queue + | + v + machine translation + glossary/context + | + v + placeholder and policy validation + | + v + sparse locale bundle or English fallback +``` + +Feature developers do not synchronize target catalogs, copy English into +untranslated locales, run whole-catalog repair, or wait for translation +completion. A source change invalidates only the affected target units. +Unchanged translations remain byte-stable. + +The bilingual source gives the compiler enough information to distinguish: + +- **missing**: the locale has no entry for the ID; +- **stale**: the stored `msgid` differs from current English — true the + moment the English change merges, before any reconciliation runs; +- **pending approval**: the stored `msgid` is current, a target exists, and + `#, fuzzy` is set; and +- **current/approved**: the stored `msgid` is current, a target exists, and + `#, fuzzy` is absent. + +Missing, stale, and pending-approval entries are omitted from generated +target bundles so i18next uses current English. Staleness must not be inferred from Git history, +file timestamps, copied-English ratios, or a second hand-maintained database. + +## Constrained PO profile + +Orca will support only the subset needed for its message model. PR B must +define and validate that subset rather than accepting arbitrary PO documents. + +Each supported entry must carry: + +- one stable Orca message ID (`msgctxt`), unique within the file; +- the English source (`msgid`), current as of the entry's last + reconciliation; +- an optional target value (`msgstr`) in a target-locale file; +- translator notes or UI context when provided (`#.`); +- runtime workflow state (`#, fuzzy`), with provenance recorded in the + documented separate comment or flag; and +- the information needed to validate interpolation placeholders. + +Plurals use Orca's existing per-key convention: each i18next plural-suffixed +key (`…_one`, `…_other`) is its own PO entry. The profile must not use +`msgid_plural`/`msgstr[n]`, which would collide with i18next's own CLDR plural +selection and gettext's `nplurals` model. + +The compiler must reject: + +- duplicate or unstable IDs (including two entries sharing a `msgctxt`); +- empty targets represented as completed translations; +- unsupported states or constructs; +- malformed source/target entries; +- nondeterministic ordering or output — the profile must document exact + serialization normalization (entry order by `msgctxt`, fixed line folding, + LF endings, escaping rules) so every tool in the pipeline emits identical + bytes for identical content; and +- locale files whose declared language headers are incorrect. + +Placeholder validation is state-dependent (an entry is current only when its +stored `msgid` matches current English and it carries no fuzzy flag): + +- **stale or pending-approval entries**: a placeholder mismatch is expected + mid-flight; the entry is already omitted from bundles, so validation + reports it without failing; +- **current/approved entries**: a placeholder mismatch is a hard validation + failure; and +- **automation and import pipelines**: a produced target with mismatched + placeholders is demoted to fuzzy rather than committed as current. + +Source comparison is a semantic comparison of parsed source strings after +documented text normalization. Line folding, wrapping, and PO escaping are +serialization concerns and must never affect comparison. Reconciliation must +never update an entry's stored source without either confirming or replacing +the target, or marking the entry fuzzy. + +If translator notes or placeholder semantics can change the required +translation without changing visible English, the source-signature contract +must include that information or explicitly invalidate the entry. + +Retired entries (`#~`) are kept only while they provide useful translation +memory for automation; a deterministic cleanup policy removes them rather than +letting them accumulate indefinitely. + +## Message ID and placeholder policy + +The current catalog undermines the stable-ID premise in two ways the format +alone cannot fix: 8,981 of 11,564 keys (77.7%) are codemod-generated content +hashes, and 825 of 954 interpolated strings use positional placeholders +(`{{value0}}`) that carry no meaning for a translator or a machine-translation +prompt. + +Policy: + +- hashed IDs already in the desktop catalog as of this decision (2026-07-30) + are grandfathered as opaque stable IDs; the generating codemod must not be + rerun against existing keys, and no mass rename happens during this + migration; +- newly minted hash IDs — including in-flight bridge catalogs not yet merged + or merged after this decision — are **not** grandfathered: they are renamed + to intent-named IDs in a dedicated change adjacent to their landing, before + PO becomes canonical for that surface, never buried inside an otherwise + reviewed feature PR; +- new keys must use intent-named IDs and named interpolation placeholders; +- converting positional placeholders to named ones requires per-message + semantic judgment (the name is part of the translatable contract), not a + mechanical rename; and +- legacy IDs and positional placeholders are improved opportunistically when + the copy itself is touched, never in bulk. + +## Runtime and source ownership + +The ownership chain is: + +```text +product code and explicit dynamic declarations + | + v + extracted current English source + | + v + sparse canonical target-locale PO files + | + v + deterministic i18next JSON bundles + | + v + lazy-loaded offline app resources +``` + +Inline English defaults remain readable at call sites and provide extraction +input. Static extraction and explicit declarations for genuinely dynamic keys +must reconcile to one current English translation source. CI must reject +missing declarations and incompatible defaults rather than creating a +permanent exception database. + +Renderer, Electron main, web, SSH, WSL, and packaged execution continue to +consume the same logical runtime bundles. The compiler and build paths must use +cross-platform path handling and deterministic line endings. + +Two additional surfaces are in scope: + +- **Mobile.** The mobile app is acquiring its own catalog tree with its own + key scheme. Mobile must adopt the same canonical contract, ID policy, and + compiler — with its own PO files — rather than a second bespoke pipeline. + Mobile keys must not be folded into the desktop catalogs, and in-flight + mobile catalog JSON is a bridge the migration replaces, not a canonical + source. Mobile requires **two deterministic projections** from the same PO + source: the mobile i18next JSON bundle, and the native metadata resources + (iOS `InfoPlist.strings`, Android resources) that render before the JS + runtime exists. i18next fallback cannot cover pre-JS surfaces, so for a + missing, stale, or pending-approval native entry the compiler must either + omit the locale-specific native key with proven OS/base-locale fallback on + both platforms, or emit current English under a documented platform rule. + The intentional locale-ID mapping (JS `zh` vs native `zh-Hans`) is part of + the projection contract. The migration covers both projections. +- **Plugin language packs.** Language packs remain external overrides + validated against compiled core keys. They are consumers of the contract, + not another canonical source, and the compiler must leave their runtime + path intact. + +## Translation automation + +The architecture is incomplete until translation work is delta-driven. +After the compiler and migration are stable, automation should: + +1. reconcile entries affected by merged English changes (update `msgid`, + preserve `#| msgid`, set fuzzy) and find target entries that are missing + or stale; +2. batch only those entries into a translation change; +3. supply translator notes, glossary terms, placeholder rules, the previous + source from `#| msgid`, and relevant existing translations; +4. preserve all unaffected target entries byte-for-byte; +5. mark runtime eligibility and provenance honestly; +6. run placeholder, terminology, formatting, and catalog validation, demoting + invalid output to fuzzy; and +7. fall back to English rather than shipping an invalid target. + +Human review should be risk-based. Destructive actions, authentication, +billing, privacy, security, legal copy, OS permission prompts (camera, +microphone, photos, local network), native app metadata, and other sensitive +flows require review. Routine labels and descriptions may use validated machine translation +under the adopted release policy. A model-provided confidence score alone is +not sufficient release evidence. + +Catalog QA should include an identical-to-English guard implemented as a +ratchet with reviewed exemptions, not a blanket prohibition: brand names, +commands, and terms that legitimately remain English live in the exemption +inputs, and the untranslated ratio for everything else must not rise. + +Glossary and forbidden-translation rules belong in stable QA inputs. The +existing repair-script policy data (glossaries, never-translate lists, +per-locale overrides) is converted into those inputs. They must replace +historical whole-catalog repair scripts rather than coexist with them as +another source of product behavior. + +## Rollout + +### PR A: decouple feature copy — merged + +PR #8512: + +- permits sparse target catalogs; +- makes English runtime fallback authoritative for missing values; +- makes catalog synchronization update English only; +- preserves existing target values and rejects placeholder incompatibility; +- adds maintained extraction and CI verification; and +- removes whole-catalog translation and repair commands from ordinary product + workflows. + +PR A is independently useful and format-neutral. It stops new parity work, but +it does not permanently model reviewed or stale translation state. + +PR A also created a deletion ratchet: extra target-locale keys hard-fail +verification and sync no longer edits targets, so an English key cannot be +retired without hand-editing every target catalog. Orphaned English keys +(2,055 unreferenced at time of writing) grow with every rename until PR C +lands. PR C should not wait long. + +### PR B: define the source and compiler + +PR B must remain a small architecture and implementation proof. It should add: + +1. the constrained PO profile and its serialization normalization; +2. extraction into the current English source; +3. sparse source/target parsing via maintained PO tooling; +4. deterministic, read-only i18next bundle compilation, omitting entries + whose stored source mismatches current English or that are fuzzy — with a + compiler shape that admits additional per-locale projections (the mobile + native-resource output) even though PR B implements only the i18next + bundle; +5. a separate reconciliation command owning all PO mutations (`msgid` + update, `#| msgid` preservation, `#, fuzzy`); +6. state-dependent placeholder validation; +7. representative fixtures for interpolation, plural-suffixed keys, multiline + copy, translator notes, and source changes; and +8. packaged-build integration across local and remote execution boundaries. + +Acceptance tests must assert every workflow field — state, previous source, +notes, retirement — field-by-field through parse → modify → serialize cycles. +Byte-identical round-trip alone is not evidence: a lossy parser faithfully +reproduces its own losses, so a round-trip gate can pass while the fields that +justify the format are silently destroyed. This failure mode was demonstrated +against the dominant XLIFF library during the format review. + +It must not migrate the complete locale inventory. That keeps the format and +compiler independently reviewable and makes rejection inexpensive. + +### PR C: migrate existing translations once + +The migration must: + +- export all current stable English IDs and values; +- import real target values without retranslating them; +- preserve known reviewed corrections; +- mark unproven values as pending approval, with imported provenance — + bridge catalogs in particular mix reused desktop translations, machine + translation, and manual corrections, and must be classified rather than + assumed approved; +- remove copied-English parity filler unless it is intentionally English, + seeding the identical-to-English exemption inputs from the existing policy + data; +- turn terminology and known mistranslations into glossary and QA inputs; +- drain the orphaned-English-key ratchet and delete the retired bootstrap and + repair script files; and +- explain every difference between current and compiled runtime bundles. + +Every existing target value must be byte-preserved, explicitly retired, or +classified as filler. No Git-history inference should remain in the permanent +runtime or verification path. + +### PR D: switch ownership and delete the bridge + +After bundle equivalence is proven: + +- PO becomes the only editable target translation source; +- generated runtime JSON stops being a canonical input; +- development, test, build, and packaging compile the bundles + deterministically; +- a JSON→PO importer ships for contributors, so existing full-catalog + community PRs can be converted instead of abandoned; +- legacy bootstrap, repair, override, and migration-only bookkeeping is + removed; and +- ordinary CI runs one focused extraction and compile verification path. + +### Translation automation + +The first automation follow-up should create or update localization-only +changes containing only missing and stale entries. It should not rewrite a +whole locale, modify feature code, or block the originating feature change. + +## Alternatives considered + +### Constrained XLIFF 2.0 — original choice, reversed 2026-07-30 + +XLIFF 2.0 has the best abstract schema fit: stable IDs, source and target in +one unit, standardized workflow state, and categorized notes. The decision was +reversed when the stated uncertainties resolved against it: + +- the dominant JS XLIFF library silently discards `state`, `subState`, and + note categories — the fields that motivate the format — while reporting a + clean round-trip, so the planned acceptance gate would have passed + vacuously; the only spec-faithful JS alternative has ~90 weekly downloads, + a single maintainer, and EPL-1.0 licensing; +- XLIFF 2.0 core defines no previous-source mechanism, so the "what changed" + payload for translation automation requires a custom extension that breaks + the interoperability that justified the format (leaving the old `` + in the target file and comparing against a separate current-English file is + possible, but then translators and tools see the wrong current source + unless another projection is built — PO represents the joined working state + natively); +- tool and platform support for XLIFF 2.x remains weaker than 1.2 more than a + decade after standardization; no verified case of XLIFF 2.0 as an in-repo + canonical source was found, and the closest public precedent for this exact + compiler design chose 1.2; and +- XML is the format most likely to let a hand-edited community PR fail on a + malformed entity, and there is no cross-tool XML formatting convention, so + external editors produce whole-file diffs; Orca's translation contributions + arrive overwhelmingly as direct catalog PRs. + +If a translation provider requires XLIFF, Orca should add a deterministic +import/export adapter at the boundary rather than change the canonical +representation. + +### XLIFF 1.2 + +Broader legacy tool support than 2.0, but more tool-specific dialects and a +less coherent data model, and it shares XML's contributor and formatting +costs. Adapter-only, as above. + +### Direct JSON or TypeScript catalogs + +These are simple runtime inputs, but they do not standardize source snapshots, +translator context, or review state. Meeting Orca's requirements would require +custom sidecars or object schemas and synchronization rules. That recreates +much of a bilingual standard while retaining the parity and stale-state risks +the migration is intended to remove. + +### Bespoke bilingual JSON — the current fallback + +A JSON schema with embedded per-locale state, source snapshots, and notes is a +proven model (a major platform vendor ships exactly this shape, with per-key +extraction state including staleness and per-locale review state) and would +add no parsing dependency. It is second to PO because PO's workflow fields are +standard rather than bespoke, existing translation editors and open tooling +understand them, and its diffs are the smallest of the three formats. If the +PR B PO proof fails, adopt this — not XLIFF — before PR C. + +### Platform-native catalogs + +Platform-native string catalogs provide strong translation state on their +own platform but are not a suitable authority for Orca's macOS, Linux, +Windows, web, WSL, and SSH surfaces. + +### Proprietary translation platform + +A translation platform may later operate on the PO boundary, but it should +not become Orca's sole source of truth or a runtime dependency. Selecting one +before the repository contract exists would create premature vendor coupling. + +### Peer practice + +Inspectable applications validate the architecture more strongly than any one +format: + +- mature systems use stable keys or source messages, authoritative English, + sparse targets, and runtime fallback; +- applications that keep canonical translations in-repo with PR-based + contribution use PO or stateful JSON catalogs; none inspected uses XLIFF + 2.0 as a canonical source, and the only XLIFF observed was 1.2 as + interchange into an external pipeline; +- the delta-driven machine-translation model this document targets is already + shipping in peer practice, keyed by per-entry source tracking; +- structural key parity is not a completeness signal — peer catalogs exist + with perfect parity and majority copied-English content, which is what the + identical-to-English ratchet detects; and +- gates that are written but not enforced decay — every verification in this + plan must run in CI from the PR that introduces it. + +Closed-source applications do not publish enough of their authoring pipelines +to establish a format decision. Orca therefore chooses based on its own +requirements rather than presumed competitor internals. + +## Confidence and decision gate + +Confidence is: + +- **97%** in the overall sparse-source, source-snapshot, deterministic-compiler, + and English-fallback architecture; +- **90%** in PO over constrained XLIFF 2.0; +- **85%** in PO over bespoke bilingual JSON; and +- **95%** that a migration-free PR B prototype on PO is the correct next step. + +The remaining PO uncertainty concerns exact serialization normalization +(folding, escaping) across the tools contributors use, the provenance +convention recorded alongside `#, fuzzy`, and the `#~` retention policy. + +PR B closes the decision gate only if its representative fixtures preserve +every workflow field through parse → modify → serialize verified +field-by-field, output is deterministic, diffs remain reviewable, and the +compiler/build integration is maintainable. If that proof fails, adopt bespoke +bilingual JSON before PR C. Do not fall back to XLIFF 2.0, add compatibility +layers, or create custom sidecar state merely to preserve a format choice. + +This is a deliberate, reversible senior-engineering decision — and it has +already been exercised once: the original XLIFF 2.0 selection was reversed +when evidence resolved its stated uncertainties, at zero migration cost +because the staged rollout deferred format commitment until PR B. + +## Explicit non-goals + +The completed system must not: + +- require every target locale to contain every English key; +- copy English into target catalogs to simulate coverage; +- infer permanent translation state from Git history; +- retranslate or rewrite complete catalogs for ordinary copy changes; +- make generated runtime JSON another hand-edited source; +- rename existing hashed message IDs in bulk; +- treat plugin language packs as a canonical source; +- block feature PRs while translations catch up; +- require runtime network access; or +- maintain multiple canonical translation databases. diff --git a/config/localization-audit.md b/config/localization-audit.md index 2090dcbb7b4..437ecc6facf 100644 --- a/config/localization-audit.md +++ b/config/localization-audit.md @@ -4,6 +4,9 @@ This is the pre-work artifact for migrating Orca to a localized UI. The goal is to make coverage repeatable: every detected user-facing string is either moved behind the localization layer or explicitly excluded with a reason. +The accepted translation-source architecture and staged migration are recorded +in [`i18n-translation-source.md`](./i18n-translation-source.md). + ## Coverage Contract Coverage means all strings matching the audit scope below are accounted for: @@ -75,9 +78,11 @@ have no package-script entry points. Ordinary product and localization work must not invoke tools that can overwrite an entire target catalog. The coverage gate compares current candidates against -`config/localization-coverage-allowlist.json`. The committed allowlist is empty: -new candidates fail the check and must be localized or added with a reviewed -reason in the same change. +`config/localization-coverage-allowlist.json`. The committed allowlist is +small (10 reviewed entries — one test fixture title, five non-English +language-name search keywords, and four reviewed product-name search +keywords): new candidates fail the check and must be localized or added with +a reviewed reason in the same change. The script scans `src/renderer/src` by default. That is the primary UI surface. Use `--source-root src` for a wider audit when checking renderer-adjacent shared diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 9546c2f251f..b6076db0e03 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -16,6 +16,7 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import type { GlobalSettings } from '../../shared/types' +import type * as ShellStartupEnv from '../pty/shell-startup-env' const testState = { userDataDir: '', @@ -280,6 +281,10 @@ describe('CodexRuntimeHomeService', () => { beforeEach(() => { vi.resetModules() vi.clearAllMocks() + vi.doMock('../pty/shell-startup-env', async () => ({ + ...(await vi.importActual('../pty/shell-startup-env')), + isShellStartupEnvProbeSupported: () => true + })) testState.userDataDir = mkdtempSync(join(tmpdir(), 'orca-runtime-home-')) testState.fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-codex-home-')) testState.previousUserDataPath = process.env.ORCA_USER_DATA_PATH diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 74b33a0bebb..f26839f8d28 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -64,7 +64,7 @@ import { getDefaultWslDistro, getWslHome } from '../wsl' import { isCodexSystemDefaultRealHomeEnabled } from '../codex/codex-real-home-flag' import { hasCustomCodexHomeOverride } from '../codex/codex-real-home-path' import { invalidateCodexSessionBackfillMarker } from '../codex/codex-session-backfill-marker' -import { readShellStartupEnvVar } from '../pty/shell-startup-env' +import { isShellStartupEnvProbeSupported, readShellStartupEnvVar } from '../pty/shell-startup-env' import { assertOwnedHostCodexManagedHomePath } from './host-codex-managed-home-ownership' import { codexAuthIsFresher, @@ -433,6 +433,9 @@ export class CodexRuntimeHomeService { if (hasCustomCodexHomeOverride(effectiveEnv)) { return false } + if (!isShellStartupEnvProbeSupported()) { + return false + } // Why: Finder/Dock launches do not inherit shell exports, but the login // shell can re-export a custom home after spawn and bypass the trusted lane. const shellCodexHome = readShellStartupEnvVar( diff --git a/src/main/codex-accounts/runtime-home-windows-profile-ownership.test.ts b/src/main/codex-accounts/runtime-home-windows-profile-ownership.test.ts new file mode 100644 index 00000000000..a1a66a847d8 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-windows-profile-ownership.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it } from 'vitest' +import type { GlobalSettings } from '../../shared/types' +import { CodexRuntimeHomeService } from './runtime-home-service' + +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') +const originalRealHomeOverride = process.env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME +const originalCodexHome = process.env.CODEX_HOME +const originalOrcaCodexHome = process.env.ORCA_CODEX_HOME + +afterEach(() => { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + restoreEnv('ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME', originalRealHomeOverride) + restoreEnv('CODEX_HOME', originalCodexHome) + restoreEnv('ORCA_CODEX_HOME', originalOrcaCodexHome) +}) + +describe('Windows System Default Codex home ownership', () => { + it('stays managed when PowerShell profile state cannot be inspected', () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + process.env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME = '1' + delete process.env.CODEX_HOME + delete process.env.ORCA_CODEX_HOME + + const service = Object.create(CodexRuntimeHomeService.prototype) as CodexRuntimeHomeService + Object.defineProperty(service, 'store', { value: createStore() }) + + expect( + service.isHostSystemDefaultRealHomeSelected({ + HOME: 'C:\\Users\\profile-only-repro', + SHELL: 'powershell.exe' + }) + ).toBe(false) + }) +}) + +function createStore() { + const settings = { + codexManagedAccounts: [], + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: {} } + } as unknown as GlobalSettings + return { getSettings: () => settings } +} + +function restoreEnv(name: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[name] + } else { + process.env[name] = value + } +} diff --git a/src/main/codex/codex-session-resume-home.test.ts b/src/main/codex/codex-session-resume-home.test.ts index 912a226959c..75d91d7ab34 100644 --- a/src/main/codex/codex-session-resume-home.test.ts +++ b/src/main/codex/codex-session-resume-home.test.ts @@ -45,6 +45,121 @@ describe('resolveTrustedCodexSessionResumeHome', () => { ).toBe('c:\\users\\example\\.codex') }) + it('accepts an extended-length Windows rollout and preserves its original path', async () => { + const homePath = 'C:\\Users\\Example\\.codex' + const transcriptPath = + '\\\\?\\C:\\Users\\Example\\.codex\\sessions\\2026\\07\\20\\rollout-session.jsonl' + + await expect( + findTrustedCodexSessionResume({ + sessionId: 'session-a', + transcriptPath, + trustedCodexHomes: [homePath], + ...withoutHomeRanking, + fileIsRegular: (filePath) => filePath === transcriptPath + }) + ).resolves.toEqual({ homePath, transcriptPath }) + }) + + it('rejects unsafe or unrelated Windows namespace paths before probing files', () => { + const fileIsRegular = vi.fn((): boolean => true) + const trustedCodexHomes = ['C:\\Users\\Example\\.codex'] + const rejectedPaths = [ + '\\\\?\\D:\\Users\\Example\\.codex\\sessions\\2026\\07\\20\\rollout-a.jsonl', + '\\\\?\\GLOBALROOT\\Device\\HarddiskVolumeShadowCopy1\\sessions\\2026\\07\\20\\rollout-a.jsonl', + '\\\\?\\Volume{00000000-0000-0000-0000-000000000000}\\sessions\\2026\\07\\20\\rollout-a.jsonl', + '\\\\.\\C:\\Users\\Example\\.codex\\sessions\\2026\\07\\20\\rollout-a.jsonl', + '\\\\?\\C:\\Users\\Example\\.codex\\sessions\\2026\\07\\20\\rollout-a:stream.jsonl', + '\\\\?\\C:\\Users\\Example\\.codex\\sessions\\2026\\07\\20\\..\\rollout-a.jsonl' + ] + + for (const transcriptPath of rejectedPaths) { + expect( + resolveTrustedCodexSessionResumeHome({ + transcriptPath, + trustedCodexHomes, + fileIsRegular + }) + ).toBeNull() + } + expect(fileIsRegular).not.toHaveBeenCalled() + }) + + it('rejects a missing or non-regular extended-length rollout', () => { + expect( + resolveTrustedCodexSessionResumeHome({ + transcriptPath: + '\\\\?\\C:\\Users\\Example\\.codex\\sessions\\2026\\07\\20\\rollout-a.jsonl', + trustedCodexHomes: ['C:\\Users\\Example\\.codex'], + fileIsRegular: () => false + }) + ).toBeNull() + }) + + // Why: the sessions root is folded too, so the home side must accept the extended spelling. + it('accepts a normal-form rollout under an extended-length trusted home', () => { + const homePath = '\\\\?\\C:\\Users\\Example\\.codex' + expect( + resolveTrustedCodexSessionResumeHome({ + transcriptPath: 'C:\\Users\\Example\\.codex\\sessions\\2026\\07\\20\\rollout-a.jsonl', + trustedCodexHomes: [homePath], + fileIsRegular: () => true + }) + ).toBe(homePath) + }) + + // Why: the .zst sibling is derived from the persisted path, so a folded + // comparison copy must never leak into the probed or returned path. + it('follows a compressed extended-length rollout without losing the extended spelling', async () => { + const homePath = 'C:\\Users\\Example\\.codex' + const plainPath = + '\\\\?\\C:\\Users\\Example\\.codex\\sessions\\2026\\07\\20\\rollout-session.jsonl' + const compressedPath = `${plainPath}.zst` + + await expect( + findTrustedCodexSessionResume({ + sessionId: 'session-a', + transcriptPath: plainPath, + trustedCodexHomes: [homePath], + ...withoutHomeRanking, + fileIsRegular: (filePath) => filePath === compressedPath + }) + ).resolves.toEqual({ homePath, transcriptPath: compressedPath }) + }) + + // Why: the id scan is a second call site of the same check, once per directory entry. + it('accepts extended-length scan entries but not device-namespace entries', async () => { + const sessionId = '019f81b9-19a9-7651-a8d1-352d9420bd11' + const homePath = 'C:\\Users\\Example\\.codex' + const extendedEntry = `\\\\?\\C:\\Users\\Example\\.codex\\sessions\\2026\\07\\20\\rollout-${sessionId}.jsonl` + + await expect( + findTrustedCodexSessionResume({ + sessionId, + transcriptPath: undefined, + trustedCodexHomes: [homePath], + ...withoutHomeRanking, + fileIsRegular: () => true, + listSessionFiles: async function* (): AsyncIterable { + yield extendedEntry + } + }) + ).resolves.toEqual({ homePath, transcriptPath: extendedEntry }) + + await expect( + findTrustedCodexSessionResume({ + sessionId, + transcriptPath: undefined, + trustedCodexHomes: [homePath], + ...withoutHomeRanking, + fileIsRegular: () => true, + listSessionFiles: async function* (): AsyncIterable { + yield `\\\\.\\C:\\Users\\Example\\.codex\\sessions\\2026\\07\\20\\rollout-${sessionId}.jsonl` + } + }) + ).resolves.toBeNull() + }) + it('rejects paths outside trusted homes or outside the rollout layout', () => { const fileIsRegular = vi.fn((): boolean => true) expect( @@ -225,6 +340,25 @@ describe('resolveTrustedCodexSessionResumeHome', () => { expect(listSessionFiles).not.toHaveBeenCalled() }) + it('does not scan another home after rejecting extended-length provenance', async () => { + const listSessionFiles = vi.fn((): AsyncIterable => { + throw new Error('must not scan') + }) + + await expect( + findTrustedCodexSessionResume({ + sessionId: '019f81b9-19a9-7651-a8d1-352d9420bd11', + transcriptPath: + '\\\\?\\D:\\Other\\.codex\\sessions\\2026\\07\\20\\rollout-019f81b9-19a9-7651-a8d1-352d9420bd11.jsonl', + trustedCodexHomes: ['C:\\Users\\Example\\.codex'], + ...withoutHomeRanking, + fileIsRegular: () => true, + listSessionFiles + }) + ).resolves.toBeNull() + expect(listSessionFiles).not.toHaveBeenCalled() + }) + it('does not scan homes for an untrusted legacy session id shape', async () => { const listSessionFiles = (): AsyncIterable => { throw new Error('must not scan') @@ -446,6 +580,14 @@ describe('claimsCodexRolloutLayout', () => { ).toBe(true) }) + it('is true for an ADS-shaped rollout that provenance trust rejects', () => { + expect( + claimsCodexRolloutLayout( + 'C:\\Users\\example\\.codex\\sessions\\2026\\07\\20\\rollout-a:stream.jsonl' + ) + ).toBe(true) + }) + it('is false for Claude (or other non-Codex) transcript paths', () => { expect( claimsCodexRolloutLayout( diff --git a/src/main/codex/codex-session-resume-home.ts b/src/main/codex/codex-session-resume-home.ts index f4aff76a4b4..2cd581dbb97 100644 --- a/src/main/codex/codex-session-resume-home.ts +++ b/src/main/codex/codex-session-resume-home.ts @@ -8,10 +8,11 @@ import { import { listCodexSessionRolloutFilesIncrementally } from './codex-session-file-listing' // Why: only Codex's dated rollout layout may establish account-home provenance; nested/misplaced JSONL must not select credentials. -const DATED_ROLLOUT_TAIL = String.raw`\d{4}/\d{2}/\d{2}/rollout-[^/]+\.jsonl(?:\.zst)?` -const ROLLOUT_RELATIVE_PATH = new RegExp(`^${DATED_ROLLOUT_TAIL}$`) +const CLAIMED_CODEX_ROLLOUT_TAIL = String.raw`\d{4}/\d{2}/\d{2}/rollout-[^/]+\.jsonl(?:\.zst)?` +const TRUSTED_CODEX_ROLLOUT_TAIL = String.raw`\d{4}/\d{2}/\d{2}/rollout-[^/:]+\.jsonl(?:\.zst)?` +const ROLLOUT_RELATIVE_PATH = new RegExp(`^${TRUSTED_CODEX_ROLLOUT_TAIL}$`) // Why: case-insensitive because trusted-home matching folds Windows path case too. -const CODEX_ROLLOUT_LAYOUT_PATH = new RegExp(`(?:^|/)sessions/${DATED_ROLLOUT_TAIL}$`, 'i') +const CODEX_ROLLOUT_LAYOUT_PATH = new RegExp(`(?:^|/)sessions/${CLAIMED_CODEX_ROLLOUT_TAIL}$`, 'i') /** `resume` pins CODEX_HOME to the account that owns the rollout. `fresh` means * provenance could not be verified, so the caller drops the resume argv — an @@ -22,8 +23,25 @@ export type CodexSessionResumePreparation = | { outcome: 'resume'; codexHomePath: string } | { outcome: 'fresh'; claimedCodexProvenance: boolean } +// Why: fold only Win32's extended drive spelling; \\.\ device namespaces and every other \\?\ form +// (including \\?\UNC\, which is a network share rather than a device) stay unfolded here. +function toCodexTrustedPathComparisonCopy(filePath: string): string | null { + if (filePath.startsWith('\\\\.\\')) { + return null + } + if (!filePath.startsWith('\\\\?\\')) { + return filePath + } + return filePath.match(/^\\\\\?\\([A-Za-z]:[\\/][\s\S]*)$/)?.[1] ?? null +} + function isCodexRolloutInsideSessionsRoot(sessionsRoot: string, filePath: string): boolean { - const relativePath = relativePathInsideRoot(sessionsRoot, filePath) + const comparisonSessionsRoot = toCodexTrustedPathComparisonCopy(sessionsRoot) + const comparisonFilePath = toCodexTrustedPathComparisonCopy(filePath) + if (!comparisonSessionsRoot || !comparisonFilePath) { + return false + } + const relativePath = relativePathInsideRoot(comparisonSessionsRoot, comparisonFilePath) return Boolean(relativePath && ROLLOUT_RELATIVE_PATH.test(relativePath.replace(/\\/g, '/'))) } diff --git a/src/main/codex/codex-session-resume-windows.test.ts b/src/main/codex/codex-session-resume-windows.test.ts new file mode 100644 index 00000000000..1f5e1d88c47 --- /dev/null +++ b/src/main/codex/codex-session-resume-windows.test.ts @@ -0,0 +1,46 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { dirname, join, toNamespacedPath } from 'node:path' +import { tmpdir } from 'node:os' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { prepareCodexSessionResume } from './codex-session-resume-preparation' + +const windowsIt = process.platform === 'win32' ? it : it.skip +const cleanupPaths: string[] = [] + +afterEach(() => { + for (const cleanupPath of cleanupPaths.splice(0)) { + rmSync(cleanupPath, { recursive: true, force: true }) + } +}) + +describe('Codex session resume on native Windows', () => { + windowsIt('resumes an extended-length rollout from its ordinary trusted home', async () => { + const homePath = mkdtempSync(join(tmpdir(), 'orca-codex-resume-')) + cleanupPaths.push(homePath) + const rolloutPath = join( + homePath, + 'sessions', + '2026', + '07', + '20', + 'rollout-019f81b9-19a9-7651-a8d1-352d9420bd11.jsonl' + ) + mkdirSync(dirname(rolloutPath), { recursive: true }) + writeFileSync(rolloutPath, '{"type":"session_meta"}\n') + const transcriptPath = toNamespacedPath(rolloutPath) + const resolveVerifiedResumeHome = vi.fn(async () => homePath) + + await expect( + prepareCodexSessionResume({ + sessionId: '019f81b9-19a9-7651-a8d1-352d9420bd11', + transcriptPath, + trustedCodexHomes: [homePath], + getSelectedAccountCodexHome: () => null, + systemCodexHomePath: homePath, + sharedRuntimeCodexHomePath: null, + resolveVerifiedResumeHome + }) + ).resolves.toEqual({ outcome: 'resume', codexHomePath: homePath }) + expect(resolveVerifiedResumeHome).toHaveBeenCalledWith({ homePath, transcriptPath }) + }) +}) diff --git a/src/main/powershell-osc133-bootstrap-windows-clm.test.ts b/src/main/powershell-osc133-bootstrap-windows-clm.test.ts new file mode 100644 index 00000000000..bec10040e59 --- /dev/null +++ b/src/main/powershell-osc133-bootstrap-windows-clm.test.ts @@ -0,0 +1,100 @@ +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 { encodePowerShellCommand } from './powershell-osc133-bootstrap' +import { resolveWindowsShellLaunchArgs } from './providers/windows-shell-args' + +const WINDOWS_POWERSHELLS = ['powershell.exe', 'pwsh.exe'] as const +const PROFILE_CODEX_HOME = 'C:\\Profile Custom\\codex' +const MANAGED_CODEX_HOME = 'C:\\Orca Managed\\codex-runtime-home' + +for (const shell of WINDOWS_POWERSHELLS) { + describe.runIf(isAvailable(shell))(`${shell} managed home bootstrap`, () => { + it.each(['FullLanguage', 'ConstrainedLanguage'] as const)( + 'restores CODEX_HOME and continues startup in %s mode', + (languageMode) => { + const cwd = mkdtempSync(join(tmpdir(), 'orca-powershell-clm-')) + try { + expect(runBootstrap(shell, languageMode, cwd)).toContain( + `mode=${languageMode};codexHome=${MANAGED_CODEX_HOME};orcaHome=${MANAGED_CODEX_HOME};startupCount=2;cwd=${cwd}` + ) + } finally { + rmSync(cwd, { recursive: true, force: true }) + } + } + ) + }) +} + +function runBootstrap( + shell: (typeof WINDOWS_POWERSHELLS)[number], + languageMode: 'FullLanguage' | 'ConstrainedLanguage', + cwd: string +): string { + const launch = resolveWindowsShellLaunchArgs( + shell, + cwd, + process.env.USERPROFILE ?? cwd, + undefined, + '$env:ORCA_TEST_STARTUP_COUNT = 1 + [int]$env:ORCA_TEST_STARTUP_COUNT' + ) + expect(launch.startupCommandDeliveredInShellArgs).toBe(true) + const encodedCommandIndex = launch.shellArgs.indexOf('-EncodedCommand') + expect(encodedCommandIndex).toBeGreaterThanOrEqual(0) + const encodedCommand = launch.shellArgs[encodedCommandIndex + 1] + expect(encodedCommand).toBeTruthy() + + return execFileSync( + shell, + ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', harness], + { + encoding: 'utf8', + env: { + ...process.env, + CODEX_HOME: PROFILE_CODEX_HOME, + ORCA_CODEX_HOME: MANAGED_CODEX_HOME, + ORCA_TEST_BOOTSTRAP: encodedCommand, + ORCA_TEST_LANGUAGE_MODE: languageMode + }, + windowsHide: true + } + ) +} + +function isAvailable(shell: (typeof WINDOWS_POWERSHELLS)[number]): boolean { + if (process.platform !== 'win32') { + return false + } + try { + execFileSync(shell, ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$null'], { + stdio: 'ignore', + windowsHide: true + }) + return true + } catch { + return false + } +} + +const harness = encodePowerShellCommand(` +$initialState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault() +$initialState.LanguageMode = $env:ORCA_TEST_LANGUAGE_MODE +$runspace = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace($initialState) +$runspace.Open() +$runner = [System.Management.Automation.PowerShell]::Create() +$runner.Runspace = $runspace +$bootstrap = [Text.Encoding]::Unicode.GetString( + [Convert]::FromBase64String($env:ORCA_TEST_BOOTSTRAP) +) +$null = $runner.AddScript($bootstrap).Invoke() +$runner.Commands.Clear() +$null = $runner.AddScript($bootstrap).Invoke() +$runner.Commands.Clear() +$runner.AddScript( + '"mode=$($ExecutionContext.SessionState.LanguageMode);codexHome=$env:CODEX_HOME;orcaHome=$env:ORCA_CODEX_HOME;startupCount=$env:ORCA_TEST_STARTUP_COUNT;cwd=$($PWD.Path)"' +).Invoke() +$runner.Dispose() +$runspace.Dispose() +`) diff --git a/src/main/powershell-osc133-bootstrap.test.ts b/src/main/powershell-osc133-bootstrap.test.ts index eedfb6159d7..8934ccbe928 100644 --- a/src/main/powershell-osc133-bootstrap.test.ts +++ b/src/main/powershell-osc133-bootstrap.test.ts @@ -29,6 +29,11 @@ describe('PowerShell OSC 133 bootstrap', () => { expect(script).not.toContain('$PROFILE') expect(script).not.toContain('ExecutionPolicy') expect(script).not.toContain('NoProfile') + + const codexHomeRestore = script.indexOf('if ($env:ORCA_CODEX_HOME)') + expect(codexHomeRestore).toBeGreaterThan(-1) + expect(codexHomeRestore).toBeLessThan(script.indexOf('Test-Path variable:global:')) + expect(codexHomeRestore).toBeLessThan(script.indexOf('LanguageMode -eq "FullLanguage"')) }) it('encodes commands as UTF-16LE base64 for PowerShell -EncodedCommand', () => { diff --git a/src/main/powershell-osc133-bootstrap.ts b/src/main/powershell-osc133-bootstrap.ts index bad6cbba6ea..523068f8239 100644 --- a/src/main/powershell-osc133-bootstrap.ts +++ b/src/main/powershell-osc133-bootstrap.ts @@ -2,68 +2,64 @@ import { getPowerShellOmpShellWrapper } from './pty/omp-shell-wrapper' export { encodePowerShellCommand } from '../shared/powershell-command-encoding' const POWERSHELL_OSC133_BOOTSTRAP = `# Orca OSC 133 shell integration for PowerShell. -if ((Test-Path variable:global:__OrcaOsc133State) -and - $null -ne $Global:__OrcaOsc133State.OriginalPrompt) { - return -} - -if ($ExecutionContext.SessionState.LanguageMode -ne "FullLanguage") { - return -} - # Profiles have already loaded normally by the time -EncodedCommand runs. -# Wrap the user's final prompt/readline state; do not source profiles here. - -# Preserve Windows CJK output by keeping ConPTY on UTF-8 without bypassing -# profile loading or execution-policy checks. -try { - [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new() - [Console]::InputEncoding = [System.Text.UTF8Encoding]::new() - $OutputEncoding = [Console]::OutputEncoding -} catch { Write-Error $_ -ErrorAction Continue } - -# Profiles can re-export user defaults after Orca's spawn env is set. +# Restore managed ownership before the shell-integration compatibility guard. if ($env:ORCA_OPENCODE_CONFIG_DIR) { $env:OPENCODE_CONFIG_DIR = $env:ORCA_OPENCODE_CONFIG_DIR } if ($env:ORCA_MIMOCODE_HOME) { $env:MIMOCODE_HOME = $env:ORCA_MIMOCODE_HOME } -${getPowerShellOmpShellWrapper()} if ($env:ORCA_CODEX_HOME) { $env:CODEX_HOME = $env:ORCA_CODEX_HOME } -$Global:__OrcaOsc133State = @{ - OriginalPrompt = $function:prompt - OriginalReadLine = $function:PSConsoleHostReadLine - HasSeenPrompt = $false - HasPSReadLine = $null -ne (Get-Module -Name PSReadLine) - Esc = [char]27 - Bel = [char]7 -} +if ($ExecutionContext.SessionState.LanguageMode -eq "FullLanguage" -and + ((-not (Test-Path variable:global:__OrcaOsc133State)) -or + $null -eq $Global:__OrcaOsc133State.OriginalPrompt)) { + # Wrap the user's final prompt/readline state; do not source profiles here. -function Global:prompt { - # Capture FIRST; any other expression can clobber PowerShell's success bit. - $fakeExitCode = [int](!$global:?) - Set-StrictMode -Off - $result = "" + # Preserve Windows CJK output by keeping ConPTY on UTF-8 without bypassing + # profile loading or execution-policy checks. + try { + [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new() + [Console]::InputEncoding = [System.Text.UTF8Encoding]::new() + $OutputEncoding = [Console]::OutputEncoding + } catch { Write-Error $_ -ErrorAction Continue } - # Emit D from prompt, not readline state. Some profile setups bypass - # PSConsoleHostReadLine; the consumer only needs completion. - if ($Global:__OrcaOsc133State.HasSeenPrompt) { - $result += "$($Global:__OrcaOsc133State.Esc)]133;D;$fakeExitCode$($Global:__OrcaOsc133State.Bel)" +${getPowerShellOmpShellWrapper()} + + $Global:__OrcaOsc133State = @{ + OriginalPrompt = $function:prompt + OriginalReadLine = $function:PSConsoleHostReadLine + HasSeenPrompt = $false + HasPSReadLine = $null -ne (Get-Module -Name PSReadLine) + Esc = [char]27 + Bel = [char]7 } - $Global:__OrcaOsc133State.HasSeenPrompt = $true - $result += "$($Global:__OrcaOsc133State.Esc)]133;A$($Global:__OrcaOsc133State.Bel)" - # Preserve the previous success/failure value for prompts that inspect it. - if ($fakeExitCode -ne 0) { Write-Error "failure" -ea ignore } - $result += $Global:__OrcaOsc133State.OriginalPrompt.Invoke() - $result += "$($Global:__OrcaOsc133State.Esc)]133;B$($Global:__OrcaOsc133State.Bel)" - $result -} + function Global:prompt { + # Capture FIRST; any other expression can clobber PowerShell's success bit. + $fakeExitCode = [int](!$global:?) + Set-StrictMode -Off + $result = "" -if ($Global:__OrcaOsc133State.HasPSReadLine -and - $null -ne $Global:__OrcaOsc133State.OriginalReadLine) { - function Global:PSConsoleHostReadLine { - $commandLine = $Global:__OrcaOsc133State.OriginalReadLine.Invoke() - [Console]::Write("$($Global:__OrcaOsc133State.Esc)]133;C$($Global:__OrcaOsc133State.Bel)") - return $commandLine + # Emit D from prompt, not readline state. Some profile setups bypass + # PSConsoleHostReadLine; the consumer only needs completion. + if ($Global:__OrcaOsc133State.HasSeenPrompt) { + $result += "$($Global:__OrcaOsc133State.Esc)]133;D;$fakeExitCode$($Global:__OrcaOsc133State.Bel)" + } + $Global:__OrcaOsc133State.HasSeenPrompt = $true + + $result += "$($Global:__OrcaOsc133State.Esc)]133;A$($Global:__OrcaOsc133State.Bel)" + # Preserve the previous success/failure value for prompts that inspect it. + if ($fakeExitCode -ne 0) { Write-Error "failure" -ea ignore } + $result += $Global:__OrcaOsc133State.OriginalPrompt.Invoke() + $result += "$($Global:__OrcaOsc133State.Esc)]133;B$($Global:__OrcaOsc133State.Bel)" + $result + } + + if ($Global:__OrcaOsc133State.HasPSReadLine -and + $null -ne $Global:__OrcaOsc133State.OriginalReadLine) { + function Global:PSConsoleHostReadLine { + $commandLine = $Global:__OrcaOsc133State.OriginalReadLine.Invoke() + [Console]::Write("$($Global:__OrcaOsc133State.Esc)]133;C$($Global:__OrcaOsc133State.Bel)") + return $commandLine + } } } ` diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index 3cf94c38777..39cb81ee9b5 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -102,6 +102,9 @@ describe('resolveWindowsShellLaunchArgs', () => { const opencodeRestoreIndex = command.indexOf( '$env:OPENCODE_CONFIG_DIR = $env:ORCA_OPENCODE_CONFIG_DIR' ) + const mimocodeRestoreIndex = command.indexOf('$env:MIMOCODE_HOME = $env:ORCA_MIMOCODE_HOME') + const duplicateStateGuardIndex = command.indexOf('Test-Path variable:global:__OrcaOsc133State') + const languageModeGuardIndex = command.indexOf('LanguageMode -eq "FullLanguage"') const ompWrapperIndex = command.indexOf('function Global:omp') const ompExtensionIndex = command.indexOf('--extension $env:ORCA_OMP_STATUS_EXTENSION') const codexRestoreIndex = command.indexOf('$env:CODEX_HOME = $env:ORCA_CODEX_HOME') @@ -114,13 +117,16 @@ describe('resolveWindowsShellLaunchArgs', () => { expect(command).not.toContain('ORCA_PI_CODING_AGENT_DIR') expect(command).not.toContain('ORCA_OMP_CODING_AGENT_DIR') expect(command).not.toContain('$env:PI_CODING_AGENT_DIR = $env:ORCA_OMP_SOURCE_AGENT_DIR') - expect(outputEncodingIndex).toBeGreaterThanOrEqual(0) - expect(opencodeRestoreIndex).toBeGreaterThan(outputEncodingIndex) - expect(ompWrapperIndex).toBeGreaterThan(opencodeRestoreIndex) + for (const restoreIndex of [opencodeRestoreIndex, mimocodeRestoreIndex, codexRestoreIndex]) { + expect(restoreIndex).toBeGreaterThanOrEqual(0) + expect(restoreIndex).toBeLessThan(duplicateStateGuardIndex) + expect(restoreIndex).toBeLessThan(languageModeGuardIndex) + } + expect(outputEncodingIndex).toBeGreaterThan(languageModeGuardIndex) + expect(outputEncodingIndex).toBeGreaterThan(duplicateStateGuardIndex) + expect(ompWrapperIndex).toBeGreaterThan(outputEncodingIndex) expect(ompExtensionIndex).toBeGreaterThan(ompWrapperIndex) - expect(codexRestoreIndex).toBeGreaterThan(outputEncodingIndex) - expect(codexRestoreIndex).toBeGreaterThan(ompWrapperIndex) - expect(promptIndex).toBeGreaterThan(codexRestoreIndex) + expect(promptIndex).toBeGreaterThan(ompWrapperIndex) expect(cwdRestoreIndex).toBeGreaterThan(promptIndex) expect(command).toContain('Esc = [char]27') expect(command).toContain('Bel = [char]7') diff --git a/src/main/pty/shell-startup-env.test.ts b/src/main/pty/shell-startup-env.test.ts index d41e6c7fdcb..73aabc3b753 100644 --- a/src/main/pty/shell-startup-env.test.ts +++ b/src/main/pty/shell-startup-env.test.ts @@ -10,7 +10,11 @@ vi.mock('fs', () => ({ readFileSync: readFileSyncMock })) -import { __resetShellStartupEnvCache, readShellStartupEnvVar } from './shell-startup-env' +import { + __resetShellStartupEnvCache, + isShellStartupEnvProbeSupported, + readShellStartupEnvVar +} from './shell-startup-env' describe('readShellStartupEnvVar', () => { const originalPlatform = process.platform @@ -82,9 +86,24 @@ describe('readShellStartupEnvVar', () => { it('returns undefined on Windows', () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR=/win\n' }) + expect(isShellStartupEnvProbeSupported()).toBe(false) expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBeUndefined() }) + it('reports startup-env probing as supported on macOS and Linux', () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + try { + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + expect(isShellStartupEnvProbeSupported()).toBe(true) + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + expect(isShellStartupEnvProbeSupported()).toBe(true) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + it('returns undefined when no startup file matches', () => { mockStartupFiles({ '.zshrc': 'export FOO=bar\n' }) expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBeUndefined() diff --git a/src/main/pty/shell-startup-env.ts b/src/main/pty/shell-startup-env.ts index 83a4cad1185..f423ef38f22 100644 --- a/src/main/pty/shell-startup-env.ts +++ b/src/main/pty/shell-startup-env.ts @@ -12,6 +12,10 @@ const ZSH_AFTER_ENV_FILES = ['.zprofile', '.zshrc', '.zlogin'] // .bashrc. Scanning .bashrc would mirror values the live Orca bash never sees. const BASH_LOGIN_FILES = ['.bash_profile', '.bash_login', '.profile'] +export function isShellStartupEnvProbeSupported(): boolean { + return process.platform !== 'win32' +} + function parseExportedValue(content: string, name: string, home: string): string | undefined { const assignment = new RegExp(`^export\\s+${name}=(.+)$`) let lastMatch: string | undefined @@ -151,7 +155,7 @@ export function readShellStartupEnvVar( home = process.env.HOME, shell = process.env.SHELL ): string | undefined { - if (!home || process.platform === 'win32') { + if (!home || !isShellStartupEnvProbeSupported()) { return undefined } // Why: the regex above is fixed; rejecting unsafe names is cheap defense diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 261916779d2..2e0ce2b2184 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -9609,6 +9609,8 @@ describe('connectPanePty', () => { }) transportFactoryQueue.push(transport) const paneKey = makePaneKey('tab-1', LEAF_1) + const transcriptPath = + '\\\\?\\C:\\Users\\Example\\.codex\\sessions\\2026\\07\\20\\rollout-codex-session-1.jsonl' // Why: after restart agentStatusByPaneKey is empty — the persisted sleeping record is the only provider session id source (#5232). mockStoreState = { ...mockStoreState, @@ -9626,7 +9628,11 @@ describe('connectPanePty', () => { tabId: 'tab-1', worktreeId: 'wt-1', agent: 'codex', - providerSession: { key: 'session_id', id: 'codex-session-1' }, + providerSession: { + key: 'session_id', + id: 'codex-session-1', + transcriptPath + }, prompt: 'finish the task', state: 'working', capturedAt: 1, @@ -9660,6 +9666,11 @@ describe('connectPanePty', () => { expect.objectContaining({ sessionId: 'lost-pty', command: "codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'", + resumeProviderSession: { + key: 'session_id', + id: 'codex-session-1', + transcriptPath + }, env: expect.objectContaining({ ORCA_PANE_KEY: paneKey, ORCA_TAB_ID: 'tab-1',