fix(ssh): keep a paired server's SSH access link across its restarts, and let it relink (#33001)

* fix(ssh): keep a paired server's SSH access link across its restarts, and let it relink

orcad mints a new runtimeId at every start. A linked server refused to record it,
and an unlinked one hid its sidecar entry behind the stale id, so relinking failed.
A new paired device or a pending link is still refused.

Fixes #32957. Reported by @badaz.

* fix(ssh): keep SSH access operations valid across a server restart

runtimeId is per-process, so it no longer binds an SSH access operation. A lost
completion retry now checks the runtime the link verified, and a pending unlink no
longer refuses a restarted runtime.
This commit is contained in:
Neil
2026-10-23 18:36:21 -07:00
committed by GitHub
parent 4095b64243
commit f3ffc4dbb8
5 changed files with 86 additions and 16 deletions
@@ -1,6 +1,9 @@
import type { KnownRuntimeEnvironment, PublicKnownRuntimeEnvironment } from './runtime-environments'
/** Everything an SSH access operation was authorized against; any change invalidates the operation. */
/**
* Everything an SSH access operation was authorized against; any change invalidates the operation.
* Not runtimeId: orcad mints one at every start, so it changes under a linked server (#32957).
*/
export function runtimeEnvironmentSshAccessBinding(
environment: KnownRuntimeEnvironment | PublicKnownRuntimeEnvironment,
ignoreIntent = false
@@ -8,7 +11,6 @@ export function runtimeEnvironmentSshAccessBinding(
return {
createdAt: environment.createdAt,
pairingRevision: environment.pairingRevision ?? environment.createdAt,
runtimeId: environment.runtimeId,
pairedDeviceId: environment.pairedDeviceId,
preferredEndpointId: environment.preferredEndpointId,
endpoints: environment.endpoints,
+2 -8
View File
@@ -35,7 +35,8 @@ const SidecarEntrySchema = z.object({
binding: SidecarBindingSchema,
// The store state a two-file rebind is moving to; the entry stays current through a crash between them.
pendingBinding: SidecarBindingSchema.optional(),
// The runtime a link verified; it outlives the link like a learned runtimeId would.
// The runtime a link verified, used only until the store learns one. orcad mints a new id at
// every start, so a different stored id is a restart, not a different server (#32957).
runtimeId: z.string().min(1).optional(),
sshAccess: SidecarSshAccessSchema.optional(),
pendingSshAccessOperation: RuntimeSshAccessOperationSchema.optional(),
@@ -145,13 +146,6 @@ export function overlayRuntimeEnvironmentSidecar(
}
const basePairingRevision = environment.pairingRevision ?? environment.createdAt
const access = entry.sshAccess
if (
entry.runtimeId !== undefined &&
environment.runtimeId !== null &&
environment.runtimeId !== entry.runtimeId
) {
return base
}
const pairingRevision = Math.max(
basePairingRevision,
entry.pairingRevisionFloor ?? basePairingRevision
@@ -58,11 +58,11 @@ describe('independent paired runtime SSH access persistence', () => {
})
}
function link(expectedEnvironment = seed()) {
function link(expectedEnvironment = seed(), verifiedRuntimeId = 'host-runtime') {
return linkVerifiedRuntimeEnvironmentSshAccess(userDataPath, {
expectedEnvironment: prepare(expectedEnvironment),
requestId: 'link-request',
verifiedRuntimeId: 'host-runtime',
verifiedRuntimeId,
verifiedConnection: pairedRuntimeConnectionFromOffer(verifiedPairing),
tunnel,
now: 90
@@ -105,6 +105,67 @@ describe('independent paired runtime SSH access persistence', () => {
expect(listEnvironments(userDataPath)).toEqual([unlinked])
})
it('keeps SSH access across a server restart and relinks after an unlink (#32957)', () => {
const linked = link()
markEnvironmentUsed(userDataPath, linked.id, { runtimeId: 'restarted-runtime' })
const restarted = listEnvironments(userDataPath)[0]!
expect(restarted.runtimeId).toBe('restarted-runtime')
expect(restarted.sshAccess).toEqual(linked.sshAccess)
expect(getPreferredPairingOffer(restarted)).toEqual(verifiedPairing)
const unlinked = unlink(restarted)
markEnvironmentUsed(userDataPath, unlinked.id, { runtimeId: 'restarted-again' })
const relinked = link(listEnvironments(userDataPath)[0], 'restarted-again')
expect(relinked.sshAccess?.requestId).toBe('link-request')
expect(listEnvironments(userDataPath)).toEqual([relinked])
})
it('lets an unlink or a lost-completion retry read before a server restart still finish', () => {
const prepared = prepare()
const args = {
expectedEnvironment: prepared,
requestId: 'link-request',
verifiedRuntimeId: 'host-runtime',
verifiedConnection: pairedRuntimeConnectionFromOffer(verifiedPairing),
tunnel
}
const linked = linkVerifiedRuntimeEnvironmentSshAccess(userDataPath, args)
markEnvironmentUsed(userDataPath, linked.id, { runtimeId: 'restarted-runtime' })
expect(linkVerifiedRuntimeEnvironmentSshAccess(userDataPath, args).sshAccess).toEqual(
linked.sshAccess
)
const unlinking = prepareRuntimeEnvironmentSshAccessUnlink(userDataPath, {
expectedEnvironment: linked,
requestId: 'unlink-request'
})
markEnvironmentUsed(userDataPath, linked.id, { runtimeId: 'restarted-again' })
const unlinked = completeRuntimeEnvironmentSshAccessUnlink(userDataPath, {
expectedEnvironment: unlinking,
requestId: 'unlink-request'
})
expect(unlinked.sshAccess).toBeUndefined()
expect(unlinked.pendingSshAccessOperation).toBeUndefined()
})
it('still refuses a new paired device on a linked server, and a new runtime during a link', () => {
const linked = link()
expect(() =>
markEnvironmentUsed(userDataPath, linked.id, {
runtimeId: 'restarted-runtime',
pairedDeviceId: 'another-client',
pairingDeviceToken: pairing.deviceToken
})
).toThrow('cannot change')
expect(listEnvironments(userDataPath)).toEqual([linked])
const pending = prepare(unlink(linked))
expect(() =>
markEnvironmentUsed(userDataPath, pending.id, { runtimeId: 'restarted-runtime' })
).toThrow('cannot change')
expect(listEnvironments(userDataPath)).toEqual([pending])
})
it('refuses a link whose verification raced with re-pairing', () => {
const original = seed()
const replaced = updateEnvironmentFromPairingCode(userDataPath, original.id, {
@@ -74,7 +74,7 @@ export function linkVerifiedRuntimeEnvironmentSshAccess(
const views = readEnvironmentStore(userDataPath).environments
const completed = views.find((entry) => entry.id === args.expectedEnvironment.id)
if (completed?.sshAccess?.requestId && completed.sshAccess.requestId === args.requestId) {
return requireMatchingCompletedLink(completed, args)
return requireMatchingCompletedLink(completed, linkedRuntimeId(userDataPath, completed), args)
}
const context = readAccessContext(userDataPath, args.expectedEnvironment)
const existing = context.view
@@ -198,6 +198,7 @@ export function cancelRuntimeEnvironmentSshAccessLink(
/** A retried completion must name exactly the link it already completed. */
function requireMatchingCompletedLink(
completed: KnownRuntimeEnvironment,
linkedRuntime: string | undefined,
args: Parameters<typeof linkVerifiedRuntimeEnvironmentSshAccess>[1]
): KnownRuntimeEnvironment {
const access = completed.sshAccess!
@@ -205,7 +206,6 @@ function requireMatchingCompletedLink(
const prior = expectedIntent
? {
...completed,
runtimeId: args.expectedEnvironment.runtimeId,
pairingRevision: args.expectedEnvironment.pairingRevision,
sshAccess: undefined,
connectionDependency: undefined,
@@ -217,7 +217,7 @@ function requireMatchingCompletedLink(
requireUnchangedEnvironment([prior], args.expectedEnvironment)
const offer = getPreferredPairingOffer(completed)
if (
completed.runtimeId !== args.verifiedRuntimeId ||
linkedRuntime !== args.verifiedRuntimeId ||
offer.endpoint !== args.verifiedConnection.endpoint ||
offer.deviceToken !== args.verifiedConnection.credential.deviceToken ||
offer.publicKeyB64 !== args.verifiedConnection.hostPublicKeyB64 ||
@@ -236,6 +236,17 @@ function requireMatchingCompletedLink(
return completed
}
/** The runtime the link verified; the store's runtimeId moves on at every server restart. */
function linkedRuntimeId(
userDataPath: string,
completed: KnownRuntimeEnvironment
): string | undefined {
const persisted = readPersistedEnvironmentStore(userDataPath).environments.find(
(entry) => entry.id === completed.id
)
return persisted && readCurrentRuntimeEnvironmentSidecarEntry(userDataPath, persisted)?.runtimeId
}
function readAccessContext(
userDataPath: string,
expected: KnownRuntimeEnvironment,
+3 -1
View File
@@ -286,7 +286,9 @@ export function markEnvironmentUsed(
}
if (runtimeIdChanged || pairedDeviceIdChanged) {
const current = resolveEnvironmentFromStore(readEnvironmentStore(userDataPath), environment.id)
if (current.sshAccess || current.pendingSshAccessOperation) {
// orcad mints a runtimeId per start; only a pending link must keep the runtime it verifies.
const intent = current.pendingSshAccessOperation
if (intent?.operation === 'link' || (pairedDeviceIdChanged && (current.sshAccess || intent))) {
throw new RuntimeEnvironmentStoreError(
'invalid_argument',
'SSH access operation cannot change the paired runtime identity.'