Merge pull request #262 from nwparker/cloud/w6-spec-ui

feat(cloud): agent-accounts W6 UI: panel Cloud sections 2–3, S2 build-sheet Secrets, S4-b secret request card
This commit is contained in:
Neil
2026-11-10 06:38:12 -08:00
committed by GitHub
48 changed files with 5196 additions and 10 deletions
+1
View File
@@ -73,6 +73,7 @@
1 src/main/wsl/host-lifecycle/wsl-hosts-policy-live-update.ts
1 src/main/wsl/orcad/wsl-orcad-connect-stop-fence.ts
1 src/renderer/src/app-shell/use-app-session-persistence.ts
1 src/renderer/src/components/agent-pane/use-secret-request-card.ts
1 src/renderer/src/components/browser-pane/browser-client-page-position-driver.ts
1 src/renderer/src/components/contextual-tours/use-contextual-tour.ts
1 src/renderer/src/components/editor/setup-contextual-copy.ts
@@ -34,6 +34,29 @@ const VIEW = {
}
describe('createCloudSecretsClient', () => {
it('reads who owns a sandbox and whether it is shared, failing closed on an unknown owner', async () => {
const answers = [
{ owner: { userId: 'u2', displayName: 'Ada', isCaller: false }, sharedWorkspace: true },
{ owner: { userId: 'u1', displayName: 'Me', isCaller: true }, sharedWorkspace: false },
{ owner: null, sharedWorkspace: true },
{ owner: null }
]
const { client, seen } = fakeControlPlane(() => Response.json(answers.shift()))
expect(await client.workspaceSharing('sbx 1')).toEqual({
shared: true,
ownerName: 'Ada',
isOwner: false
})
expect(await client.workspaceSharing('sbx')).toEqual({
shared: false,
ownerName: null,
isOwner: true
})
expect((await client.workspaceSharing('sbx')).isOwner).toBe(false)
expect((await client.workspaceSharing('sbx')).isOwner).toBe(true)
expect(seen[0].url).toBe('https://control.test/v1/agent-sources?workspaceId=sbx+1')
})
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 })
+22 -2
View File
@@ -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<CloudSecretBatchResult>
effective(repo?: string, signal?: AbortSignal): Promise<CloudSecretsEffective>
refreshEnv(sandboxId: string): Promise<boolean>
/** Who owns a sandbox and whether teammates share it (GET /v1/agent-sources?workspaceId=). */
workspaceSharing(sandboxId: string): Promise<CloudWorkspaceSharing>
}
function withRepo(path: string, repo: string | undefined): string {
@@ -94,7 +98,23 @@ 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
const shared = sources.sharedWorkspace === true
return {
shared,
ownerName: owner && !owner.isCaller ? owner.displayName : null,
// Unknown owner: only an unshared workspace is safely the caller's own.
isOwner: owner ? owner.isCaller : !shared
}
}
}
}
+13 -1
View File
@@ -68,7 +68,8 @@ const CHANNELS = [
'cloudSecrets:batch',
'cloudSecrets:effective',
'cloudSecrets:refreshEnv',
'cloudSecrets:saveDesktopKey'
'cloudSecrets:saveDesktopKey',
'cloudSecrets:workspaceSharing'
] as const
async function attempt<T>(
@@ -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) {
+8 -2
View File
@@ -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<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>>
/** Who owns an Orca Cloud workspace and whether teammates share it (control plane only). */
workspaceSharing: (request: {
runtimeEnvironmentId: string
}) => Promise<CloudSecretsResult<CloudWorkspaceSharing>>
/** 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)
}
+4
View File
@@ -7,6 +7,8 @@ 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 { SecretRequestUnseenNotifier } from './components/agent-pane/SecretRequestUnseenNotifier'
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 +124,8 @@ function App(): React.JSX.Element {
<BrowserExtensionInstallDialog />
<PlansDialogHost />
<CloudMoveToComputerDialogMount />
<CloudSecretsAskDialogHost />
<SecretRequestUnseenNotifier />
<TeamDialogHost />
</LinkRoutingPreferenceDialogProvider>
</ConfirmationDialogProvider>
@@ -0,0 +1,212 @@
import React from 'react'
import { Loader2 } from 'lucide-react'
import { useTranslate } from '@/i18n/use-translate'
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<CloudSecretScope>(scopes[0] ?? 'personal')
const [saving, setSaving] = React.useState(false)
const [error, setError] = React.useState<CloudSecretsErrorCode | null>(null)
const addLabel =
admin || guest
? t('agentHostPanel.env.add', 'Add')
: t('agentHostPanel.env.addMine', 'Add mine')
const save = async (): Promise<void> => {
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 (
<li className="flex flex-col gap-2 py-2.5">
<div className="flex min-w-0 items-center gap-2">
<code className="truncate font-mono text-xs text-foreground">{name}</code>
<span className="inline-flex shrink-0 items-center rounded-full border border-status-warning/40 bg-status-warning-background px-2 py-0.5 text-xs font-medium text-status-warning-text">
{t('agentHostPanel.env.missing', 'Missing')}
</span>
{expanded ? null : !canAdd ? (
<span className="ms-auto text-end text-xs text-muted-foreground">
{data.sharing?.ownerName
? t('agentHostPanel.env.askOwner', 'Ask {{owner}}, an owner or an admin to add it.', {
owner: data.sharing.ownerName
})
: t('agentHostPanel.keys.askAdmin', 'Ask an owner or admin to add one.')}
</span>
) : (
<Button
type="button"
variant="outline"
size="xs"
className="ms-auto"
onClick={onExpand}
aria-label={`${addLabel} · ${name}`}
>
{addLabel}
</Button>
)}
</div>
{expanded && canAdd ? (
<div className="flex flex-col gap-2">
<Label htmlFor={`${id}-value`} className="sr-only">
{t('agentHostPanel.env.valueLabel', 'Value for {{name}}', { name })}
</Label>
<div className="flex items-start gap-2">
<div
className="min-w-0 flex-1"
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
void save()
} else if (event.key === 'Escape') {
event.stopPropagation()
onCollapse()
}
}}
>
<SecretValueInput
id={`${id}-value`}
value={value}
onChange={(next) => {
setValue(next)
setError(null)
}}
placeholder={t('agentHostPanel.env.valuePlaceholder', 'Value')}
invalid={error !== null}
disabled={saving}
autoFocus={autoFocus}
describedBy={error ? `${id}-error` : `${id}-help`}
/>
</div>
{scopes.length > 1 || scopes[0] !== 'personal' ? (
<Select
value={scope}
onValueChange={(next) => setScope(parseScope(next))}
disabled={saving}
>
<SelectTrigger
size="sm"
className="w-32 shrink-0"
aria-label={t('cloudSecretsAsk.scopeLabel', 'Scope for {{name}}', { name })}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{scopes.map((option) => (
<SelectItem key={option} value={option}>
{cloudSecretScopeLabel(option, t)}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<span className="inline-flex h-8 shrink-0 items-center rounded-full border border-border px-2.5 text-xs text-muted-foreground">
{cloudSecretScopeLabel('personal', t)}
</span>
)}
</div>
{error ? (
<p id={`${id}-error`} role="alert" className="text-xs text-destructive">
{error === 'org_admin_required'
? t(
'cloudSecretsAsk.memberRefused',
'Only owners and admins can save team or repository values. Choose Personal.'
)
: cloudSecretsErrorCopy(error, t)}
</p>
) : null}
<div className="flex flex-wrap items-center justify-between gap-2">
<p id={`${id}-help`} className="min-w-0 flex-1 text-xs text-muted-foreground">
{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.'
)}
</p>
<div className="flex shrink-0 items-center gap-2">
<Button
type="button"
variant="ghost"
size="xs"
onClick={onCollapse}
disabled={saving}
>
{t('agentHostPanel.env.cancel', 'Cancel')}
</Button>
<Button
type="button"
size="xs"
aria-disabled={!value || saving || undefined}
aria-busy={saving || undefined}
onClick={value && !saving ? () => void save() : undefined}
>
{saving ? <Loader2 aria-hidden className="motion-safe:animate-spin" /> : null}
{t('agentHostPanel.env.save', 'Save')}
</Button>
</div>
</div>
</div>
) : null}
</li>
)
}
@@ -0,0 +1,191 @@
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> => 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 (
<div className="bg-background p-4">
<div className="w-[460px] overflow-hidden rounded-xl border border-border bg-card">
<CloudPanelSections
load={load}
onRetry={noop}
onAdd={saved}
onEdit={noop}
onManage={noop}
initialExpanded={initialExpanded}
/>
</div>
</div>
)
}
export const admin = defineFixture({ width: 520, render: () => <Frame load={ready(ADMIN)} /> })
export const adminAdding = defineFixture({
width: 520,
render: () => <Frame load={ready(ADMIN)} initialExpanded="STRIPE_TEST_KEY" />
})
export const member = defineFixture({ width: 520, render: () => <Frame load={ready(MEMBER)} /> })
export const memberAdding = defineFixture({
width: 520,
render: () => <Frame load={ready(MEMBER)} initialExpanded="STRIPE_TEST_KEY" />
})
export const asleep = defineFixture({
width: 520,
render: () => <Frame load={ready(MEMBER, { asleep: true })} />
})
/** D13: a teammate in Ada's shared workspace. Their Personal values don't apply there. */
export const shared = defineFixture({
width: 520,
render: () => <Frame load={ready(MEMBER, { sharing: SHARED_GUEST })} />
})
export const sharedAsleep = defineFixture({
width: 520,
render: () => <Frame load={ready(MEMBER, { asleep: true, sharing: SHARED_GUEST })} />
})
export const sharedAdmin = defineFixture({
width: 520,
render: () => (
<Frame load={ready(ADMIN, { sharing: SHARED_GUEST })} initialExpanded="STRIPE_TEST_KEY" />
)
})
export const loading = defineFixture({
width: 520,
render: () => <Frame load={{ status: 'loading' }} />
})
export const failed = defineFixture({
width: 520,
render: () => <Frame load={{ status: 'failed', error: 'unavailable' }} />
})
export const empty = defineFixture({
width: 520,
render: () => (
<Frame
load={{
status: 'ready',
data: {
effective: { caller: MEMBER, precedence: PRECEDENCE, secrets: [] },
missing: [],
repoKey: 'github.com/acme/web',
asleep: false,
sharing: null
}
}}
/>
)
})
@@ -0,0 +1,387 @@
import React from 'react'
import { useTranslate, type Translate } from '@/i18n/use-translate'
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,
CloudSecretsErrorCode
} 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<Exclude<CloudKeyOwner, 'gateway' | 'aws' | 'other'>, 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')
case 'claude':
case 'codex':
case 'gemini':
case 'cursor':
case 'copilot':
return OWNER_LABEL[owner]
}
}
/** A read that failed: transport problems say where, without the save-oriented "Nothing was changed". */
export function cloudPanelLoadErrorTitle(code: CloudSecretsErrorCode, t: Translate): string {
return code === 'unavailable' || code === 'timeout' || code === 'unknown'
? t('agentHostPanel.unreachable', 'Couldn’t reach Orca Cloud.')
: cloudSecretsErrorCopy(code, t)
}
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 (
<li
aria-disabled="true"
className="flex min-w-0 flex-col gap-0.5 text-xs leading-5 text-muted-foreground/80"
>
<span className="truncate font-mono line-through decoration-muted-foreground/50">
{name} {maskedHint(entry.hint)}
</span>
<span>
{cloudSecretScopeLabel(entry.scope, t)} · {shadowedReason(entry.shadowedBy, t)}
</span>
</li>
)
}
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 (
<li className="grid grid-cols-[5rem_minmax(0,1fr)_auto] items-start gap-x-3 py-2.5">
<span className="pt-px text-sm text-foreground">{ownerLabel(row.owner, t)}</span>
<div className="flex min-w-0 flex-col gap-1.5">
<div className="flex min-w-0 flex-col gap-0.5">
<span className="truncate font-mono text-xs text-foreground">
{row.name} {maskedHint(row.hint)}
</span>
<span className="text-xs text-muted-foreground">{detail}</span>
</div>
{row.shadowed.length > 0 ? (
<ul
aria-label={t('agentHostPanel.keys.shadowedLabel', 'Keys not used for {{name}}', {
name: row.name
})}
className="flex flex-col gap-1 border-l border-border ps-2.5"
>
{row.shadowed.map((entry) => (
<ShadowedKeyLine key={entry.scope} name={row.name} entry={entry} />
))}
</ul>
) : null}
</div>
{row.editable && onEdit ? (
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => onEdit(row)}
aria-label={t('agentHostPanel.keys.editLabel', 'Edit {{name}}', { name: row.name })}
>
{t('agentHostPanel.keys.edit', 'Edit')}
</Button>
) : (
<span />
)}
</li>
)
}
/**
* 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,
onManage,
onRetry,
extraRows
}: {
load: CloudPanelSectionsLoad
number?: React.ReactNode
onEdit?: (row: CloudKeyRow) => void
/** Opens Cloud secrets (Settings), where keys are added. */
onManage?: () => void
onRetry: () => void
extraRows?: React.ReactNode
}): React.JSX.Element {
const t = useTranslate()
const rows = load.status === 'ready' ? cloudKeyRows(load.data.effective) : []
return (
<PanelSection
number={number}
title={t('agentHostPanel.section.keysCloud', 'API keys from Cloud secrets')}
help={t(
'agentHostPanel.help.cloudKeys',
'From your Orca account and Cloud secrets. Keys only on this computer aren’t sent to Orca Cloud.'
)}
>
{load.status === 'ready' && load.data.sharing?.shared ? (
<InlineError
layout="line"
tone="neutral"
title={t('agentSources.shared', 'Personal accounts aren’t used in shared workspaces.')}
/>
) : null}
{load.status === 'loading' ? (
<LoadingRows />
) : load.status === 'failed' ? (
<InlineError
density="compact"
title={cloudPanelLoadErrorTitle(load.error, t)}
retryLabel={t('agentHostPanel.retry', 'Try again')}
onRetry={onRetry}
/>
) : rows.length === 0 && !extraRows ? (
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">
{t('agentHostPanel.keys.empty', 'No agent keys in Cloud secrets.')}{' '}
{load.data.effective.caller.canWrite.org
? null
: t(
'agentHostPanel.keys.emptyMember',
'Add your own, or ask an owner or admin for a team key.'
)}
</p>
{onManage ? (
<Button type="button" variant="outline" size="xs" onClick={onManage}>
{t('agentHostPanel.keys.manage', 'Open Cloud secrets')}
</Button>
) : null}
</div>
) : (
<ul className="flex flex-col divide-y divide-border/60">
{rows.map((row) => (
<KeyRow key={row.name} row={row} onEdit={onEdit} />
))}
{extraRows}
</ul>
)}
</PanelSection>
)
}
/**
* 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,
showLoadError = true
}: {
load: CloudPanelSectionsLoad
number?: React.ReactNode
onRetry: () => void
onAdd: CloudPanelAddSecret
/** Off when section 2 already shows the shared read's error. */
showLoadError?: boolean
/** 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<string | null>(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 (
<PanelSection
number={number}
title={t('agentHostPanel.section.env', 'Environment')}
help={
data && isGuestInSharedWorkspace(data)
? `${t('agentHostPanel.help.cloudEnv', 'From Cloud secrets: Repository, then Personal, then Shared.')} ${
data.sharing?.ownerName
? t(
'agentHostPanel.env.guestOwner',
'In a shared workspace, Personal values come from its owner, {{owner}}, not from you.',
{ owner: data.sharing.ownerName }
)
: t(
'agentHostPanel.env.guest',
'In a shared workspace, Personal values come from its owner, not from you.'
)
}`
: t(
'agentHostPanel.help.cloudEnv',
'From Cloud secrets: Repository, then Personal, then Shared.'
)
}
footer={
data?.asleep ? (
<p className="text-xs text-muted-foreground">
{t('agentHostPanel.asleepFooter', 'Changes reach the sandbox when it wakes.')}
</p>
) : null
}
>
{load.status === 'loading' ? (
<LoadingRows />
) : load.status === 'failed' ? (
showLoadError ? (
<InlineError
density="compact"
title={cloudPanelLoadErrorTitle(load.error, t)}
retryLabel={t('agentHostPanel.retry', 'Try again')}
onRetry={onRetry}
/>
) : null
) : rows.length === 0 ? (
<p className="text-xs text-muted-foreground">
{t('agentHostPanel.env.empty', 'No variables from Cloud secrets yet.')}
</p>
) : (
<>
<span role="status" className="sr-only">
{missingCount > 0
? t('cloudSecretsAsk.missing', '{{count}} secrets missing', {
count: missingCount,
defaultValue_one: '{{count}} secret missing'
})
: ''}
</span>
<ul className="flex flex-col divide-y divide-border/60">
{rows.map((row) =>
row.kind === 'set' ? (
<li key={row.name} className="flex min-w-0 flex-wrap items-center gap-2 py-2.5">
<code className="truncate font-mono text-xs text-foreground">{row.name}</code>
<span className="inline-flex shrink-0 items-center rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground">
{cloudSecretScopeLabel(row.scope, t)}
</span>
{row.overrides.length > 0 ? (
<span className="text-xs text-muted-foreground">
{t('agentHostPanel.env.overrides', 'overrides {{scopes}}', {
scopes: row.overrides
.map((scope) => cloudSecretScopeLabel(scope, t))
.join(', ')
})}
</span>
) : null}
</li>
) : data ? (
<CloudMissingSecretRow
key={row.name}
name={row.name}
data={data}
expanded={expanded === row.name}
onExpand={() => setExpanded(row.name)}
onCollapse={() => setExpanded(null)}
onAdd={onAdd}
autoFocus={expanded === row.name && focusSecret === row.name}
/>
) : null
)}
</ul>
</>
)}
</PanelSection>
)
}
/** Both Cloud sections, as the panel shell mounts them under section 1. */
export function CloudPanelSections({
load,
onRetry,
onAdd,
onEdit,
onManage,
focusSecret,
keysExtraRows,
initialExpanded,
className
}: {
load: CloudPanelSectionsLoad
onRetry: () => void
onAdd: CloudPanelAddSecret
onEdit?: (row: CloudKeyRow) => void
onManage?: () => void
focusSecret?: string
keysExtraRows?: React.ReactNode
initialExpanded?: string | null
className?: string
}): React.JSX.Element {
return (
<div className={cn('flex flex-col divide-y divide-border', className)}>
<CloudAgentKeysSection
load={load}
onEdit={onEdit}
onManage={onManage}
onRetry={onRetry}
extraRows={keysExtraRows}
/>
<CloudEnvironmentPanelSection
load={load}
onRetry={onRetry}
onAdd={onAdd}
focusSecret={focusSecret}
initialExpanded={initialExpanded}
// One read feeds both sections, so section 2 alone shows (and announces) its error.
showLoadError={false}
/>
</div>
)
}
@@ -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 (
<section aria-labelledby={id} className="flex flex-col gap-3 px-5 py-4">
<div className="flex flex-col gap-1">
<h3 id={id} className="flex items-baseline gap-2 text-sm font-medium text-foreground">
{number !== undefined ? (
<span aria-hidden className="text-muted-foreground tabular-nums">
{number}
</span>
) : null}
{title}
</h3>
{help ? <p className="text-xs leading-5 text-muted-foreground">{help}</p> : null}
</div>
{children}
{footer}
</section>
)
}
export function LoadingRows(): React.JSX.Element {
const t = useTranslate()
return (
<div aria-busy="true" className="flex flex-col gap-3">
{[0, 1].map((row) => (
<div key={row} className="flex items-center gap-3">
<Skeleton shape="small" className="h-3 w-16" />
<Skeleton shape="small" className="h-3 w-44" />
<Skeleton shape="small" className="ms-auto h-3 w-10" />
</div>
))}
<span className="sr-only">
{t('agentHostPanel.loadingSecrets', 'Loading Cloud secrets…')}
</span>
</div>
)
}
@@ -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<CloudEffectiveSecret> & { 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([])
})
})
@@ -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<CloudSecretsCaller, 'canWrite'>,
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 }
}
@@ -0,0 +1,142 @@
// 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<CloudPanelSectionsData, 'sharing'>): 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
}
}
const NOT_CONFIGURED: CloudSecretsErrorCode = 'not_configured'
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<CloudSecretsErrorCode | null>
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<CloudPanelSectionsLoad>({ 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<CloudPanelAddSecret>(
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 }
}
@@ -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 (
<>
<AgentSignInPanePortals controller={controller} />
<SecretRequestPanePortals controller={controller} />
</>
)
}
@@ -0,0 +1,76 @@
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,
onToggleCollapsed: noop,
onDetails: 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,
collapsed: false
}
/** A terminal pane behind the card, as it sits over a Claude session. */
function Pane({ model }: { model: Partial<SecretRequestCardModel> }): React.JSX.Element {
return (
<div className="bg-background p-3">
<div className="relative h-[340px] overflow-hidden rounded-lg border border-border bg-[var(--terminal-background,#111)] p-3 font-mono text-xs leading-5 text-muted-foreground">
<p>● Installing private packages…</p>
<p>npm ERR! 401 Unauthorized - GET https://npm.acme.dev/@acme%2fui</p>
<p>● I need an npm token for npm.acme.dev to continue.</p>
<div className="absolute inset-x-3 top-3 flex justify-end">
<SecretRequestCard model={{ ...BASE, ...model }} actions={ACTIONS} className="max-w-md" />
</div>
</div>
</div>
)
}
const frame = (model: Partial<SecretRequestCardModel>) =>
defineFixture({ width: 640, render: () => <Pane model={model} /> })
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' })
export const collapsed = frame({ collapsed: true, more: 1 })
export const notOnAskingPane = frame({ phase: 'saved', canRestart: false })
@@ -0,0 +1,320 @@
import React from 'react'
import { ChevronDown, ChevronUp, KeyRound, Loader2 } from 'lucide-react'
import { useTranslate } from '@/i18n/use-translate'
import { Button } from '@/components/ui/button'
import { InlineError } from '@/components/ui/inline-error'
import { Label } from '@/components/ui/label'
import { SecretValueInput } from '@/components/settings/cloud-secrets/SecretValueInput'
import { cn } from '@/lib/utils'
import type { SecretRequestScope } from '../../../../shared/secret-request-contract'
import {
SecretRequestDone,
SecretRequestScopeControl,
SecretRequestTitle,
UntrustedReason
} from './SecretRequestCardParts'
export { secretRequestTitle } from './SecretRequestCardParts'
export type SecretRequestCardPhase =
| 'open'
| 'saving'
/** The save didn't reach Orca Cloud; nothing changed. */
| 'failed'
| 'saved'
/** Saved, but the agent wasn't told: the request expired first, or orcad didn't answer (D3). */
| '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 only on the asking agent's own pane. */
canRestart: boolean
/** Folded to one line so the output under it can be read, without declining. */
collapsed: boolean
}
export type SecretRequestCardActions = {
onValueChange: (value: string) => void
onScopeChange: (scope: SecretRequestScope) => void
onAdd: () => void
onNotNow: () => void
onRetry: () => void
onRestart: () => void
onDismiss: () => void
onToggleCollapsed: () => void
/** Opens the Agent accounts & secrets panel on this name; absent until the panel exists. */
onDetails?: () => void
/** Focus entered or left the card (the desktop keeps the request alive while it has focus). */
onFocusChange?: (focused: boolean) => void
}
/**
* 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 (F6 moves focus in and back out).
*/
export function SecretRequestCard({
model,
actions,
rootRef,
className
}: {
model: SecretRequestCardModel
actions: SecretRequestCardActions
rootRef?: React.Ref<HTMLDivElement>
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 ownRef = React.useRef<HTMLDivElement>(null)
const focusedWithin = React.useRef(false)
// When the form gives way to the result, focus that was in it lands on Dismiss (never Restart, so a
// second Enter can't relaunch the agent) instead of falling to the page.
React.useLayoutEffect(() => {
const root = ownRef.current
if (done && focusedWithin.current && root && !root.contains(document.activeElement)) {
root.querySelector<HTMLElement>('[data-secret-request-dismiss]')?.focus()
}
}, [done])
const setRefs = (node: HTMLDivElement | null): void => {
ownRef.current = node
if (typeof rootRef === 'function') {
rootRef(node)
} else if (rootRef) {
rootRef.current = node
}
}
const header = (
<div className="flex items-start gap-2.5">
<KeyRound aria-hidden className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<div className="flex min-w-0 flex-1 flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5">
<h3 id={titleId} className="min-w-0 font-medium break-words text-foreground">
<SecretRequestTitle agent={model.agent} name={model.name} />
</h3>
{model.more > 0 ? (
<span className="shrink-0 text-xs text-muted-foreground">
{t('secretRequest.more', '{{count}} more requests', {
count: model.more,
defaultValue_one: '{{count}} more request'
})}
</span>
) : null}
</div>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="-my-0.5 shrink-0"
aria-expanded={!model.collapsed}
aria-label={
model.collapsed
? t('secretRequest.expand', 'Show request')
: t('secretRequest.collapse', 'Hide request')
}
onClick={actions.onToggleCollapsed}
>
{model.collapsed ? <ChevronDown aria-hidden /> : <ChevronUp aria-hidden />}
</Button>
</div>
)
return (
<div
ref={setRefs}
role="dialog"
aria-modal="false"
aria-labelledby={titleId}
data-secret-request-card=""
onKeyDown={(event) => {
if (event.key === 'Escape' && !done && !saving && model.phase !== 'expired') {
event.stopPropagation()
actions.onNotNow()
}
}}
onFocus={() => {
focusedWithin.current = true
actions.onFocusChange?.(true)
}}
onBlur={(event) => {
const root = event.currentTarget
if (root.contains(event.relatedTarget)) {
return
}
// A control that unmounts (Add → result) blurs with nowhere to go; that isn't leaving.
window.setTimeout(() => {
if (!root.isConnected || root.contains(document.activeElement)) {
return
}
if (document.activeElement === document.body) {
return
}
focusedWithin.current = false
actions.onFocusChange?.(false)
}, 0)
}}
className={cn(
'flex w-full flex-col gap-3 rounded-lg border border-border bg-popover font-sans text-sm text-popover-foreground shadow-lg motion-safe:animate-in motion-safe:fade-in-0',
model.collapsed ? 'px-3 py-2' : 'p-4',
className
)}
>
{done ? (
<SecretRequestDone
agent={model.agent}
name={model.name}
scope={model.scope}
toldAgent={model.phase === 'saved'}
canRestart={model.canRestart}
titleId={titleId}
onRestart={actions.onRestart}
onDismiss={actions.onDismiss}
/>
) : model.collapsed ? (
header
) : (
<>
{header}
{model.reason ? (
<div className="ps-6.5">
<UntrustedReason
reason={model.reason}
from={t('secretRequest.from', 'from {{agent}}', { agent: agentName })}
/>
</div>
) : null}
{model.phase === 'expired' ? (
<InlineError
tone="neutral"
title={t('secretRequest.expired', 'This request expired.')}
detail={t('secretRequest.expiredDetail', '{{agent}} can ask again.', {
agent: model.agent ?? t('secretRequest.theAgent', 'The agent')
})}
action={
<Button type="button" variant="outline" size="xs" onClick={actions.onDismiss}>
{t('secretRequest.dismiss', 'Dismiss')}
</Button>
}
/>
) : (
<>
<div data-secret-request-field="" className="flex flex-col gap-1.5">
<Label htmlFor={`${id}-value`} className="sr-only">
{t('secretRequest.valueLabel', 'Value for {{name}}', { name: model.name })}
</Label>
<div className="flex items-start gap-2">
<div
className="min-w-0 flex-1"
onKeyDown={(event) => {
if (event.key === 'Enter' && canSave) {
event.preventDefault()
actions.onAdd()
}
}}
>
<SecretValueInput
id={`${id}-value`}
value={model.value}
onChange={actions.onValueChange}
placeholder={t('secretRequest.valuePlaceholder', 'Value')}
invalid={model.fieldError !== null}
disabled={saving}
describedBy={model.fieldError ? `${errorId} ${helpId}` : helpId}
/>
</div>
<SecretRequestScopeControl
name={model.name}
scopes={model.scopes}
scope={model.scope}
disabled={saving}
onChange={actions.onScopeChange}
/>
</div>
{model.fieldError ? (
<p id={errorId} role="alert" className="text-xs text-destructive">
{model.fieldError}
</p>
) : null}
<p id={helpId} className="text-xs leading-5 text-muted-foreground">
{t(
'secretRequest.helpBeforeSave',
'Saves to your Cloud secrets. Commands in this workspace can read it as ${{name}}.',
{ name: model.name }
)}
</p>
{model.suggestedRepo && model.scopes.includes('repo') ? (
<p className="text-xs text-muted-foreground">
{t('secretRequest.repoHint', '{{agent}} suggested Repository', {
agent: agentName
})}
</p>
) : null}
</div>
{model.phase === 'failed' ? (
<InlineError
title={t('secretRequest.unreachable', 'Couldn’t reach Orca Cloud.')}
detail={t('secretRequest.nothingChanged', 'Nothing was changed.')}
retryLabel={t('secretRequest.retry', 'Try again')}
onRetry={actions.onRetry}
density="compact"
/>
) : null}
<div className="flex items-center gap-2">
{actions.onDetails ? (
<Button type="button" variant="ghost" size="xs" onClick={actions.onDetails}>
{t('agentHostPanel.details', 'Details')}
</Button>
) : null}
<Button
type="button"
variant="ghost"
size="xs"
className="ms-auto"
aria-disabled={saving || undefined}
onClick={saving ? undefined : actions.onNotNow}
>
{t('secretRequest.notNow', 'Not now')}
</Button>
<Button
type="button"
size="xs"
aria-disabled={!canSave || undefined}
aria-busy={saving || undefined}
onClick={canSave ? actions.onAdd : undefined}
>
{saving ? <Loader2 aria-hidden className="motion-safe:animate-spin" /> : null}
{t('secretRequest.add', 'Add secret')}
</Button>
</div>
</>
)}
</>
)}
</div>
)
}
@@ -0,0 +1,216 @@
import React from 'react'
import { CircleCheck, RotateCw } from 'lucide-react'
import { useTranslate, type Translate } from '@/i18n/use-translate'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { cloudSecretScopeLabel } from '@/components/settings/cloud-secrets/cloud-secrets-copy'
import { cn } from '@/lib/utils'
import type { SecretRequestScope } from '../../../../shared/secret-request-contract'
const NAME_SLOT = '\u0000'
/** The translated title with the variable name set in monospace. */
export function SecretRequestTitle({
agent,
name
}: {
agent: string | null
name: string
}): React.JSX.Element {
const t = useTranslate()
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 })
const at = text.indexOf(NAME_SLOT)
if (at === -1) {
return <>{text}</>
}
return (
<>
{text.slice(0, at)}
<code className="font-mono text-[0.95em]">{name}</code>
{text.slice(at + NAME_SLOT.length)}
</>
)
}
/** 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. */
export function UntrustedReason({
reason,
from
}: {
reason: string
from: string
}): React.JSX.Element {
const t = useTranslate()
const ref = React.useRef<HTMLSpanElement>(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 (
<figure className="flex min-w-0 flex-col gap-0.5 border-l-2 border-border pl-2.5 text-xs leading-5 text-muted-foreground">
<blockquote className="min-w-0">
<span ref={ref} className={cn('break-words', !expanded && 'line-clamp-2')}>
{`“${reason}”`}
</span>
</blockquote>
<figcaption className="flex items-center gap-2">
<span>{from}</span>
{overflows || expanded ? (
<button
type="button"
className="rounded-sm text-foreground underline-offset-2 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus-ring"
aria-expanded={expanded}
onClick={() => setExpanded(!expanded)}
>
{expanded
? t('secretRequest.showLess', 'Show less')
: t('secretRequest.showMore', 'Show more')}
</button>
) : null}
</figcaption>
</figure>
)
}
export function SecretRequestScopeControl({
name,
scopes,
scope,
disabled,
onChange
}: {
name: string
scopes: readonly SecretRequestScope[]
scope: SecretRequestScope
disabled: boolean
onChange: (scope: SecretRequestScope) => void
}): React.JSX.Element {
const t = useTranslate()
if (scopes.length < 2) {
return (
<span className="inline-flex h-8 shrink-0 items-center rounded-full border border-border px-2.5 text-xs text-muted-foreground">
{cloudSecretScopeLabel('personal', t)}
</span>
)
}
return (
<Select
value={scope}
onValueChange={(next) => onChange(next === 'repo' ? 'repo' : 'personal')}
disabled={disabled}
>
<SelectTrigger
size="sm"
className="w-32 shrink-0"
aria-label={t('secretRequest.scopeLabel', 'Scope for {{name}}', { name })}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{scopes.map((option) => (
<SelectItem key={option} value={option}>
{cloudSecretScopeLabel(option, t)}
</SelectItem>
))}
</SelectContent>
</Select>
)
}
/** Saved: when the value reaches new shells, whether the agent was told, and Restart {{agent}} (Q5). */
export function SecretRequestDone({
agent,
name,
scope,
toldAgent,
canRestart,
titleId,
onRestart,
onDismiss
}: {
agent: string | null
name: string
scope: SecretRequestScope
/** False when the request expired first or orcad didn't answer: the agent wasn't told. */
toldAgent: boolean
canRestart: boolean
titleId: string
onRestart: () => void
onDismiss: () => void
}): React.JSX.Element {
const t = useTranslate()
const theAgent = agent ?? t('secretRequest.theAgent', 'The agent')
const agentName = agent ?? t('secretRequest.anAgent', 'an agent')
return (
<div className="flex items-start gap-3">
<CircleCheck aria-hidden className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<div role="status" className="min-w-0 flex-1 space-y-1">
<p id={titleId} className="font-medium text-foreground">
{!toldAgent
? t(
'secretRequest.savedAfterExpiry',
'Saved. {{agent}} stopped waiting, so tell it the secret is ready.',
{ agent: theAgent }
)
: scope === 'repo'
? t('secretRequest.doneRepo', '{{name}} added to this repository’s secrets.', {
name
})
: t('secretRequest.done', '{{name}} added to your personal secrets.', { name })}
</p>
<p className="text-xs leading-5 text-muted-foreground">
{toldAgent
? t(
'secretRequest.doneDetail',
'New shells get it now. {{agent}} was told it’s ready, and it never gets the value from Orca.',
{ agent: theAgent }
)
: t('secretRequest.doneDetailExpired', 'New shells get it now.')}{' '}
{canRestart
? t(
'secretRequest.restartDetail',
'Restart {{agent}} to give its own shell the new value.',
{ agent: agentName }
)
: null}
</p>
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
{canRestart ? (
<Button type="button" variant="outline" size="xs" onClick={onRestart}>
<RotateCw aria-hidden />
{t('secretRequest.restart', 'Restart {{agent}}', { agent: agentName })}
</Button>
) : null}
<Button
type="button"
variant="ghost"
size="xs"
data-secret-request-dismiss=""
onClick={onDismiss}
>
{t('secretRequest.dismiss', 'Dismiss')}
</Button>
</div>
</div>
)
}
@@ -0,0 +1,174 @@
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } 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,
type SecretRequestEntry
} from '@/lib/secret-requests/secret-request-store'
import {
routeSecretRequests,
secretRequestAgentName
} 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 {
registerSecretRequestCard,
setSecretRequestSlot,
useSecretRequestSlots
} from './secret-request-focus'
import { useSecretRequestCard } from './use-secret-request-card'
function SecretRequestPaneCardBody({
entry,
more,
pane,
paneKey,
ptyId,
worktreeId,
onAskingPane,
primary,
announcement
}: {
entry: SecretRequestEntry
more: number
pane: ManagedPane
paneKey: string
ptyId: string
worktreeId: string
onAskingPane: boolean
primary: boolean
announcement: string
}): React.JSX.Element {
const { model, actions } = useSecretRequestCard({ entry, more, worktreeId, ptyId, onAskingPane })
const cardRef = useRef<HTMLDivElement>(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 this card from its pane (or from anywhere when it's on the active pane), and the card
// hands focus back to where it came from when it goes away.
useLayoutEffect(
() =>
registerSecretRequestCard({
paneKey,
container: pane.container,
card: () => cardRef.current,
primary
}),
[paneKey, pane.container, primary]
)
return (
<div
data-secret-request-overlay=""
className="pointer-events-none absolute inset-x-3 top-10 z-30 flex justify-end"
>
<span role="status" className="sr-only">
{spoken}
</span>
<SecretRequestCard
model={model}
actions={actions}
rootRef={cardRef}
className="pointer-events-auto max-w-md"
/>
</div>
)
}
function SecretRequestPaneSlot({
pane,
tabId,
visible,
primary,
worktreeId
}: {
pane: ManagedPane
tabId: string
visible: boolean
primary: boolean
worktreeId: string
}): React.JSX.Element | null {
const t = useTranslate()
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) {
return
}
setSecretRequestSlot(paneKey, { paneKey, environmentId, handle, visible, primary })
return () => setSecretRequestSlot(paneKey, null)
}, [paneKey, environmentId, handle, visible, primary])
const requests = useSecretRequests()
const slots = useSecretRequestSlots()
const mine = useMemo(
() => (environmentId ? (routeSecretRequests(requests, slots).get(paneKey) ?? []) : []),
[environmentId, requests, slots, paneKey]
)
const first = mine[0]
if (!first || !ptyId) {
return null
}
const { entry } = first
return createPortal(
<SecretRequestPaneCardBody
// A new request starts from an empty field and Personal.
key={`${entry.environmentId}:${entry.event.requestId}`}
entry={entry}
more={mine.length - 1}
pane={pane}
paneKey={paneKey}
ptyId={ptyId}
worktreeId={worktreeId}
onAskingPane={first.onAskingPane}
primary={primary}
announcement={t('secretRequest.announce', '{{title}}. Press F6 to answer.', {
title: secretRequestTitle(secretRequestAgentName(entry.event.agent), entry.event.name, t)
})}
/>,
pane.container,
`secret-request-${pane.id}`
)
}
/** 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, isRendererVisible, worktreeId } = controller
return (
<>
{managedPanes.map((pane) => (
<SecretRequestPaneSlot
key={pane.id}
pane={pane}
tabId={tabId}
worktreeId={worktreeId}
// On screen, even in a split group that isn't the active one.
visible={isRendererVisible}
primary={isActive && activePane?.id === pane.id}
/>
))}
</>
)
}
@@ -0,0 +1,78 @@
import { useEffect } from 'react'
import { toast } from 'sonner'
import { useTranslate } from '@/i18n/use-translate'
import { useAppStore } from '@/store'
import { selectWorktreesByRepo } from '@/store/workspaces/catalog/workspace-record-fields'
import { activateAndRevealWorktree } from '@/lib/workspace/activation/worktree-activation'
import {
useSecretRequests,
type SecretRequestEntry
} from '@/lib/secret-requests/secret-request-store'
import {
secretRequestAgentName,
unseenSecretRequests
} from '@/lib/secret-requests/secret-request-routing'
import { secretRequestTitle } from './SecretRequestCard'
import { useSecretRequestSlots } from './secret-request-focus'
// Panes mount a moment after their stream attaches; don't toast for a card that's about to show.
const SETTLE_MS = 2_000
const told = new Set<string>()
/** The workspace on this computer that the asking host's request belongs to. */
function workspaceFor(entry: SecretRequestEntry): { id: string; name: string } | null {
const all = Object.values(selectWorktreesByRepo(useAppStore.getState()) ?? {}).flat()
const match =
all.find((worktree) => worktree.id === entry.event.worktreeId) ??
all.find((worktree) => worktree.runtimeOwnerEnvironmentId === entry.environmentId)
return match ? { id: match.id, name: match.displayName } : null
}
/**
* A request whose workspace isn't on screen would otherwise wait unseen until it expires: say so
* once, name the workspace, and open it on click.
*/
export function SecretRequestUnseenNotifier(): null {
const t = useTranslate()
const requests = useSecretRequests()
const slots = useSecretRequestSlots()
useEffect(() => {
const unseen = unseenSecretRequests(requests, slots).filter(
(entry) => !told.has(`${entry.environmentId}:${entry.event.requestId}`)
)
if (unseen.length === 0) {
return
}
const timer = window.setTimeout(() => {
for (const entry of unseen) {
told.add(`${entry.environmentId}:${entry.event.requestId}`)
const workspace = workspaceFor(entry)
toast.info(
secretRequestTitle(secretRequestAgentName(entry.event.agent), entry.event.name, t),
{
description: workspace
? t(
'secretRequest.unseenIn',
'In {{workspace}}. The request waits up to 15 minutes.',
{
workspace: workspace.name
}
)
: t(
'secretRequest.unseen',
'Open that workspace to answer. The request waits up to 15 minutes.'
),
action: workspace
? {
label: t('secretRequest.openWorkspace', 'Open'),
onClick: () => activateAndRevealWorktree(workspace.id)
}
: undefined
}
)
}
}, SETTLE_MS)
return () => window.clearTimeout(timer)
}, [requests, slots, t])
return null
}
@@ -0,0 +1,155 @@
// Where secret-request cards can show (one slot per mounted remote pane), and keyboard focus for the
// cards: one app-wide F6 handler moves focus into the card for the pane you're in and back out, and a
// card that goes away hands focus back instead of dropping it on the page.
import { useSyncExternalStore } from 'react'
import type { SecretRequestPaneSlot } from '@/lib/secret-requests/secret-request-routing'
const slots = new Map<string, SecretRequestPaneSlot>()
const slotListeners = new Set<() => void>()
let slotSnapshot: readonly SecretRequestPaneSlot[] = []
export function setSecretRequestSlot(paneKey: string, slot: SecretRequestPaneSlot | null): void {
const current = slots.get(paneKey)
if (slot === null) {
if (!current) {
return
}
slots.delete(paneKey)
} else {
if (
current?.environmentId === slot.environmentId &&
current.handle === slot.handle &&
current.visible === slot.visible &&
current.primary === slot.primary
) {
return
}
slots.set(paneKey, slot)
}
slotSnapshot = [...slots.values()]
for (const listener of slotListeners) {
listener()
}
}
export function useSecretRequestSlots(): readonly SecretRequestPaneSlot[] {
return useSyncExternalStore(
(listener) => {
slotListeners.add(listener)
return () => slotListeners.delete(listener)
},
() => slotSnapshot,
() => slotSnapshot
)
}
type CardRecord = {
paneKey: string
container: HTMLElement
element: HTMLElement
primary: boolean
/** Where F6 came from, to go back to. */
returnTo: HTMLElement | null
hadFocus: boolean
}
const cards = new Map<string, CardRecord>()
/** The pane's terminal input, when the element focus came from is gone. */
function terminalInput(container: HTMLElement): HTMLElement | null {
return container.querySelector<HTMLElement>('textarea')
}
function focusInto(record: CardRecord, from: Element | null): void {
record.returnTo = from instanceof HTMLElement && from !== document.body ? from : null
const target =
record.element.querySelector<HTMLElement>('input:not([disabled]), textarea:not([disabled])') ??
record.element.querySelector<HTMLElement>('button')
target?.focus()
}
function focusBack(record: CardRecord): void {
const back =
record.returnTo && record.returnTo.isConnected
? record.returnTo
: terminalInput(record.container)
back?.focus()
}
function onKeyDown(event: KeyboardEvent): void {
if (event.key !== 'F6' || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
return
}
const active = document.activeElement
const all = [...cards.values()]
const inside = all.find((record) => record.element.contains(active))
const target =
inside ??
all.find((record) => active !== null && record.container.contains(active)) ??
// From the terminal or nowhere only: inside Settings or a modal, F6 belongs to that surface.
(active === null || active === document.body || active.closest('.xterm') !== null
? all.find((record) => record.primary)
: undefined)
if (!target) {
return
}
event.preventDefault()
event.stopImmediatePropagation()
if (inside) {
focusBack(inside)
} else {
focusInto(target, active)
}
}
export function registerSecretRequestCard(args: {
paneKey: string
container: HTMLElement
card: () => HTMLElement | null
primary: boolean
}): () => void {
const element = args.card()
if (!element) {
return () => {}
}
const previous = cards.get(args.paneKey)
const record: CardRecord = {
paneKey: args.paneKey,
container: args.container,
element,
primary: args.primary,
returnTo: previous?.element === element ? previous.returnTo : null,
hadFocus: previous?.element === element ? previous.hadFocus : false
}
const onFocusIn = (): void => {
record.hadFocus = true
}
const onFocusOut = (event: FocusEvent): void => {
if (event.relatedTarget instanceof Node && !element.contains(event.relatedTarget)) {
record.hadFocus = false
}
}
element.addEventListener('focusin', onFocusIn)
element.addEventListener('focusout', onFocusOut)
if (cards.size === 0) {
window.addEventListener('keydown', onKeyDown, true)
}
cards.set(args.paneKey, record)
return () => {
element.removeEventListener('focusin', onFocusIn)
element.removeEventListener('focusout', onFocusOut)
if (cards.get(args.paneKey) === record) {
cards.delete(args.paneKey)
}
if (cards.size === 0) {
window.removeEventListener('keydown', onKeyDown, true)
}
// After the commit: if the card left the page while it held focus, focus goes back, not to <body>.
queueMicrotask(() => {
const lost = document.activeElement === null || document.activeElement === document.body
if (!element.isConnected && record.hadFocus && lost) {
focusBack(record)
}
})
}
}
@@ -0,0 +1,207 @@
// @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> = {}): SecretRequestedEvent {
return {
type: 'secrets.requested',
requestId: 'sr_aaaaaaaa',
name: 'NPM_TOKEN',
reason: 'See <a href="https://evil.example">here</a>',
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 ? <Card entry={entry} /> : null
}
function Card({ entry }: { entry: ReturnType<typeof readSecretRequests>[number] }) {
const { model, actions } = useSecretRequestCard({
entry,
more: 0,
worktreeId: null,
ptyId: 'p',
onAskingPane: true
})
return <SecretRequestCard model={model} actions={actions} />
}
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(<Harness />)
expect(document.querySelector('a')).toBeNull()
expect(screen.getByText(/See <a href=/)).toBeTruthy()
// Repository isn't writable here, so only the Personal chip shows.
expect(screen.queryByRole('combobox')).toBeNull()
expect(screen.getByText(/Commands in this workspace can read it as \$NPM_TOKEN/)).toBeTruthy()
})
it('saves through Cloud secrets, then tells orcad the outcome without the value', async () => {
render(<Harness />)
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(<Harness />)
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(<Harness />)
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(<Harness />)
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(<Harness />)
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('never declines while a save is in flight', async () => {
let finish: (value: unknown) => void = () => {}
put.mockReturnValue(new Promise((resolve) => (finish = resolve)))
render(<Harness />)
fireEvent.change(valueInput(), { target: { value: 'v' } })
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Add secret' }))
})
await act(async () => {
fireEvent.keyDown(valueInput(), { key: 'Escape' })
fireEvent.click(screen.getByRole('button', { name: 'Not now' }))
})
expect(rpc.mock.calls.some(([, , params]) => params?.outcome === 'declined')).toBe(false)
await act(async () => finish({ ok: true, value: {} }))
expect(screen.getByText('NPM_TOKEN added to your personal secrets.')).toBeTruthy()
})
it('says the agent wasn’t told when orcad doesn’t answer the resolve', async () => {
rpc.mockImplementation(async (_target, method: string) => {
if (method === 'secrets.resolveRequest') {
throw new Error('timeout')
}
return { expiresAt: NOW }
})
render(<Harness />)
fireEvent.change(valueInput(), { target: { value: 'v' } })
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Add secret' }))
})
expect(screen.getByText(/stopped waiting, so tell it the secret is ready/)).toBeTruthy()
})
it('declines on Escape', async () => {
render(<Harness />)
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' })
})
})
@@ -0,0 +1,300 @@
// 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, 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,
readSecretRequestTouchedAt,
setSecretRequestExpiry,
setSecretRequestLocal,
setSecretRequestTouched,
type SecretRequestEntry
} from '@/lib/secret-requests/secret-request-store'
import { useAgentHostPanelOpener } from '@/lib/agent-host-panel/agent-host-panel-opener'
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<CloudSecretsErrorCode> = 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<string, Promise<boolean>>()
function canWriteRepo(repoKey: string): Promise<boolean> {
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<SecretsResolveRequestResult['outcome'] | null> {
try {
const result = await callRuntimeRpc<SecretsResolveRequestResult>(
{ 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
/** The card sits on the asking agent's own pane; only then can Restart target it. */
onAskingPane: boolean
}): { model: SecretRequestCardModel; actions: SecretRequestCardActions } {
const { entry, more, worktreeId, ptyId, onAskingPane } = args
const { environmentId, event } = entry
const { requestId } = event
const t = useTranslate()
const [value, setValue] = useState('')
const [scope, setScope] = useState<SecretRequestScope>('personal')
const [failed, setFailed] = useState(false)
const [fieldError, setFieldError] = useState<string | null>(null)
const [focused, setFocused] = useState(false)
const [repoWritable, setRepoWritable] = useState(false)
const [collapsed, setCollapsed] = useState(false)
const repoKey = useWorkspaceRepoKey(event.worktreeId ?? worktreeId)
const openPanel = useAgentHostPanelOpener()
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])
// 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 => {
setSecretRequestTouched(environmentId, requestId, Date.now())
void callRuntimeRpc<SecretsTouchRequestResult>(
{ 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 and card remounts too.
const delay = Math.max(
0,
readSecretRequestTouchedAt(environmentId, requestId) +
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')
// Only a confirmed 'provided' means the agent was told; otherwise say so and let the person tell it.
setSecretRequestLocal(
environmentId,
requestId,
outcome === 'provided' ? 'saved' : 'savedAfterExpiry'
)
}, [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,
// An expired request shows no field, and what was typed doesn't come back.
value: local === 'expired' ? '' : value,
phase,
fieldError,
more,
canRestart: restartAgent !== null && ptyId !== null && onAskingPane,
collapsed
},
actions: {
onValueChange: (next) => {
setValue(next)
setFieldError(null)
},
onScopeChange: setScope,
onAdd: () => void add(),
onRetry: () => void add(),
onNotNow: () => {
// Mid-save the value may already be stored: never tell the agent "declined" then.
if (local !== null) {
return
}
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),
onToggleCollapsed: () => setCollapsed((current) => !current),
onDetails: openPanel
? () => openPanel({ hostId: environmentId, focus: { secret: event.name } })
: undefined,
onFocusChange: setFocused
}
}
}
@@ -0,0 +1,374 @@
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 { 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<string, string>
scopes: Record<string, CloudSecretScope>
phase: CloudSecretsAskPhase
/** Server refusals for single names. */
refused: Record<string, CloudSecretsErrorCode>
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 (
<div className="flex items-start gap-3">
<KeyRound aria-hidden className="mt-0.5 size-4 shrink-0 text-status-warning" />
<div className="min-w-0 flex-1">
<h3 id={headingId} className="text-sm font-medium text-foreground">
{t('cloudSecretsAsk.missing', '{{count}} secrets missing', {
count: data.missing.length,
defaultValue_one: '{{count}} secret missing'
})}
</h3>
<p className="mt-0.5 text-xs leading-5 text-muted-foreground">{coverageLine(data, t)}</p>
</div>
{action}
</div>
)
}
/**
* 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 (
<div className={cn('flex items-start gap-3 py-2', className)}>
<KeyRound aria-hidden className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
<span className="text-foreground">{t('cloudSecretsAsk.skipped', 'Secrets skipped')}</span>
{' · '}
{t('cloudSecretsAsk.skippedDetail', 'Add them later from the workspace’s cloud panel.')}
</p>
</div>
)
}
const savedLine =
state.phase === 'saved' ? (
<div className="flex items-start gap-3">
<CircleCheck aria-hidden className="mt-0.5 size-4 shrink-0 text-foreground" />
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">
{t('cloudSecretsAsk.saved', 'Secrets saved')}
</p>
<p className="mt-0.5 text-xs leading-5 text-muted-foreground">
{t(
'cloudSecretsAsk.savedDetail',
'New terminals and agents in this workspace get them within a few seconds.'
)}
</p>
</div>
</div>
) : null
// Everything saved: just the confirmation.
if (state.phase === 'saved' && data.missing.length === 0) {
return (
<div className={cn('py-2', className)} role="status">
{savedLine}
</div>
)
}
const lastName = data.missing.at(-1)
return (
<section aria-labelledby={headingId} className={cn('flex flex-col gap-3 py-2', className)}>
<div role="status" className="contents">
{savedLine}
</div>
<CloudSecretsAskHeading
data={data}
headingId={headingId}
action={
<Button
type="button"
variant="ghost"
size="xs"
aria-expanded={state.pasteOpen}
onClick={actions.onTogglePaste}
disabled={saving}
>
<ClipboardPaste aria-hidden />
{t('cloudSecretsAsk.pasteEnv', 'Paste .env')}
</Button>
}
/>
{!admin && data.built ? (
<p className="ps-7 text-xs leading-5 text-muted-foreground">
{t(
'cloudSecretsAsk.buildTeamOnly',
'The environment build only uses team values. Ask an owner or admin to add {{name}} as team secrets.',
{
name: listNames(data.missing),
count: data.missing.length,
defaultValue_one:
'The environment build only uses team values. Ask an owner or admin to add {{name}} as a team secret.'
}
)}
</p>
) : null}
{state.pasteOpen ? (
<div className="flex flex-col gap-1.5 ps-7">
<label htmlFor={`${id}-paste`} className="text-xs text-muted-foreground">
{t('cloudSecretsAsk.pasteLabel', 'Paste the contents of a .env file')}
</label>
<Textarea
id={`${id}-paste`}
variant="code"
value=""
placeholder={t('cloudSecretsAsk.pastePlaceholder', 'NAME=value')}
spellCheck={false}
autoComplete="off"
data-1p-ignore=""
className="min-h-16"
// The text is read at once and dropped: only the matching values stay, in the masked fields.
onChange={(event) => actions.onPaste(event.target.value)}
/>
</div>
) : null}
{state.pasteSummary ? (
<p role="status" className="ps-7 text-xs text-muted-foreground">
{t(
'cloudSecretsAsk.pasteFound',
'Found {{matched}} of {{count}}. {{ignored}} other lines ignored.',
{
matched: state.pasteSummary.matched,
count: state.pasteSummary.count,
ignored: state.pasteSummary.ignored
}
)}
</p>
) : null}
<ul className="flex flex-col gap-2 ps-7">
{data.missing.map((name) => {
const refusal = state.refused[name]
const errorId = `${id}-${name}-error`
return (
<li
key={name}
className="grid grid-cols-[minmax(0,11rem)_minmax(0,1fr)_auto] items-start gap-x-3 gap-y-1"
>
<label
htmlFor={`${id}-${name}`}
className="h-8 min-w-0 truncate font-mono text-xs leading-8 font-normal text-foreground"
title={name}
>
{name}
</label>
<div
className="min-w-0"
onKeyDown={(event) => {
if (event.key === 'Enter' && name === lastName && canSave) {
event.preventDefault()
actions.onSave()
}
}}
>
<SecretValueInput
id={`${id}-${name}`}
value={state.values[name] ?? ''}
onChange={(value) => actions.onValueChange(name, value)}
placeholder={t('cloudSecretsAsk.valuePlaceholder', 'Value')}
invalid={Boolean(refusal)}
disabled={saving}
describedBy={refusal ? errorId : undefined}
/>
</div>
{scopes.length > 1 ? (
<Select
value={state.scopes[name] ?? 'personal'}
onValueChange={(scope) => actions.onScopeChange(name, parseScope(scope))}
disabled={saving}
>
<SelectTrigger
size="sm"
className="w-32"
aria-label={t('cloudSecretsAsk.scopeLabel', 'Scope for {{name}}', { name })}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{scopes.map((scope) => (
<SelectItem key={scope} value={scope}>
{cloudSecretScopeLabel(scope, t)}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<span className="inline-flex h-8 items-center rounded-full border border-border px-2.5 text-xs text-muted-foreground">
{cloudSecretScopeLabel('personal', t)}
</span>
)}
{refusal ? (
<p
id={errorId}
role="alert"
className="col-start-2 col-end-4 text-xs text-destructive"
>
{refusal === 'org_admin_required'
? t(
'cloudSecretsAsk.memberRefused',
'Only owners and admins can save team or repository values. Choose Personal.'
)
: cloudSecretsErrorCopy(refusal, t)}
</p>
) : null}
</li>
)
})}
</ul>
{state.phase === 'failed' ? (
<InlineError
className="ms-7"
title={t('cloudSecretsAsk.unreachable', 'Couldn’t reach Orca Cloud.')}
detail={t('cloudSecretsAsk.nothingChanged', 'Nothing was changed.')}
retryLabel={t('cloudSecretsAsk.retry', 'Try again')}
onRetry={actions.onSave}
density="compact"
/>
) : null}
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 ps-7">
<p className="min-w-0 flex-1 basis-60 text-xs leading-5 text-muted-foreground">
{admin
? t('cloudSecrets.editor.help', 'Write-only. You won’t see it again after saving.')
: t(
'cloudSecretsAsk.memberFooter',
'Saved to your personal Cloud secrets, so only your workspaces get them. To set them for the whole team, ask an owner or admin.'
)}
</p>
<div className="flex shrink-0 items-center gap-2">
<Button
type="button"
variant="ghost"
size="xs"
onClick={actions.onSkip}
disabled={saving}
>
{t('cloudSecretsAsk.skip', 'Skip')}
</Button>
<Button
type="button"
size="xs"
aria-disabled={!canSave || undefined}
aria-busy={saving || undefined}
onClick={canSave ? actions.onSave : undefined}
>
{saving ? <Loader2 aria-hidden className="motion-safe:animate-spin" /> : null}
{filled.length === 0
? t('cloudSecretsAsk.saveNone', 'Save')
: t('cloudSecretsAsk.save', 'Save {{count}} secrets', {
count: filled.length,
defaultValue_one: 'Save secret'
})}
</Button>
</div>
</div>
</section>
)
}
@@ -0,0 +1,54 @@
import { defineFixture } from '../../component-fixtures/component-fixture'
import {
recordCloudSecretsMissing,
resetCloudSecretsMissingForTests
} from '@/lib/orca-cloud/cloud-secrets-missing-notice-store'
import type { HostedWorkspaceStatus } from '../../../../shared/hosted-sandbox/hosted-sandbox-wire'
import { CloudWorkspaceStatusPanel } from '../orca-cloud/CloudWorkspaceStatusPanel'
const NOW = Date.now()
const noop = (): void => {}
const ACTIONS = { onWake: noop, onRestart: noop, onArchive: noop, onUnarchive: noop }
const RUNNING: HostedWorkspaceStatus = {
state: 'ready',
metrics: {
cpuPercent: 37,
cpus: 4,
load1: 1.2,
memTotalBytes: 8e9,
memUsedBytes: 5.1e9,
diskTotalBytes: 34.3e9,
diskUsedBytes: 6.2e9,
uptimeSeconds: 7_420,
oomKills: 0,
lastOomVictim: null,
orcadAlive: true
},
uptimeMs: 2 * 3_600_000,
usage: { totalMs: 14.6 * 3_600_000, centsPerHour: 100, costCents: 1460 },
archive: null,
checkedAt: NOW
}
/** The workspace's cloud panel after the build sheet's Secrets row was skipped. */
export const panelSecretsMissing = defineFixture({
width: 300,
setup: () => {
resetCloudSecretsMissingForTests()
recordCloudSecretsMissing('ws-secrets', {
names: ['STRIPE_TEST_KEY', 'SENTRY_DSN'],
repoKey: 'github.com/acme/web'
})
},
render: () => (
<div className="rounded-md border border-border bg-floating-surface p-3">
<CloudWorkspaceStatusPanel
workspaceId="ws-secrets"
state="running"
entry={{ status: RUNNING, loading: false, error: null, fetchedAt: NOW - 42_000 }}
pending={undefined}
actions={ACTIONS}
/>
</div>
)
})
@@ -0,0 +1,172 @@
import React, { useCallback, useEffect, useSyncExternalStore } from 'react'
import { KeyRound, X } from 'lucide-react'
import { useTranslate } from '@/i18n/use-translate'
import { Button } from '@/components/ui/button'
import { InlineError } from '@/components/ui/inline-error'
import { Skeleton } from '@/components/ui/skeleton'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { cloudSecretsErrorCopy } from '@/components/settings/cloud-secrets/cloud-secrets-copy'
import {
clearCloudSecretsMissing,
narrowCloudSecretsMissing,
readCloudSecretsMissing,
useCloudSecretsMissing
} from '@/lib/orca-cloud/cloud-secrets-missing-notice-store'
import { CloudSecretsAskForm } from './CloudSecretsAskForm'
import { useCloudSecretsAskLoad } from './use-cloud-secrets-ask'
import { useCloudSecretsAskForm } from './use-cloud-secrets-ask-form'
// One dialog for the whole app, opened from whichever workspace panel asked.
type OpenTarget = { workspaceId: string; repoKey: string }
let openFor: OpenTarget | null = null
const listeners = new Set<() => void>()
function setOpenFor(target: OpenTarget | null): void {
openFor = target
for (const listener of listeners) {
listener()
}
}
function subscribe(listener: () => void): () => void {
listeners.add(listener)
return () => listeners.delete(listener)
}
export function openCloudSecretsAsk(workspaceId: string): void {
const notice = readCloudSecretsMissing(workspaceId)
if (notice) {
setOpenFor({ workspaceId, repoKey: notice.repoKey })
}
}
/** Shown in a cloud workspace's status panel while names it needs are still unfilled. */
export function CloudSecretsMissingNotice({
workspaceId
}: {
workspaceId: string
}): React.JSX.Element | null {
const t = useTranslate()
const notice = useCloudSecretsMissing(workspaceId)
if (!notice) {
return null
}
return (
<div
role="status"
className="flex items-center gap-2 rounded-md border border-border bg-muted/40 py-1 ps-2.5 pe-1 text-xs"
>
<KeyRound aria-hidden className="size-3.5 shrink-0 text-status-warning" />
<span className="min-w-0 flex-1 truncate font-medium text-foreground">
{t('cloudSecretsAsk.missing', '{{count}} secrets missing', {
count: notice.names.length,
defaultValue_one: '{{count}} secret missing'
})}
</span>
<Button
type="button"
variant="outline"
size="xs"
onClick={() => openCloudSecretsAsk(workspaceId)}
>
{t('cloudSecretsAsk.add', 'Add')}
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={t('cloudSecretsAsk.dismissNotice', 'Dismiss secrets missing notice')}
onClick={() => clearCloudSecretsMissing(workspaceId)}
>
<X aria-hidden />
</Button>
</div>
)
}
function CloudSecretsAskDialogBody({
workspaceId,
repoKey
}: {
workspaceId: string
repoKey: string
}): React.JSX.Element {
const t = useTranslate()
const { load, reload } = useCloudSecretsAskLoad(repoKey)
const data = load.status === 'ready' ? load.data : null
// Filled elsewhere since the sheet closed: the notice follows what is really missing now.
useEffect(() => {
if (load.status === 'ready') {
narrowCloudSecretsMissing(workspaceId, load.data.missing)
} else if (load.status === 'none') {
clearCloudSecretsMissing(workspaceId)
}
}, [load, workspaceId])
const onSaved = useCallback(() => reload(), [reload])
const onSkip = useCallback(() => setOpenFor(null), [])
const { state, actions } = useCloudSecretsAskForm({
data,
refreshRuntimeEnvironmentId: null,
onSaved,
onSkip
})
if (load.status === 'loading') {
return (
<div aria-busy="true" className="flex flex-col gap-2 py-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-8 w-full" />
<span className="sr-only">{t('cloudSecretsAsk.loading', 'Loading secrets…')}</span>
</div>
)
}
if (load.status === 'failed') {
return (
<InlineError
title={cloudSecretsErrorCopy(load.error, t)}
retryLabel={t('cloudSecretsAsk.retry', 'Try again')}
onRetry={reload}
/>
)
}
if (!data) {
return (
<p className="text-sm text-muted-foreground">
{t('cloudSecretsAsk.nothingMissing', 'Nothing is missing.')}
</p>
)
}
return <CloudSecretsAskForm data={data} state={state} actions={actions} />
}
/** App-level dialog behind the notice's Add. */
export function CloudSecretsAskDialogHost(): React.JSX.Element | null {
const t = useTranslate()
const target = useSyncExternalStore(
subscribe,
() => openFor,
() => openFor
)
if (!target) {
return null
}
return (
<Dialog open onOpenChange={(open) => !open && setOpenFor(null)}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{t('cloudSecretsAsk.dialogTitle', 'Add missing secrets')}</DialogTitle>
<DialogDescription>
{t(
'cloudSecretsAsk.dialogDetail',
'Values go to your Cloud secrets. New terminals and agents in this workspace get them within a few seconds.'
)}
</DialogDescription>
</DialogHeader>
<CloudSecretsAskDialogBody workspaceId={target.workspaceId} repoKey={target.repoKey} />
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,100 @@
import React, { useCallback, useEffect, useRef } from 'react'
import { useAppStore } from '@/store'
import type { PendingWorktreeCreation } from '@/lib/workspace/creation/pending-worktree-creation'
import { cloudSecretRepoKey } from '@/components/settings/cloud-secrets/cloud-secret-repos'
import {
clearCloudSecretsMissing,
recordCloudSecretsMissing
} from '@/lib/orca-cloud/cloud-secrets-missing-notice-store'
import { isOrcaCloudBuild } from '../worktree-creation/vm-build-steps'
import type { CloudSecretsAskData } from './cloud-secrets-ask-model'
import {
CloudSecretsAskForm,
type CloudSecretsAskFormActions,
type CloudSecretsAskFormState
} from './CloudSecretsAskForm'
import { useCloudSecretsAskLoad } from './use-cloud-secrets-ask'
import { useCloudSecretsAskForm } from './use-cloud-secrets-ask-form'
/** The build sheet's list item around the form; separated from the steps above like the Agents row. */
export function VmBuildSecretsRowView({
data,
state,
actions
}: {
data: CloudSecretsAskData
state: CloudSecretsAskFormState
actions: CloudSecretsAskFormActions
}): React.JSX.Element | null {
// Hidden when nothing is missing, unless it is confirming a save.
if (data.missing.length === 0 && state.phase !== 'saved') {
return null
}
return (
<li data-vm-build-secrets="" className="mt-1 border-t border-border/60 pt-2">
<CloudSecretsAskForm data={data} state={state} actions={actions} />
</li>
)
}
/**
* S2 on the Orca Cloud build sheet: the Cloud environment's declared names that this person's
* workspace won't get, asked inline. Never blocks Open. Left unfilled, it becomes the workspace
* panel's "secrets missing" notice.
*/
export function VmBuildSecretsRow({
entry
}: {
entry: PendingWorktreeCreation
}): React.JSX.Element | null {
const cloud = isOrcaCloudBuild(entry)
const sourceRepoId = entry.request.ephemeralVmRecipe?.sourceRepoId
const repoKey = useAppStore((state) => {
const repo = state.repos.find((candidate) => candidate.id === sourceRepoId)
return repo ? cloudSecretRepoKey(repo) : null
})
const { load, reload } = useCloudSecretsAskLoad(cloud ? repoKey : null)
const data = load.status === 'ready' ? load.data : null
// What to leave behind for the workspace panel if the sheet closes with names unfilled.
const leaveBehind = useRef<{ workspaceId: string | null; names: string[] }>({
workspaceId: null,
names: []
})
const workspaceId = entry.worktreeId ?? null
const missing = data?.missing
useEffect(() => {
leaveBehind.current.workspaceId = workspaceId
leaveBehind.current.names = missing ?? []
}, [workspaceId, missing])
const onSaved = useCallback(() => reload(), [reload])
// Skipping only collapses the row; the panel notice is left either way when the sheet closes.
const onSkip = useCallback(() => {}, [])
const { state, actions } = useCloudSecretsAskForm({
data,
// Only once the sandbox answers; before that its first session env reads the new values anyway.
refreshRuntimeEnvironmentId: entry.vmEnvironmentId ?? null,
onSaved,
onSkip
})
useEffect(() => {
const record = leaveBehind.current
return () => {
if (!record.workspaceId || !repoKey) {
return
}
if (record.names.length > 0) {
recordCloudSecretsMissing(record.workspaceId, { names: record.names, repoKey })
} else {
clearCloudSecretsMissing(record.workspaceId)
}
}
}, [repoKey])
if (!data) {
return null
}
return <VmBuildSecretsRowView data={data} state={state} actions={actions} />
}
@@ -0,0 +1,122 @@
import { describe, expect, it } from 'vitest'
import type {
CloudSecretsCaller,
CloudSecretsEffective
} from '../../../../shared/cloud-secrets/cloud-secrets-wire'
import {
cloudSecretsAskData,
environmentForRepo,
githubRepoKey,
missingSecretNames,
applyDotenvPaste,
parseScope,
writableScopes
} from './cloud-secrets-ask-model'
const MEMBER: CloudSecretsCaller = {
userId: 'u',
orgId: 'o',
orgRole: 'member',
canWrite: { personal: true, org: false, repo: false }
}
function effective(names: string[]): CloudSecretsEffective {
return {
caller: MEMBER,
precedence: { env: ['repo', 'personal', 'org'], agent_key: ['org', 'repo', 'personal'] },
secrets: names.map((name) => ({
name,
kind: 'env',
scope: 'org',
hint: null,
updatedAt: 0,
shadowed: []
}))
}
}
describe('githubRepoKey', () => {
it.each([
['https://github.com/Acme/Web.git', 'github.com/acme/web'],
['git@github.com:acme/web.git', 'github.com/acme/web'],
['ssh://git@github.com/acme/web', 'github.com/acme/web'],
['github.com/acme/web/', 'github.com/acme/web'],
['acme/web', 'github.com/acme/web']
])('reads %s', (url, key) => {
expect(githubRepoKey(url)).toBe(key)
})
it('rejects other hosts', () => {
expect(githubRepoKey('https://gitlab.com/acme/web')).toBeNull()
})
})
describe('environmentForRepo', () => {
it('finds the environment that lists the repo in any URL form', () => {
const environments = [
{ id: 'a', repos: [{ url: 'https://github.com/acme/api', defaultBranch: null }] },
{ id: 'b', repos: [{ url: 'git@github.com:acme/web.git', defaultBranch: null }] }
]
expect(environmentForRepo(environments, 'github.com/acme/web')?.id).toBe('b')
expect(environmentForRepo(environments, 'github.com/acme/docs')).toBeNull()
})
})
describe('missingSecretNames', () => {
it('is declared names minus effective ones, without agent keys or repeats', () => {
expect(
missingSecretNames(
['DATABASE_URL', 'STRIPE_KEY', 'ANTHROPIC_API_KEY', 'STRIPE_KEY', 'SENTRY_DSN'],
effective(['DATABASE_URL'])
)
).toEqual(['STRIPE_KEY', 'SENTRY_DSN'])
})
})
describe('writableScopes', () => {
it('starts with Personal and adds only what the caller can write', () => {
expect(writableScopes(MEMBER, { repo: true, shared: true })).toEqual(['personal'])
const admin = { canWrite: { personal: true, org: true, repo: true } }
expect(writableScopes(admin, { repo: true, shared: true })).toEqual(['personal', 'repo', 'org'])
// A secret request never offers Shared, and Repository needs a repo.
expect(writableScopes(admin, { repo: false, shared: false })).toEqual(['personal'])
})
})
describe('cloudSecretsAskData', () => {
it('counts declared names without agent keys and flags a built environment', () => {
const data = cloudSecretsAskData({
environment: {
name: 'acme-web',
envVarNames: ['A', 'B', 'C', 'OPENAI_API_KEY'],
activeBuildId: 'bld_1'
},
effective: effective(['A']),
repoKey: 'github.com/acme/web'
})
expect(data).toMatchObject({ total: 3, missing: ['B', 'C'], built: true })
})
})
describe('parseScope', () => {
it('falls back to Personal for anything unexpected', () => {
expect(parseScope('org')).toBe('org')
expect(parseScope('team')).toBe('personal')
})
})
describe('applyDotenvPaste', () => {
it('fills only missing names and counts the rest as ignored', () => {
const result = applyDotenvPaste(
'STRIPE_KEY=sk_1\nOTHER=x\n# comment\nSENTRY_DSN="https://a"\nSTRIPE_KEY=sk_2',
['STRIPE_KEY', 'SENTRY_DSN', 'NPM_TOKEN'],
{ NPM_TOKEN: 'kept' }
)
expect(result.values).toEqual({
NPM_TOKEN: 'kept',
STRIPE_KEY: 'sk_2',
SENTRY_DSN: 'https://a'
})
expect(result.summary).toEqual({ matched: 2, count: 3, ignored: 2 })
})
})
@@ -0,0 +1,153 @@
// What a Cloud workspace still needs from Cloud secrets: the environment's declared names that no scope
// provides for this repo and caller (spec S2, panel section 3). Names only; no value ever reaches here.
import { cloudSecretKind } from '../../../../shared/cloud-secrets/cloud-secret-rules'
import { parseDotenvPaste } from '../../../../shared/cloud-secrets/dotenv-paste'
import type {
CloudEffectiveSecret,
CloudSecretScope,
CloudSecretsCaller,
CloudSecretsEffective
} from '../../../../shared/cloud-secrets/cloud-secrets-wire'
import type { HostedEnvironmentListEntry } from '../../../../shared/hosted-sandbox/hosted-environment-wire'
/** `github.com/owner/name`, lower case, from any GitHub remote form (https, ssh, bare owner/name). */
export function githubRepoKey(url: string): string | null {
const trimmed = url
.trim()
.toLowerCase()
.replace(/\.git$/, '')
.replace(/\/+$/, '')
const match =
/^(?:https?:\/\/|ssh:\/\/)?(?:[^@/]+@)?github\.com[:/]([^/\s]+)\/([^/\s]+)$/.exec(trimmed) ??
/^([^/\s:@]+)\/([^/\s]+)$/.exec(trimmed)
return match ? `github.com/${match[1]}/${match[2]}` : null
}
/** The org environment that lists this repo; the control plane boots a create from the same one. */
export function environmentForRepo<T extends Pick<HostedEnvironmentListEntry, 'repos'>>(
environments: readonly T[],
repoKey: string
): T | null {
return (
environments.find((environment) =>
environment.repos.some((repo) => githubRepoKey(repo.url) === repoKey)
) ?? null
)
}
/**
* Declared names this caller's workspace won't get. Agent keys are left out: the Agents slice owns
* those, and a Cloud secret request can't ask for them either.
*/
export function missingSecretNames(
envVarNames: readonly string[],
effective: Pick<CloudSecretsEffective, 'secrets'>
): string[] {
const provided = new Set(effective.secrets.map((secret) => secret.name))
const seen = new Set<string>()
const missing: string[] = []
for (const name of envVarNames) {
if (provided.has(name) || seen.has(name) || cloudSecretKind(name) === 'agent_key') {
continue
}
seen.add(name)
missing.push(name)
}
return missing
}
/** Scopes a person can save a missing value to, Personal first. Shared only where asked for. */
export function writableScopes(
caller: Pick<CloudSecretsCaller, 'canWrite'>,
options: { repo: boolean; shared: boolean }
): CloudSecretScope[] {
const scopes: CloudSecretScope[] = ['personal']
if (options.repo && caller.canWrite.repo) {
scopes.push('repo')
}
if (options.shared && caller.canWrite.org) {
scopes.push('org')
}
return scopes
}
/** Owners and admins: anyone who can write the team scope. */
export function isTeamAdmin(caller: Pick<CloudSecretsCaller, 'canWrite'>): boolean {
return caller.canWrite.org
}
export type CloudSecretsAskData = {
environmentName: string
/** Declared names, after dropping agent keys. */
total: number
missing: string[]
caller: CloudSecretsCaller
/** The environment has been built, so its install script ran without the missing team values. */
built: boolean
repoKey: string | null
}
export function cloudSecretsAskData(args: {
environment: Pick<HostedEnvironmentListEntry, 'name' | 'envVarNames' | 'activeBuildId'>
effective: CloudSecretsEffective
repoKey: string | null
}): CloudSecretsAskData {
const declared = args.environment.envVarNames.filter(
(name) => cloudSecretKind(name) !== 'agent_key'
)
return {
environmentName: args.environment.name,
total: new Set(declared).size,
missing: missingSecretNames(declared, args.effective),
caller: args.effective.caller,
built: args.environment.activeBuildId !== null,
repoKey: args.repoKey
}
}
/** Effective entries of one kind, sorted by name. */
export function effectiveOfKind(
effective: Pick<CloudSecretsEffective, 'secrets'>,
kind: CloudEffectiveSecret['kind']
): CloudEffectiveSecret[] {
return effective.secrets
.filter((secret) => secret.kind === kind)
.sort((a, b) => a.name.localeCompare(b.name))
}
const SCOPES: readonly CloudSecretScope[] = ['personal', 'repo', 'org']
/** A Select's string value back as a scope; anything unexpected falls back to Personal. */
export function parseScope(value: string): CloudSecretScope {
return SCOPES.find((scope) => scope === value) ?? 'personal'
}
/** Fills the missing names from pasted .env text; the text itself is never kept. */
export function applyDotenvPaste(
text: string,
missing: readonly string[],
values: Record<string, string>
): {
values: Record<string, string>
summary: { matched: number; count: number; ignored: number }
} {
const wanted = new Set(missing)
const entries = parseDotenvPaste(text)
const next = { ...values }
const matched = new Set<string>()
for (const entry of entries) {
// Later lines win, as in a shell; an earlier duplicate counts as ignored.
if (wanted.has(entry.name) && (entry.issue === null || entry.issue === 'overridden')) {
next[entry.name] = entry.value
matched.add(entry.name)
}
}
return {
values: next,
summary: {
matched: matched.size,
count: missing.length,
ignored: entries.length - matched.size
}
}
}
@@ -0,0 +1,99 @@
import { useCallback, useMemo, useState } from 'react'
import type { CloudSecretScope } from '../../../../shared/cloud-secrets/cloud-secrets-wire'
import { applyDotenvPaste, type CloudSecretsAskData } from './cloud-secrets-ask-model'
import {
EMPTY_ASK_STATE,
type CloudSecretsAskFormActions,
type CloudSecretsAskFormState
} from './CloudSecretsAskForm'
import { saveMissingSecrets } from './use-cloud-secrets-ask'
export function useCloudSecretsAskForm(args: {
data: CloudSecretsAskData | null
/** Running sandbox to refresh after a save; null while it is starting or asleep. */
refreshRuntimeEnvironmentId: string | null
onSaved: (names: string[]) => void
onSkip: () => void
}): { state: CloudSecretsAskFormState; actions: CloudSecretsAskFormActions } {
const { data, refreshRuntimeEnvironmentId, onSaved, onSkip } = args
const [state, setState] = useState<CloudSecretsAskFormState>(EMPTY_ASK_STATE)
const save = useCallback(async () => {
if (!data) {
return
}
const values = Object.fromEntries(
data.missing
.map((name) => [name, state.values[name] ?? ''] as const)
.filter(([, value]) => value.length > 0)
)
if (Object.keys(values).length === 0) {
return
}
setState((current) => ({ ...current, phase: 'saving', refused: {} }))
const result = await saveMissingSecrets({
values,
scopes: state.scopes,
repoKey: data.repoKey,
refreshRuntimeEnvironmentId
})
if (!result.ok) {
setState((current) => ({ ...current, phase: 'failed' }))
return
}
setState((current) => {
// Saved values leave the renderer at once; refused ones stay so they can be fixed.
const kept = Object.fromEntries(
Object.entries(current.values).filter(([name]) => !result.saved.includes(name))
)
return {
...current,
values: kept,
refused: result.refused,
phase: result.saved.length > 0 ? 'saved' : 'editing',
pasteSummary: null
}
})
if (result.saved.length > 0) {
onSaved(result.saved)
}
}, [data, state.values, state.scopes, refreshRuntimeEnvironmentId, onSaved])
const actions = useMemo<CloudSecretsAskFormActions>(
() => ({
onValueChange: (name, value) =>
setState((current) => {
const { [name]: _cleared, ...refused } = current.refused
return {
...current,
values: { ...current.values, [name]: value },
refused,
phase: current.phase === 'failed' ? 'editing' : current.phase
}
}),
onScopeChange: (name: string, scope: CloudSecretScope) =>
setState((current) => ({ ...current, scopes: { ...current.scopes, [name]: scope } })),
onTogglePaste: () => setState((current) => ({ ...current, pasteOpen: !current.pasteOpen })),
onPaste: (text) =>
setState((current) => {
if (!data || !text.trim()) {
return current
}
const applied = applyDotenvPaste(text, data.missing, current.values)
return {
...current,
values: applied.values,
pasteOpen: false,
pasteSummary: applied.summary
}
}),
onSave: () => void save(),
onSkip: () => {
setState({ ...EMPTY_ASK_STATE, phase: 'skipped' })
onSkip()
}
}),
[data, save, onSkip]
)
return { state, actions }
}
@@ -0,0 +1,153 @@
// Loads what S2 and the panel's section 3 need for one repo: the org environment that lists it and the
// caller's effective Cloud secrets. Two reads, no new endpoint; nothing here wakes a sandbox.
import { useCallback, useEffect, useState } from 'react'
import {
cloudSecretsErrorCode,
type CloudSecretBatchResult,
type CloudSecretScope,
type CloudSecretsEffective,
type CloudSecretsErrorCode
} from '../../../../shared/cloud-secrets/cloud-secrets-wire'
import type { HostedEnvironmentListEntry } from '../../../../shared/hosted-sandbox/hosted-environment-wire'
import {
cloudSecretsAskData,
environmentForRepo,
type CloudSecretsAskData
} from './cloud-secrets-ask-model'
const UNAVAILABLE: CloudSecretsErrorCode = 'unavailable'
const NOT_CONFIGURED: CloudSecretsErrorCode = 'not_configured'
export type CloudSecretsAskLoad =
| { status: 'loading' }
/** No Cloud, no environment for this repo, or nothing declared: S2 stays hidden. */
| { status: 'none' }
| { status: 'failed'; error: CloudSecretsErrorCode }
| {
status: 'ready'
data: CloudSecretsAskData
effective: CloudSecretsEffective
environment: HostedEnvironmentListEntry
}
export function useCloudSecretsAskLoad(repoKey: string | null): {
load: CloudSecretsAskLoad
reload: () => void
} {
const [load, setLoad] = useState<CloudSecretsAskLoad>({ status: 'loading' })
const [generation, setGeneration] = useState(0)
useEffect(() => {
const secrets = window.api.cloudSecrets
const environments = window.api.orcaCloudEnvironments
if (!repoKey || !secrets || !environments) {
setLoad({ status: 'none' })
return
}
let cancelled = false
void (async () => {
const [list, effective] = await Promise.all([
environments.list().catch(() => null),
secrets.effective({ repo: repoKey }).catch(() => null)
])
if (cancelled) {
return
}
// A list that failed isn't "no environment": the caller must not take it as nothing missing.
if (!list || !list.ok) {
setLoad({ status: 'failed', error: UNAVAILABLE })
return
}
const environment = environmentForRepo(list.data.environments, repoKey)
if (!environment) {
setLoad({ status: 'none' })
return
}
if (!effective || !effective.ok) {
setLoad({ status: 'failed', error: effective?.ok === false ? effective.error : 'unknown' })
return
}
setLoad({
status: 'ready',
data: cloudSecretsAskData({ environment, effective: effective.value, repoKey }),
effective: effective.value,
environment
})
})()
return () => {
cancelled = true
}
}, [repoKey, generation])
const reload = useCallback(() => setGeneration((value) => value + 1), [])
return { load, reload }
}
export type CloudSecretsSaveResult =
| { ok: true; saved: string[]; refused: Record<string, CloudSecretsErrorCode> }
| { ok: false; error: CloudSecretsErrorCode }
const TRANSPORT: ReadonlySet<CloudSecretsErrorCode> = new Set(['unavailable', 'timeout', 'unknown'])
/**
* One batch per scope (usually one), then the existing env refresh for a running sandbox. A transport
* failure before anything was written reports "nothing changed"; per-name refusals come back by name.
*/
export async function saveMissingSecrets(args: {
values: Record<string, string>
scopes: Record<string, CloudSecretScope>
repoKey: string | null
/** The sandbox's runtime environment, when it is up and should pick the values up now. */
refreshRuntimeEnvironmentId: string | null
}): Promise<CloudSecretsSaveResult> {
const api = window.api.cloudSecrets
if (!api) {
return { ok: false, error: NOT_CONFIGURED }
}
const byScope = new Map<CloudSecretScope, Record<string, string>>()
for (const [name, value] of Object.entries(args.values)) {
if (!value) {
continue
}
const scope = args.scopes[name] ?? 'personal'
byScope.set(scope, { ...byScope.get(scope), [name]: value })
}
const saved: string[] = []
const refused: Record<string, CloudSecretsErrorCode> = {}
for (const [scope, set] of byScope) {
if (scope === 'repo' && !args.repoKey) {
for (const name of Object.keys(set)) {
refused[name] = 'repo_required'
}
continue
}
const target = scope === 'repo' ? { scope, repo: args.repoKey ?? undefined } : { scope }
const result = await api.batch({ target, set }).catch(() => null)
if (!result || !result.ok) {
const error: CloudSecretsErrorCode = result?.ok === false ? result.error : 'unknown'
if (TRANSPORT.has(error) && saved.length === 0) {
return { ok: false, error }
}
for (const name of Object.keys(set)) {
refused[name] = error
}
continue
}
collect(result.value, saved, refused)
}
if (saved.length > 0 && args.refreshRuntimeEnvironmentId) {
void api.refreshEnv({ runtimeEnvironmentId: args.refreshRuntimeEnvironmentId }).catch(() => {})
}
return { ok: true, saved, refused }
}
function collect(
result: CloudSecretBatchResult,
saved: string[],
refused: Record<string, CloudSecretsErrorCode>
): void {
for (const secret of result.secrets) {
saved.push(secret.name)
}
for (const error of result.errors) {
refused[error.name] = cloudSecretsErrorCode(error.reason ?? error.error)
}
}
@@ -25,6 +25,7 @@ import {
type CloudStatusEntry
} from '@/lib/orca-cloud/orca-cloud-workspace-store'
import { CloudStateDot } from './CloudStateDot'
import { CloudSecretsMissingNotice } from '../cloud-secrets-ask/CloudSecretsMissingNotice'
import { cloudStateDetail, cloudStateLabel } from './cloud-state-copy'
export type CloudPanelActions = {
@@ -202,6 +203,8 @@ export function CloudWorkspaceStatusPanel({
</div>
</div>
{state === 'archived' ? null : <CloudSecretsMissingNotice workspaceId={workspaceId} />}
{unavailable ? (
<InlineError
tone="neutral"
@@ -130,6 +130,7 @@ export function fixtureCloudSecretsApi(options: {
put: ({ target, name }) =>
options.pendingWrites ? never() : ok(view(target.scope, name, 'env', null, 0, caller.userId)),
remove: () => (options.pendingWrites ? never() : ok(true as const)),
workspaceSharing: () => ok({ shared: false, ownerName: null, isOwner: true }),
batch: ({ target, set }) =>
options.pendingWrites
? never()
@@ -21,7 +21,7 @@ import { handleInternalTerminalFileDrop } from '../input/file-drop/terminal-drop
import { TerminalQuickCommandEditorDialog } from '../quick-commands/TerminalQuickCommandEditorDialog'
import { TerminalPaneNativeChatPortal } from '../chat/TerminalPaneNativeChatPortal'
import { TerminalPaneSleepingPortals } from '../session-restore/spawn-deferral/TerminalPaneSleepingPortals'
import { AgentSignInPanePortals } from '../../agent-sign-in/AgentSignInPanePortals'
import { AgentPanePortals } from '../../agent-pane/AgentPanePortals'
import { TerminalPaneCodexRestartPortals } from './TerminalPaneCodexRestartPortals'
import { TerminalPaneMobileDriverPortals } from '../mobile-driver/TerminalPaneMobileDriverPortals'
import { TerminalPaneProcessExitPortals } from '../process-exit/TerminalPaneProcessExitPortals'
@@ -390,7 +390,7 @@ export function TerminalPaneSurface({
/>
<TerminalPaneRecoveryPortals controller={controller} />
<TerminalPaneSleepingPortals controller={controller} />
<AgentSignInPanePortals controller={controller} />
<AgentPanePortals controller={controller} />
<TerminalPaneMobileDriverPortals controller={controller} />
<CloseTerminalDialog
open={pendingCloseConfirmation !== null}
@@ -12,10 +12,12 @@ import {
frame,
LOG_LINES,
NOW,
seedFixtureRepo,
sheet,
stampsFor,
T0
} from './vm-build-sheet-fixture-support'
import { secretsData, secretsRow } from './vm-build-secrets-fixture-data'
const noop = (): void => {}
@@ -310,3 +312,49 @@ function LiveBuild(): React.JSX.Element {
}
export const live = defineFixture({ width: 760, render: () => <LiveBuild /> })
// Orca Cloud › Secrets (S2): the environment's names this person doesn't get a value for.
const cloudBuilding = entry(
{ environmentCheck: { status: 'running', startedAt: T0 + 4_800 }, provisioningLog: LOG },
CLOUD_RECIPE
)
const secretsFrame = (row: React.ReactNode) =>
defineFixture({
width: 760,
setup: () => seedFixtureRepo(),
render: () => sheet(cloudBuilding, null, T0 + 6_000, undefined, { secretsRow: row })
})
const TYPED = { STRIPE_TEST_KEY: 'sk_test_51H8xQ2eZvKYlo2C' }
export const secretsMissing = secretsFrame(secretsRow(secretsData('admin'), { values: TYPED }))
export const secretsMember = secretsFrame(
secretsRow(secretsData('member', { missing: ['STRIPE_TEST_KEY', 'DATABASE_URL'] }))
)
export const secretsBuildOnlyTeam = secretsFrame(secretsRow(secretsData('member', { built: true })))
export const secretsPasted = secretsFrame(
secretsRow(secretsData('admin'), {
values: { ...TYPED, SENTRY_DSN: 'https://k@o1.ingest.sentry.io/1' },
pasteSummary: { matched: 2, count: 2, ignored: 4 }
})
)
export const secretsPasteOpen = secretsFrame(secretsRow(secretsData('admin'), { pasteOpen: true }))
export const secretsSaving = secretsFrame(
secretsRow(secretsData('admin'), { values: TYPED, phase: 'saving' })
)
export const secretsSaved = secretsFrame(
secretsRow(secretsData('admin', { missing: [] }), { phase: 'saved' })
)
export const secretsPartlySaved = secretsFrame(
secretsRow(secretsData('admin', { missing: ['SENTRY_DSN'] }), { phase: 'saved' })
)
export const secretsFailed = secretsFrame(
secretsRow(secretsData('admin'), { values: TYPED, phase: 'failed' })
)
export const secretsRefused = secretsFrame(
secretsRow(secretsData('admin'), {
values: TYPED,
refused: { STRIPE_TEST_KEY: 'value_too_large' }
})
)
export const secretsSkipped = secretsFrame(secretsRow(secretsData('admin'), { phase: 'skipped' }))
@@ -19,6 +19,7 @@ import { firstBuildLogErrorLine, VmBuildLog } from './VmBuildLog'
import { VmBuildFailureCard } from './VmBuildFailureCard'
import { VmBuildAgentSignInRow } from './VmBuildAgentSignInRow'
import { VmBuildCloudAgentStep } from '../cloud-agent-setup/VmBuildCloudAgentStep'
import { VmBuildSecretsRow } from '../cloud-secrets-ask/VmBuildSecretsRow'
import { OrcaCloudQueuedPrompt } from '@/components/orca-cloud/OrcaCloudQueuedPrompt'
import {
classifyVmBuildFailure,
@@ -92,6 +93,7 @@ export function VmBuildSheet({
onEditRecipe,
agentSignInRow,
cloudAgentStep,
secretsRow,
stepTimings
}: {
creationId: string
@@ -110,6 +112,8 @@ export function VmBuildSheet({
agentSignInRow?: React.ReactNode
/** Fixtures only: a fixed "Add an agent" step (null for none) in place of the live one. */
cloudAgentStep?: React.ReactNode
/** Fixtures only: a fixed Secrets row (null for none) in place of the live one. */
secretsRow?: React.ReactNode
/** Fixtures only: measured step timings in place of the control plane's. */
stepTimings?: VmBuildStepTimings
}): React.JSX.Element {
@@ -247,6 +251,11 @@ export function VmBuildSheet({
environmentId={entry.vmEnvironmentId}
/>
) : null)}
{secretsRow !== undefined ? (
secretsRow
) : cloud ? (
<VmBuildSecretsRow entry={entry} />
) : null}
</ol>
)}
@@ -0,0 +1,57 @@
// Builders for the build sheet's Secrets row fixtures (S2), on the Orca Cloud sheet.
import React from 'react'
import type { CloudSecretsCaller } from '../../../../shared/cloud-secrets/cloud-secrets-wire'
import type { CloudSecretsAskData } from '../cloud-secrets-ask/cloud-secrets-ask-model'
import {
EMPTY_ASK_STATE,
type CloudSecretsAskFormActions,
type CloudSecretsAskFormState
} from '../cloud-secrets-ask/CloudSecretsAskForm'
import { VmBuildSecretsRowView } from '../cloud-secrets-ask/VmBuildSecretsRow'
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 }
}
export function secretsData(
role: 'admin' | 'member',
patch: Partial<CloudSecretsAskData> = {}
): CloudSecretsAskData {
return {
environmentName: 'acme-web',
total: 5,
missing: ['STRIPE_TEST_KEY', 'SENTRY_DSN'],
caller: role === 'admin' ? ADMIN : MEMBER,
built: false,
repoKey: 'github.com/acme/web',
...patch
}
}
const noop = (): void => {}
const ACTIONS: CloudSecretsAskFormActions = {
onValueChange: noop,
onScopeChange: noop,
onTogglePaste: noop,
onPaste: noop,
onSave: noop,
onSkip: noop
}
export function secretsRow(
data: CloudSecretsAskData,
state: Partial<CloudSecretsAskFormState> = {}
): React.ReactNode {
return (
<VmBuildSecretsRowView data={data} state={{ ...EMPTY_ASK_STATE, ...state }} actions={ACTIONS} />
)
}
@@ -55,7 +55,8 @@ export function sheet(
value: PendingWorktreeCreation,
error: string | null,
now: number,
agentSignInRow?: React.ReactNode
agentSignInRow?: React.ReactNode,
options: { cloudAgentStep?: React.ReactNode; secretsRow?: React.ReactNode } = {}
) {
return (
<div className="bg-background p-3">
@@ -72,7 +73,8 @@ export function sheet(
onEditRecipe={() => {}}
agentSignInRow={agentSignInRow}
stepTimings={FIXTURE_STEP_TIMINGS}
cloudAgentStep={null}
cloudAgentStep={options.cloudAgentStep ?? null}
secretsRow={options.secretsRow ?? null}
/>
</div>
)
+12
View File
@@ -1525,6 +1525,14 @@
"lines_other": "Multi-line value · {{count}} lines"
}
},
"cloudSecretsAsk": {
"buildTeamOnly_one": "The environment build only uses team values. Ask an owner or admin to add {{name}} as a team secret.",
"buildTeamOnly_other": "The environment build only uses team values. Ask an owner or admin to add {{name}} as team secrets.",
"missing_one": "{{count}} secret missing",
"missing_other": "{{count}} secrets missing",
"save_one": "Save secret",
"save_other": "Save {{count}} secrets"
},
"codeHost": {
"bitbucketConnect": {
"accessTokenRequired": "Enter an access token.",
@@ -2372,6 +2380,10 @@
}
}
},
"secretRequest": {
"more_one": "{{count}} more request",
"more_other": "{{count}} more requests"
},
"sessionSearch": {
"panel": {
"resultCount_one": "{{count}} result",
+120 -1
View File
@@ -27187,6 +27187,125 @@
"recipeEnvReview": "Review",
"recipeEnvHide": "Hide",
"recipeEnvKeyLike": "Look like keys",
"recipeEnvOther": "Other variables"
"recipeEnvOther": "Other variables",
"env": {
"add": "Add",
"addMine": "Add mine",
"missing": "Missing",
"askOwner": "Ask {{owner}}, an owner or an admin to add it.",
"valueLabel": "Value for {{name}}",
"valuePlaceholder": "Value",
"memberHelp": "Saved to your personal Cloud secrets, so only your workspaces get it.",
"cancel": "Cancel",
"save": "Save",
"guestOwner": "In a shared workspace, Personal values come from its owner, {{owner}}, not from you.",
"guest": "In a shared workspace, Personal values come from its owner, not from you.",
"empty": "No variables from Cloud secrets yet.",
"overrides": "overrides {{scopes}}"
},
"keys": {
"askAdmin": "Ask an owner or admin to add one.",
"gateway": "AI Gateway",
"other": "Other",
"overrides": "{{scope}} · overrides {{others}}",
"readOnly": "{{scope}} · only owners and admins can change it",
"shadowedLabel": "Keys not used for {{name}}",
"editLabel": "Edit {{name}}",
"edit": "Edit",
"empty": "No agent keys in Cloud secrets.",
"emptyMember": "Add your own, or ask an owner or admin for a team key.",
"manage": "Open Cloud secrets"
},
"unreachable": "Couldn’t reach Orca Cloud.",
"section": {
"keysCloud": "API keys from Cloud secrets",
"env": "Environment"
},
"help": {
"cloudKeys": "From your Orca account and Cloud secrets. Keys only on this computer aren’t sent to Orca Cloud.",
"cloudEnv": "From Cloud secrets: Repository, then Personal, then Shared."
},
"retry": "Try again",
"asleepFooter": "Changes reach the sandbox when it wakes.",
"loadingSecrets": "Loading Cloud secrets…",
"details": "Details"
},
"cloudSecretsAsk": {
"scopeLabel": "Scope for {{name}}",
"memberRefused": "Only owners and admins can save team or repository values. Choose Personal.",
"missing_one": "{{count}} secret missing",
"missing_other": "{{count}} secrets missing",
"coverage": "The {{environment}} environment uses {{total}}. Your team and this repository provide {{covered}}.",
"skipped": "Secrets skipped",
"skippedDetail": "Add them later from the workspace’s cloud panel.",
"saved": "Secrets saved",
"savedDetail": "New terminals and agents in this workspace get them within a few seconds.",
"pasteEnv": "Paste .env",
"buildTeamOnly_one": "The environment build only uses team values. Ask an owner or admin to add {{name}} as a team secret.",
"buildTeamOnly_other": "The environment build only uses team values. Ask an owner or admin to add {{name}} as team secrets.",
"pasteLabel": "Paste the contents of a .env file",
"pastePlaceholder": "NAME=value",
"pasteFound": "Found {{matched}} of {{count}}. {{ignored}} other lines ignored.",
"valuePlaceholder": "Value",
"unreachable": "Couldn’t reach Orca Cloud.",
"nothingChanged": "Nothing was changed.",
"retry": "Try again",
"memberFooter": "Saved to your personal Cloud secrets, so only your workspaces get them. To set them for the whole team, ask an owner or admin.",
"skip": "Skip",
"saveNone": "Save",
"save_one": "Save secret",
"save_other": "Save {{count}} secrets",
"add": "Add",
"dismissNotice": "Dismiss secrets missing notice",
"loading": "Loading secrets…",
"nothingMissing": "Nothing is missing.",
"dialogTitle": "Add missing secrets",
"dialogDetail": "Values go to your Cloud secrets. New terminals and agents in this workspace get them within a few seconds."
},
"cloudAgents": {
"source": {
"shadowedByTeam": "Not used while the team key is set.",
"shadowedByRepo": "Not used while the repository key is set."
}
},
"agentSources": {
"shared": "Personal accounts aren’t used in shared workspaces."
},
"secretRequest": {
"anAgent": "an agent",
"more_one": "{{count}} more request",
"more_other": "{{count}} more requests",
"expand": "Show request",
"collapse": "Hide request",
"from": "from {{agent}}",
"expired": "This request expired.",
"expiredDetail": "{{agent}} can ask again.",
"theAgent": "The agent",
"dismiss": "Dismiss",
"valueLabel": "Value for {{name}}",
"valuePlaceholder": "Value",
"repoHint": "{{agent}} suggested Repository",
"unreachable": "Couldn’t reach Orca Cloud.",
"nothingChanged": "Nothing was changed.",
"retry": "Try again",
"notNow": "Not now",
"add": "Add secret",
"title": "{{agent}} asked for {{name}}",
"titleUnknown": "An agent asked for {{name}}",
"showLess": "Show less",
"showMore": "Show more",
"scopeLabel": "Scope for {{name}}",
"savedAfterExpiry": "Saved. {{agent}} stopped waiting, so tell it the secret is ready.",
"doneRepo": "{{name}} added to this repository’s secrets.",
"done": "{{name}} added to your personal secrets.",
"doneDetail": "New shells get it now. {{agent}} was told it’s ready, and it never gets the value from Orca.",
"doneDetailExpired": "New shells get it now.",
"restartDetail": "Restart {{agent}} to give its own shell the new value.",
"restart": "Restart {{agent}}",
"announce": "{{title}}. Press F6 to answer.",
"unseenIn": "In {{workspace}}. The request waits up to 15 minutes.",
"unseen": "Open that workspace to answer. The request waits up to 15 minutes.",
"openWorkspace": "Open",
"helpBeforeSave": "Saves to your Cloud secrets. Commands in this workspace can read it as ${{name}}."
}
}
@@ -0,0 +1,42 @@
// The deep link into the "Agent accounts & secrets" panel (spec §4). The panel shell registers itself
// here; prompts that point into it (S4-b's Details) show the link only once it's there.
import { useSyncExternalStore } from 'react'
export type AgentHostPanelFocus = 'claude' | 'codex' | 'keys' | { secret: string }
export type OpenAgentHostPanel = (target: {
/** The runtime environment id of the host. */
hostId: string
focus?: AgentHostPanelFocus
}) => void
let opener: OpenAgentHostPanel | null = null
const listeners = new Set<() => void>()
/** Called by the panel shell when it mounts; returns the unregister function. */
export function registerAgentHostPanelOpener(open: OpenAgentHostPanel): () => void {
opener = open
for (const listener of listeners) {
listener()
}
return () => {
if (opener === open) {
opener = null
for (const listener of listeners) {
listener()
}
}
}
}
/** The opener, or null while no panel shell is mounted. */
export function useAgentHostPanelOpener(): OpenAgentHostPanel | null {
return useSyncExternalStore(
(listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
() => opener,
() => opener
)
}
@@ -0,0 +1,30 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
clearCloudSecretsMissing,
narrowCloudSecretsMissing,
readCloudSecretsMissing,
recordCloudSecretsMissing,
resetCloudSecretsMissingForTests
} from './cloud-secrets-missing-notice-store'
const REPO = 'github.com/acme/web'
describe('cloud secrets missing notice', () => {
afterEach(() => resetCloudSecretsMissingForTests())
it('keeps names per workspace until filled or dismissed', () => {
recordCloudSecretsMissing('ws', { names: ['A', 'B'], repoKey: REPO }, 1)
narrowCloudSecretsMissing('ws', ['B', 'C'])
expect(readCloudSecretsMissing('ws')?.names).toEqual(['B'])
narrowCloudSecretsMissing('ws', [])
expect(readCloudSecretsMissing('ws')).toBeNull()
recordCloudSecretsMissing('ws', { names: ['A'], repoKey: REPO })
clearCloudSecretsMissing('ws')
expect(readCloudSecretsMissing('ws')).toBeNull()
})
it('records nothing when nothing is missing', () => {
recordCloudSecretsMissing('ws', { names: [], repoKey: REPO })
expect(readCloudSecretsMissing('ws')).toBeNull()
})
})
@@ -0,0 +1,133 @@
// "2 secrets missing · Add" in a cloud workspace's panel after the build sheet's Secrets row was skipped
// or left unfilled (spec S2). Names only, per workspace, until filled or dismissed. Never a value.
import { useSyncExternalStore } from 'react'
const STORAGE_KEY = 'orca.cloudSecretsMissing.v1'
const MAX_WORKSPACES = 200
export type CloudSecretsMissingNotice = {
/** The names still missing when the person left the sheet. */
names: string[]
/** `github.com/owner/name`: the repo the values are for. */
repoKey: string
at: number
}
type Notices = Readonly<Record<string, CloudSecretsMissingNotice>>
let notices: Notices = read()
const listeners = new Set<() => void>()
function read(): Notices {
try {
const raw = globalThis.localStorage?.getItem(STORAGE_KEY)
const parsed: unknown = raw ? JSON.parse(raw) : {}
if (!parsed || typeof parsed !== 'object') {
return {}
}
const clean: Record<string, CloudSecretsMissingNotice> = {}
for (const [workspaceId, value] of Object.entries(parsed)) {
if (isNotice(value)) {
clean[workspaceId] = value
}
}
return clean
} catch {
return {}
}
}
function isNotice(value: unknown): value is CloudSecretsMissingNotice {
if (
typeof value !== 'object' ||
value === null ||
!('names' in value) ||
!('repoKey' in value) ||
!('at' in value)
) {
return false
}
const { names, repoKey, at } = value
return (
Array.isArray(names) &&
names.every((name) => typeof name === 'string' && /^[A-Z_][A-Z0-9_]*$/.test(name)) &&
typeof repoKey === 'string' &&
typeof at === 'number'
)
}
function write(next: Notices): void {
// Oldest first out, so the record can't grow without bound.
const entries = Object.entries(next).sort(([, a], [, b]) => b.at - a.at)
notices = Object.fromEntries(entries.slice(0, MAX_WORKSPACES))
try {
globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify(notices))
} catch {
// Storage full or unavailable: the notice still lives for this session.
}
for (const listener of listeners) {
listener()
}
}
export function recordCloudSecretsMissing(
workspaceId: string,
notice: Omit<CloudSecretsMissingNotice, 'at'>,
now = Date.now()
): void {
if (notice.names.length === 0) {
clearCloudSecretsMissing(workspaceId)
return
}
write({ ...notices, [workspaceId]: { ...notice, names: [...notice.names], at: now } })
}
/** Filled or dismissed. */
export function clearCloudSecretsMissing(workspaceId: string): void {
if (!(workspaceId in notices)) {
return
}
const { [workspaceId]: _removed, ...rest } = notices
write(rest)
}
/** Some names were filled from elsewhere (the panel, Settings): keep only the rest. */
export function narrowCloudSecretsMissing(
workspaceId: string,
stillMissing: readonly string[]
): void {
const current = notices[workspaceId]
if (!current) {
return
}
const names = current.names.filter((name) => stillMissing.includes(name))
if (names.length === current.names.length) {
return
}
if (names.length === 0) {
clearCloudSecretsMissing(workspaceId)
} else {
write({ ...notices, [workspaceId]: { ...current, names } })
}
}
function subscribe(listener: () => void): () => void {
listeners.add(listener)
return () => listeners.delete(listener)
}
export function useCloudSecretsMissing(workspaceId: string): CloudSecretsMissingNotice | null {
return useSyncExternalStore(
subscribe,
() => notices[workspaceId] ?? null,
() => notices[workspaceId] ?? null
)
}
export function readCloudSecretsMissing(workspaceId: string): CloudSecretsMissingNotice | null {
return notices[workspaceId] ?? null
}
export function resetCloudSecretsMissingForTests(seed: Notices = {}): void {
notices = seed
}
@@ -0,0 +1,54 @@
// The host's events are checked before a card shows them: a malformed or oversized one is dropped.
import { z } from 'zod'
import {
SECRET_REQUEST_REASON_MAX,
SECRETS_REQUEST_SETTLED_EVENT,
SECRETS_REQUEST_UPDATED_EVENT,
SECRETS_REQUESTED_EVENT,
type SecretRequestedEvent,
type SecretRequestSettledEvent,
type SecretRequestUpdatedEvent
} from '../../../../shared/secret-request-contract'
const RequestId = z.string().regex(/^sr_[A-Za-z0-9_-]{8,64}$/)
const Millis = z.number().finite().nonnegative()
const RequestedSchema = z.object({
type: z.literal(SECRETS_REQUESTED_EVENT),
requestId: RequestId,
name: z
.string()
.max(128)
.regex(/^[A-Z_][A-Z0-9_]*$/),
reason: z.string().max(SECRET_REQUEST_REASON_MAX).optional(),
scope: z.enum(['personal', 'repo']),
agent: z.string().max(64).nullable(),
worktreeId: z.string().max(512).nullable(),
terminalHandle: z.string().min(1).max(512).optional(),
requestedAt: Millis,
expiresAt: Millis
})
const SettledSchema = z.object({
type: z.literal(SECRETS_REQUEST_SETTLED_EVENT),
requestId: RequestId,
status: z.enum(['provided', 'declined', 'expired'])
})
const UpdatedSchema = z.object({
type: z.literal(SECRETS_REQUEST_UPDATED_EVENT),
requestId: RequestId,
expiresAt: Millis
})
export function isSecretRequestUpdatedEvent(value: unknown): value is SecretRequestUpdatedEvent {
return UpdatedSchema.safeParse(value).success
}
export function isSecretRequestedEvent(value: unknown): value is SecretRequestedEvent {
return RequestedSchema.safeParse(value).success
}
export function isSecretRequestSettledEvent(value: unknown): value is SecretRequestSettledEvent {
return SettledSchema.safeParse(value).success
}
@@ -0,0 +1,116 @@
// Which pane shows which secret request. One card per pane; the rest count as "N more requests".
import type { SecretRequestEntry } from './secret-request-store'
export type SecretRequestPaneSlot = {
paneKey: string
environmentId: string
/** The remote terminal handle this pane runs, when known. */
handle: string | null
/** The pane's tab is the one showing. */
visible: boolean
/** The active pane of a showing tab: where requests without a showing asking pane land. */
primary: boolean
}
export type RoutedSecretRequest = {
entry: SecretRequestEntry
/** Shown on the pane running the asking terminal, so "Restart {{agent}}" restarts the right one. */
onAskingPane: boolean
}
function handleKey(environmentId: string, handle: string): string {
return JSON.stringify([environmentId, handle])
}
/**
* The asking terminal's pane when its tab is showing; else the active pane of a showing tab on the
* same host; else the asking pane even though hidden (it shows when that tab is opened).
*/
export function routeSecretRequests(
entries: readonly SecretRequestEntry[],
slots: readonly SecretRequestPaneSlot[]
): Map<string, RoutedSecretRequest[]> {
const routed = new Map<string, RoutedSecretRequest[]>()
const byHandle = new Map<string, SecretRequestPaneSlot>()
for (const slot of slots) {
if (slot.handle) {
byHandle.set(handleKey(slot.environmentId, slot.handle), slot)
}
}
// Deterministic when several showing tabs share a host: the lowest pane key.
const primaryByEnvironment = new Map<string, string>()
for (const slot of slots) {
const current = primaryByEnvironment.get(slot.environmentId)
if (slot.primary && slot.visible && (current === undefined || slot.paneKey < current)) {
primaryByEnvironment.set(slot.environmentId, slot.paneKey)
}
}
for (const entry of entries) {
const handle = entry.event.terminalHandle
const asking = handle ? byHandle.get(handleKey(entry.environmentId, handle)) : undefined
const primary = primaryByEnvironment.get(entry.environmentId)
const paneKey = asking?.visible ? asking.paneKey : (primary ?? asking?.paneKey)
if (!paneKey) {
continue
}
const item = { entry, onAskingPane: asking?.paneKey === paneKey }
const list = routed.get(paneKey)
if (list) {
list.push(item)
} else {
routed.set(paneKey, [item])
}
}
return routed
}
/** Pending requests no showing pane displays (the person is looking at another workspace). */
export function unseenSecretRequests(
entries: readonly SecretRequestEntry[],
slots: readonly SecretRequestPaneSlot[]
): SecretRequestEntry[] {
const routed = routeSecretRequests(entries, slots)
const visibleKeys = new Set(slots.filter((slot) => slot.visible).map((slot) => slot.paneKey))
const seen = new Set<SecretRequestEntry>()
for (const [paneKey, items] of routed) {
if (visibleKeys.has(paneKey)) {
for (const item of items) {
seen.add(item.entry)
}
}
}
return entries.filter(
(entry) => !seen.has(entry) && entry.local === null && entry.settled === null
)
}
const AGENT_DISPLAY_NAME: Record<string, string> = {
claude: 'Claude',
codex: 'Codex',
gemini: 'Gemini',
cursor: 'Cursor',
opencode: 'OpenCode',
copilot: 'Copilot',
pi: 'Pi',
amp: 'Amp',
droid: 'Droid',
aider: 'Aider'
}
/** orcad names the agent by id; unknown ids still read as a name, never as markup. */
export function secretRequestAgentName(agent: string | null): string | null {
if (!agent) {
return null
}
const known = AGENT_DISPLAY_NAME[agent.toLowerCase()]
if (known) {
return known
}
const clean = agent.replace(/[^A-Za-z0-9 ._-]/g, '').slice(0, 32)
return clean ? clean.charAt(0).toUpperCase() + clean.slice(1) : null
}
/** Agents Orca can restart in place in a pane (with --resume where the CLI supports it). */
export function restartableSecretRequestAgent(agent: string | null): 'claude' | 'codex' | null {
return agent === 'claude' || agent === 'codex' ? agent : null
}
@@ -0,0 +1,178 @@
import { afterEach, describe, expect, it } from 'vitest'
import type { SecretRequestedEvent } from '../../../../shared/secret-request-contract'
import {
applySecretRequested,
applySecretRequestSettled,
dismissSecretRequest,
readSecretRequests,
resetSecretRequestStoreForTests,
pruneSecretRequestsOnReady,
setSecretRequestExpiry,
setSecretRequestLocal
} from './secret-request-store'
import { isSecretRequestedEvent, isSecretRequestSettledEvent } from './secret-request-event-guards'
import {
routeSecretRequests,
secretRequestAgentName,
unseenSecretRequests,
type SecretRequestPaneSlot
} from './secret-request-routing'
function requested(patch: Partial<SecretRequestedEvent> = {}): SecretRequestedEvent {
return {
type: 'secrets.requested',
requestId: 'sr_aaaaaaaa',
name: 'NPM_TOKEN',
reason: 'Needed for npm.acme.dev',
scope: 'personal',
agent: 'claude',
worktreeId: null,
terminalHandle: 'h1',
requestedAt: 1_000,
expiresAt: 1_000 + 15 * 60_000,
...patch
}
}
describe('secret request store', () => {
afterEach(() => resetSecretRequestStoreForTests())
it('keeps what this window did when orcad replays a request on attach', () => {
applySecretRequested('env', requested())
setSecretRequestLocal('env', 'sr_aaaaaaaa', 'saving')
applySecretRequested('env', requested())
expect(readSecretRequests()).toHaveLength(1)
expect(readSecretRequests()[0].local).toBe('saving')
})
it('drops a request another client settled, but keeps one this window finished', () => {
applySecretRequested('env', requested())
applySecretRequested('env', requested({ requestId: 'sr_bbbbbbbb', name: 'B' }))
setSecretRequestLocal('env', 'sr_bbbbbbbb', 'saved')
applySecretRequestSettled('env', {
type: 'secrets.requestSettled',
requestId: 'sr_aaaaaaaa',
status: 'declined'
})
applySecretRequestSettled('env', {
type: 'secrets.requestSettled',
requestId: 'sr_bbbbbbbb',
status: 'provided'
})
expect(readSecretRequests().map((entry) => entry.event.name)).toEqual(['B'])
dismissSecretRequest('env', 'sr_bbbbbbbb')
expect(readSecretRequests()).toEqual([])
})
it('shows an expiry reported by orcad as the expired state', () => {
applySecretRequested('env', requested())
applySecretRequestSettled('env', {
type: 'secrets.requestSettled',
requestId: 'sr_aaaaaaaa',
status: 'expired'
})
expect(readSecretRequests()[0].local).toBe('expired')
})
it('moves the expiry when a touch extends it', () => {
applySecretRequested('env', requested())
setSecretRequestExpiry('env', 'sr_aaaaaaaa', 99_000_000)
expect(readSecretRequests()[0].event.expiresAt).toBe(99_000_000)
})
it('on ready, drops what orcad no longer replays (it replays pending requests before ready)', () => {
applySecretRequested('env', requested())
applySecretRequested('env', requested({ requestId: 'sr_bbbbbbbb', name: 'B' }))
applySecretRequested('env', requested({ requestId: 'sr_cccccccc', name: 'C' }))
pruneSecretRequestsOnReady('env')
expect(readSecretRequests()).toHaveLength(3)
setSecretRequestLocal('env', 'sr_bbbbbbbb', 'saved')
// Reconnect: only C is still pending in orcad and replayed, then ready.
applySecretRequested('env', requested({ requestId: 'sr_cccccccc', name: 'C' }))
pruneSecretRequestsOnReady('env')
// A was settled while away; B is kept because this window is showing its result.
expect(readSecretRequests().map((entry) => entry.event.name)).toEqual(['B', 'C'])
})
it('keeps requests from different hosts apart', () => {
applySecretRequested('a', requested())
applySecretRequested('b', requested())
expect(readSecretRequests()).toHaveLength(2)
})
})
describe('event guards', () => {
it('accepts the contract shape and rejects markup or oversized fields', () => {
expect(isSecretRequestedEvent(requested())).toBe(true)
expect(isSecretRequestedEvent(requested({ name: '<b>x</b>' }))).toBe(false)
expect(isSecretRequestedEvent(requested({ reason: 'x'.repeat(201) }))).toBe(false)
expect(isSecretRequestedEvent({ ...requested(), requestId: 'nope' })).toBe(false)
expect(
isSecretRequestSettledEvent({
type: 'secrets.requestSettled',
requestId: 'sr_aaaaaaaa',
status: 'provided'
})
).toBe(true)
})
})
describe('routeSecretRequests', () => {
afterEach(() => resetSecretRequestStoreForTests())
const slot = (patch: Partial<SecretRequestPaneSlot>): SecretRequestPaneSlot => ({
paneKey: 'tab:1',
environmentId: 'env',
handle: null,
visible: true,
primary: false,
...patch
})
it('pins a request to the showing pane running the asking terminal', () => {
applySecretRequested('env', requested({ terminalHandle: 'h2' }))
const routed = routeSecretRequests(readSecretRequests(), [
slot({ paneKey: 'tab:1', handle: 'h1', primary: true }),
slot({ paneKey: 'tab:2', handle: 'h2' })
])
expect([...routed.keys()]).toEqual(['tab:2'])
expect(routed.get('tab:2')?.[0].onAskingPane).toBe(true)
})
it('shows a request from a hidden tab on the showing pane, without Restart rights there', () => {
applySecretRequested('env', requested({ terminalHandle: 'h2' }))
const routed = routeSecretRequests(readSecretRequests(), [
slot({ paneKey: 'tab:1', handle: 'h1', primary: true }),
slot({ paneKey: 'tab:2', handle: 'h2', visible: false })
])
expect([...routed.keys()]).toEqual(['tab:1'])
expect(routed.get('tab:1')?.[0].onAskingPane).toBe(false)
})
it('falls back to the active pane on the same host, and stacks the rest there', () => {
applySecretRequested('env', requested({ terminalHandle: undefined }))
applySecretRequested('env', requested({ requestId: 'sr_cccccccc', terminalHandle: 'gone' }))
applySecretRequested('other', requested({ requestId: 'sr_dddddddd' }))
const slots = [
slot({ paneKey: 'tab:1', handle: 'h1' }),
slot({ paneKey: 'tab:9', handle: 'h9', primary: true })
]
const routed = routeSecretRequests(readSecretRequests(), slots)
expect(routed.get('tab:9')?.map((item) => item.entry.event.requestId)).toEqual([
'sr_aaaaaaaa',
'sr_cccccccc'
])
// Nothing on the other host is mounted, so its request is unseen (the notifier says so).
expect(routed.size).toBe(1)
expect(unseenSecretRequests(readSecretRequests(), slots).map((e) => e.environmentId)).toEqual([
'other'
])
})
})
describe('secretRequestAgentName', () => {
it('names known agents and cleans unknown ids', () => {
expect(secretRequestAgentName('claude')).toBe('Claude')
expect(secretRequestAgentName('my<agent>')).toBe('Myagent')
expect(secretRequestAgentName(null)).toBeNull()
})
})
@@ -0,0 +1,230 @@
// Agent secret requests (contract §3) seen on each runtime environment's client-event stream, plus what
// this window did with them. Names, reasons and ids only: the value a person types stays in the card.
import { useSyncExternalStore } from 'react'
import type {
SecretRequestedEvent,
SecretRequestSettledEvent,
SecretRequestStatus
} from '../../../../shared/secret-request-contract'
/** What this window is showing for a request once the person acted on it. */
export type SecretRequestLocalPhase =
| 'saving'
/** Saved and the agent was told. */
| 'saved'
/** Saved, but the request had expired, so the agent stopped waiting (D3). */
| 'savedAfterExpiry'
| 'expired'
export type SecretRequestEntry = {
environmentId: string
event: SecretRequestedEvent
/** Set by orcad's settle event, or by this window's own resolve. */
settled: Exclude<SecretRequestStatus, 'pending'> | null
local: SecretRequestLocalPhase | null
/** This window's clock when the event first arrived (ties in requestedAt keep arrival order). */
receivedAt: number
/** When this window last sent touchRequest (D3: at most once a minute, across card remounts). */
touchedAt: number
}
type State = { entries: ReadonlyMap<string, SecretRequestEntry> }
let state: State = { entries: new Map() }
const listeners = new Set<() => void>()
function commit(next: Map<string, SecretRequestEntry>): void {
state = { entries: next }
for (const listener of listeners) {
listener()
}
}
function subscribe(listener: () => void): () => void {
listeners.add(listener)
return () => listeners.delete(listener)
}
function keyOf(environmentId: string, requestId: string): string {
return `${environmentId}\u0000${requestId}`
}
/** A card keeps showing once the person acted (saving, done, expired) until they dismiss it. */
function stillShown(entry: SecretRequestEntry): boolean {
return entry.local !== null || entry.settled === null
}
export function applySecretRequested(
environmentId: string,
event: SecretRequestedEvent,
now = Date.now()
): void {
const key = keyOf(environmentId, event.requestId)
const existing = state.entries.get(key)
const next = new Map(state.entries)
// A replay on attach repeats the event; keep what this window already did with it.
next.set(key, {
environmentId,
event,
settled: existing?.settled ?? null,
local: existing?.local ?? null,
receivedAt: existing?.receivedAt ?? now,
touchedAt: existing?.touchedAt ?? 0
})
const seen = seenSinceReady.get(environmentId) ?? new Set<string>()
seen.add(event.requestId)
seenSinceReady.set(environmentId, seen)
commit(next)
}
/** Any client's touchRequest moved the expiry (secrets.requestUpdated). */
export function applySecretRequestUpdated(
environmentId: string,
event: { requestId: string; expiresAt: number }
): void {
setSecretRequestExpiry(environmentId, event.requestId, event.expiresAt)
}
// Requests seen on each host's stream since its last 'ready'. orcad replays every pending request
// right before 'ready', so at 'ready' anything unresolved and unseen was settled while we were away.
const seenSinceReady = new Map<string, Set<string>>()
/** The stream sent 'ready': drop requests orcad no longer has (unless this window is showing a result). */
export function pruneSecretRequestsOnReady(environmentId: string): void {
const seen = seenSinceReady.get(environmentId) ?? new Set<string>()
seenSinceReady.set(environmentId, new Set())
const pruned = new Map(
[...state.entries].filter(
([, entry]) =>
entry.environmentId !== environmentId ||
entry.local !== null ||
seen.has(entry.event.requestId)
)
)
if (pruned.size !== state.entries.size) {
commit(pruned)
}
}
export function setSecretRequestTouched(
environmentId: string,
requestId: string,
touchedAt: number
): void {
const key = keyOf(environmentId, requestId)
const existing = state.entries.get(key)
if (!existing) {
return
}
const next = new Map(state.entries)
next.set(key, { ...existing, touchedAt })
commit(next)
}
export function applySecretRequestSettled(
environmentId: string,
event: SecretRequestSettledEvent
): void {
const key = keyOf(environmentId, event.requestId)
const existing = state.entries.get(key)
if (!existing) {
return
}
const next = new Map(state.entries)
const updated: SecretRequestEntry = {
...existing,
settled: event.status,
// Expiry reported by orcad shows the expired state unless this window already finished it.
local: event.status === 'expired' && existing.local === null ? 'expired' : existing.local
}
if (stillShown(updated)) {
next.set(key, updated)
} else {
next.delete(key)
}
commit(next)
}
export function setSecretRequestLocal(
environmentId: string,
requestId: string,
local: SecretRequestLocalPhase | null
): void {
const key = keyOf(environmentId, requestId)
const existing = state.entries.get(key)
if (!existing || existing.local === local) {
return
}
const next = new Map(state.entries)
const updated = { ...existing, local }
if (stillShown(updated)) {
next.set(key, updated)
} else {
next.delete(key)
}
commit(next)
}
/** touchRequest moved the expiry; the card's clock follows it. */
export function setSecretRequestExpiry(
environmentId: string,
requestId: string,
expiresAt: number
): void {
const key = keyOf(environmentId, requestId)
const existing = state.entries.get(key)
if (!existing || existing.event.expiresAt === expiresAt) {
return
}
const next = new Map(state.entries)
next.set(key, { ...existing, event: { ...existing.event, expiresAt } })
commit(next)
}
export function dismissSecretRequest(environmentId: string, requestId: string): void {
const key = keyOf(environmentId, requestId)
if (!state.entries.has(key)) {
return
}
const next = new Map(state.entries)
next.delete(key)
commit(next)
}
const EMPTY: readonly SecretRequestEntry[] = []
let cachedFor: State | null = null
let cachedList: readonly SecretRequestEntry[] = EMPTY
function listAll(): readonly SecretRequestEntry[] {
if (cachedFor !== state) {
cachedFor = state
cachedList =
state.entries.size === 0
? EMPTY
: [...state.entries.values()].sort(
(a, b) => a.event.requestedAt - b.event.requestedAt || a.receivedAt - b.receivedAt
)
}
return cachedList
}
/** Every request this window shows a card for, oldest first. */
export function useSecretRequests(): readonly SecretRequestEntry[] {
return useSyncExternalStore(subscribe, listAll, listAll)
}
export function readSecretRequests(): readonly SecretRequestEntry[] {
return listAll()
}
export function resetSecretRequestStoreForTests(): void {
seenSinceReady.clear()
state = { entries: new Map() }
cachedFor = null
cachedList = EMPTY
}
/** When this window last touched the request, read at the time a hold starts. */
export function readSecretRequestTouchedAt(environmentId: string, requestId: string): number {
return state.entries.get(keyOf(environmentId, requestId))?.touchedAt ?? 0
}
@@ -9,6 +9,22 @@ import { admitSshConnectionState } from '../../../../../shared/ssh-retained-payl
import { getRuntimeEnvironmentRevision } from '../environment-revisions/runtime-environment-revision'
import { subscribeRuntimeEnvironment } from '../environment-revisions/runtime-environment-pairing-refresh'
import { answerBrowserClientHostRequest } from '../../browser/client-hosting/browser-client-host-request-answer'
import {
SECRETS_REQUEST_SETTLED_EVENT,
SECRETS_REQUEST_UPDATED_EVENT,
SECRETS_REQUESTED_EVENT
} from '../../../../../shared/secret-request-contract'
import {
applySecretRequested,
applySecretRequestSettled,
applySecretRequestUpdated,
pruneSecretRequestsOnReady
} from '@/lib/secret-requests/secret-request-store'
import {
isSecretRequestedEvent,
isSecretRequestSettledEvent,
isSecretRequestUpdatedEvent
} from '@/lib/secret-requests/secret-request-event-guards'
export type RuntimeClientEventSubscription = {
unsubscribe: () => void
@@ -64,6 +80,8 @@ function handleRuntimeClientEventResponse(
}
const message = response.result as RuntimeClientEventStreamMessage
if (message.type === 'ready') {
// orcad replays pending secret requests just before this; ones it didn't replay are gone.
pruneSecretRequestsOnReady(environmentId)
for (const sshState of message.snapshot?.sshStates ?? []) {
const state = admitSshConnectionState(sshState.state, sshState.targetId)
if (state) {
@@ -81,6 +99,25 @@ function handleRuntimeClientEventResponse(
void answerBrowserClientHostRequest(environmentId, message.requestId)
return
}
// Agent secret requests go to their own store; the pane cards read it (contract §3).
if (message.type === SECRETS_REQUESTED_EVENT) {
if (isSecretRequestedEvent(message)) {
applySecretRequested(environmentId, message)
}
return
}
if (message.type === SECRETS_REQUEST_UPDATED_EVENT) {
if (isSecretRequestUpdatedEvent(message)) {
applySecretRequestUpdated(environmentId, message)
}
return
}
if (message.type === SECRETS_REQUEST_SETTLED_EVENT) {
if (isSecretRequestSettledEvent(message)) {
applySecretRequestSettled(environmentId, message)
}
return
}
if (message.type === 'sshStateChanged') {
const state = admitSshConnectionState(message.state, message.targetId)
if (state) {
@@ -126,3 +126,28 @@ export type CloudSecretsResult<T> =
export type CloudSecretTarget = { scope: CloudSecretScope; repo?: string }
export type CloudSecretsStatus = { available: boolean }
/**
* GET /v1/agent-sources?workspaceId= (contract §2), read for who a workspace's values belong to. Loose:
* the panel needs only the owner and the shared flag (D13); the rest belongs to the Agents slice.
*/
export const CloudAgentSourcesResponseSchema = z.looseObject({
owner: z
.object({
userId: z.string().max(256),
displayName: z.string().max(256).nullable(),
isCaller: z.boolean()
})
.nullable()
.optional(),
/** D13: more than one member, so personal accounts aren't used there. */
sharedWorkspace: z.boolean().optional()
})
/** Whose workspace this is, as far as Cloud secrets go. */
export type CloudWorkspaceSharing = {
shared: boolean
/** The owner's display name; null when it's the caller or unknown. */
ownerName: string | null
isOwner: boolean
}