diff --git a/src/main/runtime/runtime-environment-identity-verification.ts b/src/main/runtime/runtime-environment-identity-verification.ts new file mode 100644 index 00000000000..6fec7dbc4d8 --- /dev/null +++ b/src/main/runtime/runtime-environment-identity-verification.ts @@ -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( + 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 } +} diff --git a/src/shared/runtime-environment-authority-binding.ts b/src/shared/runtime-environment-authority-binding.ts new file mode 100644 index 00000000000..79b6e114957 --- /dev/null +++ b/src/shared/runtime-environment-authority-binding.ts @@ -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 + } +} diff --git a/src/shared/runtime-environment-reconciliation-record.ts b/src/shared/runtime-environment-reconciliation-record.ts new file mode 100644 index 00000000000..232ef9bfb95 --- /dev/null +++ b/src/shared/runtime-environment-reconciliation-record.ts @@ -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.') + } +} diff --git a/src/shared/runtime-environment-sidecar.test.ts b/src/shared/runtime-environment-sidecar.test.ts new file mode 100644 index 00000000000..0e372ddb53f --- /dev/null +++ b/src/shared/runtime-environment-sidecar.test.ts @@ -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[]) => 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] + }) + }) +}) diff --git a/src/shared/runtime-environment-sidecar.ts b/src/shared/runtime-environment-sidecar.ts new file mode 100644 index 00000000000..46851781453 --- /dev/null +++ b/src/shared/runtime-environment-sidecar.ts @@ -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 +export type RuntimeEnvironmentSidecarSshAccess = z.infer +type RuntimeEnvironmentSidecar = z.infer + +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 | null +): void { + const current = readRuntimeEnvironmentSidecar(userDataPath) + const live = new Map(environments.map((candidate) => [candidate.id, candidate])) + const entries: Record = {} + 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 +} diff --git a/src/shared/runtime-environment-ssh-access-store.test.ts b/src/shared/runtime-environment-ssh-access-store.test.ts new file mode 100644 index 00000000000..b51adc71b8f --- /dev/null +++ b/src/shared/runtime-environment-ssh-access-store.test.ts @@ -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, 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() + }) +}) diff --git a/src/shared/runtime-environment-ssh-access-store.ts b/src/shared/runtime-environment-ssh-access-store.ts new file mode 100644 index 00000000000..db14401223c --- /dev/null +++ b/src/shared/runtime-environment-ssh-access-store.ts @@ -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 + +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[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) +} diff --git a/src/shared/runtime-environment-store-file.ts b/src/shared/runtime-environment-store-file.ts new file mode 100644 index 00000000000..a69a3fe22ea --- /dev/null +++ b/src/shared/runtime-environment-store-file.ts @@ -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 + 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 + } +} diff --git a/src/shared/runtime-environment-store.ts b/src/shared/runtime-environment-store.ts index e784dc80281..bf08a7276a5 100644 --- a/src/shared/runtime-environment-store.ts +++ b/src/shared/runtime-environment-store.ts @@ -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( + 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 - } -} diff --git a/src/shared/runtime-environments-ssh-access.test.ts b/src/shared/runtime-environments-ssh-access.test.ts new file mode 100644 index 00000000000..d2ec327c151 --- /dev/null +++ b/src/shared/runtime-environments-ssh-access.test.ts @@ -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) + }) +}) diff --git a/src/shared/runtime-environments.ts b/src/shared/runtime-environments.ts index b3411eb3f70..f5c57c17889 100644 --- a/src/shared/runtime-environments.ts +++ b/src/shared/runtime-environments.ts @@ -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 -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 + +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 + +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 + +/** 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 + +/** + * 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 export type PublicKnownRuntimeEnvironment = Omit & { @@ -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 @@ -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 +): 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 + } +}