Skip to content
Merged
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 서버에만 접근합니다."
"ko": "켜면 이 호스트에서 실행한 orca 명령이 이 호스트의 작업 공간에서만, 연결되어 있는 동안에만 이 데스크톱의 Orca 브라우저 탭을 열고 조작할 수 있습니다. 이 호스트에서 이전 버전의 Orca가 시작한 터미널도 자신이 감독받는 작업에 대해 오케스트레이션 명령(check, inbox, send, ask, reply)을 실행할 수 있습니다. 파일, 클립보드, 쿠키는 읽을 수 없습니다. 끄면 이 호스트 자체의 Orca 서버에만 접근합니다."
},
"auto.components.settings.AdvancedNetworkSettingsSection.3e431564b5": {
"ko": "localhost, 127.0.0.1, *.internal"
Expand Down
3 changes: 2 additions & 1 deletion src/main/ipc/ssh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,8 @@ export function registerSshHandlers(
registerLegacyRelayBridgeIpc({
store,
getMainWindow: getCurrentMainWindow,
connection: (targetId) => connectionManager?.getConnection(targetId) ?? null
connection: (targetId) => connectionManager?.getConnection(targetId) ?? null,
runtime: () => runtime ?? null
})

if (runtime) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
} from '../../../../shared/orchestration-compatibility-evidence'
import type {
OrchestrationCompatibilityCallerAuthority,
OrchestrationCompatibilitySshTerminal,
OrchestrationCompatibilityTerminalAuthority
} from '../../runtime-terminal-contracts'
import { createHash } from 'node:crypto'
Expand All @@ -28,6 +29,23 @@ export abstract class OrcaRuntimeWithVerifyOrchestrationCompatibilityCaller exte
if (!terminalHandle || !claimedPaneKey || !launchToken) {
return null
}
const sshHost = host?.kind === 'ssh' ? host : undefined
const bridged = sshHost ? this.verifyBridgedSshTerminal(sshHost) : undefined
if (sshHost && bridged) {
// COMPAT(legacy-relay-bridge): no hook can attest a terminal this runtime does not host; the
// bridge bound this one to its lease and Dispatch records for the attachment's one call.
return bridged.handle === terminalHandle &&
bridged.paneKey === claimedPaneKey &&
bridged.processIncarnation
? this.freezeOrchestrationCompatibilityCallerAuthority(
{ hostScope: { kind: 'ssh', targetId: sshHost.targetId } },
bridged.processIncarnation,
claimedPaneKey,
terminalHandle,
createHash('sha256').update(launchToken).digest('hex')
)
: null
}
const terminal = this.getOrchestrationDispatchAuthority(terminalHandle)
if (
!terminal?.processIncarnation ||
Expand Down Expand Up @@ -91,8 +109,18 @@ export abstract class OrcaRuntimeWithVerifyOrchestrationCompatibilityCaller exte
)
}

private verifyBridgedSshTerminal(
host: Extract<OrchestrationCompatibilityHostStamp, { kind: 'ssh' }>
): OrchestrationCompatibilitySshTerminal | undefined {
const terminal = this.orchestrationCompatibilitySshTerminals.get(host.attachmentId)
return terminal &&
this.orchestrationCompatibilityHostMatches({ kind: 'ssh', targetId: host.targetId }, host)
? terminal
: undefined
}

