mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
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:
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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.'] : [])
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
]
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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())
|
||||
)
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
@@ -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' ||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -68,6 +68,7 @@ export const CLI_COMMAND_NAMES = [
|
||||
'search',
|
||||
'select',
|
||||
'select-all',
|
||||
'sender',
|
||||
'serve',
|
||||
'set',
|
||||
'skills',
|
||||
|
||||
Reference in New Issue
Block a user