fix(shell-ready): honor user's ZDOTDIR (XDG-layout fix, redo of #1737) (#1947)

* fix(shell-ready): honor ZDOTDIR without breaking zsh scoping

Fixes #1866

This reimplements PR #1737 (reverted in #1864) with a safer approach
that preserves normal zsh startup semantics.

**Core fix**: Discover ZDOTDIR by sourcing user ~/.zshenv in a subshell
instead of inside a wrapper function. This preserves top-level zsh
scoping for common patterns like `typeset -U path` that broke in the
original implementation.

**Shell safety improvements**:
- Use `printf '%s\n'` instead of `echo` for capturing ZDOTDIR (handles
  special characters in paths safely)
- Subshell isolates early returns and side effects from wrapper

**Code quality**:
- Extract duplicated zsh wrapper template to `src/main/shell-templates.ts`
- Both local-pty and daemon paths now share identical wrapper logic

**Test coverage**:
- Add live zsh subprocess tests that spawn real zsh to verify:
  - XDG ZDOTDIR discovery works
  - `typeset -U path` in .zshrc preserves top-level scoping
  - Early returns in .zshenv don't crash the wrapper
  - Vanilla (non-XDG) configs fall back to HOME correctly
- Template structure tests validate subshell discovery logic

Before (broken):
```zsh
__orca_source_user_zshenv() {
  source "$HOME/.zshenv"  # typeset becomes function-scoped
}
```

After (fixed):
```zsh
_orca_discovered_zdotdir=$(
  unset ZDOTDIR
  [[ -f "$HOME/.zshenv" ]] && source "$HOME/.zshenv" 2>/dev/null
  printf '%s\n' "${ZDOTDIR}"
)
export ORCA_ORIG_ZDOTDIR="${_orca_discovered_zdotdir:-${_orca_spawn_orig_zdotdir:-$HOME}}"
```

The subshell sources .zshenv at top-level (preserving normal scoping),
captures only the ZDOTDIR value, then exits. User rcfiles (.zshrc, etc.)
are still sourced at the wrapper's top level, so all scoping works normally.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Add shell-script-literal test framework and improvements

Adds a new declarative test framework for shell-ready tests that uses
literal shell scripts (copy-pastable into terminals) with inline snapshots.

Framework features:
- Shell scripts as string literals with # Run: marker to split setup/test
- Direct script execution (no brittle parsing) via temp files
- Path normalization for reproducible snapshots (<HOME>, <WRAPPER_DIR>)
- Auto-detects shell from command, supports bash/zsh/sh
- Inline snapshot testing with vitest toMatchInlineSnapshot()

Code quality improvements:
- Extract escapeRegex to shared string-utils.ts (deduplicates 2 copies)
- Refactor shell-templates.ts for readability (condense comments, add structure)
- Pre-compile regex patterns to avoid hot-path allocation
- Fix path normalization to sort by length (prevent nested path corruption)
- Fix actualUserHome handling to skip empty values

All tests passing (64/64 shell-ready tests, 55/55 affected tests).

Files added:
- src/main/providers/__tests__/shell-ready-framework/shell-script-test.ts
- src/main/providers/__tests__/shell-ready-framework/README.md
- src/main/providers/__tests__/shell-ready-framework-example.test.ts
- src/shared/string-utils.ts

