diff --git a/.github/workflows/computer-e2e.yml b/.github/workflows/computer-e2e.yml index 235868f6155..76595f338a6 100644 --- a/.github/workflows/computer-e2e.yml +++ b/.github/workflows/computer-e2e.yml @@ -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 diff --git a/config/scripts/build-windows-cli-launcher.mjs b/config/scripts/build-windows-cli-launcher.mjs index c8478f3aee0..6f9b86bdb4b 100644 --- a/config/scripts/build-windows-cli-launcher.mjs +++ b/config/scripts/build-windows-cli-launcher.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, '../..') diff --git a/config/scripts/build-windows-cli-launcher.test.mjs b/config/scripts/build-windows-cli-launcher.test.mjs index e5bcbaa557b..4af5a68dd5a 100644 --- a/config/scripts/build-windows-cli-launcher.test.mjs +++ b/config/scripts/build-windows-cli-launcher.test.mjs @@ -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 { diff --git a/mobile/app/h/[hostId]/accounts.tsx b/mobile/app/h/[hostId]/accounts.tsx index 2a5f78abfa8..652dfffee75 100644 --- a/mobile/app/h/[hostId]/accounts.tsx +++ b/mobile/app/h/[hostId]/accounts.tsx @@ -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(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)} /> ) : null} @@ -211,12 +222,14 @@ export default function AccountsScreen() { usedPercent={sessionBar.usedPercent} unavailable={sessionBar.unavailable} loading={sessionBar.loading} + resetText={getWindowResetLabel(usage, 'session', now)} /> {usage?.error ? ( diff --git a/mobile/scripts/start-emulator.mjs b/mobile/scripts/start-emulator.mjs index eb50c9a45c0..d6489b530ee 100755 --- a/mobile/scripts/start-emulator.mjs +++ b/mobile/scripts/start-emulator.mjs @@ -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'], { diff --git a/mobile/src/components/AccountUsage.tsx b/mobile/src/components/AccountUsage.tsx index 00b3e607487..713a2dd8a46 100644 --- a/mobile/src/components/AccountUsage.tsx +++ b/mobile/src/components/AccountUsage.tsx @@ -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 ( - - {label} - - + + + {label} + + + + {loading ? ( + + ) : ( + {unavailable || used == null ? '—' : `${used}%`} + )} - {loading ? ( - - ) : ( - {unavailable || used == null ? '—' : `${used}%`} - )} + {resetText ? ( + + {resetText} + + ) : null} ) } 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 } }) diff --git a/mobile/src/components/account-usage-state.test.ts b/mobile/src/components/account-usage-state.test.ts index f567143584e..3e128b7b099 100644 --- a/mobile/src/components/account-usage-state.test.ts +++ b/mobile/src/components/account-usage-state.test.ts @@ -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( diff --git a/mobile/src/components/account-usage-state.ts b/mobile/src/components/account-usage-state.ts index c2bee731cd0..fa5b4a516b3 100644 --- a/mobile/src/components/account-usage-state.ts +++ b/mobile/src/components/account-usage-state.ts @@ -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. diff --git a/src/main/cli/cli-installer.test.ts b/src/main/cli/cli-installer.test.ts index 596cb8bbfc8..a42805313c0 100644 --- a/src/main/cli/cli-installer.test.ts +++ b/src/main/cli/cli-installer.test.ts @@ -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) => { diff --git a/src/main/cli/cli-installer.ts b/src/main/cli/cli-installer.ts index da8a3cc076a..c5f1286126b 100644 --- a/src/main/cli/cli-installer.ts +++ b/src/main/cli/cli-installer.ts @@ -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 diff --git a/src/main/runtime/windows-firewall-remote-scope.test.ts b/src/main/runtime/windows-firewall-remote-scope.test.ts new file mode 100644 index 00000000000..2c9a013d964 --- /dev/null +++ b/src/main/runtime/windows-firewall-remote-scope.test.ts @@ -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 + ) + } + ) +}) diff --git a/src/main/runtime/windows-firewall-remote-scope.ts b/src/main/runtime/windows-firewall-remote-scope.ts new file mode 100644 index 00000000000..9845413d079 --- /dev/null +++ b/src/main/runtime/windows-firewall-remote-scope.ts @@ -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(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 { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/src/main/runtime/windows-mobile-firewall.test.ts b/src/main/runtime/windows-mobile-firewall.test.ts index 4a7cc8a1219..56464c15bf1 100644 --- a/src/main/runtime/windows-mobile-firewall.test.ts +++ b/src/main/runtime/windows-mobile-firewall.test.ts @@ -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({ diff --git a/src/main/runtime/windows-mobile-firewall.ts b/src/main/runtime/windows-mobile-firewall.ts index b6419da9f5d..36797b83eb2 100644 --- a/src/main/runtime/windows-mobile-firewall.ts +++ b/src/main/runtime/windows-mobile-firewall.ts @@ -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 { diff --git a/src/main/ssh/ssh-relay-session.test.ts b/src/main/ssh/ssh-relay-session.test.ts index 76d9f436318..b124ba6861f 100644 --- a/src/main/ssh/ssh-relay-session.test.ts +++ b/src/main/ssh/ssh-relay-session.test.ts @@ -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 ) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index b4c623781f4..ec252f7025b 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -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 { + private async installRemoteOrcaCliLauncher(): Promise { 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((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((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') - } -} diff --git a/src/main/ssh/ssh-remote-cli-launcher.test.ts b/src/main/ssh/ssh-remote-cli-launcher.test.ts new file mode 100644 index 00000000000..ae0a2b18619 --- /dev/null +++ b/src/main/ssh/ssh-remote-cli-launcher.test.ts @@ -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 "$@"') + }) + ]) + }) +}) diff --git a/src/main/ssh/ssh-remote-cli-launcher.ts b/src/main/ssh/ssh-remote-cli-launcher.ts new file mode 100644 index 00000000000..871cc6076ac --- /dev/null +++ b/src/main/ssh/ssh-remote-cli-launcher.ts @@ -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`] + } +} diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index c2fde5de91a..849dc7beda3 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -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') diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index abf4b49cabd..0b694fb6fd4 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -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] diff --git a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts index 9dc16e8f70b..2f6cbcadcd8 100644 --- a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts +++ b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts @@ -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({ diff --git a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts index 2e51d42e1d5..211aae2c3b2 100644 --- a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts +++ b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts @@ -25,6 +25,7 @@ type TerminalTabActivityFlags = { type FlagsCache = { agentStatusByPaneKey: Record | undefined + agentStatusEpoch: number | undefined flagsByTabId: Map } @@ -35,9 +36,19 @@ type FlagsCache = { let flagsCache: FlagsCache | null = null function getTerminalTabActivityFlags( - agentStatusByPaneKey: Record | undefined + agentStatusByPaneKey: Record | undefined, + agentStatusEpoch: number | undefined ): Map { - 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 = new Set() type TerminalTabActivityInput = { tab: Pick agentStatusByPaneKey?: Record + // 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> ptyIdsByTabId?: Record 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: [], diff --git a/src/shared/rate-limit-reset-format.test.ts b/src/shared/rate-limit-reset-format.test.ts new file mode 100644 index 00000000000..1598c4c420f --- /dev/null +++ b/src/shared/rate-limit-reset-format.test.ts @@ -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') + }) +}) diff --git a/src/shared/rate-limit-reset-format.ts b/src/shared/rate-limit-reset-format.ts new file mode 100644 index 00000000000..2c1b48809c7 --- /dev/null +++ b/src/shared/rate-limit-reset-format.ts @@ -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}` +}