From bb07c4bdd7e76f496a17dca03f746639ffc27d2a Mon Sep 17 00:00:00 2001 From: Neil Date: Mon, 9 Nov 2026 04:29:27 -0800 Subject: [PATCH] feat(cloud-secrets): main-process client, IPC and preload bridge for Orca Cloud secrets Shares the control-plane call (token, base URL, redirect guard, zod) between the sandbox and secrets clients. Values go renderer -> control plane only; failures cross IPC as codes. Adds the .env paste parser and the client-side mirror of the server's name/value rules. --- .../cloud-secrets-client.test.ts | 137 +++++++++++++++++ .../cloud-secrets/cloud-secrets-client.ts | 119 +++++++++++++++ src/main/cloud-secrets/cloud-secrets-ipc.ts | 140 ++++++++++++++++++ .../hosted-sandbox/hosted-sandbox-request.ts | 4 +- src/main/startup/main-window-core-services.ts | 2 + src/preload/api-types.ts | 3 + src/preload/api/cloud-secrets-bridge.ts | 42 ++++++ src/preload/index.ts | 2 + .../cloud-secrets/cloud-secret-rules.ts | 139 +++++++++++++++++ .../cloud-secrets/cloud-secrets-wire.ts | 126 ++++++++++++++++ src/shared/cloud-secrets/dotenv-paste.test.ts | 58 ++++++++ src/shared/cloud-secrets/dotenv-paste.ts | 109 ++++++++++++++ 12 files changed, 879 insertions(+), 2 deletions(-) create mode 100644 src/main/cloud-secrets/cloud-secrets-client.test.ts create mode 100644 src/main/cloud-secrets/cloud-secrets-client.ts create mode 100644 src/main/cloud-secrets/cloud-secrets-ipc.ts create mode 100644 src/preload/api/cloud-secrets-bridge.ts create mode 100644 src/shared/cloud-secrets/cloud-secret-rules.ts create mode 100644 src/shared/cloud-secrets/cloud-secrets-wire.ts create mode 100644 src/shared/cloud-secrets/dotenv-paste.test.ts create mode 100644 src/shared/cloud-secrets/dotenv-paste.ts diff --git a/src/main/cloud-secrets/cloud-secrets-client.test.ts b/src/main/cloud-secrets/cloud-secrets-client.test.ts new file mode 100644 index 00000000000..4305bbda085 --- /dev/null +++ b/src/main/cloud-secrets/cloud-secrets-client.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest' +import { cloudSecretsFailureCode, createCloudSecretsClient } from './cloud-secrets-client' + +type Seen = { method: string; url: string; body: string | null; authorization: string | null } + +function fakeControlPlane(respond: (seen: Seen) => Response) { + const seen: Seen[] = [] + const fetchImpl: typeof fetch = async (input, init) => { + const request: Seen = { + method: init?.method ?? 'GET', + url: String(input), + body: typeof init?.body === 'string' ? init.body : null, + authorization: new Headers(init?.headers).get('authorization') + } + seen.push(request) + return respond(request) + } + const client = createCloudSecretsClient({ + baseUrl: 'https://control.test', + getAccessToken: async () => 'tok', + fetch: fetchImpl + }) + return { client, seen } +} + +const VIEW = { + scope: 'repo', + name: 'DATABASE_URL', + kind: 'env', + hint: 'ab12', + updatedAt: 5, + updatedBy: 'u1', + repo: 'github.com/acme/app' +} + +describe('createCloudSecretsClient', () => { + it('addresses scope, name and repo on the wire and sends the value only in the body', async () => { + const { client, seen } = fakeControlPlane(({ method }) => + Response.json(method === 'GET' ? { secrets: [VIEW] } : { secret: VIEW }) + ) + expect(await client.list({ scope: 'repo', repo: 'acme/app' })).toEqual([VIEW]) + await client.put({ scope: 'repo', repo: 'acme/app' }, 'DATABASE_URL', 'postgres://secret') + expect(seen).toEqual([ + { + method: 'GET', + url: 'https://control.test/v1/secrets?scope=repo&repo=acme%2Fapp', + body: null, + authorization: 'Bearer tok' + }, + { + method: 'PUT', + url: 'https://control.test/v1/secrets/repo/DATABASE_URL?repo=acme%2Fapp', + body: '{"value":"postgres://secret"}', + authorization: 'Bearer tok' + } + ]) + }) + + it('posts batches and reads partial success', async () => { + const result = { + secrets: [{ ...VIEW, scope: 'personal', repo: undefined }], + deleted: ['OLD'], + errors: [{ name: 'PATH', error: 'invalid_request', reason: 'reserved_name' }] + } + const { client, seen } = fakeControlPlane(() => Response.json(result)) + const out = await client.batch( + { scope: 'personal' }, + { set: { DATABASE_URL: 'x', PATH: '/bin' }, delete: ['OLD'] } + ) + expect(out.errors).toEqual([ + { name: 'PATH', error: 'invalid_request', reason: 'reserved_name' } + ]) + expect(seen[0].url).toBe('https://control.test/v1/secrets/personal/batch') + }) + + it('maps server refusals to their reason and never quotes the request', async () => { + const { client } = fakeControlPlane(() => + Response.json( + { error: 'invalid_request', reason: 'subscription_token_refused' }, + { status: 400 } + ) + ) + const error = await client + .put({ scope: 'personal' }, 'ANTHROPIC_API_KEY', 'sk-ant-oat01-very-secret') + .catch((caught: unknown) => caught) + expect(cloudSecretsFailureCode(error)).toBe('subscription_token_refused') + expect(String(error)).not.toContain('very-secret') + }) + + it.each([ + [403, { error: 'forbidden', reason: 'org_admin_required' }, 'org_admin_required'], + [409, { error: 'conflict', reason: 'secret_limit' }, 'secret_limit'], + [401, { error: 'unauthorized' }, 'unauthorized'], + [503, { error: 'internal_error' }, 'unavailable'] + ])('maps HTTP %i to %s', async (status, body, code) => { + const { client } = fakeControlPlane(() => Response.json(body, { status })) + const error = await client.list({ scope: 'org' }).catch((caught: unknown) => caught) + expect(cloudSecretsFailureCode(error)).toBe(code) + }) + + it('rejects a response that does not match the schema', async () => { + const { client } = fakeControlPlane(() => Response.json({ secrets: [{ name: 1 }] })) + const error = await client.list({ scope: 'personal' }).catch((caught: unknown) => caught) + expect(cloudSecretsFailureCode(error)).toBe('unavailable') + }) + + it('reads the effective view and refreshes a sandbox env', async () => { + const effective = { + caller: { + userId: 'u1', + orgId: 'org_1', + orgRole: 'member', + canWrite: { personal: true, org: false, repo: false } + }, + precedence: { env: ['repo', 'personal', 'org'], agent_key: ['org', 'repo', 'personal'] }, + secrets: [ + { + name: 'ANTHROPIC_API_KEY', + kind: 'agent_key', + scope: 'org', + hint: 'zz99', + updatedAt: 1, + shadowed: [{ scope: 'personal', hint: null }] + } + ] + } + const { client, seen } = fakeControlPlane(({ url }) => + Response.json(url.includes('/env/refresh') ? { applied: true } : effective) + ) + expect(await client.effective('acme/app')).toEqual(effective) + expect(await client.refreshEnv('sbx_1')).toBe(true) + expect(seen.map((request) => `${request.method} ${request.url}`)).toEqual([ + 'GET https://control.test/v1/secrets/effective?repo=acme%2Fapp', + 'POST https://control.test/v1/sandboxes/sbx_1/env/refresh' + ]) + }) +}) diff --git a/src/main/cloud-secrets/cloud-secrets-client.ts b/src/main/cloud-secrets/cloud-secrets-client.ts new file mode 100644 index 00000000000..22b19e83b44 --- /dev/null +++ b/src/main/cloud-secrets/cloud-secrets-client.ts @@ -0,0 +1,119 @@ +// Orca Cloud secrets on the sandbox control plane. Write-only for values: requests carry them, no +// response or error does, and nothing here logs a request body. +import { + CloudEnvRefreshResponseSchema, + CloudSecretBatchResponseSchema, + CloudSecretDeleteResponseSchema, + CloudSecretListResponseSchema, + CloudSecretPutResponseSchema, + CloudSecretsEffectiveResponseSchema, + cloudSecretsErrorCode, + type CloudSecretBatchResult, + type CloudSecretsErrorCode, + type CloudSecretTarget, + type CloudSecretView, + type CloudSecretsEffective +} from '../../shared/cloud-secrets/cloud-secrets-wire' +import { isEnvironmentProviderError } from '../environment-providers/environment-provider-driver' +import { + createHostedSandboxRequest, + type HostedSandboxControlPlaneClientOptions +} from '../hosted-sandbox/hosted-sandbox-request' + +export type CloudSecretsClient = { + list(target: CloudSecretTarget, signal?: AbortSignal): Promise + put(target: CloudSecretTarget, name: string, value: string): Promise + remove(target: CloudSecretTarget, name: string): Promise + batch( + target: CloudSecretTarget, + change: { set?: Record; delete?: string[] } + ): Promise + effective(repo?: string, signal?: AbortSignal): Promise + refreshEnv(sandboxId: string): Promise +} + +function withRepo(path: string, repo: string | undefined): string { + return repo ? `${path}?${new URLSearchParams({ repo })}` : path +} + +export function createCloudSecretsClient( + options: HostedSandboxControlPlaneClientOptions +): CloudSecretsClient { + const call = createHostedSandboxRequest(options) + const secretPath = (target: CloudSecretTarget, suffix: string): string => + withRepo(`v1/secrets/${encodeURIComponent(target.scope)}/${suffix}`, target.repo) + + return { + list: async (target, signal) => + ( + await call( + 'GET', + `v1/secrets?${new URLSearchParams({ + scope: target.scope, + ...(target.repo ? { repo: target.repo } : {}) + })}`, + CloudSecretListResponseSchema, + { kind: 'control', signal } + ) + ).secrets, + put: async (target, name, value) => + ( + await call( + 'PUT', + secretPath(target, encodeURIComponent(name)), + CloudSecretPutResponseSchema, + { + body: { value }, + kind: 'control' + } + ) + ).secret, + remove: async (target, name) => { + await call( + 'DELETE', + secretPath(target, encodeURIComponent(name)), + CloudSecretDeleteResponseSchema, + { kind: 'control' } + ) + }, + batch: (target, change) => + call('POST', secretPath(target, 'batch'), CloudSecretBatchResponseSchema, { + body: change, + kind: 'control' + }), + effective: (repo, signal) => + call('GET', withRepo('v1/secrets/effective', repo), CloudSecretsEffectiveResponseSchema, { + kind: 'control', + signal + }), + refreshEnv: async (sandboxId) => + ( + await call( + 'POST', + `v1/sandboxes/${encodeURIComponent(sandboxId)}/env/refresh`, + CloudEnvRefreshResponseSchema, + { kind: 'control' } + ) + ).applied + } +} + +/** The code the renderer turns into copy: the server reason when it gave one. */ +export function cloudSecretsFailureCode(error: unknown): CloudSecretsErrorCode { + if (!isEnvironmentProviderError(error)) { + return 'unknown' + } + switch (error.code) { + case 'unauthorized': + return cloudSecretsErrorCode(error.reason, 'unauthorized') + case 'not_found': + return 'not_found' + case 'timeout': + return 'timeout' + case 'invalid_request': + case 'conflict': + return cloudSecretsErrorCode(error.reason) + default: + return 'unavailable' + } +} diff --git a/src/main/cloud-secrets/cloud-secrets-ipc.ts b/src/main/cloud-secrets/cloud-secrets-ipc.ts new file mode 100644 index 00000000000..5d149532087 --- /dev/null +++ b/src/main/cloud-secrets/cloud-secrets-ipc.ts @@ -0,0 +1,140 @@ +// Renderer bridge to Orca Cloud secrets. Values travel renderer -> main -> control plane once per save; +// nothing returns them, and failures surface as codes only (an error message could quote a value). +import { app, ipcMain } from 'electron' +import { z } from 'zod' +import { + CLOUD_SECRET_SCOPES, + MAX_CLOUD_SECRET_BATCH, + type CloudSecretsResult, + type CloudSecretsStatus +} from '../../shared/cloud-secrets/cloud-secrets-wire' +import { listEphemeralVmRuntimes } from '../../shared/ephemeral-vm-runtime-store' +import { environmentHandleFromRecipeResult } from '../environment-providers/environment-provider-recipe-adapter' +import { getHostedSandboxConfig } from '../hosted-sandbox/hosted-sandbox-config' +import { HOSTED_SANDBOX_DRIVER_ID } from '../hosted-sandbox/hosted-sandbox-driver' +import { getOrcaCloudAvailability } from '../hosted-sandbox/hosted-sandbox-install' +import { + cloudSecretsFailureCode, + createCloudSecretsClient, + type CloudSecretsClient +} from './cloud-secrets-client' + +const Repo = z.string().trim().min(3).max(256) +const Target = z.object({ scope: z.enum(CLOUD_SECRET_SCOPES), repo: Repo.optional() }) +const Name = z.string().min(1).max(128) +const Value = z + .string() + .min(1) + .max(64 * 1024) +const PutRequest = z.object({ target: Target, name: Name, value: Value }) +const RemoveRequest = z.object({ target: Target, name: Name }) +const BatchRequest = z + .object({ + target: Target, + set: z.record(Name, Value).optional(), + delete: z.array(Name).max(MAX_CLOUD_SECRET_BATCH).optional() + }) + .refine( + (request) => + Object.keys(request.set ?? {}).length + (request.delete?.length ?? 0) <= + MAX_CLOUD_SECRET_BATCH + ) +const EffectiveRequest = z.object({ repo: Repo.optional() }) +const RefreshRequest = z.object({ runtimeEnvironmentId: z.string().min(1).max(256) }) + +const CHANNELS = [ + 'cloudSecrets:status', + 'cloudSecrets:list', + 'cloudSecrets:put', + 'cloudSecrets:remove', + 'cloudSecrets:batch', + 'cloudSecrets:effective', + 'cloudSecrets:refreshEnv' +] as const + +async function attempt( + client: CloudSecretsClient | null, + run: (client: CloudSecretsClient) => Promise +): Promise> { + if (!client) { + return { ok: false, error: 'not_configured' } + } + try { + return { ok: true, value: await run(client) } + } catch (error) { + const code = cloudSecretsFailureCode(error) + console.warn(`[cloud-secrets] request failed: ${code}`) + return { ok: false, error: code } + } +} + +function sandboxIdFor(userDataPath: string, runtimeEnvironmentId: string): string | null { + const runtime = listEphemeralVmRuntimes(userDataPath).find( + (record) => + record.runtimeEnvironmentId === runtimeEnvironmentId && + record.recipe?.provider === 'orca-cloud' + ) + const handle = runtime?.recipeResult + ? environmentHandleFromRecipeResult(runtime.recipeResult, HOSTED_SANDBOX_DRIVER_ID) + : null + return handle?.environmentId ?? null +} + +export function registerCloudSecretsHandlers(userDataPath: string): void { + const config = getHostedSandboxConfig(userDataPath, process.env, app.isPackaged) + const client = config + ? createCloudSecretsClient({ + baseUrl: config.controlPlaneUrl, + getAccessToken: config.getAccessToken + }) + : null + for (const channel of CHANNELS) { + ipcMain.removeHandler(channel) + } + const invalid = { ok: false, error: 'invalid_value' } as const + + ipcMain.handle('cloudSecrets:status', async (): Promise => ({ + available: client !== null && (await getOrcaCloudAvailability()) === 'offered' + })) + ipcMain.handle('cloudSecrets:list', (_event, raw: unknown) => { + const target = Target.safeParse(raw) + return target.success ? attempt(client, (c) => c.list(target.data)) : invalid + }) + ipcMain.handle('cloudSecrets:put', (_event, raw: unknown) => { + const request = PutRequest.safeParse(raw) + return request.success + ? attempt(client, (c) => c.put(request.data.target, request.data.name, request.data.value)) + : invalid + }) + ipcMain.handle('cloudSecrets:remove', (_event, raw: unknown) => { + const request = RemoveRequest.safeParse(raw) + return request.success + ? attempt(client, async (c) => { + await c.remove(request.data.target, request.data.name) + return true as const + }) + : invalid + }) + ipcMain.handle('cloudSecrets:batch', (_event, raw: unknown) => { + const request = BatchRequest.safeParse(raw) + return request.success + ? attempt(client, (c) => + c.batch(request.data.target, { set: request.data.set, delete: request.data.delete }) + ) + : invalid + }) + ipcMain.handle('cloudSecrets:effective', (_event, raw: unknown) => { + const request = EffectiveRequest.safeParse(raw ?? {}) + return request.success ? attempt(client, (c) => c.effective(request.data.repo)) : invalid + }) + ipcMain.handle('cloudSecrets:refreshEnv', (_event, raw: unknown) => { + const request = RefreshRequest.safeParse(raw) + if (!request.success) { + return invalid + } + const sandboxId = sandboxIdFor(userDataPath, request.data.runtimeEnvironmentId) + return sandboxId + ? attempt(client, (c) => c.refreshEnv(sandboxId)) + : { ok: false, error: 'not_found' } + }) +} diff --git a/src/main/hosted-sandbox/hosted-sandbox-request.ts b/src/main/hosted-sandbox/hosted-sandbox-request.ts index 08b7f7358ac..e2d9a93ad47 100644 --- a/src/main/hosted-sandbox/hosted-sandbox-request.ts +++ b/src/main/hosted-sandbox/hosted-sandbox-request.ts @@ -21,7 +21,7 @@ const DEFAULT_TIMEOUTS = { boot: 180_000, control: 60_000 } const MAX_RESPONSE_BYTES = 256 * 1024 export type HostedSandboxRequest = ( - method: 'GET' | 'POST' | 'DELETE', + method: 'GET' | 'POST' | 'PUT' | 'DELETE', path: string, schema: z.ZodType, init: { body?: unknown; kind: 'boot' | 'control'; signal?: AbortSignal } @@ -36,7 +36,7 @@ export function createHostedSandboxRequest( const timeouts = { ...DEFAULT_TIMEOUTS, ...options.timeouts } return async function call( - method: 'GET' | 'POST' | 'DELETE', + method: 'GET' | 'POST' | 'PUT' | 'DELETE', path: string, schema: z.ZodType, init: { body?: unknown; kind: 'boot' | 'control'; signal?: AbortSignal } diff --git a/src/main/startup/main-window-core-services.ts b/src/main/startup/main-window-core-services.ts index 463c0a58d89..4f917681bd2 100644 --- a/src/main/startup/main-window-core-services.ts +++ b/src/main/startup/main-window-core-services.ts @@ -1,6 +1,7 @@ import { app, type BrowserWindow } from 'electron' import { registerAgentApiKeyHandlers } from '../agent-api-keys/agent-api-key-ipc' import { registerCloudWorkspacesHandlers } from '../ipc/cloud-workspaces' +import { registerCloudSecretsHandlers } from '../cloud-secrets/cloud-secrets-ipc' import { registerCoreHandlers } from '../ipc/register-core-handlers/register-core-handlers' import { attachMainWindowServices } from '../window/attach-main-window-services' import { initTccPromptNotice } from '../macos-permissions/macos-tcc-prompt-notice' @@ -69,6 +70,7 @@ export function attachMainWindowCoreServices( holdLaunchReconnectOptOuts(store.getSshTargets()) registerAgentApiKeyHandlers(app.getPath('userData')) registerCloudWorkspacesHandlers() + registerCloudSecretsHandlers(app.getPath('userData')) registerCoreHandlers( store, runtime, diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 70a84da0739..55a8046a65b 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1,4 +1,5 @@ import type { AgentApiKeysApi } from './api/agent-api-keys-bridge' +import type { CloudSecretsApi } from './api/cloud-secrets-bridge' import type { ClaudeAccountsApi, CodexAccountsApi, @@ -178,6 +179,8 @@ export type PreloadApi = { plainSsh?: PlainSshTerminalApi /** Desktop only: the API keys Orca hands to agent launches. */ agentApiKeys?: AgentApiKeysApi + /** Desktop only: Orca Cloud secrets (personal, team, repo) injected into cloud workspaces. */ + cloudSecrets?: CloudSecretsApi /** COMPAT(legacy-relay-bridge): desktop-only, revisit 2027-01-15. */ legacyRelayBridge?: LegacyRelayBridgeApi automations: AutomationsApi diff --git a/src/preload/api/cloud-secrets-bridge.ts b/src/preload/api/cloud-secrets-bridge.ts new file mode 100644 index 00000000000..34e663d55c4 --- /dev/null +++ b/src/preload/api/cloud-secrets-bridge.ts @@ -0,0 +1,42 @@ +import { ipcRenderer } from 'electron' +import type { + CloudSecretBatchResult, + CloudSecretTarget, + CloudSecretView, + CloudSecretsEffective, + CloudSecretsResult, + CloudSecretsStatus +} from '../../shared/cloud-secrets/cloud-secrets-wire' + +/** Desktop only. Values go in; only names, scopes and last-4 hints come back. */ +export type CloudSecretsApi = { + status: () => Promise + list: (target: CloudSecretTarget) => Promise> + put: (request: { + target: CloudSecretTarget + name: string + value: string + }) => Promise> + remove: (request: { + target: CloudSecretTarget + name: string + }) => Promise> + batch: (request: { + target: CloudSecretTarget + set?: Record + delete?: string[] + }) => Promise> + effective: (request: { repo?: string }) => Promise> + /** Best-effort nudge so a running workspace picks up new values now; the server also does it on write. */ + refreshEnv: (request: { runtimeEnvironmentId: string }) => Promise> +} + +export const cloudSecretsApi: CloudSecretsApi = { + status: () => ipcRenderer.invoke('cloudSecrets:status'), + list: (target) => ipcRenderer.invoke('cloudSecrets:list', target), + put: (request) => ipcRenderer.invoke('cloudSecrets:put', request), + remove: (request) => ipcRenderer.invoke('cloudSecrets:remove', request), + batch: (request) => ipcRenderer.invoke('cloudSecrets:batch', request), + effective: (request) => ipcRenderer.invoke('cloudSecrets:effective', request), + refreshEnv: (request) => ipcRenderer.invoke('cloudSecrets:refreshEnv', request) +} diff --git a/src/preload/index.ts b/src/preload/index.ts index fe2d93e21ee..4178d36f12a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -91,6 +91,7 @@ import { cursorAccountsApi } from './api/cursor-accounts-bridge' import { sshApi } from './api/ssh-bridge' import { plainSshTerminalApi } from './api/plain-ssh-terminal-bridge' import { agentApiKeysApi } from './api/agent-api-keys-bridge' +import { cloudSecretsApi } from './api/cloud-secrets-bridge' import { legacyRelayBridgeApi } from './api/legacy-relay-bridge-api' import { automationsApi } from './api/automations-bridge' import { e2eApi } from './api/e2e-bridge' @@ -202,6 +203,7 @@ const api = { ssh: sshApi, plainSsh: plainSshTerminalApi, agentApiKeys: agentApiKeysApi, + cloudSecrets: cloudSecretsApi, legacyRelayBridge: legacyRelayBridgeApi, automations: automationsApi, e2e: e2eApi, diff --git a/src/shared/cloud-secrets/cloud-secret-rules.ts b/src/shared/cloud-secrets/cloud-secret-rules.ts new file mode 100644 index 00000000000..8b3022e7220 --- /dev/null +++ b/src/shared/cloud-secrets/cloud-secret-rules.ts @@ -0,0 +1,139 @@ +// Client-side mirror of the control plane's secret rules (cloud/apps/sandbox-control/src/secrets.ts), +// so the editor and the .env preview flag a bad line before anything is sent. The server stays the authority. +import type { CloudSecretKind, CloudSecretsErrorCode } from './cloud-secrets-wire' + +const NAME = /^[A-Z_][A-Z0-9_]*$/ +const MAX_NAME_LENGTH = 128 +export const MAX_CLOUD_SECRET_VALUE_BYTES = 8 * 1024 + +/** For these, a team-shared value beats a personal one. */ +const AGENT_KEY_NAMES: ReadonlySet = new Set([ + 'ANTHROPIC_API_KEY', + 'ANTHROPIC_AUTH_TOKEN', + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_MODEL', + 'ANTHROPIC_SMALL_FAST_MODEL', + 'OPENAI_API_KEY', + 'OPENAI_BASE_URL', + 'AI_GATEWAY_API_KEY', + 'GEMINI_API_KEY', + 'GOOGLE_API_KEY', + 'CURSOR_API_KEY', + 'AWS_ACCESS_KEY_ID', + 'AWS_SECRET_ACCESS_KEY', + 'AWS_SESSION_TOKEN', + 'AWS_REGION', + 'AWS_BEARER_TOKEN_BEDROCK', + 'CLAUDE_CODE_USE_BEDROCK', + 'CLAUDE_CODE_USE_VERTEX', + 'ANTHROPIC_VERTEX_PROJECT_ID', + 'CLOUD_ML_REGION', + 'GOOGLE_CLOUD_PROJECT' +]) + +const RESERVED_NAMES: ReadonlySet = new Set([ + 'PATH', + 'HOME', + 'USER', + 'LOGNAME', + 'SHELL', + 'PWD', + 'OLDPWD', + 'LANG', + 'TERM', + 'TMPDIR', + 'IFS', + 'ENV', + 'BASH_ENV', + 'PS4', + 'PROMPT_COMMAND', + 'SHELLOPTS', + 'BASHOPTS', + 'NODE_OPTIONS', + 'NODE_PATH', + 'NODE_EXTRA_CA_CERTS', + 'NODE_TLS_REJECT_UNAUTHORIZED', + 'SSL_CERT_FILE', + 'SSL_CERT_DIR', + 'CURL_CA_BUNDLE', + 'REQUESTS_CA_BUNDLE', + 'HTTP_PROXY', + 'HTTPS_PROXY', + 'ALL_PROXY', + 'NO_PROXY', + 'PYTHONPATH', + 'PYTHONSTARTUP', + 'PERL5OPT', + 'RUBYOPT', + 'JAVA_TOOL_OPTIONS', + 'GIT_SSH', + 'GIT_SSH_COMMAND', + 'GIT_ASKPASS', + 'GIT_EXEC_PATH', + 'GIT_PROXY_COMMAND', + 'SSH_AUTH_SOCK', + 'GH_TOKEN', + 'GITHUB_TOKEN' +]) +const RESERVED_PREFIXES = ['LD_', 'DYLD_', 'ORCA_', 'VERCEL_', 'BASH_FUNC_', 'GIT_CONFIG'] +const BASE_URL_NAMES: ReadonlySet = new Set(['ANTHROPIC_BASE_URL', 'OPENAI_BASE_URL']) +const JWT_SHAPE = /^[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}$/ + +export function cloudSecretKind(name: string): CloudSecretKind { + return AGENT_KEY_NAMES.has(name) ? 'agent_key' : 'env' +} + +export function cloudSecretNameError(name: string): CloudSecretsErrorCode | null { + if (name.length === 0 || name.length > MAX_NAME_LENGTH || !NAME.test(name)) { + return 'invalid_name' + } + if (name === 'CLAUDE_CODE_OAUTH_TOKEN') { + return 'subscription_token_refused' + } + if (RESERVED_NAMES.has(name) || RESERVED_PREFIXES.some((prefix) => name.startsWith(prefix))) { + return 'reserved_name' + } + return null +} + +export function cloudSecretValueError(name: string, value: string): CloudSecretsErrorCode | null { + if (value.length === 0 || value.includes('\0')) { + return 'invalid_value' + } + if (new TextEncoder().encode(value).length > MAX_CLOUD_SECRET_VALUE_BYTES) { + return 'value_too_large' + } + const trimmed = value.trim() + if (trimmed.startsWith('sk-ant-oat')) { + return 'subscription_token_refused' + } + if ((name === 'ANTHROPIC_API_KEY' || name === 'OPENAI_API_KEY') && JWT_SHAPE.test(trimmed)) { + return 'subscription_token_refused' + } + if (BASE_URL_NAMES.has(name) && !isPublicHttpsUrl(trimmed)) { + return 'invalid_base_url' + } + return null +} + +/** https on a host that is not obviously private; the server re-checks with DNS-free rules of its own. */ +export function isPublicHttpsUrl(value: string): boolean { + let url: URL + try { + url = new URL(value) + } catch { + return false + } + if (url.protocol !== 'https:' || url.username || url.password) { + return false + } + const host = url.hostname.toLowerCase() + return ( + host.includes('.') && + host !== 'localhost' && + !host.endsWith('.local') && + !host.endsWith('.internal') && + !/^\d+\.\d+\.\d+\.\d+$/.test(host) && + !host.startsWith('[') + ) +} diff --git a/src/shared/cloud-secrets/cloud-secrets-wire.ts b/src/shared/cloud-secrets/cloud-secrets-wire.ts new file mode 100644 index 00000000000..bcc8482ef5c --- /dev/null +++ b/src/shared/cloud-secrets/cloud-secrets-wire.ts @@ -0,0 +1,126 @@ +// Wire shapes of the Orca Cloud secrets API (control plane /v1/secrets). Values only ever travel +// desktop -> control plane; no response carries one. +import { z } from 'zod' + +export const CLOUD_SECRET_SCOPES = ['personal', 'org', 'repo'] as const +export type CloudSecretScope = (typeof CLOUD_SECRET_SCOPES)[number] +export type CloudSecretKind = 'env' | 'agent_key' + +const Name = z.string().min(1).max(128) +const Scope = z.enum(CLOUD_SECRET_SCOPES) +const Kind = z.enum(['env', 'agent_key']) +const Hint = z.string().max(8).nullable() + +export const CloudSecretViewSchema = z.object({ + scope: Scope, + name: Name, + kind: Kind, + hint: Hint, + updatedAt: z.number().finite(), + updatedBy: z.string().max(256), + repo: z.string().max(256).optional() +}) +export type CloudSecretView = z.infer + +export const CloudSecretListResponseSchema = z.object({ + secrets: z.array(CloudSecretViewSchema).max(1000) +}) +export const CloudSecretPutResponseSchema = z.object({ secret: CloudSecretViewSchema }) +export const CloudSecretDeleteResponseSchema = z.object({ deleted: z.literal(true) }) + +const BatchError = z.object({ + name: z.string().max(256), + error: z.string().max(64), + reason: z.string().max(64).optional() +}) +export const CloudSecretBatchResponseSchema = z.object({ + secrets: z.array(CloudSecretViewSchema).max(200), + deleted: z.array(Name).max(200), + errors: z.array(BatchError).max(200) +}) +export type CloudSecretBatchResult = z.infer + +const OrgRole = z.enum(['owner', 'admin', 'member']).nullable() + +export const CloudSecretsEffectiveResponseSchema = z.object({ + caller: z.object({ + userId: z.string().max(256), + orgId: z.string().max(256).nullable(), + orgRole: OrgRole, + canWrite: z.object({ personal: z.boolean(), org: z.boolean(), repo: z.boolean() }) + }), + precedence: z.object({ env: z.array(Scope).max(3), agent_key: z.array(Scope).max(3) }), + secrets: z + .array( + z.object({ + name: Name, + kind: Kind, + scope: Scope, + hint: Hint, + updatedAt: z.number().finite(), + shadowed: z.array(z.object({ scope: Scope, hint: Hint })).max(3) + }) + ) + .max(1000) +}) +export type CloudSecretsEffective = z.infer +export type CloudSecretsCaller = CloudSecretsEffective['caller'] +export type CloudEffectiveSecret = CloudSecretsEffective['secrets'][number] + +export const CloudEnvRefreshResponseSchema = z.object({ applied: z.boolean() }) + +export const MAX_CLOUD_SECRET_BATCH = 100 + +/** Server reasons plus transport failures; the renderer turns these into copy. */ +export type CloudSecretsErrorCode = + | 'invalid_name' + | 'reserved_name' + | 'subscription_token_refused' + | 'value_too_large' + | 'invalid_value' + | 'repo_required' + | 'invalid_scope' + | 'invalid_base_url' + | 'org_admin_required' + | 'no_org' + | 'secret_limit' + | 'unauthorized' + | 'not_found' + | 'unavailable' + | 'timeout' + | 'not_configured' + | 'unknown' + +const SERVER_REASONS = [ + 'invalid_name', + 'reserved_name', + 'subscription_token_refused', + 'value_too_large', + 'invalid_value', + 'repo_required', + 'invalid_scope', + 'invalid_base_url', + 'org_admin_required', + 'no_org', + 'secret_limit' +] as const satisfies readonly CloudSecretsErrorCode[] + +function isServerReason(reason: string): reason is (typeof SERVER_REASONS)[number] { + return SERVER_REASONS.some((known) => known === reason) +} + +export function cloudSecretsErrorCode( + reason: string | null | undefined, + fallback: CloudSecretsErrorCode = 'unknown' +): CloudSecretsErrorCode { + return reason && isServerReason(reason) ? reason : fallback +} + +export type CloudSecretsResult = + | { ok: true; value: T } + | { ok: false; error: CloudSecretsErrorCode } + +/** Where a write lands; `repo` is `owner/name` or `github.com/owner/name`. */ +export type CloudSecretTarget = { scope: CloudSecretScope; repo?: string } + +export type CloudSecretsStatus = { available: boolean } diff --git a/src/shared/cloud-secrets/dotenv-paste.test.ts b/src/shared/cloud-secrets/dotenv-paste.test.ts new file mode 100644 index 00000000000..8c8dcbd8f23 --- /dev/null +++ b/src/shared/cloud-secrets/dotenv-paste.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { dotenvPasteToBatch, parseDotenvPaste } from './dotenv-paste' + +describe('parseDotenvPaste', () => { + it('reads comments, export, quotes, inline comments and multi-line values', () => { + const entries = parseDotenvPaste( + [ + '# database', + '', + 'export DATABASE_URL=postgres://db/app # local', + "SINGLE='a #literal $x'", + 'DOUBLE="line1\\nline2 \\"q\\""', + 'PEM="-----BEGIN KEY-----', + 'abc', + '-----END KEY-----"', + 'SPACED = padded value ' + ].join('\r\n') + ) + expect(entries.map(({ line, name, value, issue }) => ({ line, name, value, issue }))).toEqual([ + { line: 3, name: 'DATABASE_URL', value: 'postgres://db/app', issue: null }, + { line: 4, name: 'SINGLE', value: 'a #literal $x', issue: null }, + { line: 5, name: 'DOUBLE', value: 'line1\nline2 "q"', issue: null }, + { line: 6, name: 'PEM', value: '-----BEGIN KEY-----\nabc\n-----END KEY-----', issue: null }, + { line: 9, name: 'SPACED', value: 'padded value', issue: null } + ]) + }) + + it('flags bad lines per line and lets the last duplicate win', () => { + const entries = parseDotenvPaste( + [ + 'not an assignment', + 'lower=1', + 'PATH=/bin', + 'CLAUDE_CODE_OAUTH_TOKEN=x', + 'ANTHROPIC_API_KEY=sk-ant-oat01-abcdef', + 'ANTHROPIC_BASE_URL=http://localhost:8080', + 'EMPTY=', + 'TOKEN=first', + 'TOKEN=second', + 'OPEN="never closed' + ].join('\n') + ) + expect(entries.map((entry) => entry.issue)).toEqual([ + 'syntax', + 'invalid_name', + 'reserved_name', + 'subscription_token_refused', + 'subscription_token_refused', + 'invalid_base_url', + 'invalid_value', + 'overridden', + null, + 'unterminated_quote' + ]) + expect(entries.find((entry) => entry.name === 'ANTHROPIC_API_KEY')?.kind).toBe('agent_key') + expect(dotenvPasteToBatch(entries)).toEqual({ TOKEN: 'second' }) + }) +}) diff --git a/src/shared/cloud-secrets/dotenv-paste.ts b/src/shared/cloud-secrets/dotenv-paste.ts new file mode 100644 index 00000000000..22fbe0ecd9d --- /dev/null +++ b/src/shared/cloud-secrets/dotenv-paste.ts @@ -0,0 +1,109 @@ +// Parses pasted .env text into one entry per variable, each validated against the secret rules, so the +// preview can show what will be saved before anything leaves the desktop. +import { cloudSecretKind, cloudSecretNameError, cloudSecretValueError } from './cloud-secret-rules' +import type { CloudSecretKind, CloudSecretsErrorCode } from './cloud-secrets-wire' + +export type DotenvPasteIssue = + | CloudSecretsErrorCode + | 'syntax' + | 'unterminated_quote' + | 'overridden' + +export type DotenvPasteEntry = { + /** 1-based line where the entry starts. */ + line: number + /** Empty when the line could not be read as NAME=value. */ + name: string + value: string + kind: CloudSecretKind + issue: DotenvPasteIssue | null +} + +const ASSIGNMENT = /^\s*(?:export\s+)?([^=\s]*)\s*=\s?(.*)$/ + +function unescapeDoubleQuoted(body: string): string { + return body.replace(/\\([nrt"\\$])/g, (_match, char: string) => + char === 'n' ? '\n' : char === 'r' ? '\r' : char === 't' ? '\t' : char + ) +} + +/** Index of the closing quote in `text` from `start`, skipping backslash escapes for double quotes. */ +function closingQuote(text: string, start: number, quote: string): number { + for (let index = start; index < text.length; index++) { + if (quote === '"' && text[index] === '\\') { + index++ + continue + } + if (text[index] === quote) { + return index + } + } + return -1 +} + +export function parseDotenvPaste(text: string): DotenvPasteEntry[] { + const lines = text.replace(/\r\n?/g, '\n').split('\n') + const entries: DotenvPasteEntry[] = [] + for (let index = 0; index < lines.length; index++) { + const raw = lines[index] + const trimmed = raw.trim() + if (trimmed === '' || trimmed.startsWith('#')) { + continue + } + const lineNumber = index + 1 + const match = ASSIGNMENT.exec(raw) + if (!match) { + entries.push({ line: lineNumber, name: '', value: '', kind: 'env', issue: 'syntax' }) + continue + } + const name = match[1] + let rest = match[2].trimStart() + let value: string + let issue: DotenvPasteIssue | null = null + const quote = rest[0] === '"' || rest[0] === "'" ? rest[0] : null + if (quote) { + // Quoted values may run over several lines until the matching quote. + let close = closingQuote(rest, 1, quote) + while (close === -1 && index + 1 < lines.length) { + index++ + rest = `${rest}\n${lines[index]}` + close = closingQuote(rest, 1, quote) + } + if (close === -1) { + issue = 'unterminated_quote' + value = '' + } else { + const body = rest.slice(1, close) + value = quote === '"' ? unescapeDoubleQuoted(body) : body + } + } else { + // Unquoted: an inline comment starts at whitespace followed by #. + value = rest.replace(/\s+#.*$/, '').trim() + } + issue ??= cloudSecretNameError(name) ?? cloudSecretValueError(name, value) + entries.push({ line: lineNumber, name, value, kind: cloudSecretKind(name), issue }) + } + // Like a shell sourcing the file, the last assignment of a name wins. + const lastIndexByName = new Map() + entries.forEach((entry, position) => { + if (entry.name) { + lastIndexByName.set(entry.name, position) + } + }) + return entries.map((entry, position) => + entry.name && lastIndexByName.get(entry.name) !== position && entry.issue === null + ? { ...entry, issue: 'overridden' } + : entry + ) +} + +/** The NAME -> value map a batch save sends: only clean, final entries. */ +export function dotenvPasteToBatch(entries: readonly DotenvPasteEntry[]): Record { + const set: Record = {} + for (const entry of entries) { + if (entry.issue === null) { + set[entry.name] = entry.value + } + } + return set +}