Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion config/scripts/locale-ko-key-overrides.json
Original file line number Diff line number Diff line change
Expand Up @@ -6264,7 +6264,7 @@
"ko": "이 호스트의 orca CLI가 Orca를 제어하도록 허용"
},
"auto.components.settings.SshTargetForm.allowRemoteCliControlHelp": {
"ko": "켜면 이 호스트에서 실행한 orca 명령이 이 호스트의 작업 공간에서만, 연결되어 있는 동안에만 이 데스크톱의 Orca 브라우저 탭을 열고 조작할 수 있습니다. 이 호스트에서 이전 버전의 Orca가 시작한 터미널도 자신이 감독받는 작업에 대해 오케스트레이션 명령(check, inbox, send, ask, reply)을 실행할 수 있습니다. 파일, 클립보드, 쿠키는 읽을 수 없습니다. 끄면 이 호스트 자체의 Orca 서버에만 접근합니다."
"ko": "기본적으로 켜져 있습니다. 이 호스트에서 실행한 orca 명령이 이 호스트의 작업 공간에서만, 연결되어 있는 동안에만 이 데스크톱의 Orca 브라우저 탭을 열고 조작할 수 있게 하고, 이 호스트에서 이전 버전의 Orca가 시작한 터미널이 자신이 감독받는 작업에 대해 오케스트레이션 명령(check, inbox, send, ask, reply)을 실행할 수 있게 합니다. 파일, 클립보드, 쿠키는 읽을 수 없습니다. 끄면 이 호스트 자체의 Orca 서버에만 접근합니다."
},
"auto.components.settings.AdvancedNetworkSettingsSection.3e431564b5": {
"ko": "localhost, 127.0.0.1, *.internal"
Expand Down
71 changes: 71 additions & 0 deletions src/main/ipc/desktop-browser-bridge-wiring.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SshConnectionState, SshTarget } from '../../shared/ssh-types'

const { targets } = vi.hoisted(() => {
const targets: SshTarget[] = []
return { targets }
})

vi.mock('electron', () => ({ webContents: { fromId: () => null } }))
vi.mock('../browser/browser-manager', () => ({ browserManager: {} }))
vi.mock('../runtime/rpc/methods', () => ({ ALL_RPC_METHODS: [] }))
vi.mock('./ssh-ipc-context', () => ({ persistedStore: null }))
vi.mock('../ssh/orcad-managed-tunnel', () => ({ ensureOrcadManagedTunnel: vi.fn() }))
vi.mock('../ssh/ssh-target-registry', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
getSshTargetRegistryStore: () => ({ listTargets: () => targets })
}))
vi.mock('../../shared/managed-orcad-ssh-owner', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
getManagedOrcadFenceEnvironmentId: (target: SshTarget) => `env-${target.id}`
}))
vi.mock('./runtime-environment-connectivity-handlers', () => ({
isRuntimeEnvironmentManuallyDisconnected: () => false
}))
vi.mock('../../shared/runtime-environment-store', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
resolveEnvironment: (_userDataPath: string, environmentId: string) => ({ environmentId })
}))
vi.mock('../../shared/runtime-environments', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
getOrcadSshDeployment: ({ environmentId }: { environmentId: string }) => ({
sshTargetId: environmentId.slice('env-'.length)
})
}))

import { listDesktopBrowserBridgeCandidates } from './desktop-browser-bridge-wiring'

const host = (id: string, extra: Partial<SshTarget> = {}): SshTarget => ({
id,
label: id,
host: `${id}.example.com`,
port: 22,
username: 'me',
...extra
})
const connected = {
getState: (targetId: string): SshConnectionState => ({
targetId,
status: 'connected',
error: null,
reconnectAttempt: 0
})
}

beforeEach(() => {
targets.length = 0
})

