Files
orca/cloud/apps/sandbox-control/test/ssh-access.test.ts
T
Neil 984f29fc37 feat(cloud): SSH into Orca Cloud workspaces (orca ssh, ssh-proxy, VS Code/Cursor Remote-SSH) (#55)
* feat(cloud): SSH into Orca Cloud workspaces (orca ssh, ssh-proxy, VS Code/Cursor Remote-SSH)

- VM: unprivileged key-only sshd on 127.0.0.1:2222 (orca-sshd), host key and Orca-managed
  authorized_keys on the persistent disk; orca-ssh-keys entry point; provision extracts only the
  server binaries (dnf or apt, +1.5-2 MB) and unlocks the sandbox user for key logins.
- Control plane: POST/DELETE /v1/sandboxes/:id/ssh-keys runs the fixed entry point and answers
  the pinned host key; editor Remote-SSH server hosts on the egress allowlist.
- Desktop: per-workspace ed25519 keys in userData (0600), Orca-owned ssh_config + pinned
  known_hosts, ~/.ssh/config Include only after consent; wake -> key -> runtime port forward
  (openRuntimePortForward reason 'ssh'); revoke (deferred while asleep); SSH traffic keeps the
  workspace awake.
- CLI: orca ssh-proxy <workspace> (ProxyCommand) and orca ssh <workspace>.
- UI: Open in -> VS Code / Cursor over Remote SSH, SSH submenu (Copy SSH Command, Revoke),
  include consent dialog, progress/error toasts.

* fix(cloud-ssh): review fixes — no agent forwarding, reinstall per VM boot, wake to revoke

- ForwardAgent/ForwardX11 no in Orca's host blocks; AllowAgentForwarding no in the VM's sshd.
- Reinstall the key (idempotent; restarts sshd, re-pins the host key) once per VM boot, so a
  wiped disk, rollover or dead sshd heals on the next connection.
- Revoke wakes the VM to remove its copy; only an unreachable VM falls back to the queue.
- orca-ssh-keys rejects multi-line keys; sshd log truncated per start; audit log entries for
  ssh key add/remove; state parsed per entry; % escaped in ssh_config paths; ProxyCommand pinned
  to this app's profile; ssh-proxy flushes stdout before exit; --print shell-quotes; editors
  open via --folder-uri with a checked path; "Remove from ~/.ssh/config" + one-time backup.
2026-11-09 06:37:23 -08:00

80 lines
3.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest'
import { HostedSandboxSshAccessResponseSchema } from '../../../../src/shared/hosted-sandbox/hosted-sandbox-wire'
import { SSH_KEYS_COMMAND } from '../src/ssh-access'
import { createHarness } from './harness'
const create = { workspaceId: 'ws-1', machineSize: 'standard' }
const KEY = `ssh-ed25519 ${'A'.repeat(68)} orca:mac-studio:ws-1`
async function running() {
const h = await createHarness()
const { body } = await h.call('POST', '/v1/sandboxes', { body: create })
return { h, id: body.sandbox.id as string }
}
describe('ssh key routes', () => {
it('installs a key through the fixed entry point and answers the pinned host key', async () => {
const { h, id } = await running()
const res = await h.call('POST', `/v1/sandboxes/${id}/ssh-keys`, { body: { publicKey: KEY } })
expect(res.status).toBe(200)
const { ssh } = HostedSandboxSshAccessResponseSchema.parse(res.body)
expect(ssh).toMatchObject({ port: 2222, user: 'vercel-sandbox' })
const command = h.vercel.commands.at(-1)!
// The key travels in the env, never on the command line.
expect(command).toMatchObject({ command: SSH_KEYS_COMMAND, args: ['add'], env: { ORCA_SSH_PUBLIC_KEY: KEY } })
expect([...h.vercel.authorizedKeys.values()]).toEqual([[KEY]])
expect(h.raw.prepare('SELECT action, target_id, detail FROM audit_log').all()).toEqual([
{ action: 'sandbox.ssh_key_added', target_id: id, detail: JSON.stringify({ key: 'orca:mac-studio:ws-1' }) }
])
})
it('revokes the key it installed', async () => {
const { h, id } = await running()
await h.call('POST', `/v1/sandboxes/${id}/ssh-keys`, { body: { publicKey: KEY } })
const res = await h.call('DELETE', `/v1/sandboxes/${id}/ssh-keys`, { body: { publicKey: KEY } })
expect(res.status).toBe(200)
expect(h.vercel.commands.at(-1)!.args).toEqual(['remove'])
expect([...h.vercel.authorizedKeys.values()]).toEqual([[]])
})
it.each([
['an rsa key', `ssh-rsa ${'A'.repeat(68)} orca:x`],
['key options', `command="sh" ssh-ed25519 ${'A'.repeat(68)} orca:x`],
['a second line', `ssh-ed25519 ${'A'.repeat(68)} orca:x\nssh-ed25519 ${'B'.repeat(68)} evil`],
['a comment with spaces', `ssh-ed25519 ${'A'.repeat(68)} orca x`]
])('refuses %s before reaching the VM', async (_label, publicKey) => {
const { h, id } = await running()
const before = h.vercel.commands.length
const res = await h.call('POST', `/v1/sandboxes/${id}/ssh-keys`, { body: { publicKey } })
expect(res).toEqual({ status: 400, body: { error: 'invalid_request' } })
expect(h.vercel.commands).toHaveLength(before)
})
it('answers conflict for a sleeping workspace instead of waking it', async () => {
const { h, id } = await running()
await h.call('POST', `/v1/sandboxes/${id}/suspend`)
const res = await h.call('POST', `/v1/sandboxes/${id}/ssh-keys`, { body: { publicKey: KEY } })
expect(res).toEqual({ status: 409, body: { error: 'conflict', reason: 'sandbox_suspended' } })
})
it('names an image without sshd so the desktop can say so', async () => {
const { h, id } = await running()
h.vercel.sshKeysExit = 3
const res = await h.call('POST', `/v1/sandboxes/${id}/ssh-keys`, { body: { publicKey: KEY } })
expect(res).toEqual({ status: 409, body: { error: 'conflict', reason: 'ssh_unsupported_image' } })
})
it('summarizes a failing entry point without its output', async () => {
const { h, id } = await running()
h.vercel.sshKeysExit = 1
const res = await h.call('POST', `/v1/sandboxes/${id}/ssh-keys`, { body: { publicKey: KEY } })
expect(res).toEqual({ status: 502, body: { error: 'provider_unavailable', reason: 'ssh_keys_failed' } })
})
it('hides another account’s sandbox', async () => {
const { h, id } = await running()
const res = await h.call('POST', `/v1/sandboxes/${id}/ssh-keys`, { body: { publicKey: KEY }, as: 'bob' })
expect(res.status).toBe(404)
})
})