feat(cloud): sign agents out before sandbox delete, scrub env-build images, recipe env warning (A9, P-4)

- Worker: run orca-agent-signout (each agent's logout, then credential files) on a running sandbox
  before the provider delete; bounded (20 s) and best effort, never blocks the delete.
- Environment builds: scrub agent auth (--scrub-only) before the snapshot; fail closed.
- New VM script registered in VM_SCRIPTS + provision loop; vm-scripts regenerated.
- Main IPC ephemeralVm:forwardedEnvSummary returns names only, grouped by credential-looking.
- P-4 warning component (+ fixture) shown under Settings > recipes; delete dialog line for awake
  Orca Cloud sandboxes.
This commit is contained in:
Neil
2026-11-10 04:07:13 -08:00
parent 0f8ab90314
commit 12fe0c867f
21 changed files with 690 additions and 4 deletions
@@ -0,0 +1,13 @@
import { ipcMain } from 'electron'
import {
summarizeRecipeForwardedEnv,
type RecipeForwardedEnvSummary
} from '../../shared/ephemeral-vm-recipe-forwarded-env'
/** Recipe scripts inherit this process's env (buildRecipeEnv); only the names cross to the renderer. */
export function registerRecipeForwardedEnvHandler(): void {
ipcMain.removeHandler('ephemeralVm:forwardedEnvSummary')
ipcMain.handle('ephemeralVm:forwardedEnvSummary', (): RecipeForwardedEnvSummary =>
summarizeRecipeForwardedEnv(process.env)
)
}
+3
View File
@@ -54,6 +54,7 @@ import {
pickEphemeralVmFailureCode,
type EphemeralVmFailure
} from '../../shared/ephemeral-vm-failure-copy'
import { registerRecipeForwardedEnvHandler } from './ephemeral-vm-forwarded-env'
const activeProvisionControllers = new Map<string, AbortController>()
@@ -97,6 +98,8 @@ export function registerEphemeralVmHandlers(store: Store, pluginService?: Plugin
return listPickerRecipes(store, args.repoId, pluginService)
})
registerRecipeForwardedEnvHandler()
ipcMain.handle(
'ephemeralVm:listRecipeCatalog',
async (): Promise<EphemeralVmRecipeCatalogEntry[]> => {
+3
View File
@@ -23,6 +23,7 @@ import type {
} from '../../shared/cloud-ssh'
import type { CloudRemoteDesktopResult } from '../../shared/hosted-sandbox/cloud-remote-desktop'
import type { PendingVmCheckout } from '../../shared/pending-vm-checkouts'
import type { RecipeForwardedEnvSummary } from '../../shared/ephemeral-vm-recipe-forwarded-env'
export type EphemeralVmApi = {
listRecipes: (args: { repoId: string }) => Promise<{
@@ -45,6 +46,8 @@ export type EphemeralVmApi = {
diagnostics: NonNullable<OrcaHooks['environmentRecipeDiagnostics']>
}[]
>
/** Names (never values) of this computer's env that recipe create scripts receive. */
forwardedEnvSummary: () => Promise<RecipeForwardedEnvSummary>
doctor: (args: { repoId: string; recipeId: string }) => Promise<EphemeralVmRecipeDoctorResult>
provision: (args: {
repoId: string
+1
View File
@@ -14,6 +14,7 @@ import {
export const ephemeralVmApi = {
listRecipes: (args) => ipcRenderer.invoke('ephemeralVm:listRecipes', args),
listRecipeCatalog: () => ipcRenderer.invoke('ephemeralVm:listRecipeCatalog'),
forwardedEnvSummary: () => ipcRenderer.invoke('ephemeralVm:forwardedEnvSummary'),
doctor: (args) => ipcRenderer.invoke('ephemeralVm:doctor', args),
provision: (args) => ipcRenderer.invoke('ephemeralVm:provision', args),
cancelProvision: (args) => ipcRenderer.invoke('ephemeralVm:cancelProvision', args),
@@ -0,0 +1,45 @@
import { defineFixture } from '../../component-fixtures/component-fixture'
import { RecipeEnvForwardingWarning } from './RecipeEnvForwardingWarning'
const summary = {
count: 42,
keyLike: ['ANTHROPIC_API_KEY', 'GITHUB_TOKEN', 'NPM_TOKEN'],
other: ['EDITOR', 'HOME', 'LANG', 'PATH', 'SHELL', 'TERM', 'USER']
}
export const warning = defineFixture({
width: 420,
render: () => <RecipeEnvForwardingWarning summary={summary} />
})
export const review = defineFixture({
width: 420,
render: () => <RecipeEnvForwardingWarning summary={summary} defaultReviewOpen />
})
export const manyKeys = defineFixture({
width: 420,
render: () => (
<RecipeEnvForwardingWarning
summary={{
...summary,
keyLike: ['ANTHROPIC_API_KEY', 'AWS_SECRET_ACCESS_KEY', 'GITHUB_TOKEN', 'NPM_TOKEN']
}}
/>
)
})
export const noKeys = defineFixture({
width: 420,
render: () => <RecipeEnvForwardingWarning summary={{ ...summary, keyLike: [] }} />
})
export const loading = defineFixture({
width: 420,
render: () => <RecipeEnvForwardingWarning summary={null} />
})
export const unavailable = defineFixture({
width: 420,
render: () => <RecipeEnvForwardingWarning summary={undefined} />
})
@@ -0,0 +1,151 @@
import { TriangleAlert } from 'lucide-react'
import type React from 'react'
import { useEffect, useId, useState } from 'react'
import { useTranslate } from '@/i18n/use-translate'
import { Button } from '../ui/button'
import { Skeleton } from '../ui/skeleton'
import type { RecipeForwardedEnvSummary } from '../../../../shared/ephemeral-vm-recipe-forwarded-env'
const NAMES_IN_LINE = 3
/**
* P-4 (agent accounts spec §4, Q9): recipe create scripts get this computer's whole environment.
* A warning only; names, never values. `summary` null while loading, undefined when unavailable.
*/
export function RecipeEnvForwardingWarning({
summary,
defaultReviewOpen = false
}: {
summary: RecipeForwardedEnvSummary | null | undefined
defaultReviewOpen?: boolean
}): React.JSX.Element {
const t = useTranslate()
const listId = useId()
const [reviewOpen, setReviewOpen] = useState(defaultReviewOpen)
const keyLike = summary?.keyLike ?? []
const names =
keyLike.slice(0, NAMES_IN_LINE).join(', ') + (keyLike.length > NAMES_IN_LINE ? ', …' : '')
const detail = !summary
? null
: keyLike.length === 0
? t(
'agentHostPanel.recipeEnvDetailNoKeys',
'{{count}} variables, none that look like keys. The recipe decides what reaches the VM.',
{ count: summary.count }
)
: keyLike.length === 1
? t(
'agentHostPanel.recipeEnvDetailOneKey',
'{{count}} variables, including 1 that looks like a key ({{names}}). The recipe decides what reaches the VM.',
{ count: summary.count, names }
)
: t(
'agentHostPanel.recipeEnvDetail',
'{{count}} variables, including {{keys}} that look like keys ({{names}}). The recipe decides what reaches the VM.',
{ count: summary.count, keys: keyLike.length, names }
)
return (
<div
role="note"
aria-busy={summary === null}
className="rounded-lg border border-status-warning-border bg-status-warning-background px-3 py-2.5 text-xs leading-5"
>
<div className="flex items-start gap-2.5">
<TriangleAlert className="mt-0.5 size-4 shrink-0 text-status-warning" aria-hidden />
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-sm font-medium text-foreground">
{t(
'agentHostPanel.recipeEnv',
"This recipe's create script gets this computer's environment."
)}
</p>
{summary === null ? (
<Skeleton className="h-3.5 w-4/5" shape="small" />
) : detail ? (
<p className="text-muted-foreground">{detail}</p>
) : null}
</div>
{summary ? (
<Button
type="button"
variant="outline"
size="xs"
className="shrink-0"
aria-expanded={reviewOpen}
aria-controls={listId}
onClick={() => setReviewOpen((open) => !open)}
>
{reviewOpen
? t('agentHostPanel.recipeEnvHide', 'Hide')
: t('agentHostPanel.recipeEnvReview', 'Review')}
</Button>
) : null}
</div>
{summary && reviewOpen ? (
<div id={listId} className="scrollbar-sleek mt-2 max-h-56 space-y-2 overflow-y-auto pl-6.5">
<ForwardedNameGroup
label={t('agentHostPanel.recipeEnvKeyLike', 'Look like keys')}
names={summary.keyLike}
/>
<ForwardedNameGroup
label={t('agentHostPanel.recipeEnvOther', 'Other variables')}
names={summary.other}
/>
</div>
) : null}
</div>
)
}
function ForwardedNameGroup({
label,
names
}: {
label: string
names: string[]
}): React.JSX.Element | null {
const headingId = useId()
if (names.length === 0) {
return null
}
return (
<section aria-labelledby={headingId}>
<h4 id={headingId} className="font-medium text-foreground">
{label}
</h4>
<ul className="font-mono text-muted-foreground">
{names.map((name) => (
<li key={name} className="break-all">
{name}
</li>
))}
</ul>
</section>
)
}
/** Loads the names from main; recipe scripts run there, so this computer is main's process env. */
export function RecipeEnvForwardingNotice(): React.JSX.Element {
const [summary, setSummary] = useState<RecipeForwardedEnvSummary | null | undefined>(null)
useEffect(() => {
let live = true
void (async () => {
try {
const next = await window.api.ephemeralVm.forwardedEnvSummary()
if (live) {
setSummary(next)
}
} catch {
// Older main or web client: the warning still stands, without the counts.
if (live) {
setSummary(undefined)
}
}
})()
return () => {
live = false
}
}, [])
return <RecipeEnvForwardingWarning summary={summary} />
}
@@ -7,6 +7,8 @@ import { useAppStore } from '@/store/index'
import { Button } from '../../../ui/button'
import { AgentSkillSetupPanel } from '../../../skills/setup/AgentSkillSetupPanel'
import { EphemeralVmRecipeRow } from './EphemeralVmRecipeRow'
import { RecipeEnvForwardingNotice } from '../../../agent-host-panel/RecipeEnvForwardingWarning'
import { ORCA_CLOUD_RECIPE_ID } from '../../../../../../shared/hosted-sandbox/orca-cloud-recipe-id'
import { translate } from '@/i18n/i18n'
import { useTranslate } from '@/i18n/use-translate'
import { EPHEMERAL_VMS_SKILL_NAME } from '@/lib/skills/installation/agent-feature-skill-names'
@@ -308,6 +310,9 @@ export function EphemeralVmsPane(): React.JSX.Element {
</div>
)}
</div>
{recipes.some(({ recipe }) => recipe.id !== ORCA_CLOUD_RECIPE_ID) ? (
<RecipeEnvForwardingNotice />
) : null}
</div>
</div>
)
@@ -0,0 +1,25 @@
import type React from 'react'
import { useTranslate } from '@/i18n/use-translate'
import { useCloudSshMenuState, type CloudSshWorktree } from '../cloud-ssh/use-cloud-ssh-status'
/** A9: the control plane runs each agent's sign-out on an awake sandbox before deleting it. */
export function DeleteCloudSandboxAgentsNote({
worktree
}: {
worktree: CloudSshWorktree
}): React.JSX.Element | null {
const t = useTranslate()
const state = useCloudSshMenuState(worktree)
// Asleep sandboxes are never woken for it; their disk goes with the delete.
if (state.kind !== 'ready' || state.status.asleep) {
return null
}
return (
<p className="text-xs text-muted-foreground">
{t(
'auto.components.sidebar.DeleteWorktreeDialog.cloudSignsAgentsOut',
'Signs agents out on the sandbox first.'
)}
</p>
)
}
@@ -44,6 +44,7 @@ import { useDeleteWorktreeDialogTargets } from './use-delete-worktree-dialog-tar
import { collectRemovalUnsavedFiles } from '@/components/editor/save/flush/removal-unsaved-editor-files'
import { useDetachWorktreeDeleteChildren } from './use-detach-worktree-delete-children'
import { useDeleteWorktreeDialogDismissal } from './use-delete-worktree-dialog-dismissal'
import { DeleteCloudSandboxAgentsNote } from './DeleteCloudSandboxAgentsNote'
const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
const t = useTranslate()
@@ -349,6 +350,8 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
dirtyChangePreviewsByWorktreeId={dirtyChanges.previews}
/>
{!isBatchDelete && worktree ? <DeleteCloudSandboxAgentsNote worktree={worktree} /> : null}
<RemovalUnsavedFilesNotice targets={unsavedTargets} enabled={isOpen} />
{(childWorkspaceCount > 0 || preserveChildrenSelected) && (
+15 -1
View File
@@ -6605,7 +6605,8 @@
"removeWithChildrenGit_one": "Remove <target/> and <descendants>{{count}} child workspace</descendants> from git and delete their workspace folders.",
"removeWithChildrenGit_other": "Remove <target/> and <descendants>{{count}} child workspaces</descendants> from git and delete their workspace folders.",
"removeSingleFolderFromOrca": "Removes it from Orca. The folder on disk is kept.",
"deleteSingleWorktreeAndBranch": "Deletes the worktree and its branch (kept if unmerged)."
"deleteSingleWorktreeAndBranch": "Deletes the worktree and its branch (kept if unmerged).",
"cloudSignsAgentsOut": "Signs agents out on the sandbox first."
},
"DeleteWorktreeDirtyChangeHint": {
"showLoadedPaths": "Show loaded paths",
@@ -27095,5 +27096,18 @@
"tabTitle": "Run: {{name}}",
"noneTitle": "No run actions for this workspace",
"noneBody": "Add a command under scripts.run in orca.yaml, for example a dev server on $ORCA_PORT."
},
"agentHostPanel": {
"recipeEnv": "This recipe's create script gets this computer's environment.",
"recipeEnvDetail_one": "{{count}} variable, including {{keys}} that look like keys ({{names}}). The recipe decides what reaches the VM.",
"recipeEnvDetail_other": "{{count}} variables, including {{keys}} that look like keys ({{names}}). The recipe decides what reaches the VM.",
"recipeEnvDetailOneKey_one": "{{count}} variable, including 1 that looks like a key ({{names}}). The recipe decides what reaches the VM.",
"recipeEnvDetailOneKey_other": "{{count}} variables, including 1 that looks like a key ({{names}}). The recipe decides what reaches the VM.",
"recipeEnvDetailNoKeys_one": "{{count}} variable, none that look like keys. The recipe decides what reaches the VM.",
"recipeEnvDetailNoKeys_other": "{{count}} variables, none that look like keys. The recipe decides what reaches the VM.",
"recipeEnvReview": "Review",
"recipeEnvHide": "Hide",
"recipeEnvKeyLike": "Look like keys",
"recipeEnvOther": "Other variables"
}
}
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import { summarizeRecipeForwardedEnv } from './ephemeral-vm-recipe-forwarded-env'
describe('summarizeRecipeForwardedEnv', () => {
it('returns names only, grouping the ones that look like credentials', () => {
const summary = summarizeRecipeForwardedEnv({
PATH: '/usr/bin',
HOME: '/home/me',
GITHUB_TOKEN: 'ghp_secret_value',
AWS_SECRET_ACCESS_KEY: 'aws-secret-value',
DB_PASSWORD: 'hunter2',
ANTHROPIC_BASE_URL: 'https://gateway.example',
openai_api_key: 'sk-lower',
KEYCHAIN_PATH: '/tmp/k',
UNSET: undefined
})
expect(summary).toEqual({
count: 8,
keyLike: [
'ANTHROPIC_BASE_URL',
'AWS_SECRET_ACCESS_KEY',
'DB_PASSWORD',
'GITHUB_TOKEN',
'openai_api_key'
],
other: ['HOME', 'KEYCHAIN_PATH', 'PATH']
})
expect(JSON.stringify(summary)).not.toMatch(
/ghp_secret_value|aws-secret-value|hunter2|sk-lower/
)
})
})
@@ -0,0 +1,46 @@
import { AGENT_CREDENTIAL_NAMES } from './agent-credentials/agent-credential-names'
/** Names only: recipe create scripts get this computer's whole env, so values never leave main. */
export type RecipeForwardedEnvSummary = {
count: number
/** Sorted names that look like credentials (key/token/secret/password, or an agent credential). */
keyLike: string[]
/** Every other forwarded name, sorted. */
other: string[]
}
const AGENT_CREDENTIAL_NAME_SET = new Set(Object.values(AGENT_CREDENTIAL_NAMES).flat())
const CREDENTIAL_SEGMENTS = new Set([
'KEY',
'KEYS',
'APIKEY',
'TOKEN',
'TOKENS',
'SECRET',
'SECRETS',
'CREDENTIAL',
'CREDENTIALS',
'PAT'
])
const CREDENTIAL_SUBSTRINGS = /PASSWORD|PASSWD|SECRET|TOKEN|PRIVATE_KEY|API_?KEY/
export function looksLikeCredentialEnvName(name: string): boolean {
// Windows env names are case-insensitive; match them the way agents would read them.
const upper = name.toUpperCase()
return (
AGENT_CREDENTIAL_NAME_SET.has(upper) ||
CREDENTIAL_SUBSTRINGS.test(upper) ||
upper.split(/[_\-.]/).some((segment) => CREDENTIAL_SEGMENTS.has(segment))
)
}
export function summarizeRecipeForwardedEnv(env: NodeJS.ProcessEnv): RecipeForwardedEnvSummary {
const names = Object.keys(env)
.filter((name) => name.length > 0 && env[name] !== undefined)
.sort((a, b) => a.localeCompare(b))
return {
count: names.length,
keyLike: names.filter(looksLikeCredentialEnvName),
other: names.filter((name) => !looksLikeCredentialEnvName(name))
}
}