describe('which managed hosts may attach the desktop browser bridge', () => {
it('attaches a host with no saved switch or an explicit true, never an explicit false', () => {
targets.push(
host('absent'),
host('on', { allowRemoteCliControl: true }),
host('off', { allowRemoteCliControl: false })
)

const candidates = listDesktopBrowserBridgeCandidates('/user-data', connected)

expect(candidates.map((candidate) => candidate.targetId)).toEqual(['absent', 'on'])
})
})
15 changes: 8 additions & 7 deletions src/main/ipc/desktop-browser-bridge-wiring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
getPreferredPairedRuntimeConnection
} from '../../shared/runtime-environments'
import { getManagedOrcadFenceEnvironmentId } from '../../shared/managed-orcad-ssh-owner'
import { isRemoteCliControlAllowed } from '../../shared/ssh-remote-cli-control'
import { browserManager } from '../browser/browser-manager'
import {
DesktopBrowserBridgeManager,
Expand Down Expand Up @@ -49,27 +50,27 @@ function log(message: string): void {
console.info(`[desktop-browser-bridge] ${message}`)
}

function listDesktopBrowserBridgeCandidates(
export function listDesktopBrowserBridgeCandidates(
userDataPath: string,
connections: Pick<SshConnectionManager, 'getState'> | null
): DesktopBrowserBridgeCandidate[] {
const candidates: DesktopBrowserBridgeCandidate[] = []
for (const target of getSshTargetRegistryStore()?.listTargets() ?? []) {
// Why strict true: the setting is opt-in, and an absent value is off.
if (target.allowRemoteCliControl !== true) {
if (!isRemoteCliControlAllowed(target)) {
continue
}
if (connections?.getState(target.id)?.status !== 'connected') {
continue
}
// Why: an unrouted host's pages egress from this desktop, so it must not stay attached at all.
if (!isSshTargetBrowsingRouted(target.id)) {
continue
}
// Why first: with the switch on by default, every plain host reaches here; only managed ones qualify.
const environmentId = getManagedOrcadFenceEnvironmentId(target)
if (!environmentId || isRuntimeEnvironmentManuallyDisconnected(environmentId)) {
continue
}
// Why: an unrouted host's pages egress from this desktop, so it must not stay attached at all.
if (!isSshTargetBrowsingRouted(target.id)) {
continue
}
try {
const environment = resolveEnvironment(userDataPath, environmentId)
if (getOrcadSshDeployment(environment)?.sshTargetId !== target.id) {
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import type { SshTarget } from '../../../shared/ssh-types'
import { isRemoteCliControlAllowed } from '../../../shared/ssh-remote-cli-control'
import { normalizeSshTarget } from './ssh-normalization'

const base: SshTarget = { id: 'ssh-1', label: 'Box', host: 'box', port: 22, username: 'me' }

describe("loading a host's orca CLI control switch", () => {
it('keeps a saved true on load, with or without the retired consent stamp', () => {
const plain = normalizeSshTarget({ ...base, allowRemoteCliControl: true })
const stamped = normalizeSshTarget({
...base,
allowRemoteCliControl: true,
allowRemoteCliControlConsent: 'desktop-browser-bridge'
})

expect(plain.allowRemoteCliControl).toBe(true)
expect(isRemoteCliControlAllowed(plain)).toBe(true)
expect(stamped.allowRemoteCliControl).toBe(true)
})

it('keeps an explicit false off and an absent value on', () => {
const off = normalizeSshTarget({ ...base, allowRemoteCliControl: false })
const absent = normalizeSshTarget(base)

expect(off.allowRemoteCliControl).toBe(false)
expect(isRemoteCliControlAllowed(off)).toBe(false)
expect(absent.allowRemoteCliControl).toBeUndefined()
expect(isRemoteCliControlAllowed(absent)).toBe(true)
})

it('drops the retired reset notice, so a host an earlier build reset is on again', () => {
const resetByEarlierBuild = { ...base, allowRemoteCliControlReset: true }
const loaded = normalizeSshTarget(resetByEarlierBuild)

expect(loaded).not.toHaveProperty('allowRemoteCliControlReset')
expect(isRemoteCliControlAllowed(loaded)).toBe(true)
})

it('never allows a missing target', () => {
expect(isRemoteCliControlAllowed(undefined)).toBe(false)
expect(isRemoteCliControlAllowed(null)).toBe(false)
})
})
38 changes: 10 additions & 28 deletions src/main/persistence/leasing-ssh-ptys/ssh-normalization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type LegacySshTarget = SshTarget & {
remoteWorkspaceSyncEnabled?: unknown
remoteWorkspaceSyncGracePeriodSeconds?: unknown
experimentalPtySourceCreditV1?: unknown
allowRemoteCliControlReset?: unknown
}

// Why: old targets predate configHost; default to label-based lookup so imported SSH aliases still resolve via ssh -G.
Expand All @@ -38,6 +39,8 @@ export function normalizeSshTarget(t: SshTarget): SshTarget {
delete target.remoteRuntime
delete target.remoteRuntimeResolution
delete target.experimentalPtySourceCreditV1
// Why: the one-time remote CLI control reset is retired; its notice flag means nothing now.
delete target.allowRemoteCliControlReset
// Why: prefer the synced grace over stale relayGracePeriodSeconds so a user's "unlimited" (0) survives migration.
const relayGracePeriodSeconds =
legacySyncEnabled === true && typeof legacyGracePeriodSeconds === 'number'
Expand Down Expand Up @@ -66,40 +69,19 @@ export function normalizeSshTarget(t: SshTarget): SshTarget {
if (remoteRuntimeResolution) {
normalized.remoteRuntimeResolution = remoteRuntimeResolution
}
return normalizeRemoteCliControlConsent(
normalizeManagedServerLeaveBehind(
normalizeManagedServerMoveOffered(
return normalizeManagedServerLeaveBehind(
normalizeManagedServerMoveOffered(
normalizeAppVersionNote(
normalizeAppVersionNote(
normalizeAppVersionNote(
migrateLegacyManagedOrcadOwner(normalized),
'managedServerUnavailable'
),
'managedServerUpdateFailure'
)
migrateLegacyManagedOrcadOwner(normalized),
'managedServerUnavailable'
),
'managedServerUpdateFailure'
)
)
)
}

/**
* The switch did nothing from 1.4.225 until the desktop browser bridge gave it a new meaning, so a
* `true` saved before that is not consent to it. Turn it off once and say why in settings.
*/
function normalizeRemoteCliControlConsent(target: SshTarget): SshTarget {
const {
allowRemoteCliControl,
allowRemoteCliControlConsent,
allowRemoteCliControlReset,
...rest
} = target
if (allowRemoteCliControl === true) {
return allowRemoteCliControlConsent === 'desktop-browser-bridge'
? { ...rest, allowRemoteCliControl, allowRemoteCliControlConsent }
: { ...rest, allowRemoteCliControlReset: true }
}
return allowRemoteCliControlReset === true ? { ...rest, allowRemoteCliControlReset } : rest
}

/**
* Phase-3 builds fenced a managed host through `owner`, which shipped builds hide. Moving the fence
* to `orcadFence` keeps the host visible, and reachable over its relay, after a downgrade.
Expand Down
33 changes: 32 additions & 1 deletion src/main/runtime/rpc/methods/ssh-target-management.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,11 @@ describe('server SSH target management RPC', () => {

const response = await dispatch('ssh.addTarget', { target: validInput })

expect(mocks.mutations.add).toHaveBeenCalledWith({ ...validInput, source: 'manual' })
expect(mocks.mutations.add).toHaveBeenCalledWith({
...validInput,
allowRemoteCliControl: false,
source: 'manual'
})
expect(response).toMatchObject({
ok: true,
result: {
Expand All @@ -198,6 +202,33 @@ describe('server SSH target management RPC', () => {
expectNoSecrets(response)
})

it('reads an omitted CLI control switch from an older client as off, and lists it explicitly', async () => {
mocks.mutations.update.mockReturnValue({ ...storedTarget, ...validInput })
await dispatch('ssh.updateTarget', { targetId: 'ssh-1', target: validInput })
expect(mocks.mutations.update.mock.calls[0][1]).toMatchObject({ allowRemoteCliControl: false })

await dispatch('ssh.updateTarget', {
targetId: 'ssh-1',
target: { ...validInput, allowRemoteCliControl: true }
})
expect(mocks.mutations.update.mock.calls[1][1]).toMatchObject({ allowRemoteCliControl: true })

mocks.listRegisteredSshTargets.mockReturnValue([
{ ...validInput, id: 'absent' },
{ ...validInput, id: 'off', allowRemoteCliControl: false }
])
const listed = await dispatch('ssh.listEditableTargets')
expect(listed).toMatchObject({
ok: true,
result: {
targets: [
{ id: 'absent', allowRemoteCliControl: true },
{ id: 'off', allowRemoteCliControl: false }
]
}
})
})

it('reports an unknown target instead of answering success', async () => {
mocks.mutations.update.mockReturnValue(null)

Expand Down
15 changes: 13 additions & 2 deletions src/main/runtime/rpc/methods/ssh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { browseSshDirectory } from '../../../ssh/ssh-remote-directory-browse'
import { defineMethod } from '../core'
import { getPublicSshError, getPublicSshState } from '../../public-ssh-state'
import type { SshTarget as SshTargetRecord, SshTargetSummary } from '../../../../shared/ssh-types'
import { isRemoteCliControlAllowed } from '../../../../shared/ssh-remote-cli-control'
import type {
SshEditableTarget,
SshEditableTargetField,
Expand Down Expand Up @@ -64,9 +65,16 @@ function pickSshEditableFields(source: SshEditableTargetInput): SshEditableTarge
} satisfies Record<SshEditableTargetField, unknown>
}

// Why: clients from before the default flip omit the switch when it shows off and read absent as
// off, so this boundary sends and saves it explicitly; current clients always send a boolean.
function withExplicitRemoteCliControl(input: SshEditableTargetInput): SshEditableTargetInput {
return { ...input, allowRemoteCliControl: input.allowRemoteCliControl ?? false }
}

function toSshEditableTarget(target: SshTargetRecord): SshEditableTarget {
return {
...pickSshEditableFields(target),
allowRemoteCliControl: isRemoteCliControlAllowed(target),
...toSshTargetSummary(target),
...(target.source === undefined ? {} : { source: target.source }),
...(isManagedOrcadSshTarget(target) ? { managedServer: true as const } : {})
Expand Down Expand Up @@ -131,7 +139,10 @@ export const SSH_METHODS = [
permission: 'workspace',
params: SshTargetAdd,
handler: (params) => {
const result = getRegisteredSshTargetMutations().add({ ...params.target, source: 'manual' })
const result = getRegisteredSshTargetMutations().add({
...withExplicitRemoteCliControl(params.target),
source: 'manual'
})
return {
target: toSshEditableTarget(result.target),
repoReadoptions: result.repoReadoptions
Expand All @@ -145,7 +156,7 @@ export const SSH_METHODS = [
handler: (params) => {
// Why: replace semantics, so an omitted optional field is cleared, as SSH settings do.
const updated = getRegisteredSshTargetMutations().update(params.targetId, {
...pickSshEditableFields(params.target),
...pickSshEditableFields(withExplicitRemoteCliControl(params.target)),
source: 'manual'
})
if (!updated) {
Expand Down
4 changes: 2 additions & 2 deletions src/main/ssh/legacy-relay-bridge/legacy-relay-bridge-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { ipcMain, type BrowserWindow } from 'electron'
import { z } from 'zod'
import { LEGACY_RELAY_BRIDGE_CHANNELS as CHANNELS } from '../../../shared/legacy-relay-bridge-contract'
import { isRemoteCliControlAllowed } from '../../../shared/ssh-remote-cli-control'
import type { Store } from '../../persistence'
import type { OrcaRuntimeService } from '../../runtime/orca-runtime'
import { track } from '../../telemetry/client'
Expand Down Expand Up @@ -51,9 +52,8 @@ export function registerLegacyRelayBridgeIpc(args: {
? {
remoteCli: {
runtime: args.runtime,
// Why strict true: the setting is opt-in, and an absent value is off.
allowed: (targetId: string) =>
args.store.getSshTarget(targetId)?.allowRemoteCliControl === true
isRemoteCliControlAllowed(args.store.getSshTarget(targetId))
}
}
: {}),
Expand Down
Loading
Loading