protected freezeOrchestrationCompatibilityCallerAuthority(
terminal: OrchestrationCompatibilityTerminalAuthority,
terminal: Pick<OrchestrationCompatibilityTerminalAuthority, 'hostScope'>,
processIncarnation: string,
paneKey: string,
terminalHandle: string,
Expand Down
8 changes: 8 additions & 0 deletions src/main/runtime/runtime-terminal-contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,14 @@ export type OrchestrationCompatibilitySshAttachmentAuthority = Extract<
{ kind: 'ssh' }
>

/** COMPAT(legacy-relay-bridge): a relay-era terminal this runtime no longer hosts, which the
* bridge verified for the one CLI call its attachment lives for. */
export type OrchestrationCompatibilitySshTerminal = Readonly<{
handle: string
paneKey: string
processIncarnation?: string
}>

export type TerminalWaiter = {
handle: string
condition: RuntimeTerminalWaitCondition
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,10 @@ export abstract class OrcaRuntimeWithAdoptTerminalOrphansFromInventory extends O
getTerminalPaneKey(handle: string): string | null {
return (
resolveStructuredWorkerAuthority(handle, this.getOrchestrationDbIfAvailable?.() ?? null)
?.identity.paneKey ?? this.getPaneKeyForTerminalHandle(handle)
?.identity.paneKey ??
this.getPaneKeyForTerminalHandle(handle) ??
this.getOrchestrationCompatibilitySshTerminal(handle)?.paneKey ??
null
)
}

Expand All @@ -227,7 +230,7 @@ export abstract class OrcaRuntimeWithAdoptTerminalOrphansFromInventory extends O
try {
const leaf = this.resolveLiveLeafForHandle(handle)
if (!leaf?.ptyId) {
return null
return this.getOrchestrationCompatibilitySshTerminal(handle)?.paneKey ?? null
}
const pty = this.ptysById.get(leaf.ptyId)
return pty?.connected === false ? null : this.getPaneKeyForTerminalHandle(handle)
Expand Down
47 changes: 45 additions & 2 deletions src/main/runtime/terminal/output/headless-buffer-snapshot.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import { OrcaRuntimeWithVisibleSnapshotPreview } from './terminal-visible-state'
import type { TerminalOscLinkRange } from '../../../../shared/terminal-osc-link-ranges'
import { parseTerminalKittyKeyboardFlags } from '../../../../shared/terminal-kitty-keyboard-flags'
import type { OrchestrationCompatibilitySshAttachmentAuthority } from '../../runtime-terminal-contracts'
import type {
OrchestrationCompatibilitySshAttachmentAuthority,
OrchestrationCompatibilitySshTerminal
} from '../../runtime-terminal-contracts'
import { randomUUID } from 'node:crypto'

export abstract class OrcaRuntimeWithSerializeHeadlessTerminalBuffer extends OrcaRuntimeWithVisibleSnapshotPreview {
/** Keyed by the attachment that carries each one, so releasing it forgets the terminal. */
protected readonly orchestrationCompatibilitySshTerminals = new Map<
string,
OrchestrationCompatibilitySshTerminal
>()

protected abstract getLeafKey(tabId: string, leafId: string): string

protected async serializeHeadlessTerminalBuffer(
Expand Down Expand Up @@ -139,7 +148,8 @@ export abstract class OrcaRuntimeWithSerializeHeadlessTerminalBuffer extends Orc

registerOrchestrationCompatibilitySshAttachment(
targetId: string,
connectionIncarnation: string
connectionIncarnation: string,
terminal?: OrchestrationCompatibilitySshTerminal
): OrchestrationCompatibilitySshAttachmentAuthority {
const authority = Object.freeze({
kind: 'ssh' as const,
Expand All @@ -148,10 +158,43 @@ export abstract class OrcaRuntimeWithSerializeHeadlessTerminalBuffer extends Orc
attachmentId: randomUUID()
})
this.orchestrationCompatibilitySshAttachments.set(authority.attachmentId, authority)
if (terminal) {
this.orchestrationCompatibilitySshTerminals.set(
authority.attachmentId,
Object.freeze({ ...terminal })
)
}
return authority
}

releaseOrchestrationCompatibilitySshAttachment(attachmentId: string): void {
this.orchestrationCompatibilitySshAttachments.delete(attachmentId)
this.orchestrationCompatibilitySshTerminals.delete(attachmentId)
}

/**
* Only while an attachment holds it, and only for a handle this runtime does not host. Two hosts
* claiming one handle name nobody: neither may borrow the other's pane or process.
*/
protected getOrchestrationCompatibilitySshTerminal(
handle: string
): OrchestrationCompatibilitySshTerminal | undefined {
let found: { targetId: string; terminal: OrchestrationCompatibilitySshTerminal } | undefined
for (const [attachmentId, terminal] of this.orchestrationCompatibilitySshTerminals) {
const targetId = this.orchestrationCompatibilitySshAttachments.get(attachmentId)?.targetId
if (terminal.handle !== handle || !targetId) {
continue
}
if (
found &&
(found.targetId !== targetId ||
found.terminal.paneKey !== terminal.paneKey ||
found.terminal.processIncarnation !== terminal.processIncarnation)
) {
return undefined
}
found = { targetId, terminal }
}
return found?.terminal
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ export abstract class OrcaRuntimeWithGetTerminalInteractiveWait extends OrcaRunt
const live = this.getLivePtyForHandle(handle)
const record = live?.record ?? this.handles.get(handle)
if (!record?.ptyId) {
return null
return this.getOrchestrationCompatibilitySshTerminal(handle)?.processIncarnation ?? null
}
const incarnationId = live?.pty.incarnationId ?? this.ptysById.get(record.ptyId)?.incarnationId
if (incarnationId) {
Expand Down
14 changes: 13 additions & 1 deletion src/main/ssh/legacy-relay-bridge/legacy-relay-bridge-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,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 type { Store } from '../../persistence'
import type { OrcaRuntimeService } from '../../runtime/orca-runtime'
import { track } from '../../telemetry/client'
import type { SshConnection } from '../ssh-connection'
import { knownSshHostPlatform } from '../ssh-host-platform-memo'
Expand All @@ -29,9 +30,10 @@ const PausedArgsSchema = z.object({ ptyId: PtyIdSchema, paused: z.boolean() })
const ResizeArgsSchema = z.object({ ptyId: PtyIdSchema, cols: SizeSchema, rows: SizeSchema })

export function registerLegacyRelayBridgeIpc(args: {
store: Pick<Store, 'getSshRemotePtyLeases' | 'markSshRemotePtyLease'>
store: Pick<Store, 'getSshRemotePtyLeases' | 'markSshRemotePtyLease' | 'getSshTarget'>
getMainWindow: () => BrowserWindow | null
connection: (targetId: string) => SshConnection | null
runtime?: () => OrcaRuntimeService | null
}): void {
const send = (channel: string, payload: unknown): void => {
const contents = args.getMainWindow()?.webContents
Expand All @@ -44,6 +46,16 @@ export function registerLegacyRelayBridgeIpc(args: {
retireLease: (targetId, relayPtyId) =>
args.store.markSshRemotePtyLease(targetId, relayPtyId, 'terminated'),
connection: args.connection,
...(args.runtime
? {
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
}
}
: {}),
onPanesChanged: (panes) => send(CHANNELS.panes, panes),
onData: (ptyId, data) => send(CHANNELS.data, { ptyId, data }),
onReplay: (ptyId, replay) => send(CHANNELS.replay, { ptyId, replay }),
Expand Down
31 changes: 31 additions & 0 deletions src/main/ssh/legacy-relay-bridge/legacy-relay-bridge-leases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
*/
import type { SshRemotePtyLease } from '../../../shared/ssh-types'
import type { LegacyRelayBridgedPane } from '../../../shared/legacy-relay-bridge-contract'
import { parsePaneKey } from '../../../shared/stable-pane-id'
import type { LegacyRelayPty } from './legacy-relay-client'

export type { LegacyRelayPty }

/** How long an attach waits for an unreachable relay before answering `unverifiable`. */
export const ATTACH_WAIT_MS = 20_000
Expand Down Expand Up @@ -37,6 +41,33 @@ export type Pane = {
relayPtyId: string
endpoint: string | null
state: 'live' | 'unverifiable'
/** What the listing relay claimed about the process, for the relay-era CLI's identity checks. */
listed?: LegacyRelayPty
}

export type LegacyRelayLivePane = { ptyId: string; incarnationId?: string; terminalHandle?: string }

/** The live leased pane a pane key's stable leaf names; the lease is the desktop's own record. */
export function findLivePane(
panes: ReadonlyMap<string, Pane>,
paneKey: string
): LegacyRelayLivePane | null {
const parsed = parsePaneKey(paneKey)
if (!parsed) {
return null
}
for (const [ptyId, pane] of panes) {
// Why the leaf alone: a moved pane's lease takes its new tab, but its shell keeps the old key.
if (pane.state === 'live' && pane.lease.leafId === parsed.leafId) {
const { incarnationId, terminalHandle } = pane.listed ?? {}
return {
ptyId,
...(incarnationId ? { incarnationId } : {}),
...(terminalHandle ? { terminalHandle } : {})
}
}
}
return null
}

export function toBridgedPane(targetId: string, ptyId: string, pane: Pane): LegacyRelayBridgedPane {
Expand Down
20 changes: 19 additions & 1 deletion src/main/ssh/legacy-relay-bridge/legacy-relay-bridge-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ import type { SshConnection } from '../ssh-connection'
import { waitForSentinel } from '../ssh-relay-sentinel'
import { probeRelayEndpointIncumbent } from '../ssh-relay-endpoint-incumbent'
import { isWindowsRemoteHost } from '../ssh-remote-platform'
import type { OrcaRuntimeService } from '../../runtime/orca-runtime'
import { LegacyRelayClient } from './legacy-relay-client'
import { createLegacyRelayCliServer } from './legacy-relay-cli-server'
import { listLegacyRelayEndpoints, type LegacyRelayEndpoint } from './legacy-relay-endpoints'
import { LegacyRelayTargetBridge } from './legacy-relay-target-bridge'
import { LegacyRelayHiddenOutput } from './legacy-relay-hidden-output'
Expand All @@ -35,6 +37,12 @@ export type LegacyRelayBridgeHost = {
onReplay: (ptyId: string, replay: string) => void
onExit: (ptyId: string, code: number) => void
report: (targetId: string, phase: 'connected' | 'terminal_exited', terminals: number) => void
/** Absent: relay-era `orca` commands are refused, as before this host served them. */
remoteCli?: {
runtime: () => OrcaRuntimeService | null
/** The per-host "Allow this host's orca CLI to control Orca" switch. */
allowed: (targetId: string) => boolean
}
}

let host: LegacyRelayBridgeHost | null = null
Expand Down Expand Up @@ -93,7 +101,16 @@ function bridgeFor(targetId: string): LegacyRelayTargetBridge | null {
}
return conn
}
const bridge = new LegacyRelayTargetBridge({
const remoteCli = installed.remoteCli
const cliServer = remoteCli
? createLegacyRelayCliServer({
targetId,
runtime: remoteCli.runtime,
remoteCliControl: () => remoteCli.allowed(targetId),
livePane: (paneKey) => bridge.livePaneForKey(paneKey)
})
: undefined
const bridge: LegacyRelayTargetBridge = new LegacyRelayTargetBridge({
targetId,
leases: () => installed.leases(targetId),
retireLease: (relayPtyId) => installed.retireLease(targetId, relayPtyId),
Expand All @@ -105,6 +122,7 @@ function bridgeFor(targetId: string): LegacyRelayTargetBridge | null {
LegacyRelayClient.open({
clientInstanceId,
resume,
cliServer,
openTransport: async () =>
waitForSentinel(
await connection().exec(endpoint.connectCommand ?? '', {
Expand Down
Loading
Loading