Merge remote-tracking branch 'origin/main' into fix/renderer-crash-white-screen

This commit is contained in:
Brennan Benson
2026-07-13 19:05:16 -07:00
24 changed files with 1148 additions and 136 deletions
+6
View File
@@ -6,6 +6,8 @@ on:
- '.github/workflows/computer-e2e.yml'
- 'config/electron-builder.config.cjs'
- 'config/scripts/build-computer-macos.mjs'
- 'config/scripts/build-windows-cli-launcher.mjs'
- 'config/scripts/build-windows-cli-launcher.test.mjs'
- 'config/scripts/computer-e2e-workflow.test.mjs'
- 'config/scripts/computer-use-skill-guidance.test.mjs'
- 'config/scripts/computer-use-smoke.mjs'
@@ -22,12 +24,15 @@ on:
- 'native/computer-use-macos/**'
- 'native/computer-use-linux/**'
- 'native/computer-use-windows/**'
- 'native/windows-cli-launcher/**'
- 'skills/computer-use/SKILL.md'
- 'src/cli/**'
- 'src/main/computer/**'
- 'src/main/runtime/rpc/dispatcher.ts'
- 'src/main/runtime/rpc/errors.ts'
- 'src/main/runtime/rpc/methods/computer*.ts'
- 'src/main/ssh/ssh-remote-cli-launcher.ts'
- 'src/main/ssh/ssh-remote-cli-launcher.test.ts'
- 'src/shared/computer-use-*.ts'
- 'tests/e2e/computer-linux.e2e.ts'
- 'tests/e2e/computer-mac.e2e.ts'
@@ -73,6 +78,7 @@ jobs:
- run: >-
pnpm vitest run
config/scripts/build-windows-cli-launcher.test.mjs
src/main/ssh/ssh-remote-cli-launcher.test.ts
config/scripts/computer-e2e-workflow.test.mjs
config/scripts/computer-use-skill-guidance.test.mjs
config/scripts/computer-use-smoke.test.mjs
@@ -5,7 +5,11 @@ import { existsSync, mkdirSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
if (process.platform !== 'win32') {
process.exit(0)
// Why: electron-builder treats a skipped native build like success and can
// continue toward a Windows package whose declared orca.exe does not exist.
throw new Error(
'Windows CLI launcher compilation requires a Windows host; refusing to package without it.'
)
}
const repoRoot = resolve(import.meta.dirname, '../..')
@@ -5,9 +5,27 @@ import { spawnSync } from 'node:child_process'
import { describe, expect, it } from 'vitest'
const itWindows = process.platform === 'win32' ? it : it.skip
const itCrossHost = process.platform === 'win32' ? it.skip : it
const projectRoot = resolve(import.meta.dirname, '../..')
describe('Windows CLI launcher', () => {
itCrossHost('fails closed when the Windows launcher cannot be compiled on this host', () => {
const outputRoot = mkdtempSync(join(tmpdir(), 'orca cross-host launcher '))
try {
const result = spawnSync(
process.execPath,
['config/scripts/build-windows-cli-launcher.mjs', '--output', join(outputRoot, 'orca.exe')],
{ cwd: projectRoot, encoding: 'utf8' }
)
expect(result.status).not.toBe(0)
expect(result.stderr).toContain('Windows CLI launcher')
expect(result.stderr).toContain('Windows host')
} finally {
rmSync(outputRoot, { recursive: true, force: true })
}
})
itWindows('preserves a multiline argument from PowerShell through the native launcher', () => {
const appRoot = mkdtempSync(join(tmpdir(), 'orca cli launcher '))
try {
+13
View File
@@ -23,6 +23,7 @@ import {
getActiveProviderRateLimits,
getInactiveProviderUsage,
getUsageBarState,
getWindowResetLabel,
hasActiveProviderUsage,
UsageBar
} from '../../../src/components/AccountUsage'
@@ -40,6 +41,14 @@ export default function AccountsScreen() {
const [refreshing, setRefreshing] = useState(false)
const [busyAccountId, setBusyAccountId] = useState<string | null>(null)
// Why: the reset countdown must stay fresh while the screen sits open —
// snapshot pushes only arrive when the desktop's rate-limit poll completes.
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 60_000)
return () => clearInterval(id)
}, [])
useEffect(() => {
if (!hostId) {
return
@@ -163,12 +172,14 @@ export default function AccountsScreen() {
usedPercent={activeSessionBar.usedPercent}
unavailable={activeSessionBar.unavailable}
loading={activeSessionBar.loading}
resetText={getWindowResetLabel(activeUsage, 'session', now)}
/>
<UsageBar
label="7d"
usedPercent={activeWeeklyBar.usedPercent}
unavailable={activeWeeklyBar.unavailable}
loading={activeWeeklyBar.loading}
resetText={getWindowResetLabel(activeUsage, 'weekly', now)}
/>
</View>
) : null}
@@ -211,12 +222,14 @@ export default function AccountsScreen() {
usedPercent={sessionBar.usedPercent}
unavailable={sessionBar.unavailable}
loading={sessionBar.loading}
resetText={getWindowResetLabel(usage, 'session', now)}
/>
<UsageBar
label="7d"
usedPercent={weeklyBar.usedPercent}
unavailable={weeklyBar.unavailable}
loading={weeklyBar.loading}
resetText={getWindowResetLabel(usage, 'weekly', now)}
/>
</View>
{usage?.error ? (
+5
View File
@@ -506,6 +506,11 @@ async function openPairingUrlInSimulator(pairingUrl, deviceUdid, runtime, worktr
await execFileAsync('xcrun', ['simctl', 'openurl', deviceUdid, pairingUrl])
await new Promise((resolve) => setTimeout(resolve, 2000))
// Why: the first deep link can arrive while the freshly opened Expo app is
// still mounting, so resend it once the JS router is ready to receive URLs.
await execFileAsync('xcrun', ['simctl', 'openurl', deviceUdid, pairingUrl])
await new Promise((resolve) => setTimeout(resolve, 2000))
// Why: the mobile app intentionally asks for a trust confirmation before
// saving a host. This lands on the Pair button on current iPhone simulators.
await orca(['emulator', 'tap', '0.5', '0.56', '--worktree', worktree, '--json'], {
+44 -20
View File
@@ -17,6 +17,7 @@ export {
getActiveProviderRateLimits,
getInactiveProviderUsage,
getUsageBarState,
getWindowResetLabel,
hasActiveProviderUsage,
hasRenderableUsage
} from './account-usage-state'
@@ -28,12 +29,14 @@ export function UsageBar({
label,
usedPercent,
unavailable,
loading
loading,
resetText
}: {
label: string
usedPercent: number | null
unavailable: boolean
loading?: boolean
resetText?: string | null
}) {
// Why: round then clamp so bar width, color, and label share one value (desktop parity).
const used = usedPercent == null ? null : Math.max(0, Math.min(100, Math.round(usedPercent)))
@@ -47,34 +50,48 @@ export function UsageBar({
? colors.statusAmber
: colors.statusGreen
return (
<View style={styles.usageBar}>
<Text style={styles.usageLabel}>{label}</Text>
<View style={styles.usageTrack}>
<View
style={[
styles.usageFill,
{
width: `${used ?? 0}%`,
backgroundColor: unavailable ? colors.textMuted : barColor
}
]}
/>
<View style={styles.usageBarColumn}>
<View style={styles.usageBar}>
<Text style={styles.usageLabel}>{label}</Text>
<View style={styles.usageTrack}>
<View
style={[
styles.usageFill,
{
width: `${used ?? 0}%`,
backgroundColor: unavailable ? colors.textMuted : barColor
}
]}
/>
</View>
{loading ? (
<ActivityIndicator
size="small"
color={colors.textSecondary}
style={styles.usageSpinner}
/>
) : (
<Text style={styles.usageValue}>{unavailable || used == null ? '—' : `${used}%`}</Text>
)}
</View>
{loading ? (
<ActivityIndicator size="small" color={colors.textSecondary} style={styles.usageSpinner} />
) : (
<Text style={styles.usageValue}>{unavailable || used == null ? '—' : `${used}%`}</Text>
)}
{resetText ? (
<Text style={styles.usageResetText} numberOfLines={1}>
{resetText}
</Text>
) : null}
</View>
)
}
const styles = StyleSheet.create({
usageBarColumn: {
flex: 1,
gap: 2
},
usageBar: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
flex: 1
gap: spacing.xs
},
usageLabel: {
fontSize: typography.metaSize,
@@ -100,5 +117,12 @@ const styles = StyleSheet.create({
},
usageSpinner: {
width: 36
},
// Why: indented past the window label so the countdown aligns with the
// start of the track above it.
usageResetText: {
fontSize: typography.metaSize,
color: colors.textMuted,
marginLeft: 22 + spacing.xs
}
})
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
import {
getInactiveProviderUsage,
getUsageBarState,
getWindowResetLabel,
hasActiveProviderUsage,
hasRenderableUsage,
type AccountsSnapshot,
@@ -117,6 +118,65 @@ describe('getInactiveProviderUsage', () => {
})
})
describe('getWindowResetLabel', () => {
const now = 1_700_000_000_000
const min = 60_000
const hour = 60 * min
const day = 24 * hour
function makeWindow(resetsAt: number | null): ProviderRateLimits['session'] {
return { usedPercent: 13, windowMinutes: 300, resetsAt, resetDescription: null }
}
it('is null when there are no limits or the window has no reset timestamp', () => {
expect(getWindowResetLabel(null, 'session', now)).toBe(null)
expect(getWindowResetLabel(makeLimits({ status: 'ok' }), 'session', now)).toBe(null)
expect(
getWindowResetLabel(makeLimits({ status: 'ok', session: makeWindow(null) }), 'session', now)
).toBe(null)
})
it('formats minutes, hours+minutes, and days+hours like the desktop tooltip', () => {
expect(
getWindowResetLabel(makeLimits({ session: makeWindow(now + 47 * min) }), 'session', now)
).toBe('Resets in 47m')
expect(
getWindowResetLabel(
makeLimits({ session: makeWindow(now + 3 * hour + 54 * min) }),
'session',
now
)
).toBe('Resets in 3h 54m')
expect(
getWindowResetLabel(
makeLimits({ weekly: makeWindow(now + 6 * day + 7 * hour) }),
'weekly',
now
)
).toBe('Resets in 6d 7h')
})
it('formats exact hours and exact days without a zero remainder', () => {
expect(
getWindowResetLabel(makeLimits({ session: makeWindow(now + 2 * hour) }), 'session', now)
).toBe('Resets in 2h')
expect(
getWindowResetLabel(makeLimits({ weekly: makeWindow(now + 7 * day) }), 'weekly', now)
).toBe('Resets in 7d')
})
it('reports "Resets now" for a reset timestamp in the past', () => {
expect(
getWindowResetLabel(makeLimits({ session: makeWindow(now - min) }), 'session', now)
).toBe('Resets now')
})
it('reads the requested window only', () => {
const limits = makeLimits({ session: makeWindow(now + hour) })
expect(getWindowResetLabel(limits, 'weekly', now)).toBe(null)
})
})
describe('getUsageBarState', () => {
it('keeps stale window data visible during a transient error', () => {
const bar = getUsageBarState(
@@ -5,6 +5,8 @@
// Pure state/selectors live here (no React Native imports) so they can be
// unit-tested directly; AccountUsage.tsx re-exports them alongside the
// UsageBar component.
import { formatResetCountdown } from '../../../src/shared/rate-limit-reset-format'
export type RateLimitWindow = {
usedPercent: number
windowMinutes: number
@@ -117,6 +119,27 @@ export function getUsageBarState(
}
}
/**
* Reset countdown for one window, e.g. "Resets in 3h 54m" / "Resets now",
* or null when the window has no reset timestamp (so the UI degrades to
* today's bars-only layout).
*
* Why: shares formatResetCountdown with the desktop status-bar tooltip so the
* copy stays identical across surfaces. `now` is a parameter so the function
* stays pure and unit-testable.
*/
export function getWindowResetLabel(
limits: ProviderRateLimits | null,
windowKey: 'session' | 'weekly',
now: number
): string | null {
const resetsAt = limits?.[windowKey]?.resetsAt
if (resetsAt == null) {
return null
}
return formatResetCountdown(resetsAt - now)
}
// Why: the usage UI must render for the system-default login, not only for
// Orca-managed accounts. Show a provider when it has at least one managed
// account OR active rate-limit data for the system-default target.
+8 -10
View File
@@ -1174,10 +1174,10 @@ describe('CliInstaller', () => {
}
)
it('resolves packaged Windows command path to resources/bin/orca.exe', async () => {
it('resolves custom-install packaged Windows command path from resourcesPath', async () => {
const fixture = await makeFixture()
const localAppDataPath = fixture.root
const resourcesPath = join(fixture.root, 'resources')
const localAppDataPath = join(fixture.root, 'AppData', 'Local')
const resourcesPath = join(fixture.root, 'D Custom Orca', 'resources')
await mkdir(join(resourcesPath, 'bin'), { recursive: true })
await writeFile(join(resourcesPath, 'bin', 'orca.exe'), 'native launcher', 'utf8')
@@ -1187,22 +1187,20 @@ describe('CliInstaller', () => {
resourcesPath,
localAppDataPath,
userDataPath: fixture.userDataPath,
execPath: join(localAppDataPath, 'Programs', 'Orca', 'Orca.exe'),
execPath: join(fixture.root, 'D Custom Orca', 'Orca.exe'),
appPath: fixture.appPath,
userPathReader: async () => null,
userPathWriter: async () => {}
})
const status = await installer.getStatus()
expect(status.commandPath).toBe(
join(localAppDataPath, 'Programs', 'Orca', 'resources', 'bin', 'orca.exe')
)
expect(status.commandPath).toBe(join(resourcesPath, 'bin', 'orca.exe'))
})
it('does not overwrite the packaged Windows launcher while registering PATH', async () => {
const fixture = await makeFixture()
const localAppDataPath = fixture.root
const resourcesPath = join(localAppDataPath, 'Programs', 'Orca', 'resources')
const localAppDataPath = join(fixture.root, 'AppData', 'Local')
const resourcesPath = join(fixture.root, 'D Custom Orca', 'resources')
const bundledLauncher = join(resourcesPath, 'bin', 'orca.exe')
const bundledContent = 'native launcher'
await mkdir(dirname(bundledLauncher), { recursive: true })
@@ -1215,7 +1213,7 @@ describe('CliInstaller', () => {
resourcesPath,
localAppDataPath,
userDataPath: fixture.userDataPath,
execPath: join(localAppDataPath, 'Programs', 'Orca', 'Orca.exe'),
execPath: join(fixture.root, 'D Custom Orca', 'Orca.exe'),
appPath: fixture.appPath,
userPathReader: async () => userPath,
userPathWriter: async (value) => {
+3 -1
View File
@@ -356,7 +356,9 @@ export class CliInstaller {
}
if (this.platform === 'win32') {
return join(this.localAppDataPath, 'Programs', 'Orca', 'resources', 'bin', 'orca.exe')
// Why: NSIS /D installs can live outside LOCALAPPDATA. The packaged
// resources directory is the authoritative native launcher location.
return getBundledLauncherPath(this.platform, this.resourcesPath)
}
return null
@@ -0,0 +1,155 @@
import { describe, expect, it } from 'vitest'
import { hasSufficientWindowsFirewallRemoteScope } from './windows-firewall-remote-scope'
type RuleScope = { remoteAddresses: unknown }
function rule(remoteAddresses: unknown): RuleScope {
return { remoteAddresses }
}
describe('Windows firewall remote-address scope', () => {
it.each([['Any'], ['any'], ['LocalSubnet']])('accepts the documented %s scope', (scope) => {
expect(hasSufficientWindowsFirewallRemoteScope([rule(scope)], undefined, undefined)).toBe(true)
})
it.each([['Any4'], ['LocalSubnet4']])(
'accepts address-family-specific %s on the selected IPv4 interface',
(scope) => {
expect(hasSufficientWindowsFirewallRemoteScope([rule([scope])], '192.168.0.108', 24)).toBe(
true
)
}
)
it('keeps address-family-specific keywords on the selected interface family', () => {
expect(hasSufficientWindowsFirewallRemoteScope([rule(['Any6'])], 'fd7a:115c:a1e0::5', 64)).toBe(
true
)
expect(
hasSufficientWindowsFirewallRemoteScope([rule(['LocalSubnet6'])], 'fd7a:115c:a1e0::5', 64)
).toBe(true)
expect(
hasSufficientWindowsFirewallRemoteScope([rule(['LocalSubnet6'])], '192.168.0.108', 24)
).toBe(false)
})
it.each([
['192.168.0.0/24', '192.168.0.108', 24],
['192.168.0.0-192.168.0.255', '192.168.0.108', 24],
['fd7a:115c:a1e0::/64', 'fd7a:115c:a1e0::5', 64],
['fd7a:115c:a1e0::-fd7a:115c:a1e0:0:ffff:ffff:ffff:ffff', 'fd7a:115c:a1e0::5', 64]
])('accepts explicit scope %s covering the selected local subnet', (scope, address, prefix) => {
expect(hasSufficientWindowsFirewallRemoteScope([rule([scope])], address, prefix)).toBe(true)
})
it.each([
['192.168.1.0/24', '192.168.0.108', 24],
['192.168.0.64/26', '192.168.0.108', 24],
['192.168.0.108', '192.168.0.108', 24],
['fd7a:115c:a1e1::/64', 'fd7a:115c:a1e0::5', 64],
['Internet', '192.168.0.108', 24]
])('rejects restrictive or unsupported scope %s', (scope, address, prefix) => {
expect(hasSufficientWindowsFirewallRemoteScope([rule([scope])], address, prefix)).toBe(false)
})
it('does not infer explicit scope coverage without selected interface subnet data', () => {
expect(
hasSufficientWindowsFirewallRemoteScope([rule(['192.168.0.0/24'])], undefined, undefined)
).toBe(false)
expect(
hasSufficientWindowsFirewallRemoteScope([rule(['192.168.0.0/24'])], '192.168.0.108', 40)
).toBe(false)
expect(
hasSufficientWindowsFirewallRemoteScope([rule(['100.64.0.0/10'])], '100.64.1.20', 32)
).toBe(false)
})
it.each([
undefined,
null,
[],
{},
[rule(undefined)],
[rule([])],
[rule([''])],
[rule(['not-an-address'])],
[{ remoteAddresses: [42] }]
])('rejects malformed or empty structured output %#', (rules) => {
expect(hasSufficientWindowsFirewallRemoteScope(rules, '192.168.0.108', 24)).toBe(false)
})
it('evaluates each rule independently instead of merging partial ranges', () => {
expect(
hasSufficientWindowsFirewallRemoteScope(
[rule(['192.168.0.0-192.168.0.127']), rule(['192.168.0.128-192.168.0.255'])],
'192.168.0.108',
24
)
).toBe(false)
expect(
hasSufficientWindowsFirewallRemoteScope(
[rule(['192.168.1.0/24']), rule(['192.168.0.0/24'])],
'192.168.0.108',
24
)
).toBe(true)
})
it('accepts PowerShell single-object and single-string JSON shapes', () => {
expect(
hasSufficientWindowsFirewallRemoteScope(
{ remoteAddresses: '192.168.0.0/24' },
'192.168.0.108',
24
)
).toBe(true)
})
it('accepts dotted-netmask CIDR with a contiguous mask and rejects a holey one', () => {
expect(
hasSufficientWindowsFirewallRemoteScope(
[rule(['192.168.0.0/255.255.255.0'])],
'192.168.0.108',
24
)
).toBe(true)
expect(
hasSufficientWindowsFirewallRemoteScope(
[rule(['192.168.0.0/255.0.255.0'])],
'192.168.0.108',
24
)
).toBe(false)
})
it('treats a single-host (/32) interface as coverable only by Any/LocalSubnet keywords', () => {
// A /32 subnet is just the desktop itself, so an explicit range cannot prove
// the phone (a different host) is allowed — only the keywords can.
expect(hasSufficientWindowsFirewallRemoteScope([rule(['Any'])], '100.64.1.20', 32)).toBe(true)
expect(hasSufficientWindowsFirewallRemoteScope([rule(['LocalSubnet'])], '100.64.1.20', 32)).toBe(
true
)
expect(
hasSufficientWindowsFirewallRemoteScope([rule(['100.64.0.0/10'])], '100.64.1.20', 32)
).toBe(false)
})
it('fails address-family keywords closed when the interface family is unknown', () => {
expect(hasSufficientWindowsFirewallRemoteScope([rule(['Any'])], undefined, undefined)).toBe(true)
expect(hasSufficientWindowsFirewallRemoteScope([rule(['Any4'])], undefined, undefined)).toBe(
false
)
expect(hasSufficientWindowsFirewallRemoteScope([rule(['Any6'])], undefined, undefined)).toBe(
false
)
})
it.each([['Intranet'], ['DNS'], ['DHCP'], ['DefaultGateway'], ['PlayToDevice']])(
'fails the policy-defined %s keyword closed rather than assuming subnet coverage',
(scope) => {
expect(hasSufficientWindowsFirewallRemoteScope([rule([scope])], '192.168.0.108', 24)).toBe(
false
)
}
)
})
@@ -0,0 +1,216 @@
type IpVersion = 4 | 6
type ParsedIpAddress = {
version: IpVersion
bits: 32 | 128
value: bigint
}
type IpRange = {
version: IpVersion
start: bigint
end: bigint
}
export function hasSufficientWindowsFirewallRemoteScope(
ruleScopes: unknown,
localAddress: unknown,
localPrefixLength: unknown
): boolean {
const rules = Array.isArray(ruleScopes) ? ruleScopes : [ruleScopes]
const localSubnet = parseSubnet(localAddress, localPrefixLength)
// Why: coverage is checked per scope, never unioned across rules — this
// advisory check fails safe, so accepting fragmented rules only adds risk.
return rules.some((rule) => ruleHasSufficientScope(rule, localSubnet))
}
function ruleHasSufficientScope(rule: unknown, localSubnet: IpRange | null): boolean {
if (!isRecord(rule)) {
return false
}
const addresses = Array.isArray(rule.remoteAddresses)
? rule.remoteAddresses
: [rule.remoteAddresses]
return addresses.some(
(address) => typeof address === 'string' && addressScopeIsSufficient(address, localSubnet)
)
}
function addressScopeIsSufficient(scope: string, localSubnet: IpRange | null): boolean {
const normalized = scope.trim().toLowerCase()
if (normalized === 'any' || normalized === 'localsubnet') {
return true
}
if (
normalized === 'any4' ||
normalized === 'any6' ||
normalized === 'localsubnet4' ||
normalized === 'localsubnet6'
) {
// Why: these keywords cover a single family, so they need the selected
// interface's family; an unknown family (null subnet) fails closed.
return localSubnet?.version === (normalized.endsWith('4') ? 4 : 6)
}
if (!localSubnet) {
return false
}
// Why: without a phone IP we can only prove coverage when the rule spans the
// whole selected subnet; a single-host subnet (/32, /128 VPN/Tailscale) is just
// the desktop, so start !== end blocks a desktop-only rule from a false-allow.
const explicitRange = parseIpRange(scope)
return (
localSubnet.start !== localSubnet.end &&
explicitRange?.version === localSubnet.version &&
explicitRange.start <= localSubnet.start &&
explicitRange.end >= localSubnet.end
)
}
function parseSubnet(address: unknown, prefixLength: unknown): IpRange | null {
if (typeof address !== 'string' || typeof prefixLength !== 'number') {
return null
}
const parsed = parseIpAddress(address)
return parsed ? subnetFromParsed(parsed, prefixLength) : null
}
function subnetFromParsed(parsed: ParsedIpAddress, prefixLength: number): IpRange | null {
if (!Number.isInteger(prefixLength) || prefixLength < 0 || prefixLength > parsed.bits) {
return null
}
const hostBits = BigInt(parsed.bits - prefixLength)
const hostMask = hostBits === 0n ? 0n : (1n << hostBits) - 1n
const start = parsed.value & ~hostMask
return { version: parsed.version, start, end: start | hostMask }
}
// Why: Windows also accepts dotted-netmask CIDR (192.168.0.0/255.255.255.0);
// convert a contiguous mask to a prefix length and fail closed on holey masks.
function maskPrefixLength(maskText: string, version: IpVersion): number | null {
const mask = parseIpAddress(maskText)
if (!mask || mask.version !== version) {
return null
}
const fullMask = (1n << BigInt(mask.bits)) - 1n
const hostPart = ~mask.value & fullMask
if ((hostPart & (hostPart + 1n)) !== 0n) {
return null
}
let hostBits = 0
for (let remaining = hostPart; remaining > 0n; remaining >>= 1n) {
hostBits += 1
}
return mask.bits - hostBits
}
function parseIpRange(scope: string): IpRange | null {
const trimmed = scope.trim()
const dashIndex = trimmed.indexOf('-')
if (dashIndex >= 0) {
if (dashIndex !== trimmed.lastIndexOf('-')) {
return null
}
const start = parseIpAddress(trimmed.slice(0, dashIndex))
const end = parseIpAddress(trimmed.slice(dashIndex + 1))
if (!start || !end || start.version !== end.version || start.value > end.value) {
return null
}
return { version: start.version, start: start.value, end: end.value }
}
const slashIndex = trimmed.indexOf('/')
if (slashIndex >= 0) {
if (slashIndex !== trimmed.lastIndexOf('/')) {
return null
}
const address = parseIpAddress(trimmed.slice(0, slashIndex))
if (!address) {
return null
}
const suffix = trimmed.slice(slashIndex + 1)
const prefixLength = /^\d+$/.test(suffix)
? Number(suffix)
: maskPrefixLength(suffix, address.version)
return prefixLength === null ? null : subnetFromParsed(address, prefixLength)
}
const address = parseIpAddress(trimmed)
return address ? { version: address.version, start: address.value, end: address.value } : null
}
function parseIpAddress(input: string): ParsedIpAddress | null {
const trimmed = input.trim()
const bracketed = trimmed.startsWith('[') || trimmed.endsWith(']')
if (bracketed && !(trimmed.startsWith('[') && trimmed.endsWith(']'))) {
return null
}
const address = (bracketed ? trimmed.slice(1, -1) : trimmed).split('%', 1)[0] ?? ''
return address.includes(':') ? parseIpv6(address) : parseIpv4(address)
}
function parseIpv4(address: string): ParsedIpAddress | null {
const octets = address.split('.')
if (octets.length !== 4 || octets.some((octet) => !/^\d{1,3}$/.test(octet))) {
return null
}
const values = octets.map(Number)
if (values.some((octet) => octet > 255)) {
return null
}
const value = values.reduce((result, octet) => (result << 8n) | BigInt(octet), 0n)
return { version: 4, bits: 32, value }
}
function parseIpv6(address: string): ParsedIpAddress | null {
const expandedAddress = expandEmbeddedIpv4(address)
if (!expandedAddress) {
return null
}
const halves = expandedAddress.split('::')
if (halves.length > 2) {
return null
}
const left = splitIpv6Half(halves[0] ?? '')
const right = splitIpv6Half(halves[1] ?? '')
if (!left || !right) {
return null
}
const hasCompression = halves.length === 2
const missingGroups = 8 - left.length - right.length
if ((!hasCompression && missingGroups !== 0) || (hasCompression && missingGroups < 1)) {
return null
}
const groups = [...left, ...Array<string>(missingGroups).fill('0'), ...right]
const value = groups.reduce((result, group) => (result << 16n) | BigInt(`0x${group}`), 0n)
return { version: 6, bits: 128, value }
}
function expandEmbeddedIpv4(address: string): string | null {
if (!address.includes('.')) {
return address
}
const lastColon = address.lastIndexOf(':')
const ipv4 = parseIpv4(address.slice(lastColon + 1))
if (lastColon < 0 || !ipv4) {
return null
}
const high = ((ipv4.value >> 16n) & 0xffffn).toString(16)
const low = (ipv4.value & 0xffffn).toString(16)
return `${address.slice(0, lastColon)}:${high}:${low}`
}
function splitIpv6Half(half: string): string[] | null {
if (half === '') {
return []
}
const groups = half.split(':')
return groups.every((group) => /^[\da-f]{1,4}$/i.test(group)) ? groups : null
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
@@ -24,7 +24,9 @@ describe('windows mobile firewall', () => {
it('inspects the exact executable, port, and selected interface profile', async () => {
const runPowerShell = vi.fn().mockResolvedValue(
JSON.stringify({
ruleAllowed: true,
matchingRuleScopes: [{ remoteAddresses: ['192.168.0.0/24'] }],
localAddress: '192.168.0.108',
localPrefixLength: 24,
privateFirewallEnabled: true,
networkCategory: 'Private'
})
@@ -47,6 +49,29 @@ describe('windows mobile firewall', () => {
expect(script).toContain("C:\\Users\\O''Brien\\Orca\\Orca.exe")
expect(script).toContain("$profile -match 'Private'")
expect(script).toContain("Get-NetIPAddress -IPAddress '192.168.0.108'")
expect(script).toContain('Get-NetFirewallAddressFilter')
expect(script).toContain('remoteAddresses = @($addressFilter.RemoteAddress')
expect(script).toContain('$localPrefixLength = [int]$ip.PrefixLength')
})
it('does not accept a qualifying rule whose remote-address scope excludes the phone subnet', async () => {
const runPowerShell = vi.fn().mockResolvedValue(
JSON.stringify({
matchingRuleScopes: [{ remoteAddresses: ['192.168.1.0/24'] }],
localAddress: '192.168.0.108',
localPrefixLength: 24,
privateFirewallEnabled: true,
networkCategory: 'Private'
})
)
await expect(
inspectWindowsMobileFirewall(6768, '192.168.0.108', environment(runPowerShell))
).resolves.toMatchObject({
supported: true,
ruleAllowed: false,
inspectionAvailable: true
})
})
it('does not support non-Windows or unpackaged development builds', async () => {
@@ -81,6 +106,22 @@ describe('windows mobile firewall', () => {
})
})
it('returns an actionable status for malformed or empty PowerShell output', async () => {
for (const stdout of ['', 'not json']) {
await expect(
inspectWindowsMobileFirewall(
6768,
undefined,
environment(vi.fn().mockResolvedValue(stdout))
)
).resolves.toMatchObject({
supported: true,
ruleAllowed: false,
inspectionAvailable: false
})
}
})
it('repairs only Orca mobile pairing on private networks after elevation', async () => {
const runPowerShell = vi.fn().mockResolvedValue('{"launched":true,"exitCode":0}')
await expect(repairWindowsMobileFirewall(6769, environment(runPowerShell))).resolves.toEqual({
+22 -6
View File
@@ -5,6 +5,7 @@ import type {
WindowsMobileFirewallStatus,
WindowsNetworkCategory
} from '../../shared/windows-mobile-firewall'
import { hasSufficientWindowsFirewallRemoteScope } from './windows-firewall-remote-scope'
const FIREWALL_RULE_NAME = 'Orca.MobilePairing'
const FIREWALL_RULE_DISPLAY_NAME = 'Orca Mobile Pairing'
@@ -22,7 +23,9 @@ export type WindowsMobileFirewallEnvironment = {
}
type FirewallInspection = {
ruleAllowed: boolean
matchingRuleScopes?: unknown
localAddress?: unknown
localPrefixLength?: unknown
privateFirewallEnabled: boolean
networkCategory: string
}
@@ -51,7 +54,11 @@ export async function inspectWindowsMobileFirewall(
return {
supported: true,
port,
ruleAllowed: result.ruleAllowed === true,
ruleAllowed: hasSufficientWindowsFirewallRemoteScope(
result.matchingRuleScopes,
result.localAddress,
result.localPrefixLength
),
privateFirewallEnabled: result.privateFirewallEnabled !== false,
networkCategory: parseNetworkCategory(result.networkCategory),
inspectionAvailable: true
@@ -152,12 +159,16 @@ function buildInspectionScript(port: number, executablePath: string, address?: s
? `
try {
$ip = Get-NetIPAddress -IPAddress ${quotePowerShell(address)} -ErrorAction Stop | Select-Object -First 1
$localAddress = [string]$ip.IPAddress
$localPrefixLength = [int]$ip.PrefixLength
$profile = Get-NetConnectionProfile -InterfaceIndex $ip.InterfaceIndex -ErrorAction Stop | Select-Object -First 1
if ($profile) { $networkCategory = [string]$profile.NetworkCategory }
} catch {}`
: ''
// Why: NetSecurity filter properties are stable across localized Windows
// display output and keep every rule's address scope independent.
return `$ErrorActionPreference = 'Stop'
$ruleAllowed = $false
$matchingRuleScopes = @()
$rules = @(Get-NetFirewallApplicationFilter -Program ${quotePowerShell(executablePath)} -ErrorAction SilentlyContinue | Get-NetFirewallRule | Where-Object { $_.Enabled -eq 'True' -and $_.Direction -eq 'Inbound' -and $_.Action -eq 'Allow' })
foreach ($rule in $rules) {
$portFilter = $rule | Get-NetFirewallPortFilter
@@ -165,16 +176,21 @@ foreach ($rule in $rules) {
$profile = [string]$rule.Profile
$portMatches = @($portFilter.LocalPort | Where-Object { [string]$_ -eq 'Any' -or [string]$_ -eq '${port}' }).Count -gt 0
if (($protocol -eq 'Any' -or $protocol -eq 'TCP' -or $protocol -eq '6') -and ($profile -eq 'Any' -or $profile -match 'Private') -and $portMatches) {
$ruleAllowed = $true
$addressFilter = $rule | Get-NetFirewallAddressFilter
$matchingRuleScopes += [pscustomobject]@{
remoteAddresses = @($addressFilter.RemoteAddress | ForEach-Object { [string]$_ })
}
}
}
$privateFirewallEnabled = [bool](Get-NetFirewallProfile -Name Private).Enabled
$networkCategory = 'Unknown'${addressLookup}
[pscustomobject]@{
ruleAllowed = $ruleAllowed
matchingRuleScopes = @($matchingRuleScopes)
localAddress = $localAddress
localPrefixLength = $localPrefixLength
privateFirewallEnabled = $privateFirewallEnabled
networkCategory = $networkCategory
} | ConvertTo-Json -Compress`
} | ConvertTo-Json -Depth 4 -Compress`
}
function buildRepairScript(port: number, executablePath: string): string {
+10 -10
View File
@@ -425,7 +425,7 @@ describe('SshRelaySession', () => {
expect(registerSshPtyProvider).toHaveBeenCalledWith('target-1', expect.anything())
})
it('installs a native Windows Orca CLI bridge without POSIX shell commands', async () => {
it('compiles a native Windows Orca CLI bridge without a cmd.exe shim', async () => {
const { mockStore, mockPortForward, getMainWindow } = createMockDeps()
const mockConn = {
writeFile: vi.fn().mockResolvedValue(undefined)
@@ -448,20 +448,20 @@ describe('SshRelaySession', () => {
await session.establish(mockConn)
expect(execCommand).toHaveBeenCalledTimes(1)
expect(execCommand).toHaveBeenCalledTimes(2)
expect(vi.mocked(execCommand).mock.calls[0]?.[1]).toContain('powershell.exe')
expect(vi.mocked(execCommand).mock.calls[0]?.[2]).toEqual({ wrapCommand: false })
expect(mockConn.writeFile).toHaveBeenCalledWith(
'C:/Users/me/.orca-relay/bin/orca.cmd',
expect.stringContaining('@echo off'),
'C:/Users/me/.orca-relay/bin/orca-launcher.cs',
expect.stringContaining('ProcessStartInfo'),
{ hostPlatform: getRemoteHostPlatform('win32-x64') }
)
const shim = vi.mocked(mockConn.writeFile).mock.calls[0]?.[1] as string
expect(shim).toContain('C:/Users/me/.orca-remote/relay-v1')
expect(shim).toContain('\\\\.\\pipe\\orca-relay-123')
expect(shim).not.toContain('if not exist "%ORCA_RELAY_SOCKET_PATH%"')
expect(shim).not.toContain('Orca SSH CLI bridge cannot find the relay socket')
expect(shim).not.toContain('#!/usr/bin/env sh')
const launcherSource = vi.mocked(mockConn.writeFile).mock.calls[0]?.[1] as string
expect(launcherSource).toContain('ORCA_RELAY_SOCKET_PATH')
expect(launcherSource).not.toContain('cmd.exe')
expect(launcherSource).not.toContain('%*')
expect(vi.mocked(execCommand).mock.calls[1]?.[1]).toContain('powershell.exe')
expect(vi.mocked(execCommand).mock.calls[1]?.[2]).toEqual({ wrapCommand: false })
expect(vi.mocked(execCommand).mock.calls.some(([, command]) => command.includes('chmod'))).toBe(
false
)
+21 -60
View File
@@ -62,7 +62,8 @@ import { isMainWindowVisible, onMainWindowBecameVisible } from '../window/main-w
import type { SshPortForwardManager } from './ssh-port-forward'
import type { SshConnection } from './ssh-connection'
import { joinRemotePath, isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform'
import { makeRemoteDirectoryCommand, makeRemoteExecutableCommand } from './ssh-remote-commands'
import { makeRemoteDirectoryCommand } from './ssh-remote-commands'
import { createRemoteCliInstallPlan } from './ssh-remote-cli-launcher'
import {
DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS,
type DetectedPort,
@@ -654,14 +655,14 @@ export class SshRelaySession {
}
try {
await this.installRemoteOrcaCliShim()
await this.installRemoteOrcaCliLauncher()
} catch (error) {
// Why: the remote `orca` CLI shim is a convenience bridge. On session-
// Why: the remote `orca` CLI launcher is a convenience bridge. On session-
// limited remotes (MaxSessions=1) the relay bridge holds the only slot,
// so this raw-connection install can fail — that must not fail the
// whole connection, matching the managed-hook install above.
console.warn(
`[ssh-relay-session] remote orca CLI shim install failed for ${this.targetId}: ${
`[ssh-relay-session] remote orca CLI launcher install failed for ${this.targetId}: ${
error instanceof Error ? error.message : String(error)
}`
)
@@ -792,34 +793,38 @@ export class SshRelaySession {
}
}
private async installRemoteOrcaCliShim(): Promise<void> {
private async installRemoteOrcaCliLauncher(): Promise<void> {
if (!this.remoteCliBridgeEnv) {
return
}
const { binDir, hostPlatform } = this.remoteCliBridgeEnv
const shim = buildRemoteCliShim(this.remoteCliBridgeEnv)
const plan = createRemoteCliInstallPlan(this.remoteCliBridgeEnv)
const conn = this.requireReadyConnection()
await execCommand(conn, makeRemoteDirectoryCommand(hostPlatform, binDir), {
wrapCommand: !isWindowsRemoteHost(hostPlatform)
})
if (typeof conn.writeFile === 'function') {
await conn.writeFile(shim.path, shim.contents, { hostPlatform })
for (const file of plan.files) {
await conn.writeFile(file.path, file.contents, { hostPlatform })
}
} else {
const sftp = await conn.sftp()
try {
await new Promise<void>((resolve, reject) => {
const ws = sftp.createWriteStream(shim.path)
sftp.once('error', reject)
ws.once('close', resolve)
ws.once('error', reject)
ws.end(shim.contents)
})
for (const file of plan.files) {
await new Promise<void>((resolve, reject) => {
const ws = sftp.createWriteStream(file.path)
sftp.once('error', reject)
ws.once('close', resolve)
ws.once('error', reject)
ws.end(file.contents)
})
}
} finally {
sftp.end()
}
}
if (!isWindowsRemoteHost(hostPlatform)) {
await execCommand(conn, makeRemoteExecutableCommand(hostPlatform, shim.path))
for (const command of plan.postWriteCommands) {
await execCommand(conn, command, { wrapCommand: !isWindowsRemoteHost(hostPlatform) })
}
}
@@ -1325,47 +1330,3 @@ export class SshRelaySession {
}
}
}
function quoteSh(value: string): string {
return `'${value.replaceAll("'", `'\\''`)}'`
}
function buildRemoteCliShim(env: RemoteCliBridgeEnv): {
path: string
contents: string
} {
if (isWindowsRemoteHost(env.hostPlatform)) {
const shimPath = joinRemotePath(env.hostPlatform, env.binDir, 'orca.cmd')
return {
path: shimPath,
contents: [
'@echo off',
'setlocal',
`if not defined ORCA_RELAY_NODE_PATH set "ORCA_RELAY_NODE_PATH=${env.nodePath}"`,
`if not defined ORCA_RELAY_DIR set "ORCA_RELAY_DIR=${env.relayDir}"`,
`if not defined ORCA_RELAY_SOCKET_PATH set "ORCA_RELAY_SOCKET_PATH=${env.sockPath}"`,
'"%ORCA_RELAY_NODE_PATH%" "%ORCA_RELAY_DIR%/relay.js" --sock-path "%ORCA_RELAY_SOCKET_PATH%" --orca-cli %*',
'exit /b %ERRORLEVEL%',
''
].join('\r\n')
}
}
const shimPath = joinRemotePath(env.hostPlatform, env.binDir, 'orca')
return {
path: shimPath,
contents: [
'#!/usr/bin/env sh',
'set -eu',
`ORCA_RELAY_NODE_PATH=\${ORCA_RELAY_NODE_PATH:-${quoteSh(env.nodePath)}}`,
`ORCA_RELAY_DIR=\${ORCA_RELAY_DIR:-${quoteSh(env.relayDir)}}`,
`ORCA_RELAY_SOCKET_PATH=\${ORCA_RELAY_SOCKET_PATH:-${quoteSh(env.sockPath)}}`,
'if [ ! -S "$ORCA_RELAY_SOCKET_PATH" ]; then',
' echo "Orca SSH CLI bridge cannot find the relay socket: $ORCA_RELAY_SOCKET_PATH" >&2',
' exit 1',
'fi',
'exec "$ORCA_RELAY_NODE_PATH" "$ORCA_RELAY_DIR/relay.js" --sock-path "$ORCA_RELAY_SOCKET_PATH" --orca-cli "$@"',
''
].join('\n')
}
}
@@ -0,0 +1,143 @@
import { Buffer } from 'node:buffer'
import { spawnSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { createRemoteCliInstallPlan } from './ssh-remote-cli-launcher'
import { getRemoteHostPlatform } from './ssh-remote-platform'
const itWindows = process.platform === 'win32' ? it : it.skip
function decodePowerShellCommand(command: string): string {
const encoded = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/)?.[1]
if (!encoded) {
throw new Error(`Expected an encoded PowerShell command: ${command}`)
}
return Buffer.from(encoded, 'base64').toString('utf16le')
}
describe('SSH remote Orca CLI launcher', () => {
it('compiles a native Windows launcher without a cmd.exe argument bridge', () => {
const hostPlatform = getRemoteHostPlatform('win32-x64')
const plan = createRemoteCliInstallPlan({
binDir: 'C:/Users/me user/.orca-relay/bin',
relayDir: 'C:/Users/me user/.orca-remote/relay-v1',
nodePath: 'C:/Program Files/nodejs/node.exe',
sockPath: '\\\\.\\pipe\\orca-relay-123',
hostPlatform
})
expect(plan.launcherPath).toBe('C:/Users/me user/.orca-relay/bin/orca.exe')
expect(plan.files).toHaveLength(1)
expect(plan.files[0]?.path).toBe('C:/Users/me user/.orca-relay/bin/orca-launcher.cs')
expect(plan.files[0]?.contents).toContain('ProcessStartInfo')
expect(plan.files[0]?.contents).toContain('"--orca-cli"')
expect(plan.files[0]?.contents).toContain("value[index] == '\"'")
expect(plan.files[0]?.contents).toContain("character == '\\\\'")
expect(plan.files[0]?.contents).not.toContain('cmd.exe')
expect(plan.files[0]?.contents).not.toContain('%*')
expect(plan.postWriteCommands).toHaveLength(1)
const compileScript = decodePowerShellCommand(plan.postWriteCommands[0] ?? '')
expect(compileScript).toContain('v4.0.30319\\csc.exe')
// Why: legacy csc.exe is invoked from the bin directory with bare, space-free
// file names so PowerShell 5.1 never mangles a space-bearing absolute path.
expect(compileScript).toContain(
"Set-Location -ErrorAction Stop -LiteralPath 'C:/Users/me user/.orca-relay/bin'"
)
expect(compileScript).toContain('/out:orca.exe')
expect(compileScript).toContain('C:/Users/me user/.orca-relay/bin/orca-launcher.cs')
expect(compileScript).toContain('C:/Users/me user/.orca-relay/bin/orca.cmd')
expect(compileScript.indexOf('orca.cmd')).toBeLessThan(compileScript.indexOf('csc.exe'))
})
itWindows('preserves a multiline argument through the compiled remote launcher', () => {
const root = mkdtempSync(join(tmpdir(), 'orca remote cli '))
try {
const binDir = join(root, 'bin').replaceAll('\\', '/')
const relayDir = join(root, 'relay').replaceAll('\\', '/')
const sockPath = '\\\\.\\pipe\\orca-relay-test'
const plan = createRemoteCliInstallPlan({
binDir,
relayDir,
nodePath: process.execPath,
sockPath,
hostPlatform: getRemoteHostPlatform('win32-x64')
})
for (const file of plan.files) {
mkdirSync(dirname(file.path), { recursive: true })
writeFileSync(file.path, file.contents, 'utf8')
}
const encoded = plan.postWriteCommands[0]?.match(/-EncodedCommand\s+(\S+)/)?.[1]
expect(encoded).toBeTruthy()
const compile = spawnSync(
'powershell.exe',
[
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-EncodedCommand',
encoded!
],
{ encoding: 'utf8' }
)
expect(compile.status, `${compile.stdout}\n${compile.stderr}`).toBe(0)
mkdirSync(relayDir, { recursive: true })
writeFileSync(
join(relayDir, 'relay.js'),
'process.stdout.write(JSON.stringify(process.argv.slice(2)))\n',
'utf8'
)
const body = 'line one\nline two & whoami\n"quoted" C:\\tail\\'
const launched = spawnSync(
plan.launcherPath,
['orchestration', 'send', '--body', body, '--json'],
{
encoding: 'utf8',
env: {
...process.env,
ORCA_RELAY_NODE_PATH: process.execPath,
ORCA_RELAY_DIR: relayDir,
ORCA_RELAY_SOCKET_PATH: sockPath
}
}
)
expect(launched.status, launched.stderr).toBe(0)
expect(JSON.parse(launched.stdout)).toEqual([
'--sock-path',
sockPath,
'--orca-cli',
'orchestration',
'send',
'--body',
body,
'--json'
])
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('keeps the POSIX launcher as an argv-preserving shell exec', () => {
const plan = createRemoteCliInstallPlan({
binDir: '/home/me/.orca-relay/bin',
relayDir: '/home/me/.orca-remote/relay-v1',
nodePath: '/usr/bin/node',
sockPath: '/home/me/.orca-remote/relay-v1/relay.sock',
hostPlatform: getRemoteHostPlatform('linux-x64')
})
expect(plan.launcherPath).toBe('/home/me/.orca-relay/bin/orca')
expect(plan.files).toEqual([
expect.objectContaining({
path: '/home/me/.orca-relay/bin/orca',
contents: expect.stringContaining('--orca-cli "$@"')
})
])
})
})
+237
View File
@@ -0,0 +1,237 @@
import type { RemoteHostPlatform } from './ssh-remote-platform'
import { isWindowsRemoteHost, joinRemotePath } from './ssh-remote-platform'
import { powerShellCommand, powerShellLiteral, powerShellNativeArg } from './ssh-remote-powershell'
type RemoteCliInstallEnv = {
binDir: string
relayDir: string
nodePath: string
sockPath: string
hostPlatform: RemoteHostPlatform
}
type RemoteCliInstallFile = {
path: string
contents: string
}
export type RemoteCliInstallPlan = {
launcherPath: string
files: RemoteCliInstallFile[]
postWriteCommands: string[]
}
const WINDOWS_REMOTE_CLI_LAUNCHER_SOURCE = String.raw`using System;
using System.Diagnostics;
using System.IO;
using System.Text;
internal static class OrcaRemoteCliLauncher
{
private static int Main(string[] args)
{
try
{
string nodePath = RequireEnvironmentVariable("ORCA_RELAY_NODE_PATH");
string relayDirectory = RequireEnvironmentVariable("ORCA_RELAY_DIR");
string socketPath = RequireEnvironmentVariable("ORCA_RELAY_SOCKET_PATH");
string relayPath = Path.Combine(relayDirectory, "relay.js");
if (!File.Exists(nodePath))
{
Console.Error.WriteLine("Orca SSH CLI bridge cannot find Node.js at \"{0}\"", nodePath);
return 1;
}
if (!File.Exists(relayPath))
{
Console.Error.WriteLine("Orca SSH CLI bridge cannot find the relay at \"{0}\"", relayPath);
return 1;
}
ProcessStartInfo startInfo = new ProcessStartInfo
{
FileName = nodePath,
Arguments = BuildArguments(relayPath, socketPath, args),
UseShellExecute = false
};
using (Process child = Process.Start(startInfo))
{
child.WaitForExit();
return child.ExitCode;
}
}
catch (Exception error)
{
Console.Error.WriteLine("Unable to start the Orca SSH CLI bridge: {0}", error.Message);
return 1;
}
}
private static string RequireEnvironmentVariable(string name)
{
string value = Environment.GetEnvironmentVariable(name);
if (String.IsNullOrEmpty(value))
{
throw new InvalidOperationException(name + " is not set.");
}
return value;
}
private static string BuildArguments(string relayPath, string socketPath, string[] args)
{
StringBuilder commandLine = new StringBuilder();
AppendArgument(commandLine, relayPath);
AppendArgument(commandLine, "--sock-path");
AppendArgument(commandLine, socketPath);
AppendArgument(commandLine, "--orca-cli");
foreach (string arg in args)
{
AppendArgument(commandLine, arg);
}
return commandLine.ToString();
}
private static void AppendArgument(StringBuilder commandLine, string value)
{
if (commandLine.Length > 0)
{
commandLine.Append(' ');
}
commandLine.Append(QuoteArgument(value));
}
private static string QuoteArgument(string value)
{
bool requiresQuotes = value.Length == 0;
for (int index = 0; index < value.Length && !requiresQuotes; index += 1)
{
requiresQuotes = value[index] == '"' || Char.IsWhiteSpace(value[index]);
}
if (!requiresQuotes)
{
return value;
}
StringBuilder quoted = new StringBuilder("\"");
int backslashCount = 0;
foreach (char character in value)
{
if (character == '\\')
{
backslashCount += 1;
continue;
}
if (character == '"')
{
quoted.Append('\\', backslashCount * 2 + 1);
quoted.Append('"');
}
else
{
quoted.Append('\\', backslashCount);
quoted.Append(character);
}
backslashCount = 0;
}
quoted.Append('\\', backslashCount * 2);
quoted.Append('"');
return quoted.ToString();
}
}
`
function quoteSh(value: string): string {
return `'${value.replaceAll("'", `'\\''`)}'`
}
function createWindowsLauncherCompileCommand(
binDir: string,
sourceFileName: string,
launcherFileName: string,
launcherPath: string,
sourcePath: string,
legacyShimPath: string
): string {
// Why: legacy csc.exe mis-parses space-bearing absolute paths handed to it by
// Windows PowerShell 5.1's native-argument quoting, so compile from the bin
// directory and pass only the bare, space-free launcher file names.
const compilerArgs = [
'/nologo',
'/target:exe',
'/optimize+',
'/warnaserror+',
`/out:${launcherFileName}`,
sourceFileName
]
.map(powerShellNativeArg)
.join(' ')
return powerShellCommand(
[
// Why: an upgrade must not leave the old %* batch bridge callable when
// compiler discovery fails; losing the convenience CLI is safer.
`Remove-Item -LiteralPath ${powerShellLiteral(legacyShimPath)} -Force -ErrorAction SilentlyContinue`,
`Set-Location -ErrorAction Stop -LiteralPath ${powerShellLiteral(binDir)}`,
'$windowsDirectory = if ($env:WINDIR) { $env:WINDIR } else { $env:SystemRoot }',
`$compilerCandidates = @((Join-Path $windowsDirectory 'Microsoft.NET\\Framework64\\v4.0.30319\\csc.exe'), (Join-Path $windowsDirectory 'Microsoft.NET\\Framework\\v4.0.30319\\csc.exe'))`,
'$compiler = $compilerCandidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1',
"if (-not $compiler) { Write-Error 'Unable to find the .NET Framework C# compiler required for the Orca SSH CLI launcher.'; exit 1 }",
`& $compiler ${compilerArgs}`,
'if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }',
`if (-not (Test-Path -LiteralPath ${powerShellLiteral(launcherPath)} -PathType Leaf)) { Write-Error 'The Orca SSH CLI launcher compiler produced no executable.'; exit 1 }`,
`Remove-Item -LiteralPath ${powerShellLiteral(sourcePath)} -Force`
].join('; ')
)
}
export function createRemoteCliInstallPlan(env: RemoteCliInstallEnv): RemoteCliInstallPlan {
if (isWindowsRemoteHost(env.hostPlatform)) {
const launcherFileName = 'orca.exe'
const sourceFileName = 'orca-launcher.cs'
const launcherPath = joinRemotePath(env.hostPlatform, env.binDir, launcherFileName)
const sourcePath = joinRemotePath(env.hostPlatform, env.binDir, sourceFileName)
const legacyShimPath = joinRemotePath(env.hostPlatform, env.binDir, 'orca.cmd')
const binDir = joinRemotePath(env.hostPlatform, env.binDir)
return {
launcherPath,
files: [{ path: sourcePath, contents: WINDOWS_REMOTE_CLI_LAUNCHER_SOURCE }],
// Why: compiling on the Windows target avoids shipping an unsigned
// cross-host binary while ensuring argv never crosses cmd.exe's parser.
postWriteCommands: [
createWindowsLauncherCompileCommand(
binDir,
sourceFileName,
launcherFileName,
launcherPath,
sourcePath,
legacyShimPath
)
]
}
}
const launcherPath = joinRemotePath(env.hostPlatform, env.binDir, 'orca')
return {
launcherPath,
files: [
{
path: launcherPath,
contents: [
'#!/usr/bin/env sh',
'set -eu',
`ORCA_RELAY_NODE_PATH=\${ORCA_RELAY_NODE_PATH:-${quoteSh(env.nodePath)}}`,
`ORCA_RELAY_DIR=\${ORCA_RELAY_DIR:-${quoteSh(env.relayDir)}}`,
`ORCA_RELAY_SOCKET_PATH=\${ORCA_RELAY_SOCKET_PATH:-${quoteSh(env.sockPath)}}`,
'if [ ! -S "$ORCA_RELAY_SOCKET_PATH" ]; then',
' echo "Orca SSH CLI bridge cannot find the relay socket: $ORCA_RELAY_SOCKET_PATH" >&2',
' exit 1',
'fi',
'exec "$ORCA_RELAY_NODE_PATH" "$ORCA_RELAY_DIR/relay.js" --sock-path "$ORCA_RELAY_SOCKET_PATH" --orca-cli "$@"',
''
].join('\n')
}
],
postWriteCommands: [`chmod +x ${quoteSh(launcherPath)} 2>/dev/null; true`]
}
}
@@ -1,4 +1,8 @@
import type { ProviderRateLimits, RateLimitWindow } from '../../../../shared/rate-limit-types'
import {
formatResetCountdown,
formatResetDuration
} from '../../../../shared/rate-limit-reset-format'
import { AgentIcon } from '@/lib/agent-catalog'
import { ClaudeIcon, GeminiIcon, MiniMaxIcon, OpenAIIcon, OpenCodeGoIcon } from './icons'
import { translate } from '@/i18n/i18n'
@@ -39,28 +43,9 @@ export function formatTimeAgo(ts: number): string {
return `${hours}h ago`
}
function formatDuration(ms: number): string {
if (ms <= 0) {
return 'now'
}
const totalMins = Math.floor(ms / 60_000)
if (totalMins < 60) {
return `${totalMins}m`
}
const hours = Math.floor(totalMins / 60)
const mins = totalMins % 60
if (hours >= 24) {
const days = Math.floor(hours / 24)
const remHours = hours % 24
return remHours > 0 ? `${days}d ${remHours}h` : `${days}d`
}
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`
}
export function formatResetCountdown(ms: number): string {
const duration = formatDuration(ms)
return duration === 'now' ? 'Resets now' : `Resets in ${duration}`
}
// Re-export so existing tooltip consumers/tests keep their import path; the
// implementation is shared with mobile in src/shared/rate-limit-reset-format.
export { formatResetCountdown }
export function formatResetCreditExpiry(
expiresAt: number | null | undefined,
@@ -69,7 +54,7 @@ export function formatResetCreditExpiry(
if (!expiresAt) {
return null
}
const duration = formatDuration(expiresAt - Date.now())
const duration = formatResetDuration(expiresAt - Date.now())
if (duration === 'now') {
return count > 1
? translate('auto.components.status.bar.tooltip.7ec6e030a0', 'Next expires now')
@@ -100,6 +100,7 @@ export default function SortableTab({
resolveTerminalTabActivityStatus({
tab,
agentStatusByPaneKey: s.agentStatusByPaneKey,
agentStatusEpoch: s.agentStatusEpoch,
runtimePaneTitlesByTabId: s.runtimePaneTitlesByTabId,
ptyIdsByTabId: s.ptyIdsByTabId,
terminalLayout: s.terminalLayoutsByTabId?.[tab.id]
@@ -108,6 +108,33 @@ describe('resolveTerminalTabActivityStatus', () => {
).toBe('working')
})
it('de-spins a stale working tab on an epoch bump without a new map reference', () => {
// Why: the freshness scheduler bumps agentStatusEpoch (not the map ref) at
// the 30m stale boundary. The flag cache must invalidate on that bump, or an
// abandoned tab keeps spinning while the sidebar (epoch-keyed) de-spins.
const working = entry(FIRST_LEAF_ID, 'working')
const agentStatusByPaneKey = { [working.paneKey]: working }
expect(
resolveTerminalTabActivityStatus({
tab: TAB,
agentStatusByPaneKey,
agentStatusEpoch: 0,
ptyIdsByTabId: LIVE_PTY
})
).toBe('working')
vi.setSystemTime(31 * 60 * 1000)
// Same map reference, bumped epoch — the entry is now stale.
expect(
resolveTerminalTabActivityStatus({
tab: TAB,
agentStatusByPaneKey,
agentStatusEpoch: 1,
ptyIdsByTabId: LIVE_PTY
})
).toBe('active')
})
it('does not treat a preserved title on a sleeping tab as activity', () => {
expect(
resolveTerminalTabActivityStatus({
@@ -25,6 +25,7 @@ type TerminalTabActivityFlags = {
type FlagsCache = {
agentStatusByPaneKey: Record<string, AgentStatusEntry> | undefined
agentStatusEpoch: number | undefined
flagsByTabId: Map<string, TerminalTabActivityFlags>
}
@@ -35,9 +36,19 @@ type FlagsCache = {
let flagsCache: FlagsCache | null = null
function getTerminalTabActivityFlags(
agentStatusByPaneKey: Record<string, AgentStatusEntry> | undefined
agentStatusByPaneKey: Record<string, AgentStatusEntry> | undefined,
agentStatusEpoch: number | undefined
): Map<string, TerminalTabActivityFlags> {
if (flagsCache && flagsCache.agentStatusByPaneKey === agentStatusByPaneKey) {
// Why: freshness is time-based, so the store bumps agentStatusEpoch without
// replacing the map at the 30m stale boundary (createFreshnessScheduler).
// Keying on the map reference alone would keep serving flags computed at the
// old `now`, spinning an abandoned tab forever while the sidebar — which keys
// on agentStatusEpoch — correctly de-spins. Invalidate on either changing.
if (
flagsCache &&
flagsCache.agentStatusByPaneKey === agentStatusByPaneKey &&
flagsCache.agentStatusEpoch === agentStatusEpoch
) {
return flagsCache.flagsByTabId
}
@@ -74,7 +85,7 @@ function getTerminalTabActivityFlags(
}
}
flagsCache = { agentStatusByPaneKey, flagsByTabId }
flagsCache = { agentStatusByPaneKey, agentStatusEpoch, flagsByTabId }
return flagsByTabId
}
@@ -95,6 +106,9 @@ const EMPTY_PANE_IDS: ReadonlySet<string> = new Set()
type TerminalTabActivityInput = {
tab: Pick<TerminalTab, 'id' | 'title'>
agentStatusByPaneKey?: Record<string, AgentStatusEntry>
// Why: the store bumps this at the 30m stale boundary without replacing the
// pane-status map; it is the flag cache's invalidation key (see above).
agentStatusEpoch?: number
runtimePaneTitlesByTabId?: Record<string, Record<number, string>>
ptyIdsByTabId?: Record<string, string[]>
terminalLayout?: TerminalLayoutSnapshot
@@ -109,11 +123,12 @@ type TerminalTabActivityInput = {
export function resolveTerminalTabActivityStatus({
tab,
agentStatusByPaneKey,
agentStatusEpoch,
runtimePaneTitlesByTabId,
ptyIdsByTabId,
terminalLayout
}: TerminalTabActivityInput): TerminalTabActivityStatus {
const flags = getTerminalTabActivityFlags(agentStatusByPaneKey).get(tab.id)
const flags = getTerminalTabActivityFlags(agentStatusByPaneKey, agentStatusEpoch).get(tab.id)
return resolveWorktreeStatus({
tabs: [tab],
browserTabs: [],
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { formatResetCountdown, formatResetDuration } from './rate-limit-reset-format'
const MIN = 60_000
const HOUR = 60 * MIN
const DAY = 24 * HOUR
describe('formatResetDuration', () => {
it('returns "now" for non-positive deltas', () => {
expect(formatResetDuration(0)).toBe('now')
expect(formatResetDuration(-1)).toBe('now')
})
it('floors to whole units and drops zero remainders', () => {
expect(formatResetDuration(47 * MIN)).toBe('47m')
expect(formatResetDuration(3 * HOUR + 54 * MIN)).toBe('3h 54m')
expect(formatResetDuration(2 * HOUR)).toBe('2h')
expect(formatResetDuration(6 * DAY + 7 * HOUR)).toBe('6d 7h')
expect(formatResetDuration(7 * DAY)).toBe('7d')
})
})
describe('formatResetCountdown', () => {
it('prefixes the duration or reports "Resets now"', () => {
expect(formatResetCountdown(0)).toBe('Resets now')
expect(formatResetCountdown(3 * HOUR + 54 * MIN)).toBe('Resets in 3h 54m')
expect(formatResetCountdown(6 * DAY + 7 * HOUR)).toBe('Resets in 6d 7h')
})
})
+32
View File
@@ -0,0 +1,32 @@
// Why: shared by the desktop status-bar tooltip and the mobile accounts screen
// so rate-limit reset/expiry countdown copy stays identical across surfaces.
// Pure (no platform imports) — safe to bundle in both the renderer and mobile.
/**
* Compact human duration for a rate-limit window, flooring to whole units:
* "47m", "3h 54m", "6d 7h". Returns "now" for a non-positive delta so callers
* can special-case the "already reset" copy.
*/
export function formatResetDuration(ms: number): string {
if (ms <= 0) {
return 'now'
}
const totalMins = Math.floor(ms / 60_000)
if (totalMins < 60) {
return `${totalMins}m`
}
const hours = Math.floor(totalMins / 60)
const mins = totalMins % 60
if (hours >= 24) {
const days = Math.floor(hours / 24)
const remHours = hours % 24
return remHours > 0 ? `${days}d ${remHours}h` : `${days}d`
}
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`
}
/** "Resets in 3h 54m" / "Resets now" for a window's time-until-reset (ms). */
export function formatResetCountdown(ms: number): string {
const duration = formatResetDuration(ms)
return duration === 'now' ? 'Resets now' : `Resets in ${duration}`
}