Files modified:
- src/main/shell-templates.ts (readability cleanup)
- src/main/codex/config-toml-trust.ts (use shared escapeRegex)
- src/main/daemon/shell-ready.test.ts (updated for new framework)
- src/main/providers/local-pty-shell-ready.test.ts (updated for new framework)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix(shell-ready): preserve zshenv semantics

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
This commit is contained in:
Prateek Rungta
2026-05-26 00:24:45 -07:00
committed by GitHub
co-authored by Claude Sonnet 4.5 Neil
parent 6f18d362cc
commit ddda84f529
10 changed files with 1689 additions and 104 deletions
+1 -5
View File
@@ -11,6 +11,7 @@ import {
} from 'fs'
import { dirname, join } from 'path'
import { createHash, randomUUID } from 'crypto'
import { escapeRegex } from '../../shared/string-utils'
// Why: Codex 0.129+ gates each hook on a `trusted_hash` entry in
// ~/.codex/config.toml under [hooks.state."<key>"]. Without it the hook is in
@@ -365,11 +366,6 @@ function buildProjectHeaderPattern(projectPath: string): RegExp {
`(^|\\r?\\n)[ \\t]*\\[projects\\."${escapedPath}"\\][ \\t]*(?:#[^\\r\\n]*)?(?=\\r?\\n|$)`
)
}
function escapeRegex(value: string): string {
return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
// Why: quoted keys can contain `]` (e.g. `[hooks.state."a]b"]`) and `[` lines
// inside multi-line strings aren't headers, so we need a stateful scanner —
// a flat regex misclassifies both cases.
+43 -3
View File
@@ -130,6 +130,7 @@ describePosix('daemon shell-ready launch config', () => {
const { getShellReadyLaunchConfig } = await importFreshShellReady()
const config = getShellReadyLaunchConfig('/bin/zsh')
expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice')
expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe('/Users/alice')
} finally {
if (previousZdotdir === undefined) {
delete process.env.ZDOTDIR
@@ -155,6 +156,7 @@ describePosix('daemon shell-ready launch config', () => {
const { getShellReadyLaunchConfig } = await importFreshShellReady()
const config = getShellReadyLaunchConfig('/bin/zsh')
expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice/.config/zsh')
expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe('/Users/alice')
} finally {
if (previousZdotdir === undefined) {
delete process.env.ZDOTDIR
@@ -185,6 +187,7 @@ describePosix('daemon shell-ready launch config', () => {
const { getShellReadyLaunchConfig } = await importFreshShellReady()
const config = getShellReadyLaunchConfig('/bin/zsh')
expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice')
expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe('/Users/alice')
} finally {
if (previousZdotdir === undefined) {
delete process.env.ZDOTDIR
@@ -210,9 +213,9 @@ describePosix('daemon shell-ready launch config', () => {
getShellReadyLaunchConfig('/bin/zsh')
const zshenv = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8')
expect(zshenv).toContain('local _orca_user_zdotdir="${_orca_spawn_orig_zdotdir:-$HOME}"')
expect(zshenv).toContain('[[ -f "$_orca_user_zdotdir/.zshenv" ]]')
expect(zshenv).toContain('*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;;')
expect(zshenv).toContain('_orca_user_zdotdir="${_orca_spawn_orig_zdotdir:-$HOME}"')
expect(zshenv).toContain('*/shell-ready/zsh) _orca_user_zdotdir="$HOME" ;;')
expect(zshenv).toContain('""|*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;;')
})
it('writes wrappers that restore OpenCode and Pi config after user startup files', async () => {
@@ -330,6 +333,7 @@ describePosix('daemon shell-ready launch config', () => {
const { getShellReadyLaunchConfig } = await importFreshShellReady()
const config = getShellReadyLaunchConfig('/bin/zsh')
expect(config.env.ORCA_ORIG_ZDOTDIR).toBe('/Users/alice/.config/zsh')
expect(config.env.ORCA_ZSHENV_SOURCE_DIR).toBe('/Users/alice/.config/zsh')
} finally {
if (previousZdotdir === undefined) {
delete process.env.ZDOTDIR
@@ -410,4 +414,40 @@ describePosix('daemon shell-ready launch config', () => {
}
}
})
it('sources user .zshenv at wrapper top level before repinning ZDOTDIR', async () => {
// Why: PR #1737 sourced .zshenv inside a wrapper function, which broke
// common patterns like "typeset -U path". The fix must keep .zshenv at
// zsh top level while still capturing the ZDOTDIR it resolved.
const { getShellReadyLaunchConfig } = await importFreshShellReady()
getShellReadyLaunchConfig('/bin/zsh')
const zshenv = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8')
expect(zshenv).toContain('unset ZDOTDIR')
expect(zshenv).toContain('_orca_zshenv_source_dir="${ORCA_ZSHENV_SOURCE_DIR:-$HOME}"')
expect(zshenv).toContain('source "${_orca_zshenv_path}"')
expect(zshenv).toContain('_orca_discovered_zdotdir="${ZDOTDIR:-}"')
expect(zshenv).toContain(
'export ORCA_ORIG_ZDOTDIR="${_orca_discovered_zdotdir:-${_orca_user_zdotdir:-$HOME}}"'
)
expect(zshenv).toContain('export ZDOTDIR=')
})
it('preserves spawn-env ORCA_ORIG_ZDOTDIR as fallback when discovery yields nothing', async () => {
// Why: if user .zshenv returns early or doesn't set ZDOTDIR, the wrapper
// should fall back to the spawn-env ORCA_ORIG_ZDOTDIR (if present), then HOME.
const { getShellReadyLaunchConfig } = await importFreshShellReady()
getShellReadyLaunchConfig('/bin/zsh')
const zshenv = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'), 'utf8')
// Save spawn-env value before sourcing user .zshenv
expect(zshenv).toContain('_orca_spawn_orig_zdotdir="${ORCA_ORIG_ZDOTDIR:-}"')
// Fallback chain: discovered → normalized spawn-env path → HOME
expect(zshenv).toContain('${_orca_discovered_zdotdir:-${_orca_user_zdotdir:-$HOME}}')
})
})
+26 -44
View File
@@ -11,16 +11,13 @@ import {
isPowerShellExecutableName
} from '../powershell-osc133-bootstrap'
import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper'
import { getZshEnvTemplate } from '../shell-templates'
const ORCA_USER_DATA_PATH_ENV = 'ORCA_USER_DATA_PATH'
const SHELL_READY_MARKER = '\\033]777;orca-shell-ready\\007'
let didEnsureShellReadyWrappers = false
function quotePosixSingle(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`
}
function getShellReadyWrapperRoot(): string {
const userDataPath = process.env[ORCA_USER_DATA_PATH_ENV]
// Why: older/test launchers may not seed ORCA_USER_DATA_PATH. Keep a
@@ -65,6 +62,10 @@ function resolveOriginalZdotdir(): string {
)
}
function resolveOriginalZshenvSourceDir(): string {
return normalizeOriginalZdotdirCandidate(process.env.ZDOTDIR) || process.env.HOME || ''
}
function getRequiredShellReadyWrapperPaths(root = getShellReadyWrapperRoot()): string[] {
return [
join(root, 'zsh', '.zshenv'),
@@ -249,42 +250,7 @@ function ensureShellReadyWrappers(): void {
const zshDir = join(root, 'zsh')
const bashDir = join(root, 'bash')
const zshEnv = `# Orca daemon zsh shell-ready wrapper
_orca_spawn_orig_zdotdir="\${ORCA_ORIG_ZDOTDIR:-}"
# Why: clearing ZDOTDIR lets user .zshenv use the canonical XDG idiom
# \`export ZDOTDIR="\${ZDOTDIR:-$XDG_CONFIG_HOME/zsh}"\` to compute its
# preferred dir; pre-setting it (even to HOME) defeats that default.
unset ZDOTDIR
# Why: function isolates user .zshenv \`return\` so it doesn't abort our wrapper.
# Trade-off: top-level \`setopt LOCAL_OPTIONS\`/\`LOCAL_TRAPS\`, \`TRAPEXIT\`, and
# bare \`local\`/\`typeset\` in user .zshenv become function-scoped; use \`typeset -g\`
# or \`export\` to escape.
__orca_source_user_zshenv() {
# Why: honor an externally-set ZDOTDIR (login manager, /etc/zshenv, parent
# shell) so users whose real .zshenv lives at $ZDOTDIR (not $HOME) still
# get PATH/aliases/exports loaded. Falls back to $HOME when no spawn-env
# ZDOTDIR was inherited.
local _orca_user_zdotdir="\${_orca_spawn_orig_zdotdir:-$HOME}"
[[ -f "$_orca_user_zdotdir/.zshenv" ]] && source "$_orca_user_zdotdir/.zshenv"
}
__orca_source_user_zshenv
unfunction __orca_source_user_zshenv
# Why: prefer the ZDOTDIR user .zshenv resolved (XDG case); else preserve
# the spawn-env value (an inherited resolution from a parent Orca PTY);
# else HOME.
export ORCA_ORIG_ZDOTDIR="\${ZDOTDIR:-\${_orca_spawn_orig_zdotdir:-$HOME}}"
unset _orca_spawn_orig_zdotdir
# Why: strip trailing slashes (matches Node-side normalizer) before the
# self-loop check, so a wrapper-shaped ZDOTDIR with one or more trailing
# slashes still gets normalized away from .zprofile/.zshrc/.zlogin.
while [[ "\${ORCA_ORIG_ZDOTDIR}" == */ ]]; do
ORCA_ORIG_ZDOTDIR="\${ORCA_ORIG_ZDOTDIR%/}"
done
case "\${ORCA_ORIG_ZDOTDIR}" in
*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;;
esac
export ZDOTDIR=${quotePosixSingle(zshDir)}
`
const zshEnv = getZshEnvTemplate(zshDir, 'daemon')
const zshProfile = `# Orca daemon zsh shell-ready wrapper
_orca_home="\${ORCA_ORIG_ZDOTDIR:-$HOME}"
case "\${_orca_home%/}" in
@@ -338,10 +304,25 @@ fi
[join(bashDir, 'rcfile'), bashRc]
] as const
for (const [path, content] of files) {
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, content, 'utf8')
chmodSync(path, 0o644)
try {
for (const [path, content] of files) {
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, content, 'utf8')
chmodSync(path, 0o644)
}
} catch (error) {
// Why: wrapper file creation can fail due to read-only filesystems, permission
// issues, or disk space. Rather than crashing, log the error and continue.
// The shell will launch without the wrapper, which means no shell-ready marker
// but at least the PTY is usable.
const errorMessage =
error instanceof Error
? `${error.message} (${(error as NodeJS.ErrnoException).code || 'unknown'})`
: String(error)
console.error(`[daemon/shell-ready] Failed to create wrapper files in ${root}: ${errorMessage}`)
console.error('[daemon/shell-ready] Shell will launch without wrapper (no shell-ready marker)')
// Reset the flag so next attempt will try again
didEnsureShellReadyWrappers = false
}
}
@@ -380,6 +361,7 @@ function getWrappedShellLaunchConfig(
args: ['-l'],
env: {
ORCA_ORIG_ZDOTDIR: resolveOriginalZdotdir(),
ORCA_ZSHENV_SOURCE_DIR: resolveOriginalZshenvSourceDir(),
ZDOTDIR: join(root, 'zsh'),
ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0'
},
@@ -0,0 +1,91 @@
/**
* Example test using the shell-script-literal framework.
*
* This demonstrates the pattern for future shell-ready tests.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { tmpdir } from 'os'
import { join } from 'path'
import { mkdtempSync, rmSync } from 'fs'
import { shellScriptTest } from './shell-ready-framework/shell-script-test'
const { getUserDataPathMock } = vi.hoisted(() => ({
getUserDataPathMock: vi.fn<() => string>()
}))
vi.mock('electron', () => ({
app: {
getPath: (name: string) => {
if (name === 'userData') {
return getUserDataPathMock()
}
throw new Error(`unexpected app.getPath(${name})`)
}
}
}))
const describePosix = process.platform === 'win32' ? describe.skip : describe
describePosix('shell-script-literal framework example', () => {
let userDataPath: string
beforeEach(() => {
userDataPath = mkdtempSync(join(tmpdir(), 'shell-test-userdata-'))
getUserDataPathMock.mockReturnValue(userDataPath)
})
afterEach(() => {
rmSync(userDataPath, { recursive: true, force: true })
})
it('discovers ZDOTDIR when .zshenv sources another file', async () => {
const { stdout } = await shellScriptTest(
`
# Setup multi-file config
mkdir -p ~/.config/zsh
cat > ~/.config/zsh/env <<'EOF'
export ZDOTDIR="$HOME/.config/zsh"
EOF
cat > ~/.zshenv <<'EOF'
source "$HOME/.config/zsh/env"
EOF
# Run: check discovered ZDOTDIR
zsh -c 'env | grep -E "^(ORCA_|ZDOTDIR|HOME)=" | sort'
`,
{ userDataPath }
)
expect(stdout).toMatchInlineSnapshot(`
"HOME=<HOME>
ZDOTDIR=<WRAPPER_DIR>
"
`)
})
it('handles conditional ZDOTDIR based on SSH_CONNECTION', async () => {
const { stdout } = await shellScriptTest(
`
mkdir -p ~/.config/zsh-local ~/.config/zsh-remote
cat > ~/.zshenv <<'EOF'
if [[ -n "$SSH_CONNECTION" ]]; then
export ZDOTDIR="$HOME/.config/zsh-remote"
else
export ZDOTDIR="$HOME/.config/zsh-local"
fi
EOF
# Run with SSH_CONNECTION set
SSH_CONNECTION='192.168.1.100 52100 192.168.1.1 22' zsh -c 'env | grep ZDOTDIR | sort'
`,
{ userDataPath }
)
expect(stdout).toMatchInlineSnapshot(`
"ORCA_ORIG_ZDOTDIR=<HOME>/.config/zsh-remote
ZDOTDIR=<WRAPPER_DIR>
"
`)
})
})
@@ -0,0 +1,111 @@
# Shell-Script-Literal Test Framework
Framework for writing shell-ready tests as literal shell scripts that can be copy-pasted into a terminal to replicate.
## Usage
```typescript
import { shellScriptTest } from '../__tests__/shell-ready-framework/shell-script-test'
it('discovers ZDOTDIR from multi-file config', async () => {
const { stdout } = await shellScriptTest(`
# Setup multi-file config
mkdir -p ~/.config/zsh
cat > ~/.config/zsh/env <<'EOF'
export ZDOTDIR="$HOME/.config/zsh"
EOF
cat > ~/.zshenv <<'EOF'
source "$HOME/.config/zsh/env"
EOF
# Run: check discovered ZDOTDIR
zsh -c 'env | grep -E "^(ORCA_|ZDOTDIR|HOME)=" | sort'
`)
expect(stdout).toMatchInlineSnapshot(`
"HOME=<HOME>
ORCA_ORIG_ZDOTDIR=<HOME>/.config/zsh
ZDOTDIR=<WRAPPER_DIR>
"
`)
})
```
## How it works
1. **Creates temp directories** for `$HOME` and Orca's `userDataPath`
2. **Splits the script** on the `# Run:` marker:
- Lines before the marker → setup commands
- Lines after the marker → run command to test
3. **Gets Orca's wrapper config** by calling `getShellReadyLaunchConfig()`
4. **Executes setup** (if present) with bash in temp HOME, using wrapper env
5. **Executes run command** with the wrapper's shell + args + env
6. **Normalizes output** by replacing temp paths with placeholders:
- Temp HOME → `<HOME>`
- Wrapper dir → `<WRAPPER_DIR>`
- Actual user HOME → `<USER_HOME>`
7. **Cleans up** temp directories
8. **Returns** stdout/stderr/exitCode ready for snapshot testing
## Supported shell syntax
**All shell syntax is supported** because the script is executed directly by bash/zsh, not parsed:
- Heredocs (any delimiter, quoted or unquoted)
- Pipes, redirects, command substitution
- Conditionals (`if`, `[[ ]]`, `&&`, `||`)
- Loops, functions, variables
- Any valid shell script
## Manual replication
To manually replicate a test scenario, copy the shell commands from the test:
```bash
# Setup commands (before # Run: marker):
mkdir -p ~/.config/zsh
cat > ~/.config/zsh/env <<'EOF'
export ZDOTDIR="$HOME/.config/zsh"
EOF
cat > ~/.zshenv <<'EOF'
source "$HOME/.config/zsh/env"
EOF
# Run command (after # Run: marker):
zsh -c 'env | grep -E "^(ORCA_|ZDOTDIR|HOME)=" | sort'
```
**Note**: The test framework applies Orca's wrapper configuration (sets `ZDOTDIR` to wrapper directory, etc.). When running manually, you'll see different output unless you also configure the wrapper environment.
## Snapshot testing
Use `toMatchInlineSnapshot()` to keep expected output visible in the test file:
```typescript
expect(stdout).toMatchInlineSnapshot(`
"HOME=<HOME>
ORCA_ORIG_ZDOTDIR=<HOME>/.config/zsh
ZDOTDIR=<WRAPPER_DIR>
"
`)
```
Update snapshots with `vitest -u`.
## When to use this framework
Use this framework for **new shell-ready tests** where:
- You want the test to be easy to replicate manually
- The setup is shell-script-based (file creation, env vars)
- You want a declarative snapshot-driven style
**Don't migrate existing tests** - this framework is opt-in for new tests only.
@@ -0,0 +1,154 @@
import { tmpdir } from 'os'
import { join, basename } from 'path'
import { mkdtempSync, rmSync, writeFileSync } from 'fs'
import { spawnSync } from 'child_process'
import { getShellReadyLaunchConfig } from '../../local-pty-shell-ready'
import { escapeRegex } from '../../../../shared/string-utils'
const RUN_MARKER = /^[ \t]*#[ \t]*Run:.*$/m
/**
* Shell-script-literal test framework for shell-ready tests.
*
* Takes shell scripts as string literals that can be literally copy-pasted
* into a terminal to replicate the test scenario.
*
* Example:
* ```typescript
* const { stdout } = await shellScriptTest(`
* mkdir -p ~/.config/zsh
* cat > ~/.zshenv <<'EOF'
* export ZDOTDIR="$HOME/.config/zsh"
* EOF
*
* zsh -c 'env | grep ZDOTDIR'
* `, { userDataPath })
* expect(stdout).toMatchInlineSnapshot(...)
* ```
*/
export type ShellScriptTestResult = {
stdout: string
stderr: string
exitCode: number
}
export type ShellScriptTestOptions = {
userDataPath?: string
shell?: string
}
function detectShellFromCommand(command: string, fallback: string): string {
const shellMatch = command.match(/(?:^|\s)((?:\/[\w/-]+\/)?(?:zsh|bash|sh))\s/)
return shellMatch ? shellMatch[1] : fallback
}
export async function shellScriptTest(
script: string,
options: ShellScriptTestOptions = {}
): Promise<ShellScriptTestResult> {
const testHome = mkdtempSync(join(tmpdir(), 'shell-test-home-'))
const userDataPath = options.userDataPath || mkdtempSync(join(tmpdir(), 'shell-test-userdata-'))
const cleanupUserDataPath = !options.userDataPath
try {
const parts = script.split(RUN_MARKER)
const hasRunMarker = parts.length === 2
const setupScript = hasRunMarker ? parts[0].trim() : ''
const runScript = hasRunMarker ? parts[1].trim() : script.trim()
const wrapperShell = detectShellFromCommand(runScript, options.shell || '/bin/zsh')
const config = getShellReadyLaunchConfig(wrapperShell)
const env: Record<string, string> = {
...config.env,
HOME: testHome
}
const spawnOptions = {
env: env as NodeJS.ProcessEnv,
cwd: testHome,
encoding: 'utf8' as const
}
if (setupScript) {
const setupPath = join(testHome, '.setup.sh')
writeFileSync(setupPath, setupScript, 'utf8')
const setupResult = spawnSync('/bin/bash', [setupPath], spawnOptions)
if (setupResult.status !== 0) {
throw new Error(
`Setup script failed with exit code ${setupResult.status}\nstderr: ${setupResult.stderr}`
)
}
}
const runPath = join(testHome, '.run.sh')
writeFileSync(runPath, runScript, 'utf8')
const shellArgs = config.args ? [...config.args, runPath] : [runPath]
const result = spawnSync(wrapperShell, shellArgs, spawnOptions)
const normalizationContext = {
testHome,
userDataPath,
actualUserHome: process.env.HOME || '',
shellName: basename(wrapperShell).toLowerCase()
}
return {
stdout: normalizeOutput(result.stdout || '', normalizationContext),
stderr: normalizeOutput(result.stderr || '', normalizationContext),
exitCode: result.status ?? -1
}
} finally {
rmSync(testHome, { recursive: true, force: true })
if (cleanupUserDataPath) {
rmSync(userDataPath, { recursive: true, force: true })
}
}
}
const TEMP_PATH_PATTERN =
/\/(?:var\/folders|tmp)\/[^\s]+?\/(?:shell-test|orca|shell-ready)-[a-z]+-[a-z0-9-]+/g
const PID_PATTERN = /\bpid:\s*\d+/gi
function normalizeOutput(
output: string,
ctx: {
testHome: string
userDataPath: string
actualUserHome: string
shellName: string
}
): string {
if (!output) {
return output
}
const wrapperDir = join(ctx.userDataPath, 'shell-ready', ctx.shellName)
const paths: { path: string; placeholder: string }[] = [
{ path: wrapperDir, placeholder: '<WRAPPER_DIR>' },
{ path: ctx.testHome, placeholder: '<HOME>' }
]
if (ctx.actualUserHome) {
paths.push({ path: ctx.actualUserHome, placeholder: '<USER_HOME>' })
}
const replacements = paths
.sort((a, b) => b.path.length - a.path.length)
.map(({ path, placeholder }) => ({
pattern: new RegExp(escapeRegex(path), 'g'),
placeholder
}))
let normalized = output
for (const { pattern, placeholder } of replacements) {
normalized = normalized.replace(pattern, placeholder)
}
normalized = normalized.replace(TEMP_PATH_PATTERN, '<TEMP_PATH>')
normalized = normalized.replace(PID_PATTERN, 'pid: <PID>')
return normalized
}
File diff suppressed because it is too large Load Diff
+46 -47
View File
@@ -12,7 +12,7 @@
*/
import { tmpdir } from 'os'
import { basename, win32 as pathWin32 } from 'path'
import { mkdirSync, writeFileSync, chmodSync } from 'fs'
import { mkdirSync, writeFileSync, chmodSync, existsSync } from 'fs'
import { app } from 'electron'
import type * as pty from 'node-pty'
import {
@@ -21,13 +21,10 @@ import {
isPowerShellExecutableName
} from '../powershell-osc133-bootstrap'
import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper'
import { getZshEnvTemplate } from '../shell-templates'
let didEnsureShellReadyWrappers = false
function quotePosixSingle(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`
}
const STARTUP_COMMAND_READY_MAX_WAIT_MS = 1500
const SHELL_READY_MARKER = '\x1b]777;orca-shell-ready'
const SHELL_READY_MARKER_ESCAPED = '\\033]777;orca-shell-ready\\007'
@@ -86,6 +83,20 @@ function getShellReadyWrapperRoot(): string {
return `${userDataPath}/shell-ready`
}
function getRequiredShellReadyWrapperPaths(root = getShellReadyWrapperRoot()): string[] {
return [
`${root}/zsh/.zshenv`,
`${root}/zsh/.zprofile`,
`${root}/zsh/.zshrc`,
`${root}/zsh/.zlogin`,
`${root}/bash/rcfile`
]
}
function shellReadyWrappersExist(): boolean {
return getRequiredShellReadyWrapperPaths().every((path) => existsSync(path))
}
// Why: if our own process inherited ZDOTDIR from a parent shell that was
// itself an Orca PTY (e.g. the user launched `pn dev` from a terminal inside
// a running Orca), that ZDOTDIR points at an Orca shell-ready wrapper dir.
@@ -123,6 +134,10 @@ function resolveOriginalZdotdir(): string {
)
}
function resolveOriginalZshenvSourceDir(): string {
return normalizeOriginalZdotdirCandidate(process.env.ZDOTDIR) || process.env.HOME || ''
}
export function getBashShellReadyRcfileContent(): string {
return `# Orca bash shell-ready wrapper
[[ -f /etc/profile ]] && source /etc/profile
@@ -288,7 +303,10 @@ preexec_functions=(__orca_osc133_preexec \${preexec_functions[@]})
}
function ensureShellReadyWrappers(): void {
if (didEnsureShellReadyWrappers || process.platform === 'win32') {
if (process.platform === 'win32') {
return
}
if (didEnsureShellReadyWrappers && shellReadyWrappersExist()) {
return
}
didEnsureShellReadyWrappers = true
@@ -297,42 +315,7 @@ function ensureShellReadyWrappers(): void {
const zshDir = `${root}/zsh`
const bashDir = `${root}/bash`
const zshEnv = `# Orca zsh shell-ready wrapper
_orca_spawn_orig_zdotdir="\${ORCA_ORIG_ZDOTDIR:-}"
# Why: clearing ZDOTDIR lets user .zshenv use the canonical XDG idiom
# \`export ZDOTDIR="\${ZDOTDIR:-$XDG_CONFIG_HOME/zsh}"\` to compute its
# preferred dir; pre-setting it (even to HOME) defeats that default.
unset ZDOTDIR
# Why: function isolates user .zshenv \`return\` so it doesn't abort our wrapper.
# Trade-off: top-level \`setopt LOCAL_OPTIONS\`/\`LOCAL_TRAPS\`, \`TRAPEXIT\`, and
# bare \`local\`/\`typeset\` in user .zshenv become function-scoped; use \`typeset -g\`
# or \`export\` to escape.
__orca_source_user_zshenv() {
# Why: honor an externally-set ZDOTDIR (login manager, /etc/zshenv, parent
# shell) so users whose real .zshenv lives at $ZDOTDIR (not $HOME) still
# get PATH/aliases/exports loaded. Falls back to $HOME when no spawn-env
# ZDOTDIR was inherited.
local _orca_user_zdotdir="\${_orca_spawn_orig_zdotdir:-$HOME}"
[[ -f "$_orca_user_zdotdir/.zshenv" ]] && source "$_orca_user_zdotdir/.zshenv"
}
__orca_source_user_zshenv
unfunction __orca_source_user_zshenv
# Why: prefer the ZDOTDIR user .zshenv resolved (XDG case); else preserve
# the spawn-env value (an inherited resolution from a parent Orca PTY);
# else HOME.
export ORCA_ORIG_ZDOTDIR="\${ZDOTDIR:-\${_orca_spawn_orig_zdotdir:-$HOME}}"
unset _orca_spawn_orig_zdotdir
# Why: strip trailing slashes (matches Node-side normalizer) before the
# self-loop check, so a wrapper-shaped ZDOTDIR with one or more trailing
# slashes still gets normalized away from .zprofile/.zshrc/.zlogin.
while [[ "\${ORCA_ORIG_ZDOTDIR}" == */ ]]; do
ORCA_ORIG_ZDOTDIR="\${ORCA_ORIG_ZDOTDIR%/}"
done
case "\${ORCA_ORIG_ZDOTDIR}" in
*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;;
esac
export ZDOTDIR=${quotePosixSingle(zshDir)}
`
const zshEnv = getZshEnvTemplate(zshDir)
const zshProfile = `# Orca zsh shell-ready wrapper
_orca_home="\${ORCA_ORIG_ZDOTDIR:-$HOME}"
case "\${_orca_home%/}" in
@@ -386,11 +369,26 @@ fi
[`${bashDir}/rcfile`, bashRc]
] as const
for (const [path, content] of files) {
const dir = path.slice(0, path.lastIndexOf('/'))
mkdirSync(dir, { recursive: true })
writeFileSync(path, content, 'utf8')
chmodSync(path, 0o644)
try {
for (const [path, content] of files) {
const dir = path.slice(0, path.lastIndexOf('/'))
mkdirSync(dir, { recursive: true })
writeFileSync(path, content, 'utf8')
chmodSync(path, 0o644)
}
} catch (error) {
// Why: wrapper file creation can fail due to read-only filesystems, permission
// issues, or disk space. Rather than crashing, log the error and continue.
// The shell will launch without the wrapper, which means no shell-ready marker
// but at least the PTY is usable.
const errorMessage =
error instanceof Error
? `${error.message} (${(error as NodeJS.ErrnoException).code || 'unknown'})`
: String(error)
console.error(`[shell-ready] Failed to create wrapper files in ${root}: ${errorMessage}`)
console.error('[shell-ready] Shell will launch without wrapper (no shell-ready marker)')
// Reset the flag so next attempt will try again
didEnsureShellReadyWrappers = false
}
}
@@ -414,6 +412,7 @@ function getWrappedShellLaunchConfig(
args: ['-l'],
env: {
ORCA_ORIG_ZDOTDIR: resolveOriginalZdotdir(),
ORCA_ZSHENV_SOURCE_DIR: resolveOriginalZshenvSourceDir(),
ZDOTDIR: `${getShellReadyWrapperRoot()}/zsh`,
ORCA_SHELL_READY_MARKER: options.emitReadyMarker ? '1' : '0'
},
+73
View File
@@ -0,0 +1,73 @@
// Why: local PTYs and the daemon/SSH path must use identical ZDOTDIR discovery;
// small drift here breaks different terminal transports in different ways.
function quotePosixSingle(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`
}
export function getZshEnvTemplate(zshDir: string, headerPrefix = ''): string {
const header = headerPrefix
? `Orca ${headerPrefix} zsh shell-ready wrapper`
: 'Orca zsh shell-ready wrapper'
return `# ${header}
_orca_spawn_orig_zdotdir="\${ORCA_ORIG_ZDOTDIR:-}"
_orca_user_zdotdir="\${_orca_spawn_orig_zdotdir:-$HOME}"
_orca_zshenv_source_dir="\${ORCA_ZSHENV_SOURCE_DIR:-$HOME}"
_orca_zshenv_path=""
unset ORCA_ZSHENV_SOURCE_DIR
# Normalize fallback and source roots before reading user .zshenv so nested
# Orca PTYs never source another Orca wrapper recursively.
while [[ "\${_orca_user_zdotdir}" == */ ]]; do
_orca_user_zdotdir="\${_orca_user_zdotdir%/}"
done
case "\${_orca_user_zdotdir}" in
""|*/shell-ready/zsh) _orca_user_zdotdir="$HOME" ;;
esac
while [[ "\${_orca_zshenv_source_dir}" == */ ]]; do
_orca_zshenv_source_dir="\${_orca_zshenv_source_dir%/}"
done
case "\${_orca_zshenv_source_dir}" in
""|*/shell-ready/zsh) _orca_zshenv_source_dir="$HOME" ;;
esac
# Why: source at wrapper top level, not in a function/subshell, so .zshenv
# exports, functions, path/fpath typesets, and zsh options keep normal scope.
unset ZDOTDIR
if [[ -n "\${_orca_zshenv_source_dir:-}" && -f "\${_orca_zshenv_source_dir}/.zshenv" ]]; then
_orca_zshenv_path="\${_orca_zshenv_source_dir}/.zshenv"
fi
if [[ -n "\${_orca_zshenv_path:-}" ]]; then
source "\${_orca_zshenv_path}"
fi
_orca_discovered_zdotdir="\${ZDOTDIR:-}"
while [[ "\${_orca_discovered_zdotdir}" == */ ]]; do
_orca_discovered_zdotdir="\${_orca_discovered_zdotdir%/}"
done
case "\${_orca_discovered_zdotdir}" in
*[![:space:]]*) ;;
*) _orca_discovered_zdotdir="" ;;
esac
if [[ -n "\${_orca_discovered_zdotdir}" && ! -d "\${_orca_discovered_zdotdir}" ]]; then
[[ "\${ORCA_DEBUG:-0}" == "1" ]] && echo "[orca-shell-ready] Discovered ZDOTDIR '\${_orca_discovered_zdotdir}' does not exist, falling back" >&2
_orca_discovered_zdotdir=""
fi
export ORCA_ORIG_ZDOTDIR="\${_orca_discovered_zdotdir:-\${_orca_user_zdotdir:-$HOME}}"
while [[ "\${ORCA_ORIG_ZDOTDIR}" == */ ]]; do
ORCA_ORIG_ZDOTDIR="\${ORCA_ORIG_ZDOTDIR%/}"
done
case "\${ORCA_ORIG_ZDOTDIR}" in
""|*/shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;;
esac
export ZDOTDIR=${quotePosixSingle(zshDir)}
unset _orca_spawn_orig_zdotdir _orca_user_zdotdir _orca_zshenv_source_dir _orca_zshenv_path _orca_discovered_zdotdir
`
}
+12
View File
@@ -0,0 +1,12 @@
/**
* Escape special regex characters in a string for use in RegExp constructor.
*
* Why: When building a regex from user input or file paths, special regex
* characters (. * + ? ^ $ { } ( ) | [ ] \) must be escaped to match literally.
*
* @param str - String to escape
* @returns Escaped string safe for use in new RegExp()
*/
export function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}