mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 08:03:28 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/fix-10385-false-connected
This commit is contained in:
@@ -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 `<source>`
|
||||
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.
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof ShellStartupEnv>('../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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<string> {
|
||||
yield extendedEntry
|
||||
}
|
||||
})
|
||||
).resolves.toEqual({ homePath, transcriptPath: extendedEntry })
|
||||
|
||||
await expect(
|
||||
findTrustedCodexSessionResume({
|
||||
sessionId,
|
||||
transcriptPath: undefined,
|
||||
trustedCodexHomes: [homePath],
|
||||
...withoutHomeRanking,
|
||||
fileIsRegular: () => true,
|
||||
listSessionFiles: async function* (): AsyncIterable<string> {
|
||||
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<string> => {
|
||||
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<string> => {
|
||||
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(
|
||||
|
||||
@@ -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, '/')))
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
`)
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user