mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 00:03:45 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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']
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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'}`
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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"']]))
|
||||
|
||||
@@ -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'}`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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.')
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -182,7 +182,7 @@ export function registerCoreHandlers(
|
||||
onBeforeSignOut: lifecycleOptions.onBeforeOrcaProfileSignOut
|
||||
})
|
||||
registerBrowserHandlers()
|
||||
registerShellHandlers()
|
||||
registerShellHandlers(store)
|
||||
registerPetHandlers()
|
||||
registerSessionHandlers(store)
|
||||
registerUIHandlers(store)
|
||||
|
||||
+187
-13
@@ -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
@@ -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
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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}` }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user