mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 16:02:15 +00:00
feat(runtime): SSH access links for paired servers in a downgrade-safe sidecar (#16741 T5-1+T5-2) (#24420)
* feat(runtime): SSH access links for paired servers in a downgrade-safe sidecar (#16741 T5-1+T5-2)
Paired runtime environments gain a durable two-phase SSH access link
(prepare link, verified link, prepare unlink, complete unlink, cancel),
plus the reconciliation record type and the runtime identity verification
helper that T6 needs. Nothing calls the link store yet; T6's managed tunnel
and runtime SSH access are its first writers.
Why a sidecar: v1.4.217 and v1.4.218 parse orca-environments.json with plain
z.object schemas, which strip unknown keys, and rewrite the whole file on
routine use (markEnvironmentUsed). Storing the link there, as #16741 did, would
let a downgraded build keep the tunnel endpoint but drop sshAccess, stranding
the server unlinkable and losing its pinned host-key fingerprint. #16741's own
answer (bumping the store version) makes those builds reject the whole file.
So orca-environments.json keeps exactly its shipped shape (version 1, persisted
fields only), and all T5 state lives in orca-environment-sidecar.json beside
it: the link and its tunnel endpoint, the pending operation, the reconciliation
record, the verified runtime id and a monotonic pairing-revision floor. Reads
overlay the sidecar; each entry is bound to the environment's createdAt,
pairing revision and preferred endpoint, so a re-pair, removal or edit by an
older build makes it stale (ignored, pruned on the next sidecar write). An
unreadable sidecar fails closed, like the main file.
Porting note (source: #16741 a68b6f3531):
- Taken: the link-store behavior and messages, the access-link schemas and
refinements, the reconciliation record, identity verification, and the
store/schema tests.
- Adapted: link state moved from orca-environments.json to the sidecar;
existing mutators write persisted fields only; removal, re-pairing and
runtime identity changes refuse while SSH access is linked or pending.
- Left for later slices: orcadDeployment and restoreManagedOrcadEnvironmentLink
(T6), reconciliation store, integrity, catalog and UI (T5-3 to T5-5), the 24
renderer terminal-input files (T7), runtime-identity and the managed-tunnel
resolver (T6).
* test(runtime): read SSH access from the known view of a persisted environment
---------
Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
getPreferredPairingOffer,
|
||||
type KnownRuntimeEnvironment
|
||||
} from '../../shared/runtime-environments'
|
||||
import { sendRemoteRuntimeRequest } from '../../shared/remote-runtime-client'
|
||||
import { verifyRemotePairingRuntimeStatus } from '../../shared/remote-pairing-verification'
|
||||
import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/protocol-version'
|
||||
import type { RuntimeStatus } from '../../shared/runtime-types'
|
||||
|
||||
export async function verifyRuntimeEnvironmentIdentity(
|
||||
environment: KnownRuntimeEnvironment,
|
||||
options: { endpoint?: string; signal?: AbortSignal } = {}
|
||||
) {
|
||||
options.signal?.throwIfAborted()
|
||||
const verifiedPairing = {
|
||||
...getPreferredPairingOffer(environment),
|
||||
...(options.endpoint ? { endpoint: options.endpoint } : {})
|
||||
}
|
||||
const response = await sendRemoteRuntimeRequest<RuntimeStatus>(
|
||||
verifiedPairing,
|
||||
'status.get',
|
||||
undefined,
|
||||
15_000,
|
||||
undefined,
|
||||
options.signal,
|
||||
ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES
|
||||
)
|
||||
options.signal?.throwIfAborted()
|
||||
if (!response.ok) {
|
||||
throw new Error(`Runtime identity verification failed: ${response.error.message}`)
|
||||
}
|
||||
const status = verifyRemotePairingRuntimeStatus(response.result)
|
||||
if (!status.ok) {
|
||||
throw new Error(status.message)
|
||||
}
|
||||
const runtimeId = status.runtimeStatus.runtimeId
|
||||
if (
|
||||
response._meta.runtimeId !== runtimeId ||
|
||||
(environment.runtimeId !== null && runtimeId !== environment.runtimeId) ||
|
||||
(environment.pairedDeviceId !== undefined &&
|
||||
status.runtimeStatus.pairedDeviceId !== undefined &&
|
||||
status.runtimeStatus.pairedDeviceId !== environment.pairedDeviceId)
|
||||
) {
|
||||
throw new Error('The endpoint does not match this paired runtime identity.')
|
||||
}
|
||||
return { verifiedPairing, verifiedRuntimeId: runtimeId, runtimeStatus: status.runtimeStatus }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { KnownRuntimeEnvironment, PublicKnownRuntimeEnvironment } from './runtime-environments'
|
||||
|
||||
/** Everything an SSH access operation was authorized against; any change invalidates the operation. */
|
||||
export function runtimeEnvironmentSshAccessBinding(
|
||||
environment: KnownRuntimeEnvironment | PublicKnownRuntimeEnvironment,
|
||||
ignoreIntent = false
|
||||
): unknown {
|
||||
return {
|
||||
createdAt: environment.createdAt,
|
||||
pairingRevision: environment.pairingRevision ?? environment.createdAt,
|
||||
runtimeId: environment.runtimeId,
|
||||
pairedDeviceId: environment.pairedDeviceId,
|
||||
preferredEndpointId: environment.preferredEndpointId,
|
||||
endpoints: environment.endpoints,
|
||||
connectionDependency: environment.connectionDependency,
|
||||
sshAccess: environment.sshAccess,
|
||||
pendingSshAccessOperation: ignoreIntent ? undefined : environment.pendingSshAccessOperation
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const RuntimeEnvironmentReconciliationRecordSchema = z.object({
|
||||
version: z.literal(1),
|
||||
stage: z.enum(['prepared', 'catalog-active']),
|
||||
requestId: z.string().min(1),
|
||||
canonicalEnvironmentId: z.string().min(1),
|
||||
runtimeId: z.string().min(1),
|
||||
preparedAt: z.number().finite(),
|
||||
registrations: z
|
||||
.array(
|
||||
z.object({
|
||||
environmentId: z.string().min(1),
|
||||
authorityDigest: z.string().regex(/^[a-f0-9]{64}$/)
|
||||
})
|
||||
)
|
||||
.length(2)
|
||||
})
|
||||
|
||||
export type RuntimeEnvironmentReconciliationRecord = z.infer<
|
||||
typeof RuntimeEnvironmentReconciliationRecordSchema
|
||||
>
|
||||
|
||||
export function assertRuntimeEnvironmentNotReconciling(environment: {
|
||||
reconciliation?: unknown
|
||||
}): void {
|
||||
if (environment.reconciliation) {
|
||||
throw new Error('Finish or cancel host reconciliation before changing this server lifecycle.')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import { encodePairingOffer } from './pairing'
|
||||
import {
|
||||
addEnvironmentFromPairingCode,
|
||||
getEnvironmentStorePath,
|
||||
listEnvironments,
|
||||
removeEnvironment
|
||||
} from './runtime-environment-store'
|
||||
import {
|
||||
getRuntimeEnvironmentSidecarPath,
|
||||
writeRuntimeEnvironmentSidecarEntry
|
||||
} from './runtime-environment-sidecar'
|
||||
import { assertRuntimeEnvironmentNotReconciling } from './runtime-environment-reconciliation-record'
|
||||
import {
|
||||
linkVerifiedRuntimeEnvironmentSshAccess,
|
||||
prepareRuntimeEnvironmentSshAccessLink
|
||||
} from './runtime-environment-ssh-access-store'
|
||||
import { readPersistedEnvironmentStore } from './runtime-environment-store-file'
|
||||
|
||||
const pairing = {
|
||||
v: 2 as const,
|
||||
endpoint: 'wss://server.example/runtime',
|
||||
publicKeyB64: Buffer.alloc(32, 1).toString('base64'),
|
||||
deviceToken: 'private-device-token',
|
||||
pairedDeviceId: 'paired-client'
|
||||
}
|
||||
const tunnel = { sshTargetId: 'target', sshTargetGeneration: 3, localPort: 41000, remotePort: 6768 }
|
||||
|
||||
// The environment schema v1.4.217 and v1.4.218 shipped: a plain z.object, so unknown keys are
|
||||
// stripped, and every write (lastUsedAt included) rewrites the whole file.
|
||||
const ShippedEnvironmentSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
createdAt: z.number().finite(),
|
||||
updatedAt: z.number().finite(),
|
||||
pairingRevision: z.number().finite().optional(),
|
||||
pairedDeviceId: z.string().min(1).optional(),
|
||||
lastUsedAt: z.number().finite().nullable(),
|
||||
runtimeId: z.string().min(1).nullable(),
|
||||
source: z.enum(['manual', 'ephemeral-vm']).optional(),
|
||||
connectionDependency: z.literal('ssh-tunnel').optional(),
|
||||
endpoints: z.array(z.object({}).passthrough()).min(1),
|
||||
preferredEndpointId: z.string().min(1)
|
||||
})
|
||||
const ShippedStoreSchema = z.object({
|
||||
version: z.literal(1),
|
||||
environments: z.array(ShippedEnvironmentSchema)
|
||||
})
|
||||
|
||||
describe('runtime environment sidecar across a downgrade', () => {
|
||||
let userDataPath: string
|
||||
beforeEach(() => {
|
||||
userDataPath = mkdtempSync(join(tmpdir(), 'orca-environment-sidecar-'))
|
||||
})
|
||||
afterEach(() => {
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function linked() {
|
||||
const seeded = addEnvironmentFromPairingCode(userDataPath, {
|
||||
name: 'Host',
|
||||
pairingCode: encodePairingOffer(pairing),
|
||||
now: 100
|
||||
})
|
||||
const prepared = prepareRuntimeEnvironmentSshAccessLink(userDataPath, {
|
||||
expectedEnvironment: seeded,
|
||||
requestId: 'link-request',
|
||||
...tunnel,
|
||||
targetFingerprint: 'target-fingerprint'
|
||||
})
|
||||
return linkVerifiedRuntimeEnvironmentSshAccess(userDataPath, {
|
||||
expectedEnvironment: prepared,
|
||||
requestId: 'link-request',
|
||||
verifiedRuntimeId: 'host-runtime',
|
||||
verifiedPairing: { ...pairing, endpoint: 'ws://127.0.0.1:41000/runtime' },
|
||||
tunnel,
|
||||
now: 90
|
||||
})
|
||||
}
|
||||
|
||||
function shippedBuildRewrite(
|
||||
edit: (environments: z.infer<typeof ShippedEnvironmentSchema>[]) => void
|
||||
) {
|
||||
const path = getEnvironmentStorePath(userDataPath)
|
||||
const store = ShippedStoreSchema.parse(JSON.parse(readFileSync(path, 'utf8')))
|
||||
edit(store.environments)
|
||||
writeFileSync(path, JSON.stringify(store))
|
||||
}
|
||||
|
||||
it('keeps SSH access after a shipped build rewrites orca-environments.json', () => {
|
||||
const access = linked()
|
||||
shippedBuildRewrite((environments) => {
|
||||
environments[0]!.lastUsedAt = 500
|
||||
environments[0]!.runtimeId = 'host-runtime'
|
||||
})
|
||||
const [restored] = listEnvironments(userDataPath)
|
||||
expect(restored?.sshAccess).toEqual(access.sshAccess)
|
||||
expect(restored?.preferredEndpointId).toBe(access.preferredEndpointId)
|
||||
expect(restored?.lastUsedAt).toBe(500)
|
||||
})
|
||||
|
||||
it('reads a link as stale once a shipped build re-pairs the server, and prunes it on the next write', () => {
|
||||
const access = linked()
|
||||
shippedBuildRewrite((environments) => {
|
||||
environments[0]!.pairingRevision = 200
|
||||
})
|
||||
const [restored] = listEnvironments(userDataPath)
|
||||
expect(restored?.sshAccess).toBeUndefined()
|
||||
expect(restored?.preferredEndpointId).toBe(access.sshAccess?.previousPreferredEndpointId)
|
||||
expect(restored?.connectionDependency).toBeUndefined()
|
||||
|
||||
const persisted = readPersistedEnvironmentStore(userDataPath).environments
|
||||
writeRuntimeEnvironmentSidecarEntry(userDataPath, persisted, persisted[0]!, null)
|
||||
expect(
|
||||
JSON.parse(readFileSync(getRuntimeEnvironmentSidecarPath(userDataPath), 'utf8'))
|
||||
).toEqual({ version: 1, entries: {} })
|
||||
})
|
||||
|
||||
it('ignores a dangling entry for a server a shipped build removed', () => {
|
||||
linked()
|
||||
shippedBuildRewrite((environments) => {
|
||||
environments.splice(0, 1)
|
||||
})
|
||||
expect(listEnvironments(userDataPath)).toEqual([])
|
||||
const other = addEnvironmentFromPairingCode(userDataPath, {
|
||||
name: 'Other',
|
||||
pairingCode: encodePairingOffer(pairing)
|
||||
})
|
||||
expect(listEnvironments(userDataPath)).toEqual([other])
|
||||
})
|
||||
|
||||
it('refuses ordinary removal while linked, and drops the entry when an unlinked server is removed', () => {
|
||||
const access = linked()
|
||||
expect(() => removeEnvironment(userDataPath, access.id)).toThrow('Unlink')
|
||||
const plain = addEnvironmentFromPairingCode(userDataPath, {
|
||||
name: 'Plain',
|
||||
pairingCode: encodePairingOffer(pairing)
|
||||
})
|
||||
removeEnvironment(userDataPath, plain.id)
|
||||
expect(listEnvironments(userDataPath).map((entry) => entry.id)).toEqual([access.id])
|
||||
})
|
||||
|
||||
it('fails closed on an unreadable sidecar instead of dropping a pinned host key', () => {
|
||||
linked()
|
||||
writeFileSync(getRuntimeEnvironmentSidecarPath(userDataPath), '{not json')
|
||||
expect(() => listEnvironments(userDataPath)).toThrow('Orca environment links')
|
||||
})
|
||||
|
||||
it('overlays a reconciliation record so lifecycle changes refuse while it exists', () => {
|
||||
const seeded = addEnvironmentFromPairingCode(userDataPath, {
|
||||
name: 'Host',
|
||||
pairingCode: encodePairingOffer(pairing),
|
||||
now: 100
|
||||
})
|
||||
const persisted = readPersistedEnvironmentStore(userDataPath).environments
|
||||
writeRuntimeEnvironmentSidecarEntry(userDataPath, persisted, persisted[0]!, {
|
||||
reconciliation: {
|
||||
version: 1,
|
||||
stage: 'prepared',
|
||||
requestId: 'reconcile',
|
||||
canonicalEnvironmentId: seeded.id,
|
||||
runtimeId: 'host-runtime',
|
||||
preparedAt: 1,
|
||||
registrations: [
|
||||
{ environmentId: seeded.id, authorityDigest: 'a'.repeat(64) },
|
||||
{ environmentId: 'other', authorityDigest: 'b'.repeat(64) }
|
||||
]
|
||||
}
|
||||
})
|
||||
const [restored] = listEnvironments(userDataPath)
|
||||
expect(() => assertRuntimeEnvironmentNotReconciling(restored!)).toThrow('reconciliation')
|
||||
expect(JSON.parse(readFileSync(getEnvironmentStorePath(userDataPath), 'utf8'))).toEqual({
|
||||
version: 1,
|
||||
environments: [seeded]
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,192 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { z } from 'zod'
|
||||
import { writeSecureJsonFileWithinLimit } from './bounded-secure-json-file'
|
||||
import { readNodeFileSyncWithinLimit } from './node-bounded-file-reader'
|
||||
import { RuntimeEnvironmentReconciliationRecordSchema } from './runtime-environment-reconciliation-record'
|
||||
import {
|
||||
KnownRuntimeEnvironmentSchema,
|
||||
RuntimeAccessEndpointSchema,
|
||||
RuntimeSshAccessLinkSchema,
|
||||
RuntimeSshAccessOperationSchema,
|
||||
type KnownRuntimeEnvironment,
|
||||
type PersistedRuntimeEnvironment
|
||||
} from './runtime-environments'
|
||||
import { hardenExistingSecureFile } from './secure-file'
|
||||
|
||||
// Why a sidecar: shipped builds parse orca-environments.json with key-stripping schemas and rewrite
|
||||
// it on routine use, so T5 state stored there would vanish across a downgrade. They never touch this.
|
||||
const SIDECAR_FILE = 'orca-environment-sidecar.json'
|
||||
const MAX_SIDECAR_FILE_BYTES = 1024 * 1024
|
||||
|
||||
const SidecarBindingSchema = z.object({
|
||||
createdAt: z.number().finite(),
|
||||
pairingRevision: z.number().finite(),
|
||||
preferredEndpointId: z.string().min(1)
|
||||
})
|
||||
|
||||
const SidecarSshAccessSchema = RuntimeSshAccessLinkSchema.extend({
|
||||
endpoint: RuntimeAccessEndpointSchema
|
||||
})
|
||||
|
||||
const SidecarEntrySchema = z.object({
|
||||
binding: SidecarBindingSchema,
|
||||
// The runtime a link verified; it outlives the link like a learned runtimeId would.
|
||||
runtimeId: z.string().min(1).optional(),
|
||||
sshAccess: SidecarSshAccessSchema.optional(),
|
||||
pendingSshAccessOperation: RuntimeSshAccessOperationSchema.optional(),
|
||||
// Keeps the overlaid pairing revision monotonic after the access that raised it is removed.
|
||||
pairingRevisionFloor: z.number().finite().optional(),
|
||||
reconciliation: RuntimeEnvironmentReconciliationRecordSchema.optional()
|
||||
})
|
||||
|
||||
const SidecarSchema = z.object({
|
||||
version: z.literal(1),
|
||||
entries: z.record(z.string().min(1), SidecarEntrySchema)
|
||||
})
|
||||
|
||||
export type RuntimeEnvironmentSidecarEntry = z.infer<typeof SidecarEntrySchema>
|
||||
export type RuntimeEnvironmentSidecarSshAccess = z.infer<typeof SidecarSshAccessSchema>
|
||||
type RuntimeEnvironmentSidecar = z.infer<typeof SidecarSchema>
|
||||
|
||||
export class RuntimeEnvironmentSidecarInvalidError extends Error {
|
||||
constructor(path: string) {
|
||||
super(`Could not read Orca environment links at ${path}; the file is invalid.`)
|
||||
this.name = 'RuntimeEnvironmentSidecarInvalidError'
|
||||
}
|
||||
}
|
||||
|
||||
export function getRuntimeEnvironmentSidecarPath(userDataPath: string): string {
|
||||
return join(userDataPath, SIDECAR_FILE)
|
||||
}
|
||||
|
||||
/** The persisted state a sidecar entry was written against; any change makes the entry stale. */
|
||||
export function runtimeEnvironmentSidecarBinding(environment: PersistedRuntimeEnvironment) {
|
||||
return {
|
||||
createdAt: environment.createdAt,
|
||||
pairingRevision: environment.pairingRevision ?? environment.createdAt,
|
||||
preferredEndpointId: environment.preferredEndpointId
|
||||
}
|
||||
}
|
||||
|
||||
export function readRuntimeEnvironmentSidecar(userDataPath: string): RuntimeEnvironmentSidecar {
|
||||
const path = getRuntimeEnvironmentSidecarPath(userDataPath)
|
||||
if (!existsSync(path)) {
|
||||
return { version: 1, entries: {} }
|
||||
}
|
||||
try {
|
||||
hardenExistingSecureFile(path)
|
||||
return SidecarSchema.parse(
|
||||
JSON.parse(readNodeFileSyncWithinLimit(path, MAX_SIDECAR_FILE_BYTES).buffer.toString('utf8'))
|
||||
)
|
||||
} catch {
|
||||
throw new RuntimeEnvironmentSidecarInvalidError(path)
|
||||
}
|
||||
}
|
||||
|
||||
function isCurrentEntry(
|
||||
environment: PersistedRuntimeEnvironment,
|
||||
entry: RuntimeEnvironmentSidecarEntry
|
||||
): boolean {
|
||||
const binding = runtimeEnvironmentSidecarBinding(environment)
|
||||
return (
|
||||
entry.binding.createdAt === binding.createdAt &&
|
||||
entry.binding.pairingRevision === binding.pairingRevision &&
|
||||
entry.binding.preferredEndpointId === binding.preferredEndpointId
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The environment callers see. An entry bound to a different pairing, or one whose overlay would be
|
||||
* inconsistent, is stale and ignored rather than trusted.
|
||||
*/
|
||||
export function overlayRuntimeEnvironmentSidecar(
|
||||
environment: PersistedRuntimeEnvironment,
|
||||
entry: RuntimeEnvironmentSidecarEntry | undefined
|
||||
): KnownRuntimeEnvironment {
|
||||
const base = KnownRuntimeEnvironmentSchema.parse(environment)
|
||||
if (!entry || !isCurrentEntry(environment, entry)) {
|
||||
return base
|
||||
}
|
||||
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
|
||||
)
|
||||
const { endpoint, ...link } = access ?? {}
|
||||
const overlaid = KnownRuntimeEnvironmentSchema.safeParse({
|
||||
...environment,
|
||||
...(pairingRevision !== basePairingRevision ? { pairingRevision } : {}),
|
||||
...(entry.runtimeId !== undefined
|
||||
? { runtimeId: environment.runtimeId ?? entry.runtimeId }
|
||||
: {}),
|
||||
...(access && endpoint
|
||||
? {
|
||||
connectionDependency: 'ssh-tunnel',
|
||||
sshAccess: link,
|
||||
endpoints: [...environment.endpoints.filter((e) => e.id !== endpoint.id), endpoint],
|
||||
preferredEndpointId: endpoint.id
|
||||
}
|
||||
: {}),
|
||||
...(entry.pendingSshAccessOperation
|
||||
? { pendingSshAccessOperation: entry.pendingSshAccessOperation }
|
||||
: {}),
|
||||
...(entry.reconciliation ? { reconciliation: entry.reconciliation } : {})
|
||||
})
|
||||
return overlaid.success ? overlaid.data : base
|
||||
}
|
||||
|
||||
/** Replaces one environment's sidecar entry and drops entries whose environment no longer exists. */
|
||||
export function writeRuntimeEnvironmentSidecarEntry(
|
||||
userDataPath: string,
|
||||
environments: readonly PersistedRuntimeEnvironment[],
|
||||
environment: PersistedRuntimeEnvironment,
|
||||
entry: Omit<RuntimeEnvironmentSidecarEntry, 'binding'> | null
|
||||
): void {
|
||||
const current = readRuntimeEnvironmentSidecar(userDataPath)
|
||||
const live = new Map(environments.map((candidate) => [candidate.id, candidate]))
|
||||
const entries: Record<string, RuntimeEnvironmentSidecarEntry> = {}
|
||||
for (const [id, existing] of Object.entries(current.entries)) {
|
||||
const owner = live.get(id)
|
||||
if (id !== environment.id && owner && isCurrentEntry(owner, existing)) {
|
||||
entries[id] = existing
|
||||
}
|
||||
}
|
||||
const basePairingRevision = environment.pairingRevision ?? environment.createdAt
|
||||
const hasState =
|
||||
entry !== null &&
|
||||
((entry.runtimeId !== undefined && environment.runtimeId === null) ||
|
||||
entry.sshAccess !== undefined ||
|
||||
entry.pendingSshAccessOperation !== undefined ||
|
||||
entry.reconciliation !== undefined ||
|
||||
(entry.pairingRevisionFloor ?? basePairingRevision) > basePairingRevision)
|
||||
if (entry && hasState) {
|
||||
entries[environment.id] = SidecarEntrySchema.parse({
|
||||
...entry,
|
||||
binding: runtimeEnvironmentSidecarBinding(environment)
|
||||
})
|
||||
}
|
||||
writeSecureJsonFileWithinLimit(
|
||||
getRuntimeEnvironmentSidecarPath(userDataPath),
|
||||
SidecarSchema.parse({ version: 1, entries }),
|
||||
MAX_SIDECAR_FILE_BYTES,
|
||||
{ durable: true }
|
||||
)
|
||||
}
|
||||
|
||||
/** The current sidecar entry for an environment, or none when absent or stale. */
|
||||
export function readCurrentRuntimeEnvironmentSidecarEntry(
|
||||
userDataPath: string,
|
||||
environment: PersistedRuntimeEnvironment
|
||||
): RuntimeEnvironmentSidecarEntry | undefined {
|
||||
const entry = readRuntimeEnvironmentSidecar(userDataPath).entries[environment.id]
|
||||
return entry && isCurrentEntry(environment, entry) ? entry : undefined
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { encodePairingOffer } from './pairing'
|
||||
import {
|
||||
addEnvironmentFromPairingCode,
|
||||
getEnvironmentStorePath,
|
||||
listEnvironments,
|
||||
markEnvironmentUsed,
|
||||
removeEnvironment,
|
||||
updateEnvironmentFromPairingCode
|
||||
} from './runtime-environment-store'
|
||||
import { getPreferredPairingOffer, PersistedRuntimeEnvironmentSchema } from './runtime-environments'
|
||||
import {
|
||||
linkVerifiedRuntimeEnvironmentSshAccess,
|
||||
prepareRuntimeEnvironmentSshAccessLink,
|
||||
prepareRuntimeEnvironmentSshAccessUnlink,
|
||||
completeRuntimeEnvironmentSshAccessUnlink,
|
||||
cancelRuntimeEnvironmentSshAccessLink
|
||||
} from './runtime-environment-ssh-access-store'
|
||||
import { z } from 'zod'
|
||||
|
||||
const pairing = {
|
||||
v: 2 as const,
|
||||
endpoint: 'wss://server.example/runtime',
|
||||
publicKeyB64: Buffer.alloc(32, 1).toString('base64'),
|
||||
deviceToken: 'private-device-token',
|
||||
pairedDeviceId: 'paired-client'
|
||||
}
|
||||
const tunnel = { sshTargetId: 'target', sshTargetGeneration: 3, localPort: 41000, remotePort: 6768 }
|
||||
const verifiedPairing = { ...pairing, endpoint: 'ws://127.0.0.1:41000/runtime' }
|
||||
|
||||
describe('independent paired runtime SSH access persistence', () => {
|
||||
let userDataPath: string
|
||||
beforeEach(() => {
|
||||
userDataPath = mkdtempSync(join(tmpdir(), 'orca-ssh-access-store-'))
|
||||
})
|
||||
afterEach(() => {
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function seed() {
|
||||
return addEnvironmentFromPairingCode(userDataPath, {
|
||||
name: 'Independent host',
|
||||
pairingCode: encodePairingOffer(pairing),
|
||||
now: 100
|
||||
})
|
||||
}
|
||||
|
||||
function prepare(expectedEnvironment = seed(), requestId = 'link-request') {
|
||||
return prepareRuntimeEnvironmentSshAccessLink(userDataPath, {
|
||||
expectedEnvironment,
|
||||
requestId,
|
||||
...tunnel,
|
||||
targetFingerprint: 'target-fingerprint'
|
||||
})
|
||||
}
|
||||
|
||||
function link(expectedEnvironment = seed()) {
|
||||
return linkVerifiedRuntimeEnvironmentSshAccess(userDataPath, {
|
||||
expectedEnvironment: prepare(expectedEnvironment),
|
||||
requestId: 'link-request',
|
||||
verifiedRuntimeId: 'host-runtime',
|
||||
verifiedPairing,
|
||||
tunnel,
|
||||
now: 90
|
||||
})
|
||||
}
|
||||
|
||||
function unlink(expectedEnvironment: ReturnType<typeof seed>, now?: number) {
|
||||
const prepared = prepareRuntimeEnvironmentSshAccessUnlink(userDataPath, {
|
||||
expectedEnvironment,
|
||||
requestId: 'unlink-request',
|
||||
now
|
||||
})
|
||||
return completeRuntimeEnvironmentSshAccessUnlink(userDataPath, {
|
||||
expectedEnvironment: prepared,
|
||||
requestId: 'unlink-request'
|
||||
})
|
||||
}
|
||||
|
||||
it('adds access to the same host without deployment ownership and restores its original endpoint', () => {
|
||||
const original = seed()
|
||||
const linked = link(original)
|
||||
expect(listEnvironments(userDataPath)).toEqual([linked])
|
||||
expect(linked.id).toBe(original.id)
|
||||
expect(linked.name).toBe(original.name)
|
||||
expect(linked.createdAt).toBe(original.createdAt)
|
||||
expect(linked.runtimeId).toBe('host-runtime')
|
||||
expect(linked.pairingRevision).toBe(101)
|
||||
expect(linked.endpoints).toHaveLength(2)
|
||||
expect(linked.endpoints[0]).toEqual(original.endpoints[0])
|
||||
expect(getPreferredPairingOffer(linked)).toEqual(verifiedPairing)
|
||||
|
||||
const unlinked = unlink(linked, 80)
|
||||
expect(unlinked.id).toBe(original.id)
|
||||
expect(unlinked.runtimeId).toBe('host-runtime')
|
||||
expect(unlinked.pairingRevision).toBe(102)
|
||||
expect(unlinked.endpoints).toEqual(original.endpoints)
|
||||
expect(unlinked.preferredEndpointId).toBe(original.preferredEndpointId)
|
||||
expect(unlinked.sshAccess).toBeUndefined()
|
||||
expect(unlinked.connectionDependency).toBeUndefined()
|
||||
expect(listEnvironments(userDataPath)).toEqual([unlinked])
|
||||
})
|
||||
|
||||
it('refuses a link whose verification raced with re-pairing', () => {
|
||||
const original = seed()
|
||||
const replaced = updateEnvironmentFromPairingCode(userDataPath, original.id, {
|
||||
pairingCode: encodePairingOffer({ ...pairing, deviceToken: 'replacement-token' }),
|
||||
now: 100
|
||||
})
|
||||
expect(() => link(original)).toThrow('changed while SSH access')
|
||||
expect(listEnvironments(userDataPath)).toEqual([replaced])
|
||||
})
|
||||
|
||||
it('permits unrelated last-used timestamp updates during verification', () => {
|
||||
const original = seed()
|
||||
markEnvironmentUsed(userDataPath, original.id, { now: 200 })
|
||||
expect(link(original).lastUsedAt).toBe(200)
|
||||
})
|
||||
|
||||
it('keeps orca-environments.json in the shape shipped builds read while SSH access is linked', () => {
|
||||
const original = seed()
|
||||
const shippedEnvelope = z
|
||||
.object({
|
||||
version: z.literal(1),
|
||||
environments: z.array(PersistedRuntimeEnvironmentSchema.strict())
|
||||
})
|
||||
.strict()
|
||||
const readEnvelope = () =>
|
||||
JSON.parse(readFileSync(getEnvironmentStorePath(userDataPath), 'utf8'))
|
||||
expect(shippedEnvelope.safeParse(readEnvelope()).success).toBe(true)
|
||||
const linked = link(original)
|
||||
expect(shippedEnvelope.safeParse(readEnvelope()).success).toBe(true)
|
||||
expect(readEnvelope().environments[0]).toEqual(original)
|
||||
markEnvironmentUsed(userDataPath, linked.id, { now: 500 })
|
||||
expect(shippedEnvelope.safeParse(readEnvelope()).success).toBe(true)
|
||||
expect(listEnvironments(userDataPath)[0]?.sshAccess).toEqual(linked.sshAccess)
|
||||
unlink(linked)
|
||||
expect(shippedEnvelope.safeParse(readEnvelope()).success).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ ...verifiedPairing, publicKeyB64: Buffer.alloc(32, 2).toString('base64') },
|
||||
{ ...verifiedPairing, deviceToken: 'another-token' },
|
||||
{ ...verifiedPairing, pairedDeviceId: 'another-client' }
|
||||
])('refuses a different authenticated host or pairing grant', (wrongPairing) => {
|
||||
const original = seed()
|
||||
const prepared = prepare(original)
|
||||
expect(() =>
|
||||
linkVerifiedRuntimeEnvironmentSshAccess(userDataPath, {
|
||||
expectedEnvironment: prepared,
|
||||
requestId: 'link-request',
|
||||
verifiedRuntimeId: 'host-runtime',
|
||||
verifiedPairing: wrongPairing,
|
||||
tunnel
|
||||
})
|
||||
).toThrow('did not verify')
|
||||
expect(listEnvironments(userDataPath)).toEqual([prepared])
|
||||
})
|
||||
|
||||
it('refuses changing a known execution runtime identity', () => {
|
||||
const original = seed()
|
||||
markEnvironmentUsed(userDataPath, original.id, { runtimeId: 'incumbent-runtime' })
|
||||
const current = listEnvironments(userDataPath)[0]
|
||||
expect(() => link(current)).toThrow('did not verify')
|
||||
expect(listEnvironments(userDataPath)[0]).toMatchObject({
|
||||
...current,
|
||||
pendingSshAccessOperation: { operation: 'link' }
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses a second registration of the same execution runtime', () => {
|
||||
const original = seed()
|
||||
const duplicate = addEnvironmentFromPairingCode(userDataPath, {
|
||||
name: 'Duplicate host',
|
||||
pairingCode: encodePairingOffer(pairing)
|
||||
})
|
||||
markEnvironmentUsed(userDataPath, duplicate.id, { runtimeId: 'host-runtime' })
|
||||
const before = listEnvironments(userDataPath)
|
||||
expect(() => link(original)).toThrow('registered more than once')
|
||||
expect(
|
||||
listEnvironments(userDataPath).find((entry) => entry.id === original.id)
|
||||
?.pendingSshAccessOperation?.operation
|
||||
).toBe('link')
|
||||
expect(listEnvironments(userDataPath).find((entry) => entry.id === duplicate.id)).toEqual(
|
||||
before.find((entry) => entry.id === duplicate.id)
|
||||
)
|
||||
})
|
||||
|
||||
it('prevents ordinary removal or re-pairing from orphaning SSH access', () => {
|
||||
const linked = link()
|
||||
expect(() => removeEnvironment(userDataPath, linked.id)).toThrow('Unlink')
|
||||
expect(() =>
|
||||
updateEnvironmentFromPairingCode(userDataPath, linked.id, {
|
||||
pairingCode: encodePairingOffer(pairing)
|
||||
})
|
||||
).toThrow('Unlink')
|
||||
expect(() => link(linked)).toThrow('already has SSH access')
|
||||
expect(listEnvironments(userDataPath)).toEqual([linked])
|
||||
})
|
||||
|
||||
it('does not let a stale unlink remove a subsequently replaced SSH link', () => {
|
||||
const first = link()
|
||||
const unlinked = unlink(first)
|
||||
const replacement = link(unlinked)
|
||||
expect(() => unlink(first)).toThrow('changed while SSH access')
|
||||
expect(listEnvironments(userDataPath)).toEqual([replacement])
|
||||
})
|
||||
|
||||
it('persists retryable link intent before target claim and fences pairing mutations', () => {
|
||||
const original = seed()
|
||||
const prepared = prepare(original)
|
||||
expect(prepared.endpoints).toEqual(original.endpoints)
|
||||
expect(prepared.sshAccess).toBeUndefined()
|
||||
expect(listEnvironments(userDataPath)).toEqual([prepared])
|
||||
expect(JSON.parse(readFileSync(getEnvironmentStorePath(userDataPath), 'utf8')).version).toBe(1)
|
||||
expect(prepare(original)).toEqual(prepared)
|
||||
expect(prepare(prepared)).toEqual(prepared)
|
||||
expect(() => prepare(prepared, 'other-request')).toThrow('Another SSH access')
|
||||
expect(() => removeEnvironment(userDataPath, prepared.id)).toThrow('Unlink')
|
||||
expect(() =>
|
||||
updateEnvironmentFromPairingCode(userDataPath, prepared.id, {
|
||||
pairingCode: encodePairingOffer(pairing)
|
||||
})
|
||||
).toThrow('Unlink')
|
||||
expect(() =>
|
||||
markEnvironmentUsed(userDataPath, prepared.id, { runtimeId: 'different-runtime' })
|
||||
).toThrow('cannot change')
|
||||
})
|
||||
|
||||
it('completes a matching verified link atomically and handles lost completion responses', () => {
|
||||
const prepared = prepare()
|
||||
const args = {
|
||||
expectedEnvironment: prepared,
|
||||
requestId: 'link-request',
|
||||
verifiedRuntimeId: 'host-runtime',
|
||||
verifiedPairing,
|
||||
tunnel
|
||||
}
|
||||
expect(() =>
|
||||
linkVerifiedRuntimeEnvironmentSshAccess(userDataPath, { ...args, requestId: 'stale-request' })
|
||||
).toThrow('pending link intent')
|
||||
expect(() =>
|
||||
linkVerifiedRuntimeEnvironmentSshAccess(userDataPath, {
|
||||
...args,
|
||||
tunnel: { ...tunnel, sshTargetGeneration: 4 }
|
||||
})
|
||||
).toThrow('pending link intent')
|
||||
const linked = linkVerifiedRuntimeEnvironmentSshAccess(userDataPath, args)
|
||||
expect(linked.pendingSshAccessOperation).toBeUndefined()
|
||||
expect(linked.sshAccess?.requestId).toBe('link-request')
|
||||
expect(linkVerifiedRuntimeEnvironmentSshAccess(userDataPath, args)).toEqual(linked)
|
||||
expect(() =>
|
||||
linkVerifiedRuntimeEnvironmentSshAccess(userDataPath, {
|
||||
...args,
|
||||
verifiedRuntimeId: 'impostor'
|
||||
})
|
||||
).toThrow('completed link')
|
||||
})
|
||||
|
||||
it('keeps a durable release intent after restoring the endpoint until exact completion', () => {
|
||||
const linked = link()
|
||||
const prepared = prepareRuntimeEnvironmentSshAccessUnlink(userDataPath, {
|
||||
expectedEnvironment: linked,
|
||||
requestId: 'release-request'
|
||||
})
|
||||
expect(prepared.sshAccess).toBeUndefined()
|
||||
expect(getPreferredPairingOffer(prepared)).toEqual(pairing)
|
||||
expect(prepared.pendingSshAccessOperation).toMatchObject({
|
||||
operation: 'unlink',
|
||||
requestId: 'release-request',
|
||||
sshTargetId: tunnel.sshTargetId,
|
||||
sshTargetGeneration: tunnel.sshTargetGeneration
|
||||
})
|
||||
expect(JSON.parse(readFileSync(getEnvironmentStorePath(userDataPath), 'utf8')).version).toBe(1)
|
||||
expect(
|
||||
prepareRuntimeEnvironmentSshAccessUnlink(userDataPath, {
|
||||
expectedEnvironment: prepared,
|
||||
requestId: 'release-request'
|
||||
})
|
||||
).toEqual(prepared)
|
||||
expect(() =>
|
||||
completeRuntimeEnvironmentSshAccessUnlink(userDataPath, {
|
||||
expectedEnvironment: prepared,
|
||||
requestId: 'stale-request'
|
||||
})
|
||||
).toThrow('pending unlink intent')
|
||||
expect(() => removeEnvironment(userDataPath, prepared.id)).toThrow('Unlink')
|
||||
const finished = completeRuntimeEnvironmentSshAccessUnlink(userDataPath, {
|
||||
expectedEnvironment: prepared,
|
||||
requestId: 'release-request'
|
||||
})
|
||||
expect(finished.pendingSshAccessOperation).toBeUndefined()
|
||||
})
|
||||
|
||||
it('turns failed verification into durable release intent without ever publishing access', () => {
|
||||
const prepared = prepare()
|
||||
expect(() =>
|
||||
cancelRuntimeEnvironmentSshAccessLink(userDataPath, {
|
||||
expectedEnvironment: prepared,
|
||||
requestId: 'wrong'
|
||||
})
|
||||
).toThrow('pending link intent')
|
||||
const cancelling = cancelRuntimeEnvironmentSshAccessLink(userDataPath, {
|
||||
expectedEnvironment: prepared,
|
||||
requestId: 'link-request'
|
||||
})
|
||||
expect(cancelling.endpoints).toEqual(prepared.endpoints)
|
||||
expect(cancelling.pendingSshAccessOperation?.operation).toBe('unlink')
|
||||
expect(cancelling.sshAccess).toBeUndefined()
|
||||
expect(
|
||||
cancelRuntimeEnvironmentSshAccessLink(userDataPath, {
|
||||
expectedEnvironment: cancelling,
|
||||
requestId: 'link-request'
|
||||
})
|
||||
).toEqual(cancelling)
|
||||
const finished = completeRuntimeEnvironmentSshAccessUnlink(userDataPath, {
|
||||
expectedEnvironment: cancelling,
|
||||
requestId: 'link-request'
|
||||
})
|
||||
expect(finished.pendingSshAccessOperation).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,307 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { runtimeEnvironmentSshAccessBinding } from './runtime-environment-authority-binding'
|
||||
export { runtimeEnvironmentSshAccessBinding } from './runtime-environment-authority-binding'
|
||||
import type { PairingOffer } from './pairing'
|
||||
import {
|
||||
getPreferredPairingOffer,
|
||||
RuntimeSshAccessOperationSchema,
|
||||
type KnownRuntimeEnvironment,
|
||||
type PersistedRuntimeEnvironment,
|
||||
type RuntimeSshAccessOperation,
|
||||
type RuntimeSshTunnelLink
|
||||
} from './runtime-environments'
|
||||
import {
|
||||
readEnvironmentStore,
|
||||
readPersistedEnvironmentStore,
|
||||
RuntimeEnvironmentStoreError
|
||||
} from './runtime-environment-store-file'
|
||||
import {
|
||||
overlayRuntimeEnvironmentSidecar,
|
||||
readCurrentRuntimeEnvironmentSidecarEntry,
|
||||
runtimeEnvironmentSidecarBinding,
|
||||
writeRuntimeEnvironmentSidecarEntry,
|
||||
type RuntimeEnvironmentSidecarEntry
|
||||
} from './runtime-environment-sidecar'
|
||||
|
||||
type SidecarState = Omit<RuntimeEnvironmentSidecarEntry, 'binding'>
|
||||
|
||||
type AccessContext = {
|
||||
environments: PersistedRuntimeEnvironment[]
|
||||
persisted: PersistedRuntimeEnvironment
|
||||
entry: SidecarState
|
||||
view: KnownRuntimeEnvironment
|
||||
}
|
||||
|
||||
export function prepareRuntimeEnvironmentSshAccessLink(
|
||||
userDataPath: string,
|
||||
args: {
|
||||
expectedEnvironment: KnownRuntimeEnvironment
|
||||
requestId: string
|
||||
sshTargetId: string
|
||||
sshTargetGeneration: number
|
||||
remotePort: number
|
||||
targetFingerprint: string
|
||||
}
|
||||
): KnownRuntimeEnvironment {
|
||||
const { expectedEnvironment: _expected, ...fields } = args
|
||||
const intent = RuntimeSshAccessOperationSchema.parse({ ...fields, operation: 'link' })
|
||||
const context = readAccessContext(userDataPath, args.expectedEnvironment, true)
|
||||
const existing = context.view
|
||||
if (existing.pendingSshAccessOperation) {
|
||||
requireMatchingIntent(existing, intent)
|
||||
return existing
|
||||
}
|
||||
if (existing.sshAccess) {
|
||||
throw invalid('This server already has SSH access.')
|
||||
}
|
||||
if (existing.connectionDependency) {
|
||||
throw invalid('Unlink the existing external tunnel before adding SSH access.')
|
||||
}
|
||||
return commit(userDataPath, context, { ...context.entry, pendingSshAccessOperation: intent })
|
||||
}
|
||||
|
||||
export function linkVerifiedRuntimeEnvironmentSshAccess(
|
||||
userDataPath: string,
|
||||
args: {
|
||||
expectedEnvironment: KnownRuntimeEnvironment
|
||||
requestId: string
|
||||
verifiedRuntimeId: string
|
||||
verifiedPairing: PairingOffer
|
||||
tunnel: RuntimeSshTunnelLink
|
||||
now?: number
|
||||
}
|
||||
): KnownRuntimeEnvironment {
|
||||
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)
|
||||
}
|
||||
const context = readAccessContext(userDataPath, args.expectedEnvironment)
|
||||
const existing = context.view
|
||||
const intent = existing.pendingSshAccessOperation
|
||||
if (
|
||||
!intent ||
|
||||
intent.operation !== 'link' ||
|
||||
intent.requestId !== args.requestId ||
|
||||
intent.sshTargetId !== args.tunnel.sshTargetId ||
|
||||
intent.sshTargetGeneration !== args.tunnel.sshTargetGeneration ||
|
||||
intent.remotePort !== args.tunnel.remotePort
|
||||
) {
|
||||
throw invalid('SSH access completion does not match its pending link intent.')
|
||||
}
|
||||
const offer = getPreferredPairingOffer(existing)
|
||||
if (
|
||||
!args.verifiedRuntimeId.trim() ||
|
||||
(existing.runtimeId !== null && existing.runtimeId !== args.verifiedRuntimeId) ||
|
||||
args.verifiedPairing.publicKeyB64 !== offer.publicKeyB64 ||
|
||||
args.verifiedPairing.deviceToken !== offer.deviceToken ||
|
||||
args.verifiedPairing.pairedDeviceId !== offer.pairedDeviceId
|
||||
) {
|
||||
throw invalid('The SSH endpoint did not verify as this paired server.')
|
||||
}
|
||||
if (
|
||||
views.some((entry) => entry.id !== existing.id && entry.runtimeId === args.verifiedRuntimeId)
|
||||
) {
|
||||
throw invalid(
|
||||
'This runtime is registered more than once. Reconcile its existing registrations first.'
|
||||
)
|
||||
}
|
||||
const endpointId = `ssh-${randomUUID()}`
|
||||
const { pendingSshAccessOperation: _intent, ...remaining } = context.entry
|
||||
return commit(userDataPath, context, {
|
||||
...remaining,
|
||||
pairingRevisionFloor: nextPairingRevision(existing, args.now ?? Date.now()),
|
||||
runtimeId: args.verifiedRuntimeId,
|
||||
sshAccess: {
|
||||
...args.tunnel,
|
||||
requestId: intent.requestId,
|
||||
targetFingerprint: intent.targetFingerprint,
|
||||
endpointId,
|
||||
previousPreferredEndpointId: existing.preferredEndpointId,
|
||||
endpoint: {
|
||||
id: endpointId,
|
||||
kind: 'websocket',
|
||||
label: 'SSH tunnel',
|
||||
endpoint: args.verifiedPairing.endpoint,
|
||||
publicKeyB64: offer.publicKeyB64,
|
||||
deviceToken: offer.deviceToken
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function prepareRuntimeEnvironmentSshAccessUnlink(
|
||||
userDataPath: string,
|
||||
args: { expectedEnvironment: KnownRuntimeEnvironment; requestId: string; now?: number }
|
||||
): KnownRuntimeEnvironment {
|
||||
const context = readAccessContext(userDataPath, args.expectedEnvironment)
|
||||
const existing = context.view
|
||||
if (existing.pendingSshAccessOperation) {
|
||||
if (
|
||||
existing.pendingSshAccessOperation.operation === 'unlink' &&
|
||||
existing.pendingSshAccessOperation.requestId === args.requestId
|
||||
) {
|
||||
return existing
|
||||
}
|
||||
throw invalid('Another SSH access operation is pending.')
|
||||
}
|
||||
const sshAccess = existing.sshAccess
|
||||
if (!sshAccess) {
|
||||
throw invalid('This server does not have independently linked SSH access.')
|
||||
}
|
||||
const { sshAccess: _access, ...remaining } = context.entry
|
||||
return commit(userDataPath, context, {
|
||||
...remaining,
|
||||
pairingRevisionFloor: nextPairingRevision(existing, args.now ?? Date.now()),
|
||||
pendingSshAccessOperation: {
|
||||
requestId: args.requestId,
|
||||
operation: 'unlink',
|
||||
sshTargetId: sshAccess.sshTargetId,
|
||||
sshTargetGeneration: sshAccess.sshTargetGeneration,
|
||||
remotePort: sshAccess.remotePort,
|
||||
targetFingerprint: sshAccess.targetFingerprint
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function completeRuntimeEnvironmentSshAccessUnlink(
|
||||
userDataPath: string,
|
||||
args: { expectedEnvironment: KnownRuntimeEnvironment; requestId: string }
|
||||
): KnownRuntimeEnvironment {
|
||||
const context = readAccessContext(userDataPath, args.expectedEnvironment)
|
||||
const intent = context.view.pendingSshAccessOperation
|
||||
if (!intent || intent.operation !== 'unlink' || intent.requestId !== args.requestId) {
|
||||
throw invalid('SSH access completion does not match its pending unlink intent.')
|
||||
}
|
||||
const { pendingSshAccessOperation: _intent, ...remaining } = context.entry
|
||||
return commit(userDataPath, context, remaining)
|
||||
}
|
||||
|
||||
export function cancelRuntimeEnvironmentSshAccessLink(
|
||||
userDataPath: string,
|
||||
args: { expectedEnvironment: KnownRuntimeEnvironment; requestId: string }
|
||||
): KnownRuntimeEnvironment {
|
||||
const context = readAccessContext(userDataPath, args.expectedEnvironment)
|
||||
const intent = context.view.pendingSshAccessOperation
|
||||
if (!intent || intent.requestId !== args.requestId) {
|
||||
throw invalid('SSH access cancellation does not match its pending link intent.')
|
||||
}
|
||||
if (intent.operation === 'unlink') {
|
||||
return context.view
|
||||
}
|
||||
return commit(userDataPath, context, {
|
||||
...context.entry,
|
||||
pendingSshAccessOperation: { ...intent, operation: 'unlink' }
|
||||
})
|
||||
}
|
||||
|
||||
/** A retried completion must name exactly the link it already completed. */
|
||||
function requireMatchingCompletedLink(
|
||||
completed: KnownRuntimeEnvironment,
|
||||
args: Parameters<typeof linkVerifiedRuntimeEnvironmentSshAccess>[1]
|
||||
): KnownRuntimeEnvironment {
|
||||
const access = completed.sshAccess!
|
||||
const expectedIntent = args.expectedEnvironment.pendingSshAccessOperation
|
||||
const prior = expectedIntent
|
||||
? {
|
||||
...completed,
|
||||
runtimeId: args.expectedEnvironment.runtimeId,
|
||||
pairingRevision: args.expectedEnvironment.pairingRevision,
|
||||
sshAccess: undefined,
|
||||
connectionDependency: undefined,
|
||||
pendingSshAccessOperation: expectedIntent,
|
||||
preferredEndpointId: access.previousPreferredEndpointId,
|
||||
endpoints: completed.endpoints.filter((entry) => entry.id !== access.endpointId)
|
||||
}
|
||||
: completed
|
||||
requireUnchangedEnvironment([prior], args.expectedEnvironment)
|
||||
const offer = getPreferredPairingOffer(completed)
|
||||
if (
|
||||
completed.runtimeId !== args.verifiedRuntimeId ||
|
||||
offer.endpoint !== args.verifiedPairing.endpoint ||
|
||||
offer.deviceToken !== args.verifiedPairing.deviceToken ||
|
||||
offer.publicKeyB64 !== args.verifiedPairing.publicKeyB64 ||
|
||||
offer.pairedDeviceId !== args.verifiedPairing.pairedDeviceId ||
|
||||
access.sshTargetId !== args.tunnel.sshTargetId ||
|
||||
access.sshTargetGeneration !== args.tunnel.sshTargetGeneration ||
|
||||
access.localPort !== args.tunnel.localPort ||
|
||||
access.remotePort !== args.tunnel.remotePort ||
|
||||
(expectedIntent &&
|
||||
(expectedIntent.operation !== 'link' ||
|
||||
expectedIntent.requestId !== args.requestId ||
|
||||
expectedIntent.targetFingerprint !== access.targetFingerprint))
|
||||
) {
|
||||
throw invalid('SSH access retry does not match its completed link.')
|
||||
}
|
||||
return completed
|
||||
}
|
||||
|
||||
function readAccessContext(
|
||||
userDataPath: string,
|
||||
expected: KnownRuntimeEnvironment,
|
||||
ignoreIntent = false
|
||||
): AccessContext {
|
||||
const { environments } = readPersistedEnvironmentStore(userDataPath)
|
||||
const views = readEnvironmentStore(userDataPath).environments
|
||||
const view = requireUnchangedEnvironment(views, expected, ignoreIntent)
|
||||
const persisted = environments.find((entry) => entry.id === view.id)!
|
||||
const current = readCurrentRuntimeEnvironmentSidecarEntry(userDataPath, persisted)
|
||||
const { binding: _binding, ...entry } = current ?? { binding: undefined }
|
||||
return { environments, persisted, entry, view }
|
||||
}
|
||||
|
||||
/** Writes only after the overlay proves every requested field survives validation. */
|
||||
function commit(
|
||||
userDataPath: string,
|
||||
context: AccessContext,
|
||||
next: SidecarState
|
||||
): KnownRuntimeEnvironment {
|
||||
const view = overlayRuntimeEnvironmentSidecar(context.persisted, {
|
||||
...next,
|
||||
binding: runtimeEnvironmentSidecarBinding(context.persisted)
|
||||
})
|
||||
if (
|
||||
(next.sshAccess !== undefined) !== (view.sshAccess !== undefined) ||
|
||||
(next.pendingSshAccessOperation !== undefined) !==
|
||||
(view.pendingSshAccessOperation !== undefined)
|
||||
) {
|
||||
throw invalid('The SSH access state is inconsistent with this paired server.')
|
||||
}
|
||||
writeRuntimeEnvironmentSidecarEntry(userDataPath, context.environments, context.persisted, next)
|
||||
return view
|
||||
}
|
||||
|
||||
function requireMatchingIntent(
|
||||
environment: KnownRuntimeEnvironment,
|
||||
intent: RuntimeSshAccessOperation
|
||||
): void {
|
||||
if (JSON.stringify(environment.pendingSshAccessOperation) !== JSON.stringify(intent)) {
|
||||
throw invalid('Another SSH access operation is pending.')
|
||||
}
|
||||
}
|
||||
|
||||
function requireUnchangedEnvironment(
|
||||
environments: KnownRuntimeEnvironment[],
|
||||
expected: KnownRuntimeEnvironment,
|
||||
ignoreIntent = false
|
||||
): KnownRuntimeEnvironment {
|
||||
const existing = environments.find((entry) => entry.id === expected.id)
|
||||
if (
|
||||
!existing ||
|
||||
JSON.stringify(runtimeEnvironmentSshAccessBinding(existing, ignoreIntent)) !==
|
||||
JSON.stringify(runtimeEnvironmentSshAccessBinding(expected, ignoreIntent))
|
||||
) {
|
||||
throw invalid(
|
||||
'The paired server changed while SSH access was being verified. Retry with its current pairing.'
|
||||
)
|
||||
}
|
||||
return existing
|
||||
}
|
||||
|
||||
function nextPairingRevision(environment: KnownRuntimeEnvironment, now: number): number {
|
||||
return Math.max(now, (environment.pairingRevision ?? environment.createdAt) + 1)
|
||||
}
|
||||
|
||||
function invalid(message: string): RuntimeEnvironmentStoreError {
|
||||
return new RuntimeEnvironmentStoreError('invalid_argument', message)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { writeSecureJsonFileWithinLimit } from './bounded-secure-json-file'
|
||||
import { readNodeFileSyncWithinLimit } from './node-bounded-file-reader'
|
||||
import { JsonStringifyByteLimitError } from './node-bounded-json-stringify'
|
||||
import {
|
||||
PersistedRuntimeEnvironmentSchema,
|
||||
RuntimeEnvironmentStoreSchema,
|
||||
type KnownRuntimeEnvironment,
|
||||
type RuntimeEnvironmentStore
|
||||
} from './runtime-environments'
|
||||
import {
|
||||
overlayRuntimeEnvironmentSidecar,
|
||||
readRuntimeEnvironmentSidecar,
|
||||
RuntimeEnvironmentSidecarInvalidError
|
||||
} from './runtime-environment-sidecar'
|
||||
import { hardenExistingSecureFile } from './secure-file'
|
||||
|
||||
const ENVIRONMENTS_FILE = 'orca-environments.json'
|
||||
export const MAX_RUNTIME_ENVIRONMENT_STORE_FILE_BYTES = 1024 * 1024
|
||||
|
||||
export type RuntimeEnvironmentStoreErrorCode = 'invalid_argument' | 'runtime_error'
|
||||
|
||||
export class RuntimeEnvironmentStoreError extends Error {
|
||||
readonly code: RuntimeEnvironmentStoreErrorCode
|
||||
|
||||
constructor(code: RuntimeEnvironmentStoreErrorCode, message: string) {
|
||||
super(message)
|
||||
this.name = 'RuntimeEnvironmentStoreError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
export function getEnvironmentStorePath(userDataPath: string): string {
|
||||
return join(userDataPath, ENVIRONMENTS_FILE)
|
||||
}
|
||||
|
||||
/** The orca-environments.json content, exactly as shipped builds read and rewrite it. */
|
||||
export function readPersistedEnvironmentStore(userDataPath: string): RuntimeEnvironmentStore {
|
||||
const path = getEnvironmentStorePath(userDataPath)
|
||||
if (!existsSync(path)) {
|
||||
return { version: 1, environments: [] }
|
||||
}
|
||||
try {
|
||||
hardenExistingSecureFile(path)
|
||||
const parsed = RuntimeEnvironmentStoreSchema.parse(
|
||||
JSON.parse(
|
||||
readNodeFileSyncWithinLimit(path, MAX_RUNTIME_ENVIRONMENT_STORE_FILE_BYTES).buffer.toString(
|
||||
'utf8'
|
||||
)
|
||||
)
|
||||
)
|
||||
return {
|
||||
version: 1,
|
||||
environments: parsed.environments
|
||||
.map((entry) => PersistedRuntimeEnvironmentSchema.parse(entry))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
} catch {
|
||||
throw new RuntimeEnvironmentStoreError(
|
||||
'runtime_error',
|
||||
`Could not read Orca environments at ${path}; the file is invalid.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Persisted environments with their sidecar state overlaid; stale sidecar entries are ignored. */
|
||||
export function readEnvironmentStore(userDataPath: string): {
|
||||
version: 1
|
||||
environments: KnownRuntimeEnvironment[]
|
||||
} {
|
||||
const store = readPersistedEnvironmentStore(userDataPath)
|
||||
let sidecar: ReturnType<typeof readRuntimeEnvironmentSidecar>
|
||||
try {
|
||||
sidecar = readRuntimeEnvironmentSidecar(userDataPath)
|
||||
} catch (error) {
|
||||
// Fail closed like the main file: a link we cannot read may pin a host key we must not drop.
|
||||
if (error instanceof RuntimeEnvironmentSidecarInvalidError) {
|
||||
throw new RuntimeEnvironmentStoreError('runtime_error', error.message)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
environments: store.environments.map((environment) =>
|
||||
overlayRuntimeEnvironmentSidecar(environment, sidecar.entries[environment.id])
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes only persisted fields; sidecar state is written by the sidecar module. */
|
||||
export function writeEnvironmentStore(userDataPath: string, store: RuntimeEnvironmentStore): void {
|
||||
const path = getEnvironmentStorePath(userDataPath)
|
||||
try {
|
||||
writeSecureJsonFileWithinLimit(
|
||||
path,
|
||||
RuntimeEnvironmentStoreSchema.parse(store),
|
||||
MAX_RUNTIME_ENVIRONMENT_STORE_FILE_BYTES
|
||||
)
|
||||
} catch (error) {
|
||||
if (error instanceof JsonStringifyByteLimitError) {
|
||||
throw new RuntimeEnvironmentStoreError(
|
||||
'runtime_error',
|
||||
`Could not write Orca environments at ${path}; the store exceeds its durable capacity.`
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -1,40 +1,27 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { JsonStringifyByteLimitError } from './node-bounded-json-stringify'
|
||||
import { readNodeFileSyncWithinLimit } from './node-bounded-file-reader'
|
||||
import { parsePairingCode, type PairingOffer } from './pairing'
|
||||
import { classifyRemotePairingHostname } from './remote-pairing-address'
|
||||
import { writeSecureJsonFileWithinLimit } from './bounded-secure-json-file'
|
||||
import { hardenExistingSecureFile } from './secure-file'
|
||||
import {
|
||||
createEnvironmentFromPairingOffer,
|
||||
getPreferredPairingOffer,
|
||||
KnownRuntimeEnvironmentSchema,
|
||||
RuntimeEnvironmentStoreSchema,
|
||||
type KnownRuntimeEnvironment,
|
||||
type RuntimeEnvironmentSource,
|
||||
type RuntimeEnvironmentStore
|
||||
type PersistedRuntimeEnvironment,
|
||||
type RuntimeEnvironmentSource
|
||||
} from './runtime-environments'
|
||||
import {
|
||||
readEnvironmentStore,
|
||||
readPersistedEnvironmentStore,
|
||||
RuntimeEnvironmentStoreError,
|
||||
writeEnvironmentStore
|
||||
} from './runtime-environment-store-file'
|
||||
import { writeRuntimeEnvironmentSidecarEntry } from './runtime-environment-sidecar'
|
||||
|
||||
const ENVIRONMENTS_FILE = 'orca-environments.json'
|
||||
export const MAX_RUNTIME_ENVIRONMENT_STORE_FILE_BYTES = 1024 * 1024
|
||||
|
||||
export type RuntimeEnvironmentStoreErrorCode = 'invalid_argument' | 'runtime_error'
|
||||
|
||||
export class RuntimeEnvironmentStoreError extends Error {
|
||||
readonly code: RuntimeEnvironmentStoreErrorCode
|
||||
|
||||
constructor(code: RuntimeEnvironmentStoreErrorCode, message: string) {
|
||||
super(message)
|
||||
this.name = 'RuntimeEnvironmentStoreError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
export function getEnvironmentStorePath(userDataPath: string): string {
|
||||
return join(userDataPath, ENVIRONMENTS_FILE)
|
||||
}
|
||||
export {
|
||||
getEnvironmentStorePath,
|
||||
MAX_RUNTIME_ENVIRONMENT_STORE_FILE_BYTES,
|
||||
RuntimeEnvironmentStoreError,
|
||||
type RuntimeEnvironmentStoreErrorCode
|
||||
} from './runtime-environment-store-file'
|
||||
|
||||
export function listEnvironments(userDataPath: string): KnownRuntimeEnvironment[] {
|
||||
return readEnvironmentStore(userDataPath).environments
|
||||
@@ -57,7 +44,7 @@ export function addEnvironmentFromPairingCode(
|
||||
'Invalid pairing code. Expected an orca://pair?... URL or bare pairing payload.'
|
||||
)
|
||||
}
|
||||
const store = readEnvironmentStore(userDataPath)
|
||||
const store = readPersistedEnvironmentStore(userDataPath)
|
||||
const now = args.now ?? Date.now()
|
||||
const existing = store.environments.find((entry) => entry.name === args.name)
|
||||
if (existing) {
|
||||
@@ -87,12 +74,14 @@ export function addEnvironmentFromPairingCode(
|
||||
}
|
||||
|
||||
export function removeEnvironment(userDataPath: string, selector: string): KnownRuntimeEnvironment {
|
||||
const store = readEnvironmentStore(userDataPath)
|
||||
const environment = resolveEnvironmentFromStore(store, selector)
|
||||
writeEnvironmentStore(userDataPath, {
|
||||
version: 1,
|
||||
environments: store.environments.filter((entry) => entry.id !== environment.id)
|
||||
})
|
||||
const environment = resolveEnvironmentFromStore(readEnvironmentStore(userDataPath), selector)
|
||||
assertNoIndependentSshAccess(environment)
|
||||
const store = readPersistedEnvironmentStore(userDataPath)
|
||||
const persisted = resolveEnvironmentFromStore(store, environment.id)
|
||||
const remaining = store.environments.filter((entry) => entry.id !== environment.id)
|
||||
writeEnvironmentStore(userDataPath, { version: 1, environments: remaining })
|
||||
// A leftover entry would read as stale anyway; dropping it keeps the sidecar from growing.
|
||||
writeRuntimeEnvironmentSidecarEntry(userDataPath, remaining, persisted, null)
|
||||
return environment
|
||||
}
|
||||
|
||||
@@ -108,7 +97,10 @@ export function updateEnvironmentFromPairingCode(
|
||||
'Invalid pairing code. Expected an orca://pair?... URL or bare pairing payload.'
|
||||
)
|
||||
}
|
||||
const store = readEnvironmentStore(userDataPath)
|
||||
assertNoIndependentSshAccess(
|
||||
resolveEnvironmentFromStore(readEnvironmentStore(userDataPath), selector)
|
||||
)
|
||||
const store = readPersistedEnvironmentStore(userDataPath)
|
||||
const existing = resolveEnvironmentFromStore(store, selector)
|
||||
const now = args.now ?? Date.now()
|
||||
const previousPairingRevision = existing.pairingRevision ?? existing.createdAt
|
||||
@@ -178,7 +170,7 @@ export function markEnvironmentUsed(
|
||||
selector: string,
|
||||
args: { runtimeId?: string | null; pairedDeviceId?: string; now?: number } = {}
|
||||
): void {
|
||||
const store = readEnvironmentStore(userDataPath)
|
||||
const store = readPersistedEnvironmentStore(userDataPath)
|
||||
const environment = resolveEnvironmentFromStore(store, selector)
|
||||
const now = args.now ?? Date.now()
|
||||
const runtimeIdChanged = args.runtimeId != null && args.runtimeId !== environment.runtimeId
|
||||
@@ -191,6 +183,15 @@ export function markEnvironmentUsed(
|
||||
if (!runtimeIdChanged && !pairedDeviceIdChanged && lastUsedIsFresh) {
|
||||
return
|
||||
}
|
||||
if (runtimeIdChanged || pairedDeviceIdChanged) {
|
||||
const current = resolveEnvironmentFromStore(readEnvironmentStore(userDataPath), environment.id)
|
||||
if (current.sshAccess || current.pendingSshAccessOperation) {
|
||||
throw new RuntimeEnvironmentStoreError(
|
||||
'invalid_argument',
|
||||
'SSH access operation cannot change the paired runtime identity.'
|
||||
)
|
||||
}
|
||||
}
|
||||
const next = store.environments.map((entry) =>
|
||||
entry.id === environment.id
|
||||
? {
|
||||
@@ -205,10 +206,10 @@ export function markEnvironmentUsed(
|
||||
writeEnvironmentStore(userDataPath, { version: 1, environments: next })
|
||||
}
|
||||
|
||||
function resolveEnvironmentFromStore(
|
||||
store: RuntimeEnvironmentStore,
|
||||
export function resolveEnvironmentFromStore<T extends PersistedRuntimeEnvironment>(
|
||||
store: { environments: T[] },
|
||||
selector: string
|
||||
): KnownRuntimeEnvironment {
|
||||
): T {
|
||||
const byId = store.environments.find((entry) => entry.id === selector)
|
||||
if (byId) {
|
||||
return byId
|
||||
@@ -226,49 +227,11 @@ function resolveEnvironmentFromStore(
|
||||
throw new RuntimeEnvironmentStoreError('invalid_argument', `Unknown environment: ${selector}`)
|
||||
}
|
||||
|
||||
function readEnvironmentStore(userDataPath: string): RuntimeEnvironmentStore {
|
||||
const path = getEnvironmentStorePath(userDataPath)
|
||||
if (!existsSync(path)) {
|
||||
return { version: 1, environments: [] }
|
||||
}
|
||||
try {
|
||||
hardenExistingSecureFile(path)
|
||||
const parsed = RuntimeEnvironmentStoreSchema.parse(
|
||||
JSON.parse(
|
||||
readNodeFileSyncWithinLimit(path, MAX_RUNTIME_ENVIRONMENT_STORE_FILE_BYTES).buffer.toString(
|
||||
'utf8'
|
||||
)
|
||||
)
|
||||
)
|
||||
return {
|
||||
version: 1,
|
||||
environments: parsed.environments
|
||||
.map((entry) => KnownRuntimeEnvironmentSchema.parse(entry))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
} catch {
|
||||
function assertNoIndependentSshAccess(environment: KnownRuntimeEnvironment): void {
|
||||
if (environment.sshAccess || environment.pendingSshAccessOperation) {
|
||||
throw new RuntimeEnvironmentStoreError(
|
||||
'runtime_error',
|
||||
`Could not read Orca environments at ${path}; the file is invalid.`
|
||||
'invalid_argument',
|
||||
"Unlink this server's SSH access before replacing its pairing or removing it."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function writeEnvironmentStore(userDataPath: string, store: RuntimeEnvironmentStore): void {
|
||||
const path = getEnvironmentStorePath(userDataPath)
|
||||
try {
|
||||
writeSecureJsonFileWithinLimit(
|
||||
path,
|
||||
RuntimeEnvironmentStoreSchema.parse(store),
|
||||
MAX_RUNTIME_ENVIRONMENT_STORE_FILE_BYTES
|
||||
)
|
||||
} catch (error) {
|
||||
if (error instanceof JsonStringifyByteLimitError) {
|
||||
throw new RuntimeEnvironmentStoreError(
|
||||
'runtime_error',
|
||||
`Could not write Orca environments at ${path}; the store exceeds its durable capacity.`
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { PAIRING_OFFER_VERSION, type PairingOffer } from './pairing'
|
||||
import {
|
||||
createEnvironmentFromPairingOffer,
|
||||
getPreferredPairingOffer,
|
||||
getRuntimeSshAccess,
|
||||
KnownRuntimeEnvironmentSchema,
|
||||
redactRuntimeEnvironment,
|
||||
RuntimeEnvironmentStoreSchema,
|
||||
type RuntimeSshTunnelLink
|
||||
} from './runtime-environments'
|
||||
|
||||
const link: RuntimeSshTunnelLink = {
|
||||
sshTargetId: 'ssh-host',
|
||||
sshTargetGeneration: 7,
|
||||
localPort: 46768,
|
||||
remotePort: 6768
|
||||
}
|
||||
const accessLink = {
|
||||
...link,
|
||||
endpointId: 'ssh-access',
|
||||
previousPreferredEndpointId: 'ws-environment-1'
|
||||
}
|
||||
const offer: PairingOffer = {
|
||||
v: PAIRING_OFFER_VERSION,
|
||||
endpoint: 'ws://127.0.0.1:46768',
|
||||
deviceToken: 'secret-device-token',
|
||||
publicKeyB64: 'secret-key',
|
||||
pairedDeviceId: 'paired-device'
|
||||
}
|
||||
function accessEnvironment() {
|
||||
const original = environment()
|
||||
return {
|
||||
...original,
|
||||
connectionDependency: 'ssh-tunnel' as const,
|
||||
sshAccess: accessLink,
|
||||
endpoints: [...original.endpoints, { ...original.endpoints[0]!, id: accessLink.endpointId }],
|
||||
preferredEndpointId: accessLink.endpointId
|
||||
}
|
||||
}
|
||||
function environment() {
|
||||
return createEnvironmentFromPairingOffer({
|
||||
id: 'environment-1',
|
||||
name: 'Host',
|
||||
now: 1,
|
||||
offer,
|
||||
runtimeId: 'host-runtime'
|
||||
})
|
||||
}
|
||||
|
||||
describe('runtime SSH access without deployment ownership', () => {
|
||||
it('keeps old stored environments valid without adding an SSH dependency', () => {
|
||||
const original = environment()
|
||||
const restored = RuntimeEnvironmentStoreSchema.parse({ version: 1, environments: [original] })
|
||||
.environments[0]!
|
||||
expect(restored).toEqual(original)
|
||||
expect(getRuntimeSshAccess(KnownRuntimeEnvironmentSchema.parse(restored))).toBeUndefined()
|
||||
expect(restored).not.toHaveProperty('sshAccess')
|
||||
// The persisted envelope never carries sidecar state, even when handed a linked environment.
|
||||
const persisted = RuntimeEnvironmentStoreSchema.parse({
|
||||
version: 1,
|
||||
environments: [accessEnvironment()]
|
||||
}).environments[0]!
|
||||
expect(persisted).not.toHaveProperty('sshAccess')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ sshTargetId: '' },
|
||||
{ sshTargetGeneration: 0 },
|
||||
{ sshTargetGeneration: 1.5 },
|
||||
{ localPort: 0 },
|
||||
{ localPort: 65536 },
|
||||
{ remotePort: 0 },
|
||||
{ remotePort: 65536 }
|
||||
])('rejects malformed generic access links: %j', (change) => {
|
||||
expect(
|
||||
KnownRuntimeEnvironmentSchema.safeParse({
|
||||
...accessEnvironment(),
|
||||
sshAccess: { ...accessLink, ...change }
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves SSH metadata while redacting the same pairing secrets', () => {
|
||||
const accessed = KnownRuntimeEnvironmentSchema.parse(accessEnvironment())
|
||||
const redacted = redactRuntimeEnvironment(accessed)
|
||||
expect(getRuntimeSshAccess(redacted)).toEqual(accessLink)
|
||||
expect(redacted.endpoints[0]).not.toHaveProperty('deviceToken')
|
||||
expect(redacted.endpoints[0]).not.toHaveProperty('publicKeyB64')
|
||||
expect(JSON.stringify(redacted)).not.toContain('secret-')
|
||||
expect(getPreferredPairingOffer(accessed)).toEqual(offer)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ endpointId: 'missing' },
|
||||
{ previousPreferredEndpointId: 'missing' },
|
||||
{ previousPreferredEndpointId: 'ssh-access' },
|
||||
{ localPort: 46769 }
|
||||
])('rejects access links that cannot restore the prior endpoint: %j', (change) => {
|
||||
expect(
|
||||
KnownRuntimeEnvironmentSchema.safeParse({
|
||||
...accessEnvironment(),
|
||||
sshAccess: { ...accessLink, ...change }
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an unpreferred SSH access endpoint', () => {
|
||||
expect(
|
||||
KnownRuntimeEnvironmentSchema.safeParse({
|
||||
...accessEnvironment(),
|
||||
preferredEndpointId: accessLink.previousPreferredEndpointId
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a non-loopback SSH access endpoint', () => {
|
||||
const accessed = accessEnvironment()
|
||||
accessed.endpoints[1]!.endpoint = 'ws://remote.example:46768'
|
||||
expect(KnownRuntimeEnvironmentSchema.safeParse(accessed).success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a generic access link without its SSH dependency', () => {
|
||||
expect(
|
||||
KnownRuntimeEnvironmentSchema.safeParse({
|
||||
...accessEnvironment(),
|
||||
connectionDependency: undefined
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a non-WebSocket loopback endpoint', () => {
|
||||
const accessed = accessEnvironment()
|
||||
accessed.endpoints[1]!.endpoint = 'https://127.0.0.1:46768'
|
||||
expect(KnownRuntimeEnvironmentSchema.safeParse(accessed).success).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,7 @@
|
||||
import { z } from 'zod'
|
||||
import { PAIRING_OFFER_VERSION, type PairingOffer } from './pairing'
|
||||
import { classifyRemotePairingHostname } from './remote-pairing-address'
|
||||
import { RuntimeEnvironmentReconciliationRecordSchema } from './runtime-environment-reconciliation-record'
|
||||
|
||||
export const RuntimeAccessEndpointSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
@@ -20,7 +22,38 @@ export type PublicRuntimeAccessEndpoint = z.infer<typeof PublicRuntimeAccessEndp
|
||||
export const RuntimeEnvironmentSourceSchema = z.enum(['manual', 'ephemeral-vm'])
|
||||
export type RuntimeEnvironmentSource = z.infer<typeof RuntimeEnvironmentSourceSchema>
|
||||
|
||||
export const KnownRuntimeEnvironmentSchema = z.object({
|
||||
export const RuntimeSshTunnelLinkSchema = z.object({
|
||||
sshTargetId: z.string().min(1),
|
||||
sshTargetGeneration: z.number().int().positive(),
|
||||
localPort: z.number().int().min(1).max(65_535),
|
||||
remotePort: z.number().int().min(1).max(65_535)
|
||||
})
|
||||
|
||||
export type RuntimeSshTunnelLink = z.infer<typeof RuntimeSshTunnelLinkSchema>
|
||||
|
||||
export const RuntimeSshAccessLinkSchema = RuntimeSshTunnelLinkSchema.extend({
|
||||
requestId: z.string().min(1).optional(),
|
||||
targetFingerprint: z.string().min(1).optional(),
|
||||
endpointId: z.string().min(1),
|
||||
previousPreferredEndpointId: z.string().min(1)
|
||||
})
|
||||
|
||||
export type RuntimeSshAccessLink = z.infer<typeof RuntimeSshAccessLinkSchema>
|
||||
|
||||
export const RuntimeSshAccessOperationSchema = RuntimeSshTunnelLinkSchema.omit({ localPort: true })
|
||||
.extend({
|
||||
requestId: z.string().min(1),
|
||||
operation: z.enum(['link', 'unlink']),
|
||||
targetFingerprint: z.string().min(1).optional()
|
||||
})
|
||||
.refine((intent) => intent.operation !== 'link' || !!intent.targetFingerprint, {
|
||||
message: 'Link intent requires a target fingerprint.'
|
||||
})
|
||||
|
||||
export type RuntimeSshAccessOperation = z.infer<typeof RuntimeSshAccessOperationSchema>
|
||||
|
||||
/** The fields shipped builds read and rewrite in orca-environments.json. */
|
||||
export const PersistedRuntimeEnvironmentSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
createdAt: z.number().finite(),
|
||||
@@ -35,6 +68,46 @@ export const KnownRuntimeEnvironmentSchema = z.object({
|
||||
preferredEndpointId: z.string().min(1)
|
||||
})
|
||||
|
||||
export type PersistedRuntimeEnvironment = z.infer<typeof PersistedRuntimeEnvironmentSchema>
|
||||
|
||||
/**
|
||||
* A persisted environment with its sidecar state overlaid (runtime-environment-sidecar). These
|
||||
* fields never enter orca-environments.json: shipped builds strip unknown keys when they rewrite it.
|
||||
*/
|
||||
export const KnownRuntimeEnvironmentSchema = PersistedRuntimeEnvironmentSchema.extend({
|
||||
sshAccess: RuntimeSshAccessLinkSchema.optional(),
|
||||
pendingSshAccessOperation: RuntimeSshAccessOperationSchema.optional(),
|
||||
reconciliation: RuntimeEnvironmentReconciliationRecordSchema.optional()
|
||||
})
|
||||
.refine(
|
||||
({ pendingSshAccessOperation, sshAccess, connectionDependency }) =>
|
||||
!pendingSshAccessOperation || (!sshAccess && !connectionDependency),
|
||||
{
|
||||
message: 'Pending SSH access operations cannot coexist with active SSH access.',
|
||||
path: ['pendingSshAccessOperation']
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(environment) => {
|
||||
const access = environment.sshAccess
|
||||
return (
|
||||
!access ||
|
||||
(environment.connectionDependency === 'ssh-tunnel' &&
|
||||
environment.preferredEndpointId === access.endpointId &&
|
||||
access.previousPreferredEndpointId !== access.endpointId &&
|
||||
environment.endpoints.some(
|
||||
(endpoint) => endpoint.id === access.previousPreferredEndpointId
|
||||
) &&
|
||||
getPreferredLoopbackRuntimePort(environment) === access.localPort)
|
||||
)
|
||||
},
|
||||
{
|
||||
message:
|
||||
'Runtime SSH access must preserve its previous endpoint and prefer its loopback endpoint.',
|
||||
path: ['sshAccess']
|
||||
}
|
||||
)
|
||||
|
||||
export type KnownRuntimeEnvironment = z.infer<typeof KnownRuntimeEnvironmentSchema>
|
||||
|
||||
export type PublicKnownRuntimeEnvironment = Omit<KnownRuntimeEnvironment, 'endpoints'> & {
|
||||
@@ -52,9 +125,11 @@ export function redactRuntimeEnvironment(
|
||||
}
|
||||
}
|
||||
|
||||
// Why version 1 and the persisted schema: shipped builds accept only this shape, so every
|
||||
// T5 field lives in the sidecar and a downgrade rewrite cannot lose or reject it.
|
||||
export const RuntimeEnvironmentStoreSchema = z.object({
|
||||
version: z.literal(1),
|
||||
environments: z.array(KnownRuntimeEnvironmentSchema)
|
||||
environments: z.array(PersistedRuntimeEnvironmentSchema)
|
||||
})
|
||||
|
||||
export type RuntimeEnvironmentStore = z.infer<typeof RuntimeEnvironmentStoreSchema>
|
||||
@@ -121,3 +196,35 @@ export function getPreferredPairingOffer(environment: KnownRuntimeEnvironment):
|
||||
...(environment.pairedDeviceId ? { pairedDeviceId: environment.pairedDeviceId } : {})
|
||||
}
|
||||
}
|
||||
|
||||
/** SSH is an access path for a paired server; managed lifecycle ownership is T6's deployment link. */
|
||||
export function getRuntimeSshAccess(
|
||||
environment: Pick<KnownRuntimeEnvironment, 'sshAccess'>
|
||||
): RuntimeSshTunnelLink | undefined {
|
||||
return environment.sshAccess
|
||||
}
|
||||
|
||||
export function getPreferredLoopbackRuntimePort(environment: {
|
||||
endpoints: { id: string; endpoint: string }[]
|
||||
preferredEndpointId: string
|
||||
}): number | null {
|
||||
const endpoint = environment.endpoints.find(
|
||||
(entry) => entry.id === environment.preferredEndpointId
|
||||
)
|
||||
if (!endpoint) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const url = new URL(endpoint.endpoint)
|
||||
const port = Number(url.port)
|
||||
return (url.protocol === 'ws:' || url.protocol === 'wss:') &&
|
||||
classifyRemotePairingHostname(url.hostname) === 'loopback' &&
|
||||
Number.isInteger(port) &&
|
||||
port >= 1 &&
|
||||
port <= 65_535
|
||||
? port
|
||||
: null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user