diff --git a/src/shared/runtime-environment-authority-binding.ts b/src/shared/runtime-environment-authority-binding.ts index 79b6e114957..5c27207fe12 100644 --- a/src/shared/runtime-environment-authority-binding.ts +++ b/src/shared/runtime-environment-authority-binding.ts @@ -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, diff --git a/src/shared/runtime-environment-sidecar.ts b/src/shared/runtime-environment-sidecar.ts index 6a9a6352b64..0d2b19a61a0 100644 --- a/src/shared/runtime-environment-sidecar.ts +++ b/src/shared/runtime-environment-sidecar.ts @@ -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 diff --git a/src/shared/runtime-environment-ssh-access-store.test.ts b/src/shared/runtime-environment-ssh-access-store.test.ts index 025fc3e4936..9524712f0fc 100644 --- a/src/shared/runtime-environment-ssh-access-store.test.ts +++ b/src/shared/runtime-environment-ssh-access-store.test.ts @@ -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, { diff --git a/src/shared/runtime-environment-ssh-access-store.ts b/src/shared/runtime-environment-ssh-access-store.ts index f20d3b8d5a0..6b91c53bddf 100644 --- a/src/shared/runtime-environment-ssh-access-store.ts +++ b/src/shared/runtime-environment-ssh-access-store.ts @@ -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[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, diff --git a/src/shared/runtime-environment-store.ts b/src/shared/runtime-environment-store.ts index c0a7d1c1c47..9f991154c69 100644 --- a/src/shared/runtime-environment-store.ts +++ b/src/shared/runtime-environment-store.ts @@ -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.'