mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
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:
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user