mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.'
|
||||
|
||||
Reference in New Issue
Block a user