From 2454d317790e80aad1b6ffa8355686b951f610e9 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 04:49:00 -0800 Subject: [PATCH 1/6] =?UTF-8?q?feat(cloud):=20agent-accounts=20W6=20UI=20s?= =?UTF-8?q?lices:=20panel=20Cloud=20sections=202=E2=80=933,=20S2=20build-s?= =?UTF-8?q?heet=20Secrets,=20S4-b=20secret=20request=20card?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Panel sections 2–3 on Orca Cloud (standalone, for gap-agents' AgentHostPanel shell): Cloud-secret key rows with scope chips and shadowed keys listed disabled with the reason; effective variables; missing names with inline Add / Add mine; asleep and shared (D13) states. - S2: missing = environment envVarNames − effective secrets for the repo and caller; Paste .env, per-name scope, batch save, build-time team-only note (Q7), post-open 'secrets missing' notice. - S4-b: card for orcad secrets.requested/requestSettled; Personal by default, Repository only if writable, agent scope as hint; N more requests; expired and saved-after-expiry; touchRequest at most once a minute; Restart {{agent}}; value saved via Cloud secrets PUT, then resolveRequest. - Main: cloudSecrets.workspaceSharing reads GET /v1/agent-sources (owner, sharedWorkspace). - Fixtures for every state, dark and light. --- .../cloud-secrets/cloud-secrets-client.ts | 22 +- src/main/cloud-secrets/cloud-secrets-ipc.ts | 14 +- src/preload/api/cloud-secrets-bridge.ts | 10 +- src/renderer/src/App.tsx | 2 + .../cloud-sections/CloudMissingSecretRow.tsx | 213 ++++++++++ .../CloudPanelSections.fixture.tsx | 190 +++++++++ .../cloud-sections/CloudPanelSections.tsx | 353 +++++++++++++++++ .../cloud-sections/PanelSection.tsx | 55 +++ .../cloud-panel-sections-model.test.ts | 114 ++++++ .../cloud-panel-sections-model.ts | 153 ++++++++ .../use-cloud-panel-sections.ts | 140 +++++++ .../agent-pane/AgentPanePortals.tsx | 17 + .../agent-pane/SecretRequestCard.fixture.tsx | 71 ++++ .../agent-pane/SecretRequestCard.tsx | 369 +++++++++++++++++ .../agent-pane/SecretRequestPanePortals.tsx | 227 +++++++++++ .../use-secret-request-card.test.tsx | 169 ++++++++ .../agent-pane/use-secret-request-card.ts | 284 ++++++++++++++ .../cloud-secrets-ask/CloudSecretsAskForm.tsx | 371 ++++++++++++++++++ .../CloudSecretsMissingNotice.fixture.tsx | 54 +++ .../CloudSecretsMissingNotice.tsx | 172 ++++++++ .../cloud-secrets-ask/VmBuildSecretsRow.tsx | 100 +++++ .../cloud-secrets-ask-model.test.ts | 122 ++++++ .../cloud-secrets-ask-model.ts | 153 ++++++++ .../use-cloud-secrets-ask-form.ts | 99 +++++ .../use-cloud-secrets-ask.ts | 146 +++++++ .../orca-cloud/CloudWorkspaceStatusPanel.tsx | 3 + .../cloud-secrets-fixture-data.ts | 1 + .../controller/TerminalPaneSurface.tsx | 4 +- .../VmBuildSheet.fixture.tsx | 48 +++ .../worktree-creation/VmBuildSheet.tsx | 9 + .../vm-build-secrets-fixture-data.tsx | 57 +++ .../vm-build-sheet-fixture-support.tsx | 6 +- src/renderer/src/i18n/locales/en.json | 109 +++++ .../cloud-secrets-missing-notice-store.ts | 133 +++++++ .../secret-request-event-guards.ts | 42 ++ .../secret-requests/secret-request-routing.ts | 80 ++++ .../secret-request-store.test.ts | 147 +++++++ .../secret-requests/secret-request-store.ts | 184 +++++++++ .../subscriptions/runtime-client-events.ts | 25 ++ .../cloud-secrets/cloud-secrets-wire.ts | 25 ++ 40 files changed, 4484 insertions(+), 9 deletions(-) create mode 100644 src/renderer/src/components/agent-host-panel/cloud-sections/CloudMissingSecretRow.tsx create mode 100644 src/renderer/src/components/agent-host-panel/cloud-sections/CloudPanelSections.fixture.tsx create mode 100644 src/renderer/src/components/agent-host-panel/cloud-sections/CloudPanelSections.tsx create mode 100644 src/renderer/src/components/agent-host-panel/cloud-sections/PanelSection.tsx create mode 100644 src/renderer/src/components/agent-host-panel/cloud-sections/cloud-panel-sections-model.test.ts create mode 100644 src/renderer/src/components/agent-host-panel/cloud-sections/cloud-panel-sections-model.ts create mode 100644 src/renderer/src/components/agent-host-panel/cloud-sections/use-cloud-panel-sections.ts create mode 100644 src/renderer/src/components/agent-pane/AgentPanePortals.tsx create mode 100644 src/renderer/src/components/agent-pane/SecretRequestCard.fixture.tsx create mode 100644 src/renderer/src/components/agent-pane/SecretRequestCard.tsx create mode 100644 src/renderer/src/components/agent-pane/SecretRequestPanePortals.tsx create mode 100644 src/renderer/src/components/agent-pane/use-secret-request-card.test.tsx create mode 100644 src/renderer/src/components/agent-pane/use-secret-request-card.ts create mode 100644 src/renderer/src/components/cloud-secrets-ask/CloudSecretsAskForm.tsx create mode 100644 src/renderer/src/components/cloud-secrets-ask/CloudSecretsMissingNotice.fixture.tsx create mode 100644 src/renderer/src/components/cloud-secrets-ask/CloudSecretsMissingNotice.tsx create mode 100644 src/renderer/src/components/cloud-secrets-ask/VmBuildSecretsRow.tsx create mode 100644 src/renderer/src/components/cloud-secrets-ask/cloud-secrets-ask-model.test.ts create mode 100644 src/renderer/src/components/cloud-secrets-ask/cloud-secrets-ask-model.ts create mode 100644 src/renderer/src/components/cloud-secrets-ask/use-cloud-secrets-ask-form.ts create mode 100644 src/renderer/src/components/cloud-secrets-ask/use-cloud-secrets-ask.ts create mode 100644 src/renderer/src/components/worktree-creation/vm-build-secrets-fixture-data.tsx create mode 100644 src/renderer/src/lib/orca-cloud/cloud-secrets-missing-notice-store.ts create mode 100644 src/renderer/src/lib/secret-requests/secret-request-event-guards.ts create mode 100644 src/renderer/src/lib/secret-requests/secret-request-routing.ts create mode 100644 src/renderer/src/lib/secret-requests/secret-request-store.test.ts create mode 100644 src/renderer/src/lib/secret-requests/secret-request-store.ts diff --git a/src/main/cloud-secrets/cloud-secrets-client.ts b/src/main/cloud-secrets/cloud-secrets-client.ts index 19f9aeb88bb..20df0fac1d4 100644 --- a/src/main/cloud-secrets/cloud-secrets-client.ts +++ b/src/main/cloud-secrets/cloud-secrets-client.ts @@ -1,6 +1,7 @@ // 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 { + CloudAgentSourcesResponseSchema, CloudEnvRefreshResponseSchema, CloudSecretBatchResponseSchema, CloudSecretDeleteResponseSchema, @@ -12,7 +13,8 @@ import { type CloudSecretsErrorCode, type CloudSecretTarget, type CloudSecretView, - type CloudSecretsEffective + type CloudSecretsEffective, + type CloudWorkspaceSharing } from '../../shared/cloud-secrets/cloud-secrets-wire' import { isEnvironmentProviderError } from '../environment-providers/environment-provider-driver' import { @@ -30,6 +32,8 @@ export type CloudSecretsClient = { ): Promise effective(repo?: string, signal?: AbortSignal): Promise refreshEnv(sandboxId: string): Promise + /** Who owns a sandbox and whether teammates share it (GET /v1/agent-sources?workspaceId=). */ + workspaceSharing(sandboxId: string): Promise } function withRepo(path: string, repo: string | undefined): string { @@ -94,7 +98,21 @@ export function createCloudSecretsClient( CloudEnvRefreshResponseSchema, { kind: 'control' } ) - ).applied + ).applied, + workspaceSharing: async (sandboxId) => { + const sources = await call( + 'GET', + `v1/agent-sources?${new URLSearchParams({ workspaceId: sandboxId })}`, + CloudAgentSourcesResponseSchema, + { kind: 'control' } + ) + const owner = sources.owner ?? null + return { + shared: sources.sharedWorkspace === true, + ownerName: owner && !owner.isCaller ? owner.displayName : null, + isOwner: owner === null || owner.isCaller + } + } } } diff --git a/src/main/cloud-secrets/cloud-secrets-ipc.ts b/src/main/cloud-secrets/cloud-secrets-ipc.ts index 502a3502990..6c13f5e87b1 100644 --- a/src/main/cloud-secrets/cloud-secrets-ipc.ts +++ b/src/main/cloud-secrets/cloud-secrets-ipc.ts @@ -68,7 +68,8 @@ const CHANNELS = [ 'cloudSecrets:batch', 'cloudSecrets:effective', 'cloudSecrets:refreshEnv', - 'cloudSecrets:saveDesktopKey' + 'cloudSecrets:saveDesktopKey', + 'cloudSecrets:workspaceSharing' ] as const async function attempt( @@ -179,6 +180,17 @@ export function registerCloudSecretsHandlers(userDataPath: string): void { } return attempt(client, (c) => c.put({ scope: 'personal' }, envName, value)) }) + // Reads the control plane only; never wakes the sandbox. + ipcMain.handle('cloudSecrets:workspaceSharing', (_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.workspaceSharing(sandboxId)) + : { ok: false, error: 'not_found' } + }) ipcMain.handle('cloudSecrets:refreshEnv', (_event, raw: unknown) => { const request = RefreshRequest.safeParse(raw) if (!request.success) { diff --git a/src/preload/api/cloud-secrets-bridge.ts b/src/preload/api/cloud-secrets-bridge.ts index a311162c9f2..8a0381587a2 100644 --- a/src/preload/api/cloud-secrets-bridge.ts +++ b/src/preload/api/cloud-secrets-bridge.ts @@ -5,7 +5,8 @@ import type { CloudSecretView, CloudSecretsEffective, CloudSecretsResult, - CloudSecretsStatus + CloudSecretsStatus, + CloudWorkspaceSharing } from '../../shared/cloud-secrets/cloud-secrets-wire' /** Desktop only. Values go in; only names, scopes and last-4 hints come back. */ @@ -29,6 +30,10 @@ export type CloudSecretsApi = { 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> + /** Who owns an Orca Cloud workspace and whether teammates share it (control plane only). */ + workspaceSharing: (request: { + runtimeEnvironmentId: string + }) => Promise> /** Saves one of this computer's Agent API Keys as a personal Cloud secret (the value never leaves main). */ saveDesktopKey: (request: { agent: string @@ -44,5 +49,6 @@ export const cloudSecretsApi: CloudSecretsApi = { batch: (request) => ipcRenderer.invoke('cloudSecrets:batch', request), effective: (request) => ipcRenderer.invoke('cloudSecrets:effective', request), refreshEnv: (request) => ipcRenderer.invoke('cloudSecrets:refreshEnv', request), - saveDesktopKey: (request) => ipcRenderer.invoke('cloudSecrets:saveDesktopKey', request) + saveDesktopKey: (request) => ipcRenderer.invoke('cloudSecrets:saveDesktopKey', request), + workspaceSharing: (request) => ipcRenderer.invoke('cloudSecrets:workspaceSharing', request) } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 51127280e98..110b29916fd 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -7,6 +7,7 @@ import { BrowserWebAuthnAccountDialog } from './components/browser-webauthn-acco import { BrowserExtensionInstallDialog } from './components/browser-extension-install-dialog' import { PlansDialogHost } from './components/billing/plans/PlansDialogHost' import { CloudMoveToComputerDialogMount } from './components/orca-cloud/CloudMoveToComputerDialogMount' +import { CloudSecretsAskDialogHost } from './components/cloud-secrets-ask/CloudSecretsMissingNotice' import { TeamDialogHost } from './components/billing/team/TeamDialogHost' import { BrowserPagePrompts } from './components/browser-pane/browser-page-prompts' import { LinkRoutingPreferenceDialogProvider } from './components/link-routing-preference-dialog' @@ -122,6 +123,7 @@ function App(): React.JSX.Element { + diff --git a/src/renderer/src/components/agent-host-panel/cloud-sections/CloudMissingSecretRow.tsx b/src/renderer/src/components/agent-host-panel/cloud-sections/CloudMissingSecretRow.tsx new file mode 100644 index 00000000000..9bfe27a149d --- /dev/null +++ b/src/renderer/src/components/agent-host-panel/cloud-sections/CloudMissingSecretRow.tsx @@ -0,0 +1,213 @@ +import React from 'react' +import { Loader2 } from 'lucide-react' +import { useTranslate } from '@/i18n/use-translate' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Label } from '@/components/ui/label' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select' +import { SecretValueInput } from '@/components/settings/cloud-secrets/SecretValueInput' +import { + cloudSecretScopeLabel, + cloudSecretsErrorCopy +} from '@/components/settings/cloud-secrets/cloud-secrets-copy' +import type { + CloudSecretScope, + CloudSecretsErrorCode +} from '../../../../../shared/cloud-secrets/cloud-secrets-wire' +import { parseScope, writableScopes } from '../../cloud-secrets-ask/cloud-secrets-ask-model' +import { + isGuestInSharedWorkspace, + type CloudPanelAddSecret, + type CloudPanelSectionsData +} from './use-cloud-panel-sections' + +/** A declared name nobody provides: Missing chip, then Add (members: Add mine) opens an inline field. */ +export function CloudMissingSecretRow({ + name, + data, + expanded, + onExpand, + onCollapse, + onAdd, + autoFocus +}: { + name: string + data: CloudPanelSectionsData + expanded: boolean + onExpand: () => void + onCollapse: () => void + onAdd: CloudPanelAddSecret + autoFocus: boolean +}): React.JSX.Element { + const t = useTranslate() + const id = React.useId() + const admin = data.effective.caller.canWrite.org + // A teammate's Personal values don't reach someone else's shared workspace. + const guest = isGuestInSharedWorkspace(data) + const scopes = writableScopes(data.effective.caller, { + repo: data.repoKey !== null, + shared: true + }).filter((option) => !guest || option !== 'personal') + const canAdd = scopes.length > 0 + const [value, setValue] = React.useState('') + const [scope, setScope] = React.useState(scopes[0] ?? 'personal') + const [saving, setSaving] = React.useState(false) + const [error, setError] = React.useState(null) + const addLabel = + admin || guest + ? t('agentHostPanel.env.add', 'Add') + : t('agentHostPanel.env.addMine', 'Add mine') + const save = async (): Promise => { + if (!value || saving) { + return + } + setSaving(true) + setError(null) + const refusal = await onAdd(name, value, scope) + setSaving(false) + if (refusal) { + setError(refusal) + return + } + setValue('') + onCollapse() + } + return ( +
  • +
    + {name} + + {t('agentHostPanel.env.missing', 'Missing')} + + {expanded ? null : !canAdd ? ( + + {t('agentHostPanel.keys.askAdmin', 'Ask an owner or admin to add one.')} + + ) : ( + + )} +
    + {expanded && canAdd ? ( +
    + +
    +
    { + if (event.key === 'Enter') { + event.preventDefault() + void save() + } else if (event.key === 'Escape') { + event.stopPropagation() + onCollapse() + } + }} + > + { + setValue(next) + setError(null) + }} + placeholder={t('agentHostPanel.env.valuePlaceholder', 'Value')} + invalid={error !== null} + disabled={saving} + autoFocus={autoFocus} + describedBy={error ? `${id}-error` : `${id}-help`} + /> +
    + {scopes.length > 1 || scopes[0] !== 'personal' ? ( + + ) : ( + + {cloudSecretScopeLabel('personal', t)} + + )} +
    + {error ? ( + + ) : null} +
    +

    + {admin || guest + ? t('cloudSecrets.editor.help', 'Write-only. You won’t see it again after saving.') + : t( + 'agentHostPanel.env.memberHelp', + 'Saved to your personal Cloud secrets, so only your workspaces get it.' + )} +

    +
    + + +
    +
    +
    + ) : null} +
  • + ) +} diff --git a/src/renderer/src/components/agent-host-panel/cloud-sections/CloudPanelSections.fixture.tsx b/src/renderer/src/components/agent-host-panel/cloud-sections/CloudPanelSections.fixture.tsx new file mode 100644 index 00000000000..e4bc62edefd --- /dev/null +++ b/src/renderer/src/components/agent-host-panel/cloud-sections/CloudPanelSections.fixture.tsx @@ -0,0 +1,190 @@ +import React from 'react' +import { defineFixture } from '../../../component-fixtures/component-fixture' +import type { + CloudSecretsCaller, + CloudSecretsEffective, + CloudWorkspaceSharing +} from '../../../../../shared/cloud-secrets/cloud-secrets-wire' +import { CloudPanelSections } from './CloudPanelSections' +import { cloudPanelSectionsData, type CloudPanelSectionsLoad } from './use-cloud-panel-sections' + +const UPDATED = 1_731_000_000_000 +const PRECEDENCE: CloudSecretsEffective['precedence'] = { + env: ['repo', 'personal', 'org'], + agent_key: ['org', 'repo', 'personal'] +} + +const ADMIN: CloudSecretsCaller = { + userId: 'u_ada', + orgId: 'org_acme', + orgRole: 'admin', + canWrite: { personal: true, org: true, repo: true } +} +const MEMBER: CloudSecretsCaller = { + userId: 'u_bo', + orgId: 'org_acme', + orgRole: 'member', + canWrite: { personal: true, org: false, repo: false } +} + +function effective(caller: CloudSecretsCaller): CloudSecretsEffective { + return { + caller, + precedence: PRECEDENCE, + secrets: [ + { + name: 'OPENAI_API_KEY', + kind: 'agent_key', + scope: 'org', + hint: 'M3kz', + updatedAt: UPDATED, + shadowed: [{ scope: 'personal', hint: '9f2c' }] + }, + { + name: 'ANTHROPIC_API_KEY', + kind: 'agent_key', + scope: 'repo', + hint: 'a71b', + updatedAt: UPDATED, + shadowed: [] + }, + { + name: 'DATABASE_URL', + kind: 'env', + scope: 'repo', + hint: null, + updatedAt: UPDATED, + shadowed: [{ scope: 'personal', hint: null }] + }, + { + name: 'NPM_TOKEN', + kind: 'env', + scope: 'personal', + hint: null, + updatedAt: UPDATED, + shadowed: [] + }, + { + name: 'SENTRY_DSN', + kind: 'env', + scope: 'org', + hint: null, + updatedAt: UPDATED, + shadowed: [] + } + ] + } +} + +function ready( + caller: CloudSecretsCaller, + options: { asleep?: boolean; sharing?: CloudWorkspaceSharing } = {} +): CloudPanelSectionsLoad { + return { + status: 'ready', + data: cloudPanelSectionsData({ + effective: effective(caller), + envVarNames: ['DATABASE_URL', 'NPM_TOKEN', 'SENTRY_DSN', 'STRIPE_TEST_KEY'], + repoKey: 'github.com/acme/web', + asleep: options.asleep ?? false, + sharing: options.sharing ?? null + }) + } +} + +const SHARED_GUEST: CloudWorkspaceSharing = { + shared: true, + ownerName: 'Ada Lovelace', + isOwner: false +} + +const noop = (): void => {} +const saved = async (): Promise => null + +/** Stands in for the panel shell (gap-agents' AgentHostPanel): 460 px, sections 2 and 3 only. */ +function Frame({ + load, + initialExpanded = null +}: { + load: CloudPanelSectionsLoad + initialExpanded?: string | null +}): React.JSX.Element { + return ( +
    +
    + +
    +
    + ) +} + +export const admin = defineFixture({ width: 520, render: () => }) + +export const adminAdding = defineFixture({ + width: 520, + render: () => +}) + +export const member = defineFixture({ width: 520, render: () => }) + +export const memberAdding = defineFixture({ + width: 520, + render: () => +}) + +export const asleep = defineFixture({ + width: 520, + render: () => +}) + +/** D13: a teammate in Ada's shared workspace. Their Personal values don't apply there. */ +export const shared = defineFixture({ + width: 520, + render: () => +}) + +export const sharedAsleep = defineFixture({ + width: 520, + render: () => +}) + +export const sharedAdmin = defineFixture({ + width: 520, + render: () => ( + + ) +}) + +export const loading = defineFixture({ + width: 520, + render: () => +}) + +export const failed = defineFixture({ + width: 520, + render: () => +}) + +export const empty = defineFixture({ + width: 520, + render: () => ( + + ) +}) diff --git a/src/renderer/src/components/agent-host-panel/cloud-sections/CloudPanelSections.tsx b/src/renderer/src/components/agent-host-panel/cloud-sections/CloudPanelSections.tsx new file mode 100644 index 00000000000..3e92b56fddf --- /dev/null +++ b/src/renderer/src/components/agent-host-panel/cloud-sections/CloudPanelSections.tsx @@ -0,0 +1,353 @@ +import React from 'react' +import { useTranslate, type Translate } from '@/i18n/use-translate' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { InlineError } from '@/components/ui/inline-error' +import { maskedHint } from '@/components/settings/cloud-secrets/CloudSecretList' +import { + cloudSecretScopeLabel, + cloudSecretsErrorCopy +} from '@/components/settings/cloud-secrets/cloud-secrets-copy' +import { cn } from '@/lib/utils' +import type { CloudSecretScope } from '../../../../../shared/cloud-secrets/cloud-secrets-wire' +import { CloudMissingSecretRow } from './CloudMissingSecretRow' +import { LoadingRows, PanelSection } from './PanelSection' +import { + cloudEnvRows, + cloudKeyRows, + type CloudKeyOwner, + type CloudKeyRow, + type ShadowedKey +} from './cloud-panel-sections-model' +import { + isGuestInSharedWorkspace, + type CloudPanelAddSecret, + type CloudPanelSectionsLoad +} from './use-cloud-panel-sections' + +const OWNER_LABEL: Record, string> = { + claude: 'Claude', + codex: 'Codex', + gemini: 'Gemini', + cursor: 'Cursor', + copilot: 'Copilot' +} + +function ownerLabel(owner: CloudKeyOwner, t: Translate): string { + switch (owner) { + case 'gateway': + return t('agentHostPanel.keys.gateway', 'AI Gateway') + case 'aws': + return 'AWS' + case 'other': + return t('agentHostPanel.keys.other', 'Other') + default: + return OWNER_LABEL[owner] + } +} + +function shadowedReason(shadowedBy: CloudSecretScope, t: Translate): string { + return shadowedBy === 'org' + ? t('cloudAgents.source.shadowedByTeam', 'Not used while the team key is set.') + : t('cloudAgents.source.shadowedByRepo', 'Not used while the repository key is set.') +} + +function ShadowedKeyLine({ name, entry }: { name: string; entry: ShadowedKey }): React.JSX.Element { + const t = useTranslate() + return ( +
  • + + {name} {maskedHint(entry.hint)} + + + {cloudSecretScopeLabel(entry.scope, t)} · {shadowedReason(entry.shadowedBy, t)} + +
  • + ) +} + +function KeyRow({ + row, + onEdit +}: { + row: CloudKeyRow + onEdit?: (row: CloudKeyRow) => void +}): React.JSX.Element { + const t = useTranslate() + const scope = cloudSecretScopeLabel(row.scope, t) + const detail = row.editable + ? row.shadowed.length > 0 + ? t('agentHostPanel.keys.overrides', '{{scope}} · overrides {{others}}', { + scope, + others: row.shadowed.map((entry) => cloudSecretScopeLabel(entry.scope, t)).join(', ') + }) + : scope + : t('agentHostPanel.keys.readOnly', '{{scope}} · only owners and admins can change it', { + scope + }) + return ( +
  • + {ownerLabel(row.owner, t)} +
    +
    + + {row.name} {maskedHint(row.hint)} + + {detail} +
    + {row.shadowed.length > 0 ? ( +
      + {row.shadowed.map((entry) => ( + + ))} +
    + ) : null} +
    + {row.editable && onEdit ? ( + + ) : ( + + )} +
  • + ) +} + +/** + * Panel section 2 on Orca Cloud: the agent keys a workspace gets from Cloud secrets, the winning + * scope first and any key it hides listed disabled with the reason. Desktop keys never appear here; + * the shell appends its "Use in Orca Cloud" offer through `extraRows`. + */ +export function CloudAgentKeysSection({ + load, + number, + onEdit, + onRetry, + extraRows +}: { + load: CloudPanelSectionsLoad + number?: React.ReactNode + onEdit?: (row: CloudKeyRow) => void + onRetry: () => void + extraRows?: React.ReactNode +}): React.JSX.Element { + const t = useTranslate() + const rows = load.status === 'ready' ? cloudKeyRows(load.data.effective) : [] + return ( + + {load.status === 'ready' && load.data.sharing?.shared ? ( + + ) : null} + {load.status === 'loading' ? ( + + ) : load.status === 'failed' ? ( + + ) : rows.length === 0 && !extraRows ? ( +

    + {t('agentHostPanel.keys.empty', 'No agent keys in Cloud secrets.')}{' '} + {load.data.effective.caller.canWrite.org + ? null + : t('agentHostPanel.keys.askAdmin', 'Ask an owner or admin to add one.')} +

    + ) : ( +
      + {rows.map((row) => ( + + ))} + {extraRows} +
    + )} +
    + ) +} + +/** + * Panel section 3 on Orca Cloud: the variables the workspace gets (winning scope and what it + * overrides), then the environment's declared names nobody provides, each with an inline Add + * (members: Add mine, Personal only). + */ +export function CloudEnvironmentPanelSection({ + load, + number, + onRetry, + onAdd, + focusSecret, + initialExpanded = null +}: { + load: CloudPanelSectionsLoad + number?: React.ReactNode + onRetry: () => void + onAdd: CloudPanelAddSecret + /** Deep link `{ secret: NAME }`: that missing row opens with its field focused. */ + focusSecret?: string + /** Fixtures only: a row shown already expanded. */ + initialExpanded?: string | null +}): React.JSX.Element { + const t = useTranslate() + const [expanded, setExpanded] = React.useState(focusSecret ?? initialExpanded) + const data = load.status === 'ready' ? load.data : null + const rows = data ? cloudEnvRows(data.effective, data.missing) : [] + const missingCount = data?.missing.length ?? 0 + return ( + + {t('agentHostPanel.asleepFooter', 'Changes reach the sandbox when it wakes.')} +

    + ) : null + } + > + {load.status === 'loading' ? ( + + ) : load.status === 'failed' ? ( + + ) : rows.length === 0 ? ( +

    + {t('agentHostPanel.env.empty', 'No variables from Cloud secrets yet.')} +

    + ) : ( + <> + + {missingCount > 0 + ? t('cloudSecretsAsk.missing', '{{count}} secrets missing', { + count: missingCount, + defaultValue_one: '{{count}} secret missing' + }) + : ''} + +
      + {rows.map((row) => + row.kind === 'set' ? ( +
    • + {row.name} + + {cloudSecretScopeLabel(row.scope, t)} + + {row.overrides.length > 0 ? ( + + {t('agentHostPanel.env.overrides', 'overrides {{scopes}}', { + scopes: row.overrides + .map((scope) => cloudSecretScopeLabel(scope, t)) + .join(', ') + })} + + ) : null} +
    • + ) : data ? ( + setExpanded(row.name)} + onCollapse={() => setExpanded(null)} + onAdd={onAdd} + autoFocus={expanded === row.name && focusSecret === row.name} + /> + ) : null + )} +
    + + )} +
    + ) +} + +/** Both Cloud sections, as the panel shell mounts them under section 1. */ +export function CloudPanelSections({ + load, + onRetry, + onAdd, + onEdit, + focusSecret, + keysExtraRows, + initialExpanded, + className +}: { + load: CloudPanelSectionsLoad + onRetry: () => void + onAdd: CloudPanelAddSecret + onEdit?: (row: CloudKeyRow) => void + focusSecret?: string + keysExtraRows?: React.ReactNode + initialExpanded?: string | null + className?: string +}): React.JSX.Element { + return ( +
    + + +
    + ) +} diff --git a/src/renderer/src/components/agent-host-panel/cloud-sections/PanelSection.tsx b/src/renderer/src/components/agent-host-panel/cloud-sections/PanelSection.tsx new file mode 100644 index 00000000000..e512ff8cac0 --- /dev/null +++ b/src/renderer/src/components/agent-host-panel/cloud-sections/PanelSection.tsx @@ -0,0 +1,55 @@ +import React from 'react' +import { useTranslate } from '@/i18n/use-translate' +import { Skeleton } from '@/components/ui/skeleton' + +/** The section frame the panel shell uses: numbered h3, help line, then rows. */ +export function PanelSection({ + number, + title, + help, + children, + footer +}: { + number?: React.ReactNode + title: string + help?: string + children: React.ReactNode + footer?: React.ReactNode +}): React.JSX.Element { + const id = React.useId() + return ( +
    +
    +

    + {number !== undefined ? ( + + {number} + + ) : null} + {title} +

    + {help ?

    {help}

    : null} +
    + {children} + {footer} +
    + ) +} + +export function LoadingRows(): React.JSX.Element { + const t = useTranslate() + return ( +
    + {[0, 1].map((row) => ( +
    + + + +
    + ))} + + {t('agentHostPanel.loadingSecrets', 'Loading Cloud secrets…')} + +
    + ) +} diff --git a/src/renderer/src/components/agent-host-panel/cloud-sections/cloud-panel-sections-model.test.ts b/src/renderer/src/components/agent-host-panel/cloud-sections/cloud-panel-sections-model.test.ts new file mode 100644 index 00000000000..325eeaca5b0 --- /dev/null +++ b/src/renderer/src/components/agent-host-panel/cloud-sections/cloud-panel-sections-model.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest' +import type { + CloudEffectiveSecret, + CloudSecretsCaller, + CloudSecretsEffective +} from '../../../../../shared/cloud-secrets/cloud-secrets-wire' +import { + cloudEnvRows, + cloudKeyOwner, + cloudKeyRows, + withoutCallerPersonal +} from './cloud-panel-sections-model' +import { cloudPanelSectionsData } from './use-cloud-panel-sections' + +const MEMBER: CloudSecretsCaller = { + userId: 'u', + orgId: 'o', + orgRole: 'member', + canWrite: { personal: true, org: false, repo: false } +} + +function secret(patch: Partial & { name: string }): CloudEffectiveSecret { + return { kind: 'env', scope: 'org', hint: null, updatedAt: 0, shadowed: [], ...patch } +} + +function effective(secrets: CloudEffectiveSecret[]): CloudSecretsEffective { + return { + caller: MEMBER, + precedence: { env: ['repo', 'personal', 'org'], agent_key: ['org', 'repo', 'personal'] }, + secrets + } +} + +describe('cloudKeyOwner', () => { + it('maps credential names to their agent', () => { + expect(cloudKeyOwner('ANTHROPIC_AUTH_TOKEN')).toBe('claude') + expect(cloudKeyOwner('OPENAI_BASE_URL')).toBe('codex') + expect(cloudKeyOwner('GEMINI_API_KEY')).toBe('gemini') + expect(cloudKeyOwner('AI_GATEWAY_API_KEY')).toBe('gateway') + expect(cloudKeyOwner('AWS_REGION')).toBe('aws') + }) +}) + +describe('cloudKeyRows', () => { + it('lists agent keys only, by agent, with the keys each one hides', () => { + const rows = cloudKeyRows( + effective([ + secret({ name: 'DATABASE_URL' }), + secret({ + name: 'OPENAI_API_KEY', + kind: 'agent_key', + scope: 'org', + hint: 'M3kz', + shadowed: [{ scope: 'personal', hint: '9f2c' }] + }), + secret({ name: 'ANTHROPIC_API_KEY', kind: 'agent_key', scope: 'personal', hint: 'a1' }) + ]) + ) + expect(rows.map((row) => row.name)).toEqual(['ANTHROPIC_API_KEY', 'OPENAI_API_KEY']) + // A member can change their own Personal key, never the team's. + expect(rows.map((row) => row.editable)).toEqual([true, false]) + expect(rows[1].shadowed).toEqual([{ scope: 'personal', hint: '9f2c', shadowedBy: 'org' }]) + }) +}) + +describe('cloudEnvRows', () => { + it('lists set variables with what they override, then missing names', () => { + const rows = cloudEnvRows( + effective([ + secret({ + name: 'DATABASE_URL', + scope: 'repo', + shadowed: [{ scope: 'personal', hint: null }] + }), + secret({ name: 'OPENAI_API_KEY', kind: 'agent_key' }) + ]), + ['STRIPE_KEY'] + ) + expect(rows).toEqual([ + { kind: 'set', name: 'DATABASE_URL', scope: 'repo', overrides: ['personal'] }, + { kind: 'missing', name: 'STRIPE_KEY' } + ]) + }) +}) + +describe('shared workspaces (D13)', () => { + const values = effective([ + secret({ name: 'NPM_TOKEN', scope: 'personal' }), + secret({ name: 'SENTRY_DSN', scope: 'personal', shadowed: [{ scope: 'org', hint: null }] }), + secret({ name: 'DATABASE_URL', scope: 'repo', shadowed: [{ scope: 'personal', hint: null }] }) + ]) + + it("drops a teammate's Personal values: the next scope wins or the name goes missing", () => { + expect(withoutCallerPersonal(values).secrets).toEqual([ + secret({ name: 'SENTRY_DSN', scope: 'org' }), + secret({ name: 'DATABASE_URL', scope: 'repo' }) + ]) + }) + + it('applies only to a teammate who is not the owner', () => { + const declared = ['NPM_TOKEN', 'SENTRY_DSN', 'DATABASE_URL'] + const base = { effective: values, envVarNames: declared, repoKey: null, asleep: false } + expect( + cloudPanelSectionsData({ + ...base, + sharing: { shared: true, ownerName: 'Ada', isOwner: false } + }).missing + ).toEqual(['NPM_TOKEN']) + expect( + cloudPanelSectionsData({ ...base, sharing: { shared: true, ownerName: null, isOwner: true } }) + .missing + ).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/agent-host-panel/cloud-sections/cloud-panel-sections-model.ts b/src/renderer/src/components/agent-host-panel/cloud-sections/cloud-panel-sections-model.ts new file mode 100644 index 00000000000..583102e0ce4 --- /dev/null +++ b/src/renderer/src/components/agent-host-panel/cloud-sections/cloud-panel-sections-model.ts @@ -0,0 +1,153 @@ +// Rows for the Orca Cloud panel's sections 2 (API keys from Cloud secrets) and 3 (Environment). +// Pure: names, scopes and hints only. +import { AGENT_CREDENTIAL_NAMES } from '../../../../../shared/agent-credentials/agent-credential-names' +import type { + CloudEffectiveSecret, + CloudSecretScope, + CloudSecretsCaller, + CloudSecretsEffective +} from '../../../../../shared/cloud-secrets/cloud-secrets-wire' +import { effectiveOfKind } from '../../cloud-secrets-ask/cloud-secrets-ask-model' + +/** Which agent a Cloud-secret key serves, for the row label. Shared names (gateway, AWS) say so. */ +export type CloudKeyOwner = + | 'claude' + | 'codex' + | 'gemini' + | 'cursor' + | 'copilot' + | 'gateway' + | 'aws' + | 'other' + +const OWNER_ORDER: readonly CloudKeyOwner[] = [ + 'claude', + 'codex', + 'gemini', + 'cursor', + 'copilot', + 'gateway', + 'aws', + 'other' +] + +export function cloudKeyOwner(name: string): CloudKeyOwner { + if (AGENT_CREDENTIAL_NAMES.claude.includes(name)) { + return 'claude' + } + if (AGENT_CREDENTIAL_NAMES.codex.includes(name)) { + return 'codex' + } + if (name.startsWith('GEMINI_') || name.startsWith('GOOGLE_')) { + return 'gemini' + } + if (name.startsWith('CURSOR_')) { + return 'cursor' + } + if (name.startsWith('COPILOT_')) { + return 'copilot' + } + if (name === 'AI_GATEWAY_API_KEY') { + return 'gateway' + } + if (name.startsWith('AWS_')) { + return 'aws' + } + return 'other' +} + +export type ShadowedKey = { + scope: CloudSecretScope + hint: string | null + /** Why it's not used: the winner's scope ('org' reads "team key", 'repo' "repository key"). */ + shadowedBy: CloudSecretScope +} + +export type CloudKeyRow = { + owner: CloudKeyOwner + name: string + scope: CloudSecretScope + hint: string | null + /** The caller may change the winning value (members can't touch Shared or Repository). */ + editable: boolean + shadowed: ShadowedKey[] +} + +export function canWriteScope( + caller: Pick, + scope: CloudSecretScope +): boolean { + return scope === 'org' + ? caller.canWrite.org + : scope === 'repo' + ? caller.canWrite.repo + : caller.canWrite.personal +} + +/** Section 2: one row per agent key name, the winner first, the keys it hides listed under it. */ +export function cloudKeyRows(effective: CloudSecretsEffective): CloudKeyRow[] { + return effectiveOfKind(effective, 'agent_key') + .map((secret) => ({ + owner: cloudKeyOwner(secret.name), + name: secret.name, + scope: secret.scope, + hint: secret.hint, + editable: canWriteScope(effective.caller, secret.scope), + shadowed: secret.shadowed.map((entry) => ({ + scope: entry.scope, + hint: entry.hint, + shadowedBy: secret.scope + })) + })) + .sort( + (a, b) => + OWNER_ORDER.indexOf(a.owner) - OWNER_ORDER.indexOf(b.owner) || a.name.localeCompare(b.name) + ) +} + +export type CloudEnvRow = + | { + kind: 'set' + name: string + scope: CloudSecretScope + /** Scopes this value overrides, in precedence order. */ + overrides: CloudSecretScope[] + } + | { kind: 'missing'; name: string } + +/** + * Section 3: variables the workspace gets (with the scope that wins), then the environment's declared + * names nobody provides. + */ +export function cloudEnvRows( + effective: CloudSecretsEffective, + missing: readonly string[] +): CloudEnvRow[] { + const set: CloudEnvRow[] = effectiveOfKind(effective, 'env').map( + (secret: CloudEffectiveSecret) => ({ + kind: 'set', + name: secret.name, + scope: secret.scope, + overrides: secret.shadowed.map((entry) => entry.scope) + }) + ) + return [...set, ...missing.map((name): CloudEnvRow => ({ kind: 'missing', name }))] +} + +/** + * What a teammate who doesn't own a shared workspace actually gets there: the owner's Personal values + * apply, not theirs, so the caller's Personal winners give way to the next scope (or drop out). + */ +export function withoutCallerPersonal(effective: CloudSecretsEffective): CloudSecretsEffective { + const secrets: CloudEffectiveSecret[] = [] + for (const secret of effective.secrets) { + const chain = [{ scope: secret.scope, hint: secret.hint }, ...secret.shadowed].filter( + (entry) => entry.scope !== 'personal' + ) + const [winner, ...rest] = chain + if (winner) { + secrets.push({ ...secret, scope: winner.scope, hint: winner.hint, shadowed: rest }) + } + } + return { ...effective, secrets } +} diff --git a/src/renderer/src/components/agent-host-panel/cloud-sections/use-cloud-panel-sections.ts b/src/renderer/src/components/agent-host-panel/cloud-sections/use-cloud-panel-sections.ts new file mode 100644 index 00000000000..e68650685a6 --- /dev/null +++ b/src/renderer/src/components/agent-host-panel/cloud-sections/use-cloud-panel-sections.ts @@ -0,0 +1,140 @@ +// Data for the Orca Cloud panel's sections 2–3. Both reads go to the control plane (effective Cloud +// secrets and the org environment list), never to the sandbox, so an asleep sandbox stays asleep. +import { useCallback, useEffect, useState } from 'react' +import type { + CloudSecretScope, + CloudSecretsEffective, + CloudSecretsErrorCode, + CloudWorkspaceSharing +} from '../../../../../shared/cloud-secrets/cloud-secrets-wire' +import { withoutCallerPersonal } from './cloud-panel-sections-model' +import { + environmentForRepo, + missingSecretNames +} from '../../cloud-secrets-ask/cloud-secrets-ask-model' + +export type CloudPanelSectionsData = { + effective: CloudSecretsEffective + /** The environment's declared names nobody provides (empty without an environment). */ + missing: string[] + /** `github.com/owner/name`, for Repository writes; null when the workspace repo isn't on GitHub. */ + repoKey: string | null + /** Asleep: show what the control plane knows and say changes land on wake. Never wake to check. */ + asleep: boolean + /** Shared with teammates (D13), and whose workspace it is; null when unknown. */ + sharing: CloudWorkspaceSharing | null +} + +/** A teammate's view of someone else's shared workspace: only their non-Personal values apply. */ +export function isGuestInSharedWorkspace(data: Pick): boolean { + return data.sharing !== null && data.sharing.shared && !data.sharing.isOwner +} + +export function cloudPanelSectionsData(args: { + effective: CloudSecretsEffective + envVarNames: readonly string[] | null + repoKey: string | null + asleep: boolean + sharing: CloudWorkspaceSharing | null +}): CloudPanelSectionsData { + const guest = isGuestInSharedWorkspace(args) + const effective = guest ? withoutCallerPersonal(args.effective) : args.effective + return { + effective, + missing: args.envVarNames ? missingSecretNames(args.envVarNames, effective) : [], + repoKey: args.repoKey, + asleep: args.asleep, + sharing: args.sharing + } +} + +export type CloudPanelSectionsLoad = + | { status: 'loading' } + | { status: 'failed'; error: CloudSecretsErrorCode } + | { status: 'ready'; data: CloudPanelSectionsData } + +/** Saves one value for a missing name; resolves to the refusal code, or null once saved. */ +export type CloudPanelAddSecret = ( + name: string, + value: string, + scope: CloudSecretScope +) => Promise + +export function useCloudPanelSections(args: { + repoKey: string | null + /** The sandbox's runtime environment, refreshed after a save while it's awake. */ + runtimeEnvironmentId: string | null + asleep: boolean +}): { load: CloudPanelSectionsLoad; reload: () => void; addSecret: CloudPanelAddSecret } { + const { repoKey, runtimeEnvironmentId, asleep } = args + const [load, setLoad] = useState({ status: 'loading' }) + const [generation, setGeneration] = useState(0) + + useEffect(() => { + const secrets = window.api.cloudSecrets + if (!secrets) { + setLoad({ status: 'failed', error: 'not_configured' }) + return + } + let cancelled = false + void (async () => { + const [effective, list, sharing] = await Promise.all([ + secrets.effective(repoKey ? { repo: repoKey } : {}).catch(() => null), + window.api.orcaCloudEnvironments?.list().catch(() => null) ?? Promise.resolve(null), + runtimeEnvironmentId + ? secrets.workspaceSharing({ runtimeEnvironmentId }).catch(() => null) + : Promise.resolve(null) + ]) + if (cancelled) { + return + } + if (!effective || !effective.ok) { + setLoad({ status: 'failed', error: effective?.ok === false ? effective.error : 'unknown' }) + return + } + const environment = + repoKey && list?.ok ? environmentForRepo(list.data.environments, repoKey) : null + setLoad({ + status: 'ready', + data: cloudPanelSectionsData({ + effective: effective.value, + envVarNames: environment?.envVarNames ?? null, + repoKey, + asleep, + sharing: sharing?.ok ? sharing.value : null + }) + }) + })() + return () => { + cancelled = true + } + }, [repoKey, asleep, runtimeEnvironmentId, generation]) + + const reload = useCallback(() => setGeneration((value) => value + 1), []) + + const addSecret = useCallback( + async (name, value, scope) => { + const api = window.api.cloudSecrets + if (!api) { + return 'not_configured' + } + if (scope === 'repo' && !repoKey) { + return 'repo_required' + } + const target = scope === 'repo' ? { scope, repo: repoKey ?? undefined } : { scope } + const result = await api.put({ target, name, value }).catch(() => null) + if (!result || !result.ok) { + return result?.ok === false ? result.error : 'unknown' + } + // An awake sandbox picks it up now; an asleep one gets it when it wakes. + if (!asleep && runtimeEnvironmentId) { + void api.refreshEnv({ runtimeEnvironmentId }).catch(() => {}) + } + setGeneration((current) => current + 1) + return null + }, + [repoKey, asleep, runtimeEnvironmentId] + ) + + return { load, reload, addSecret } +} diff --git a/src/renderer/src/components/agent-pane/AgentPanePortals.tsx b/src/renderer/src/components/agent-pane/AgentPanePortals.tsx new file mode 100644 index 00000000000..400d15a6089 --- /dev/null +++ b/src/renderer/src/components/agent-pane/AgentPanePortals.tsx @@ -0,0 +1,17 @@ +import { AgentSignInPanePortals } from '../agent-sign-in/AgentSignInPanePortals' +import type { TerminalPaneController } from '../terminal-pane/controller/use-terminal-pane-controller' +import { SecretRequestPanePortals } from './SecretRequestPanePortals' + +/** Cards an agent's host can raise over its pane: sign-in (#40228) and secret requests (S4-b). */ +export function AgentPanePortals({ + controller +}: { + controller: TerminalPaneController +}): React.JSX.Element { + return ( + <> + + + + ) +} diff --git a/src/renderer/src/components/agent-pane/SecretRequestCard.fixture.tsx b/src/renderer/src/components/agent-pane/SecretRequestCard.fixture.tsx new file mode 100644 index 00000000000..35921fa5d0c --- /dev/null +++ b/src/renderer/src/components/agent-pane/SecretRequestCard.fixture.tsx @@ -0,0 +1,71 @@ +import React from 'react' +import { defineFixture } from '../../component-fixtures/component-fixture' +import { + SecretRequestCard, + type SecretRequestCardActions, + type SecretRequestCardModel +} from './SecretRequestCard' + +const noop = (): void => {} +const ACTIONS: SecretRequestCardActions = { + onValueChange: noop, + onScopeChange: noop, + onAdd: noop, + onNotNow: noop, + onRetry: noop, + onRestart: noop, + onDismiss: noop +} + +const BASE: SecretRequestCardModel = { + agent: 'Claude', + name: 'NPM_TOKEN', + reason: 'Needed to install @acme packages from npm.acme.dev.', + suggestedRepo: false, + scopes: ['personal', 'repo'], + scope: 'personal', + value: '', + phase: 'open', + fieldError: null, + more: 0, + canRestart: true +} + +/** A terminal pane behind the card, as it sits over a Claude session. */ +function Pane({ model }: { model: Partial }): React.JSX.Element { + return ( +
    +
    +

    ● Installing private packages…

    +

    npm ERR! 401 Unauthorized - GET https://npm.acme.dev/@acme%2fui

    +

    ● I need an npm token for npm.acme.dev to continue.

    +
    + +
    +
    +
    + ) +} + +const frame = (model: Partial) => + defineFixture({ width: 640, render: () => }) + +export const open = frame({ value: 'npm_9f2cA81bX7' }) +export const openWithMore = frame({ more: 2 }) +export const repoHint = frame({ suggestedRepo: true }) +export const personalOnly = frame({ scopes: ['personal'] }) +export const unknownAgent = frame({ agent: null, canRestart: false }) +export const longReason = frame({ + reason: + 'The install step pulls @acme/ui, @acme/icons and @acme/tokens from the private registry at npm.acme.dev, which answers 401 without a token. I tried the public mirror first, but these packages are not published there, so I need NPM_TOKEN to finish setting up.' +}) +export const saving = frame({ value: 'npm_9f2cA81bX7', phase: 'saving' }) +export const refused = frame({ + value: 'x'.repeat(12), + fieldError: 'Values can be up to 8 KB.' +}) +export const failed = frame({ value: 'npm_9f2cA81bX7', phase: 'failed' }) +export const saved = frame({ phase: 'saved' }) +export const savedRepo = frame({ phase: 'saved', scope: 'repo' }) +export const savedAfterExpiry = frame({ phase: 'savedAfterExpiry' }) +export const expired = frame({ phase: 'expired' }) diff --git a/src/renderer/src/components/agent-pane/SecretRequestCard.tsx b/src/renderer/src/components/agent-pane/SecretRequestCard.tsx new file mode 100644 index 00000000000..625fc1bc97f --- /dev/null +++ b/src/renderer/src/components/agent-pane/SecretRequestCard.tsx @@ -0,0 +1,369 @@ +import React from 'react' +import { CircleCheck, KeyRound, Loader2, RotateCw } from 'lucide-react' +import { useTranslate, type Translate } from '@/i18n/use-translate' +import { Button } from '@/components/ui/button' +import { InlineError } from '@/components/ui/inline-error' +import { Label } from '@/components/ui/label' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select' +import { SecretValueInput } from '@/components/settings/cloud-secrets/SecretValueInput' +import { cloudSecretScopeLabel } from '@/components/settings/cloud-secrets/cloud-secrets-copy' +import { cn } from '@/lib/utils' +import type { SecretRequestScope } from '../../../../shared/secret-request-contract' + +export type SecretRequestCardPhase = + | 'open' + | 'saving' + /** The save didn't reach Orca Cloud; nothing changed. */ + | 'failed' + | 'saved' + | 'savedAfterExpiry' + | 'expired' + +export type SecretRequestCardModel = { + /** Display name ("Claude"), or null when orcad couldn't tell which agent asked. */ + agent: string | null + name: string + /** Untrusted agent text; rendered as plain text only. */ + reason: string | null + /** The agent suggested Repository. A hint only: it never preselects (D2). */ + suggestedRepo: boolean + /** Personal always; Repository only when the person can write it. Never Shared (D6). */ + scopes: readonly SecretRequestScope[] + scope: SecretRequestScope + value: string + phase: SecretRequestCardPhase + /** A refusal for this name or value (reserved name, too large…), shown under the field. */ + fieldError: string | null + /** Other requests waiting on this pane. */ + more: number + /** Restart is offered where Orca can restart that agent in the pane. */ + canRestart: boolean +} + +export type SecretRequestCardActions = { + onValueChange: (value: string) => void + onScopeChange: (scope: SecretRequestScope) => void + onAdd: () => void + onNotNow: () => void + onRetry: () => void + onRestart: () => void + onDismiss: () => void + /** Focus entered or left the card (the desktop keeps the request alive while it has focus). */ + onFocusChange?: (focused: boolean) => void +} + +const NAME_SLOT = '\u0000' + +/** The translated title with the variable name set in monospace. */ +function titleFor(agent: string | null, name: string, t: Translate): React.ReactNode { + const text = agent + ? t('secretRequest.title', '{{agent}} asked for {{name}}', { agent, name: NAME_SLOT }) + : t('secretRequest.titleUnknown', 'An agent asked for {{name}}', { name: NAME_SLOT }) + return text.split(NAME_SLOT).map((part, index) => ( + + {index > 0 ? {name} : null} + {part} + + )) +} + +/** Title text for aria-label and announcements. */ +export function secretRequestTitle(agent: string | null, name: string, t: Translate): string { + return agent + ? t('secretRequest.title', '{{agent}} asked for {{name}}', { agent, name }) + : t('secretRequest.titleUnknown', 'An agent asked for {{name}}', { name }) +} + +/** Plain text clamped to two lines, with Show more when it overflows. Never linkified. */ +function UntrustedReason({ reason, from }: { reason: string; from: string }): React.JSX.Element { + const t = useTranslate() + const ref = React.useRef(null) + const [expanded, setExpanded] = React.useState(false) + const [overflows, setOverflows] = React.useState(false) + React.useLayoutEffect(() => { + const element = ref.current + if (element && !expanded) { + setOverflows(element.scrollHeight > element.clientHeight + 1) + } + }, [reason, expanded]) + return ( +
    +
    + + {`“${reason}”`} + +
    +
    + {from} + {overflows || expanded ? ( + + ) : null} +
    +
    + ) +} + +/** + * S4-b: an agent in an Orca Cloud workspace asked for a secret. The value goes from this card to + * Orca Cloud only; the agent is told it's ready and never gets it from Orca. Non-modal: it never takes + * focus from the terminal. + */ +export function SecretRequestCard({ + model, + actions, + inputRef, + className +}: { + model: SecretRequestCardModel + actions: SecretRequestCardActions + /** Lets F6 move focus into the card. */ + inputRef?: React.Ref + className?: string +}): React.JSX.Element { + const t = useTranslate() + const id = React.useId() + const titleId = `${id}-title` + const helpId = `${id}-help` + const errorId = `${id}-error` + const agentName = model.agent ?? t('secretRequest.anAgent', 'an agent') + const done = model.phase === 'saved' || model.phase === 'savedAfterExpiry' + const saving = model.phase === 'saving' + const canSave = model.value.length > 0 && !saving + + const onKeyDown = (event: React.KeyboardEvent): void => { + if (event.key === 'Escape' && !done && model.phase !== 'expired') { + event.stopPropagation() + actions.onNotNow() + } + } + + return ( +
    actions.onFocusChange?.(true)} + onBlur={(event) => { + if (!event.currentTarget.contains(event.relatedTarget)) { + actions.onFocusChange?.(false) + } + }} + className={cn( + 'flex w-full flex-col gap-3 rounded-lg border border-border bg-popover p-4 font-sans text-sm text-popover-foreground shadow-lg motion-safe:animate-in motion-safe:fade-in-0', + className + )} + > + {done ? ( +
    + +
    +

    + {model.phase === 'savedAfterExpiry' + ? t( + 'secretRequest.savedAfterExpiry', + 'Saved. {{agent}} stopped waiting, so tell it the secret is ready.', + { agent: model.agent ?? t('secretRequest.theAgent', 'The agent') } + ) + : model.scope === 'repo' + ? t('secretRequest.doneRepo', '{{name}} added to this repository’s secrets.', { + name: model.name + }) + : t('secretRequest.done', '{{name}} added to your personal secrets.', { + name: model.name + })} +

    +

    + {model.phase === 'saved' + ? t( + 'secretRequest.doneDetail', + 'New shells get it now. {{agent}} was told it’s ready, and it never gets the value from Orca.', + { agent: model.agent ?? t('secretRequest.theAgent', 'The agent') } + ) + : t('secretRequest.doneDetailExpired', 'New shells get it now.')}{' '} + {model.canRestart + ? t( + 'secretRequest.restartDetail', + 'Restart {{agent}} to give its own shell the new value.', + { agent: agentName } + ) + : null} +

    +
    +
    + {model.canRestart ? ( + + ) : null} + +
    +
    + ) : ( + <> +
    + +
    +
    +

    + {titleFor(model.agent, model.name, t)} +

    + {model.more > 0 ? ( + + {t('secretRequest.more', '{{count}} more requests', { + count: model.more, + defaultValue_one: '{{count}} more request' + })} + + ) : null} +
    + {model.reason ? ( + + ) : null} +
    +
    + + {model.phase === 'expired' ? ( + + {t('secretRequest.dismiss', 'Dismiss')} + + } + /> + ) : ( + <> +
    + +
    +
    { + if (event.key === 'Enter' && canSave) { + event.preventDefault() + actions.onAdd() + } + }} + > + +
    + {model.scopes.length > 1 ? ( + + ) : ( + + {cloudSecretScopeLabel('personal', t)} + + )} +
    + {model.fieldError ? ( + + ) : null} +

    + {t( + 'secretRequest.help', + 'Saved to your Cloud secrets. Commands in this workspace can read it as ${{name}}.', + { name: model.name } + )} +

    + {model.suggestedRepo && model.scopes.includes('repo') ? ( +

    + {t('secretRequest.repoHint', '{{agent}} suggested Repository', { + agent: agentName + })} +

    + ) : null} +
    + {model.phase === 'failed' ? ( + + ) : null} +
    + + +
    + + )} + + )} +
    + ) +} diff --git a/src/renderer/src/components/agent-pane/SecretRequestPanePortals.tsx b/src/renderer/src/components/agent-pane/SecretRequestPanePortals.tsx new file mode 100644 index 00000000000..4807a44688f --- /dev/null +++ b/src/renderer/src/components/agent-pane/SecretRequestPanePortals.tsx @@ -0,0 +1,227 @@ +import React, { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react' +import { createPortal } from 'react-dom' +import { useAppStore } from '@/store' +import { useTranslate } from '@/i18n/use-translate' +import type { ManagedPane } from '@/lib/pane-manager/pane-manager-types' +import { useSecretRequests } from '@/lib/secret-requests/secret-request-store' +import { + routeSecretRequests, + secretRequestAgentName, + type SecretRequestPaneSlot +} from '@/lib/secret-requests/secret-request-routing' +import { makePaneKey } from '../../../../shared/workspace-layout/stable-pane-id' +import { parseRemoteRuntimePtyId } from '../../../../shared/remote-runtime/remote-runtime-pty-id' +import type { TerminalPaneController } from '../terminal-pane/controller/use-terminal-pane-controller' +import { SecretRequestCard, secretRequestTitle } from './SecretRequestCard' +import { useSecretRequestCard } from './use-secret-request-card' + +// Every mounted pane on a remote host, across tabs, so a request lands on exactly one of them. +const slots = new Map() +const slotListeners = new Set<() => void>() +let slotSnapshot: readonly SecretRequestPaneSlot[] = [] + +function publishSlots(): void { + slotSnapshot = [...slots.values()] + for (const listener of slotListeners) { + listener() + } +} + +function setSlot(slot: SecretRequestPaneSlot | null, paneKey: string): void { + const current = slots.get(paneKey) + if (slot === null) { + if (current) { + slots.delete(paneKey) + publishSlots() + } + return + } + if ( + current?.environmentId === slot.environmentId && + current.handle === slot.handle && + current.primary === slot.primary + ) { + return + } + slots.set(paneKey, slot) + publishSlots() +} + +function useSlots(): readonly SecretRequestPaneSlot[] { + return useSyncExternalStore( + (listener) => { + slotListeners.add(listener) + return () => slotListeners.delete(listener) + }, + () => slotSnapshot, + () => slotSnapshot + ) +} + +function SecretRequestPaneCard({ + pane, + paneKey, + ptyId, + worktreeId, + primary +}: { + pane: ManagedPane + paneKey: string + ptyId: string + worktreeId: string + primary: boolean +}): React.JSX.Element | null { + const t = useTranslate() + const requests = useSecretRequests() + const allSlots = useSlots() + const routed = useMemo(() => routeSecretRequests(requests, allSlots), [requests, allSlots]) + const mine = routed.get(paneKey) ?? [] + const entry = mine[0] + if (!entry) { + return null + } + return createPortal( + , + pane.container, + `secret-request-${pane.id}` + ) +} + +function SecretRequestPaneCardBody({ + entry, + more, + ptyId, + worktreeId, + primary, + announcement +}: { + entry: Parameters[0]['entry'] + more: number + ptyId: string + worktreeId: string + primary: boolean + announcement: string +}): React.JSX.Element { + const { model, actions } = useSecretRequestCard({ entry, more, worktreeId, ptyId }) + const fieldRef = useRef(null) + // Filled after mount so screen readers hear it once; a live region's initial text isn't read. + const [spoken, setSpoken] = useState('') + const open = model.phase === 'open' + useEffect(() => { + if (!open) { + setSpoken('') + return + } + const timer = window.setTimeout(() => setSpoken(announcement), 150) + return () => window.clearTimeout(timer) + }, [open, announcement]) + // F6 reaches the card from the terminal; the card itself never takes focus when it appears. + useEffect(() => { + if (!primary) { + return + } + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key !== 'F6' || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) { + return + } + const field = fieldRef.current?.querySelector('input, textarea') + const target = field ?? fieldRef.current + if (target) { + event.preventDefault() + event.stopPropagation() + target.focus() + } + } + window.addEventListener('keydown', onKeyDown, true) + return () => window.removeEventListener('keydown', onKeyDown, true) + }, [primary]) + return ( +
    + + {spoken} + + +
    + ) +} + +function SecretRequestPaneSlotTracker({ + pane, + tabId, + primary, + worktreeId +}: { + pane: ManagedPane + tabId: string + primary: boolean + worktreeId: string +}): React.JSX.Element | null { + const paneKey = makePaneKey(tabId, pane.leafId) + const ptyId = useAppStore( + (state) => state.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId?.[pane.leafId] ?? null + ) + const remote = ptyId ? parseRemoteRuntimePtyId(ptyId) : null + const environmentId = remote?.environmentId ?? null + const handle = remote?.handle ?? null + useEffect(() => { + if (!environmentId) { + setSlot(null, paneKey) + return + } + setSlot({ paneKey, environmentId, handle, primary }, paneKey) + return () => setSlot(null, paneKey) + }, [paneKey, environmentId, handle, primary]) + if (!environmentId || !ptyId) { + return null + } + return ( + + ) +} + +/** S4-b: a pane whose agent asked for a secret shows the request card over its top edge. */ +export function SecretRequestPanePortals({ + controller +}: { + controller: TerminalPaneController +}): React.JSX.Element { + const { managedPanes, tabId, activePane, isActive, worktreeId } = controller + return ( + <> + {managedPanes.map((pane) => ( + + ))} + + ) +} diff --git a/src/renderer/src/components/agent-pane/use-secret-request-card.test.tsx b/src/renderer/src/components/agent-pane/use-secret-request-card.test.tsx new file mode 100644 index 00000000000..fce1b87098c --- /dev/null +++ b/src/renderer/src/components/agent-pane/use-secret-request-card.test.tsx @@ -0,0 +1,169 @@ +// @vitest-environment happy-dom +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SecretRequestedEvent } from '../../../../shared/secret-request-contract' +import { + applySecretRequested, + readSecretRequests, + resetSecretRequestStoreForTests, + useSecretRequests +} from '@/lib/secret-requests/secret-request-store' +import { SecretRequestCard } from './SecretRequestCard' +import { + resetSecretRequestCardCachesForTests, + SECRET_REQUEST_TOUCH_INTERVAL_MS, + useSecretRequestCard +} from './use-secret-request-card' + +const rpc = vi.hoisted(() => vi.fn()) +vi.mock('@/runtime/rpc/calls/runtime-rpc-call', () => ({ callRuntimeRpc: rpc })) + +const NOW = 1_700_000_000_000 +const put = vi.fn() +const refreshEnv = vi.fn() +const effective = vi.fn() + +function event(patch: Partial = {}): SecretRequestedEvent { + return { + type: 'secrets.requested', + requestId: 'sr_aaaaaaaa', + name: 'NPM_TOKEN', + reason: 'See here', + scope: 'repo', + agent: 'claude', + worktreeId: null, + terminalHandle: 'h1', + requestedAt: NOW, + expiresAt: NOW + 15 * 60_000, + ...patch + } +} + +function Harness(): React.JSX.Element | null { + const entry = useSecretRequests()[0] + return entry ? : null +} + +function Card({ entry }: { entry: ReturnType[number] }) { + const { model, actions } = useSecretRequestCard({ entry, more: 0, worktreeId: null, ptyId: 'p' }) + return +} + +function valueInput(): HTMLInputElement { + return screen.getByLabelText('Value for NPM_TOKEN') +} + +describe('S4-b secret request card', () => { + beforeEach(() => { + vi.useFakeTimers({ + now: NOW, + toFake: ['Date', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] + }) + put.mockResolvedValue({ ok: true, value: {} }) + refreshEnv.mockResolvedValue({ ok: true, value: true }) + effective.mockResolvedValue({ ok: false, error: 'unavailable' }) + rpc.mockImplementation(async (_target, method: string) => + method === 'secrets.touchRequest' + ? { expiresAt: Date.now() + 15 * 60_000 } + : { outcome: 'provided' } + ) + Object.assign(window, { api: { cloudSecrets: { put, refreshEnv, effective } } }) + applySecretRequested('env-1', event()) + }) + afterEach(() => { + cleanup() + vi.useRealTimers() + vi.clearAllMocks() + resetSecretRequestStoreForTests() + resetSecretRequestCardCachesForTests() + }) + + it('shows the reason as plain text and never preselects the agent’s Repository hint', () => { + render() + expect(document.querySelector('a')).toBeNull() + expect(screen.getByText(/See { + render() + fireEvent.change(valueInput(), { target: { value: 'npm_secret_1' } }) + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Add secret' })) + }) + expect(put).toHaveBeenCalledWith({ + target: { scope: 'personal' }, + name: 'NPM_TOKEN', + value: 'npm_secret_1' + }) + const resolve = rpc.mock.calls.find(([, method]) => method === 'secrets.resolveRequest') + expect(resolve?.[2]).toEqual({ requestId: 'sr_aaaaaaaa', outcome: 'provided' }) + expect(JSON.stringify(rpc.mock.calls)).not.toContain('npm_secret_1') + expect(screen.getByText('NPM_TOKEN added to your personal secrets.')).toBeTruthy() + expect(screen.getByRole('button', { name: /Restart Claude/ })).toBeTruthy() + }) + + it('says the agent stopped waiting when the request expired mid-save', async () => { + rpc.mockImplementation(async (_target, method: string) => + method === 'secrets.resolveRequest' ? { outcome: 'expired' } : { expiresAt: NOW } + ) + render() + fireEvent.change(valueInput(), { target: { value: 'v' } }) + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Add secret' })) + }) + expect(screen.getByText(/Claude stopped waiting, so tell it the secret is ready/)).toBeTruthy() + }) + + it('keeps the value and offers Try again when Orca Cloud is unreachable', async () => { + put.mockResolvedValue({ ok: false, error: 'unavailable' }) + render() + fireEvent.change(valueInput(), { target: { value: 'v' } }) + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Add secret' })) + }) + expect(screen.getByText('Couldn’t reach Orca Cloud.')).toBeTruthy() + expect(valueInput().value).toBe('v') + expect(rpc.mock.calls.some(([, method]) => method === 'secrets.resolveRequest')).toBe(false) + }) + + it('touches the request at most once a minute while a value is typed', async () => { + render() + fireEvent.change(valueInput(), { target: { value: 'v' } }) + await act(async () => { + vi.advanceTimersByTime(0) + }) + fireEvent.focus(valueInput()) + fireEvent.blur(valueInput()) + fireEvent.focus(valueInput()) + await act(async () => { + vi.advanceTimersByTime(SECRET_REQUEST_TOUCH_INTERVAL_MS * 2 + 1_000) + }) + const touches = rpc.mock.calls.filter(([, method]) => method === 'secrets.touchRequest') + expect(touches).toHaveLength(3) + }) + + it('expires on its own clock and clears what was typed', async () => { + render() + fireEvent.change(valueInput(), { target: { value: 'typed' } }) + // Fails the touch so the expiry stays put. + rpc.mockRejectedValue(new Error('offline')) + await act(async () => { + vi.advanceTimersByTime(15 * 60_000 + 1) + }) + expect(screen.getByText('This request expired.')).toBeTruthy() + expect(screen.queryByLabelText('Value for NPM_TOKEN')).toBeNull() + }) + + it('declines on Escape', async () => { + render() + await act(async () => { + fireEvent.keyDown(valueInput(), { key: 'Escape' }) + }) + expect(readSecretRequests()).toEqual([]) + const resolve = rpc.mock.calls.find(([, method]) => method === 'secrets.resolveRequest') + expect(resolve?.[2]).toEqual({ requestId: 'sr_aaaaaaaa', outcome: 'declined' }) + }) +}) diff --git a/src/renderer/src/components/agent-pane/use-secret-request-card.ts b/src/renderer/src/components/agent-pane/use-secret-request-card.ts new file mode 100644 index 00000000000..28b65f7c42d --- /dev/null +++ b/src/renderer/src/components/agent-pane/use-secret-request-card.ts @@ -0,0 +1,284 @@ +// Drives one S4-b card: the typed value lives only here, is sent once to Orca Cloud (PUT +// /v1/secrets/:scope/:NAME through main), then orcad is told the outcome. The value never goes to orcad. +import { useCallback, useEffect, useRef, useState } from 'react' +import { useTranslate } from '@/i18n/use-translate' +import { useAppStore } from '@/store' +import { callRuntimeRpc } from '@/runtime/rpc/calls/runtime-rpc-call' +import { cloudSecretsErrorCopy } from '@/components/settings/cloud-secrets/cloud-secrets-copy' +import { cloudSecretRepoKey } from '@/components/settings/cloud-secrets/cloud-secret-repos' +import { + dismissSecretRequest, + setSecretRequestExpiry, + setSecretRequestLocal, + type SecretRequestEntry +} from '@/lib/secret-requests/secret-request-store' +import { + restartableSecretRequestAgent, + secretRequestAgentName +} from '@/lib/secret-requests/secret-request-routing' +import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' +import { + SECRETS_RESOLVE_REQUEST_METHOD, + SECRETS_TOUCH_REQUEST_METHOD, + type SecretRequestScope, + type SecretsResolveRequestResult, + type SecretsTouchRequestResult +} from '../../../../shared/secret-request-contract' +import type { CloudSecretsErrorCode } from '../../../../shared/cloud-secrets/cloud-secrets-wire' +import type { + SecretRequestCardActions, + SecretRequestCardModel, + SecretRequestCardPhase +} from './SecretRequestCard' + +/** D3: while the card has focus or a typed value, keep the request alive, at most once a minute. */ +export const SECRET_REQUEST_TOUCH_INTERVAL_MS = 60_000 +const RPC_TIMEOUT_MS = 15_000 + +/** Failures that mean the request never reached Orca Cloud: nothing changed, so retry is safe. */ +const TRANSPORT_FAILURES: ReadonlySet = new Set([ + 'unavailable', + 'timeout', + 'unknown' +]) + +/** Whether this person can write the workspace repo's scope; asked once per repo per session. */ +const repoWriteCache = new Map>() + +function canWriteRepo(repoKey: string): Promise { + let pending = repoWriteCache.get(repoKey) + if (!pending) { + const api = window.api.cloudSecrets + pending = api + ? api.effective({ repo: repoKey }).then( + (result) => result.ok && result.value.caller.canWrite.repo, + () => false + ) + : Promise.resolve(false) + // A failed read is retried next time rather than cached as "no". + void pending.then((value) => { + if (!value) { + repoWriteCache.delete(repoKey) + } + }) + repoWriteCache.set(repoKey, pending) + } + return pending +} + +export function resetSecretRequestCardCachesForTests(): void { + repoWriteCache.clear() +} + +function useWorkspaceRepoKey(worktreeId: string | null): string | null { + return useAppStore((state) => { + if (!worktreeId) { + return null + } + const repoId = getRepoIdFromWorktreeId(worktreeId) + const repo = state.repos.find((entry) => entry.id === repoId) + return repo ? cloudSecretRepoKey(repo) : null + }) +} + +async function resolveRequest( + environmentId: string, + requestId: string, + outcome: 'provided' | 'declined' +): Promise { + try { + const result = await callRuntimeRpc( + { kind: 'environment', environmentId }, + SECRETS_RESOLVE_REQUEST_METHOD, + { requestId, outcome }, + { timeoutMs: RPC_TIMEOUT_MS } + ) + return result.outcome + } catch { + return null + } +} + +export function useSecretRequestCard(args: { + entry: SecretRequestEntry + more: number + worktreeId: string | null + /** The pane's PTY, for Restart {{agent}}. */ + ptyId: string | null +}): { model: SecretRequestCardModel; actions: SecretRequestCardActions } { + const { entry, more, worktreeId, ptyId } = args + const { environmentId, event } = entry + const { requestId } = event + const t = useTranslate() + const [value, setValue] = useState('') + const [scope, setScope] = useState('personal') + const [failed, setFailed] = useState(false) + const [fieldError, setFieldError] = useState(null) + const [focused, setFocused] = useState(false) + const [repoWritable, setRepoWritable] = useState(false) + const repoKey = useWorkspaceRepoKey(event.worktreeId ?? worktreeId) + const lastTouch = useRef(0) + const local = entry.local + + useEffect(() => { + let cancelled = false + if (repoKey) { + void canWriteRepo(repoKey).then((writable) => { + if (!cancelled) { + setRepoWritable(writable) + } + }) + } + return () => { + cancelled = true + } + }, [repoKey]) + + // The card's own clock: an open request past its expiry shows as expired and drops what was typed. + useEffect(() => { + if (local !== null) { + return + } + const remaining = event.expiresAt - Date.now() + const expire = (): void => { + setValue('') + setSecretRequestLocal(environmentId, requestId, 'expired') + } + if (remaining <= 0) { + expire() + return + } + const timer = window.setTimeout(expire, remaining) + return () => window.clearTimeout(timer) + }, [environmentId, requestId, event.expiresAt, local]) + + useEffect(() => { + if (local === 'expired') { + setValue('') + } + }, [local]) + + // D3 hold: touch now (if a minute has passed) and every minute while focused or holding a value. + const holding = (focused || value.length > 0) && local === null + useEffect(() => { + if (!holding) { + return + } + const touch = (): void => { + lastTouch.current = Date.now() + void callRuntimeRpc( + { kind: 'environment', environmentId }, + SECRETS_TOUCH_REQUEST_METHOD, + { requestId }, + { timeoutMs: RPC_TIMEOUT_MS } + ).then( + (result) => setSecretRequestExpiry(environmentId, requestId, result.expiresAt), + () => {} + ) + } + // Never more than once a minute, across focus changes too. + const delay = Math.max(0, lastTouch.current + SECRET_REQUEST_TOUCH_INTERVAL_MS - Date.now()) + let interval: number | undefined + const first = window.setTimeout(() => { + touch() + interval = window.setInterval(touch, SECRET_REQUEST_TOUCH_INTERVAL_MS) + }, delay) + return () => { + window.clearTimeout(first) + if (interval !== undefined) { + window.clearInterval(interval) + } + } + }, [holding, environmentId, requestId]) + + const scopes: SecretRequestScope[] = repoKey && repoWritable ? ['personal', 'repo'] : ['personal'] + const effectiveScope: SecretRequestScope = scopes.includes(scope) ? scope : 'personal' + + const add = useCallback(async () => { + const api = window.api.cloudSecrets + if (!value || local !== null) { + return + } + setFailed(false) + setFieldError(null) + if (!api) { + setFailed(true) + return + } + setSecretRequestLocal(environmentId, requestId, 'saving') + const target = + effectiveScope === 'repo' && repoKey + ? { scope: 'repo' as const, repo: repoKey } + : { scope: 'personal' as const } + const result = await api.put({ target, name: event.name, value }).catch(() => null) + if (!result || !result.ok) { + const code: CloudSecretsErrorCode = result?.ok === false ? result.error : 'unknown' + setSecretRequestLocal(environmentId, requestId, null) + if (TRANSPORT_FAILURES.has(code)) { + setFailed(true) + } else { + setFieldError(cloudSecretsErrorCopy(code, t)) + } + return + } + // Saved: the value is gone from the renderer from here on. + setValue('') + void api.refreshEnv({ runtimeEnvironmentId: environmentId }).catch(() => {}) + const outcome = await resolveRequest(environmentId, requestId, 'provided') + setSecretRequestLocal( + environmentId, + requestId, + outcome === 'expired' ? 'savedAfterExpiry' : 'saved' + ) + }, [value, local, environmentId, requestId, effectiveScope, repoKey, event.name, t]) + + const phase: SecretRequestCardPhase = + local === 'saving' + ? 'saving' + : local === 'saved' || local === 'savedAfterExpiry' || local === 'expired' + ? local + : failed + ? 'failed' + : 'open' + + const agent = secretRequestAgentName(event.agent) + const restartAgent = restartableSecretRequestAgent(event.agent) + + return { + model: { + agent, + name: event.name, + reason: event.reason ?? null, + suggestedRepo: event.scope === 'repo', + scopes, + scope: effectiveScope, + value, + phase, + fieldError, + more, + canRestart: restartAgent !== null && ptyId !== null + }, + actions: { + onValueChange: (next) => { + setValue(next) + setFieldError(null) + }, + onScopeChange: setScope, + onAdd: () => void add(), + onRetry: () => void add(), + onNotNow: () => { + setValue('') + dismissSecretRequest(environmentId, requestId) + void resolveRequest(environmentId, requestId, 'declined') + }, + onRestart: () => { + if (restartAgent && ptyId) { + useAppStore.getState().queuePaneAccountRestarts([ptyId], restartAgent) + } + dismissSecretRequest(environmentId, requestId) + }, + onDismiss: () => dismissSecretRequest(environmentId, requestId), + onFocusChange: setFocused + } + } +} diff --git a/src/renderer/src/components/cloud-secrets-ask/CloudSecretsAskForm.tsx b/src/renderer/src/components/cloud-secrets-ask/CloudSecretsAskForm.tsx new file mode 100644 index 00000000000..a46bc8f28b3 --- /dev/null +++ b/src/renderer/src/components/cloud-secrets-ask/CloudSecretsAskForm.tsx @@ -0,0 +1,371 @@ +import React from 'react' +import { CircleCheck, ClipboardPaste, KeyRound, Loader2 } from 'lucide-react' +import { useTranslate, type Translate } from '@/i18n/use-translate' +import { getIntlLocale } from '@/i18n/i18n' +import { Button } from '@/components/ui/button' +import { InlineError } from '@/components/ui/inline-error' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select' +import { SecretValueInput } from '@/components/settings/cloud-secrets/SecretValueInput' +import { + cloudSecretScopeLabel, + cloudSecretsErrorCopy +} from '@/components/settings/cloud-secrets/cloud-secrets-copy' +import { cn } from '@/lib/utils' +import type { + CloudSecretScope, + CloudSecretsErrorCode +} from '../../../../shared/cloud-secrets/cloud-secrets-wire' +import { + isTeamAdmin, + parseScope, + writableScopes, + type CloudSecretsAskData +} from './cloud-secrets-ask-model' + +export type CloudSecretsAskPhase = 'editing' | 'saving' | 'failed' | 'saved' | 'skipped' + +export type CloudSecretsAskFormState = { + /** Typed values, by name. Cleared (and the inputs unmounted) once saved. */ + values: Record + scopes: Record + phase: CloudSecretsAskPhase + /** Server refusals for single names. */ + refused: Record + pasteOpen: boolean + pasteSummary: { matched: number; count: number; ignored: number } | null +} + +export const EMPTY_ASK_STATE: CloudSecretsAskFormState = { + values: {}, + scopes: {}, + phase: 'editing', + refused: {}, + pasteOpen: false, + pasteSummary: null +} + +export type CloudSecretsAskFormActions = { + onValueChange: (name: string, value: string) => void + onScopeChange: (name: string, scope: CloudSecretScope) => void + onTogglePaste: () => void + onPaste: (text: string) => void + onSave: () => void + onSkip: () => void +} + +/** "A, B and C", in the UI language. */ +function listNames(names: readonly string[]): string { + try { + return new Intl.ListFormat(getIntlLocale(), { style: 'long', type: 'conjunction' }).format( + names + ) + } catch { + return names.join(', ') + } +} + +function coverageLine(data: CloudSecretsAskData, t: Translate): string { + return t( + 'cloudSecretsAsk.coverage', + 'The {{environment}} environment uses {{total}}. Your team and this repository provide {{covered}}.', + { + environment: data.environmentName, + total: data.total, + covered: data.total - data.missing.length + } + ) +} + +/** "2 secrets missing" heading plus its coverage line (shared by the sheet and the dialog). */ +export function CloudSecretsAskHeading({ + data, + headingId, + action +}: { + data: CloudSecretsAskData + headingId: string + action?: React.ReactNode +}): React.JSX.Element { + const t = useTranslate() + return ( +
    + +
    +

    + {t('cloudSecretsAsk.missing', '{{count}} secrets missing', { + count: data.missing.length, + defaultValue_one: '{{count}} secret missing' + })} +

    +

    {coverageLine(data, t)}

    +
    + {action} +
    + ) +} + +/** + * S2: the environment's declared names this person doesn't get a value for, asked inline. Never + * blocks Open; values go to Orca Cloud in one batch and the inputs unmount after the save. + */ +export function CloudSecretsAskForm({ + data, + state, + actions, + className +}: { + data: CloudSecretsAskData + state: CloudSecretsAskFormState + actions: CloudSecretsAskFormActions + className?: string +}): React.JSX.Element | null { + const t = useTranslate() + const id = React.useId() + const headingId = `${id}-heading` + const admin = isTeamAdmin(data.caller) + const scopes = writableScopes(data.caller, { repo: data.repoKey !== null, shared: true }) + const filled = data.missing.filter((name) => (state.values[name] ?? '').length > 0) + const saving = state.phase === 'saving' + const canSave = filled.length > 0 && !saving + + if (state.phase === 'skipped') { + return ( +
    + +

    + {t('cloudSecretsAsk.skipped', 'Secrets skipped')} + {' · '} + {t('cloudSecretsAsk.skippedDetail', 'Add them later from the workspace’s cloud panel.')} +

    +
    + ) + } + + const savedLine = + state.phase === 'saved' ? ( +
    + +
    +

    + {t('cloudSecretsAsk.saved', 'Secrets saved')} +

    +

    + {t( + 'cloudSecretsAsk.savedDetail', + 'New terminals and agents in this workspace get them within a few seconds.' + )} +

    +
    +
    + ) : null + + // Everything saved: just the confirmation. + if (state.phase === 'saved' && data.missing.length === 0) { + return ( +
    + {savedLine} +
    + ) + } + + const lastName = data.missing.at(-1) + return ( +
    +
    + {savedLine} +
    + + + {t('cloudSecretsAsk.pasteEnv', 'Paste .env')} + + } + /> + {!admin && data.built ? ( +

    + {t( + 'cloudSecretsAsk.buildTeamOnly', + 'The environment build only uses team values. Ask an owner or admin to add {{name}} as a team secret.', + { name: listNames(data.missing) } + )} +

    + ) : null} + {state.pasteOpen ? ( +
    + +