Merge remote-tracking branch 'origin/main' into brennanb2025/fix-transient-pr-already-exists

# Conflicts:
#	src/main/ipc/worktree-common-git-directory.ts
#	src/main/skills/skill-installation-topology.ts
This commit is contained in:
Brennan Benson
2026-07-22 17:36:35 -07:00
60 changed files with 3053 additions and 292 deletions
+3
View File
@@ -22,18 +22,21 @@
"../src/main/codex/codex-app-server-session.ts",
"../src/main/codex/codex-config-mirror.ts",
"../src/main/codex/codex-config-path-reference-rewrite.ts",
"../src/main/codex/codex-config-settings-upsert.ts",
"../src/main/codex/codex-home-paths.ts",
"../src/main/codex/codex-hook-identity.ts",
"../src/main/codex/codex-hook-trust-grant.ts",
"../src/main/codex/codex-managed-trust-reconciliation.ts",
"../src/main/codex/codex-process-exit-deadline.ts",
"../src/main/codex/codex-trust-config-rollback.ts",
"../src/main/codex/codex-trust-grant-telemetry.ts",
"../src/main/codex/codex-trust-grant-host.ts",
"../src/main/codex/codex-trust-grant-ledger.ts",
"../src/main/codex/codex-user-hook-trust-rebase-client.ts",
"../src/main/codex/codex-user-hook-trust-rebase.ts",
"../src/main/codex/codex-wsl-hook-install-plan.ts",
"../src/main/codex/config-settings-promotion.ts",
"../src/main/codex/config-toml-key-path.ts",
"../src/main/codex/config-toml-line-scan.ts",
"../src/main/codex/config-toml-trust.ts",
"../src/main/codex/hook-service.ts",
+3 -2
View File
@@ -117,8 +117,9 @@ Use `--json` for agent-friendly output. Coordinates are **normalized 0..1**
not. For unicode-heavy input, use the app UI directly.
- `gesture` is a straight swipe between the first and last point (adb limitation);
fine for scroll/swipe, not for true multi-touch paths.
- Capability verbs (`install/launch/permissions/ax/logcat`) are **Android-only**;
running them against an iOS device fails with `emulator_unsupported`.
- Capability verbs `install/launch/permissions/logcat` are **Android-only** and
fail against an iOS device with `emulator_unsupported`. `ax` works on both,
with backend-specific output (uiautomator tree vs serve-sim AX snapshot).
- No camera/sensor injection yet.
## Targeting devices & worktrees
+1 -1
View File
@@ -99,7 +99,7 @@ Use `--json` for agent-friendly output. Commands are workspace-scoped by default
| Rotate device | `ORCA emulator rotate landscape_left` | Remembers orientation for subsequent gestures. |
| Camera injection | `ORCA emulator camera com.acme.App --webcam` | Or --file, placeholder. Hot-swap with switch. May (re)launch app. |
| Permissions | `ORCA emulator permissions grant camera com.acme.App` | grant/revoke/reset/list. See full subcommand help. |
| Accessibility tree | `ORCA emulator ax [--device <id>]` | Or via exec for raw endpoint. |
| Accessibility tree | `ORCA emulator ax [--device <id>]` | Raw serve-sim AX snapshot (screen + elements). Needs an active session. |
| Raw / advanced | `ORCA emulator exec --command "tap 0.5 0.7"` | Or "ca-debug blended on", "memory-warning", full serve-sim subcommands (no "serve-sim" prefix needed in the command string). Bridge injects active device context. |
| Stop | `ORCA emulator kill [--device <id>]` | Or let pane close / Orca quit clean up. |
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -108,7 +108,7 @@ export const EMULATOR_COMMAND_SPECS: CommandSpec[] = [
},
{
path: ['emulator', 'ax'],
summary: 'Dump the Android accessibility (uiautomator) tree',
summary: 'Dump the device accessibility tree (uiautomator on Android, serve-sim AX on iOS)',
usage: 'orca emulator ax [--device <id>] [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'device', 'emulator', 'worktree']
},
+3 -2
View File
@@ -6,6 +6,7 @@ import {
} from '../../shared/wsl-login-shell-command'
export const WSL_CODEX_AVAILABILITY_TIMEOUT_MS = 5_000
export const WSL_CODEX_NOT_FOUND_MESSAGE = 'Codex CLI not found in the WSL login-shell PATH.'
export function buildWslCodexAvailabilityArgs(distro: string): string[] {
const command = [buildCodexPathLookup(), '[ -n "$resolved" ]'].join('\n')
@@ -16,7 +17,7 @@ export function buildWslCodexIdentityArgs(distro: string): string[] {
const command = [
buildCodexPathLookup(),
'if [ -z "$resolved" ]; then',
" printf '%s\\n' 'Codex CLI not found in the WSL login-shell PATH.' >&2",
` printf '%s\\n' '${WSL_CODEX_NOT_FOUND_MESSAGE}' >&2`,
' exit 127',
'fi',
'printf \'%s\\n\' "$resolved"',
@@ -29,7 +30,7 @@ export function buildWslCodexAppServerArgs(distro: string, linuxHomePath: string
const command = [
buildCodexPathLookup(),
'if [ -z "$resolved" ]; then',
" printf '%s\\n' 'Codex CLI not found in the WSL login-shell PATH.' >&2",
` printf '%s\\n' '${WSL_CODEX_NOT_FOUND_MESSAGE}' >&2`,
' exit 127',
'fi',
`export CODEX_HOME=${quotePosixShell(linuxHomePath)}`,
@@ -321,7 +321,7 @@ describe('runCodexHookTrustGrantSession', () => {
})
const result = await runCodexHookTrustGrantSession(request)
expect(result.outcome).toBe('verify-failed')
expect(result).toMatchObject({ outcome: 'verify-failed', reasonClass: 'list-mismatch' })
})
it('rejects duplicate normalized aliases that conceal a missing expected key', async () => {
@@ -337,7 +337,8 @@ describe('runCodexHookTrustGrantSession', () => {
})
await expect(runCodexHookTrustGrantSession(request)).resolves.toMatchObject({
outcome: 'verify-failed'
outcome: 'verify-failed',
reasonClass: 'list-mismatch'
})
expect(existsSync(recordFile)).toBe(false)
})
+21 -9
View File
@@ -41,6 +41,13 @@ export type CodexGrantedHookTrust = {
trustedHash: string
}
/** Closed verify-failure taxonomy — crosses the grant-bridge JSON envelope, so
* telemetry never has to parse the free-form `reason` diagnostics string. */
export type CodexTrustGrantSessionVerifyClass =
| 'list-mismatch'
| 'post-grant-untrusted'
| 'post-grant-mismatch'
export type CodexHookTrustGrantSessionResult =
| {
outcome: 'granted'
@@ -48,7 +55,7 @@ export type CodexHookTrustGrantSessionResult =
/** False when every expected entry was already trusted (no write). */
wroteTrust: boolean
}
| { outcome: 'verify-failed'; reason: string }
| { outcome: 'verify-failed'; reason: string; reasonClass: CodexTrustGrantSessionVerifyClass }
type CodexHookListing = {
key: string
@@ -117,7 +124,8 @@ export async function runCodexHookTrustGrantSession(
) {
return {
outcome: 'verify-failed',
reason: `hooks/list reported ${managedListings.length} entries covering ${managedKeyCoverage.size} of ${expectedKeys.size} expected managed entries`
reason: `hooks/list reported ${managedListings.length} entries covering ${managedKeyCoverage.size} of ${expectedKeys.size} expected managed entries`,
reasonClass: 'list-mismatch'
}
}
@@ -144,13 +152,17 @@ export async function runCodexHookTrustGrantSession(
!setContainsEvery(verifiedKeyCoverage, expectedKeys) ||
untrusted.length > 0
) {
return {
outcome: 'verify-failed',
reason:
untrusted.length > 0
? `post-grant verify left ${untrusted.length} entries ${untrusted[0].trustStatus}`
: `post-grant verify reported ${verifiedListings.length} entries covering ${verifiedKeyCoverage.size} of ${expectedKeys.size} expected entries`
}
return untrusted.length > 0
? {
outcome: 'verify-failed',
reason: `post-grant verify left ${untrusted.length} entries ${untrusted[0].trustStatus}`,
reasonClass: 'post-grant-untrusted'
}
: {
outcome: 'verify-failed',
reason: `post-grant verify reported ${verifiedListings.length} entries covering ${verifiedKeyCoverage.size} of ${expectedKeys.size} expected entries`,
reasonClass: 'post-grant-mismatch'
}
}
return {
outcome: 'granted',
@@ -0,0 +1,322 @@
import {
createTomlLineScanState,
getTomlTableHeader,
isTomlStructuralLine,
updateTomlLineScanState
} from './config-toml-line-scan'
import { parseTomlKeyPath, parseTomlTableHeaderPath } from './config-toml-key-path'
const TUI_STRUCTURED_PREFIX = 'tui.'
// Why: promoted [tui] settings are keyed by structured path (tui.<key>) so their
// baseline/update entries can never collide with a top-level key of the same name.
export function tuiStructuredKey(key: string): string {
return `${TUI_STRUCTURED_PREFIX}${key}`
}
export function isTuiStructuredKey(structuredKey: string): boolean {
return structuredKey.startsWith(TUI_STRUCTURED_PREFIX)
}
export function tuiKeyFromStructuredKey(structuredKey: string): string {
return structuredKey.slice(TUI_STRUCTURED_PREFIX.length)
}
// Why: promoted updates arrive keyed by structured path; the preamble and [tui]
// regions are disjoint, so a mixed batch (e.g. /model + a status-line change)
// composes in one rewrite — top-level keys land in the preamble, tui.<key>
// entries wherever the [tui] placement rule puts them.
export function upsertPromotedSettingsInContent(
content: string,
updates: Map<string, string>
): string {
const topLevelUpdates = new Map<string, string>()
const tuiUpdates = new Map<string, string>()
for (const [key, raw] of updates) {
if (isTuiStructuredKey(key)) {
tuiUpdates.set(tuiKeyFromStructuredKey(key), raw)
} else {
topLevelUpdates.set(key, raw)
}
}
let result = content
if (topLevelUpdates.size > 0) {
result = upsertTopLevelSettingsInContent(result, topLevelUpdates)
}
if (tuiUpdates.size > 0) {
result = upsertTuiSettingsInContent(result, tuiUpdates)
}
return result
}
export function upsertTopLevelSettingsInContent(
content: string,
updates: Map<string, string>
): string {
const lines = content.split('\n')
let state = createTomlLineScanState()
let preambleEnd = lines.length
const keyLineIndexes = new Map<string, number>()
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index] ?? ''
if (isTomlStructuralLine(state)) {
if (getTomlTableHeader(line)) {
preambleEnd = index
break
}
const parsed = parseTomlKeyPath(line)
const key = parsed?.segments.length === 1 ? parsed.segments[0] : null
if (parsed && line[parsed.end] === '=' && key && updates.has(key)) {
keyLineIndexes.set(key, index)
}
}
state = updateTomlLineScanState(state, line)
}
// Why: CRLF configs keep a trailing \r after the split; new lines must use
// the file's existing endings or a Windows-owned config becomes mixed-EOL.
const usesCrlf = content.includes('\r\n')
const insertions: string[] = []
for (const [key, raw] of updates) {
const existingIndex = keyLineIndexes.get(key)
const rendered = `${key} = ${raw}`
if (existingIndex !== undefined) {
lines[existingIndex] = lines[existingIndex]?.endsWith('\r') ? `${rendered}\r` : rendered
} else {
insertions.push(usesCrlf ? `${rendered}\r` : rendered)
}
}
if (insertions.length > 0) {
let insertAt = preambleEnd
while (insertAt > 0 && (lines[insertAt - 1] ?? '').trim() === '') {
insertAt -= 1
}
if (insertAt === preambleEnd && preambleEnd < lines.length) {
insertions.push(usesCrlf ? '\r' : '')
}
lines.splice(insertAt, 0, ...insertions)
}
return joinPreservingTrailingNewline(lines, usesCrlf)
}
type TuiPlacementScan = {
bareKeyIndexes: Map<string, number>
dottedKeyIndexes: Map<string, number>
hasBareTuiTable: boolean
hasDottedTuiKey: boolean
blocksNewTuiTable: boolean
blockedAbsentKeys: Set<string>
bareBodyInsertIndex: number
lastDottedTuiIndex: number
}
/**
* Upserts promoted `[tui]` keys (keyed by bare name) into the system config,
* placing each per the design's total placement rule: replace an existing key
* in place keeping its form; else insert bare into the first `[tui]` body; else
* dotted in the preamble beside existing dotted `tui.*` keys; else create one
* `[tui]` table at EOF for every key that reaches that branch. Rendering follows
* the destination — bare inside a table, dotted in the preamble — so no `tui`
* table is ever defined twice.
*/
export function upsertTuiSettingsInContent(content: string, updates: Map<string, string>): string {
const lines = content.split('\n')
const scan = scanTuiPlacement(lines, updates)
const usesCrlf = content.includes('\r\n')
const bareBodyInserts: string[] = []
const dottedPreambleInserts: string[] = []
const newTableKeys: string[] = []
for (const [key, raw] of updates) {
const dottedIndex = scan.dottedKeyIndexes.get(key)
if (dottedIndex !== undefined) {
lines[dottedIndex] = withTrailingCr(lines[dottedIndex]!, `${tuiStructuredKey(key)} = ${raw}`)
continue
}
const bareIndex = scan.bareKeyIndexes.get(key)
if (bareIndex !== undefined) {
lines[bareIndex] = withTrailingCr(lines[bareIndex]!, `${key} = ${raw}`)
continue
}
// Why: adding a scalar beside an existing tui.<key> descendant would turn valid TOML invalid.
if (scan.blockedAbsentKeys.has(key)) {
continue
}
if (scan.hasBareTuiTable) {
bareBodyInserts.push(`${key} = ${raw}`)
} else if (scan.hasDottedTuiKey) {
dottedPreambleInserts.push(`${tuiStructuredKey(key)} = ${raw}`)
} else if (!scan.blocksNewTuiTable) {
// Why: inline/array tui definitions block this branch because adding a
// plain [tui] beside either would make the config invalid.
newTableKeys.push(`${key} = ${raw}`)
}
}
// Why: the config shape routes every absent key to the same branch, so at most
// one insert group is non-empty; still apply EOF→body→preamble so a splice
// never shifts a lower index a later splice depends on.
if (newTableKeys.length > 0) {
appendNewTuiTable(lines, newTableKeys, usesCrlf)
}
if (bareBodyInserts.length > 0) {
lines.splice(
scan.bareBodyInsertIndex,
0,
...bareBodyInserts.map((line) => withCrLine(line, usesCrlf))
)
}
if (dottedPreambleInserts.length > 0) {
lines.splice(
scan.lastDottedTuiIndex + 1,
0,
...dottedPreambleInserts.map((line) => withCrLine(line, usesCrlf))
)
}
return joinPreservingTrailingNewline(lines, usesCrlf)
}
function scanTuiPlacement(lines: string[], updates: Map<string, string>): TuiPlacementScan {
let state = createTomlLineScanState()
let inPreamble = true
let tuiTableSeen = false
let tuiBodyActive = false
let tuiBodyHeaderIndex = -1
let tuiBodyEndIndex = -1
let hasDottedTuiKey = false
let blocksNewTuiTable = false
let lastDottedTuiIndex = -1
const bareKeyIndexes = new Map<string, number>()
const dottedKeyIndexes = new Map<string, number>()
const blockedAbsentKeys = new Set<string>()
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index] ?? ''
if (isTomlStructuralLine(state)) {
const header = getTomlTableHeader(line)
if (header) {
if (tuiBodyActive) {
tuiBodyEndIndex = index
tuiBodyActive = false
}
const table = parseTomlTableHeaderPath(header)
if (
table &&
!table.isArray &&
table.segments.length === 1 &&
table.segments[0] === 'tui' &&
!tuiTableSeen
) {
tuiTableSeen = true
tuiBodyActive = true
tuiBodyHeaderIndex = index
}
// Why: a root [[tui]] is already an array, so appending [tui] would
// redefine it and make an otherwise valid config unparseable.
if (table?.isArray && table.segments.length === 1 && table.segments[0] === 'tui') {
blocksNewTuiTable = true
}
const descendantKey =
table?.segments[0] === 'tui' && table.segments.length > 1 ? table.segments[1] : null
if (descendantKey && updates.has(descendantKey)) {
blockedAbsentKeys.add(descendantKey)
}
inPreamble = false
state = updateTomlLineScanState(state, line)
continue
}
if (inPreamble) {
// Why: any dotted `tui.*` key (allowlisted or not) already defines the
// implicit tui table, so a new `[tui]` table at EOF would duplicate it.
const parsed = parseTomlKeyPath(line)
const isAssignment = parsed && line[parsed.end] === '='
if (isAssignment && parsed.segments[0] === 'tui' && parsed.segments.length > 1) {
hasDottedTuiKey = true
lastDottedTuiIndex = index
const promotedKey = parsed.segments.length === 2 ? parsed.segments[1] : null
if (promotedKey && updates.has(promotedKey)) {
dottedKeyIndexes.set(promotedKey, index)
}
const descendantKey = parsed.segments.length > 2 ? parsed.segments[1] : null
if (descendantKey && updates.has(descendantKey)) {
blockedAbsentKeys.add(descendantKey)
}
} else if (isAssignment && parsed.segments.length === 1 && parsed.segments[0] === 'tui') {
blocksNewTuiTable = true
}
} else if (tuiBodyActive) {
const parsed = parseTomlKeyPath(line)
const key = parsed?.segments.length === 1 ? parsed.segments[0] : null
if (parsed && line[parsed.end] === '=' && key && updates.has(key)) {
bareKeyIndexes.set(key, index)
}
const descendantKey = parsed && parsed.segments.length > 1 ? parsed.segments[0] : null
if (descendantKey && updates.has(descendantKey)) {
blockedAbsentKeys.add(descendantKey)
}
}
}
state = updateTomlLineScanState(state, line)
}
if (tuiBodyActive) {
tuiBodyEndIndex = lines.length
}
return {
bareKeyIndexes,
dottedKeyIndexes,
hasBareTuiTable: tuiTableSeen,
hasDottedTuiKey,
blocksNewTuiTable,
blockedAbsentKeys,
bareBodyInsertIndex: computeBareBodyInsertIndex(lines, tuiBodyHeaderIndex, tuiBodyEndIndex),
lastDottedTuiIndex
}
}
// Why: TOML forbids adding bare keys to `[tui]` after a `[tui.*]` subtable opens,
// so absent keys land at the body's end — before trailing blanks and before the
// next header — which is the only valid spot.
function computeBareBodyInsertIndex(
lines: string[],
headerIndex: number,
endIndex: number
): number {
if (headerIndex === -1) {
return -1
}
let insertAt = endIndex
while (insertAt > headerIndex + 1 && (lines[insertAt - 1] ?? '').trim() === '') {
insertAt -= 1
}
return insertAt
}
function appendNewTuiTable(lines: string[], keyRenders: string[], usesCrlf: boolean): void {
let appendAt = lines.length
while (appendAt > 0 && (lines[appendAt - 1] ?? '').trim() === '') {
appendAt -= 1
}
// Why: separate the new table from prior content with a blank line, unless the
// file was empty/blank, where a leading blank would be spurious.
const block = appendAt > 0 ? ['', '[tui]', ...keyRenders] : ['[tui]', ...keyRenders]
lines.splice(appendAt, 0, ...block.map((line) => withCrLine(line, usesCrlf)))
}
function withTrailingCr(originalLine: string, rendered: string): string {
return originalLine.endsWith('\r') ? `${rendered}\r` : rendered
}
function withCrLine(rendered: string, usesCrlf: boolean): string {
return usesCrlf ? `${rendered}\r` : rendered
}
// Why: a missing trailing newline is restored in the file's own EOL so a
// preamble-only or table-appended rewrite matches the source's newline behavior.
function joinPreservingTrailingNewline(lines: string[], usesCrlf: boolean): string {
const result = lines.join('\n')
if (result.endsWith('\n') || result.length === 0) {
return result
}
return result.endsWith('\r') ? `${result}\n` : `${result}${usesCrlf ? '\r\n' : '\n'}`
}
+103 -4
View File
@@ -13,9 +13,9 @@ import {
CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS,
getCodexTrustGrantDiagnostics,
grantManagedCodexHookTrust,
setCodexTrustGrantTelemetry,
type CodexManagedTrustGrantPlan
} from './codex-hook-trust-grant'
import { setCodexTrustGrantTelemetry } from './codex-trust-grant-telemetry'
import { readCodexTrustGrantLedgerHome } from './codex-trust-grant-ledger'
import {
computeTrustKey,
@@ -76,7 +76,8 @@ function buildPlan(entries: CodexTrustEntry[]): CodexManagedTrustGrantPlan {
tomlPath: join(runtimeHomeDir, 'config.toml'),
managedCommand: MANAGED_COMMAND,
managedEntries: entries,
host: { kind: 'native' }
host: { kind: 'native' },
telemetryLane: 'real-home'
}
}
@@ -248,7 +249,11 @@ describe('grantManagedCodexHookTrust', () => {
it('falls back on verify-failed without marking unsupported', () => {
const entries = [managedEntry('session_start')]
const runner = vi.fn(() => ({ outcome: 'verify-failed' as const, reason: 'missing entries' }))
const runner = vi.fn(() => ({
outcome: 'verify-failed' as const,
reason: 'missing entries',
reasonClass: 'list-mismatch' as const
}))
_internals.setGrantSessionRunnerSync(runner)
expect(grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({
@@ -307,7 +312,11 @@ describe('grantManagedCodexHookTrust', () => {
mkdirSync(runtimeHomeDir, { recursive: true })
_internals.setGrantSessionRunnerSync(() => {
writeFileSync(plan.tomlPath, '[hooks.state."rpc-partial"]\ntrusted_hash = "changed"\n')
return { outcome: 'verify-failed', reason: 'post-write listing failed' }
return {
outcome: 'verify-failed',
reason: 'post-write listing failed',
reasonClass: 'post-grant-mismatch'
}
})
expect(grantManagedCodexHookTrust(plan)).toMatchObject({
@@ -347,3 +356,93 @@ describe('grantManagedCodexHookTrust', () => {
expect(request.hooksListCwd).toBe('/home/alice/.codex-runtime')
})
})
describe('trust-grant telemetry detail', () => {
type CapturedEvent = Record<string, unknown>
function captureTelemetry(): CapturedEvent[] {
const events: CapturedEvent[] = []
setCodexTrustGrantTelemetry((event) => {
events.push(event)
})
return events
}
it('attributes the plan lane on granted events', () => {
const events = captureTelemetry()
const entries = [managedEntry('session_start')]
_internals.setGrantSessionRunnerSync(() => grantedSessionResult(entries))
expect(grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({ lane: 'rpc' })
expect(events).toEqual([{ outcome: 'granted', hostKind: 'native', lane: 'real-home' }])
})
it('reports the managed lane independently of host kind', () => {
const events = captureTelemetry()
const entries = [managedEntry('session_start')]
_internals.setGrantSessionRunnerSync(() => grantedSessionResult(entries))
grantManagedCodexHookTrust({ ...buildPlan(entries), telemetryLane: 'managed' })
expect(events).toEqual([{ outcome: 'granted', hostKind: 'native', lane: 'managed' }])
})
it('classifies error fallbacks on the wire', () => {
const events = captureTelemetry()
const entries = [managedEntry('session_start')]
_internals.setGrantSessionRunnerSync(() => {
throw new Error('spawn codex ENOENT')
})
expect(grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({
lane: 'fallback',
reason: 'error'
})
expect(events).toEqual([
{
outcome: 'fallback',
hostKind: 'native',
lane: 'real-home',
reason: 'error',
errorClass: 'binary-missing'
}
])
})
it('carries the session verify class through the fallback event', () => {
const events = captureTelemetry()
const entries = [managedEntry('session_start')]
_internals.setGrantSessionRunnerSync(() => ({
outcome: 'verify-failed' as const,
reason: 'post-grant verify left 1 entries untrusted',
reasonClass: 'post-grant-untrusted' as const
}))
grantManagedCodexHookTrust(buildPlan(entries))
expect(events).toEqual([
{
outcome: 'verify_failed',
hostKind: 'native',
lane: 'real-home',
reason: 'verify-failed',
verifyClass: 'post-grant-untrusted'
}
])
})
it('classifies module-detected verify failures', () => {
const events = captureTelemetry()
const entries = [managedEntry('session_start'), managedEntry('stop')]
_internals.setGrantSessionRunnerSync(() => grantedSessionResult([entries[0]!, entries[0]!]))
grantManagedCodexHookTrust(buildPlan(entries))
expect(events).toEqual([
{
outcome: 'verify_failed',
hostKind: 'native',
lane: 'real-home',
reason: 'verify-failed',
verifyClass: 'duplicate-key'
}
])
})
})
+43 -51
View File
@@ -3,6 +3,13 @@ import {
type CodexHookTrustGrantRequest,
type CodexHookTrustGrantSessionResult
} from './codex-app-server-client'
import {
classifyCodexTrustGrantError,
emitCodexTrustGrantTelemetry,
type CodexTrustGrantFallbackReason,
type CodexTrustGrantTelemetryLane,
type CodexTrustGrantVerifyClass
} from './codex-trust-grant-telemetry'
import { runCodexHookTrustGrantSessionSync } from './codex-app-server-grant-bridge'
import {
codexAppServerCapabilityCache,
@@ -46,69 +53,31 @@ export type CodexManagedTrustGrantPlan = {
/** Managed trust identities Orca just wrote (no trustedHash). */
managedEntries: readonly CodexTrustEntry[]
host: CodexTrustGrantHost
telemetryLane: CodexTrustGrantTelemetryLane
/** Match a pane where CODEX_HOME is absent instead of an explicit managed home. */
useDefaultCodexHome?: boolean
}
export type CodexTrustGrantFallbackReason =
| 'disabled'
| 'no-managed-entries'
| 'unsupported'
| 'unsupported-cached'
| 'verify-failed'
| 'retry-cached'
| 'error'
export type { CodexTrustGrantFallbackReason, CodexTrustGrantTelemetryLane }
export type CodexManagedTrustGrantOutcome =
| { lane: 'rpc'; entries: CodexTrustEntry[] }
| { lane: 'fallback'; reason: CodexTrustGrantFallbackReason }
export type CodexTrustGrantDiagnostics = {
granted: number
ledgerHits: number
fellBack: number
verifyFailed: number
lastFallbackReason: CodexTrustGrantFallbackReason | null
}
const diagnostics: CodexTrustGrantDiagnostics = {
const diagnostics = {
granted: 0,
ledgerHits: 0,
fellBack: 0,
verifyFailed: 0,
lastFallbackReason: null
lastFallbackReason: null as CodexTrustGrantFallbackReason | null
}
export type CodexTrustGrantDiagnostics = typeof diagnostics
const transientRetryAfterByHost = new Map<string, number>()
export function getCodexTrustGrantDiagnostics(): CodexTrustGrantDiagnostics {
return { ...diagnostics }
}
type CodexTrustGrantTelemetry = (event: {
outcome: 'granted' | 'fallback' | 'verify_failed'
hostKind: 'native' | 'wsl'
reason?: CodexTrustGrantFallbackReason
}) => void
// Why: hook-service is bundled into plain-node CLI entries where electron
// (and therefore the telemetry client) cannot load; the Electron main process
// injects the tracker at startup instead of a static import.
let telemetry: CodexTrustGrantTelemetry = () => {}
export function setCodexTrustGrantTelemetry(tracker: CodexTrustGrantTelemetry): void {
telemetry = tracker
}
function emitTelemetry(event: Parameters<CodexTrustGrantTelemetry>[0]): void {
try {
telemetry(event)
} catch (error) {
// Why: observability must never turn a verified grant into fallback or
// violate this launch-prep API's no-throw contract.
console.warn('[codex-trust-grant] failed to emit telemetry', error)
}
}
type GrantSessionRunnerSync = (
request: CodexHookTrustGrantRequest
) => CodexHookTrustGrantSessionResult
@@ -118,7 +87,8 @@ let runSessionSync: GrantSessionRunnerSync = runCodexHookTrustGrantSessionSync
function fallback(
plan: CodexManagedTrustGrantPlan,
reason: CodexTrustGrantFallbackReason,
detail?: unknown
detail?: unknown,
verifyClass?: CodexTrustGrantVerifyClass
): CodexManagedTrustGrantOutcome {
diagnostics.fellBack += 1
diagnostics.lastFallbackReason = reason
@@ -129,10 +99,13 @@ function fallback(
`[codex-trust-grant] falling back to self-computed trust (reason=${reason}, host=${plan.host.kind})`,
detail ?? ''
)
emitTelemetry({
emitCodexTrustGrantTelemetry({
outcome: reason === 'verify-failed' ? 'verify_failed' : 'fallback',
hostKind: plan.host.kind,
reason
lane: plan.telemetryLane,
reason,
...(reason === 'error' ? { errorClass: classifyCodexTrustGrantError(detail) } : {}),
...(verifyClass !== undefined ? { verifyClass } : {})
})
return { lane: 'fallback', reason }
}
@@ -272,7 +245,7 @@ export function grantManagedCodexHookTrust(
hostKey,
Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS
)
return fallback(plan, 'verify-failed', result.reason)
return fallback(plan, 'verify-failed', result.reason, result.reasonClass)
}
const byNormalizedKey = new Map(expected.map((item) => [item.normalizedKey, item]))
@@ -287,7 +260,12 @@ export function grantManagedCodexHookTrust(
hostKey,
Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS
)
return fallback(plan, 'verify-failed', `unexpected granted key ${granted.key}`)
return fallback(
plan,
'verify-failed',
`unexpected granted key ${granted.key}`,
'unexpected-key'
)
}
if (seenNormalizedKeys.has(granted.normalizedKey)) {
restoreCodexTrustConfig(plan.tomlPath, configSnapshot)
@@ -295,7 +273,12 @@ export function grantManagedCodexHookTrust(
hostKey,
Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS
)
return fallback(plan, 'verify-failed', `duplicate granted key ${granted.key}`)
return fallback(
plan,
'verify-failed',
`duplicate granted key ${granted.key}`,
'duplicate-key'
)
}
seenNormalizedKeys.add(granted.normalizedKey)
grantedEntries.push({ ...match.entry, trustedHash: granted.trustedHash })
@@ -310,7 +293,12 @@ export function grantManagedCodexHookTrust(
hostKey,
Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS
)
return fallback(plan, 'verify-failed', 'granted entry set did not cover expected entries')
return fallback(
plan,
'verify-failed',
'granted entry set did not cover expected entries',
'coverage'
)
}
transientRetryAfterByHost.delete(hostKey)
try {
@@ -327,7 +315,11 @@ export function grantManagedCodexHookTrust(
`[codex-trust-grant] granted ${grantedEntries.length} managed hook entries via codex app-server ` +
`(host=${plan.host.kind}, wrote=${result.wroteTrust}, ${Date.now() - startedAtMs}ms)`
)
emitTelemetry({ outcome: 'granted', hostKind: plan.host.kind })
emitCodexTrustGrantTelemetry({
outcome: 'granted',
hostKind: plan.host.kind,
lane: plan.telemetryLane
})
return { lane: 'rpc', entries: grantedEntries }
} catch (error) {
return fallback(plan, 'error', error)
@@ -196,6 +196,7 @@ function installRealHomeCodexHook(userDataPath: string): RealHomeCodexHookLane {
managedCommand: material.command,
managedEntries,
host: { kind: 'native' },
telemetryLane: 'real-home',
useDefaultCodexHome: true
})
if (grant.lane === 'rpc') {
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { WSL_CODEX_NOT_FOUND_MESSAGE } from '../codex-accounts/wsl-codex-command'
import { CodexAppServerTimeoutError } from './codex-app-server-client'
import { classifyCodexTrustGrantError } from './codex-trust-grant-telemetry'
describe('classifyCodexTrustGrantError', () => {
it.each([
[new CodexAppServerTimeoutError('entry exceeded 20000ms session deadline'), 'timeout'],
[new Error('spawn codex ENOENT'), 'binary-missing'],
[new Error('spawn /Users/ada/.local/bin/codex ENOENT'), 'binary-missing'],
[
new Error(
`codex app-server exited before completing the session: ${WSL_CODEX_NOT_FOUND_MESSAGE}`
),
'binary-missing'
],
[new Error('spawn wsl.exe ENOENT'), 'unexpected'],
[
new Error("ENOENT: no such file or directory, open '/home/ada/.codex/config.toml'"),
'unexpected'
],
[new Error('codex trust-grant entry bundle not found'), 'entry-failed'],
[new Error('codex trust-grant entry produced no result (exit 1)'), 'entry-failed'],
[
new Error('codex app-server exited before completing the session: panicked at main.rs'),
'early-exit'
],
[new Error('codex app-server config/batchWrite failed: unknown key'), 'rpc-failed'],
[new Error('write EPIPE'), 'unexpected'],
['not an error object', 'unexpected']
] as const)('classifies %s as %s', (error, expected) => {
expect(classifyCodexTrustGrantError(error)).toBe(expected)
})
it('keeps an ENOENT-mentioning stderr tail classified as early-exit', () => {
expect(
classifyCodexTrustGrantError(
new Error('codex app-server exited before completing the session: ENOENT in codex output')
)
).toBe('early-exit')
})
})
@@ -0,0 +1,91 @@
import type { CodexTrustGrantSessionVerifyClass } from './codex-app-server-client'
import { WSL_CODEX_NOT_FOUND_MESSAGE } from '../codex-accounts/wsl-codex-command'
/** Which install surface asked for the grant: the system-default real ~/.codex
* or a managed (mirror/per-account) home. Telemetry attribution only — the
* grant behaves identically; host_kind alone cannot distinguish the lanes
* (native hosts grant for both surfaces). */
export type CodexTrustGrantTelemetryLane = 'real-home' | 'managed'
export type CodexTrustGrantFallbackReason =
| 'disabled'
| 'no-managed-entries'
| 'unsupported'
| 'unsupported-cached'
| 'verify-failed'
| 'retry-cached'
| 'error'
/** Closed classification of `reason: 'error'` fallbacks. Errors cross the
* grant-bridge envelope as message text (only timeout/unsupported keep their
* name), so classes are matched on the bounded message shapes each layer
* produces — never forwarded raw. */
export type CodexTrustGrantErrorClass =
| 'binary-missing'
| 'timeout'
| 'entry-failed'
| 'early-exit'
| 'rpc-failed'
| 'unexpected'
export type CodexTrustGrantVerifyClass =
| CodexTrustGrantSessionVerifyClass
| 'unexpected-key'
| 'duplicate-key'
| 'coverage'
export function classifyCodexTrustGrantError(error: unknown): CodexTrustGrantErrorClass {
if (!(error instanceof Error)) {
return 'unexpected'
}
if (error.name === 'CodexAppServerTimeoutError') {
return 'timeout'
}
const message = error.message
if (message.includes('codex trust-grant entry')) {
return 'entry-failed'
}
if (
/^spawn (?:.*[\\/])?codex(?:\.(?:cmd|exe|bat))? ENOENT$/.test(message) ||
message.includes(WSL_CODEX_NOT_FOUND_MESSAGE)
) {
return 'binary-missing'
}
if (message.includes('exited before completing the session')) {
return 'early-exit'
}
if (/codex app-server \S+ failed:/.test(message)) {
return 'rpc-failed'
}
return 'unexpected'
}
export type CodexTrustGrantTelemetryEvent = {
outcome: 'granted' | 'fallback' | 'verify_failed'
hostKind: 'native' | 'wsl'
lane: CodexTrustGrantTelemetryLane
reason?: CodexTrustGrantFallbackReason
errorClass?: CodexTrustGrantErrorClass
verifyClass?: CodexTrustGrantVerifyClass
}
type CodexTrustGrantTelemetry = (event: CodexTrustGrantTelemetryEvent) => void
// Why: hook-service is bundled into plain-node CLI entries where electron
// (and therefore the telemetry client) cannot load; the Electron main process
// injects the tracker at startup instead of a static import.
let telemetry: CodexTrustGrantTelemetry = () => {}
export function setCodexTrustGrantTelemetry(tracker: CodexTrustGrantTelemetry): void {
telemetry = tracker
}
export function emitCodexTrustGrantTelemetry(event: CodexTrustGrantTelemetryEvent): void {
try {
telemetry(event)
} catch (error) {
// Why: observability must never turn a verified grant into fallback or
// violate the launch-prep no-throw contract of the grant lane.
console.warn('[codex-trust-grant] failed to emit telemetry', error)
}
}
@@ -19,7 +19,7 @@ import type * as CodexFsUtils from '../codex-accounts/fs-utils'
const { homedirMock, promotionTestState } = vi.hoisted(() => ({
homedirMock: vi.fn<() => string>(),
promotionTestState: { failAtomicWrite: false }
promotionTestState: { failAtomicWrite: false, atomicWritePaths: [] as string[] }
}))
vi.mock('node:os', async (importOriginal) => {
@@ -35,6 +35,7 @@ vi.mock('../codex-accounts/fs-utils', async (importOriginal) => {
return {
...actual,
writeFileAtomically: (...args: Parameters<typeof actual.writeFileAtomically>) => {
promotionTestState.atomicWritePaths.push(args[0])
if (promotionTestState.failAtomicWrite) {
throw new Error('injected atomic write failure')
}
@@ -44,7 +45,15 @@ vi.mock('../codex-accounts/fs-utils', async (importOriginal) => {
})
import { syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror'
import { upsertTopLevelSettingsInContent } from './config-settings-promotion'
import {
upsertPromotedSettingsInContent,
upsertTopLevelSettingsInContent
} from './codex-config-settings-upsert'
// The exact [tui] block codex 0.144.6 writes via config/batchWrite (all four
// promoted keys single-line, theme a string).
const CODEX_TUI_BLOCK =
'[tui]\nstatus_line = ["model-with-reasoning", "task-progress"]\nstatus_line_use_colors = true\nterminal_title = ["model"]\ntheme = "dark-photon"\n'
let tmpHome: string
let userDataDir: string
@@ -57,6 +66,7 @@ beforeEach(() => {
process.env.ORCA_USER_DATA_PATH = userDataDir
homedirMock.mockReturnValue(tmpHome)
promotionTestState.failAtomicWrite = false
promotionTestState.atomicWritePaths.length = 0
// Why: promotion writes into homedir()/.codex — if the mock ever fails to
// intercept, these tests would rewrite the developer's real Codex config.
if (homedir() !== tmpHome) {
@@ -117,6 +127,13 @@ function simulateCodexSettingWrite(key: string, rawValue: string): void {
writeFileSync(runtimeConfigPath(), next, 'utf-8')
}
// Codex reads then rewrites the whole runtime config; simulate that by writing
// a known runtime config directly (its EOL is normalized by the mirror anyway).
function setRuntimeConfig(content: string): void {
mkdirSync(runtimeHomeDir(), { recursive: true })
writeFileSync(runtimeConfigPath(), content, 'utf-8')
}
function simulateCodexSettingRemoval(key: string): void {
const existing = readFileSync(runtimeConfigPath(), 'utf-8')
const linePattern = new RegExp(`^${key}[ \\t]*=.*\\n?`, 'm')
@@ -402,6 +419,363 @@ describe('codex settings write-back promotion', () => {
})
})
describe('codex [tui] settings write-back promotion', () => {
it('promotes a runtime [tui] block (codex 0.144.6 shape) into ~/.codex and survives the remirror', () => {
writeSystemConfig('model = "gpt-5"\n')
syncSystemConfigIntoManagedCodexHome()
// The user customizes the status line/theme inside Orca-launched Codex.
writeFileSync(runtimeConfigPath(), `${readRuntimeConfig()}\n${CODEX_TUI_BLOCK}`, 'utf-8')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe(`model = "gpt-5"\n\n${CODEX_TUI_BLOCK}`)
const runtime = readRuntimeConfig()
expect(runtime).toContain('status_line = ["model-with-reasoning", "task-progress"]')
expect(runtime).toContain('status_line_use_colors = true')
expect(runtime).toContain('terminal_title = ["model"]')
expect(runtime).toContain('theme = "dark-photon"')
const settledSystem = readSystemConfig()
const settledRuntime = readRuntimeConfig()
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe(settledSystem)
expect(readRuntimeConfig()).toBe(settledRuntime)
})
it('replaces a promoted key in an existing [tui] table, leaving non-promoted neighbors untouched', () => {
writeSystemConfig('model = "gpt-5"\n\n[tui]\nanimations = true\ntheme = "dark"\n')
syncSystemConfigIntoManagedCodexHome()
setRuntimeConfig('model = "gpt-5"\n\n[tui]\nanimations = true\ntheme = "light"\n')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe(
'model = "gpt-5"\n\n[tui]\nanimations = true\ntheme = "light"\n'
)
})
it('promotes a changed status_line array value', () => {
writeSystemConfig('model = "gpt-5"\n\n[tui]\nstatus_line = ["model"]\n')
syncSystemConfigIntoManagedCodexHome()
setRuntimeConfig(
'model = "gpt-5"\n\n[tui]\nstatus_line = ["model-with-reasoning", "task-progress"]\n'
)
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe(
'model = "gpt-5"\n\n[tui]\nstatus_line = ["model-with-reasoning", "task-progress"]\n'
)
})
it('promotes a model change and a status-line change in one pass into their regions', () => {
writeSystemConfig('model = "gpt-5"\n\n[tui]\ntheme = "dark-photon"\n')
syncSystemConfigIntoManagedCodexHome()
setRuntimeConfig('model = "o4"\n\n[tui]\ntheme = "dark-photon"\nstatus_line = ["model"]\n')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe(
'model = "o4"\n\n[tui]\ntheme = "dark-photon"\nstatus_line = ["model"]\n'
)
})
it('detects and replaces a dotted-form system tui key without creating a [tui] table', () => {
writeSystemConfig('model = "gpt-5"\ntui.theme = "dark"\n')
syncSystemConfigIntoManagedCodexHome()
// toml_edit preserves the dotted form when codex rewrites the value.
setRuntimeConfig('model = "gpt-5"\ntui.theme = "light"\n')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe('model = "gpt-5"\ntui.theme = "light"\n')
expect(readSystemConfig()).not.toContain('[tui]')
})
it('promotes through a quoted tui table without creating a duplicate table', () => {
writeSystemConfig('model = "gpt-5"\n\n["tui"]\ntheme = "dark"\n')
syncSystemConfigIntoManagedCodexHome()
setRuntimeConfig('model = "gpt-5"\n\n["tui"]\ntheme = "light"\n')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe('model = "gpt-5"\n\n["tui"]\ntheme = "light"\n')
expect(readSystemConfig()).not.toContain('\n[tui]\n')
})
it('inserts a second dotted tui key beside an existing dotted-only tui config', () => {
writeSystemConfig('model = "gpt-5"\ntui.theme = "dark"\n')
syncSystemConfigIntoManagedCodexHome()
setRuntimeConfig('model = "gpt-5"\ntui.theme = "dark"\ntui.status_line = ["model"]\n')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe(
'model = "gpt-5"\ntui.theme = "dark"\ntui.status_line = ["model"]\n'
)
expect(readSystemConfig()).not.toContain('[tui]')
})
it('inserts dotted beside a non-promoted dotted tui key instead of creating a [tui] table', () => {
// Why: any dotted tui.* key already defines the implicit tui table, so a
// fresh [tui] table at EOF would be a duplicate-definition parse error.
writeSystemConfig('model = "gpt-5"\ntui.pet = "cat"\n')
syncSystemConfigIntoManagedCodexHome()
setRuntimeConfig('model = "gpt-5"\ntui.pet = "cat"\ntui.theme = "dark-photon"\n')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe('model = "gpt-5"\ntui.pet = "cat"\ntui.theme = "dark-photon"\n')
expect(readSystemConfig()).not.toContain('[tui]')
})
it('creates a [tui] table at EOF when the only tui presence is a subtable', () => {
writeSystemConfig('model = "gpt-5"\n\n[tui.notifications]\nenabled = true\n')
syncSystemConfigIntoManagedCodexHome()
setRuntimeConfig(
'model = "gpt-5"\n\n[tui.notifications]\nenabled = true\n\n[tui]\nstatus_line = ["model"]\n'
)
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe(
'model = "gpt-5"\n\n[tui.notifications]\nenabled = true\n\n[tui]\nstatus_line = ["model"]\n'
)
})
it('creates exactly one [tui] table for two keys promoted in one pass', () => {
writeSystemConfig('model = "gpt-5"\n')
syncSystemConfigIntoManagedCodexHome()
setRuntimeConfig('model = "gpt-5"\n\n[tui]\ntheme = "dark-photon"\nstatus_line = ["model"]\n')
syncSystemConfigIntoManagedCodexHome()
const system = readSystemConfig()
expect(system.match(/^\[tui\]$/gm)?.length).toBe(1)
expect(system).toBe(
'model = "gpt-5"\n\n[tui]\nstatus_line = ["model"]\ntheme = "dark-photon"\n'
)
})
it('lets an outside ~/.codex [tui] edit win over a conflicting in-Codex tui change', () => {
writeSystemConfig('model = "gpt-5"\n\n[tui]\ntheme = "dark"\n')
syncSystemConfigIntoManagedCodexHome()
setRuntimeConfig('model = "gpt-5"\n\n[tui]\ntheme = "in-codex"\n')
writeSystemConfig('model = "gpt-5"\n\n[tui]\ntheme = "outside-edit"\n')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe('model = "gpt-5"\n\n[tui]\ntheme = "outside-edit"\n')
expect(readRuntimeConfig()).toContain('theme = "outside-edit"')
})
it('does not promote a [tui] key deletion', () => {
writeSystemConfig('model = "gpt-5"\n\n[tui]\ntheme = "dark"\n')
syncSystemConfigIntoManagedCodexHome()
setRuntimeConfig('model = "gpt-5"\n\n[tui]\n')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toContain('theme = "dark"')
})
it('inserts a promoted key into a CRLF system [tui] table preserving CRLF', () => {
writeSystemConfig('model = "gpt-5"\r\n\r\n[tui]\r\ntheme = "dark"\r\n')
syncSystemConfigIntoManagedCodexHome()
setRuntimeConfig('model = "gpt-5"\n\n[tui]\ntheme = "dark"\nstatus_line = ["model"]\n')
syncSystemConfigIntoManagedCodexHome()
const system = readSystemConfig()
expect(system).toContain('status_line = ["model"]\r\n')
expect(system).toBe(
'model = "gpt-5"\r\n\r\n[tui]\r\ntheme = "dark"\r\nstatus_line = ["model"]\r\n'
)
})
it('never appends a [tui] table when the system config defines tui inline', () => {
writeSystemConfig('model = "gpt-5"\n')
syncSystemConfigIntoManagedCodexHome()
// In-Codex tui change racing an outside edit that adds an inline tui table:
// appending [tui] would make the system config unparseable, so the change
// is dropped instead.
setRuntimeConfig('model = "gpt-5"\n\n[tui]\ntheme = "dark-photon"\n')
writeSystemConfig('model = "gpt-5"\ntui = { animations = false }\n')
promotionTestState.atomicWritePaths.length = 0
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe('model = "gpt-5"\ntui = { animations = false }\n')
expect(promotionTestState.atomicWritePaths).not.toContain(systemConfigPath())
})
it('ignores an allowlisted key nested under a [tui.*] subtable', () => {
writeSystemConfig('model = "gpt-5"\n\n[tui.notifications]\ntheme = "should-not-promote"\n')
syncSystemConfigIntoManagedCodexHome()
setRuntimeConfig('model = "gpt-5"\n\n[tui.notifications]\ntheme = "changed-in-subtable"\n')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe(
'model = "gpt-5"\n\n[tui.notifications]\ntheme = "should-not-promote"\n'
)
})
})
describe('upsertPromotedSettingsInContent', () => {
it('replaces a bare key in place inside the [tui] table', () => {
expect(
upsertPromotedSettingsInContent(
'[tui]\ntheme = "dark"\n',
new Map([['tui.theme', '"light"']])
)
).toBe('[tui]\ntheme = "light"\n')
})
it('inserts a bare key at the end of the [tui] body, before a subtable', () => {
expect(
upsertPromotedSettingsInContent(
'[tui]\ntheme = "dark"\n\n[tui.notifications]\nenabled = true\n',
new Map([['tui.status_line', '["model"]']])
)
).toBe(
'[tui]\ntheme = "dark"\nstatus_line = ["model"]\n\n[tui.notifications]\nenabled = true\n'
)
})
it('replaces a dotted preamble tui key in place, keeping the dotted form', () => {
expect(
upsertPromotedSettingsInContent('tui.theme = "dark"\n', new Map([['tui.theme', '"light"']]))
).toBe('tui.theme = "light"\n')
})
it('inserts a dotted tui key beside an existing dotted tui key', () => {
expect(
upsertPromotedSettingsInContent(
'tui.theme = "dark"\n\n[features]\nx = 1\n',
new Map([['tui.status_line', '["model"]']])
)
).toBe('tui.theme = "dark"\ntui.status_line = ["model"]\n\n[features]\nx = 1\n')
})
it('creates a [tui] table from empty content', () => {
expect(upsertPromotedSettingsInContent('', new Map([['tui.theme', '"dark"']]))).toBe(
'[tui]\ntheme = "dark"\n'
)
})
it('drops an absent key instead of appending [tui] beside an inline tui table', () => {
expect(
upsertPromotedSettingsInContent(
'tui = { animations = false }\n',
new Map([['tui.theme', '"dark"']])
)
).toBe('tui = { animations = false }\n')
})
it('drops an absent key beside a quoted inline tui table', () => {
expect(
upsertPromotedSettingsInContent(
'"tui" = { animations = false }\n',
new Map([['tui.theme', '"dark"']])
)
).toBe('"tui" = { animations = false }\n')
})
it('inserts beside a quoted dotted tui key instead of appending a table', () => {
expect(
upsertPromotedSettingsInContent('"tui" . "pet" = "cat"\n', new Map([['tui.theme', '"dark"']]))
).toBe('"tui" . "pet" = "cat"\ntui.theme = "dark"\n')
})
it('creates a [tui] super-table at EOF after a [tui.*] subtable', () => {
expect(
upsertPromotedSettingsInContent(
'[tui.notifications]\nenabled = true\n',
new Map([['tui.theme', '"dark"']])
)
).toBe('[tui.notifications]\nenabled = true\n\n[tui]\ntheme = "dark"\n')
})
it('drops an absent scalar that would redefine an existing tui key table', () => {
expect(
upsertPromotedSettingsInContent(
'[tui."theme"]\nvariant = "dark"\n',
new Map([['tui.theme', '"light"']])
)
).toBe('[tui."theme"]\nvariant = "dark"\n')
})
it('drops an absent scalar that would redefine a dotted tui key table', () => {
expect(
upsertPromotedSettingsInContent(
'tui.theme.variant = "dark"\n',
new Map([['tui.theme', '"light"']])
)
).toBe('tui.theme.variant = "dark"\n')
})
it('does not mistake a dotted tui key inside an array table for a root key', () => {
expect(
upsertPromotedSettingsInContent(
'[[profiles]]\ntui.theme = "profile-theme"\n',
new Map([['tui.theme', '"root-theme"']])
)
).toBe('[[profiles]]\ntui.theme = "profile-theme"\n\n[tui]\ntheme = "root-theme"\n')
})
it('does not append a table beside a root tui array-of-tables', () => {
expect(
upsertPromotedSettingsInContent(
'[[tui]]\ntheme = "array-theme"\n',
new Map([['tui.theme', '"root-theme"']])
)
).toBe('[[tui]]\ntheme = "array-theme"\n')
})
it('does not append a table beside a quoted root tui array-of-tables', () => {
expect(
upsertPromotedSettingsInContent(
'[["tui"]]\ntheme = "array-theme"\n',
new Map([['tui.theme', '"root-theme"']])
)
).toBe('[["tui"]]\ntheme = "array-theme"\n')
})
it('creates one [tui] table for multiple keys reaching the new-table branch', () => {
expect(
upsertPromotedSettingsInContent(
'',
new Map([
['tui.status_line', '["model"]'],
['tui.theme', '"dark"']
])
)
).toBe('[tui]\nstatus_line = ["model"]\ntheme = "dark"\n')
})
it('routes a mixed top-level + tui batch to its two regions in one rewrite', () => {
expect(
upsertPromotedSettingsInContent(
'model = "gpt-5"\n\n[tui]\ntheme = "dark"\n',
new Map([
['model', '"o4"'],
['tui.theme', '"light"']
])
)
).toBe('model = "o4"\n\n[tui]\ntheme = "light"\n')
})
it('inserts into a CRLF [tui] table with CRLF endings', () => {
expect(
upsertPromotedSettingsInContent(
'[tui]\r\ntheme = "dark"\r\n',
new Map([['tui.status_line', '["model"]']])
)
).toBe('[tui]\r\ntheme = "dark"\r\nstatus_line = ["model"]\r\n')
})
})
describe('upsertTopLevelSettingsInContent', () => {
it('writes into empty content', () => {
expect(upsertTopLevelSettingsInContent('', new Map([['model', '"x"']]))).toBe('model = "x"\n')
@@ -428,6 +802,12 @@ describe('upsertTopLevelSettingsInContent', () => {
).toBe('# keep\nmodel = "new"\n\n[t]\nk = 1\n')
})
it('replaces a quoted top-level key instead of adding its bare equivalent', () => {
expect(
upsertTopLevelSettingsInContent('"model" = "old"\n', new Map([['model', '"new"']]))
).toBe('model = "new"\n')
})
it('inserts with CRLF endings into CRLF content', () => {
expect(
upsertTopLevelSettingsInContent('[features]\r\nhooks = true\r\n', new Map([['model', '"x"']]))
+109 -67
View File
@@ -18,6 +18,8 @@ import {
isTomlStructuralLine,
updateTomlLineScanState
} from './config-toml-line-scan'
import { parseTomlKeyPath, parseTomlTableHeaderPath } from './config-toml-key-path'
import { tuiStructuredKey, upsertPromotedSettingsInContent } from './codex-config-settings-upsert'
// Why: the mirror reverts in-Codex config changes each launch; promotion salvages them by diffing the last baseline.
@@ -29,6 +31,45 @@ export const PROMOTED_CODEX_SETTING_KEYS = [
'sandbox_mode'
] as const
// Why: the [tui] keys the Codex TUI's user-facing pickers persist (status line,
// terminal title, theme). Like the top-level list, every key here gets written
// into the user's real ~/.codex/config.toml on promotion — grow it deliberately.
export const PROMOTED_CODEX_TUI_SETTING_KEYS = [
'status_line',
'status_line_use_colors',
'terminal_title',
'theme'
] as const
// Why: promotion diffs and upserts operate on structured keys — top-level keys
// keep their bare name, [tui] keys are namespaced tui.<key> so their baseline
// entries cannot collide with a top-level key of the same name.
const PROMOTED_STRUCTURED_KEYS: readonly string[] = [
...PROMOTED_CODEX_SETTING_KEYS,
...PROMOTED_CODEX_TUI_SETTING_KEYS.map(tuiStructuredKey)
]
function isPromotedTuiKey(key: string): boolean {
return (PROMOTED_CODEX_TUI_SETTING_KEYS as readonly string[]).includes(key)
}
// Returns the structured tui key a scanned line's key represents, or null. In
// the preamble it recognizes the dotted `tui.<key>` form a user may hand-author;
// inside the first `[tui]` table body it recognizes the bare `<key>` form Codex
// writes. Both map to the same structured key so either config shape promotes.
function matchTuiStructuredKey(
keyPath: string[],
inPreamble: boolean,
tuiBodyActive: boolean
): string | null {
if (inPreamble) {
const tuiKey = keyPath.length === 2 && keyPath[0] === 'tui' ? keyPath[1] : null
return tuiKey && isPromotedTuiKey(tuiKey) ? tuiStructuredKey(tuiKey) : null
}
const tuiKey = keyPath.length === 1 ? keyPath[0] : null
return tuiBodyActive && tuiKey && isPromotedTuiKey(tuiKey) ? tuiStructuredKey(tuiKey) : null
}
type TopLevelSettingValue = {
raw: string
// Why: a multiline string/array value can't be replaced line-by-line, so it's excluded from promotion.
@@ -70,24 +111,67 @@ function readSettingsBaseline(runtimeHomePath: string): Map<string, string> | nu
}
}
// Why: only top-level preamble keys are scanned; rewriting nested [profiles.*] tables isn't worth the risk here.
function readTopLevelSettingValues(configPath: string): Map<string, TopLevelSettingValue> {
function matchPromotedStructuredKey(
line: string,
inPreamble: boolean,
tuiBodyActive: boolean
): { structuredKey: string; raw: string } | null {
const parsed = parseTomlKeyPath(line)
if (!parsed || line[parsed.end] !== '=') {
return null
}
const raw = line.slice(parsed.end + 1).trim()
const topLevelKey = parsed.segments.length === 1 ? parsed.segments[0] : null
if (
inPreamble &&
topLevelKey &&
(PROMOTED_CODEX_SETTING_KEYS as readonly string[]).includes(topLevelKey)
) {
return { structuredKey: topLevelKey, raw }
}
const tuiKey = matchTuiStructuredKey(parsed.segments, inPreamble, tuiBodyActive)
return tuiKey ? { structuredKey: tuiKey, raw } : null
}
// Why: top-level preamble scalars keep the historical behavior; [tui] keys are
// collected from the first bare [tui] table body or the dotted preamble form,
// keyed by structured path. Any table header (including [tui.*] subtables) ends
// the [tui] body, and [profiles.*]/other tables are still ignored.
function readPromotedSettingValues(configPath: string): Map<string, TopLevelSettingValue> {
const result = new Map<string, TopLevelSettingValue>()
if (!existsSync(configPath)) {
return result
}
const lines = readFileSync(configPath, 'utf-8').split('\n')
let state = createTomlLineScanState()
let inPreamble = true
let tuiTableSeen = false
let tuiBodyActive = false
for (const line of lines) {
if (isTomlStructuralLine(state)) {
if (getTomlTableHeader(line)) {
break
const header = getTomlTableHeader(line)
if (header) {
const table = parseTomlTableHeaderPath(header)
tuiBodyActive =
table !== null &&
!table.isArray &&
table.segments.length === 1 &&
table.segments[0] === 'tui' &&
!tuiTableSeen
if (tuiBodyActive) {
tuiTableSeen = true
}
inPreamble = false
state = updateTomlLineScanState(state, line)
continue
}
const match = /^[ \t]*([A-Za-z0-9_-]+)[ \t]*=[ \t]*(.*?)[ \t\r]*$/.exec(line)
const key = match?.[1]
if (key && (PROMOTED_CODEX_SETTING_KEYS as readonly string[]).includes(key)) {
const matched = matchPromotedStructuredKey(line, inPreamble, tuiBodyActive)
if (matched) {
const nextState = updateTomlLineScanState(state, line)
result.set(key, { raw: match?.[2] ?? '', multiline: !isTomlStructuralLine(nextState) })
result.set(matched.structuredKey, {
raw: matched.raw,
multiline: !isTomlStructuralLine(nextState)
})
state = nextState
continue
}
@@ -109,7 +193,7 @@ export function snapshotCodexRuntimeSettingsBaseline(
const runtimeTomlPath = join(runtimeHomePath, 'config.toml')
// Why: record an empty baseline even for a missing runtime config, so Codex's first write still diffs and promotes.
const settings: Record<string, string> = {}
for (const [key, value] of readTopLevelSettingValues(runtimeTomlPath)) {
for (const [key, value] of readPromotedSettingValues(runtimeTomlPath)) {
if (!value.multiline) {
settings[key] = value.raw
}
@@ -173,10 +257,9 @@ function promoteCodexRuntimeSettingsToSystemUnsafe(homes: CodexSettingsPromotion
if (!baseline) {
return
}
const runtimeValues = readTopLevelSettingValues(runtimeTomlPath)
const systemValues = readTopLevelSettingValues(systemTomlPath)
const updates = new Map<string, string>()
for (const key of PROMOTED_CODEX_SETTING_KEYS) {
const runtimeValues = readPromotedSettingValues(runtimeTomlPath)
const changedRuntimeValues = new Map<string, string>()
for (const key of PROMOTED_STRUCTURED_KEYS) {
const runtime = runtimeValues.get(key)
if (!runtime || runtime.multiline) {
continue
@@ -185,6 +268,14 @@ function promoteCodexRuntimeSettingsToSystemUnsafe(homes: CodexSettingsPromotion
// Orca mirrored this value and nothing touched it since — not a change.
continue
}
changedRuntimeValues.set(key, runtime.raw)
}
if (changedRuntimeValues.size === 0) {
return
}
const systemValues = readPromotedSettingValues(systemTomlPath)
const updates = new Map<string, string>()
for (const [key, runtimeRaw] of changedRuntimeValues) {
const system = systemValues.get(key)
if (system?.multiline) {
continue
@@ -193,7 +284,7 @@ function promoteCodexRuntimeSettingsToSystemUnsafe(homes: CodexSettingsPromotion
if (system?.raw !== baseline.get(key)) {
continue
}
updates.set(key, runtime.raw)
updates.set(key, runtimeRaw)
}
if (updates.size === 0) {
return
@@ -205,7 +296,10 @@ function promoteCodexRuntimeSettingsToSystemUnsafe(homes: CodexSettingsPromotion
mkdirSync(dirname(writeTarget.path), { recursive: true, mode: 0o700 })
const targetExists = existsSync(writeTarget.path)
const systemContent = targetExists ? readFileSync(writeTarget.path, 'utf-8') : ''
const nextContent = upsertTopLevelSettingsInContent(systemContent, updates)
const nextContent = upsertPromotedSettingsInContent(systemContent, updates)
if (nextContent === systemContent) {
return
}
if (targetExists && parseWslUncPath(writeTarget.path)) {
// Why: \\wsl$ 9P symlink metadata is unreliable; write through the existing file to preserve the WSL-side inode.
writeFileSync(writeTarget.path, nextContent, 'utf-8')
@@ -252,55 +346,3 @@ function resolveDanglingSymlinkTarget(linkPath: string): string {
// Why: replacing any link in a cycle would destroy dotfile-manager state; abort instead.
throw new Error(`Codex config symlink cycle at ${linkPath}`)
}
export function upsertTopLevelSettingsInContent(
content: string,
updates: Map<string, string>
): string {
const lines = content.split('\n')
let state = createTomlLineScanState()
let preambleEnd = lines.length
const keyLineIndexes = new Map<string, number>()
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index] ?? ''
if (isTomlStructuralLine(state)) {
if (getTomlTableHeader(line)) {
preambleEnd = index
break
}
const match = /^[ \t]*([A-Za-z0-9_-]+)[ \t]*=/.exec(line)
if (match?.[1] && updates.has(match[1])) {
keyLineIndexes.set(match[1], index)
}
}
state = updateTomlLineScanState(state, line)
}
// Why: match the file's existing EOL (CRLF split leaves a trailing \r) so a Windows config doesn't go mixed-EOL.
const usesCrlf = content.includes('\r\n')
const insertions: string[] = []
for (const [key, raw] of updates) {
const existingIndex = keyLineIndexes.get(key)
const rendered = `${key} = ${raw}`
if (existingIndex !== undefined) {
lines[existingIndex] = lines[existingIndex]?.endsWith('\r') ? `${rendered}\r` : rendered
} else {
insertions.push(usesCrlf ? `${rendered}\r` : rendered)
}
}
if (insertions.length > 0) {
let insertAt = preambleEnd
while (insertAt > 0 && (lines[insertAt - 1] ?? '').trim() === '') {
insertAt -= 1
}
if (insertAt === preambleEnd && preambleEnd < lines.length) {
insertions.push(usesCrlf ? '\r' : '')
}
lines.splice(insertAt, 0, ...insertions)
}
const result = lines.join('\n')
if (result.endsWith('\n') || result.length === 0) {
return result
}
return result.endsWith('\r') ? `${result}\n` : `${result}${usesCrlf ? '\r\n' : '\n'}`
}
+67
View File
@@ -0,0 +1,67 @@
import { parseTomlSingleLineStringValue } from './config-toml-line-scan'
export type ParsedTomlKeyPath = {
segments: string[]
end: number
}
export type ParsedTomlTableHeaderPath = ParsedTomlKeyPath & {
isArray: boolean
}
export function parseTomlTableHeaderPath(header: string): ParsedTomlTableHeaderPath | null {
const trimmed = header.trim()
let source: string
let isArray: boolean
if (trimmed.startsWith('[[')) {
if (!trimmed.endsWith(']]')) {
return null
}
source = trimmed.slice(2, -2)
isArray = true
} else {
if (!trimmed.startsWith('[') || !trimmed.endsWith(']') || trimmed.endsWith(']]')) {
return null
}
source = trimmed.slice(1, -1)
isArray = false
}
const parsed = parseTomlKeyPath(source)
if (!parsed || parsed.end !== source.length) {
return null
}
return { ...parsed, isArray }
}
export function parseTomlKeyPath(source: string, offset = 0): ParsedTomlKeyPath | null {
const segments: string[] = []
let index = skipTomlKeyWhitespace(source, offset)
while (index < source.length) {
const quoted = parseTomlSingleLineStringValue(source, index)
if (quoted) {
segments.push(quoted.value)
index = quoted.end
} else {
const bare = /^[A-Za-z0-9_-]+/.exec(source.slice(index))
if (!bare) {
return null
}
segments.push(bare[0])
index += bare[0].length
}
index = skipTomlKeyWhitespace(source, index)
if (source[index] !== '.') {
return { segments, end: index }
}
index = skipTomlKeyWhitespace(source, index + 1)
}
return null
}
function skipTomlKeyWhitespace(source: string, offset: number): number {
let index = offset
while (source[index] === ' ' || source[index] === '\t') {
index += 1
}
return index
}
+4 -2
View File
@@ -916,7 +916,8 @@ function installManagedHooksIntoWslRuntime(
tomlPath: plan.tomlPath,
managedCommand: command,
managedEntries: trustEntries,
host: { kind: 'wsl', distro: plan.wslDistro, linuxRuntimeHome: plan.linuxRuntimeHome }
host: { kind: 'wsl', distro: plan.wslDistro, linuxRuntimeHome: plan.linuxRuntimeHome },
telemetryLane: 'managed'
})
if (grant.lane === 'fallback') {
// Why: WSL runtime homes may carry user hook approvals we did not rebuild
@@ -1353,7 +1354,8 @@ export class CodexHookService {
tomlPath,
managedCommand: command,
managedEntries: managedTrustEntries,
host: { kind: 'native' }
host: { kind: 'native' },
telemetryLane: 'managed'
})
if (grant.lane === 'rpc') {
recentGrantEntries = grant.entries
@@ -3,6 +3,7 @@ import { spawn } from 'node:child_process'
import { AndroidEmulatorBackend } from './android-emulator-backend'
import type { AndroidCommandResult, AndroidCommandRunner } from '../android/android-command-runner'
import type { AndroidSdkPaths } from '../android/android-sdk-discovery'
import type { EmulatorBackend } from './emulator-backend'
// The AVD boot spawns the emulator detached (not via the command runner).
vi.mock('node:child_process', async (importOriginal) => {
@@ -219,6 +220,26 @@ describe('AndroidEmulatorBackend', () => {
expect(tree.children[0]).toMatchObject({ text: 'Hi' })
})
it('ignores the ios ax endpoint argument and still dumps via adb', async () => {
runner.mockImplementation(async (binary: string, args: readonly string[]) => {
const a = args.join(' ')
if (binary === SDK.adb && a === 'devices -l') {
return ok(RUNNING_ADB)
}
if (binary === SDK.adb && a === '-s emulator-5554 shell cat /sdcard/window_dump.xml') {
return ok('<hierarchy><node text="Hi"/></hierarchy>')
}
return ok('')
})
// The widened EmulatorBackend.accessibilityTree(deviceId, axUrl) hands Android an
// iOS-only ax URL through the interface; Android must drop it and dump via adb.
const iface: EmulatorBackend = backend(runner)
const tree = (await iface.accessibilityTree!('emulator-5554', 'http://127.0.0.1:3100/ax')) as {
children: { text?: string }[]
}
expect(tree.children[0]).toMatchObject({ text: 'Hi' })
})
it('boots a shutdown AVD and waits for the new booted serial', async () => {
let bootStarted = false
vi.mocked(spawn).mockImplementation(() => {
@@ -80,8 +80,8 @@ export type EmulatorBackend = {
rotate(deviceId: string, orientation: string): Promise<void>
exec(deviceId: string, command: string): Promise<unknown>
// Capability-gated verbs (Android today). The router checks `capabilities`
// before calling these and rejects unsupported backends with emulator_unsupported.
// Capability-gated verbs. The router checks `capabilities` before calling
// these and rejects unsupported backends with emulator_unsupported.
installApp?(deviceId: string, apkPath: string, options?: { reinstall?: boolean }): Promise<void>
launchApp?(deviceId: string, packageName: string, activity?: string): Promise<void>
setPermission?(
@@ -90,7 +90,9 @@ export type EmulatorBackend = {
packageName: string,
permission?: string
): Promise<void>
accessibilityTree?(deviceId: string): Promise<unknown>
// axUrl is the session's serve-sim /ax endpoint from the registry; Android
// dumps via adb from the device serial and ignores it.
accessibilityTree?(deviceId: string, axUrl: string | null): Promise<unknown>
logcat?(
deviceId: string,
options?: { lines?: number; filters?: readonly string[] }
@@ -83,7 +83,7 @@ describe('IosEmulatorBackend', () => {
parseServeSimDetachedSessionMock.mockReset()
})
it('declares ios kind, mjpeg codec, and no explicit-verb capabilities', () => {
it('declares ios kind, mjpeg codec, and only the ax explicit-verb capability', () => {
const backend = new IosEmulatorBackend()
expect(backend.kind).toBe('ios')
expect(backend.streamCodec).toBe('mjpeg')
@@ -91,11 +91,23 @@ describe('IosEmulatorBackend', () => {
install: false,
launch: false,
permissions: false,
accessibilityTree: false,
accessibilityTree: true,
logcat: false
})
})
it('fetches the accessibility tree from the session ax endpoint and rejects without one', async () => {
const fetchAccessibilityTree = vi.fn(async () => ({ elements: [] }))
const backend = new IosEmulatorBackend({ fetchAccessibilityTree })
await expect(
backend.accessibilityTree('device-1', 'http://127.0.0.1:3100/ax')
).resolves.toEqual({ elements: [] })
expect(fetchAccessibilityTree).toHaveBeenCalledWith('http://127.0.0.1:3100/ax')
await expect(backend.accessibilityTree('device-1', null)).rejects.toMatchObject({
code: 'emulator_no_active'
})
})
it('taps via serve-sim with the resolved device', async () => {
const backend = new IosEmulatorBackend()
await backend.tap('iPhone 16 Pro', 0.5, 0.7)
@@ -23,6 +23,7 @@ import {
import type { EmulatorBridgeOptions } from '../emulator-bridge-types'
import { sendEmulatorGestureSequence, type EmulatorGesturePoint } from '../emulator-gesture-sender'
import { parseServeSimDetachedSession } from '../serve-sim-detached-session'
import { fetchServeSimAccessibilityTree, type FetchAccessibilityTree } from '../serve-sim-ax-tree'
import { hideNativeSimulatorApp } from '../simulator-app-visibility'
import type {
BackendAvailability,
@@ -37,20 +38,23 @@ import type {
export class IosEmulatorBackend implements EmulatorBackend {
readonly kind = 'ios' as const
readonly streamCodec = 'mjpeg' as const
// iOS exposes ax/permissions/etc. via `exec`; explicit verbs are Android-only for v1.
// iOS exposes install/launch/permissions/logcat via `exec`; ax has an explicit
// verb backed by the active session's serve-sim /ax endpoint.
readonly capabilities: EmulatorBackendCapabilities = {
install: false,
launch: false,
permissions: false,
accessibilityTree: false,
accessibilityTree: true,
logcat: false
}
private cachedServeSimExecutable: ServeSimExecutable | undefined
private readonly waitForEndpointReady: (endpoint: string) => Promise<boolean>
private readonly fetchAccessibilityTree: FetchAccessibilityTree
constructor(options: EmulatorBridgeOptions = {}) {
this.waitForEndpointReady = options.waitForEndpointReady ?? waitForServeSimEndpointReady
this.fetchAccessibilityTree = options.fetchAccessibilityTree ?? fetchServeSimAccessibilityTree
}
// Why: resolving the executable can materialize the serve-sim runtime (a one-time
@@ -170,6 +174,18 @@ export class IosEmulatorBackend implements EmulatorBackend {
await this.execServeSim(['rotate', orientation, '-d', udid])
}
// Mirrors gesture: the tree comes from the active session's helper endpoint,
// so without a session there is nothing to query.
async accessibilityTree(_deviceId: string, axUrl: string | null): Promise<unknown> {
if (!axUrl) {
throw new EmulatorError(
'emulator_no_active',
'No active emulator session for the accessibility tree. Start one first.'
)
}
return this.fetchAccessibilityTree(axUrl)
}
async exec(deviceId: string, command: string): Promise<unknown> {
const udid = await this.resolveDeviceId(deviceId)
const rawArgs = stripEmulatorTargetArgs(parseServeSimCommandArgs(command.trim()))
@@ -14,4 +14,5 @@ export type EmulatorSessionState = {
export type EmulatorBridgeOptions = {
waitForEndpointReady?: (endpoint: string) => Promise<boolean>
fetchAccessibilityTree?: (axUrl: string) => Promise<unknown>
}
+63 -1
View File
@@ -184,12 +184,51 @@ describe('EmulatorBridge helper ownership', () => {
it('rejects a capability the resolved backend does not support', async () => {
const bridge = new EmulatorBridge()
// device-1 resolves to the iOS backend, which advertises no explicit-verb caps.
// device-1 resolves to the iOS backend, which does not advertise install.
await expect(
bridge.runCapability('install', { device: 'device-1' }, async () => 'unused')
).rejects.toMatchObject({ code: 'emulator_unsupported' })
})
it('routes ax to the active session ax endpoint on iOS', async () => {
const fetchAccessibilityTree = vi.fn(async () => ({ elements: [] }))
const bridge = new EmulatorBridge({ fetchAccessibilityTree })
bridge.registerActiveEmulator('wt-1', {
...session('device-1'),
axUrl: 'http://127.0.0.1:3100/device-1/ax'
})
await expect(bridge.accessibilityTree({ worktreeId: 'wt-1' })).resolves.toEqual({
elements: []
})
expect(fetchAccessibilityTree).toHaveBeenCalledWith('http://127.0.0.1:3100/device-1/ax')
})
it('derives the ax endpoint for sessions registered without axUrl', async () => {
const fetchAccessibilityTree = vi.fn(async () => ({ elements: [] }))
const bridge = new EmulatorBridge({ fetchAccessibilityTree })
// e.g. renderer-supplied session info that predates ax derivation.
bridge.registerActiveEmulator('wt-1', {
...session('device-1'),
streamUrl: 'http://127.0.0.1:3100/stream.mjpeg'
})
await expect(bridge.accessibilityTree({ worktreeId: 'wt-1' })).resolves.toEqual({
elements: []
})
expect(fetchAccessibilityTree).toHaveBeenCalledWith('http://127.0.0.1:3100/ax')
})
it('rejects ax when the session has no ax endpoint and none can be derived', async () => {
const bridge = new EmulatorBridge()
// session() streamUrl has no mjpeg suffix, so no /ax endpoint can be inferred.
bridge.registerActiveEmulator('wt-1', session('device-1'))
await expect(bridge.accessibilityTree({ worktreeId: 'wt-1' })).rejects.toMatchObject({
code: 'emulator_no_active'
})
})
it('kills the helper and shuts down the selected simulator', async () => {
const bridge = new EmulatorBridge()
bridge.registerActiveEmulator('wt-1', session('device-1'), { managed: true })
@@ -381,6 +420,29 @@ describe('RuntimeEmulatorCommands attach lifecycle', () => {
})
})
it('routes emulatorAx through the bridge to the active session ax endpoint', async () => {
const fetchAccessibilityTree = vi.fn(async () => ({ elements: [{ label: 'Login' }] }))
const bridge = new EmulatorBridge({ fetchAccessibilityTree })
bridge.registerActiveEmulator('wt-1', {
...session('device-1'),
axUrl: 'http://127.0.0.1:3100/device-1/ax'
})
const commands = new RuntimeEmulatorCommands({
getEmulatorBridge: () => bridge,
resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1' })),
getAuthoritativeWindow: () => ({ webContents: { send: vi.fn() } }) as never,
getSettings: () => ({
mobileEmulatorEnabled: true,
mobileEmulatorDefaultDeviceUdid: null
})
})
await expect(commands.emulatorAx({ worktree: 'wt-1' })).resolves.toEqual({
elements: [{ label: 'Login' }]
})
expect(fetchAccessibilityTree).toHaveBeenCalledWith('http://127.0.0.1:3100/device-1/ax')
})
it('rejects attach when mobile emulator is disabled', async () => {
const bridge = new EmulatorBridge()
const commands = new RuntimeEmulatorCommands({
+12
View File
@@ -5,6 +5,7 @@ import type { SimulatorDevice } from './simctl-simulator-devices'
import type { EmulatorBridgeOptions } from './emulator-bridge-types'
import type { EmulatorGesturePoint } from './emulator-gesture-sender'
import { EmulatorSessionRegistry } from './emulator-session-registry'
import { deriveServeSimAxUrl } from './serve-sim-detached-session'
import { IosEmulatorBackend } from './backends/ios-emulator-backend'
import { AndroidEmulatorBackend } from './backends/android-emulator-backend'
import type {
@@ -199,6 +200,17 @@ export class EmulatorBridge {
return backend.exec(device, command)
}
async accessibilityTree(opts?: EmulatorTargetOpts): Promise<unknown> {
return this.runCapability('accessibilityTree', opts, async (backend, device) => {
const udid = await backend.resolveDeviceId(device)
const session = this.sessionRegistry.getSession(udid)
// Fallback heals sessions registered without axUrl (e.g. renderer-supplied
// info that predates ax derivation); Android backends ignore the argument.
const axUrl = session?.axUrl ?? deriveServeSimAxUrl(session?.streamUrl) ?? null
return backend.accessibilityTree!(udid, axUrl)
})
}
// Runs a capability-gated verb against the resolved target, rejecting backends
// that do not advertise the capability (e.g. install/logcat on iOS).
async runCapability<T>(
+175
View File
@@ -0,0 +1,175 @@
import { describe, expect, it, vi } from 'vitest'
import { fetchServeSimAccessibilityTree } from './serve-sim-ax-tree'
function sseResponse(chunks: string[], init: { status?: number } = {}): Response {
const encoder = new TextEncoder()
const body = new ReadableStream<Uint8Array>({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(encoder.encode(chunk))
}
controller.close()
}
})
return new Response(body, {
status: init.status ?? 200,
headers: { 'Content-Type': 'text/event-stream' }
})
}
describe('fetchServeSimAccessibilityTree', () => {
it('returns the first data event and skips the SSE comment preamble', async () => {
const tree = { screen: { width: 393, height: 852 }, elements: [{ label: 'Login' }], errors: [] }
const fetchImpl = vi.fn(async () => sseResponse([':\n\n', `data: ${JSON.stringify(tree)}\n\n`]))
await expect(
fetchServeSimAccessibilityTree('http://127.0.0.1:3100/ax', { fetchImpl })
).resolves.toEqual(tree)
})
it('prefers a live update over the replayed cached tree', async () => {
// The helper replays the cached tree to new clients, then polls the device
// and writes a fresh event only if the tree changed — the fresh one must win.
const stale = JSON.stringify({ elements: [{ label: 'Old' }] })
const fresh = JSON.stringify({ elements: [{ label: 'New' }] })
const fetchImpl = vi.fn(async () =>
sseResponse([':\n\n', `data: ${stale}\n\n`, `data: ${fresh}\n\n`])
)
await expect(
fetchServeSimAccessibilityTree('http://127.0.0.1:3100/ax', { fetchImpl })
).resolves.toEqual({ elements: [{ label: 'New' }] })
})
it('settles on the first event when no follow-up arrives within the window', async () => {
const encoder = new TextEncoder()
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode(':\n\n'))
controller.enqueue(encoder.encode('data: {"elements":[]}\n\n'))
// Never closes — an unchanged tree writes nothing, so the settle window must end the read.
}
})
const fetchImpl = vi.fn(async () => new Response(body, { status: 200 }))
await expect(
fetchServeSimAccessibilityTree('http://127.0.0.1:3100/ax', { fetchImpl, settleMs: 30 })
).resolves.toEqual({ elements: [] })
})
it('handles a data event split across stream chunks', async () => {
const payload = JSON.stringify({ elements: [] })
const mid = Math.floor(payload.length / 2)
const fetchImpl = vi.fn(async () =>
sseResponse([':\n\n', `data: ${payload.slice(0, mid)}`, `${payload.slice(mid)}\n\n`])
)
await expect(
fetchServeSimAccessibilityTree('http://127.0.0.1:3100/ax', { fetchImpl })
).resolves.toEqual({ elements: [] })
})
it('maps a non-200 response to an actionable stale-helper error', async () => {
const fetchImpl = vi.fn(async () => sseResponse([], { status: 404 }))
await expect(
fetchServeSimAccessibilityTree('http://127.0.0.1:3100/ax', { fetchImpl })
).rejects.toMatchObject({
code: 'emulator_error',
message: expect.stringContaining('Restart the emulator session')
})
})
it('maps a connection failure to emulator_no_active', async () => {
const fetchImpl = vi.fn(async () => {
throw new TypeError('fetch failed')
})
await expect(
fetchServeSimAccessibilityTree('http://127.0.0.1:3100/ax', { fetchImpl })
).rejects.toMatchObject({ code: 'emulator_no_active' })
})
it('fails when the stream ends without a data event', async () => {
const fetchImpl = vi.fn(async () => sseResponse([':\n\n', ':\n\n']))
await expect(
fetchServeSimAccessibilityTree('http://127.0.0.1:3100/ax', { fetchImpl })
).rejects.toMatchObject({ code: 'emulator_helper_failed' })
})
it('times out when no data event arrives', async () => {
const fetchImpl = vi.fn(
async () =>
new Response(
new ReadableStream<Uint8Array>({
start() {
// Never emits and never closes; the timeout abort must win.
}
}),
{ status: 200 }
)
)
await expect(
fetchServeSimAccessibilityTree('http://127.0.0.1:3100/ax', { fetchImpl, timeoutMs: 50 })
).rejects.toMatchObject({ code: 'emulator_error', message: expect.stringMatching(/Timed out/) })
})
// Pull-based: controller.error() discards still-queued chunks, so the stream
// must hand out each chunk on its own read before erroring on a later pull.
function droppingSseResponse(chunks: string[]): Response {
const encoder = new TextEncoder()
let step = 0
const body = new ReadableStream<Uint8Array>({
pull(controller) {
if (step < chunks.length) {
controller.enqueue(encoder.encode(chunks[step]))
step += 1
} else {
controller.error(new TypeError('terminated'))
}
}
})
return new Response(body, { status: 200 })
}
it('returns the last captured tree when the stream drops uncleanly mid-read', async () => {
const tree = { elements: [{ label: 'Captured' }] }
const fetchImpl = vi.fn(async () =>
droppingSseResponse([':\n\n', `data: ${JSON.stringify(tree)}\n\n`])
)
// settleMs is long so the drop, not the settle window, ends the read.
await expect(
fetchServeSimAccessibilityTree('http://127.0.0.1:3100/ax', { fetchImpl, settleMs: 5000 })
).resolves.toEqual(tree)
})
it('maps an unclean mid-stream drop with no captured tree to emulator_helper_failed', async () => {
const fetchImpl = vi.fn(async () => droppingSseResponse([':\n\n']))
await expect(
fetchServeSimAccessibilityTree('http://127.0.0.1:3100/ax', { fetchImpl })
).rejects.toMatchObject({ code: 'emulator_helper_failed' })
})
it('returns the captured tree when the timeout aborts mid-settle', async () => {
const encoder = new TextEncoder()
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode(':\n\n'))
controller.enqueue(encoder.encode('data: {"elements":[{"label":"Only"}]}\n\n'))
// Never closes; the hard timeout must abort while the settle window is open.
}
})
const fetchImpl = vi.fn(async () => new Response(body, { status: 200 }))
await expect(
fetchServeSimAccessibilityTree('http://127.0.0.1:3100/ax', {
fetchImpl,
settleMs: 5000,
timeoutMs: 40
})
).resolves.toEqual({ elements: [{ label: 'Only' }] })
})
it('rejects an unparseable ax event with emulator_helper_failed', async () => {
const fetchImpl = vi.fn(async () => sseResponse([':\n\n', 'data: not-json\n\n']))
await expect(
fetchServeSimAccessibilityTree('http://127.0.0.1:3100/ax', { fetchImpl })
).rejects.toMatchObject({
code: 'emulator_helper_failed',
message: expect.stringContaining('unparseable')
})
})
})
+167
View File
@@ -0,0 +1,167 @@
import { EmulatorError } from './emulator-errors'
const DEFAULT_TIMEOUT_MS = 15_000
// Why: /ax replays the last *cached* tree to every new client before polling the
// device, and polling pauses while no client is connected — so the first event
// can be stale. A fresh event is only written if the tree changed, so we linger
// briefly after the first event and return the last one seen: a follow-up means
// the cache was stale; silence means the cache still matches the device.
const DEFAULT_SETTLE_MS = 800
export type FetchAccessibilityTree = (axUrl: string) => Promise<unknown>
export async function fetchServeSimAccessibilityTree(
axUrl: string,
options: { timeoutMs?: number; settleMs?: number; fetchImpl?: typeof fetch } = {}
): Promise<unknown> {
const fetchImpl = options.fetchImpl ?? fetch
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? DEFAULT_TIMEOUT_MS)
try {
let response: Response
try {
response = await fetchImpl(axUrl, {
headers: { Accept: 'text/event-stream' },
signal: controller.signal
})
} catch {
if (controller.signal.aborted) {
throw timeoutError(axUrl)
}
throw new EmulatorError(
'emulator_no_active',
`serve-sim accessibility endpoint is unreachable at ${axUrl}. Start the emulator session first.`
)
}
if (!response.ok || !response.body) {
// Why: a helper spawned by an older serve-sim build may predate /ax.
throw new EmulatorError(
'emulator_error',
`serve-sim /ax endpoint returned HTTP ${response.status}. Restart the emulator session to refresh the helper.`
)
}
const payload = await readSettledSseDataEvent(
response.body,
controller,
options.settleMs ?? DEFAULT_SETTLE_MS
)
if (payload === null) {
throw new EmulatorError(
'emulator_helper_failed',
'serve-sim /ax stream ended without an accessibility tree event.'
)
}
try {
return JSON.parse(payload)
} catch {
throw new EmulatorError(
'emulator_helper_failed',
'serve-sim /ax returned an unparseable accessibility tree event.'
)
}
} finally {
clearTimeout(timeout)
controller.abort()
}
}
function timeoutError(axUrl: string): EmulatorError {
return new EmulatorError(
'emulator_error',
`Timed out waiting for the accessibility tree from ${axUrl}.`
)
}
const SETTLED = Symbol('settled')
// Returns the payload of the last data event seen up to settleMs after the
// first one (see DEFAULT_SETTLE_MS), or null if the stream ends with none.
async function readSettledSseDataEvent(
body: ReadableStream<Uint8Array>,
controller: AbortController,
settleMs: number
): Promise<string | null> {
const reader = body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let latest: string | null = null
let settled: Promise<typeof SETTLED> | null = null
let settleTimer: NodeJS.Timeout | undefined
// Why: abort must also win over a body that never emits — reads on a stalled
// stream do not observe the fetch signal on their own.
const aborted = new Promise<never>((_, reject) => {
const rejectAborted = (): void => reject(new Error('aborted'))
if (controller.signal.aborted) {
rejectAborted()
return
}
controller.signal.addEventListener('abort', rejectAborted, { once: true })
})
aborted.catch(() => {})
try {
for (;;) {
const result = await Promise.race(
settled ? [reader.read(), aborted, settled] : [reader.read(), aborted]
)
if (result === SETTLED) {
return latest
}
const { done, value } = result
if (value) {
buffer += decoder.decode(value, { stream: true })
const payload = extractLastDataPayload(buffer)
if (payload !== null) {
latest = payload
settled ??= new Promise((resolve) => {
settleTimer = setTimeout(() => resolve(SETTLED), settleMs)
})
}
// Keep only the trailing partial event; complete ones are consumed.
const lastBoundary = buffer.lastIndexOf('\n\n')
if (lastBoundary !== -1) {
buffer = buffer.slice(lastBoundary + 2)
}
}
if (done) {
return latest
}
}
} catch {
// A tree was captured before the stream aborted or dropped — return it rather
// than discarding a valid result on an unclean close (helper crash / truncated
// chunked stream), which also keeps a raw non-EmulatorError off the caller path.
if (latest !== null) {
return latest
}
if (controller.signal.aborted) {
throw new EmulatorError(
'emulator_error',
'Timed out waiting for the accessibility tree event from serve-sim.'
)
}
// Map an unclean mid-stream failure to the EmulatorError contract callers expect.
throw new EmulatorError(
'emulator_helper_failed',
'serve-sim /ax stream ended unexpectedly. Restart the emulator session and retry.'
)
} finally {
if (settleTimer) {
clearTimeout(settleTimer)
}
reader.releaseLock()
}
}
function extractLastDataPayload(buffer: string): string | null {
let payload: string | null = null
for (const event of buffer.split('\n\n').slice(0, -1)) {
const dataLines = event
.split('\n')
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).replace(/^ /, ''))
if (dataLines.length > 0) {
payload = dataLines.join('\n')
}
}
return payload
}
@@ -1,5 +1,23 @@
import { describe, expect, it } from 'vitest'
import { parseServeSimDetachedSession } from './serve-sim-detached-session'
import { deriveServeSimAxUrl, parseServeSimDetachedSession } from './serve-sim-detached-session'
describe('deriveServeSimAxUrl', () => {
it('swaps the mjpeg suffix for /ax, preserving the path prefix', () => {
expect(deriveServeSimAxUrl('http://127.0.0.1:3100/stream.mjpeg')).toBe(
'http://127.0.0.1:3100/ax'
)
expect(deriveServeSimAxUrl('http://127.0.0.1:3100/device-1/stream.mjpeg')).toBe(
'http://127.0.0.1:3100/device-1/ax'
)
})
it('does not derive from a non-mjpeg, query-tailed, or missing stream url', () => {
// A query string defeats the suffix match, so no /ax is fabricated.
expect(deriveServeSimAxUrl('http://127.0.0.1:3100/stream.mjpeg?token=x')).toBeUndefined()
expect(deriveServeSimAxUrl('http://127.0.0.1:3100/custom-stream')).toBeUndefined()
expect(deriveServeSimAxUrl(undefined)).toBeUndefined()
})
})
describe('parseServeSimDetachedSession', () => {
it('uses serve-sim streamUrl when present', () => {
@@ -31,4 +49,44 @@ describe('parseServeSimDetachedSession', () => {
expect(info.streamUrl).toBe('http://127.0.0.1:3100/stream.mjpeg')
})
it('derives the ax endpoint when serve-sim omits axUrl', () => {
const info = parseServeSimDetachedSession(
{
device: 'device-1',
streamUrl: 'http://127.0.0.1:3100/stream.mjpeg',
wsUrl: 'ws://127.0.0.1:3100/ws'
},
'device-1'
)
expect(info.axUrl).toBe('http://127.0.0.1:3100/ax')
})
it('does not fabricate an ax endpoint from a non-mjpeg stream url', () => {
const info = parseServeSimDetachedSession(
{
device: 'device-1',
streamUrl: 'http://127.0.0.1:3100/custom-stream',
wsUrl: 'ws://127.0.0.1:3100/ws'
},
'device-1'
)
expect(info.axUrl).toBeUndefined()
})
it('keeps an explicit axUrl when serve-sim provides one', () => {
const info = parseServeSimDetachedSession(
{
device: 'device-1',
streamUrl: 'http://127.0.0.1:3100/stream.mjpeg',
wsUrl: 'ws://127.0.0.1:3100/ws',
axUrl: 'http://127.0.0.1:3100/custom-ax'
},
'device-1'
)
expect(info.axUrl).toBe('http://127.0.0.1:3100/custom-ax')
})
})
@@ -8,6 +8,15 @@ function streamUrlFromServeSimUrl(url: string): string {
return url.endsWith('/stream.mjpeg') ? url : `${url.replace(/\/$/, '')}/stream.mjpeg`
}
// Why: serve-sim serves /ax on the helper but omits it from --detach output.
// Guarded on the mjpeg suffix so a foreign stream URL never masquerades as an
// AX endpoint. Also used by the bridge to heal sessions registered without axUrl.
export function deriveServeSimAxUrl(streamUrl: string | undefined): string | undefined {
return streamUrl?.endsWith('/stream.mjpeg')
? streamUrl.replace(/\/stream\.mjpeg$/, '/ax')
: undefined
}
export function parseServeSimDetachedSession(raw: unknown, udid: string): EmulatorSessionInfo {
if (!raw || typeof raw !== 'object') {
throw new EmulatorError('emulator_helper_failed', 'serve-sim did not return stream endpoints.')
@@ -24,7 +33,7 @@ export function parseServeSimDetachedSession(raw: unknown, udid: string): Emulat
deviceUdid: typeof json.device === 'string' ? json.device : udid,
wsUrl: wsUrl ?? '',
streamUrl: streamUrl ?? '',
axUrl: typeof json.axUrl === 'string' ? json.axUrl : undefined
axUrl: typeof json.axUrl === 'string' ? json.axUrl : deriveServeSimAxUrl(streamUrl)
}
if (!info.streamUrl || !info.wsUrl) {
throw new EmulatorError('emulator_helper_failed', 'serve-sim did not return stream endpoints.')
+71 -1
View File
@@ -9,7 +9,10 @@ vi.mock('./codex-cli/command', () => ({
}))
import { getCmdExePath } from './win32-utils'
import { resolveExternalEditorLaunchSpec } from './external-editor-launch'
import {
resolveExternalEditorLaunchSpec,
resolveVsCodeRemoteSshLaunchSpec
} from './external-editor-launch'
describe('resolveExternalEditorLaunchSpec', () => {
beforeEach(() => {
@@ -255,3 +258,70 @@ describe('resolveExternalEditorLaunchSpec', () => {
})
})
})
describe('resolveVsCodeRemoteSshLaunchSpec', () => {
beforeEach(() => {
resolveCliCommandMock.mockReset()
resolveCliCommandMock.mockImplementation((command: string) => command)
})
it.each(['code', 'code-insiders'])('builds exact Remote-SSH arguments for %s', (command) => {
expect(
resolveVsCodeRemoteSshLaunchSpec(command, '/home/Ada Lovelace/project', 'builder', {
platform: 'linux'
})
).toEqual({
kind: 'executable',
hideWindowsConsole: true,
spawnCmd: command,
spawnArgs: ['--remote', 'ssh-remote+builder', '/home/Ada Lovelace/project']
})
})
it.each([
'C:\\Program Files\\Microsoft VS Code\\Code.exe',
'C:\\Program Files\\Microsoft VS Code Insiders\\Code - Insiders.exe',
'C:\\Tools\\code.cmd',
'C:\\Tools\\code-insiders.bat'
])('supports the direct Windows launcher %s', (command) => {
expect(
resolveVsCodeRemoteSshLaunchSpec(command, 'C:\\Users\\Ada Lovelace\\project', 'builder', {
platform: 'win32'
})?.spawnArgs
).toEqual(['--remote', 'ssh-remote+builder', 'C:\\Users\\Ada Lovelace\\project'])
})
it('supports an existing direct POSIX launcher path containing spaces', () => {
const command = '/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code'
expect(
resolveVsCodeRemoteSshLaunchSpec(command, '/srv/project', 'builder', {
platform: 'darwin',
fileExists: (candidate) => candidate === command
})
).toMatchObject({
kind: 'executable',
spawnCmd: command,
spawnArgs: ['--remote', 'ssh-remote+builder', '/srv/project']
})
})
it('recognizes a simple CLI name resolved to a Windows shim', () => {
resolveCliCommandMock.mockReturnValueOnce('C:\\Tools\\Code.CMD')
expect(
resolveVsCodeRemoteSshLaunchSpec('code', '/srv/project', 'builder', {
platform: 'win32'
})
).toMatchObject({ spawnCmd: 'C:\\Tools\\Code.CMD' })
})
it.each(['cursor', 'zed', 'code --reuse-window', 'open -a "Visual Studio Code"'])(
'rejects unsupported and compound SSH commands: %s',
(command) => {
expect(
resolveVsCodeRemoteSshLaunchSpec(command, '/srv/project', 'builder', {
platform: 'linux'
})
).toBeNull()
}
)
})
+33 -2
View File
@@ -1,12 +1,12 @@
import { existsSync } from 'node:fs'
import { basename, posix, win32 } from 'node:path'
import { parseWslUncPath } from '../shared/wsl-paths'
import { isVsCodeLauncherExecutable } from '../shared/vscode-remote-ssh-launcher'
import { resolveCliCommand } from './codex-cli/command'
import { getCmdExePath } from './win32-utils'
export const EXTERNAL_EDITOR_CLI_COMMAND = 'code'
const WINDOWS_CONSOLE_EDITORS = new Set(['nvim', 'vim'])
const VSCODE_REMOTE_EDITORS = new Set(['code', 'code-insiders', 'code - insiders'])
export type ExternalEditorLaunchSpec =
| {
@@ -119,7 +119,7 @@ function buildExecutableArgs(
// workbench. A new window keeps "Open in Cursor" scoped to this worktree.
return ['--new-window', pathValue]
}
if (platform === 'win32' && VSCODE_REMOTE_EDITORS.has(launcherBaseName)) {
if (platform === 'win32' && isVsCodeLauncherExecutable(editorCommand)) {
const wslPath = parseWslUncPath(pathValue)
if (wslPath) {
// Why: VS Code otherwise treats a WSL UNC path as a local Windows folder.
@@ -186,3 +186,34 @@ export function resolveExternalEditorLaunchSpec(
spawnArgs: buildExecutableArgs(editorCommand, pathValue, platform)
}
}
export function resolveVsCodeRemoteSshLaunchSpec(
command: string | undefined,
pathValue: string,
authority: string,
options: { platform?: NodeJS.Platform; fileExists?: (path: string) => boolean } = {}
): ExternalEditorLaunchSpec | null {
const platform = options.platform ?? process.platform
const fileExists = options.fileExists ?? existsSync
const trimmed = command?.trim() || EXTERNAL_EDITOR_CLI_COMMAND
let editorCommand: string
if (isDirectExecutablePath(trimmed, platform, fileExists)) {
editorCommand = stripMatchingQuotes(trimmed)
} else {
if (isCompoundShellCommand(trimmed)) {
return null
}
editorCommand = resolveCliCommand(trimmed, { platform })
}
if (!isVsCodeLauncherExecutable(editorCommand)) {
return null
}
return {
kind: 'executable',
hideWindowsConsole: true,
spawnCmd: editorCommand,
spawnArgs: ['--remote', `ssh-remote+${authority}`, pathValue]
}
}
+6 -3
View File
@@ -150,7 +150,7 @@ import {
ensureRealHomeCodexHookState,
isRealHomeCodexHookLaneUsable
} from './codex/codex-real-home-hook-install'
import { setCodexTrustGrantTelemetry } from './codex/codex-hook-trust-grant'
import { setCodexTrustGrantTelemetry } from './codex/codex-trust-grant-telemetry'
import { startCodexSessionBackfillInBackground } from './codex/codex-session-backfill'
import { startCodexSessionIndexHealInBackground } from './codex/codex-session-index-heal'
import { createCodexSessionMigrationScheduler } from './codex/codex-session-migration-scheduler'
@@ -1852,11 +1852,14 @@ app.whenReady().then(async () => {
// Why: the trust-grant module is bundled into plain-node CLI entries where
// the telemetry client cannot load, so the tracker is injected here instead
// of imported there.
setCodexTrustGrantTelemetry(({ outcome, hostKind, reason }) => {
setCodexTrustGrantTelemetry(({ outcome, hostKind, lane, reason, errorClass, verifyClass }) => {
track('codex_trust_grant', {
outcome,
host_kind: hostKind,
...(reason !== undefined ? { fallback_reason: reason } : {})
lane,
...(reason !== undefined ? { fallback_reason: reason } : {}),
...(errorClass !== undefined ? { error_class: errorClass } : {}),
...(verifyClass !== undefined ? { verify_class: verifyClass } : {})
})
})
// Why: the error-tracking lane (telemetry-error-tracking.md) is its own
+1 -1
View File
@@ -536,7 +536,7 @@ describe('registerCoreHandlers', () => {
expect(registerNativeChatHandlersMock).toHaveBeenCalled()
expect(registerCliHandlersMock).toHaveBeenCalled()
expect(registerPreflightHandlersMock).toHaveBeenCalled()
expect(registerShellHandlersMock).toHaveBeenCalled()
expect(registerShellHandlersMock).toHaveBeenCalledWith(store)
expect(registerClipboardHandlersMock).toHaveBeenCalledWith(store)
expect(registerUpdaterHandlersMock).toHaveBeenCalled()
expect(setTrustedBrowserRendererWebContentsIdMock).toHaveBeenCalledWith(null)
+1 -1
View File
@@ -182,7 +182,7 @@ export function registerCoreHandlers(
onBeforeSignOut: lifecycleOptions.onBeforeOrcaProfileSignOut
})
registerBrowserHandlers()
registerShellHandlers()
registerShellHandlers(store)
registerPetHandlers()
registerSessionHandlers(store)
registerUIHandlers(store)
+187 -13
View File
@@ -57,6 +57,7 @@ vi.mock('../win32-utils', () => ({
import { EXTERNAL_EDITOR_CLI_COMMAND, registerShellHandlers } from './shell'
import { resolveExternalEditorLaunchSpec } from '../external-editor-launch'
import type { SshTarget } from '../../shared/ssh-types'
function createSpawnedProcess(result: 'spawn' | 'error' = 'spawn'): {
once: ReturnType<typeof vi.fn>
@@ -78,7 +79,27 @@ function createSpawnedProcess(result: 'spawn' | 'error' = 'spawn'): {
return child
}
function createSshTarget(overrides: Partial<SshTarget> = {}): SshTarget {
return {
id: 'ssh-1',
label: 'Builder',
host: 'builder.example.com',
port: 22,
username: 'ada',
source: 'ssh-config',
configHost: 'builder',
...overrides
}
}
describe('registerShellHandlers', () => {
const settings = { activeRuntimeEnvironmentId: null as string | null }
const sshTargets = new Map<string, SshTarget>()
const store = {
getSettings: () => settings,
getSshTarget: (id: string) => sshTargets.get(id)
}
beforeEach(() => {
handleMock.mockReset()
getSpawnArgsForWindowsMock.mockReset()
@@ -88,6 +109,8 @@ describe('registerShellHandlers', () => {
showOpenDialogMock.mockReset()
spawnMock.mockReset()
statMock.mockReset()
settings.activeRuntimeEnvironmentId = null
sshTargets.clear()
openPathMock.mockResolvedValue('')
resolveCliCommandMock.mockReturnValue('editor-cli')
getSpawnArgsForWindowsMock.mockImplementation((command: string, args: string[]) => ({
@@ -99,7 +122,7 @@ describe('registerShellHandlers', () => {
})
function getHandler(channel: string): (event: unknown, ...args: unknown[]) => Promise<unknown> {
registerShellHandlers()
registerShellHandlers(store as never)
const call = handleMock.mock.calls.find((c: unknown[]) => c[0] === channel)
if (!call) {
throw new Error(`${channel} handler not registered`)
@@ -238,7 +261,7 @@ describe('registerShellHandlers', () => {
it('rejects relative paths', async () => {
const handler = getHandler('shell:openInExternalEditor')
await expect(handler({}, 'relative/workspace')).resolves.toEqual({
await expect(handler({}, { path: 'relative/workspace' })).resolves.toEqual({
ok: false,
reason: 'not-absolute'
})
@@ -252,7 +275,7 @@ describe('registerShellHandlers', () => {
const workspacePath = resolve('missing-workspace')
const handler = getHandler('shell:openInExternalEditor')
await expect(handler({}, workspacePath)).resolves.toEqual({
await expect(handler({}, { path: workspacePath })).resolves.toEqual({
ok: false,
reason: 'not-found'
})
@@ -267,7 +290,7 @@ describe('registerShellHandlers', () => {
const workspacePath = resolve('workspace')
const handler = getHandler('shell:openInExternalEditor')
await expect(handler({}, workspacePath)).resolves.toEqual({
await expect(handler({}, { path: workspacePath })).resolves.toEqual({
ok: false,
reason: 'launch-failed'
})
@@ -293,7 +316,7 @@ describe('registerShellHandlers', () => {
const workspacePath = resolve('workspace')
const handler = getHandler('shell:openInExternalEditor')
await expect(handler({}, workspacePath)).resolves.toEqual({ ok: true })
await expect(handler({}, { path: workspacePath })).resolves.toEqual({ ok: true })
expect(resolveCliCommandMock).toHaveBeenCalledWith(EXTERNAL_EDITOR_CLI_COMMAND, {
platform: process.platform
})
@@ -314,7 +337,9 @@ describe('registerShellHandlers', () => {
const workspacePath = resolve('workspace')
const handler = getHandler('shell:openInExternalEditor')
await expect(handler({}, workspacePath, 'cursor')).resolves.toEqual({ ok: true })
await expect(handler({}, { path: workspacePath, command: 'cursor' })).resolves.toEqual({
ok: true
})
expect(resolveCliCommandMock).toHaveBeenCalledWith('cursor', { platform: process.platform })
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('editor-cli', [
normalize(workspacePath)
@@ -329,7 +354,9 @@ describe('registerShellHandlers', () => {
resolveCliCommandMock.mockReturnValueOnce(codeShim)
const handler = getHandler('shell:openInExternalEditor')
await expect(handler({}, workspacePath, 'code')).resolves.toEqual({ ok: true })
await expect(handler({}, { path: workspacePath, command: 'code' })).resolves.toEqual({
ok: true
})
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(codeShim, [
'--remote',
'wsl+Ubuntu Preview',
@@ -346,7 +373,9 @@ describe('registerShellHandlers', () => {
const nvimPath = 'C:\\Program Files\\Neovim\\bin\\nvim.exe'
try {
await expect(handler({}, workspacePath, nvimPath)).resolves.toEqual({ ok: true })
await expect(handler({}, { path: workspacePath, command: nvimPath })).resolves.toEqual({
ok: true
})
expect(resolveCliCommandMock).not.toHaveBeenCalled()
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(nvimPath, [
normalize(workspacePath)
@@ -368,13 +397,17 @@ describe('registerShellHandlers', () => {
const workspacePath = resolve('workspace')
const handler = getHandler('shell:openInExternalEditor')
await expect(handler({}, workspacePath, 'cursor')).resolves.toEqual({ ok: true })
await expect(handler({}, { path: workspacePath, command: 'cursor' })).resolves.toEqual({
ok: true
})
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('/usr/local/bin/cursor', [
'--new-window',
normalize(workspacePath)
])
resolveCliCommandMock.mockReturnValueOnce('C:\\Cursor\\cursor.cmd')
await expect(handler({}, workspacePath, 'cursor')).resolves.toEqual({ ok: true })
await expect(handler({}, { path: workspacePath, command: 'cursor' })).resolves.toEqual({
ok: true
})
expect(getSpawnArgsForWindowsMock).toHaveBeenLastCalledWith('C:\\Cursor\\cursor.cmd', [
'--new-window',
normalize(workspacePath)
@@ -385,7 +418,9 @@ describe('registerShellHandlers', () => {
const workspacePath = resolve('workspace')
const handler = getHandler('shell:openInExternalEditor')
await expect(handler({}, workspacePath, ' ')).resolves.toEqual({ ok: true })
await expect(handler({}, { path: workspacePath, command: ' ' })).resolves.toEqual({
ok: true
})
expect(resolveCliCommandMock).toHaveBeenCalledWith(EXTERNAL_EDITOR_CLI_COMMAND, {
platform: process.platform
})
@@ -399,7 +434,7 @@ describe('registerShellHandlers', () => {
const workspacePath = resolve('workspace')
const handler = getHandler('shell:openInExternalEditor')
await expect(handler({}, workspacePath)).resolves.toEqual({ ok: true })
await expect(handler({}, { path: workspacePath })).resolves.toEqual({ ok: true })
expect(resolveCliCommandMock).toHaveBeenCalledWith(EXTERNAL_EDITOR_CLI_COMMAND, {
platform: process.platform
})
@@ -419,7 +454,9 @@ describe('registerShellHandlers', () => {
const handler = getHandler('shell:openInExternalEditor')
const launchSpec = resolveExternalEditorLaunchSpec('open -a "Typora"', filePath)
await expect(handler({}, filePath, 'open -a "Typora"')).resolves.toEqual({ ok: true })
await expect(handler({}, { path: filePath, command: 'open -a "Typora"' })).resolves.toEqual({
ok: true
})
expect(resolveCliCommandMock).not.toHaveBeenCalled()
expect(getSpawnArgsForWindowsMock).not.toHaveBeenCalled()
expect(launchSpec.kind).toBe('shell')
@@ -429,6 +466,143 @@ describe('registerShellHandlers', () => {
windowsHide: true
})
})
it('rejects local and SSH launches while a remote runtime is active', async () => {
settings.activeRuntimeEnvironmentId = 'runtime-1'
sshTargets.set('ssh-1', createSshTarget())
const handler = getHandler('shell:openInExternalEditor')
await expect(handler({}, { path: resolve('workspace') })).resolves.toEqual({
ok: false,
reason: 'remote-runtime-unsupported'
})
await expect(
handler({}, { path: '/srv/project', command: 'code', connectionId: 'ssh-1' })
).resolves.toEqual({ ok: false, reason: 'remote-runtime-unsupported' })
expect(statMock).not.toHaveBeenCalled()
expect(spawnMock).not.toHaveBeenCalled()
})
it('rejects missing and runtime-owned SSH targets', async () => {
const handler = getHandler('shell:openInExternalEditor')
await expect(
handler({}, { path: '/srv/project', command: 'code', connectionId: 'missing' })
).resolves.toEqual({ ok: false, reason: 'ssh-target-not-found' })
sshTargets.set(
'ssh-1',
createSshTarget({ owner: { type: 'on-demand-runtime', runtimeId: 'runtime-1' } })
)
await expect(
handler({}, { path: '/srv/project', command: 'code', connectionId: 'ssh-1' })
).resolves.toEqual({ ok: false, reason: 'remote-runtime-unsupported' })
expect(spawnMock).not.toHaveBeenCalled()
})
it('opens POSIX SSH paths through a persisted config alias without local validation', async () => {
sshTargets.set('ssh-1', createSshTarget())
resolveCliCommandMock.mockReturnValueOnce('/usr/local/bin/code')
const handler = getHandler('shell:openInExternalEditor')
const remotePath = '/home/Ada Lovelace/project'
await expect(
handler({}, { path: remotePath, command: 'code', connectionId: 'ssh-1' })
).resolves.toEqual({ ok: true })
expect(statMock).not.toHaveBeenCalled()
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('/usr/local/bin/code', [
'--remote',
'ssh-remote+builder',
remotePath
])
})
it('preserves Windows-form SSH paths and uses the manual port-22 authority', async () => {
sshTargets.set(
'ssh-1',
createSshTarget({
source: 'manual',
configHost: 'win-builder.example.com',
host: 'win-builder.example.com',
username: 'Ada'
})
)
resolveCliCommandMock.mockReturnValueOnce('C:\\Tools\\code.cmd')
const handler = getHandler('shell:openInExternalEditor')
const remotePath = 'C:\\Users\\Ada Lovelace\\project'
await expect(
handler({}, { path: remotePath, command: 'code', connectionId: 'ssh-1' })
).resolves.toEqual({ ok: true })
expect(statMock).not.toHaveBeenCalled()
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('C:\\Tools\\code.cmd', [
'--remote',
'ssh-remote+Ada@win-builder.example.com',
remotePath
])
})
it('rejects relative SSH paths before resolving or spawning a launcher', async () => {
sshTargets.set('ssh-1', createSshTarget())
const handler = getHandler('shell:openInExternalEditor')
await expect(
handler({}, { path: 'relative/project', command: 'code', connectionId: 'ssh-1' })
).resolves.toEqual({ ok: false, reason: 'not-absolute' })
expect(statMock).not.toHaveBeenCalled()
expect(resolveCliCommandMock).not.toHaveBeenCalled()
expect(spawnMock).not.toHaveBeenCalled()
})
it('returns alias recovery details for manual custom-port targets', async () => {
sshTargets.set(
'ssh-1',
createSshTarget({
source: 'manual',
configHost: 'builder.example.com',
host: 'builder.example.com',
port: 2222
})
)
const handler = getHandler('shell:openInExternalEditor')
await expect(
handler({}, { path: '/srv/project', command: 'code', connectionId: 'ssh-1' })
).resolves.toEqual({
ok: false,
reason: 'ssh-alias-required',
host: 'builder.example.com',
port: 2222
})
expect(spawnMock).not.toHaveBeenCalled()
})
it.each(['cursor', 'zed', 'code --reuse-window'])(
'rejects the unsupported SSH launcher %s',
async (command) => {
sshTargets.set('ssh-1', createSshTarget())
const handler = getHandler('shell:openInExternalEditor')
await expect(
handler({}, { path: '/srv/project', command, connectionId: 'ssh-1' })
).resolves.toEqual({ ok: false, reason: 'remote-editor-unsupported' })
expect(spawnMock).not.toHaveBeenCalled()
}
)
it('maps unsafe Windows batch arguments to a closed launch failure', async () => {
sshTargets.set('ssh-1', createSshTarget())
resolveCliCommandMock.mockReturnValueOnce('C:\\Tools\\code.cmd')
getSpawnArgsForWindowsMock.mockImplementationOnce(() => {
throw new Error('unsafe batch arguments')
})
const handler = getHandler('shell:openInExternalEditor')
await expect(
handler({}, { path: '/srv/project&whoami', command: 'code', connectionId: 'ssh-1' })
).resolves.toEqual({ ok: false, reason: 'launch-failed' })
expect(spawnMock).not.toHaveBeenCalled()
})
})
describe('legacy file open handlers', () => {
+69 -16
View File
@@ -1,15 +1,23 @@
import { ipcMain, shell, dialog } from 'electron'
import { spawn } from 'node:child_process'
import { constants, copyFile, readFile, stat } from 'node:fs/promises'
import { basename, extname, isAbsolute, normalize } from 'node:path'
import { basename, extname, isAbsolute, normalize, posix, win32 } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { ShellOpenLocalPathResult } from '../../shared/shell-open-types'
import type {
ShellOpenExternalEditorRequest,
ShellOpenExternalEditorResult,
ShellOpenLocalPathResult
} from '../../shared/shell-open-types'
import { MAX_REPO_ICON_UPLOAD_BYTES } from '../../shared/repo-icon'
import type { Store } from '../persistence'
import { getSpawnArgsForWindows } from '../win32-utils'
import {
EXTERNAL_EDITOR_CLI_COMMAND,
resolveExternalEditorLaunchSpec
resolveExternalEditorLaunchSpec,
resolveVsCodeRemoteSshLaunchSpec,
type ExternalEditorLaunchSpec
} from '../external-editor-launch'
import { resolveVsCodeSshAuthority } from '../ssh/vscode-ssh-authority'
export { EXTERNAL_EDITOR_CLI_COMMAND }
@@ -39,7 +47,17 @@ async function validateLocalPathTarget(
return { ok: true, path: normalizedPath }
}
async function openInFileManager(pathValue: string): Promise<ShellOpenLocalPathResult> {
function hasActiveRuntime(store: Store): boolean {
return Boolean(store.getSettings().activeRuntimeEnvironmentId?.trim())
}
async function openInFileManager(
store: Store,
pathValue: string
): Promise<ShellOpenLocalPathResult> {
if (hasActiveRuntime(store)) {
return { ok: false, reason: 'remote-runtime-unsupported' }
}
const target = await validateLocalPathTarget(pathValue)
if (!target.ok) {
return target
@@ -54,8 +72,7 @@ async function openInFileManager(pathValue: string): Promise<ShellOpenLocalPathR
}
}
async function launchExternalEditor(pathValue: string, command?: string): Promise<void> {
const launchSpec = resolveExternalEditorLaunchSpec(command, pathValue)
async function launchExternalEditor(launchSpec: ExternalEditorLaunchSpec): Promise<void> {
const { spawnCmd, spawnArgs } =
launchSpec.kind === 'executable'
? getSpawnArgsForWindows(launchSpec.spawnCmd, launchSpec.spawnArgs)
@@ -99,15 +116,51 @@ async function launchExternalEditor(pathValue: string, command?: string): Promis
}
async function openInExternalEditor(
pathValue: string,
command?: string
): Promise<ShellOpenLocalPathResult> {
const target = await validateLocalPathTarget(pathValue)
store: Store,
request: ShellOpenExternalEditorRequest
): Promise<ShellOpenExternalEditorResult> {
if (hasActiveRuntime(store)) {
return { ok: false, reason: 'remote-runtime-unsupported' }
}
const connectionId = request.connectionId?.trim()
if (connectionId) {
const sshTarget = store.getSshTarget(connectionId)
if (!sshTarget) {
return { ok: false, reason: 'ssh-target-not-found' }
}
if (sshTarget.owner?.type === 'on-demand-runtime') {
return { ok: false, reason: 'remote-runtime-unsupported' }
}
if (!posix.isAbsolute(request.path) && !win32.isAbsolute(request.path)) {
return { ok: false, reason: 'not-absolute' }
}
const authority = resolveVsCodeSshAuthority(sshTarget)
if (!authority.ok) {
return authority
}
const launchSpec = resolveVsCodeRemoteSshLaunchSpec(
request.command,
request.path,
authority.authority
)
if (!launchSpec) {
return { ok: false, reason: 'remote-editor-unsupported' }
}
try {
await launchExternalEditor(launchSpec)
return { ok: true }
} catch {
return { ok: false, reason: 'launch-failed' }
}
}
const target = await validateLocalPathTarget(request.path)
if (!target.ok) {
return target
}
try {
await launchExternalEditor(target.path, command)
await launchExternalEditor(resolveExternalEditorLaunchSpec(request.command, target.path))
return { ok: true }
} catch {
return { ok: false, reason: 'launch-failed' }
@@ -127,22 +180,22 @@ async function openWithSystemDefault(pathValue: string): Promise<boolean> {
}
}
export function registerShellHandlers(): void {
export function registerShellHandlers(store: Store): void {
ipcMain.handle('shell:openPath', async (_event, path: string): Promise<void> => {
// Why: keep the legacy fire-and-forget renderer contract while reusing the
// same absolute/existing path validation as the explicit file-manager API.
void (await openInFileManager(path))
void (await openInFileManager(store, path))
})
ipcMain.handle(
'shell:openInFileManager',
(_event, path: string): Promise<ShellOpenLocalPathResult> => openInFileManager(path)
(_event, path: string): Promise<ShellOpenLocalPathResult> => openInFileManager(store, path)
)
ipcMain.handle(
'shell:openInExternalEditor',
(_event, path: string, command?: string): Promise<ShellOpenLocalPathResult> =>
openInExternalEditor(path, command)
(_event, request: ShellOpenExternalEditorRequest): Promise<ShellOpenExternalEditorResult> =>
openInExternalEditor(store, request)
)
ipcMain.handle('shell:openUrl', (_event, rawUrl: string) => {
@@ -1,3 +1,4 @@
import type { Stats } from 'node:fs'
import { readFile, stat } from 'node:fs/promises'
import type { Repo } from '../../shared/types'
import {
@@ -7,7 +8,7 @@ import {
} from '../../shared/cross-platform-path'
import type { FileStat } from '../providers/types'
type GitDirectoryStat = NonNullable<Awaited<ReturnType<typeof stat>>> | FileStat
type GitDirectoryStat = Stats | FileStat
type GitDirectoryAccess = {
stat?: (path: string) => Promise<GitDirectoryStat>
@@ -0,0 +1,107 @@
import { session, type Session } from 'electron'
import {
getProxyBypassRulesFromEnvironment,
getProxyUrlFromEnvironment,
normalizeProxyBypassRules,
normalizeProxyUrl,
type NetworkProxySettings
} from '../../shared/network-proxy'
export const OPENCODE_BASE_URL = 'https://opencode.ai'
const OPENCODE_SESSION_PARTITION = 'orca-opencode-go-rate-limit-fetch'
const appliedProxyKeys = new WeakMap<Session, string>()
export async function clearOpenCodeSessionCookies(openCodeSession: Session): Promise<void> {
await openCodeSession.clearStorageData({ origin: OPENCODE_BASE_URL, storages: ['cookies'] })
}
async function setOpenCodeSessionProxy(
openCodeSession: Session,
proxyRules: string,
proxyBypassRules: string,
source: 'settings' | 'env'
): Promise<void> {
const key = `${source}\0${proxyRules}\0${proxyBypassRules}`
if (appliedProxyKeys.get(openCodeSession) === key) {
return
}
await openCodeSession.setProxy({
mode: 'fixed_servers',
proxyRules,
...(proxyBypassRules ? { proxyBypassRules } : {})
})
await openCodeSession.closeAllConnections()
appliedProxyKeys.set(openCodeSession, key)
}
async function ensureEnvironmentProxyForOpenCodeSession(openCodeSession: Session): Promise<void> {
const envProxy = getProxyUrlFromEnvironment(process.env)
const proxyBypassRules = getProxyBypassRulesFromEnvironment(process.env)
const envKey =
envProxy.ok && envProxy.value ? `env\0${envProxy.value}\0${proxyBypassRules}` : null
if (envKey && appliedProxyKeys.get(openCodeSession) === envKey) {
return
}
if (appliedProxyKeys.has(openCodeSession)) {
await openCodeSession.setProxy({ mode: 'system' })
await openCodeSession.closeAllConnections()
appliedProxyKeys.delete(openCodeSession)
}
// Environment proxy bridging is best-effort, matching the app-wide startup path.
try {
if ((await openCodeSession.resolveProxy(OPENCODE_BASE_URL)) !== 'DIRECT') {
return
}
if (!envProxy.ok || !envProxy.value) {
return
}
await setOpenCodeSessionProxy(openCodeSession, envProxy.value, proxyBypassRules, 'env')
} catch {
// Direct networking remains available when optional environment bridging fails.
}
}
async function ensureProxyForOpenCodeSession(
openCodeSession: Session,
networkProxySettings?: NetworkProxySettings
): Promise<void> {
const configuredProxy = normalizeProxyUrl(networkProxySettings?.httpProxyUrl)
if (configuredProxy.ok && configuredProxy.value) {
await setOpenCodeSessionProxy(
openCodeSession,
configuredProxy.value,
normalizeProxyBypassRules(networkProxySettings?.httpProxyBypassRules),
'settings'
)
return
}
await ensureEnvironmentProxyForOpenCodeSession(openCodeSession)
}
export async function createOpenCodeRequestSession(
authCookies: { name: string; value: string }[],
networkProxySettings?: NetworkProxySettings
): Promise<Session> {
const openCodeSession = session.fromPartition(OPENCODE_SESSION_PARTITION)
await clearOpenCodeSessionCookies(openCodeSession)
// The isolated cookie jar must still honor Orca, environment, and system proxies.
await ensureProxyForOpenCodeSession(openCodeSession, networkProxySettings)
try {
// Sequential writes ensure cleanup cannot race an in-flight cookie write after a rejection.
for (const { name, value } of authCookies) {
await openCodeSession.cookies.set({
url: OPENCODE_BASE_URL,
name,
value,
secure: true,
path: '/'
})
}
return openCodeSession
} catch (error) {
await clearOpenCodeSessionCookies(openCodeSession).catch(() => undefined)
throw error
}
}
@@ -1,13 +1,17 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const netFetchMock = vi.hoisted(() => vi.fn())
const cookiesSetMock = vi.hoisted(() => vi.fn())
const clearStorageDataMock = vi.hoisted(() => vi.fn())
const resolveProxyMock = vi.hoisted(() => vi.fn())
const setProxyMock = vi.hoisted(() => vi.fn())
const fromPartitionMock = vi.hoisted(() => vi.fn())
vi.mock('electron', () => ({
net: { fetch: netFetchMock }
session: { fromPartition: fromPartitionMock }
}))
import { fetchOpenCodeGoRateLimits, normalizeCookieInput } from './opencode-go-usage-fetcher'
const WORKSPACES_SERVER_ID = 'def39973159c7f0483d8793a822b8dbb10d067e12c65455fcb4608459ba0234f'
function makeResponse(body: string, status = 200): Response {
@@ -42,6 +46,18 @@ describe('fetchOpenCodeGoRateLimits', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-24T12:00:00.000Z'))
netFetchMock.mockReset()
cookiesSetMock.mockReset().mockResolvedValue(undefined)
clearStorageDataMock.mockReset().mockResolvedValue(undefined)
resolveProxyMock.mockReset().mockResolvedValue('PROXY system.example:8080')
setProxyMock.mockReset().mockResolvedValue(undefined)
fromPartitionMock.mockReset().mockReturnValue({
fetch: netFetchMock,
cookies: { set: cookiesSetMock },
clearStorageData: clearStorageDataMock,
resolveProxy: resolveProxyMock,
setProxy: setProxyMock,
closeAllConnections: vi.fn().mockResolvedValue(undefined)
})
})
it('returns unavailable when cookie is empty', async () => {
@@ -112,8 +128,9 @@ describe('fetchOpenCodeGoRateLimits', () => {
const result = await fetchOpenCodeGoRateLimits('Fe26.2**baretoken')
expect(result.status).toBe('ok')
// Cookie sent to the server must be auth=<token>, not the bare value.
expect(netFetchMock.mock.calls[0][1].headers.Cookie).toBe('auth=Fe26.2**baretoken')
expect(cookiesSetMock).toHaveBeenCalledWith(
expect.objectContaining({ name: 'auth', value: 'Fe26.2**baretoken' })
)
})
it('uses GET /_server?id=<hash> with correct headers for workspaces', async () => {
@@ -129,11 +146,102 @@ describe('fetchOpenCodeGoRateLimits', () => {
expect.objectContaining({
method: 'GET',
headers: expect.objectContaining({
Cookie: 'auth=mytoken',
'X-Server-Id': WORKSPACES_SERVER_ID
})
})
)
expect(netFetchMock.mock.calls[0][1].headers).not.toHaveProperty('Cookie')
})
it('uses an isolated session cookie jar and clears it after fetching', async () => {
netFetchMock
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
.mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY))
await fetchOpenCodeGoRateLimits('auth=mytoken')
expect(fromPartitionMock).toHaveBeenCalledWith('orca-opencode-go-rate-limit-fetch')
expect(clearStorageDataMock).toHaveBeenCalledTimes(2)
expect(clearStorageDataMock).toHaveBeenLastCalledWith({
origin: 'https://opencode.ai',
storages: ['cookies']
})
})
it('clears partially installed cookies when cookie setup fails', async () => {
cookiesSetMock.mockRejectedValueOnce(new Error('cookie rejected'))
const result = await fetchOpenCodeGoRateLimits('auth=mytoken')
expect(result.status).toBe('error')
expect(result.error).toBe('cookie rejected')
expect(clearStorageDataMock).toHaveBeenCalledTimes(2)
expect(netFetchMock).not.toHaveBeenCalled()
})
it('finishes each cookie write before starting the next one', async () => {
let resolveFirstCookie!: () => void
let markFirstCookieStarted!: () => void
const firstCookiePending = new Promise<void>((resolve) => {
resolveFirstCookie = resolve
})
const firstCookieStarted = new Promise<void>((resolve) => {
markFirstCookieStarted = resolve
})
cookiesSetMock
.mockImplementationOnce(() => {
markFirstCookieStarted()
return firstCookiePending
})
.mockRejectedValueOnce(new Error('second cookie rejected'))
const resultPending = fetchOpenCodeGoRateLimits('auth=first; __Host-auth=second')
await firstCookieStarted
expect(cookiesSetMock).toHaveBeenCalledTimes(1)
resolveFirstCookie()
const result = await resultPending
expect(result.error).toBe('second cookie rejected')
expect(cookiesSetMock).toHaveBeenCalledTimes(2)
expect(clearStorageDataMock).toHaveBeenCalledTimes(2)
})
it('applies configured proxy settings once to the isolated session', async () => {
netFetchMock
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
.mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY))
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
.mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY))
const proxySettings = {
httpProxyUrl: 'http://proxy.example:8080',
httpProxyBypassRules: 'localhost, *.internal'
}
const result = await fetchOpenCodeGoRateLimits('auth=mytoken', undefined, proxySettings)
const repeatedResult = await fetchOpenCodeGoRateLimits('auth=mytoken', undefined, proxySettings)
expect(result.status).toBe('ok')
expect(repeatedResult.status).toBe('ok')
expect(setProxyMock).toHaveBeenCalledWith({
mode: 'fixed_servers',
proxyRules: 'http://proxy.example:8080',
proxyBypassRules: 'localhost;*.internal'
})
expect(setProxyMock).toHaveBeenCalledTimes(1)
expect(resolveProxyMock).not.toHaveBeenCalled()
})
it('does not bypass an explicitly configured proxy when setup fails', async () => {
setProxyMock.mockRejectedValueOnce(new Error('proxy setup failed'))
const result = await fetchOpenCodeGoRateLimits('auth=mytoken', undefined, {
httpProxyUrl: 'http://proxy.example:8080'
})
expect(result.error).toBe('proxy setup failed')
expect(cookiesSetMock).not.toHaveBeenCalled()
expect(netFetchMock).not.toHaveBeenCalled()
})
it('fetches usage from /workspace/<id>/go after resolving workspace ID', async () => {
@@ -284,8 +392,10 @@ describe('fetchOpenCodeGoRateLimits', () => {
await fetchOpenCodeGoRateLimits('session=secret; auth=realtoken; tracking=xyz')
const firstCall = netFetchMock.mock.calls[0]
expect(firstCall[1].headers.Cookie).toBe('auth=realtoken')
expect(cookiesSetMock).toHaveBeenCalledTimes(1)
expect(cookiesSetMock).toHaveBeenCalledWith(
expect.objectContaining({ name: 'auth', value: 'realtoken' })
)
})
it('returns error on 404 from workspaces fetch', async () => {
@@ -1,9 +1,14 @@
import { net } from 'electron'
import type { Session } from 'electron'
import { randomUUID } from 'node:crypto'
import type { NetworkProxySettings } from '../../shared/network-proxy'
import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types'
import {
clearOpenCodeSessionCookies,
createOpenCodeRequestSession,
OPENCODE_BASE_URL
} from './opencode-go-request-session'
import { parseSubscriptionFromPageText } from './opencode-go-page-scraper'
const OPENCODE_BASE_URL = 'https://opencode.ai'
const OPENCODE_SERVER_URL = 'https://opencode.ai/_server'
const API_TIMEOUT_MS = 15_000
@@ -36,18 +41,20 @@ export function normalizeCookieInput(raw: string): string {
return trimmed
}
function filterAuthCookie(raw: string): string {
function parseAuthCookies(raw: string): { name: string; value: string }[] {
return raw
.split(';')
.map((p) => p.trim())
.filter((pair) => {
.map((pair) => {
const eq = pair.indexOf('=')
if (eq < 0) {
return false
return null
}
return AUTH_COOKIE_NAMES.has(pair.slice(0, eq).trim())
const name = pair.slice(0, eq).trim()
const value = pair.slice(eq + 1).trim()
return AUTH_COOKIE_NAMES.has(name) && value ? { name, value } : null
})
.join('; ')
.filter((pair): pair is { name: string; value: string } => pair !== null)
}
function parseWorkspaceIds(text: string): string[] {
@@ -81,7 +88,8 @@ function makeWindow(
export async function fetchOpenCodeGoRateLimits(
cookie: string,
workspaceIdOverride?: string
workspaceIdOverride?: string,
networkProxySettings?: NetworkProxySettings
): Promise<ProviderRateLimits> {
// Normalize before any guard — bare tokens become auth=<token>.
const normalizedCookie = normalizeCookieInput(cookie)
@@ -99,8 +107,8 @@ export async function fetchOpenCodeGoRateLimits(
}
// Filter to only auth cookies — avoids sending unrelated session data.
const cookieHeader = filterAuthCookie(normalizedCookie)
if (!cookieHeader) {
const authCookies = parseAuthCookies(normalizedCookie)
if (authCookies.length === 0) {
return {
provider: 'opencode-go',
session: null,
@@ -112,6 +120,40 @@ export async function fetchOpenCodeGoRateLimits(
}
}
// Why: Chromium can reject a manually supplied Cookie header on Windows.
// An isolated session jar lets its network stack attach auth normally.
let openCodeSession: Session
try {
openCodeSession = await createOpenCodeRequestSession(authCookies, networkProxySettings)
} catch (error) {
return makeOpenCodeError(error)
}
try {
return await fetchOpenCodeGoRateLimitsWithSession(openCodeSession, workspaceIdOverride)
} finally {
await clearOpenCodeSessionCookies(openCodeSession).catch((error: unknown) => {
console.warn('[opencode-go] failed to clear session cookie jar after fetch', error)
})
}
}
function makeOpenCodeError(error: unknown): ProviderRateLimits {
return {
provider: 'opencode-go',
session: null,
weekly: null,
monthly: null,
updatedAt: Date.now(),
error: error instanceof Error ? error.message : 'Unknown error',
status: 'error'
}
}
async function fetchOpenCodeGoRateLimitsWithSession(
openCodeSession: Session,
workspaceIdOverride?: string
): Promise<ProviderRateLimits> {
// Step 1: resolve workspace IDs to try.
let ids: string[] = []
const override = workspaceIdOverride?.trim()
@@ -135,10 +177,9 @@ export async function fetchOpenCodeGoRateLimits(
// and X-Server-Id / X-Server-Instance headers for routing.
const instanceId = `server-fn:${randomUUID()}`
const workspacesUrl = `${OPENCODE_SERVER_URL}?id=${WORKSPACES_SERVER_ID}`
const workspacesRes = await net.fetch(workspacesUrl, {
const workspacesRes = await openCodeSession.fetch(workspacesUrl, {
method: 'GET',
headers: {
Cookie: cookieHeader,
'X-Server-Id': WORKSPACES_SERVER_ID,
'X-Server-Instance': instanceId,
Accept: 'text/javascript, application/json;q=0.9, */*;q=0.8',
@@ -195,10 +236,9 @@ export async function fetchOpenCodeGoRateLimits(
for (const candidateId of ids) {
try {
const usagePageUrl = `${OPENCODE_BASE_URL}/workspace/${candidateId}/go`
const pageRes = await net.fetch(usagePageUrl, {
const pageRes = await openCodeSession.fetch(usagePageUrl, {
method: 'GET',
headers: {
Cookie: cookieHeader,
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
Origin: OPENCODE_BASE_URL,
Referer: OPENCODE_BASE_URL
+10 -1
View File
@@ -1381,6 +1381,11 @@ describe('RateLimitService', () => {
sessionCookie: 'session=abc123',
workspaceIdOverride: ''
}))
const networkProxySettings = {
httpProxyUrl: 'http://proxy.example:8080',
httpProxyBypassRules: 'localhost'
}
service.setNetworkProxySettingsResolver(() => networkProxySettings)
service.setGeminiCliOAuthEnabledResolver(() => true)
vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now()))
@@ -1405,7 +1410,11 @@ describe('RateLimitService', () => {
expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(1)
expect(fetchGeminiRateLimits).toHaveBeenCalledWith(true)
expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledTimes(1)
expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledWith('session=abc123', undefined)
expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledWith(
'session=abc123',
undefined,
networkProxySettings
)
expect(fetchGrokRateLimits).toHaveBeenCalledWith({
signal: expect.any(AbortSignal),
authReadResult: { status: 'missing' }
+5 -1
View File
@@ -1540,7 +1540,11 @@ export class RateLimitService {
signal
}),
fetchGeminiRateLimits(geminiCliOAuthEnabled),
fetchOpenCodeGoRateLimits(cookie, workspaceIdOverride || undefined),
fetchOpenCodeGoRateLimits(
cookie,
workspaceIdOverride || undefined,
this.networkProxySettingsResolver?.()
),
fetchKimiRateLimits(),
miniMaxConfigResult.error
? Promise.resolve(this.getMiniMaxCredentialError(miniMaxConfigResult.error))
+5 -5
View File
@@ -249,11 +249,11 @@ export class RuntimeEmulatorCommands {
async emulatorAx(params: EmulatorTargetParams): Promise<unknown> {
const worktreeId = await this.resolveWorktreeId(params.worktree)
return this.requireEmulatorBridge().runCapability(
'accessibilityTree',
{ device: params.device ?? params.emulator, worktreeId },
(backend, device) => backend.accessibilityTree!(device)
)
// Via the bridge (not runCapability directly) so iOS gets the session's /ax endpoint.
return this.requireEmulatorBridge().accessibilityTree({
device: params.device ?? params.emulator,
worktreeId
})
}
async emulatorLogcat(
@@ -1,5 +1,5 @@
import { createHash } from 'node:crypto'
import { constants } from 'node:fs'
import { constants, type Stats } from 'node:fs'
import { access, lstat, realpath, stat } from 'node:fs/promises'
import { dirname, normalize, resolve } from 'node:path'
import type { SkillInstallationTopology } from '../../shared/skill-freshness'
@@ -26,10 +26,7 @@ export function normalizedSkillIdentityPath(value: string): string {
return process.platform === 'win32' ? normalized.toLocaleLowerCase('en-US') : normalized
}
export function skillPhysicalIdentity(
resolvedPath: string,
fileStat: NonNullable<Awaited<ReturnType<typeof stat>>>
): string {
export function skillPhysicalIdentity(resolvedPath: string, fileStat: Stats): string {
const inodeIdentity = fileStat.dev || fileStat.ino ? `${fileStat.dev}:${fileStat.ino}` : null
return inodeIdentity ?? normalizedSkillIdentityPath(resolvedPath)
}
+1 -1
View File
@@ -166,7 +166,7 @@ function shouldUseOpenSshConfigHost(target: SshTarget): boolean {
return isOpenSshConfigBackedTarget(target)
}
function isOpenSshConfigBackedTarget(target: SshTarget): boolean {
export function isOpenSshConfigBackedTarget(target: SshTarget): boolean {
if (target.source === 'ssh-config') {
return true
}
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import type { SshTarget } from '../../shared/ssh-types'
import { resolveVsCodeSshAuthority } from './vscode-ssh-authority'
function createTarget(overrides: Partial<SshTarget> = {}): SshTarget {
return {
id: 'ssh-1',
label: 'Builder',
host: 'builder.example.com',
port: 22,
username: 'ada',
source: 'manual',
...overrides
}
}
describe('resolveVsCodeSshAuthority', () => {
it('uses the config host for imported and legacy OpenSSH targets', () => {
expect(
resolveVsCodeSshAuthority(createTarget({ source: 'ssh-config', configHost: ' builder ' }))
).toEqual({ ok: true, authority: 'builder' })
expect(
resolveVsCodeSshAuthority(
createTarget({ source: undefined, configHost: 'legacy-builder', host: '192.0.2.10' })
)
).toEqual({ ok: true, authority: 'legacy-builder' })
})
it('does not treat a manual target configHost default as an alias', () => {
expect(
resolveVsCodeSshAuthority(
createTarget({ configHost: 'builder.example.com', port: 22, source: 'manual' })
)
).toEqual({ ok: true, authority: 'ada@builder.example.com' })
})
it('requires an alias for manual targets on non-default ports', () => {
expect(
resolveVsCodeSshAuthority(
createTarget({ configHost: 'builder.example.com', port: 2222, source: 'manual' })
)
).toEqual({
ok: false,
reason: 'ssh-alias-required',
host: 'builder.example.com',
port: 2222
})
})
it.each([
createTarget({ host: ' ' }),
createTarget({ username: '' }),
createTarget({ host: 'builder\nmalicious' }),
createTarget({ source: 'ssh-config', configHost: '\u0000builder' }),
createTarget({ port: 0 })
])('rejects incomplete or unsafe target fields', (target) => {
expect(resolveVsCodeSshAuthority(target)).toEqual({
ok: false,
reason: 'ssh-target-invalid'
})
})
})
+42
View File
@@ -0,0 +1,42 @@
import type { SshTarget } from '../../shared/ssh-types'
import { isOpenSshConfigBackedTarget } from './system-ssh-args'
export type VsCodeSshAuthorityResult =
| { ok: true; authority: string }
| { ok: false; reason: 'ssh-target-invalid' }
| { ok: false; reason: 'ssh-alias-required'; host: string; port: number }
function isValidAuthorityPart(value: string): boolean {
return (
value.length > 0 &&
!Array.from(value).some((character) => {
const codePoint = character.codePointAt(0) ?? 0
return codePoint <= 0x1f || codePoint === 0x7f
})
)
}
export function resolveVsCodeSshAuthority(target: SshTarget): VsCodeSshAuthorityResult {
if (isOpenSshConfigBackedTarget(target)) {
const configHost = target.configHost?.trim() ?? ''
return isValidAuthorityPart(configHost)
? { ok: true, authority: configHost }
: { ok: false, reason: 'ssh-target-invalid' }
}
const host = target.host.trim()
const username = target.username.trim()
if (
!isValidAuthorityPart(host) ||
!isValidAuthorityPart(username) ||
!Number.isInteger(target.port) ||
target.port < 1 ||
target.port > 65_535
) {
return { ok: false, reason: 'ssh-target-invalid' }
}
if (target.port !== 22) {
return { ok: false, reason: 'ssh-alias-required', host, port: target.port }
}
return { ok: true, authority: `${username}@${host}` }
}
+13 -3
View File
@@ -336,7 +336,11 @@ import type {
} from '../shared/commit-message-agent-spec'
import type { ResolvedSourceControlAiGenerationParams } from '../shared/source-control-ai'
import type { SourceControlAiSettings } from '../shared/source-control-ai-types'
import type { ShellOpenLocalPathResult } from '../shared/shell-open-types'
import type {
ShellOpenExternalEditorRequest,
ShellOpenExternalEditorResult,
ShellOpenLocalPathResult
} from '../shared/shell-open-types'
import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../shared/skills'
import type { SkillFreshnessInventory } from '../shared/skill-freshness'
import type {
@@ -349,7 +353,11 @@ import type {
ReactErrorBoundaryReportResult
} from '../shared/crash-reporting'
export type { ShellOpenLocalPathResult } from '../shared/shell-open-types'
export type {
ShellOpenExternalEditorRequest,
ShellOpenExternalEditorResult,
ShellOpenLocalPathResult
} from '../shared/shell-open-types'
type RuntimeEnvironmentSubscriptionHandle = {
unsubscribe: () => void
@@ -2272,7 +2280,9 @@ export type PreloadApi = {
shell: {
openPath: (path: string) => Promise<void>
openInFileManager: (path: string) => Promise<ShellOpenLocalPathResult>
openInExternalEditor: (path: string, command?: string) => Promise<ShellOpenLocalPathResult>
openInExternalEditor: (
request: ShellOpenExternalEditorRequest
) => Promise<ShellOpenExternalEditorResult>
openUrl: (url: string) => Promise<void>
openFilePath: (path: string) => Promise<boolean>
openFileUri: (uri: string) => Promise<void>
+9 -3
View File
@@ -78,7 +78,11 @@ import type {
WarpThemeImportSource
} from '../shared/terminal-custom-themes'
import type { GitHistoryOptions, GitHistoryResult } from '../shared/git-history'
import type { ShellOpenLocalPathResult } from '../shared/shell-open-types'
import type {
ShellOpenExternalEditorRequest,
ShellOpenExternalEditorResult,
ShellOpenLocalPathResult
} from '../shared/shell-open-types'
import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../shared/skills'
import type { SkillFreshnessInventory } from '../shared/skill-freshness'
import type {
@@ -2182,8 +2186,10 @@ const api = {
openInFileManager: (path: string): Promise<ShellOpenLocalPathResult> =>
ipcRenderer.invoke('shell:openInFileManager', path),
openInExternalEditor: (path: string, command?: string): Promise<ShellOpenLocalPathResult> =>
ipcRenderer.invoke('shell:openInExternalEditor', path, command),
openInExternalEditor: (
request: ShellOpenExternalEditorRequest
): Promise<ShellOpenExternalEditorResult> =>
ipcRenderer.invoke('shell:openInExternalEditor', request),
openUrl: (url: string): Promise<void> => ipcRenderer.invoke('shell:openUrl', url),
@@ -15,6 +15,7 @@ import { OpenInApplicationIcon } from '@/lib/open-in-app-catalog'
import { translate } from '@/i18n/i18n'
import { getLocalFileManagerLabel } from '@/lib/local-file-manager-label'
import {
getOpenInEntryAvailability,
getWorktreeOpenInEntries,
openOpenInAppsSettings,
openWorktreePath
@@ -40,6 +41,7 @@ export function SourceControlEntryContextMenu({
children
}: SourceControlEntryContextMenuProps): React.JSX.Element {
const openInApplications = useAppStore((s) => s.settings?.openInApplications ?? [])
const settings = useAppStore((s) => s.settings)
const fileManagerLabel = getLocalFileManagerLabel()
const openInEntries = React.useMemo(
() => getWorktreeOpenInEntries(openInApplications, fileManagerLabel),
@@ -98,22 +100,30 @@ export function SourceControlEntryContextMenu({
{translate('auto.components.sidebar.WorktreeOpenInMenu.8009ab69a6', 'Open in')}
</ContextMenuSubTrigger>
<ContextMenuSubContent className="w-52">
{openInEntries.map((entry) => (
<ContextMenuItem
key={entry.id}
onSelect={() => handleOpenInExternal(entry.target, entry.command)}
disabled={!absolutePath}
>
{entry.target === 'file-manager' ? (
<FolderOpen className="size-3.5" />
) : entry.command ? (
<OpenInApplicationIcon application={{ command: entry.command }} size={14} />
) : (
<ExternalLink className="size-3.5" />
)}
{entry.label}
</ContextMenuItem>
))}
{openInEntries.map((entry) => {
const availability = getOpenInEntryAvailability(entry, settings, connectionId)
return (
<ContextMenuItem
key={entry.id}
onSelect={() => handleOpenInExternal(entry.target, entry.command)}
disabled={!absolutePath || availability.disabled}
>
{entry.target === 'file-manager' ? (
<FolderOpen className="size-3.5" />
) : entry.command ? (
<OpenInApplicationIcon application={{ command: entry.command }} size={14} />
) : (
<ExternalLink className="size-3.5" />
)}
<span className="min-w-0 truncate">{entry.label}</span>
{availability.metadata ? (
<span className="ml-auto shrink-0 text-[11px] text-muted-foreground">
{availability.metadata}
</span>
) : null}
</ContextMenuItem>
)
})}
<ContextMenuSeparator />
<ContextMenuItem onSelect={openOpenInAppsSettings}>
{translate(
@@ -132,7 +132,7 @@ export function KeybindingsFileActions(): React.JSX.Element {
)
return
}
const result = await window.api.shell.openInExternalEditor(filePath, command)
const result = await window.api.shell.openInExternalEditor({ path: filePath, command })
if (!result.ok) {
toast.error(openFailureMessage(result.reason))
}
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DropdownMenuSubContent, DropdownMenuSubTrigger } from '@/components/ui/dropdown-menu'
import {
getWorktreeOpenInEntries,
getOpenInEntryAvailability,
getLocalFileManagerLabel,
openOpenInAppsSettings,
openWorktreePath,
@@ -161,7 +162,11 @@ describe('WorktreeOpenInMenu', () => {
connectionId: null
})
expect(openInExternalEditorMock).toHaveBeenCalledWith('/tmp/workspace', undefined)
expect(openInExternalEditorMock).toHaveBeenCalledWith({
path: '/tmp/workspace',
command: undefined,
connectionId: null
})
expect(toastErrorMock).toHaveBeenCalledWith('Could not open workspace folder.', {
description: 'Check the editor command or file manager configuration on this machine.'
})
@@ -198,7 +203,11 @@ describe('WorktreeOpenInMenu', () => {
connectionId: null,
command: 'cursor'
})
expect(openInExternalEditorMock).toHaveBeenCalledWith('/tmp/workspace', 'cursor')
expect(openInExternalEditorMock).toHaveBeenCalledWith({
path: '/tmp/workspace',
command: 'cursor',
connectionId: null
})
})
it('blocks configured launchers in remote context before calling main IPC', async () => {
@@ -213,7 +222,89 @@ describe('WorktreeOpenInMenu', () => {
expect(openInExternalEditorMock).not.toHaveBeenCalled()
expect(toastErrorMock).toHaveBeenCalledWith(
'Opening remote paths in the local OS is not available.'
'Opening this path in a local app is not available.',
{ description: 'Switch to a local or SSH workspace, then try again.' }
)
})
it('enables only VS Code-compatible launchers for SSH paths', () => {
const entries = getWorktreeOpenInEntries(
[
{ id: 'renamed', label: 'My Remote Editor', command: 'code-insiders' },
{ id: 'fake', label: 'VS Code', command: 'cursor' },
{ id: 'compound', label: 'VS Code Reuse', command: 'code --reuse-window' }
],
'Finder'
)
expect(getOpenInEntryAvailability(entries[0], mockState.settings, 'ssh-1')).toEqual({
disabled: false,
metadata: 'Remote SSH'
})
expect(getOpenInEntryAvailability(entries[1], mockState.settings, 'ssh-1')).toEqual({
disabled: true,
metadata: 'Local only'
})
expect(getOpenInEntryAvailability(entries[2], mockState.settings, 'ssh-1')).toEqual({
disabled: true,
metadata: 'Local only'
})
expect(getOpenInEntryAvailability(entries[3], mockState.settings, 'ssh-1')).toEqual({
disabled: true,
metadata: 'Local only'
})
})
it('forwards SSH context for a supported VS Code launcher', async () => {
await openWorktreePath({
target: 'external-editor',
worktreePath: '/home/ada/project',
connectionId: 'ssh-1',
command: 'code'
})
expect(openInExternalEditorMock).toHaveBeenCalledWith({
path: '/home/ada/project',
command: 'code',
connectionId: 'ssh-1'
})
})
it('blocks SSH local-only launchers before IPC with actionable copy', async () => {
await openWorktreePath({
target: 'external-editor',
worktreePath: '/home/ada/project',
connectionId: 'ssh-1',
command: 'cursor'
})
expect(openInExternalEditorMock).not.toHaveBeenCalled()
expect(toastErrorMock).toHaveBeenCalledWith('This app cannot open SSH workspaces.', {
description: 'Choose VS Code or use the app locally.'
})
})
it('shows the SSH alias recovery details returned by main', async () => {
openInExternalEditorMock.mockResolvedValueOnce({
ok: false,
reason: 'ssh-alias-required',
host: 'builder.example.com',
port: 2222
})
await openWorktreePath({
target: 'external-editor',
worktreePath: '/srv/project',
connectionId: 'ssh-1',
command: 'code'
})
expect(toastErrorMock).toHaveBeenCalledWith(
'VS Code needs an SSH config alias for this host.',
{
description:
'Add a Host alias for builder.example.com:2222 to your local SSH config, reconnect the workspace, then try again.'
}
)
})
})
@@ -12,8 +12,9 @@ import { useAppStore } from '@/store'
import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard'
import { getLocalFileManagerLabel } from '@/lib/local-file-manager-label'
import { OpenInApplicationIcon } from '@/lib/open-in-app-catalog'
import type { ShellOpenLocalPathFailureReason } from '../../../../shared/shell-open-types'
import type { OpenInApplication } from '../../../../shared/types'
import { getExternalEditorOpenCapability } from '@/lib/external-editor-open-capability'
import type { ShellOpenExternalEditorResult } from '../../../../shared/shell-open-types'
import type { GlobalSettings, OpenInApplication } from '../../../../shared/types'
import { translate } from '@/i18n/i18n'
export { getLocalFileManagerLabel } from '@/lib/local-file-manager-label'
@@ -25,7 +26,7 @@ type WorktreeOpenInMenuItemsProps = {
labelPrefix?: string
}
type OpenInMenuEntry = {
export type OpenInMenuEntry = {
id: string
label: string
target: 'external-editor' | 'file-manager'
@@ -47,17 +48,141 @@ export function getWorktreeOpenInEntries(
]
}
function showOpenFailureToast(reason: ShellOpenLocalPathFailureReason): void {
if (reason === 'not-absolute') {
export function getOpenInEntryAvailability(
entry: OpenInMenuEntry,
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
connectionId?: string | null
): { disabled: boolean; metadata?: string } {
if (entry.target === 'file-manager') {
const disabled = isLocalPathOpenBlocked(settings, { connectionId })
return disabled
? {
disabled: true,
metadata: translate('auto.components.sidebar.WorktreeOpenInMenu.localOnly', 'Local only')
}
: { disabled: false }
}
const capability = getExternalEditorOpenCapability(settings, {
connectionId,
command: entry.command
})
if (!capability.allowed) {
return {
disabled: true,
metadata: translate('auto.components.sidebar.WorktreeOpenInMenu.localOnly', 'Local only')
}
}
return capability.remote
? {
disabled: false,
metadata: translate('auto.components.sidebar.WorktreeOpenInMenu.remoteSsh', 'Remote SSH')
}
: { disabled: false }
}
function showOpenFailureToast(
result: Exclude<ShellOpenExternalEditorResult, { ok: true }>,
remote: boolean
): void {
if (result.reason === 'remote-runtime-unsupported') {
toast.error(
translate(
'auto.components.sidebar.WorktreeOpenInMenu.f387af445b',
'Workspace path is not a valid local path.'
)
'auto.components.sidebar.WorktreeOpenInMenu.remoteRuntimeUnsupported',
'Opening this path in a local app is not available.'
),
{
description: translate(
'auto.components.sidebar.WorktreeOpenInMenu.remoteRuntimeUnsupportedDetail',
'Switch to a local or SSH workspace, then try again.'
)
}
)
return
}
if (reason === 'not-found') {
if (result.reason === 'ssh-target-not-found') {
toast.error(
translate(
'auto.components.sidebar.WorktreeOpenInMenu.sshTargetNotFound',
'SSH host is no longer available.'
),
{
description: translate(
'auto.components.sidebar.WorktreeOpenInMenu.sshTargetNotFoundDetail',
'Refresh workspaces or reconnect the host, then try again.'
)
}
)
return
}
if (result.reason === 'ssh-target-invalid') {
toast.error(
translate(
'auto.components.sidebar.WorktreeOpenInMenu.sshTargetInvalid',
'SSH host configuration is incomplete.'
),
{
description: translate(
'auto.components.sidebar.WorktreeOpenInMenu.sshTargetInvalidDetail',
'Edit or reconnect the SSH host, then try again.'
)
}
)
return
}
if (result.reason === 'ssh-alias-required') {
toast.error(
translate(
'auto.components.sidebar.WorktreeOpenInMenu.sshAliasRequired',
'VS Code needs an SSH config alias for this host.'
),
{
description: translate(
'auto.components.sidebar.WorktreeOpenInMenu.sshAliasRequiredDetail',
'Add a Host alias for {{host}}:{{port}} to your local SSH config, reconnect the workspace, then try again.',
{ host: result.host, port: result.port }
)
}
)
return
}
if (result.reason === 'remote-editor-unsupported') {
toast.error(
translate(
'auto.components.sidebar.WorktreeOpenInMenu.remoteEditorUnsupported',
'This app cannot open SSH workspaces.'
),
{
description: translate(
'auto.components.sidebar.WorktreeOpenInMenu.remoteEditorUnsupportedDetail',
'Choose VS Code or use the app locally.'
)
}
)
return
}
if (result.reason === 'not-absolute') {
toast.error(
remote
? translate(
'auto.components.sidebar.WorktreeOpenInMenu.remotePathInvalid',
'Path is not valid for the SSH host.'
)
: translate(
'auto.components.sidebar.WorktreeOpenInMenu.f387af445b',
'Workspace path is not a valid local path.'
),
remote
? {
description: translate(
'auto.components.sidebar.WorktreeOpenInMenu.remotePathInvalidDetail',
'Refresh the workspace before trying again.'
)
}
: undefined
)
return
}
if (result.reason === 'not-found') {
toast.error(
translate(
'auto.components.sidebar.WorktreeOpenInMenu.3921d3d9a5',
@@ -72,6 +197,21 @@ function showOpenFailureToast(reason: ShellOpenLocalPathFailureReason): void {
)
return
}
if (remote) {
toast.error(
translate(
'auto.components.sidebar.WorktreeOpenInMenu.remoteLaunchFailed',
'Could not open the path in VS Code.'
),
{
description: translate(
'auto.components.sidebar.WorktreeOpenInMenu.remoteLaunchFailedDetail',
'Check the VS Code command configured on this machine.'
)
}
)
return
}
toast.error(
translate(
'auto.components.sidebar.WorktreeOpenInMenu.9a5381eb09',
@@ -106,21 +246,37 @@ export async function openWorktreePath(args: {
connectionId?: string | null
command?: string
}): Promise<void> {
if (
isLocalPathOpenBlocked(useAppStore.getState().settings, {
connectionId: args.connectionId ?? null
const settings = useAppStore.getState().settings
if (args.target === 'file-manager') {
if (isLocalPathOpenBlocked(settings, { connectionId: args.connectionId ?? null })) {
showLocalPathOpenBlockedToast()
return
}
} else {
const capability = getExternalEditorOpenCapability(settings, {
connectionId: args.connectionId,
command: args.command
})
) {
showLocalPathOpenBlockedToast()
return
if (!capability.allowed) {
if (capability.reason === 'remote-runtime') {
showOpenFailureToast({ ok: false, reason: 'remote-runtime-unsupported' }, false)
} else {
showOpenFailureToast({ ok: false, reason: 'remote-editor-unsupported' }, true)
}
return
}
}
const result =
args.target === 'file-manager'
? await window.api.shell.openInFileManager(args.worktreePath)
: await window.api.shell.openInExternalEditor(args.worktreePath, args.command)
: await window.api.shell.openInExternalEditor({
path: args.worktreePath,
command: args.command,
connectionId: args.connectionId
})
if (!result.ok) {
showOpenFailureToast(result.reason)
showOpenFailureToast(result, Boolean(args.connectionId?.trim()))
}
}
@@ -147,31 +303,42 @@ export function WorktreeOpenInMenuItems({
}: WorktreeOpenInMenuItemsProps): React.JSX.Element {
const openInWorktreePath = useOpenInWorktreePath({ worktreePath, connectionId })
const openInApplications = useAppStore((s) => s.settings?.openInApplications ?? [])
const settings = useAppStore((s) => s.settings)
const fileManagerLabel = getLocalFileManagerLabel()
const entries = getWorktreeOpenInEntries(openInApplications, fileManagerLabel)
return (
<>
{entries.map((entry) => (
<DropdownMenuItem
key={entry.id}
onClick={stopMenuPropagation}
onSelect={() => {
void openInWorktreePath(entry.target, entry.command)
}}
disabled={disabled}
>
{entry.target === 'file-manager' ? (
<FolderOpen className="size-3.5" />
) : entry.command ? (
<OpenInApplicationIcon application={{ command: entry.command }} size={14} />
) : (
<ExternalLink className="size-3.5" />
)}
{labelPrefix}
{entry.label}
</DropdownMenuItem>
))}
{entries.map((entry) => {
const availability = getOpenInEntryAvailability(entry, settings, connectionId)
return (
<DropdownMenuItem
key={entry.id}
onClick={stopMenuPropagation}
onSelect={() => {
void openInWorktreePath(entry.target, entry.command)
}}
disabled={disabled || availability.disabled}
>
{entry.target === 'file-manager' ? (
<FolderOpen className="size-3.5" />
) : entry.command ? (
<OpenInApplicationIcon application={{ command: entry.command }} size={14} />
) : (
<ExternalLink className="size-3.5" />
)}
<span className="min-w-0 truncate">
{labelPrefix}
{entry.label}
</span>
{availability.metadata ? (
<span className="ml-auto shrink-0 text-[11px] text-muted-foreground">
{availability.metadata}
</span>
) : null}
</DropdownMenuItem>
)
})}
</>
)
}
+16
View File
@@ -4449,6 +4449,22 @@
"61d6f612cf": "Saving..."
},
"WorktreeOpenInMenu": {
"localOnly": "Local only",
"remoteSsh": "Remote SSH",
"remoteRuntimeUnsupported": "Opening this path in a local app is not available.",
"remoteRuntimeUnsupportedDetail": "Switch to a local or SSH workspace, then try again.",
"sshTargetNotFound": "SSH host is no longer available.",
"sshTargetNotFoundDetail": "Refresh workspaces or reconnect the host, then try again.",
"sshTargetInvalid": "SSH host configuration is incomplete.",
"sshTargetInvalidDetail": "Edit or reconnect the SSH host, then try again.",
"sshAliasRequired": "VS Code needs an SSH config alias for this host.",
"sshAliasRequiredDetail": "Add a Host alias for {{host}}:{{port}} to your local SSH config, reconnect the workspace, then try again.",
"remoteEditorUnsupported": "This app cannot open SSH workspaces.",
"remoteEditorUnsupportedDetail": "Choose VS Code or use the app locally.",
"remotePathInvalid": "Path is not valid for the SSH host.",
"remotePathInvalidDetail": "Refresh the workspace before trying again.",
"remoteLaunchFailed": "Could not open the path in VS Code.",
"remoteLaunchFailedDetail": "Check the VS Code command configured on this machine.",
"1417fd8380": "Customize apps...",
"8009ab69a6": "Open in",
"bd0e8159f8": "Check the editor command or file manager configuration on this machine.",
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { getExternalEditorOpenCapability } from './external-editor-open-capability'
describe('getExternalEditorOpenCapability', () => {
it('allows every configured launcher for local paths', () => {
expect(
getExternalEditorOpenCapability(
{ activeRuntimeEnvironmentId: null },
{ connectionId: null, command: 'cursor --new-window' }
)
).toEqual({ allowed: true, remote: false })
})
it('allows supported VS Code commands for SSH paths', () => {
expect(
getExternalEditorOpenCapability(
{ activeRuntimeEnvironmentId: null },
{ connectionId: 'ssh-1', command: 'code-insiders' }
)
).toEqual({ allowed: true, remote: true })
})
it('rejects non-VS Code and compound commands for SSH paths', () => {
expect(
getExternalEditorOpenCapability(
{ activeRuntimeEnvironmentId: null },
{ connectionId: 'ssh-1', command: 'cursor' }
)
).toEqual({ allowed: false, reason: 'local-only-editor' })
expect(
getExternalEditorOpenCapability(
{ activeRuntimeEnvironmentId: null },
{ connectionId: 'ssh-1', command: 'code --reuse-window' }
)
).toEqual({ allowed: false, reason: 'local-only-editor' })
})
it('rejects every local-app launch while a remote runtime is active', () => {
expect(
getExternalEditorOpenCapability(
{ activeRuntimeEnvironmentId: 'runtime-1' },
{ connectionId: 'ssh-1', command: 'code' }
)
).toEqual({ allowed: false, reason: 'remote-runtime' })
})
})
@@ -0,0 +1,21 @@
import type { GlobalSettings } from '../../../shared/types'
import { isVsCodeRemoteSshCommand } from '../../../shared/vscode-remote-ssh-launcher'
export type ExternalEditorOpenCapability =
| { allowed: true; remote: boolean }
| { allowed: false; reason: 'remote-runtime' | 'local-only-editor' }
export function getExternalEditorOpenCapability(
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
context: { connectionId?: string | null; command?: string }
): ExternalEditorOpenCapability {
if (settings?.activeRuntimeEnvironmentId?.trim()) {
return { allowed: false, reason: 'remote-runtime' }
}
if (!context.connectionId?.trim()) {
return { allowed: true, remote: false }
}
return isVsCodeRemoteSshCommand(context.command)
? { allowed: true, remote: true }
: { allowed: false, reason: 'local-only-editor' }
}
+25 -1
View File
@@ -1,5 +1,29 @@
export type ShellOpenLocalPathFailureReason = 'not-absolute' | 'not-found' | 'launch-failed'
export type ShellOpenExternalEditorRequest = {
path: string
command?: string
connectionId?: string | null
}
export type ShellOpenPathFailureReason =
| 'not-absolute'
| 'not-found'
| 'launch-failed'
| 'remote-runtime-unsupported'
| 'ssh-target-not-found'
| 'ssh-target-invalid'
| 'ssh-alias-required'
| 'remote-editor-unsupported'
export type ShellOpenLocalPathFailureReason = Extract<
ShellOpenPathFailureReason,
'not-absolute' | 'not-found' | 'launch-failed' | 'remote-runtime-unsupported'
>
export type ShellOpenLocalPathResult =
| { ok: true }
| { ok: false; reason: ShellOpenLocalPathFailureReason }
export type ShellOpenExternalEditorResult =
| { ok: true }
| { ok: false; reason: Exclude<ShellOpenPathFailureReason, 'ssh-alias-required'> }
| { ok: false; reason: 'ssh-alias-required'; host: string; port: number }
+18 -1
View File
@@ -367,11 +367,15 @@ const daemonStartFailedSchema = z.object({ error_class: errorClassSchema }).stri
// Rollout signal for granting Codex hook trust via codex app-server RPCs
// instead of Orca's self-computed trusted_hash. `fallback`/`verify_failed`
// spikes mean the RPC lane is not taking; steady-state ledger skips are not
// reported (they would only measure launch volume).
// reported (they would only measure launch volume). `lane` attributes the
// grant surface (real ~/.codex vs managed home); `error_class`/`verify_class`
// are closed classifications so `error` fallbacks are diagnosable in the
// field — e.g. `binary-missing` = codex CLI absent, no rollout impact.
const codexTrustGrantSchema = z
.object({
outcome: z.enum(['granted', 'fallback', 'verify_failed']),
host_kind: z.enum(['native', 'wsl']),
lane: z.enum(['real-home', 'managed']),
fallback_reason: z
.enum([
'disabled',
@@ -382,6 +386,19 @@ const codexTrustGrantSchema = z
'retry-cached',
'error'
])
.optional(),
error_class: z
.enum(['binary-missing', 'timeout', 'entry-failed', 'early-exit', 'rpc-failed', 'unexpected'])
.optional(),
verify_class: z
.enum([
'list-mismatch',
'post-grant-untrusted',
'post-grant-mismatch',
'unexpected-key',
'duplicate-key',
'coverage'
])
.optional()
})
.strict()
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import { isVsCodeLauncherExecutable, isVsCodeRemoteSshCommand } from './vscode-remote-ssh-launcher'
describe('VS Code Remote-SSH launcher capability', () => {
it.each([
'code',
'code-insiders',
'/usr/local/bin/code',
'/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code',
'C:\\Program Files\\Microsoft VS Code\\Code.exe',
'C:\\Program Files\\Microsoft VS Code Insiders\\Code - Insiders.exe',
'C:\\Tools\\CODE.CMD',
'C:\\Tools\\code-insiders.bat'
])('recognizes a safe configured launcher: %s', (command) => {
expect(isVsCodeRemoteSshCommand(command)).toBe(true)
})
it.each(['cursor', 'zed', 'code --reuse-window', 'open -a "Visual Studio Code"'])(
'rejects an unsupported or compound command: %s',
(command) => {
expect(isVsCodeRemoteSshCommand(command)).toBe(false)
}
)
it('recognizes resolved Windows launchers by executable basename', () => {
expect(isVsCodeLauncherExecutable('C:\\Tools\\Code - Insiders.exe')).toBe(true)
expect(isVsCodeLauncherExecutable('C:\\Tools\\cursor.exe')).toBe(false)
})
})
+30
View File
@@ -0,0 +1,30 @@
const VSCODE_LAUNCHER_NAMES = new Set(['code', 'code-insiders', 'code - insiders'])
const WINDOWS_ABSOLUTE_PATH = /^(?:[a-z]:[\\/]|\\\\)/i
function stripMatchingQuotes(value: string): string {
const trimmed = value.trim()
const quote = trimmed[0]
if ((quote === '"' || quote === "'") && trimmed.endsWith(quote)) {
return trimmed.slice(1, -1)
}
return trimmed
}
export function isVsCodeLauncherExecutable(command: string): boolean {
const unquoted = stripMatchingQuotes(command)
const segments = unquoted.split(/[\\/]/)
const fileName = segments.at(-1) ?? ''
const launcherName = fileName.replace(/\.(?:cmd|exe|bat)$/i, '').toLowerCase()
return VSCODE_LAUNCHER_NAMES.has(launcherName)
}
export function isVsCodeRemoteSshCommand(command: string | undefined): boolean {
const trimmed = command?.trim() || 'code'
const unquoted = stripMatchingQuotes(trimmed)
if (!/\s/.test(unquoted)) {
return isVsCodeLauncherExecutable(unquoted)
}
const isAbsolutePath = unquoted.startsWith('/') || WINDOWS_ABSOLUTE_PATH.test(unquoted)
return isAbsolutePath && isVsCodeLauncherExecutable(unquoted)
}