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
@@ -11,6 +11,7 @@ export const VM_SCRIPTS = [
'orca-session-env.sh',
'orca-egress-probe',
'orca-image-wipe',
'orca-agent-signout',
'orca-share-proxy',
'orca-sshd',
'orca-ssh-keys',
@@ -209,7 +209,7 @@ if [ -n "${NOVNC_VERSION:-}" ]; then
fi
log 'Orca entry points'
for name in orca-sandbox-start orca-sandbox-drain orca-sandbox-env orca-session-env.sh orca-egress-probe orca-image-wipe orca-share-proxy orca-sshd orca-ssh-keys orca-desktop-start orca-desktop-proxy orca-desktop-page.js orca-sandbox-grant orca-sandbox-revoke orca-sandbox-members orca-sandbox-read-snapshot orca-read-snapshot.cjs orca-git-as orca-git-identity orcad-boot git; do
for name in orca-sandbox-start orca-sandbox-drain orca-sandbox-env orca-session-env.sh orca-egress-probe orca-image-wipe orca-agent-signout orca-share-proxy orca-sshd orca-ssh-keys orca-desktop-start orca-desktop-proxy orca-desktop-page.js orca-sandbox-grant orca-sandbox-revoke orca-sandbox-members orca-sandbox-read-snapshot orca-read-snapshot.cjs orca-git-as orca-git-identity orcad-boot git; do
var="ORCA_VM_SCRIPT_${name//[-.]/_}"
printf '%s' "${!var}" | base64 -d >"$ORCA_HOME/bin/$name"
chmod 0755 "$ORCA_HOME/bin/$name"
File diff suppressed because one or more lines are too long
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
# Signs every agent out on this sandbox (A9). Two callers, both from the control plane:
# - before Orca deletes a workspace sandbox: each agent's own logout, then the credential files go;
# - `--scrub-only` before an environment build is snapshotted into an image other people boot: the
# files only (no CLI runs, so nothing else in the image changes).
# Never fails: whatever happens, the sandbox is deleted or snapshotted next. Values are never printed.
set -u
HOME="${HOME:-$(getent passwd "$(id -u)" | cut -d: -f6)}"
export PATH="$HOME/.local/bin:$HOME/.npm-global/bin:/usr/local/bin:/usr/bin:/bin:/opt/orca/bin:${PATH:-}"
claude_dir="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
claude_json="${CLAUDE_CONFIG_DIR:-$HOME}/.claude.json"
codex_dir="${CODEX_HOME:-$HOME/.codex}"
logged_out=0
if [ "${1:-}" != --scrub-only ]; then
# Each logout gets a few seconds; a hung CLI must not hold up the delete.
if command -v claude >/dev/null 2>&1 && timeout 8 claude auth logout </dev/null >/dev/null 2>&1; then
logged_out=$((logged_out + 1))
fi
if command -v codex >/dev/null 2>&1 && timeout 8 codex logout </dev/null >/dev/null 2>&1; then
logged_out=$((logged_out + 1))
fi
fi
# The files go whether or not a logout ran: an older CLI, a missing binary or no network leaves them.
rm -f -- "$claude_dir"/.credentials* "$HOME/.claude"/.credentials* "$codex_dir/auth.json" "$HOME/.codex/auth.json" 2>/dev/null
# Orca-managed Codex homes keep their own auth.json under orcad's data dir.
if [ -d "$HOME/.orcad-data" ]; then
find "$HOME/.orcad-data" -path '*codex*' -type f -name auth.json -delete 2>/dev/null
fi
# ~/.claude.json keeps settings Orca wrote (onboarding, trust); only the sign-in fields go.
node_bin="$(command -v node || find /opt/orca/runtime/runtimes -type f -name node 2>/dev/null | head -n 1)"
for file in "$claude_json" "$HOME/.claude.json"; do
[ -f "$file" ] || continue
if [ -n "$node_bin" ]; then
ORCA_FILE="$file" "$node_bin" -e '
const fs = require("fs")
const file = process.env.ORCA_FILE
let c
try { c = JSON.parse(fs.readFileSync(file, "utf8")) } catch { process.exit(2) }
if (!c || typeof c !== "object" || Array.isArray(c)) process.exit(2)
let changed = false
for (const key of Object.keys(c)) {
if (/oauth/i.test(key) || key === "primaryApiKey") { delete c[key]; changed = true }
}
if (changed) {
const tmp = `${file}.orca-${process.pid}`
fs.writeFileSync(tmp, JSON.stringify(c, null, 2) + "\n", { mode: 0o600 })
fs.renameSync(tmp, file)
}
' 2>/dev/null || rm -f -- "$file"
else
# No node to edit it safely: losing Orca's settings beats leaving a sign-in behind.
rm -f -- "$file"
fi
done
left=0
for file in "$claude_dir"/.credentials* "$HOME/.claude"/.credentials* "$codex_dir/auth.json" "$HOME/.codex/auth.json"; do
[ -e "$file" ] && left=$((left + 1))
done
printf '{"loggedOut":%d,"left":%d}\n' "$logged_out" "$left"
@@ -0,0 +1,73 @@
// A9: agents are signed out of a sandbox before Orca deletes it, and an environment build's VM is
// scrubbed of agent sign-ins before it becomes an image other people boot.
//
// Both run the image's orca-agent-signout by source (`bash -c`), not by its installed path, so
// sandboxes booted from images built before the script existed get the same treatment.
import { VM_SCRIPT_SOURCES } from '../image/vm-scripts.generated'
import type { SandboxProviderApi } from './sandbox-provider'
export const AGENT_SIGNOUT_SCRIPT = VM_SCRIPT_SOURCES['orca-agent-signout']
/** Upper bound the delete waits for sign-out; the script gives each CLI 8 s. */
export const AGENT_SIGNOUT_TIMEOUT_MS = 20_000
const SCRUB_TIMEOUT_MS = 30_000
const log = (event: string, fields: Record<string, unknown>) => console.log(JSON.stringify({ event, ...fields }))
function signoutCommand(scrubOnly: boolean): { command: string; args: string[] } {
return { command: 'bash', args: ['-c', AGENT_SIGNOUT_SCRIPT, 'orca-agent-signout', ...(scrubOnly ? ['--scrub-only'] : [])] }
}
/** True when the script ran and reports no credential file left. */
function scrubbed(result: { exitCode: number; stdout: string }): boolean {
if (result.exitCode !== 0) {
return false
}
try {
const report = JSON.parse(result.stdout.trim().split('\n').at(-1) ?? '') as { left?: unknown }
return report.left === 0
} catch {
return false
}
}
/**
* Best effort and bounded: each agent's own logout, then its credential files, on the running VM.
* Never throws and never takes longer than AGENT_SIGNOUT_TIMEOUT_MS, so it cannot block a delete.
*/
export async function signOutAgentsBeforeDelete(
provider: SandboxProviderApi,
sessionId: string,
context: { sandboxId: string }
): Promise<boolean> {
let timer: ReturnType<typeof setTimeout> | undefined
const timedOut = new Promise<'timeout'>((resolve) => {
timer = setTimeout(() => resolve('timeout'), AGENT_SIGNOUT_TIMEOUT_MS)
})
try {
const outcome = await Promise.race([
provider.runCommand(sessionId, { ...signoutCommand(false), timeoutMs: AGENT_SIGNOUT_TIMEOUT_MS }),
timedOut
])
if (outcome === 'timeout') {
log('sandbox_agent_signout', { sandboxId: context.sandboxId, ok: false, reason: 'timeout' })
return false
}
const ok = scrubbed(outcome)
log('sandbox_agent_signout', { sandboxId: context.sandboxId, ok, exitCode: outcome.exitCode })
return ok
} catch (error) {
log('sandbox_agent_signout', { sandboxId: context.sandboxId, ok: false, error: String(error).slice(0, 120) })
return false
} finally {
clearTimeout(timer)
}
}
/**
* Removes agent credential files from a build VM before its snapshot. Fails closed: an image that
* might carry someone's sign-in must not be handed to the rest of the team.
*/
export async function scrubAgentAuthForImage(provider: SandboxProviderApi, sessionId: string): Promise<boolean> {
const result = await provider.runCommand(sessionId, { ...signoutCommand(true), timeoutMs: SCRUB_TIMEOUT_MS })
return scrubbed(result)
}
@@ -26,6 +26,7 @@ import { ProviderError, type SandboxProviderApi } from './sandbox-provider'
import { ORCAD_PORT, startOrcad } from './sandbox-vm-commands'
import type { Keyring } from './secrets-crypto'
import { SANDBOX_VCPUS } from './machine-size'
import { scrubAgentAuthForImage } from './agent-signout'
export const BUILD_DEADLINE_MS = 45 * 60_000
const STARTING_STUCK_MS = 5 * 60_000
@@ -445,6 +446,12 @@ async function finalize(deps: BuildDeps, row: BuildRow, exitCode: number): Promi
if (!recorded) {
return (await getBuild(deps.db, row.environment_id, row.id)) ?? row
}
// The image is shared with everyone who uses the environment: no agent sign-in may ride along
// (A9 clone scrub). Last before the snapshot, so nothing the build ran can leave one after it.
if (!(await scrubAgentAuthForImage(deps.provider, sessionId))) {
log('environment_build_agent_scrub_failed', { buildId: row.id })
return (await failBuild(deps, recorded, 'record', 'record_failed')) ?? recorded
}
const snapshotId = await deps.provider.createSnapshot(sessionId, 0)
return completeBuild(deps, recorded, snapshotId)
} catch (error) {
@@ -1,6 +1,7 @@
// Sandbox lifecycle: one persistent provider sandbox per Orca workspace, with an Orca-owned lease,
// a forced stop before the provider's 24h cap, usage metering and write-ahead rows.
import { deleteAgentSourcesForSandbox } from './agent-sources'
import { signOutAgentsBeforeDelete } from './agent-signout'
import type { EntitlementClient, MachineSize } from './entitlements'
import { parseGitHubRepo, type GitHubAppMinter } from './github-app'
import {
@@ -812,6 +813,11 @@ export async function destroySandbox(deps: LifecycleDeps, row: SandboxRow): Prom
if (settled !== destroying.usage_settled_at) {
destroying = await claim(deps, destroying, { usage_settled_at: settled })
}
// A9: sign each agent out on the VM first (bounded, best effort; never blocks the delete). A
// suspended sandbox has no session and is never woken for it: its disk goes with the delete.
if (destroying.session_id) {
await signOutAgentsBeforeDelete(deps.provider, destroying.session_id, { sandboxId: row.id })
}
await deps.provider.deleteSandbox(destroying.provider_name)
// Persistent sandboxes leave automatic snapshots behind delete; prove none survive.
const listed = await deps.provider.listSnapshots(destroying.provider_name)
@@ -0,0 +1,192 @@
// A9: agents are signed out on a sandbox before Orca deletes it, and environment-build images are
// scrubbed of agent sign-ins before anyone else boots them.
import { execFileSync } from 'node:child_process'
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AGENT_SIGNOUT_TIMEOUT_MS, signOutAgentsBeforeDelete } from '../src/agent-signout'
import { ProviderError, type RunCommandParams, type SandboxProviderApi } from '../src/sandbox-provider'
import { createHarness } from './harness'
const create = { workspaceId: 'ws-1', machineSize: 'standard' }
const isSignout = (params: RunCommandParams) => params.command === 'bash' && params.args?.[2] === 'orca-agent-signout'
const SCRIPT = fileURLToPath(new URL('../image/vm/orca-agent-signout', import.meta.url).href)
describe('sign agents out before delete', () => {
it('runs each agent sign-out on the running VM before the provider delete', async () => {
const h = await createHarness()
const { body } = await h.call('POST', '/v1/sandboxes', { body: create })
const name = `orca-test-${body.sandbox.id}`
const seen: { scrubOnly: boolean; boxAlive: boolean }[] = []
h.vercel.commandHandler = (params) => {
if (isSignout(params)) {
seen.push({ scrubOnly: params.args!.includes('--scrub-only'), boxAlive: h.vercel.boxes.has(name) })
}
return undefined
}
const destroyed = await h.call('DELETE', `/v1/sandboxes/${body.sandbox.id}`)
expect(destroyed.body.sandbox.status).toBe('destroyed')
expect(seen).toEqual([{ scrubOnly: false, boxAlive: true }])
expect(h.vercel.boxes.has(name)).toBe(false)
})
it('still deletes when the sign-out fails or the provider errors', async () => {
for (const failure of ['exit', 'throw'] as const) {
const h = await createHarness()
const { body } = await h.call('POST', '/v1/sandboxes', { body: create })
h.vercel.commandHandler = (params) => {
if (!isSignout(params)) {
return undefined
}
if (failure === 'throw') {
throw new ProviderError(502, 'bad_gateway')
}
return { exitCode: 1, stdout: '', stderr: 'boom' }
}
const destroyed = await h.call('DELETE', `/v1/sandboxes/${body.sandbox.id}`)
expect(destroyed.body.sandbox.status).toBe('destroyed')
expect(h.vercel.boxes.size).toBe(0)
}
})
it('never wakes a suspended sandbox to sign out', async () => {
const h = await createHarness()
const { body } = await h.call('POST', '/v1/sandboxes', { body: create })
await h.call('POST', `/v1/sandboxes/${body.sandbox.id}/suspend`)
const before = h.vercel.commands.length
const destroyed = await h.call('DELETE', `/v1/sandboxes/${body.sandbox.id}`)
expect(destroyed.body.sandbox.status).toBe('destroyed')
expect(h.vercel.commands.slice(before).filter((c) => c.args?.[2] === 'orca-agent-signout')).toEqual([])
})
describe('bounded wait', () => {
afterEach(() => {
vi.useRealTimers()
})
it('gives up after the timeout when the VM never answers', async () => {
vi.useFakeTimers()
const provider = { runCommand: () => new Promise(() => undefined) } as unknown as SandboxProviderApi
const done = signOutAgentsBeforeDelete(provider, 'sess', { sandboxId: 'sb' })
await vi.advanceTimersByTimeAsync(AGENT_SIGNOUT_TIMEOUT_MS)
await expect(done).resolves.toBe(false)
})
})
})
describe('scrub agent sign-ins on clone (environment builds)', () => {
const RESULT = JSON.stringify({ durationSec: 1, repos: [], diskUsedBytes: 1, diskTotalBytes: 2 })
async function setup() {
const h = await createHarness()
h.vercel.buildResult = RESULT
const admin = await h.token('alice', 'acme', 'sandbox', { orgRole: 'admin' })
const call = (method: string, path: string, body?: unknown) => h.call(method, path, { bearer: admin, body })
const created = await call('POST', '/v1/environments', {
name: 'Default',
repos: [{ url: 'acme/web', defaultBranch: 'main' }],
installScript: 'claude auth login',
envVarNames: []
})
const envId: string = created.body.environment.id
async function build() {
const started = await call('POST', `/v1/environments/${envId}/builds`)
const command = [...h.vercel.buildCommands.values()].at(-1)!
command.exitCode = 0
h.advance(1000)
return (await call('GET', `/v1/environments/${envId}/builds/${started.body.build.id}`)).body.build
}
return { h, build }
}
it('scrubs agent auth (files only) before the build is snapshotted', async () => {
const { h, build } = await setup()
const order: string[] = []
const createSnapshot = h.vercel.createSnapshot.bind(h.vercel)
h.vercel.createSnapshot = async (sessionId, expirationMs) => {
order.push('snapshot')
return createSnapshot(sessionId, expirationMs)
}
h.vercel.commandHandler = (params) => {
if (isSignout(params)) {
order.push(params.args!.includes('--scrub-only') ? 'scrub' : 'signout')
}
return undefined
}
expect(await build()).toMatchObject({ status: 'succeeded', active: true })
expect(order.slice(0, 2)).toEqual(['scrub', 'snapshot'])
})
it('fails the build, with no image, when the scrub cannot confirm the sign-ins are gone', async () => {
const { h, build } = await setup()
const images = () => [...h.vercel.snapshots.values()].filter((s) => s.creationMethod === 'manual').length
h.vercel.commandHandler = (params) =>
isSignout(params) ? { exitCode: 0, stdout: '{"loggedOut":0,"left":1}\n', stderr: '' } : undefined
expect(await build()).toMatchObject({ status: 'failed', failedStep: 'record', active: false })
expect(images()).toBe(0)
})
})
describe('orca-agent-signout (VM script)', () => {
let home = ''
afterEach(() => {
rmSync(home, { recursive: true, force: true })
})
function seed(): void {
home = mkdtempSync(join(tmpdir(), 'orca-signout-'))
mkdirSync(join(home, '.claude'), { recursive: true })
mkdirSync(join(home, '.codex'), { recursive: true })
mkdirSync(join(home, '.orcad-data/codex-accounts/a'), { recursive: true })
writeFileSync(join(home, '.claude/.credentials.json'), '{"claudeAiOauth":{"accessToken":"x"}}')
writeFileSync(join(home, '.codex/auth.json'), '{"tokens":{}}')
writeFileSync(join(home, '.codex/config.toml'), 'model = "x"\n')
writeFileSync(join(home, '.orcad-data/codex-accounts/a/auth.json'), '{}')
writeFileSync(
join(home, '.claude.json'),
JSON.stringify({ oauthAccount: { emailAddress: 'a@b.c' }, primaryApiKey: 'sk', hasCompletedOnboarding: true })
)
}
function run(args: string[]): { loggedOut: number; left: number } {
const env = { HOME: home, PATH: process.env.PATH ?? '/usr/bin:/bin' }
return JSON.parse(execFileSync('bash', [SCRIPT, ...args], { env, encoding: 'utf8' }).trim())
}
it('--scrub-only removes credential files and sign-in fields, keeping settings and running no CLI', () => {
seed()
const bin = join(home, '.local/bin')
mkdirSync(bin, { recursive: true })
for (const cli of ['claude', 'codex']) {
writeFileSync(join(bin, cli), `#!/bin/sh\ntouch "${home}/ran-${cli}"\n`)
chmodSync(join(bin, cli), 0o755)
}
expect(run(['--scrub-only'])).toEqual({ loggedOut: 0, left: 0 })
expect(existsSync(join(home, '.claude/.credentials.json'))).toBe(false)
expect(existsSync(join(home, '.codex/auth.json'))).toBe(false)
expect(existsSync(join(home, '.orcad-data/codex-accounts/a/auth.json'))).toBe(false)
expect(existsSync(join(home, '.codex/config.toml'))).toBe(true)
expect(JSON.parse(readFileSync(join(home, '.claude.json'), 'utf8'))).toEqual({ hasCompletedOnboarding: true })
expect(existsSync(join(home, 'ran-claude'))).toBe(false)
expect(existsSync(join(home, 'ran-codex'))).toBe(false)
})
it('runs each agent logout, then removes the files even when a logout fails', () => {
seed()
const bin = join(home, '.local/bin')
mkdirSync(bin, { recursive: true })
// A portable stand-in for coreutils timeout, so the test does not depend on the host having it.
writeFileSync(join(bin, 'timeout'), '#!/bin/sh\nshift\nexec "$@"\n')
writeFileSync(join(bin, 'claude'), `#!/bin/sh\necho "$@" >"${home}/claude-args"\n`)
writeFileSync(join(bin, 'codex'), '#!/bin/sh\nexit 1\n')
for (const file of ['timeout', 'claude', 'codex']) {
chmodSync(join(bin, file), 0o755)
}
expect(run([])).toEqual({ loggedOut: 1, left: 0 })
expect(readFileSync(join(home, 'claude-args'), 'utf8').trim()).toBe('auth logout')
expect(existsSync(join(home, '.codex/auth.json'))).toBe(false)
expect(existsSync(join(home, '.claude/.credentials.json'))).toBe(false)
})
})
@@ -293,6 +293,10 @@ export function createFakeVercel(now: () => number): FakeVercel {
if (params.command === 'bash' && params.args?.[1]?.includes('result.json')) {
return fake.runtimeMissing ? { exitCode: 1, stdout: '', stderr: '' } : { exitCode: 0, stdout: `${fake.buildResult}\n`, stderr: '' }
}
if (params.command === 'bash' && params.args?.[2] === 'orca-agent-signout') {
// orca-agent-signout run by source (A9); tests override through commandHandler.
return { exitCode: 0, stdout: '{"loggedOut":2,"left":0}\n', stderr: '' }
}
if (params.command === '/opt/orca/bin/orca-image-wipe') {
return { exitCode: 0, stdout: '{"wiped":true}\n', stderr: '' }
}
@@ -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))
}
}