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.
This commit is contained in:
Neil
2026-11-09 07:20:18 -08:00
parent e141a74ead
commit bb07c4bdd7
12 changed files with 879 additions and 2 deletions
@@ -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'
])
})
})
@@ -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<CloudSecretView[]>
put(target: CloudSecretTarget, name: string, value: string): Promise<CloudSecretView>
remove(target: CloudSecretTarget, name: string): Promise<void>
batch(
target: CloudSecretTarget,
change: { set?: Record<string, string>; delete?: string[] }
): Promise<CloudSecretBatchResult>
effective(repo?: string, signal?: AbortSignal): Promise<CloudSecretsEffective>
refreshEnv(sandboxId: string): Promise<boolean>
}
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'
}
}
+140
View File
@@ -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<T>(
client: CloudSecretsClient | null,
run: (client: CloudSecretsClient) => Promise<T>
): Promise<CloudSecretsResult<T>> {
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<CloudSecretsStatus> => ({
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' }
})
}
@@ -21,7 +21,7 @@ const DEFAULT_TIMEOUTS = { boot: 180_000, control: 60_000 }
const MAX_RESPONSE_BYTES = 256 * 1024
export type HostedSandboxRequest = <T>(
method: 'GET' | 'POST' | 'DELETE',
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
path: string,
schema: z.ZodType<T>,
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<T>(
method: 'GET' | 'POST' | 'DELETE',
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
path: string,
schema: z.ZodType<T>,
init: { body?: unknown; kind: 'boot' | 'control'; signal?: AbortSignal }
@@ -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,
+3
View File
@@ -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
+42
View File
@@ -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<CloudSecretsStatus>
list: (target: CloudSecretTarget) => Promise<CloudSecretsResult<CloudSecretView[]>>
put: (request: {
target: CloudSecretTarget
name: string
value: string
}) => Promise<CloudSecretsResult<CloudSecretView>>
remove: (request: {
target: CloudSecretTarget
name: string
}) => Promise<CloudSecretsResult<true>>
batch: (request: {
target: CloudSecretTarget
set?: Record<string, string>
delete?: string[]
}) => Promise<CloudSecretsResult<CloudSecretBatchResult>>
effective: (request: { repo?: string }) => Promise<CloudSecretsResult<CloudSecretsEffective>>
/** Best-effort nudge so a running workspace picks up new values now; the server also does it on write. */
refreshEnv: (request: { runtimeEnvironmentId: string }) => Promise<CloudSecretsResult<boolean>>
}
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)
}
+2
View File
@@ -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,
@@ -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<string> = 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<string> = 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<string> = 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('[')
)
}
@@ -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<typeof CloudSecretViewSchema>
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<typeof CloudSecretBatchResponseSchema>
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<typeof CloudSecretsEffectiveResponseSchema>
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<T> =
| { 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 }
@@ -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' })
})
})
+109
View File
@@ -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<string, number>()
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<string, string> {
const set: Record<string, string> = {}
for (const entry of entries) {
if (entry.issue === null) {
set[entry.name] = entry.value
}
}
return set
}