Files
orca/src/main/runtime/orca-runtime-create-agent-session.ts
T
Neil d084a2a36a fix(ssh): decide remote-vs-local from the resolved execution host, not a raw field (#18294)
`repoIsRemote` read `repo.connectionId` directly. That is one of four spellings
of host ownership, so the predicate was wrong in both directions: a row carrying
only `executionHostId: 'ssh:<target>'` read as local and got the Linux-only
`orca-ide` rename it cannot resolve through the relay shim, while a row that
declares itself `local` with a stale `connectionId` read as remote and lost the
rename it needs on a Linux desktop.

The predicate now resolves the host first and asks "does an SSH target hold this
row's files" via `getRepoSshConnectionId`. That keeps a `runtime:` host's nested
SSH target remote (that machine reaches the files through its own relay shim)
while a runtime with no nested target - a full Orca install - stays local, as do
WSL and local.

Its call sites did not all want that question:

- The four launch-scope sites in main already hold the resolved PTY route on
  `TerminalWorkspaceLaunchScope.connectionId`. `scope.repo` is documented display
  metadata and can be a row from a different host than the worktree names, so
  they now read the route they will actually spawn on. A launch shape that
  disagrees with its own route is the bug, not a second predicate.
- `launchAgentInNewTab` picked its repo row with a host-blind
  `store.repos.find`, so a worktree that names its own host could be shaped by
  another host's row. It now resolves through `getConnectionIdFromState`, the
  same rule the file already used for transcript readability.
- `resolveAgentBackgroundLaunchHost` derived the route, the trust write and the
  launch shape from three reads of the raw field; one resolution now feeds all
  three.

Also converts the raw `repo.connectionId` agent-detection probe eight lines above
`buildWorktreeStartupForDraft`'s launch shape, which #17919 deferred precisely
because converting it alone would have left that file internally inconsistent.

Tests cover two distinct SSH hosts (a single-host fixture passes even when the
answer comes off the wrong row, which is how the `ssh:m4air` -> openclaw leak
survived review) and a `runtime:` host carrying a nested SSH target.
2026-09-02 17:46:01 -07:00

291 lines
12 KiB
TypeScript

// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
import { OrcaRuntimeWithGetAgentSessionExecutionNamespace } from './orca-runtime-get-agent-session-execution-namespace'
import type {
RuntimeAgentSessionRpcCaller,
RuntimeCreateAgentSessionRequest,
RuntimeCreateAgentSessionResult
} from '../../shared/agent-session-host-authority'
import {
AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS,
AGENT_SESSION_OPERATION_FUTURE_SKEW_MS,
parseAgentSessionOperationTimestamp
} from '../../shared/agent-session-host-authority'
import { createHash } from 'node:crypto'
import {
AGENT_SESSION_OPERATION_GLOBAL_LIMIT,
AGENT_SESSION_OPERATION_PER_CLIENT_LIMIT
} from './orca-runtime-core'
import { isTuiAgentEnabled } from '../../shared/tui-agent-selection'
import { resolveLocalWindowsAgentStartupShell } from '../../shared/windows-terminal-shell'
import {
resolveTuiAgentLaunchArgs,
resolveTuiAgentLaunchEnv
} from '../../shared/tui-agent-launch-defaults'
import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '../../shared/tui-agent-startup'
import type { RuntimeTerminalCreate } from '../../shared/runtime-types'
import type {
AgentSessionCreateOperation,
AgentSessionCreateReclaimIdentity
} from './runtime-terminal-contracts'
import {
deterministicAgentSessionUuid,
isAgentSessionOperationOutcomeUnknown
} from './runtime-agent-launch-resolution'
export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSessionExecutionNamespace {
async createAgentSession(
request: RuntimeCreateAgentSessionRequest,
caller: RuntimeAgentSessionRpcCaller = {}
): Promise<RuntimeCreateAgentSessionResult> {
if (!this.store) {
throw new Error('runtime_unavailable')
}
const now = Date.now()
const operationTimestamp = parseAgentSessionOperationTimestamp(request.clientOperationId)
if (
operationTimestamp === null ||
operationTimestamp > now + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS
) {
throw new Error('agent_session_operation_invalid')
}
const callerKey = caller.clientId?.trim() || `trusted-local:${caller.clientKind ?? 'runtime'}`
const operationKey = `${callerKey}\0${request.clientOperationId}`
const requestFingerprint = createHash('sha256')
.update(
JSON.stringify([
request.worktree,
request.agent,
request.prompt ?? null,
request.promptDelivery ?? null,
request.agentArgs ?? null,
request.agentArgs === undefined ? 'host-default' : 'client-override',
request.launchPreferences?.model ?? null,
request.launchPreferences?.effort ?? null,
request.launchPreferences?.mode ?? null,
request.startupCwd ?? null,
request.presentation ?? null,
request.placement?.tabId ?? null,
request.placement?.leafId ?? null,
request.viewMode ?? null
])
)
.digest('base64url')
const existing = this.agentSessionCreateOperations.get(operationKey)
if (existing) {
if (existing.fingerprint !== requestFingerprint) {
throw new Error('agent_session_operation_conflict')
}
let replayed: RuntimeCreateAgentSessionResult
try {
replayed = await existing.promise
} catch (error) {
const reclaimed = await this.reclaimFencedAgentSessionSpawn(existing.reclaim.identity)
if (!reclaimed) {
throw error
}
return { terminal: reclaimed, disposition: 'replayed' }
}
return { ...replayed, disposition: 'replayed' }
}
if (now - operationTimestamp > AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS) {
// Why: once a tombstone could have expired, an unseen replay must never
// be reinterpreted as permission to start another fresh agent.
throw new Error('agent_session_operation_expired')
}
let callerOperationCount = 0
const callerPrefix = `${callerKey}\0`
for (const key of this.agentSessionCreateOperations.keys()) {
if (key.startsWith(callerPrefix)) {
callerOperationCount += 1
}
}
if (
callerOperationCount >= AGENT_SESSION_OPERATION_PER_CLIENT_LIMIT ||
this.agentSessionCreateOperations.size >= AGENT_SESSION_OPERATION_GLOBAL_LIMIT
) {
// Why: tombstones cannot be evicted early without making an old replay
// capable of spawning again; reject new IDs until retained entries age out.
throw new Error('agent_session_operation_capacity')
}
let retainReplayFence = false
const reclaim: AgentSessionCreateOperation['reclaim'] = {}
const operation = (async (): Promise<RuntimeCreateAgentSessionResult> => {
// Why: reserve the client operation before any async preflight so concurrent retries cannot
// both observe an empty ledger and reach the execution owner independently.
const workspace = await this.resolveTerminalWorkspaceLaunchScope(request.worktree)
if (
!(await this.executionOwnerSupportsAgentSessionOperation(
workspace,
'create',
caller.signal
))
) {
// Why: the exact legacy launch remains client-owned until this pre-spawn check succeeds.
throw new Error('agent_session_legacy_required')
}
const startupCwd = this.resolveWorkspaceTerminalStartupCwd(workspace, request.startupCwd)
// Why: aliases and object property order are client syntax, not authority;
// fingerprint the host-resolved fields in one fixed order.
const resolvedFingerprint = createHash('sha256')
.update(
JSON.stringify([
workspace.id,
request.agent,
request.prompt ?? null,
request.promptDelivery ?? null,
request.agentArgs ?? null,
request.agentArgs === undefined ? 'host-default' : 'client-override',
request.launchPreferences?.model ?? null,
request.launchPreferences?.effort ?? null,
request.launchPreferences?.mode ?? null,
startupCwd ?? null,
request.presentation ?? null,
request.placement?.tabId ?? null,
request.placement?.leafId ?? null,
request.viewMode ?? null
])
)
.digest('base64url')
const settings = this.store!.getSettings()
if (!isTuiAgentEnabled(request.agent, settings.disabledTuiAgents)) {
throw new Error('Selected agent is disabled. Choose an enabled agent before creating.')
}
const platform = this.getAgentLaunchPlatformForWorkspace(workspace)
// Why: `workspace.repo` is display metadata and may be a row from another host; the launch
// shape must match the PTY route this scope already resolved.
const isRemote = Boolean(workspace.connectionId)
const shell = resolveLocalWindowsAgentStartupShell({
platform,
isRemote,
terminalWindowsShell: settings.terminalWindowsShell
})
const startupArgs = {
agent: request.agent,
cmdOverrides: settings.agentCmdOverrides ?? {},
agentArgs:
request.agentArgs !== undefined
? request.agentArgs
: resolveTuiAgentLaunchArgs(request.agent, settings.agentDefaultArgs),
agentEnv: resolveTuiAgentLaunchEnv(request.agent, settings.agentDefaultEnv),
sessionOptions: this.toAgentSessionOptions(request.launchPreferences),
platform,
shell,
isRemote
}
const startup =
request.promptDelivery === 'draft'
? buildAgentDraftLaunchPlan({ ...startupArgs, draft: request.prompt ?? '' })
: buildAgentStartupPlan({
...startupArgs,
prompt: request.prompt ?? '',
allowEmptyPromptLaunch: true
})
if (!startup) {
throw new Error('agent_session_identity_required')
}
await this.markWorkspaceTrustedForAgent(request.agent, workspace.connectionId, workspace.path)
if (caller.signal?.aborted) {
throw new Error('client_disconnected')
}
let terminal: RuntimeTerminalCreate
const executionOperationId = createHash('sha256')
.update(this.runtimeId)
.update('\0')
.update(operationKey)
.update('\0')
.update(resolvedFingerprint)
.digest('base64url')
const operationTabId =
request.placement?.tabId ?? deterministicAgentSessionUuid(`${executionOperationId}:tab`)
const operationLeafId =
request.placement?.leafId ?? deterministicAgentSessionUuid(`${executionOperationId}:leaf`)
const operationHandle = `term_${deterministicAgentSessionUuid(`${executionOperationId}:handle`)}`
// Why: recorded before dispatch — this handle is exported into the PTY as
// ORCA_TERMINAL_HANDLE, so it is the only name a lost spawn can be re-found by.
reclaim.identity = {
worktreeId: workspace.id,
connectionId: workspace.connectionId ?? null,
terminalHandle: operationHandle
}
try {
terminal = await this.createTerminal(`id:${workspace.id}`, {
command: startup.launchCommand,
env: startup.env,
launchConfig: startup.launchConfig,
launchAgent: request.agent,
startupCommandDelivery: startup.startupCommandDelivery,
cwd: startupCwd,
presentation: request.presentation ?? 'background',
tabId: operationTabId,
leafId: operationLeafId,
preAllocatedHandle: operationHandle,
viewMode: request.viewMode,
agentSessionCreateOperationId: executionOperationId,
signal: caller.signal,
onPtySpawnCommitted: () => {
retainReplayFence = true
}
})
} catch (error) {
if (isAgentSessionOperationOutcomeUnknown(error)) {
retainReplayFence = true
}
throw error
}
return { terminal, disposition: 'created' }
})()
this.agentSessionCreateOperations.set(operationKey, {
fingerprint: requestFingerprint,
promise: operation,
reclaim
})
const expireOperation = (): void => {
const expiresAt = Math.max(now, operationTimestamp) + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS
const timer = setTimeout(
() => {
if (this.agentSessionCreateOperations.get(operationKey)?.promise === operation) {
this.agentSessionCreateOperations.delete(operationKey)
}
},
Math.max(1, expiresAt - Date.now())
)
timer.unref?.()
}
try {
const result = await operation
expireOperation()
return result
} catch (error) {
if (retainReplayFence) {
// Why: the first PTY may still be alive; replay the same failure until
// expiry instead of interpreting a lost outcome as a fresh spawn grant.
expireOperation()
} else if (this.agentSessionCreateOperations.get(operationKey)?.promise === operation) {
this.agentSessionCreateOperations.delete(operationKey)
}
throw error
}
}
// Why: the host may still hold the PTY this operation launched. Adoption-only —
// this never spawns and never kills, so an unreachable or silent host just replays
// the original failure instead of authorising anything.
private async reclaimFencedAgentSessionSpawn(
identity: AgentSessionCreateReclaimIdentity | undefined
): Promise<RuntimeTerminalCreate | null> {
if (!identity) {
return null
}
try {
return await this.reconcileRemoteTerminalCreate(
identity.worktreeId,
identity.terminalHandle,
identity.connectionId
)
} catch {
// Unverifiable or ambiguous inventory is never evidence the PTY exited.
return null
}
}
}