Merge pull request #112 from nwparker/cloud/w5-cli

feat(cli): orca cloud commands + in-VM cloud access (VMs can reach the control plane their token names)
This commit is contained in:
Neil
2026-11-09 15:37:43 -08:00
committed by GitHub
18 changed files with 2516 additions and 2 deletions
+14 -1
View File
@@ -213,6 +213,19 @@ export function publicOrigin(env: Env, url?: URL): string | undefined {
return configured?.replace(/\/+$/, '') ?? url?.origin
}
/**
* Hosts every VM may reach beyond the defaults: EGRESS_EXTRA_HOSTS plus this control plane's public
* host, which the in-VM `orca cloud` CLI calls with its workspace token (credentials.json apiUrl).
*/
export function extraEgressHostsFor(env: Env): string[] {
const hosts = (env.EGRESS_EXTRA_HOSTS ?? '').split(',').map((host) => host.trim()).filter(Boolean)
const origin = publicOrigin(env)
if (origin) {
hosts.push(new URL(origin).hostname)
}
return [...new Set(hosts)]
}
function egressRoutes(env: Env, deps: Deps): (row: SandboxRow, host: string) => Promise<ProxiedRoute[]> {
const broker = deps.credentials ?? egressCredentials(env)
const memberGit = memberGitEnabled(env)
@@ -494,7 +507,7 @@ async function lifecycleDeps(env: Env, deps: Deps): Promise<LifecycleDeps | null
config: {
pool: poolConfigFromEnv(env),
shareLinkSecret: shareSecret(env),
extraEgressHosts: (env.EGRESS_EXTRA_HOSTS ?? '').split(',').map((host) => host.trim()).filter(Boolean),
extraEgressHosts: extraEgressHostsFor(env),
guardrails: guardrailsFromEnv(env),
snapshotExpirationMs:
env.SNAPSHOT_EXPIRATION_DAYS && Number(env.SNAPSHOT_EXPIRATION_DAYS) >= 1
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import type { Env } from '../src/env'
import { extraEgressHostsFor } from '../src/index'
const env = (vars: Partial<Env>) => vars as Env
describe('extraEgressHostsFor', () => {
it('lets VMs reach the control plane the in-VM CLI is told to call', () => {
expect(extraEgressHostsFor(env({ PUBLIC_API_URL: 'https://cp.example.dev/' }))).toEqual(['cp.example.dev'])
})
it('keeps EGRESS_EXTRA_HOSTS and de-duplicates', () => {
expect(
extraEgressHostsFor(env({ EGRESS_EXTRA_HOSTS: 'a.dev, cp.example.dev', PUBLIC_API_URL: 'https://cp.example.dev' }))
).toEqual(['a.dev', 'cp.example.dev'])
})
it('falls back to the egress proxy origin, and to nothing', () => {
expect(extraEgressHostsFor(env({ EGRESS_PROXY_URL: 'https://proxy.example.dev/egress' }))).toEqual(['proxy.example.dev'])
expect(extraEgressHostsFor(env({}))).toEqual([])
})
})
+201
View File
@@ -0,0 +1,201 @@
// Private build only: a small HTTPS client for the Orca Cloud public API (/v1/…). Every failure
// becomes a RuntimeClientError with a stable `cloud_*` code and next steps, so `--json` callers
// (agents) and people both get an actionable message.
import { RuntimeClientError } from '../runtime/types'
import type { CloudCredentials } from './cloud-credentials'
export type CloudFetch = (input: string, init: RequestInit) => Promise<Response>
export type CloudApiErrorBody = { error?: unknown; reason?: unknown }
const DEFAULT_TIMEOUT_MS = 60_000
export class CloudApiClient {
constructor(
readonly credentials: CloudCredentials,
private readonly fetchImpl: CloudFetch = fetch
) {}
async request<T>(
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
path: string,
options: { body?: unknown; timeoutMs?: number; query?: Record<string, string | undefined> } = {}
): Promise<T> {
const url = new URL(`${this.credentials.apiUrl}${path}`)
for (const [key, value] of Object.entries(options.query ?? {})) {
if (value !== undefined) {
url.searchParams.set(key, value)
}
}
let response: Response
try {
response = await this.fetchImpl(url.toString(), {
method,
headers: {
authorization: `Bearer ${this.credentials.token}`,
accept: 'application/json',
...(options.body !== undefined ? { 'content-type': 'application/json' } : {})
},
...(options.body !== undefined ? { body: JSON.stringify(options.body) } : {}),
// Why: a redirect could forward the bearer token to another origin.
redirect: 'error',
signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS)
})
} catch (error) {
const timedOut = error instanceof Error && error.name === 'TimeoutError'
throw new RuntimeClientError(
'cloud_unreachable',
timedOut
? `Orca Cloud did not answer ${method} ${path} within ${Math.round((options.timeoutMs ?? DEFAULT_TIMEOUT_MS) / 1000)}s.`
: `Could not reach Orca Cloud at ${this.credentials.apiUrl}: ${error instanceof Error ? error.message : String(error)}`,
{
apiUrl: this.credentials.apiUrl,
nextSteps: this.credentials.token.startsWith('orca_ws_')
? [
"Inside an Orca Cloud workspace the control plane must be on the VM's egress allowlist; restart the workspace after a control-plane update."
]
: ['Check the network and the control-plane URL, then retry.']
}
)
}
const text = await response.text()
let body: unknown = null
if (text) {
try {
body = JSON.parse(text)
} catch {
body = null
}
}
if (!response.ok) {
throw cloudApiError(response.status, (body ?? {}) as CloudApiErrorBody, {
credentials: this.credentials,
retryAfter: response.headers.get('retry-after')
})
}
return body as T
}
}
/** Maps a control-plane error response to the CLI's error. Exported for tests. */
export function cloudApiError(
status: number,
body: CloudApiErrorBody,
context: {
credentials: Pick<CloudCredentials, 'source' | 'token' | 'path'>
retryAfter?: string | null
}
): RuntimeClientError {
const code = typeof body.error === 'string' ? body.error : `http_${status}`
const reason = typeof body.reason === 'string' ? body.reason : null
const data = (nextSteps: string[], extra: Record<string, unknown> = {}) => ({
status,
apiError: code,
...(reason ? { reason } : {}),
...extra,
...(nextSteps.length > 0 ? { nextSteps } : {})
})
const inVm = context.credentials.token.startsWith('orca_ws_')
if (status === 401) {
return new RuntimeClientError(
'cloud_unauthorized',
inVm
? 'Orca Cloud rejected this workspace token. In-workspace CLI access may be turned off for the organization (that revokes every workspace token at once), or the token was replaced at boot or revoked on sleep.'
: 'Orca Cloud rejected the token: it is expired, revoked or for another control plane.',
data(
inVm
? [
'Ask an organization owner or admin to check in-workspace CLI access in Orca Cloud settings (vmCliEnabled); once it is on, restart the workspace for a fresh token.',
'Or set ORCA_CLOUD_TOKEN to an API key.'
]
: context.credentials.source === 'env'
? ['Set ORCA_CLOUD_TOKEN to a valid API key or session token.']
: ['Run `orca cloud login --token <token>` with a fresh API key or session token.']
)
)
}
if (status === 403 && reason === 'vm_cli_disabled') {
return new RuntimeClientError(
'cloud_vm_cli_disabled',
'In-workspace CLI access is turned off for this Orca Cloud organization, so this workspace cannot call Orca Cloud.',
data([
'Ask an organization owner or admin to turn on in-workspace CLI access in Orca Cloud settings (PUT /v1/settings {"vmCliEnabled": true}).'
])
)
}
if (status === 403 && reason === 'own_workspace') {
return new RuntimeClientError(
'cloud_forbidden',
'A workspace cannot sleep, archive or destroy itself; pick another workspace.',
data([])
)
}
if (status === 403 && code === 'insufficient_scope') {
return new RuntimeClientError(
'cloud_forbidden',
`This token lacks the ${reason ?? 'required'} scope.`,
data(['Create an API key with that scope in Orca Cloud settings and log in with it.'])
)
}
if (status === 403) {
const why: Record<string, string> = {
session_required: 'This needs a signed-in Orca session, not an API key or workspace token.',
admin_required: 'Only an organization owner or admin can do this.'
}
return new RuntimeClientError(
'cloud_forbidden',
(reason && why[reason]) || `Orca Cloud refused the request (${reason ?? code}).`,
data([])
)
}
if (status === 404) {
return new RuntimeClientError(
'cloud_not_found',
inVm
? 'Not found. A workspace token sees only its own workspace and the workspaces it created.'
: 'Not found in this Orca Cloud account.',
data(['Run `orca cloud workspace list` to see the workspaces this token can use.'])
)
}
if (status === 429) {
const seconds = Number(context.retryAfter)
return new RuntimeClientError(
'cloud_rate_limited',
reason === 'workspace_creates'
? 'Too many workspace creates for this account right now.'
: 'Too many requests to Orca Cloud right now.',
data([`Retry in ${Number.isFinite(seconds) && seconds > 0 ? seconds : 60}s.`], {
retryAfterSeconds: Number.isFinite(seconds) ? seconds : null
})
)
}
if (status === 400) {
return new RuntimeClientError(
'invalid_argument',
`Orca Cloud rejected the request: ${reason ?? code}.`,
data([])
)
}
if (status === 409) {
return new RuntimeClientError('cloud_conflict', `Conflict: ${reason ?? code}.`, data([]))
}
if (status === 402 || code === 'entitlement_denied' || code === 'billing_required') {
return new RuntimeClientError(
'cloud_billing',
`Orca Cloud billing refused this (${reason ?? code}).`,
data(['Check the plan and usage limits in Orca Cloud billing settings.'])
)
}
if (status === 503 && code === 'not_configured') {
return new RuntimeClientError(
'cloud_unavailable',
'This Orca Cloud control plane cannot run workspaces right now (not configured).',
data([])
)
}
return new RuntimeClientError(
status >= 500 ? 'cloud_unavailable' : 'cloud_api_error',
`Orca Cloud returned ${status} ${code}${reason ? ` (${reason})` : ''}.`,
data(status >= 500 ? ['Retry shortly; the workspace provider may be busy.'] : [])
)
}
+578
View File
@@ -0,0 +1,578 @@
import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync, mkdirSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { HandlerContext } from '../dispatch'
import { cloudApiError } from './cloud-api-client'
import {
cloudCredentialsPath,
nodeCloudCredentialFs,
resolveCloudCredentials,
writeCloudCredentials
} from './cloud-credentials'
import { createCloudHandlers, type CloudHandlerDeps } from './cloud-handlers'
import { normalizeCloudRepoUrl } from './cloud-operations'
import type { CloudWorkspace } from './cloud-format'
const API = 'https://cloud.test'
function workspace(overrides: Partial<CloudWorkspace> = {}): CloudWorkspace {
return {
id: 'sbx_aa11',
name: 'child',
projectId: null,
status: 'running',
archived: false,
hidden: false,
origin: 'vm',
machineSize: 'standard',
region: 'iad1',
repoUrl: null,
session: null,
lastError: null,
createdAt: 1_000,
updatedAt: 1_000,
...overrides
}
}
type Call = { method: string; url: string; body: unknown; auth: string | null }
/** A fake control plane: routes are "METHOD /path" → handler returning [status, body]. */
function fakeApi(
routes: Record<
string,
(call: Call) => [number, unknown] | [number, unknown, Record<string, string>]
>
) {
const calls: Call[] = []
const fetchImpl = vi.fn(async (input: string, init: RequestInit) => {
const url = new URL(input)
const call: Call = {
method: init.method ?? 'GET',
url: `${url.pathname}${url.search}`,
body: init.body ? JSON.parse(String(init.body)) : undefined,
auth: new Headers(init.headers).get('authorization')
}
calls.push(call)
const route = routes[`${call.method} ${url.pathname}`]
if (!route) {
return Response.json({ error: 'not_found' }, { status: 404 })
}
const [status, body, headers] = route(call)
return Response.json(body, { status, headers })
})
return { calls, fetchImpl }
}
let home: string
let logs: string[]
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'orca-cloud-cli-'))
logs = []
vi.spyOn(console, 'log').mockImplementation((line: unknown) => {
logs.push(String(line))
})
})
afterEach(() => {
vi.restoreAllMocks()
rmSync(home, { recursive: true, force: true })
})
function writeFile(contents: unknown): string {
const path = join(home, '.config', 'orca-cloud', 'credentials.json')
mkdirSync(join(home, '.config', 'orca-cloud'), { recursive: true })
writeFileSync(path, typeof contents === 'string' ? contents : JSON.stringify(contents))
return path
}
function deps(
fetchImpl: CloudHandlerDeps['fetch'],
env: Record<string, string | undefined> = {
ORCA_CLOUD_TOKEN: 'orca_sk_test',
ORCA_CLOUD_API_URL: API
},
extra: Partial<CloudHandlerDeps> = {}
): CloudHandlerDeps {
let clock = 10_000
return {
env,
fetch: fetchImpl,
fs: nodeCloudCredentialFs,
home,
now: () => clock,
sleep: async (ms) => {
clock += ms
},
readStdin: async () => null,
...extra
}
}
function ctx(flags: Record<string, string | boolean>, json = true): HandlerContext {
return {
flags: new Map(Object.entries(flags)),
// These handlers must never reach the Orca runtime.
get client(): never {
throw new Error('cloud commands must not use the runtime client')
},
cwd: home,
json
} as HandlerContext
}
function lastJson(): { ok: boolean; schemaVersion: number; result: Record<string, unknown> } {
return JSON.parse(logs.at(-1)!)
}
describe('cloud credential resolution', () => {
it('prefers ORCA_CLOUD_TOKEN + ORCA_CLOUD_API_URL over the credentials file', () => {
writeFile({ apiUrl: 'https://file.test', token: 'orca_ws_file', workspaceId: 'sbx_file' })
const creds = resolveCloudCredentials(
{ ORCA_CLOUD_TOKEN: 'orca_sk_env', ORCA_CLOUD_API_URL: 'https://env.test/' },
{ home }
)
expect(creds).toEqual({
apiUrl: 'https://env.test',
token: 'orca_sk_env',
workspaceId: null,
source: 'env',
path: null
})
})
it('takes the URL from the file when only ORCA_CLOUD_TOKEN is set', () => {
writeFile({ apiUrl: 'https://file.test', token: 'orca_ws_file' })
expect(resolveCloudCredentials({ ORCA_CLOUD_TOKEN: 'orca_sk_env' }, { home }).apiUrl).toBe(
'https://file.test'
)
})
it('reads the file the control plane writes inside a VM', () => {
const path = writeFile({
apiUrl: 'https://cp.test',
token: 'orca_ws_abc',
workspaceId: 'sbx_1'
})
expect(resolveCloudCredentials({}, { home })).toEqual({
apiUrl: 'https://cp.test',
token: 'orca_ws_abc',
workspaceId: 'sbx_1',
source: 'file',
path
})
})
it('honours ORCA_CLOUD_CREDENTIALS_FILE', () => {
const custom = join(home, 'custom.json')
writeFileSync(custom, JSON.stringify({ apiUrl: API, token: 't' }))
expect(cloudCredentialsPath({ ORCA_CLOUD_CREDENTIALS_FILE: custom }, home)).toBe(custom)
expect(resolveCloudCredentials({ ORCA_CLOUD_CREDENTIALS_FILE: custom }, { home }).token).toBe(
't'
)
})
it('explains how to sign in when nothing is configured, including the in-VM setting', () => {
expect(() => resolveCloudCredentials({}, { home })).toThrow(
expect.objectContaining({
code: 'cloud_not_signed_in',
data: expect.objectContaining({
nextSteps: [
expect.stringContaining('orca cloud login'),
expect.stringContaining('vmCliEnabled')
]
})
})
)
})
it('refuses ORCA_CLOUD_TOKEN without any URL', () => {
expect(() => resolveCloudCredentials({ ORCA_CLOUD_TOKEN: 'x' }, { home })).toThrow(
expect.objectContaining({
code: 'cloud_not_signed_in',
message: expect.stringContaining('ORCA_CLOUD_API_URL')
})
)
})
it('rejects malformed files and non-https URLs', () => {
writeFile('{not json')
expect(() => resolveCloudCredentials({}, { home })).toThrow(
expect.objectContaining({ code: 'cloud_credentials_invalid' })
)
expect(() =>
resolveCloudCredentials(
{ ORCA_CLOUD_TOKEN: 'x', ORCA_CLOUD_API_URL: 'http://evil.test' },
{ home }
)
).toThrow(/https/)
expect(
resolveCloudCredentials(
{ ORCA_CLOUD_TOKEN: 'x', ORCA_CLOUD_API_URL: 'http://localhost:8787' },
{ home }
).apiUrl
).toBe('http://localhost:8787')
})
it.skipIf(process.platform === 'win32')('writes the credentials file 0600', () => {
const path = writeCloudCredentials({}, { apiUrl: API, token: 'orca_sk_x' }, { home })
expect(statSync(path).mode & 0o777).toBe(0o600)
expect(JSON.parse(readFileSync(path, 'utf8'))).toEqual({ apiUrl: API, token: 'orca_sk_x' })
})
})
describe('cloud API error messages', () => {
const user = { source: 'file' as const, token: 'orca_sk_x', path: '/p' }
const vm = { source: 'file' as const, token: 'orca_ws_x', path: '/p' }
it('names the org setting when in-VM access is off', () => {
const error = cloudApiError(
403,
{ error: 'forbidden', reason: 'vm_cli_disabled' },
{ credentials: vm }
)
expect(error.code).toBe('cloud_vm_cli_disabled')
expect(error.message).toContain('turned off')
expect(JSON.stringify(error.data)).toContain('vmCliEnabled')
})
it('explains token expiry differently for workspace tokens and keys', () => {
expect(cloudApiError(401, { error: 'unauthorized' }, { credentials: vm }).message).toContain(
'turned off for the organization'
)
expect(cloudApiError(401, { error: 'unauthorized' }, { credentials: user }).data).toMatchObject(
{
nextSteps: [expect.stringContaining('orca cloud login')]
}
)
})
it('explains own-workspace refusals, sibling-only visibility, scopes and rate limits', () => {
expect(
cloudApiError(403, { error: 'forbidden', reason: 'own_workspace' }, { credentials: vm })
.message
).toContain('cannot sleep, archive or destroy itself')
expect(cloudApiError(404, { error: 'not_found' }, { credentials: vm }).message).toContain(
'workspaces it created'
)
expect(
cloudApiError(
403,
{ error: 'insufficient_scope', reason: 'workspaces:write' },
{ credentials: user }
).message
).toContain('workspaces:write')
expect(
cloudApiError(429, { error: 'rate_limited' }, { credentials: user, retryAfter: '12' }).data
).toMatchObject({ retryAfterSeconds: 12, nextSteps: ['Retry in 12s.'] })
})
})
describe('orca cloud handlers', () => {
it('workspace create posts the body and prints { ok, schemaVersion, result }', async () => {
const api = fakeApi({
'POST /v1/workspaces': () => [201, { workspace: workspace() }]
})
await createCloudHandlers(() => deps(api.fetchImpl))['cloud workspace create'](
ctx({ name: 'child', repo: 'git@github.com:acme/app.git', ref: 'main', size: 'large' })
)
expect(api.calls[0]).toMatchObject({
method: 'POST',
url: '/v1/workspaces',
auth: 'Bearer orca_sk_test',
body: {
name: 'child',
repo: { url: 'https://github.com/acme/app.git', ref: 'main' },
machineSize: 'large'
}
})
const out = lastJson()
expect(out).toMatchObject({
ok: true,
schemaVersion: 1,
result: { workspace: { id: 'sbx_aa11', origin: 'vm' } }
})
expect(typeof out.result.elapsedMs).toBe('number')
})
it('workspace create turns --agent/--prompt into the first message and resolves --project by name', async () => {
const api = fakeApi({
'GET /v1/projects': () => [200, { projects: [{ id: 'prj_1', name: 'web' }] }],
'POST /v1/workspaces': () => [
201,
{ workspace: workspace(), message: { id: 'msg_1', status: 'queued' } }
]
})
await createCloudHandlers(() => deps(api.fetchImpl))['cloud workspace create'](
ctx({ project: 'web', agent: 'codex', prompt: 'hi' })
)
expect(api.calls.at(-1)!.body).toEqual({
projectId: 'prj_1',
message: { text: 'hi', agent: 'codex' }
})
})
it('rejects bad flags before calling the API', async () => {
const api = fakeApi({})
const handlers = createCloudHandlers(() => deps(api.fetchImpl))
await expect(
handlers['cloud workspace create'](ctx({ agent: 'claude' }))
).rejects.toMatchObject({
code: 'invalid_argument',
message: expect.stringContaining('--prompt')
})
await expect(
handlers['cloud workspace create'](ctx({ agent: 'gpt', prompt: 'x' }))
).rejects.toThrow('--agent must be one of: claude, codex')
await expect(handlers['cloud workspace create'](ctx({ size: 'huge' }))).rejects.toThrow(
'--size'
)
await expect(
handlers['cloud workspace create'](ctx({ repo: 'http://x.test/a' }))
).rejects.toThrow('https')
await expect(
handlers['cloud message'](ctx({ workspace: 'sbx_1', text: 'x', session: 'bogus' }))
).rejects.toThrow('--session')
expect(api.calls).toHaveLength(0)
})
it('resolves a workspace by name and maps lifecycle verbs to routes', async () => {
const api = fakeApi({
'GET /v1/workspaces': () => [
200,
{ workspaces: [workspace({ id: 'sbx_b2', name: 'child' })] }
],
'POST /v1/workspaces/sbx_b2/sleep': () => [
200,
{ workspace: workspace({ id: 'sbx_b2', status: 'sleeping' }) }
],
'DELETE /v1/workspaces/sbx_b2': () => [
200,
{ workspace: workspace({ id: 'sbx_b2', status: 'destroyed' }) }
]
})
const handlers = createCloudHandlers(() => deps(api.fetchImpl))
await handlers['cloud workspace sleep'](ctx({ workspace: 'child' }))
await handlers['cloud workspace destroy'](ctx({ workspace: 'sbx_b2' }))
expect(api.calls.map((c) => `${c.method} ${c.url}`)).toEqual([
'GET /v1/workspaces?include=archived',
'POST /v1/workspaces/sbx_b2/sleep',
'DELETE /v1/workspaces/sbx_b2'
])
expect(lastJson().result).toMatchObject({ workspace: { status: 'destroyed' } })
})
it('reports ambiguous and missing names, and `self` from VM credentials', async () => {
const api = fakeApi({
'GET /v1/workspaces': () => [
200,
{ workspaces: [workspace({ id: 'sbx_1' }), workspace({ id: 'sbx_2' })] }
],
'GET /v1/workspaces/sbx_self': () => [200, { workspace: workspace({ id: 'sbx_self' }) }]
})
const handlers = createCloudHandlers(() => deps(api.fetchImpl))
await expect(
handlers['cloud workspace show'](ctx({ workspace: 'child' }))
).rejects.toMatchObject({
code: 'selector_ambiguous'
})
await expect(
handlers['cloud workspace show'](ctx({ workspace: 'nope' }))
).rejects.toMatchObject({
code: 'cloud_not_found'
})
await expect(handlers['cloud workspace show'](ctx({ workspace: 'self' }))).rejects.toThrow(
'inside an Orca Cloud workspace'
)
writeFile({ apiUrl: API, token: 'orca_ws_t', workspaceId: 'sbx_self' })
await createCloudHandlers(() => deps(api.fetchImpl, {}))['cloud workspace show'](
ctx({ workspace: 'self' })
)
expect(api.calls.at(-1)).toMatchObject({
url: '/v1/workspaces/sbx_self',
auth: 'Bearer orca_ws_t'
})
})
it('message --wait polls until delivered and returns the session handle', async () => {
let polls = 0
const api = fakeApi({
'POST /v1/workspaces/sbx_1/messages': () => [
202,
{
message: {
id: 'msg_1',
workspaceId: 'sbx_1',
agent: 'claude',
status: 'queued',
session: null
}
}
],
'GET /v1/workspaces/sbx_1/messages/msg_1': () => {
polls += 1
return [
200,
{
message: {
id: 'msg_1',
workspaceId: 'sbx_1',
agent: 'claude',
status: polls < 3 ? 'delivering' : 'delivered',
session: polls < 3 ? null : 'term_abc'
}
}
]
}
})
await createCloudHandlers(() => deps(api.fetchImpl))['cloud message'](
ctx({ workspace: 'sbx_1', text: 'run tests', wait: true })
)
expect(api.calls[0].body).toEqual({ text: 'run tests' })
expect(polls).toBe(3)
expect(lastJson().result).toMatchObject({
message: { status: 'delivered', session: 'term_abc' },
waitedMs: 4500
})
})
it('message --wait fails with the delivery error, and times out with a follow-up command', async () => {
const failed = fakeApi({
'POST /v1/workspaces/sbx_1/messages': () => [
202,
{
message: {
id: 'msg_1',
workspaceId: 'sbx_1',
status: 'failed',
error: 'agent_launch_failed'
}
}
]
})
await expect(
createCloudHandlers(() => deps(failed.fetchImpl))['cloud message'](
ctx({ workspace: 'sbx_1', text: 'x', wait: true })
)
).rejects.toMatchObject({
code: 'cloud_message_failed',
message: expect.stringContaining('agent_launch_failed')
})
const stuck = fakeApi({
'POST /v1/workspaces/sbx_1/messages': () => [
202,
{ message: { id: 'msg_1', workspaceId: 'sbx_1', status: 'queued' } }
],
'GET /v1/workspaces/sbx_1/messages/msg_1': () => [
200,
{ message: { id: 'msg_1', workspaceId: 'sbx_1', status: 'queued' } }
]
})
await expect(
createCloudHandlers(() => deps(stuck.fetchImpl))['cloud message'](
ctx({ workspace: 'sbx_1', text: 'x', wait: true, timeout: '3' })
)
).rejects.toMatchObject({
code: 'cloud_message_timeout',
data: expect.objectContaining({
nextSteps: ['Check later: orca cloud messages sbx_1 msg_1']
})
})
})
it('surfaces vm_cli_disabled from the API as a clear error', async () => {
writeFile({ apiUrl: API, token: 'orca_ws_t', workspaceId: 'sbx_a' })
const api = fakeApi({
'POST /v1/workspaces': () => [403, { error: 'forbidden', reason: 'vm_cli_disabled' }]
})
await expect(
createCloudHandlers(() => deps(api.fetchImpl, {}))['cloud workspace create'](
ctx({ name: 'child' })
)
).rejects.toMatchObject({ code: 'cloud_vm_cli_disabled' })
})
it('login verifies the token, then saves it; logout removes it', async () => {
const api = fakeApi({
'GET /v1/me': (call) =>
call.auth === 'Bearer orca_sk_good'
? [
200,
{
accountId: 'org:acme',
userId: 'u1',
orgId: 'acme',
role: 'owner',
via: 'user',
scopes: []
}
]
: [401, { error: 'unauthorized' }]
})
const handlers = createCloudHandlers(() =>
deps(api.fetchImpl, {}, { readStdin: async () => 'orca_sk_good\n' })
)
await expect(
handlers['cloud login'](ctx({ token: 'orca_sk_bad', 'api-url': API }))
).rejects.toMatchObject({ code: 'cloud_unauthorized' })
expect(() => resolveCloudCredentials({}, { home })).toThrow()
await handlers['cloud login'](ctx({ 'api-url': API }))
expect(resolveCloudCredentials({}, { home })).toMatchObject({
apiUrl: API,
token: 'orca_sk_good'
})
expect(lastJson().result).toMatchObject({ tokenKind: 'API key' })
await handlers['cloud logout'](ctx({}))
expect(lastJson().result).toMatchObject({ removed: true })
})
it('whoami reports the credential source and token kind', async () => {
const api = fakeApi({
'GET /v1/me': () => [
200,
{
accountId: 'org:acme',
userId: 'u1',
orgId: 'acme',
role: 'member',
via: 'workspace',
scopes: ['workspaces:read'],
workspaceId: 'sbx_a'
}
]
})
writeFile({ apiUrl: API, token: 'orca_ws_t', workspaceId: 'sbx_a' })
await createCloudHandlers(() => deps(api.fetchImpl, {}))['cloud whoami'](ctx({}, false))
expect(logs.at(-1)).toContain('via: workspace (workspace token from')
expect(logs.at(-1)).toContain('workspace: sbx_a')
})
it('wraps network failures as cloud_unreachable', async () => {
const fetchImpl = vi.fn(async () => {
throw new TypeError('fetch failed')
})
await expect(
createCloudHandlers(() => deps(fetchImpl))['cloud projects list'](ctx({}))
).rejects.toMatchObject({ code: 'cloud_unreachable' })
})
})
describe('normalizeCloudRepoUrl', () => {
it('rewrites ssh remotes and drops embedded credentials', () => {
expect(normalizeCloudRepoUrl('git@github.com:acme/app.git')).toBe(
'https://github.com/acme/app.git'
)
expect(normalizeCloudRepoUrl('ssh://git@github.com/acme/app')).toBe(
'https://github.com/acme/app'
)
expect(normalizeCloudRepoUrl('https://user:pw@github.com/acme/app')).toBe(
'https://github.com/acme/app'
)
})
})
+221
View File
@@ -0,0 +1,221 @@
// Private build only: where `orca cloud …` finds its control-plane credentials. Order:
// 1. ORCA_CLOUD_TOKEN (+ ORCA_CLOUD_API_URL) in the environment
// 2. ~/.config/orca-cloud/credentials.json ({apiUrl, token, workspaceId?}). Inside an Orca Cloud
// VM the control plane writes this file at each boot with a workspace-scoped token; on a
// desktop `orca cloud login` writes it (0600).
// The CLI talks HTTPS to the control plane directly, so it works with no Orca app running.
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
import { RuntimeClientError } from '../runtime/types'
export type CloudCredentialSource = 'env' | 'file'
export type CloudCredentials = {
apiUrl: string
token: string
/** Set inside a VM: the workspace the token was minted for. */
workspaceId: string | null
source: CloudCredentialSource
/** The credentials file, when that is where the token came from. */
path: string | null
}
export type CloudCredentialEnv = Readonly<Record<string, string | undefined>>
export type CloudCredentialFs = {
readFile: (path: string) => string | null
writeFile: (path: string, contents: string) => void
removeFile: (path: string) => boolean
}
export const nodeCloudCredentialFs: CloudCredentialFs = {
readFile: (path) => {
try {
return readFileSync(path, 'utf8')
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null
}
throw error
}
},
writeFile: (path, contents) => {
mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
const tmp = `${path}.${process.pid}.tmp`
// Why: mode on create keeps the token unreadable to other users from the first byte.
writeFileSync(tmp, contents, { mode: 0o600 })
chmodSync(tmp, 0o600)
renameSync(tmp, path)
},
removeFile: (path) => {
try {
rmSync(path)
return true
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return false
}
throw error
}
}
}
export function cloudCredentialsPath(env: CloudCredentialEnv, home: string = homedir()): string {
const override = env.ORCA_CLOUD_CREDENTIALS_FILE?.trim()
return override ? override : join(home, '.config', 'orca-cloud', 'credentials.json')
}
const LOGIN_HINT =
'Run `orca cloud login --token <token> --api-url <control-plane-url>`, or set ORCA_CLOUD_TOKEN and ORCA_CLOUD_API_URL.'
// Inside a VM the file only appears when the org allows it, so say why it may be missing.
const IN_VM_HINT =
'Inside an Orca Cloud workspace the token is written at boot only when the organization has in-workspace CLI access turned on (Orca Cloud settings, vmCliEnabled).'
/**
* Only https may carry the bearer token; plain http is allowed for loopback dev servers.
* Returns the URL without a trailing slash.
*/
export function normalizeCloudApiUrl(raw: string, label: string): string {
let url: URL
try {
url = new URL(raw.trim())
} catch {
throw new RuntimeClientError('invalid_argument', `${label} is not a valid URL: ${raw}`)
}
const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) {
throw new RuntimeClientError(
'invalid_argument',
`${label} must be an https:// URL (http:// only for localhost).`
)
}
if (url.username || url.password || url.search || url.hash) {
throw new RuntimeClientError(
'invalid_argument',
`${label} must be a plain origin URL without credentials, query or fragment.`
)
}
return url.toString().replace(/\/+$/, '')
}
type CredentialsFile = { apiUrl?: unknown; token?: unknown; workspaceId?: unknown }
function readCredentialsFile(
path: string,
fs: CloudCredentialFs
): { apiUrl: string | null; token: string | null; workspaceId: string | null } | null {
const raw = fs.readFile(path)
if (raw === null) {
return null
}
let parsed: CredentialsFile
try {
parsed = JSON.parse(raw) as CredentialsFile
} catch {
throw new RuntimeClientError('cloud_credentials_invalid', `${path} is not valid JSON.`, {
path,
nextSteps: [LOGIN_HINT]
})
}
const str = (value: unknown): string | null =>
typeof value === 'string' && value.trim() ? value.trim() : null
return {
apiUrl: str(parsed?.apiUrl),
token: str(parsed?.token),
workspaceId: str(parsed?.workspaceId)
}
}
/** Resolves credentials or throws `cloud_not_signed_in` with the ways to provide them. */
export function resolveCloudCredentials(
env: CloudCredentialEnv,
options: { fs?: CloudCredentialFs; home?: string } = {}
): CloudCredentials {
const fs = options.fs ?? nodeCloudCredentialFs
const path = cloudCredentialsPath(env, options.home)
const envToken = env.ORCA_CLOUD_TOKEN?.trim()
if (envToken) {
// The URL may come from the env, the desktop's control-plane setting or the credentials file.
const envUrl = env.ORCA_CLOUD_API_URL?.trim() || env.ORCA_CLOUD_SANDBOX_URL?.trim()
const fileUrl = envUrl ? null : readCredentialsFile(path, fs)?.apiUrl
const apiUrl = envUrl || fileUrl
if (!apiUrl) {
throw new RuntimeClientError(
'cloud_not_signed_in',
'ORCA_CLOUD_TOKEN is set but no control-plane URL is: set ORCA_CLOUD_API_URL too.',
{ nextSteps: ['export ORCA_CLOUD_API_URL=https://<control-plane-host>'] }
)
}
return {
apiUrl: normalizeCloudApiUrl(apiUrl, envUrl ? 'ORCA_CLOUD_API_URL' : `apiUrl in ${path}`),
token: envToken,
workspaceId: env.ORCA_CLOUD_WORKSPACE_ID?.trim() || null,
source: 'env',
path: null
}
}
const file = readCredentialsFile(path, fs)
if (!file || !file.token) {
throw new RuntimeClientError(
'cloud_not_signed_in',
file ? `${path} has no token.` : 'Not signed in to Orca Cloud.',
{ path, nextSteps: [LOGIN_HINT, IN_VM_HINT] }
)
}
const apiUrl = env.ORCA_CLOUD_API_URL?.trim() || file.apiUrl
if (!apiUrl) {
throw new RuntimeClientError('cloud_credentials_invalid', `${path} has no apiUrl.`, {
path,
nextSteps: [LOGIN_HINT]
})
}
return {
apiUrl: normalizeCloudApiUrl(
apiUrl,
env.ORCA_CLOUD_API_URL?.trim() ? 'ORCA_CLOUD_API_URL' : `apiUrl in ${path}`
),
token: file.token,
workspaceId: file.workspaceId,
source: 'file',
path
}
}
export function writeCloudCredentials(
env: CloudCredentialEnv,
value: { apiUrl: string; token: string; workspaceId?: string | null },
options: { fs?: CloudCredentialFs; home?: string } = {}
): string {
const fs = options.fs ?? nodeCloudCredentialFs
const path = cloudCredentialsPath(env, options.home)
fs.writeFile(
path,
`${JSON.stringify({
apiUrl: value.apiUrl,
token: value.token,
...(value.workspaceId ? { workspaceId: value.workspaceId } : {})
})}\n`
)
return path
}
export function removeCloudCredentials(
env: CloudCredentialEnv,
options: { fs?: CloudCredentialFs; home?: string } = {}
): { path: string; removed: boolean } {
const fs = options.fs ?? nodeCloudCredentialFs
const path = cloudCredentialsPath(env, options.home)
return { path, removed: fs.removeFile(path) }
}
/** Shown in whoami/errors; never more than a prefix of the secret. */
export function describeCloudToken(token: string): string {
if (token.startsWith('orca_ws_')) {
return 'workspace token'
}
if (token.startsWith('orca_')) {
return 'API key'
}
return 'session token'
}
+218
View File
@@ -0,0 +1,218 @@
// Private build only: text output for `orca cloud …`. JSON output is the API view wrapped as
// { ok, schemaVersion, result } (see printCloudResult).
export const CLOUD_CLI_SCHEMA_VERSION = 1
export type CloudWorkspace = {
id: string
name: string
projectId: string | null
status: string
archived: boolean
hidden: boolean
origin: string
machineSize: string
region: string | null
repoUrl: string | null
session: { startedAt: number; leaseExpiresAt: number; deadlineAt: number | null } | null
lastError: string | null
createdAt: number
updatedAt: number
}
export type CloudProject = {
id: string
name: string
repo: { url: string; ref: string | null } | null
environment: { machineSize: string; region: string | null; buildId: string | null }
createdAt: number
}
export type CloudMessage = {
id: string
workspaceId: string
agent: string
status: 'queued' | 'delivering' | 'delivered' | 'failed'
session: string | null
error: string | null
attempts: number
createdAt: number
deliveredAt: number | null
}
export type CloudSession = {
handle: string
agent: string | null
title: string | null
connected: boolean
lastOutputAt: number | null
preview: string | null
}
export type CloudMe = {
accountId: string
userId: string
orgId: string | null
role: string
via: string
scopes: string[]
workspaceId?: string
}
export function printCloudResult<T>(result: T, json: boolean, format: (value: T) => string): void {
if (json) {
console.log(
JSON.stringify({ ok: true, schemaVersion: CLOUD_CLI_SCHEMA_VERSION, result }, null, 2)
)
return
}
console.log(format(result))
}
function ago(at: number | null | undefined, now: number): string {
if (!at) {
return '-'
}
const seconds = Math.max(0, Math.round((now - at) / 1000))
if (seconds < 60) {
return `${seconds}s ago`
}
if (seconds < 3600) {
return `${Math.round(seconds / 60)}m ago`
}
if (seconds < 86_400) {
return `${Math.round(seconds / 3600)}h ago`
}
return `${Math.round(seconds / 86_400)}d ago`
}
function table(header: string[], rows: string[][]): string {
const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? '').length)))
const line = (cells: string[]) =>
cells
.map((cell, i) => (i === cells.length - 1 ? cell : cell.padEnd(widths[i])))
.join(' ')
.trimEnd()
return [line(header), ...rows.map(line)].join('\n')
}
export function formatCloudWorkspaceList(
value: { workspaces: CloudWorkspace[] },
now = Date.now()
): string {
if (value.workspaces.length === 0) {
return 'No workspaces.'
}
return table(
['ID', 'NAME', 'STATUS', 'ORIGIN', 'CREATED'],
value.workspaces.map((w) => [
w.id,
w.name,
w.archived ? `${w.status} (archived)` : w.status,
w.origin,
ago(w.createdAt, now)
])
)
}
export function formatCloudWorkspace(
value: { workspace: CloudWorkspace; message?: CloudMessage; elapsedMs?: number },
now = Date.now()
): string {
const w = value.workspace
const lines = [
`${w.name} (${w.id})`,
`status: ${w.status}${w.archived ? ' (archived)' : ''}`,
`origin: ${w.origin}`,
`size: ${w.machineSize}${w.region ? ` in ${w.region}` : ''}`,
...(w.repoUrl ? [`repo: ${w.repoUrl}`] : []),
...(w.projectId ? [`project: ${w.projectId}`] : []),
...(w.session
? [`session lease expires: ${new Date(w.session.leaseExpiresAt).toISOString()}`]
: []),
...(w.lastError ? [`last error: ${w.lastError}`] : []),
`created: ${ago(w.createdAt, now)}`
]
if (value.message) {
lines.push(`message: ${value.message.id} (${value.message.status}, ${value.message.agent})`)
}
if (value.elapsedMs !== undefined) {
lines.push(`took ${(value.elapsedMs / 1000).toFixed(1)}s`)
}
return lines.join('\n')
}
export function formatCloudMessage(value: { message: CloudMessage; waitedMs?: number }): string {
const m = value.message
return [
`message ${m.id}: ${m.status}`,
`agent: ${m.agent}`,
...(m.session ? [`session: ${m.session} (continue with --session ${m.session})`] : []),
...(m.error ? [`error: ${m.error}`] : []),
...(value.waitedMs !== undefined ? [`waited ${(value.waitedMs / 1000).toFixed(1)}s`] : [])
].join('\n')
}
export function formatCloudMessageList(value: { messages: CloudMessage[] }): string {
if (value.messages.length === 0) {
return 'No messages.'
}
return value.messages
.map(
(m) =>
`${m.id} ${m.status} ${m.agent}${m.session ? ` ${m.session}` : ''}${m.error ? ` error: ${m.error}` : ''}`
)
.join('\n')
}
export function formatCloudSessions(
value: { workspaceStatus: string; sessions: CloudSession[] },
now = Date.now()
): string {
if (value.sessions.length === 0) {
return value.workspaceStatus === 'running'
? 'No agent sessions.'
: `No agent sessions (workspace is ${value.workspaceStatus}; wake it to see live sessions).`
}
return value.sessions
.map((s) =>
[
`${s.handle} ${s.agent ?? 'terminal'} ${s.connected ? 'connected' : 'disconnected'} last output ${ago(s.lastOutputAt, now)}${s.title ? ` ${s.title}` : ''}`,
...(s.preview ? s.preview.split('\n').map((line) => ` ${line}`) : [])
].join('\n')
)
.join('\n')
}
export function formatCloudProjects(value: { projects: CloudProject[] }): string {
if (value.projects.length === 0) {
return 'No projects.'
}
return table(
['ID', 'NAME', 'REPO', 'SIZE'],
value.projects.map((p) => [
p.id,
p.name,
p.repo ? `${p.repo.url}${p.repo.ref ? `#${p.repo.ref}` : ''}` : '-',
p.environment.machineSize
])
)
}
export function formatCloudWhoami(value: {
me: CloudMe
apiUrl: string
credentialSource: string
tokenKind: string
}): string {
const me = value.me
return [
`account: ${me.accountId}`,
`user: ${me.userId}`,
...(me.orgId ? [`org: ${me.orgId} (${me.role})`] : [`role: ${me.role}`]),
`via: ${me.via} (${value.tokenKind} from ${value.credentialSource})`,
...(me.workspaceId ? [`workspace: ${me.workspaceId}`] : []),
`scopes: ${me.scopes.join(', ') || '-'}`,
`api: ${value.apiUrl}`
].join('\n')
}
+23
View File
@@ -0,0 +1,23 @@
// Private build only: routes `orca cloud …` to its handlers (loaded on demand).
import type { HandlerGroup } from '../handler-group-manifest'
export const CLOUD_HANDLER_GROUP: HandlerGroup = {
name: 'cloud',
keys: [
'cloud whoami',
'cloud login',
'cloud logout',
'cloud workspace list',
'cloud workspace show',
'cloud workspace create',
'cloud workspace sleep',
'cloud workspace wake',
'cloud workspace archive',
'cloud workspace destroy',
'cloud message',
'cloud messages',
'cloud sessions',
'cloud projects list'
],
load: async () => (await import('./cloud-handlers.js')).CLOUD_HANDLERS
}
+284
View File
@@ -0,0 +1,284 @@
// Private build only: `orca cloud …` handlers. They never touch the Orca runtime (ctx.client): they
// call the Orca Cloud public API over HTTPS, so they work inside a VM with no desktop app running.
import type { CommandHandler } from '../dispatch'
import {
getOptionalPositiveIntegerFlag,
getOptionalStringFlag,
getRequiredStringFlag
} from '../flags'
import { RuntimeClientError } from '../runtime/types'
import { CloudApiClient } from './cloud-api-client'
import {
describeCloudToken,
normalizeCloudApiUrl,
removeCloudCredentials,
resolveCloudCredentials,
writeCloudCredentials,
type CloudCredentials
} from './cloud-credentials'
import {
formatCloudMessage,
formatCloudMessageList,
formatCloudProjects,
formatCloudSessions,
formatCloudWhoami,
formatCloudWorkspace,
formatCloudWorkspaceList,
printCloudResult,
type CloudMe,
type CloudMessage,
type CloudProject,
type CloudSession,
type CloudWorkspace
} from './cloud-format'
import {
DEFAULT_MESSAGE_WAIT_S,
MUTATE_TIMEOUT_MS,
cloudClient,
createCloudWorkspace,
defaultCloudHandlerDeps,
getAgentFlag,
getSizeFlag,
normalizeCloudRepoUrl,
readMessageFlags,
resolveCloudProjectId,
resolveCloudWorkspaceId,
waitForCloudMessage,
workspacePath,
type CloudHandlerDeps,
type CloudWorkspaceCreateInput
} from './cloud-operations'
export type { CloudHandlerDeps } from './cloud-operations'
export function createCloudHandlers(
getDeps: () => CloudHandlerDeps = defaultCloudHandlerDeps
): Record<string, CommandHandler> {
const mutate =
(action: 'sleep' | 'wake' | 'archive' | 'destroy'): CommandHandler =>
async ({ flags, json }) => {
const deps = getDeps()
const api = cloudClient(deps)
const id = await resolveCloudWorkspaceId(api, getRequiredStringFlag(flags, 'workspace'))
const started = deps.now()
const result = await api.request<{ workspace: CloudWorkspace }>(
action === 'destroy' ? 'DELETE' : 'POST',
action === 'destroy' ? workspacePath(id) : workspacePath(id, `/${action}`),
{ timeoutMs: MUTATE_TIMEOUT_MS }
)
printCloudResult({ ...result, elapsedMs: deps.now() - started }, json, (value) =>
formatCloudWorkspace(value)
)
}
return {
'cloud whoami': async ({ json }) => {
const deps = getDeps()
const api = cloudClient(deps)
const me = await api.request<CloudMe>('GET', '/v1/me')
printCloudResult(
{
me,
apiUrl: api.credentials.apiUrl,
credentialSource:
api.credentials.source === 'env'
? 'ORCA_CLOUD_TOKEN'
: (api.credentials.path ?? 'file'),
tokenKind: describeCloudToken(api.credentials.token)
},
json,
formatCloudWhoami
)
},
'cloud login': async ({ flags, json }) => {
const deps = getDeps()
const flagToken = getOptionalStringFlag(flags, 'token')
const token = (flagToken ?? (await deps.readStdin()) ?? '').trim()
if (!token) {
throw new RuntimeClientError(
'invalid_argument',
'Pass --token <token>, or pipe the token on stdin (keeps it out of shell history).',
{ field: 'token' }
)
}
let existingUrl: string | null = null
try {
existingUrl = resolveCloudCredentials(
{ ...deps.env, ORCA_CLOUD_TOKEN: undefined },
{ fs: deps.fs, home: deps.home }
).apiUrl
} catch {
existingUrl = null
}
const rawUrl =
getOptionalStringFlag(flags, 'api-url') ??
deps.env.ORCA_CLOUD_API_URL?.trim() ??
deps.env.ORCA_CLOUD_SANDBOX_URL?.trim() ??
existingUrl
if (!rawUrl) {
throw new RuntimeClientError(
'invalid_argument',
'Pass --api-url <control-plane-url> (first login only).',
{
field: 'api-url'
}
)
}
const apiUrl = normalizeCloudApiUrl(rawUrl, '--api-url')
const credentials: CloudCredentials = {
apiUrl,
token,
workspaceId: null,
source: 'file',
path: null
}
// Check before saving, so a typo never replaces working credentials.
const me = await new CloudApiClient(credentials, deps.fetch).request<CloudMe>('GET', '/v1/me')
const path = writeCloudCredentials(
deps.env,
{ apiUrl, token, workspaceId: me.workspaceId ?? null },
{ fs: deps.fs, home: deps.home }
)
printCloudResult(
{ me, apiUrl, path, tokenKind: describeCloudToken(token) },
json,
(value) =>
`Signed in to ${value.apiUrl} as ${value.me.userId} (${value.me.accountId}, ${value.tokenKind}).\nSaved to ${value.path}`
)
},
'cloud logout': async ({ json }) => {
const deps = getDeps()
const result = removeCloudCredentials(deps.env, { fs: deps.fs, home: deps.home })
printCloudResult(result, json, (value) =>
value.removed ? `Removed ${value.path}` : `No credentials at ${value.path}`
)
},
'cloud workspace list': async ({ flags, json }) => {
const deps = getDeps()
const api = cloudClient(deps)
const all = flags.get('all') === true
const include = [
...(all || flags.get('include-archived') === true ? ['archived'] : []),
...(all ? ['hidden'] : [])
]
const project = getOptionalStringFlag(flags, 'project')
const result = await api.request<{ workspaces: CloudWorkspace[] }>('GET', '/v1/workspaces', {
query: {
...(include.length > 0 ? { include: include.join(',') } : {}),
...(project ? { projectId: await resolveCloudProjectId(api, project) } : {})
}
})
printCloudResult(result, json, (value) => formatCloudWorkspaceList(value, deps.now()))
},
'cloud workspace show': async ({ flags, json }) => {
const deps = getDeps()
const api = cloudClient(deps)
const id = await resolveCloudWorkspaceId(api, getRequiredStringFlag(flags, 'workspace'))
const result = await api.request<{ workspace: CloudWorkspace }>('GET', workspacePath(id))
printCloudResult(result, json, (value) => formatCloudWorkspace(value, deps.now()))
},
'cloud workspace create': async ({ flags, json }) => {
const deps = getDeps()
const agent = getAgentFlag(flags)
const prompt = getOptionalStringFlag(flags, 'prompt')
if (agent && !prompt) {
throw new RuntimeClientError(
'invalid_argument',
'--agent needs --prompt (the first message).',
{
field: 'prompt'
}
)
}
const repoFlag = getOptionalStringFlag(flags, 'repo')
const ref = getOptionalStringFlag(flags, 'ref')
if (ref && !repoFlag) {
throw new RuntimeClientError('invalid_argument', '--ref needs --repo.', { field: 'ref' })
}
const machineSize = getSizeFlag(flags)
const api = cloudClient(deps)
const project = getOptionalStringFlag(flags, 'project')
const name = getOptionalStringFlag(flags, 'name')
const region = getOptionalStringFlag(flags, 'region')
const input: CloudWorkspaceCreateInput = {
...(name ? { name } : {}),
...(project ? { projectId: await resolveCloudProjectId(api, project) } : {}),
...(repoFlag
? { repo: { url: normalizeCloudRepoUrl(repoFlag), ...(ref ? { ref } : {}) } }
: {}),
...(machineSize ? { machineSize } : {}),
...(region ? { region } : {}),
...(prompt ? { message: { text: prompt, ...(agent ? { agent } : {}) } } : {})
}
const result = await createCloudWorkspace(api, input, deps.now)
printCloudResult(result, json, (value) => formatCloudWorkspace(value, deps.now()))
},
'cloud workspace sleep': mutate('sleep'),
'cloud workspace wake': mutate('wake'),
'cloud workspace archive': mutate('archive'),
'cloud workspace destroy': mutate('destroy'),
'cloud message': async ({ flags, json }) => {
const deps = getDeps()
const body = readMessageFlags(flags)
const waitSeconds = getOptionalPositiveIntegerFlag(flags, 'timeout') ?? DEFAULT_MESSAGE_WAIT_S
const api = cloudClient(deps)
const id = await resolveCloudWorkspaceId(api, getRequiredStringFlag(flags, 'workspace'))
const started = deps.now()
const { message } = await api.request<{ message: CloudMessage }>(
'POST',
workspacePath(id, '/messages'),
{
body
}
)
if (flags.get('wait') !== true) {
printCloudResult({ message }, json, formatCloudMessage)
return
}
const delivered = await waitForCloudMessage(api, deps, message, waitSeconds * 1000)
printCloudResult(
{ message: delivered, waitedMs: deps.now() - started },
json,
formatCloudMessage
)
},
'cloud messages': async ({ flags, json }) => {
const deps = getDeps()
const api = cloudClient(deps)
const id = await resolveCloudWorkspaceId(api, getRequiredStringFlag(flags, 'workspace'))
const messageId = getOptionalStringFlag(flags, 'message')
if (!messageId) {
const result = await api.request<{ messages: CloudMessage[] }>(
'GET',
workspacePath(id, '/messages')
)
printCloudResult(result, json, formatCloudMessageList)
return
}
const result = await api.request<{ message: CloudMessage }>(
'GET',
workspacePath(id, `/messages/${encodeURIComponent(messageId)}`)
)
printCloudResult(result, json, formatCloudMessage)
},
'cloud sessions': async ({ flags, json }) => {
const deps = getDeps()
const api = cloudClient(deps)
const id = await resolveCloudWorkspaceId(api, getRequiredStringFlag(flags, 'workspace'))
const result = await api.request<{ workspaceStatus: string; sessions: CloudSession[] }>(
'GET',
workspacePath(id, '/sessions'),
{ timeoutMs: 90_000 }
)
printCloudResult(result, json, (value) => formatCloudSessions(value, deps.now()))
},
'cloud projects list': async ({ json }) => {
const deps = getDeps()
const api = cloudClient(deps)
const result = await api.request<{ projects: CloudProject[] }>('GET', '/v1/projects')
printCloudResult(result, json, formatCloudProjects)
}
}
}
export const CLOUD_HANDLERS = createCloudHandlers()
+261
View File
@@ -0,0 +1,261 @@
// Private build only: the API operations behind `orca cloud …` (and `worktree create --on cloud`):
// selectors, create, message polling and flag readers. No Orca runtime involved.
import { homedir } from 'node:os'
import { getOptionalStringFlag, getRequiredStringFlag } from '../flags'
import { RuntimeClientError } from '../runtime/types'
import { CloudApiClient, type CloudFetch } from './cloud-api-client'
import {
nodeCloudCredentialFs,
resolveCloudCredentials,
type CloudCredentialEnv,
type CloudCredentialFs
} from './cloud-credentials'
import type { CloudMessage, CloudProject, CloudWorkspace } from './cloud-format'
export type CloudHandlerDeps = {
env: CloudCredentialEnv
fetch: CloudFetch
fs: CloudCredentialFs
home: string
now: () => number
sleep: (ms: number) => Promise<void>
/** Piped stdin (for `login` without --token); null when stdin is a terminal. */
readStdin: () => Promise<string | null>
}
export const CLOUD_AGENTS = ['claude', 'codex'] as const
const WORKSPACE_ID = /^sbx_[A-Za-z0-9]+$/
const PROJECT_ID = /^prj_[A-Za-z0-9]+$/
const SESSION_HANDLE = /^term_[A-Za-z0-9-]{1,80}$/
/** A create waits for the VM to boot; the control plane bounds that, this only stops a hang. */
export const CREATE_TIMEOUT_MS = 6 * 60_000
export const MUTATE_TIMEOUT_MS = 3 * 60_000
const MESSAGE_POLL_MS = 1500
export const DEFAULT_MESSAGE_WAIT_S = 180
async function readPipedStdin(): Promise<string | null> {
if (process.stdin.isTTY) {
return null
}
const chunks: Buffer[] = []
for await (const chunk of process.stdin) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)))
if (chunks.reduce((n, c) => n + c.length, 0) > 16_384) {
throw new RuntimeClientError('invalid_argument', 'The token on stdin is too long.')
}
}
return Buffer.concat(chunks).toString('utf8')
}
export const defaultCloudHandlerDeps = (): CloudHandlerDeps => ({
env: process.env,
fetch: (input, init) => fetch(input, init),
fs: nodeCloudCredentialFs,
home: homedir(),
now: () => Date.now(),
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
readStdin: readPipedStdin
})
export function cloudClient(deps: CloudHandlerDeps): CloudApiClient {
return new CloudApiClient(
resolveCloudCredentials(deps.env, { fs: deps.fs, home: deps.home }),
deps.fetch
)
}
export function getAgentFlag(
flags: Map<string, string | boolean>
): (typeof CLOUD_AGENTS)[number] | undefined {
const agent = getOptionalStringFlag(flags, 'agent')
if (agent === undefined) {
return undefined
}
if (!(CLOUD_AGENTS as readonly string[]).includes(agent)) {
throw new RuntimeClientError(
'invalid_argument',
`--agent must be one of: ${CLOUD_AGENTS.join(', ')}`,
{
field: 'agent'
}
)
}
return agent as (typeof CLOUD_AGENTS)[number]
}
/**
* `sbx_…` ids pass through; `self` is the workspace this token was minted for (inside a VM);
* anything else is matched against workspace names this token can see.
*/
export async function resolveCloudWorkspaceId(
api: CloudApiClient,
selector: string
): Promise<string> {
if (WORKSPACE_ID.test(selector)) {
return selector
}
if (selector === 'self') {
if (!api.credentials.workspaceId) {
throw new RuntimeClientError(
'invalid_argument',
'"self" works only inside an Orca Cloud workspace (its credentials name the workspace).'
)
}
return api.credentials.workspaceId
}
const { workspaces } = await api.request<{ workspaces: CloudWorkspace[] }>(
'GET',
'/v1/workspaces',
{
query: { include: 'archived' }
}
)
const matches = workspaces.filter((w) => w.name === selector)
if (matches.length === 1) {
return matches[0].id
}
if (matches.length === 0) {
throw new RuntimeClientError('cloud_not_found', `No workspace named "${selector}".`, {
nextSteps: ['Run `orca cloud workspace list` and pass the workspace id (sbx_…).']
})
}
throw new RuntimeClientError(
'selector_ambiguous',
`${matches.length} workspaces are named "${selector}"; pass the id instead.`,
{ choices: matches.map((w) => ({ id: w.id, status: w.status, createdAt: w.createdAt })) }
)
}
export async function resolveCloudProjectId(
api: CloudApiClient,
selector: string
): Promise<string> {
if (PROJECT_ID.test(selector)) {
return selector
}
const { projects } = await api.request<{ projects: CloudProject[] }>('GET', '/v1/projects')
const match = projects.find((p) => p.name === selector)
if (!match) {
throw new RuntimeClientError('cloud_not_found', `No project named "${selector}".`, {
nextSteps: ['Run `orca cloud projects list`.']
})
}
return match.id
}
export type CloudWorkspaceCreateInput = {
name?: string
projectId?: string
repo?: { url: string; ref?: string }
machineSize?: 'standard' | 'large'
region?: string
message?: { text: string; agent?: (typeof CLOUD_AGENTS)[number] }
}
export async function createCloudWorkspace(
api: CloudApiClient,
input: CloudWorkspaceCreateInput,
now: () => number
): Promise<{ workspace: CloudWorkspace; message?: CloudMessage; elapsedMs: number }> {
const started = now()
const created = await api.request<{ workspace: CloudWorkspace; message?: CloudMessage }>(
'POST',
'/v1/workspaces',
{ body: input, timeoutMs: CREATE_TIMEOUT_MS }
)
return { ...created, elapsedMs: now() - started }
}
/** Repo URLs the control plane accepts: https only. `git@host:owner/repo` is rewritten. */
export function normalizeCloudRepoUrl(raw: string): string {
const value = raw.trim()
const scp = /^[\w.-]+@([\w.-]+):(.+?)$/.exec(value)
const candidate = scp
? `https://${scp[1]}/${scp[2]}`
: value.replace(/^ssh:\/\/[\w.-]+@/, 'https://')
let url: URL
try {
url = new URL(candidate)
} catch {
throw new RuntimeClientError('invalid_argument', `--repo must be an https:// git URL: ${raw}`, {
field: 'repo'
})
}
if (url.protocol !== 'https:') {
throw new RuntimeClientError('invalid_argument', `--repo must be an https:// git URL: ${raw}`, {
field: 'repo'
})
}
// Credentials in a URL would be stored server-side; private repos use the GitHub connection.
url.username = ''
url.password = ''
return url.toString()
}
export function getSizeFlag(
flags: Map<string, string | boolean>
): 'standard' | 'large' | undefined {
const size = getOptionalStringFlag(flags, 'size')
if (size === undefined || size === 'standard' || size === 'large') {
return size
}
throw new RuntimeClientError('invalid_argument', '--size must be standard or large', {
field: 'size'
})
}
/** Reads `--text` (required) / `--agent` / `--session` for `cloud message`. */
export function readMessageFlags(flags: Map<string, string | boolean>) {
const text = getRequiredStringFlag(flags, 'text')
const agent = getAgentFlag(flags)
const session = getOptionalStringFlag(flags, 'session')
if (session !== undefined && !SESSION_HANDLE.test(session)) {
throw new RuntimeClientError(
'invalid_argument',
'--session must be a session handle (term_…) from `orca cloud sessions` or a previous message.',
{ field: 'session' }
)
}
return { text, ...(agent ? { agent } : {}), ...(session ? { session } : {}) }
}
export async function waitForCloudMessage(
api: CloudApiClient,
deps: Pick<CloudHandlerDeps, 'now' | 'sleep'>,
message: CloudMessage,
timeoutMs: number
): Promise<CloudMessage> {
const deadline = deps.now() + timeoutMs
let current = message
while (current.status === 'queued' || current.status === 'delivering') {
if (deps.now() >= deadline) {
throw new RuntimeClientError(
'cloud_message_timeout',
`Message ${current.id} is still ${current.status} after ${Math.round(timeoutMs / 1000)}s (the workspace may be waking).`,
{
message: current,
nextSteps: [`Check later: orca cloud messages ${current.workspaceId} ${current.id}`]
}
)
}
await deps.sleep(MESSAGE_POLL_MS)
current = (
await api.request<{ message: CloudMessage }>(
'GET',
`/v1/workspaces/${encodeURIComponent(current.workspaceId)}/messages/${encodeURIComponent(current.id)}`
)
).message
}
if (current.status === 'failed') {
throw new RuntimeClientError(
'cloud_message_failed',
`Message ${current.id} could not be delivered: ${current.error ?? 'unknown error'}.`,
{ message: current }
)
}
return current
}
export function workspacePath(id: string, sub = ''): string {
return `/v1/workspaces/${encodeURIComponent(id)}${sub}`
}
+174
View File
@@ -0,0 +1,174 @@
// Private build only: `orca cloud …` command specs.
import type { CommandSpec } from '../args'
import { LOCAL_COMMAND_FLAGS } from '../args'
const CREDENTIALS_NOTE =
'Credentials: ORCA_CLOUD_TOKEN (+ ORCA_CLOUD_API_URL), else ~/.config/orca-cloud/credentials.json (written by `orca cloud login`, and at boot inside an Orca Cloud workspace when the organization allows in-workspace CLI access). These commands call Orca Cloud over HTTPS and do not need the Orca app running.'
const WORKSPACE_ARG_NOTE =
'<workspace> is a workspace id (sbx_…), a workspace name, or `self` inside an Orca Cloud workspace.'
const JSON_NOTE =
'--json prints { ok, schemaVersion: 1, result }; failures print { ok: false, error: { code, message, data } }.'
const lifecycle = (
action: 'sleep' | 'wake' | 'archive' | 'destroy',
summary: string,
notes: string[] = []
): CommandSpec => ({
path: ['cloud', 'workspace', action],
summary,
usage: `orca cloud workspace ${action} <workspace> [--json]`,
allowedFlags: [...LOCAL_COMMAND_FLAGS, 'workspace'],
positionalArgs: ['workspace'],
...(action === 'destroy' ? { destructive: true } : {}),
notes: [
...notes,
WORKSPACE_ARG_NOTE,
'A workspace token cannot sleep, archive or destroy its own workspace.'
],
examples: [`orca cloud workspace ${action} sbx_0123abcd --json`]
})
export const CLOUD_COMMAND_SPECS: CommandSpec[] = [
{
path: ['cloud', 'whoami'],
summary: 'Show the Orca Cloud account, role and scopes these credentials act as',
usage: 'orca cloud whoami [--json]',
allowedFlags: [...LOCAL_COMMAND_FLAGS],
notes: [CREDENTIALS_NOTE, JSON_NOTE],
examples: ['orca cloud whoami', 'orca cloud whoami --json']
},
{
path: ['cloud', 'login'],
summary: 'Save an Orca Cloud API key or session token for the CLI',
usage: 'orca cloud login [--token <token>] [--api-url <url>] [--json]',
allowedFlags: [...LOCAL_COMMAND_FLAGS, 'token', 'api-url'],
notes: [
'Checks the token with Orca Cloud, then writes ~/.config/orca-cloud/credentials.json (mode 0600).',
'Without --token the token is read from piped stdin, which keeps it out of shell history.',
'--api-url is needed on first login; later logins reuse the saved URL.'
],
examples: [
'printf %s "$ORCA_KEY" | orca cloud login --api-url https://cloud.example.com',
'orca cloud login --token orca_sk_… --api-url https://cloud.example.com'
]
},
{
path: ['cloud', 'logout'],
summary: 'Delete the saved Orca Cloud credentials file',
usage: 'orca cloud logout [--json]',
allowedFlags: [...LOCAL_COMMAND_FLAGS],
destructive: true
},
{
path: ['cloud', 'workspace', 'list'],
summary: 'List Orca Cloud workspaces',
usage:
'orca cloud workspace list [--project <id-or-name>] [--include-archived] [--all] [--json]',
allowedFlags: [...LOCAL_COMMAND_FLAGS, 'project', 'include-archived', 'all'],
notes: [
'--all also lists archived and hidden workspaces.',
'A workspace token (inside a VM) lists only the workspaces that workspace created.',
JSON_NOTE
],
examples: ['orca cloud workspace list', 'orca cloud workspace list --all --json']
},
{
path: ['cloud', 'workspace', 'show'],
summary: 'Show one Orca Cloud workspace',
usage: 'orca cloud workspace show <workspace> [--json]',
allowedFlags: [...LOCAL_COMMAND_FLAGS, 'workspace'],
positionalArgs: ['workspace'],
notes: [WORKSPACE_ARG_NOTE, JSON_NOTE],
examples: ['orca cloud workspace show self --json']
},
{
path: ['cloud', 'workspace', 'create'],
summary: 'Create an Orca Cloud workspace and wait until it is running',
usage:
'orca cloud workspace create [--name <name>] [--project <id-or-name>] [--repo <https-url> [--ref <ref>]] [--size standard|large] [--region <region>] [--agent claude|codex --prompt <text>] [--json]',
allowedFlags: [
...LOCAL_COMMAND_FLAGS,
'name',
'project',
'repo',
'ref',
'size',
'region',
'agent',
'prompt'
],
notes: [
'Returns once the VM is running. --prompt queues a first message for --agent (default claude), delivered as soon as the VM is up; follow it with `orca cloud messages`.',
'Inside a workspace (workspace token) the new workspace is recorded with origin "vm" and is visible to the workspace that created it.',
CREDENTIALS_NOTE,
JSON_NOTE
],
examples: [
'orca cloud workspace create --name child --json',
'orca cloud workspace create --repo https://github.com/acme/app --name fix-login --agent claude --prompt "Fix the login redirect"'
]
},
lifecycle('sleep', 'Put an Orca Cloud workspace to sleep (disk kept, not billed)'),
lifecycle('wake', 'Wake a sleeping or archived Orca Cloud workspace'),
lifecycle('archive', 'Sleep an Orca Cloud workspace and hide it from default lists'),
lifecycle('destroy', 'Destroy an Orca Cloud workspace and its disk', ['This cannot be undone.']),
{
path: ['cloud', 'message'],
summary: 'Send a message to an agent in an Orca Cloud workspace',
usage:
'orca cloud message <workspace> --text <text> [--agent claude|codex] [--session <handle>] [--wait [--timeout <seconds>]] [--json]',
allowedFlags: [
...LOCAL_COMMAND_FLAGS,
'workspace',
'text',
'agent',
'session',
'wait',
'timeout'
],
positionalArgs: ['workspace'],
notes: [
'Without --session a new agent session starts with the text as its prompt; --session <handle> (from `orca cloud sessions` or a previous message) types it into that session.',
'A sleeping workspace is woken first. --wait polls until the message is delivered to the agent (default timeout 180s); it does not wait for the agent to finish.',
WORKSPACE_ARG_NOTE,
JSON_NOTE
],
examples: [
'orca cloud message my-ws --text "Run the tests and fix failures" --wait',
'orca cloud message sbx_0123abcd --session term_abc --text "Also update the changelog" --json'
]
},
{
path: ['cloud', 'messages'],
summary: 'List recent messages sent to an Orca Cloud workspace, or show one',
usage: 'orca cloud messages <workspace> [<message-id>] [--json]',
allowedFlags: [...LOCAL_COMMAND_FLAGS, 'workspace', 'message'],
positionalArgs: ['workspace', 'message'],
notes: [
'Shows delivery status (queued, delivering, delivered, failed) and the session handle.',
WORKSPACE_ARG_NOTE,
JSON_NOTE
],
examples: ['orca cloud messages my-ws', 'orca cloud messages sbx_0123abcd msg_abc --json']
},
{
path: ['cloud', 'sessions'],
summary: 'List agent sessions running in an Orca Cloud workspace',
usage: 'orca cloud sessions <workspace> [--json]',
allowedFlags: [...LOCAL_COMMAND_FLAGS, 'workspace'],
positionalArgs: ['workspace'],
notes: [
'Only a running workspace has live sessions; a sleeping one lists none.',
WORKSPACE_ARG_NOTE,
JSON_NOTE
],
examples: ['orca cloud sessions my-ws --json']
},
{
path: ['cloud', 'projects', 'list'],
summary: 'List Orca Cloud projects (repo + environment defaults for new workspaces)',
usage: 'orca cloud projects list [--json]',
allowedFlags: [...LOCAL_COMMAND_FLAGS],
notes: [JSON_NOTE]
}
]
+129
View File
@@ -0,0 +1,129 @@
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'
const { callMock, runtimeClientConstructorMock, serveOrcaAppMock, getDefaultUserDataPathMock } =
vi.hoisted(() => ({
callMock: vi.fn(),
runtimeClientConstructorMock: vi.fn(),
serveOrcaAppMock: vi.fn(),
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data')
}))
vi.mock('../runtime-client', async () => {
const { createRuntimeClientModuleMock } = await import('../index-test-harness.js')
return createRuntimeClientModuleMock({
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock
})
})
import { main } from '../index'
let home: string
let out: string[]
let err: string[]
const fetchMock = vi.fn()
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'orca-cloud-main-'))
out = []
err = []
vi.spyOn(console, 'log').mockImplementation((line: unknown) => void out.push(String(line)))
vi.spyOn(console, 'error').mockImplementation((line: unknown) => void err.push(String(line)))
vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown) => {
out.push(String(chunk))
return true
}) as typeof process.stdout.write)
vi.stubEnv('ORCA_CLOUD_CREDENTIALS_FILE', join(home, 'credentials.json'))
vi.stubEnv('ORCA_CLOUD_TOKEN', 'orca_ws_test')
vi.stubEnv('ORCA_CLOUD_API_URL', 'https://cloud.test')
vi.stubGlobal('fetch', fetchMock)
process.exitCode = undefined
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllEnvs()
vi.unstubAllGlobals()
fetchMock.mockReset()
runtimeClientConstructorMock.mockReset()
rmSync(home, { recursive: true, force: true })
process.exitCode = undefined
})
describe('orca cloud via main()', () => {
it('parses `cloud workspace create --name child --json` and never builds a runtime client', async () => {
fetchMock.mockResolvedValue(
Response.json(
{ workspace: { id: 'sbx_c1', name: 'child', status: 'running', origin: 'vm' } },
{ status: 201 }
)
)
await main(['cloud', 'workspace', 'create', '--name', 'child', '--json'], home)
expect(process.exitCode).toBeUndefined()
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toBe('https://cloud.test/v1/workspaces')
expect(JSON.parse(String(init.body))).toEqual({ name: 'child' })
expect(JSON.parse(out.join(''))).toMatchObject({
ok: true,
schemaVersion: 1,
result: { workspace: { id: 'sbx_c1', origin: 'vm' } }
})
expect(runtimeClientConstructorMock).not.toHaveBeenCalled()
})
it('takes the workspace positional for message and sessions', async () => {
fetchMock.mockResolvedValue(
Response.json(
{ message: { id: 'msg_1', workspaceId: 'sbx_c1', status: 'queued', agent: 'codex' } },
{ status: 202 }
)
)
await main(
['cloud', 'message', 'sbx_c1', '--text', 'hello', '--agent', 'codex', '--json'],
home
)
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toBe('https://cloud.test/v1/workspaces/sbx_c1/messages')
expect(JSON.parse(String(init.body))).toEqual({ text: 'hello', agent: 'codex' })
fetchMock.mockResolvedValue(Response.json({ workspaceStatus: 'running', sessions: [] }))
await main(['cloud', 'sessions', 'sbx_c1', '--json'], home)
expect(fetchMock.mock.calls[1][0]).toBe('https://cloud.test/v1/workspaces/sbx_c1/sessions')
})
it('prints a JSON error with code and next steps when in-VM access is off', async () => {
fetchMock.mockResolvedValue(
Response.json({ error: 'forbidden', reason: 'vm_cli_disabled' }, { status: 403 })
)
await main(['cloud', 'workspace', 'list', '--json'], home)
expect(process.exitCode).toBe(1)
const body = JSON.parse(out.join(''))
expect(body).toMatchObject({ ok: false, error: { code: 'cloud_vm_cli_disabled' } })
expect(body.error.data.nextSteps[0]).toContain('vmCliEnabled')
})
it('prints a readable error without --json', async () => {
vi.stubEnv('ORCA_CLOUD_TOKEN', '')
await main(['cloud', 'whoami'], home)
expect(process.exitCode).toBe(1)
expect(err.join('\n')).toContain('Not signed in to Orca Cloud.')
expect(err.join('\n')).toContain('Next step: Run `orca cloud login')
expect(fetchMock).not.toHaveBeenCalled()
})
it('rejects unknown flags before any request', async () => {
await main(['cloud', 'workspace', 'create', '--bogus', 'x', '--json'], home)
expect(process.exitCode).toBe(1)
expect(fetchMock).not.toHaveBeenCalled()
})
it('shows help for the cloud group', async () => {
await main(['cloud', '--help'], home)
expect(out.join('')).toMatch(/workspace create +Create an Orca Cloud workspace/)
})
})
@@ -0,0 +1,163 @@
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 { nodeCloudCredentialFs } from './cloud-credentials'
import {
createWorktreeOnCloud,
readCloudOnTarget,
resolveCloudRepo,
type CloudOnDeps
} from './worktree-create-on-cloud'
let home: string
let logs: string[]
let errs: string[]
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'orca-cloud-on-'))
logs = []
errs = []
vi.spyOn(console, 'log').mockImplementation((line: unknown) => void logs.push(String(line)))
vi.spyOn(console, 'error').mockImplementation((line: unknown) => void errs.push(String(line)))
})
afterEach(() => {
vi.restoreAllMocks()
rmSync(home, { recursive: true, force: true })
})
function setup(origins: Record<string, string> = {}) {
const bodies: unknown[] = []
const fetchImpl = vi.fn(async (input: string, init: RequestInit) => {
const path = new URL(input).pathname
if (path === '/v1/projects') {
return Response.json({ projects: [{ id: 'prj_9', name: 'web' }] })
}
bodies.push(JSON.parse(String(init.body)))
return Response.json(
{
workspace: {
id: 'sbx_n1',
name: 'task',
status: 'running',
origin: 'vm',
machineSize: 'standard'
}
},
{ status: 201 }
)
})
const deps: CloudOnDeps = {
env: { ORCA_CLOUD_TOKEN: 'orca_ws_x', ORCA_CLOUD_API_URL: 'https://cloud.test' },
fetch: fetchImpl,
fs: nodeCloudCredentialFs,
home,
now: () => 0,
sleep: async () => {},
readStdin: async () => null,
originUrl: (dir) => origins[dir] ?? null
}
return { bodies, fetchImpl, deps }
}
const flags = (entries: Record<string, string | boolean>) => new Map(Object.entries(entries))
describe('readCloudOnTarget', () => {
it('claims cloud and cloud:<project> only', () => {
expect(readCloudOnTarget(flags({ on: 'cloud' }))).toEqual({ project: null })
expect(readCloudOnTarget(flags({ on: 'cloud:web' }))).toEqual({ project: 'web' })
expect(readCloudOnTarget(flags({ on: 'recipe:dev' }))).toBeNull()
expect(readCloudOnTarget(flags({ on: 'local' }))).toBeNull()
expect(readCloudOnTarget(flags({}))).toBeNull()
expect(() => readCloudOnTarget(flags({ on: 'cloud:' }))).toThrow('project id or name')
})
})
describe('resolveCloudRepo', () => {
it('uses a URL as given, a checkout path via its origin, else the cwd checkout', () => {
const origins = { [home]: 'git@github.com:acme/app.git' }
expect(resolveCloudRepo(flags({ repo: 'https://github.com/a/b' }), '/x', () => null)).toEqual({
url: 'https://github.com/a/b'
})
expect(resolveCloudRepo(flags({ repo: home }), '/x', (d) => origins[d] ?? null)).toEqual({
url: 'https://github.com/acme/app.git'
})
expect(resolveCloudRepo(flags({}), home, (d) => origins[d] ?? null)).toEqual({
url: 'https://github.com/acme/app.git'
})
expect(resolveCloudRepo(flags({}), '/nowhere', () => null)).toBeNull()
expect(() => resolveCloudRepo(flags({ repo: 'id:abc' }), home, () => null)).toThrow(
'git URL or a local checkout'
)
})
})
describe('worktree create --on cloud', () => {
it('creates a cloud workspace from the cwd checkout with the startup agent as first message', async () => {
const { bodies, deps } = setup({ [home]: 'https://github.com/acme/app' })
await createWorktreeOnCloud(
{
flags: flags({
on: 'cloud',
name: 'task',
agent: 'claude',
prompt: 'fix it',
'base-branch': 'dev',
json: true
}),
cwd: home,
json: true
},
() => deps
)
expect(bodies[0]).toEqual({
name: 'task',
repo: { url: 'https://github.com/acme/app', ref: 'dev' },
message: { text: 'fix it', agent: 'claude' }
})
expect(JSON.parse(logs.at(-1)!)).toMatchObject({
ok: true,
schemaVersion: 1,
result: {
workspace: { id: 'sbx_n1' },
host: {
id: 'sbx_n1',
origin: { target: 'cloud' },
lifecycle: 'per-workspace',
state: 'running'
}
}
})
// --json defaults progress to none so stderr stays quiet for scripts.
expect(errs).toEqual([])
})
it('cloud:<project> resolves the project and skips repo inference', async () => {
const { bodies, deps } = setup({ [home]: 'https://github.com/acme/app' })
await createWorktreeOnCloud(
{ flags: flags({ on: 'cloud:web', name: 't' }), cwd: home, json: false },
() => deps
)
expect(bodies[0]).toEqual({ name: 't', projectId: 'prj_9' })
expect(errs[0]).toContain('Creating an Orca Cloud workspace')
})
it('refuses flags and agents a cloud workspace cannot honour, before any request', async () => {
const { fetchImpl, deps } = setup()
const run = (f: Record<string, string | boolean>) =>
createWorktreeOnCloud(
{ flags: flags({ on: 'cloud', name: 't', ...f }), cwd: home, json: true },
() => deps
)
await expect(run({ 'parent-worktree': 'active' })).rejects.toThrow(
"--parent-worktree doesn't apply to --on cloud"
)
await expect(run({ agent: 'gemini', prompt: 'x' })).rejects.toThrow(
'--agent gemini is not available'
)
await expect(run({ prompt: 'x' })).rejects.toThrow('--prompt requires --agent')
await expect(run({ size: 'xl' })).rejects.toThrow('--size')
expect(fetchImpl).not.toHaveBeenCalled()
})
})
+211
View File
@@ -0,0 +1,211 @@
// Private build only: the `cloud` target for `orca worktree create --on <target>`.
// orca worktree create --on cloud [--name <n>] [--repo <url-or-path>] [--base-branch <ref>]
// [--size standard|large] [--agent claude|codex --prompt <text>] [--json]
// --on cloud:<project> uses an Orca Cloud project's repo and environment defaults.
// It creates an Orca Cloud workspace through the public API with the same credentials as
// `orca cloud …`, so it works on a desktop and inside a VM (no Orca runtime involved).
import { execFileSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import { isAbsolute, resolve } from 'node:path'
import type { HandlerContext } from '../dispatch'
import { getOptionalStringFlag } from '../flags'
import { RuntimeClientError } from '../runtime/types'
import { formatCloudWorkspace, printCloudResult } from './cloud-format'
import {
CLOUD_AGENTS,
cloudClient,
createCloudWorkspace,
defaultCloudHandlerDeps,
getSizeFlag,
normalizeCloudRepoUrl,
resolveCloudProjectId,
type CloudHandlerDeps,
type CloudWorkspaceCreateInput
} from './cloud-operations'
export const CLOUD_ON_TARGET = 'cloud'
/** Flags `worktree create` accepts that mean something for a cloud workspace. */
const SUPPORTED = new Set([
'on',
'name',
'repo',
'base-branch',
'size',
'agent',
'prompt',
'progress',
'json',
'help'
])
export type CloudOnDeps = CloudHandlerDeps & {
/** `git remote get-url origin` for a checkout, or null. */
originUrl: (dir: string) => string | null
}
function gitOriginUrl(dir: string): string | null {
try {
const out = execFileSync('git', ['-C', dir, 'remote', 'get-url', 'origin'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 5000
}).trim()
return out || null
} catch {
return null
}
}
export const defaultCloudOnDeps = (): CloudOnDeps => ({
...defaultCloudHandlerDeps(),
originUrl: gitOriginUrl
})
/** `--on cloud` or `--on cloud:<project>`; anything else belongs to the generic --on parser. */
export function readCloudOnTarget(
flags: Map<string, string | boolean>
): { project: string | null } | null {
const raw = flags.get('on')
if (typeof raw !== 'string') {
return null
}
const value = raw.trim()
if (value === CLOUD_ON_TARGET) {
return { project: null }
}
if (value.startsWith(`${CLOUD_ON_TARGET}:`)) {
const project = value.slice(CLOUD_ON_TARGET.length + 1).trim()
if (!project) {
throw new RuntimeClientError(
'invalid_argument',
'--on cloud:<project> needs a project id or name.',
{
field: 'on'
}
)
}
return { project }
}
return null
}
function looksLikeGitUrl(value: string): boolean {
return /^(https?|ssh|git):\/\//.test(value) || /^[\w.-]+@[\w.-]+:/.test(value)
}
/** --repo is a git URL or a local checkout (its origin is used); without it, the cwd checkout. */
export function resolveCloudRepo(
flags: Map<string, string | boolean>,
cwd: string,
originUrl: CloudOnDeps['originUrl']
): { url: string } | null {
const repo = getOptionalStringFlag(flags, 'repo')
if (repo && looksLikeGitUrl(repo)) {
return { url: normalizeCloudRepoUrl(repo) }
}
if (repo) {
const dir = isAbsolute(repo) ? repo : resolve(cwd, repo)
const origin = existsSync(dir) ? originUrl(dir) : null
if (!origin) {
throw new RuntimeClientError(
'invalid_argument',
`With --on cloud, --repo is a git URL or a local checkout with an origin remote; "${repo}" is neither.`,
{ field: 'repo' }
)
}
return { url: normalizeCloudRepoUrl(origin) }
}
const origin = originUrl(cwd)
return origin ? { url: normalizeCloudRepoUrl(origin) } : null
}
export async function createWorktreeOnCloud(
context: Pick<HandlerContext, 'flags' | 'cwd' | 'json'>,
getDeps: () => CloudOnDeps = defaultCloudOnDeps
): Promise<void> {
const { flags, cwd, json } = context
const target = readCloudOnTarget(flags)
if (!target) {
throw new RuntimeClientError('invalid_argument', '--on cloud expected.', { field: 'on' })
}
const unsupported = [...flags.keys()].filter((name) => !SUPPORTED.has(name))
if (unsupported.length > 0) {
throw new RuntimeClientError(
'invalid_argument',
`--${unsupported[0]} doesn't apply to --on cloud. Supported: --name, --repo, --base-branch, --size, --agent/--prompt.`,
{ field: unsupported[0] }
)
}
const agent = getOptionalStringFlag(flags, 'agent')
const prompt = getOptionalStringFlag(flags, 'prompt')
if (agent !== undefined && !(CLOUD_AGENTS as readonly string[]).includes(agent)) {
throw new RuntimeClientError(
'invalid_argument',
`--on cloud runs ${CLOUD_AGENTS.join(' or ')}; --agent ${agent} is not available there yet.`,
{ field: 'agent' }
)
}
if (prompt && !agent) {
throw new RuntimeClientError('invalid_argument', '--prompt requires --agent', {
field: 'agent'
})
}
const progress = flags.get('progress') ?? (json ? 'none' : 'text')
if (progress !== 'text' && progress !== 'json' && progress !== 'none') {
throw new RuntimeClientError(
'invalid_argument',
'--progress must be one of: text, json, none',
{
field: 'progress'
}
)
}
const deps = getDeps()
const machineSize = getSizeFlag(flags)
const ref = getOptionalStringFlag(flags, 'base-branch')
const repo = target.project ? null : resolveCloudRepo(flags, cwd, deps.originUrl)
if (ref && !repo && !target.project) {
throw new RuntimeClientError(
'invalid_argument',
'--base-branch needs a repo (--repo or a checkout as cwd).',
{
field: 'base-branch'
}
)
}
const api = cloudClient(deps)
const name = getOptionalStringFlag(flags, 'name')
const input: CloudWorkspaceCreateInput = {
...(name ? { name } : {}),
...(target.project ? { projectId: await resolveCloudProjectId(api, target.project) } : {}),
...(repo ? { repo: { url: repo.url, ...(ref ? { ref } : {}) } } : {}),
...(machineSize ? { machineSize } : {}),
...(prompt && agent
? { message: { text: prompt, agent: agent as (typeof CLOUD_AGENTS)[number] } }
: {})
}
const phase = (name: string, text: string) => {
if (progress === 'json') {
process.stderr.write(`${JSON.stringify({ event: 'phase', phase: name, at: deps.now() })}\n`)
} else if (progress === 'text') {
console.error(`- ${text}`)
}
}
phase('create', `Creating an Orca Cloud workspace${repo ? ` from ${repo.url}` : ''}...`)
const created = await createCloudWorkspace(api, input, deps.now)
phase('ready', `Workspace ${created.workspace.id} is ${created.workspace.status}.`)
printCloudResult(
{
...created,
host: {
id: created.workspace.id,
origin: { target: target.project ? `cloud:${target.project}` : CLOUD_ON_TARGET },
lifecycle: 'per-workspace',
state: created.workspace.status
}
},
json,
(value) => formatCloudWorkspace(value, deps.now())
)
}
+2
View File
@@ -7,6 +7,7 @@ import { SERVE_PAIRING_HANDLER_GROUPS } from './serve-pairing-handler-groups'
import { PLUGIN_HANDLER_GROUPS } from './plugin-handler-groups'
import { HOST_HANDLER_GROUPS } from './host-handler-groups'
import { WORKTREE_HANDLER_GROUPS } from './worktree-handler-groups'
import { CLOUD_HANDLER_GROUP } from './cloud/cloud-handler-group'
export type HandlerGroup = {
name: string
@@ -20,6 +21,7 @@ export const HANDLER_GROUPS: readonly HandlerGroup[] = [
JIRA_HANDLER_GROUP,
LINEAR_HANDLER_GROUP,
REFERENCE_HANDLER_GROUP,
CLOUD_HANDLER_GROUP,
{
name: 'core',
keys: ['claude-teams', 'open', 'serve', 'status'],
+2
View File
@@ -3,6 +3,8 @@ export function ignoresRemoteSelection(commandPath: readonly string[]): boolean
return (
commandPath[0] === 'account' ||
commandPath[0] === 'artifacts' ||
// Orca Cloud API commands call the cloud API directly; `cloud env` still goes through the runtime.
(commandPath[0] === 'cloud' && commandPath[1] !== 'env') ||
commandPath[0] === 'environment' ||
// Paired servers come from the local store; routing SSH lookup would mix two machines in one list.
commandPath.join(' ') === 'host list' ||
+9
View File
@@ -85,6 +85,15 @@ export const ROOT_HELP_TEXT_PRIMARY = [
' cloud env builds List recent Orca Cloud environment builds',
' cloud env logs Print or follow an Orca Cloud build log',
'',
'Orca Cloud:',
' cloud whoami Show the Orca Cloud account these credentials act as',
' cloud login Save an Orca Cloud API key for the CLI',
' cloud workspace List, show, create, sleep, wake, archive or destroy workspaces',
' cloud message Send a message to an agent in a cloud workspace',
' cloud messages List or show messages sent to a cloud workspace',
' cloud sessions List agent sessions in a cloud workspace',
' cloud projects list List Orca Cloud projects',
'',
'Automations:',
' automations list List scheduled Orca automations',
' automations show Show one Orca automation',
+3 -1
View File
@@ -36,6 +36,7 @@ import { PLUGIN_AUTHORING_COMMAND_SPECS, PLUGIN_RUNTIME_COMMAND_SPECS } from './
import { PAIR_COMMAND_SPECS } from './pair'
import { PLUGIN_VIEW_COMMAND_SPECS } from './plugin-view'
import { PLUGIN_INSTALL_COMMAND_SPECS } from './plugin-install'
import { CLOUD_COMMAND_SPECS } from '../cloud/cloud-specs'
export const COMMAND_SPECS: CommandSpec[] = [
...CORE_COMMAND_SPECS,
@@ -74,5 +75,6 @@ export const COMMAND_SPECS: CommandSpec[] = [
...PLUGIN_RUNTIME_COMMAND_SPECS,
...PLUGIN_VIEW_COMMAND_SPECS,
...PLUGIN_INSTALL_COMMAND_SPECS,
...PAIR_COMMAND_SPECS
...PAIR_COMMAND_SPECS,
...CLOUD_COMMAND_SPECS
]
+1
View File
@@ -68,6 +68,7 @@ export const CLI_COMMAND_NAMES = [
'search',
'select',
'select-all',
'sender',
'serve',
'set',
'skills',