mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 08:02:21 +00:00
* feat(ssh): deploy and pair an empty managed orcad server over SSH (#16741 T6-5) Adds deploy + pair + status for a managed orcad environment on an empty SSH host, the loopback tunnel it is reached through (rebuilt on reconnect and after host resume), SSH provisioning of a new host, and SSH access for an already paired server. The deployment link lives in the environment sidecar so a downgraded build cannot strip it. Inert until the T6-6 settings UI. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(ssh): refuse a managed claim while saved state still references the host Until the T6-8 census exists, a target is claimable only when no workspace session, automation, worktree metadata or saved PTY lease (any status) points at it. An unreadable store refuses as unverifiable. Refusals name what blocked them. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(ssh): keep electron out of the resume path; report a decommission journal in status The caller now passes the profile path for managed-tunnel recovery after host resume, so ssh-host-sleep-reconnect no longer reads electron's app. Status maps T6-4's decommission transaction. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: m4air <m4air@m4airs-Air.localdomain> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
379 lines
15 KiB
TypeScript
379 lines
15 KiB
TypeScript
import { mkdtempSync, rmSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { encodePairingOffer } from '../../shared/pairing'
|
|
import {
|
|
addEnvironmentFromPairingCode,
|
|
resolveEnvironment
|
|
} from '../../shared/runtime-environment-store'
|
|
import { createManagedOrcadSshOwner } from '../../shared/managed-orcad-ssh-owner'
|
|
import { readPersistedEnvironmentStore } from '../../shared/runtime-environment-store-file'
|
|
import { writeRuntimeEnvironmentSidecarEntry } from '../../shared/runtime-environment-sidecar'
|
|
import type { SshTarget } from '../../shared/ssh-types'
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
infrastructure: vi.fn(),
|
|
connect: vi.fn(),
|
|
start: vi.fn(),
|
|
close: vi.fn(),
|
|
ensure: vi.fn(),
|
|
verify: vi.fn(),
|
|
flush: vi.fn(),
|
|
preflight: vi.fn(),
|
|
hasDirectAuthority: vi.fn()
|
|
}))
|
|
vi.mock('./orcad-managed-runtime-context', () => ({
|
|
requireManagedOrcadInfrastructure: mocks.infrastructure
|
|
}))
|
|
vi.mock('./orcad-managed-tunnel', () => ({
|
|
startOrcadManagedTunnel: mocks.start,
|
|
closeOrcadManagedTunnel: mocks.close,
|
|
ensureOrcadManagedTunnel: mocks.ensure
|
|
}))
|
|
vi.mock('./runtime-ssh-access-verification', () => ({
|
|
verifyRuntimeEnvironmentSshTunnel: mocks.verify
|
|
}))
|
|
vi.mock('./ssh-target-registry', () => ({
|
|
hasRegisteredDirectSshAuthority: mocks.hasDirectAuthority
|
|
}))
|
|
import {
|
|
fingerprintRuntimeSshTarget,
|
|
linkRuntimeSshAccess,
|
|
unlinkRuntimeSshAccess
|
|
} from './runtime-ssh-access'
|
|
|
|
const pairing = {
|
|
v: 2 as const,
|
|
endpoint: 'wss://example.test/runtime',
|
|
publicKeyB64: Buffer.alloc(32, 1).toString('base64'),
|
|
deviceToken: 'secret',
|
|
pairedDeviceId: 'client'
|
|
}
|
|
|
|
describe('independent runtime SSH access coordinator', () => {
|
|
let userDataPath: string
|
|
let environmentId: string
|
|
let target: SshTarget
|
|
let events: string[]
|
|
const request = () => ({
|
|
selector: environmentId,
|
|
requestId: 'request',
|
|
sshTargetId: 'target',
|
|
remotePort: 6768
|
|
})
|
|
const current = () => resolveEnvironment(userDataPath, environmentId)
|
|
const unlink = (requestId = 'unlink') =>
|
|
unlinkRuntimeSshAccess(
|
|
userDataPath,
|
|
{ selector: environmentId, requestId },
|
|
{
|
|
invalidateTransport: () => {
|
|
events.push('invalidate')
|
|
expect(current().sshAccess).toBeUndefined()
|
|
}
|
|
}
|
|
)
|
|
beforeEach(() => {
|
|
vi.resetAllMocks()
|
|
events = []
|
|
userDataPath = mkdtempSync(join(tmpdir(), 'runtime-access-test-'))
|
|
environmentId = addEnvironmentFromPairingCode(userDataPath, {
|
|
name: 'server',
|
|
pairingCode: encodePairingOffer(pairing)
|
|
}).id
|
|
target = {
|
|
id: 'target',
|
|
label: 'host',
|
|
host: 'host.example',
|
|
port: 22,
|
|
username: 'user',
|
|
source: 'manual',
|
|
generation: 3
|
|
}
|
|
mocks.preflight.mockReturnValue({ blockers: [] })
|
|
mocks.flush.mockImplementation(async () => {
|
|
expect(current().pendingSshAccessOperation).toBeDefined()
|
|
events.push('flush')
|
|
})
|
|
mocks.infrastructure.mockReturnValue({
|
|
connectionManager: { connect: mocks.connect },
|
|
claims: {
|
|
listTargets: () => [target],
|
|
preflight: mocks.preflight,
|
|
flush: mocks.flush,
|
|
claim: () => {
|
|
expect(current().pendingSshAccessOperation?.operation).toBe('link')
|
|
events.push('claim')
|
|
target = { ...target, owner: createManagedOrcadSshOwner(environmentId) }
|
|
return target
|
|
},
|
|
release: () => {
|
|
expect(current().pendingSshAccessOperation?.operation).toBe('unlink')
|
|
events.push('release')
|
|
target = { ...target, owner: undefined }
|
|
return target
|
|
}
|
|
}
|
|
})
|
|
mocks.connect.mockImplementation(async () => {
|
|
events.push('connect')
|
|
return {}
|
|
})
|
|
mocks.start.mockImplementation(async () => {
|
|
events.push('tunnel')
|
|
return 41000
|
|
})
|
|
mocks.close.mockImplementation(async () => {
|
|
events.push('close')
|
|
})
|
|
mocks.verify.mockImplementation(async () => {
|
|
events.push('verify')
|
|
return {
|
|
verifiedRuntimeId: 'runtime',
|
|
verifiedPairing: { ...pairing, endpoint: 'ws://127.0.0.1:41000' },
|
|
runtimeStatus: {}
|
|
}
|
|
})
|
|
})
|
|
afterEach(() => rmSync(userDataPath, { recursive: true, force: true }))
|
|
|
|
it.each(['link', 'unlink'])(
|
|
'refuses %s before side effects during host reconciliation',
|
|
async (operation) => {
|
|
const environments = readPersistedEnvironmentStore(userDataPath).environments
|
|
const persisted = environments.find((entry) => entry.id === environmentId)
|
|
if (!persisted) {
|
|
throw new Error('missing environment')
|
|
}
|
|
writeRuntimeEnvironmentSidecarEntry(userDataPath, environments, persisted, {
|
|
reconciliation: {
|
|
version: 1,
|
|
stage: 'prepared',
|
|
requestId: 'reconciliation',
|
|
canonicalEnvironmentId: environmentId,
|
|
runtimeId: 'host-runtime',
|
|
preparedAt: 1,
|
|
registrations: [
|
|
{ environmentId, authorityDigest: 'a'.repeat(64) },
|
|
{ environmentId: 'peer', authorityDigest: 'b'.repeat(64) }
|
|
]
|
|
}
|
|
})
|
|
await expect(
|
|
operation === 'link' ? linkRuntimeSshAccess(userDataPath, request()) : unlink()
|
|
).rejects.toThrow('reconciliation')
|
|
expect(mocks.connect).not.toHaveBeenCalled()
|
|
expect(mocks.start).not.toHaveBeenCalled()
|
|
expect(mocks.close).not.toHaveBeenCalled()
|
|
expect(mocks.flush).not.toHaveBeenCalled()
|
|
}
|
|
)
|
|
|
|
it('persists intent then claim before connecting and publishes redacted authenticated access', async () => {
|
|
const result = await linkRuntimeSshAccess(userDataPath, request())
|
|
expect(events).toEqual(['flush', 'claim', 'flush', 'connect', 'tunnel', 'verify'])
|
|
expect(result.sshAccess?.sshTargetId).toBe('target')
|
|
expect(result.orcadDeployment).toBeUndefined()
|
|
expect(JSON.stringify(result)).not.toContain('secret')
|
|
expect(current().pendingSshAccessOperation).toBeUndefined()
|
|
expect(mocks.preflight).toHaveBeenCalledWith('target')
|
|
})
|
|
|
|
it('handles lost link responses without connecting or verifying again', async () => {
|
|
const first = await linkRuntimeSshAccess(userDataPath, request())
|
|
expect(await linkRuntimeSshAccess(userDataPath, request())).toEqual(first)
|
|
expect(mocks.verify).toHaveBeenCalledTimes(1)
|
|
expect(mocks.ensure).toHaveBeenCalledWith(userDataPath, environmentId)
|
|
})
|
|
|
|
it('invalidates old transport only after authenticated access is published', async () => {
|
|
const invalidateTransport = vi.fn(() => {
|
|
expect(current().sshAccess).toBeDefined()
|
|
expect(current().pendingSshAccessOperation).toBeUndefined()
|
|
})
|
|
await linkRuntimeSshAccess(userDataPath, request(), { invalidateTransport })
|
|
await linkRuntimeSshAccess(userDataPath, request(), { invalidateTransport })
|
|
expect(invalidateTransport).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('rejects changed request tuple after a completed link', async () => {
|
|
await linkRuntimeSshAccess(userDataPath, request())
|
|
await expect(
|
|
linkRuntimeSshAccess(userDataPath, { ...request(), remotePort: 6769 })
|
|
).rejects.toThrow('different')
|
|
expect(mocks.verify).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('preserves the committed tunnel when transport refresh fails and retries the same link', async () => {
|
|
const invalidateTransport = vi.fn().mockRejectedValueOnce(new Error('refresh failed'))
|
|
await expect(
|
|
linkRuntimeSshAccess(userDataPath, request(), { invalidateTransport })
|
|
).rejects.toThrow('refresh failed')
|
|
const committed = current()
|
|
expect(committed.sshAccess?.requestId).toBe('request')
|
|
expect(committed.pendingSshAccessOperation).toBeUndefined()
|
|
expect(target.owner).toEqual(createManagedOrcadSshOwner(environmentId))
|
|
expect(mocks.close).not.toHaveBeenCalled()
|
|
|
|
await linkRuntimeSshAccess(userDataPath, request(), { invalidateTransport })
|
|
expect(current()).toEqual(committed)
|
|
expect(mocks.ensure).toHaveBeenCalledExactlyOnceWith(userDataPath, environmentId)
|
|
expect(mocks.connect).toHaveBeenCalledTimes(1)
|
|
expect(mocks.verify).toHaveBeenCalledTimes(1)
|
|
expect(mocks.start).toHaveBeenCalledTimes(1)
|
|
expect(invalidateTransport).toHaveBeenCalledTimes(2)
|
|
expect(mocks.close).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('fences connection configuration changes before opening a forward', async () => {
|
|
mocks.connect.mockImplementationOnce(async () => {
|
|
target = { ...target, username: 'other' }
|
|
return {}
|
|
})
|
|
await expect(linkRuntimeSshAccess(userDataPath, request())).rejects.toThrow('changed')
|
|
expect(mocks.start).not.toHaveBeenCalled()
|
|
expect(current().pendingSshAccessOperation?.operation).toBe('link')
|
|
})
|
|
|
|
it('restores pairing before invalidating and closes before releasing the exact claim', async () => {
|
|
await linkRuntimeSshAccess(userDataPath, request())
|
|
events = []
|
|
await unlink()
|
|
expect(events).toEqual(['invalidate', 'close', 'release', 'flush'])
|
|
expect(current().endpoints[0].endpoint).toBe(pairing.endpoint)
|
|
expect(current().sshAccess).toBeUndefined()
|
|
expect(target.owner).toBeUndefined()
|
|
})
|
|
|
|
it('retains claim and pending intent on wrong-host proof, then permits cancellation', async () => {
|
|
mocks.verify.mockRejectedValueOnce(new Error('wrong host'))
|
|
await expect(linkRuntimeSshAccess(userDataPath, request())).rejects.toThrow('wrong host')
|
|
expect(current().pendingSshAccessOperation?.operation).toBe('link')
|
|
expect(current().sshAccess).toBeUndefined()
|
|
expect(target.owner).toBeDefined()
|
|
expect(mocks.close).toHaveBeenCalledTimes(1)
|
|
await unlink('request')
|
|
expect(target.owner).toBeUndefined()
|
|
})
|
|
|
|
it('retries verification failure with the same durable intent', async () => {
|
|
mocks.verify.mockRejectedValueOnce(new Error('unverifiable'))
|
|
await expect(linkRuntimeSshAccess(userDataPath, request())).rejects.toThrow()
|
|
await linkRuntimeSshAccess(userDataPath, request())
|
|
expect(current().sshAccess).toBeDefined()
|
|
})
|
|
|
|
it.each(['host', 'generation', 'owner'] as const)(
|
|
'fences %s reassignment during authenticated proof',
|
|
async (field) => {
|
|
mocks.verify.mockImplementationOnce(async () => {
|
|
target = {
|
|
...target,
|
|
...(field === 'host'
|
|
? { host: 'elsewhere' }
|
|
: field === 'generation'
|
|
? { generation: 4 }
|
|
: { owner: createManagedOrcadSshOwner('other') })
|
|
}
|
|
return { verifiedRuntimeId: 'runtime', verifiedPairing: pairing }
|
|
})
|
|
await expect(linkRuntimeSshAccess(userDataPath, request())).rejects.toThrow('changed')
|
|
expect(current().sshAccess).toBeUndefined()
|
|
expect(mocks.close).toHaveBeenCalled()
|
|
await expect(unlink('request')).rejects.toThrow('changed')
|
|
expect(events).not.toContain('release')
|
|
}
|
|
)
|
|
|
|
it.each([1, 2])('never connects when durable flush %s fails', async (flushNumber) => {
|
|
if (flushNumber === 2) {
|
|
mocks.flush.mockResolvedValueOnce(undefined)
|
|
}
|
|
mocks.flush.mockRejectedValueOnce(new Error('disk full'))
|
|
await expect(linkRuntimeSshAccess(userDataPath, request())).rejects.toThrow('disk full')
|
|
expect(mocks.connect).not.toHaveBeenCalled()
|
|
expect(current().pendingSshAccessOperation).toBeDefined()
|
|
expect(!!target.owner).toBe(flushNumber === 2)
|
|
})
|
|
|
|
it('resumes unlink after release flush failed without releasing another owner', async () => {
|
|
await linkRuntimeSshAccess(userDataPath, request())
|
|
mocks.flush.mockRejectedValueOnce(new Error('disk full'))
|
|
await expect(unlink()).rejects.toThrow('disk full')
|
|
expect(target.owner).toBeUndefined()
|
|
await unlink()
|
|
expect(events.filter((event) => event === 'release')).toHaveLength(1)
|
|
expect(current().pendingSshAccessOperation).toBeUndefined()
|
|
})
|
|
|
|
it('does not release a new owner when retrying an interrupted unlink', async () => {
|
|
await linkRuntimeSshAccess(userDataPath, request())
|
|
mocks.flush.mockRejectedValueOnce(new Error('disk full'))
|
|
await expect(unlink()).rejects.toThrow('disk full')
|
|
target = { ...target, owner: createManagedOrcadSshOwner('other') }
|
|
await expect(unlink()).rejects.toThrow('changed')
|
|
expect(events.filter((event) => event === 'release')).toHaveLength(1)
|
|
expect(current().pendingSshAccessOperation?.operation).toBe('unlink')
|
|
})
|
|
|
|
it('refuses direct authority even on an already claimed retry', async () => {
|
|
mocks.verify.mockRejectedValueOnce(new Error('offline'))
|
|
await expect(linkRuntimeSshAccess(userDataPath, request())).rejects.toThrow()
|
|
mocks.preflight.mockReturnValue({
|
|
blockers: [{ code: 'orcad_migration_direct_ssh_repositories' }]
|
|
})
|
|
await expect(linkRuntimeSshAccess(userDataPath, request())).rejects.toThrow(
|
|
'direct SSH authority'
|
|
)
|
|
expect(mocks.connect).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('rejects provisioning and missing durable generations without connecting', async () => {
|
|
target.orcadProvisioning = { name: 'host', requestId: 'provision' }
|
|
await expect(linkRuntimeSshAccess(userDataPath, request())).rejects.toThrow('provisioning')
|
|
target.orcadProvisioning = undefined
|
|
target.generation = undefined
|
|
await expect(linkRuntimeSshAccess(userDataPath, request())).rejects.toThrow('generation')
|
|
expect(mocks.connect).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('refuses a connected direct provider even when it has no catalog or leases', async () => {
|
|
mocks.hasDirectAuthority.mockReturnValue(true)
|
|
await expect(linkRuntimeSshAccess(userDataPath, request())).rejects.toThrow(
|
|
'Disconnect direct SSH authority'
|
|
)
|
|
expect(mocks.connect).not.toHaveBeenCalled()
|
|
expect(current().pendingSshAccessOperation).toBeUndefined()
|
|
})
|
|
|
|
it('treats a lost successful unlink response as an idempotent no-op', async () => {
|
|
await linkRuntimeSshAccess(userDataPath, request())
|
|
const unlinked = await unlink()
|
|
const before = [...events]
|
|
expect(await unlink()).toEqual(unlinked)
|
|
expect(events).toEqual(before)
|
|
})
|
|
|
|
it('hashes connection identity, not display state or owner', () => {
|
|
const hash = fingerprintRuntimeSshTarget(target)
|
|
expect(
|
|
fingerprintRuntimeSshTarget({
|
|
...target,
|
|
label: 'renamed',
|
|
lastRequiredPassphrase: true,
|
|
owner: createManagedOrcadSshOwner('other')
|
|
})
|
|
).toBe(hash)
|
|
expect(fingerprintRuntimeSshTarget({ ...target, proxyCommand: 'other-proxy' })).not.toBe(hash)
|
|
})
|
|
|
|
it('strictly validates ports and request IDs', () => {
|
|
expect(() => linkRuntimeSshAccess(userDataPath, { ...request(), remotePort: 0 })).toThrow()
|
|
expect(() =>
|
|
linkRuntimeSshAccess(userDataPath, { ...request(), requestId: '../other' })
|
|
).toThrow()
|
|
expect(mocks.infrastructure).not.toHaveBeenCalled()
|
|
})
|
|
})
|