mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 08:03:22 +00:00
Merge remote-tracking branch 'origin/main' into fix/renderer-crash-white-screen
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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'], {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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 "$@"')
|
||||
})
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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}`
|
||||
}
|
||||
Reference in New Issue
Block a user