diff --git a/cloud/apps/sandbox-control/src/index.ts b/cloud/apps/sandbox-control/src/index.ts index a0848df9a4d..3dde83b32f9 100644 --- a/cloud/apps/sandbox-control/src/index.ts +++ b/cloud/apps/sandbox-control/src/index.ts @@ -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 { const broker = deps.credentials ?? egressCredentials(env) const memberGit = memberGitEnabled(env) @@ -494,7 +507,7 @@ async function lifecycleDeps(env: Env, deps: Deps): Promise host.trim()).filter(Boolean), + extraEgressHosts: extraEgressHostsFor(env), guardrails: guardrailsFromEnv(env), snapshotExpirationMs: env.SNAPSHOT_EXPIRATION_DAYS && Number(env.SNAPSHOT_EXPIRATION_DAYS) >= 1 diff --git a/cloud/apps/sandbox-control/test/egress-hosts.test.ts b/cloud/apps/sandbox-control/test/egress-hosts.test.ts new file mode 100644 index 00000000000..a3e2f0831f1 --- /dev/null +++ b/cloud/apps/sandbox-control/test/egress-hosts.test.ts @@ -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) => 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([]) + }) +}) diff --git a/src/cli/cloud/cloud-api-client.ts b/src/cli/cloud/cloud-api-client.ts new file mode 100644 index 00000000000..4570426114b --- /dev/null +++ b/src/cli/cloud/cloud-api-client.ts @@ -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 + +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( + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', + path: string, + options: { body?: unknown; timeoutMs?: number; query?: Record } = {} + ): Promise { + 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 + 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 = {}) => ({ + 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 ` 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 = { + 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.'] : []) + ) +} diff --git a/src/cli/cloud/cloud-cli.test.ts b/src/cli/cloud/cloud-cli.test.ts new file mode 100644 index 00000000000..0832a5de031 --- /dev/null +++ b/src/cli/cloud/cloud-cli.test.ts @@ -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 { + 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] + > +) { + 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 = { + ORCA_CLOUD_TOKEN: 'orca_sk_test', + ORCA_CLOUD_API_URL: API + }, + extra: Partial = {} +): 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, 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 } { + 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' + ) + }) +}) diff --git a/src/cli/cloud/cloud-credentials.ts b/src/cli/cloud/cloud-credentials.ts new file mode 100644 index 00000000000..d33f1033a2d --- /dev/null +++ b/src/cli/cloud/cloud-credentials.ts @@ -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> + +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 --api-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://'] } + ) + } + 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' +} diff --git a/src/cli/cloud/cloud-format.ts b/src/cli/cloud/cloud-format.ts new file mode 100644 index 00000000000..d9f5ba0b716 --- /dev/null +++ b/src/cli/cloud/cloud-format.ts @@ -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(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') +} diff --git a/src/cli/cloud/cloud-handler-group.ts b/src/cli/cloud/cloud-handler-group.ts new file mode 100644 index 00000000000..e24a84c43e6 --- /dev/null +++ b/src/cli/cloud/cloud-handler-group.ts @@ -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 +} diff --git a/src/cli/cloud/cloud-handlers.ts b/src/cli/cloud/cloud-handlers.ts new file mode 100644 index 00000000000..15755a02c1a --- /dev/null +++ b/src/cli/cloud/cloud-handlers.ts @@ -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 { + 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('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 , 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 (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('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() diff --git a/src/cli/cloud/cloud-operations.ts b/src/cli/cloud/cloud-operations.ts new file mode 100644 index 00000000000..76a98d90bf5 --- /dev/null +++ b/src/cli/cloud/cloud-operations.ts @@ -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 + /** Piped stdin (for `login` without --token); null when stdin is a terminal. */ + readStdin: () => Promise +} + +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 { + 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 +): (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 { + 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 { + 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 +): '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) { + 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, + message: CloudMessage, + timeoutMs: number +): Promise { + 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}` +} diff --git a/src/cli/cloud/cloud-specs.ts b/src/cli/cloud/cloud-specs.ts new file mode 100644 index 00000000000..f05ee64ecde --- /dev/null +++ b/src/cli/cloud/cloud-specs.ts @@ -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 = + ' 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} [--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 ] [--api-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 ] [--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 [--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 ] [--project ] [--repo [--ref ]] [--size standard|large] [--region ] [--agent claude|codex --prompt ] [--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 --text [--agent claude|codex] [--session ] [--wait [--timeout ]] [--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 (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 [] [--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 [--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] + } +] diff --git a/src/cli/cloud/index-cloud-commands.test.ts b/src/cli/cloud/index-cloud-commands.test.ts new file mode 100644 index 00000000000..31ac30f52d3 --- /dev/null +++ b/src/cli/cloud/index-cloud-commands.test.ts @@ -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/) + }) +}) diff --git a/src/cli/cloud/worktree-create-on-cloud.test.ts b/src/cli/cloud/worktree-create-on-cloud.test.ts new file mode 100644 index 00000000000..38759a98369 --- /dev/null +++ b/src/cli/cloud/worktree-create-on-cloud.test.ts @@ -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 = {}) { + 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) => new Map(Object.entries(entries)) + +describe('readCloudOnTarget', () => { + it('claims cloud and cloud: 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: 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) => + 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() + }) +}) diff --git a/src/cli/cloud/worktree-create-on-cloud.ts b/src/cli/cloud/worktree-create-on-cloud.ts new file mode 100644 index 00000000000..e747288b5d7 --- /dev/null +++ b/src/cli/cloud/worktree-create-on-cloud.ts @@ -0,0 +1,211 @@ +// Private build only: the `cloud` target for `orca worktree create --on `. +// orca worktree create --on cloud [--name ] [--repo ] [--base-branch ] +// [--size standard|large] [--agent claude|codex --prompt ] [--json] +// --on cloud: 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:`; anything else belongs to the generic --on parser. */ +export function readCloudOnTarget( + flags: Map +): { 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: 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, + 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, + getDeps: () => CloudOnDeps = defaultCloudOnDeps +): Promise { + 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()) + ) +} diff --git a/src/cli/handler-group-manifest.ts b/src/cli/handler-group-manifest.ts index 9fd4f3ead36..60b44102359 100644 --- a/src/cli/handler-group-manifest.ts +++ b/src/cli/handler-group-manifest.ts @@ -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'], diff --git a/src/cli/remote-selection-scope.ts b/src/cli/remote-selection-scope.ts index 7c31dae0b73..829ffa8c54b 100644 --- a/src/cli/remote-selection-scope.ts +++ b/src/cli/remote-selection-scope.ts @@ -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' || diff --git a/src/cli/root-help-text-primary.ts b/src/cli/root-help-text-primary.ts index 9264f64581d..b28fd7feb1c 100644 --- a/src/cli/root-help-text-primary.ts +++ b/src/cli/root-help-text-primary.ts @@ -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', diff --git a/src/cli/specs/index.ts b/src/cli/specs/index.ts index 010bf1dfbcc..dfeb0239e7f 100644 --- a/src/cli/specs/index.ts +++ b/src/cli/specs/index.ts @@ -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 ] diff --git a/src/main/startup/cli-command-names.ts b/src/main/startup/cli-command-names.ts index 17fd31bf2f9..13bd049c0bc 100644 --- a/src/main/startup/cli-command-names.ts +++ b/src/main/startup/cli-command-names.ts @@ -68,6 +68,7 @@ export const CLI_COMMAND_NAMES = [ 'search', 'select', 'select-all', + 'sender', 'serve', 'set', 'skills',