mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
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:
@@ -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
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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[]> => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
+25
@@ -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>
|
||||
)
|
||||
}
|
||||
+3
@@ -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) && (
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user