From 5ae72093e2c1be3b68a4997d9711cf27806a40e9 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 06:41:50 -0800 Subject: [PATCH 01/50] fix(recipes): scope the recipe-host env warning to the recipe's passEnv The host panel's recipe warning summarized all of process.env, but a recipe that sets passEnv only gets buildRecipeInheritedEnv(process.env, passEnv). The IPC now takes the host's environmentId; main finds its runtime and resolves passEnv through getRuntimeRecipeContext (never from the renderer), falling back to the whole env when the recipe can't be resolved. The summary reports narrowed, and the panel says so with a new string. One classifier (isRecipeCredentialEnvName) now backs both this warning and listCredentialLikeEnvNames. Co-Authored-By: Claude --- .../ipc/ephemeral-vm-forwarded-env.test.ts | 94 +++++++++++++++++++ src/main/ipc/ephemeral-vm-forwarded-env.ts | 41 +++++++- src/main/ipc/ephemeral-vm.ts | 2 +- src/preload/api/ephemeral-vm-api.ts | 7 +- src/preload/api/ephemeral-vm-bridge.ts | 2 +- .../agent-host-panel/HostPanelBody.tsx | 15 ++- .../RecipeEnvForwardingWarning.fixture.tsx | 19 +++- .../RecipeEnvForwardingWarning.tsx | 18 ++-- .../agent-host-panel-host-fixtures.tsx | 3 +- src/renderer/src/i18n/locales/en.json | 3 +- src/shared/ephemeral-vm-recipe-env.ts | 33 ++++++- .../ephemeral-vm-recipe-forwarded-env.test.ts | 46 ++++++++- .../ephemeral-vm-recipe-forwarded-env.ts | 46 +++------ 13 files changed, 275 insertions(+), 54 deletions(-) create mode 100644 src/main/ipc/ephemeral-vm-forwarded-env.test.ts diff --git a/src/main/ipc/ephemeral-vm-forwarded-env.test.ts b/src/main/ipc/ephemeral-vm-forwarded-env.test.ts new file mode 100644 index 00000000000..6a59860b2c3 --- /dev/null +++ b/src/main/ipc/ephemeral-vm-forwarded-env.test.ts @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +const handlers = new Map unknown>() +const { listRuntimesMock, recipeContextMock } = vi.hoisted(() => ({ + listRuntimesMock: vi.fn(), + recipeContextMock: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { getPath: () => '/tmp/orca-user-data' }, + ipcMain: { + handle: (channel: string, handler: never) => handlers.set(channel, handler), + removeHandler: vi.fn() + } +})) +vi.mock('../../shared/ephemeral-vm-runtime-store', () => ({ + listEphemeralVmRuntimes: listRuntimesMock +})) +vi.mock('./ephemeral-vm-recipe-context', () => ({ + getRuntimeRecipeContext: recipeContextMock +})) + +import { registerRecipeForwardedEnvHandler } from './ephemeral-vm-forwarded-env' +import type { RecipeForwardedEnvSummary } from '../../shared/ephemeral-vm-recipe-forwarded-env' + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler hands the store to the mocked recipe context only +const store = {} as never + +function summarize(args?: unknown): RecipeForwardedEnvSummary { + const handler = handlers.get('ephemeralVm:forwardedEnvSummary') + if (!handler) { + throw new Error('handler not registered') + } + return handler({}, args) as RecipeForwardedEnvSummary +} + +beforeEach(() => { + vi.stubEnv('AWS_SECRET_ACCESS_KEY', 'aws-secret') + vi.stubEnv('OPENAI_API_KEY', 'sk-openai') + listRuntimesMock.mockReturnValue([ + { id: 'runtime-1', recipeId: 'aws', runtimeEnvironmentId: 'env-recipe' } + ]) + recipeContextMock.mockReturnValue({ recipe: { id: 'aws', passEnv: ['AWS_*'] } }) + registerRecipeForwardedEnvHandler(store) +}) + +afterEach(() => { + vi.unstubAllEnvs() + vi.clearAllMocks() + handlers.clear() +}) + +it("applies the recipe's own passEnv, looked up from the host's runtime", () => { + const summary = summarize({ environmentId: 'env-recipe' }) + expect(recipeContextMock).toHaveBeenCalledWith(store, '/tmp/orca-user-data', 'runtime-1') + expect(summary.narrowed).toBe(true) + expect(summary.keyLike).toContain('AWS_SECRET_ACCESS_KEY') + expect(summary.keyLike).not.toContain('OPENAI_API_KEY') +}) + +it('ignores a passEnv sent from the renderer', () => { + const summary = summarize({ environmentId: 'env-recipe', passEnv: ['OPENAI_*'] }) + expect(summary.keyLike).toContain('AWS_SECRET_ACCESS_KEY') + expect(summary.keyLike).not.toContain('OPENAI_API_KEY') + + const withoutHost = summarize({ passEnv: ['AWS_*'] }) + expect(withoutHost.narrowed).toBe(false) + expect(withoutHost.keyLike).toContain('OPENAI_API_KEY') +}) + +it('falls back to the whole env for an unknown host or an unresolvable recipe', () => { + const unknown = summarize({ environmentId: 'env-missing' }) + expect(recipeContextMock).not.toHaveBeenCalled() + expect(unknown.narrowed).toBe(false) + expect(unknown.keyLike).toEqual( + expect.arrayContaining(['AWS_SECRET_ACCESS_KEY', 'OPENAI_API_KEY']) + ) + + recipeContextMock.mockImplementation(() => { + throw new Error('recipe not found') + }) + const unresolved = summarize({ environmentId: 'env-recipe' }) + expect(unresolved.narrowed).toBe(false) + expect(unresolved.keyLike).toContain('OPENAI_API_KEY') +}) + +it('summarizes the whole env when the recipe sets no passEnv', () => { + recipeContextMock.mockReturnValue({ recipe: { id: 'aws' } }) + const summary = summarize({ environmentId: 'env-recipe' }) + expect(summary.narrowed).toBe(false) + expect(summary.keyLike).toEqual( + expect.arrayContaining(['AWS_SECRET_ACCESS_KEY', 'OPENAI_API_KEY']) + ) +}) diff --git a/src/main/ipc/ephemeral-vm-forwarded-env.ts b/src/main/ipc/ephemeral-vm-forwarded-env.ts index 80c94261d92..cca2decb82d 100644 --- a/src/main/ipc/ephemeral-vm-forwarded-env.ts +++ b/src/main/ipc/ephemeral-vm-forwarded-env.ts @@ -1,13 +1,46 @@ -import { ipcMain } from 'electron' +import { app, ipcMain } from 'electron' +import type { Store } from '../persistence/loading-store/store' +import { listEphemeralVmRuntimes } from '../../shared/ephemeral-vm-runtime-store' import { summarizeRecipeForwardedEnv, type RecipeForwardedEnvSummary } from '../../shared/ephemeral-vm-recipe-forwarded-env' +import { getRuntimeRecipeContext } from './ephemeral-vm-recipe-context' + +/** Which recipe host the renderer asks about. Main looks its recipe up; it never takes a passEnv. */ +export type RecipeForwardedEnvSummaryArgs = { environmentId?: string } + +/** The recipe's passEnv as its lifecycle scripts get it; undefined (whole env) when unresolvable. */ +export function resolveRecipeHostPassEnv( + store: Store, + userDataPath: string, + environmentId: unknown +): readonly string[] | undefined { + if (typeof environmentId !== 'string' || environmentId.length === 0) { + return undefined + } + try { + const runtime = listEphemeralVmRuntimes(userDataPath).find( + (entry) => entry.runtimeEnvironmentId === environmentId + ) + return runtime + ? getRuntimeRecipeContext(store, userDataPath, runtime.id).recipe.passEnv + : undefined + } catch { + // Why: an unresolvable recipe falls back to the whole env, which can only over-warn. + return undefined + } +} /** Recipe scripts inherit this process's env (buildRecipeEnv); only the names cross to the renderer. */ -export function registerRecipeForwardedEnvHandler(): void { +export function registerRecipeForwardedEnvHandler(store: Store): void { ipcMain.removeHandler('ephemeralVm:forwardedEnvSummary') - ipcMain.handle('ephemeralVm:forwardedEnvSummary', (): RecipeForwardedEnvSummary => - summarizeRecipeForwardedEnv(process.env) + ipcMain.handle( + 'ephemeralVm:forwardedEnvSummary', + (_event, args?: RecipeForwardedEnvSummaryArgs): RecipeForwardedEnvSummary => + summarizeRecipeForwardedEnv( + process.env, + resolveRecipeHostPassEnv(store, app.getPath('userData'), args?.environmentId) + ) ) } diff --git a/src/main/ipc/ephemeral-vm.ts b/src/main/ipc/ephemeral-vm.ts index e29e15fdfb3..bae4d043a41 100644 --- a/src/main/ipc/ephemeral-vm.ts +++ b/src/main/ipc/ephemeral-vm.ts @@ -104,7 +104,7 @@ export function registerEphemeralVmHandlers(store: Store, pluginService?: Plugin return listPickerRecipes(store, args.repoId, pluginService) }) - registerRecipeForwardedEnvHandler() + registerRecipeForwardedEnvHandler(store) ipcMain.handle( 'ephemeralVm:listRecipeCatalog', diff --git a/src/preload/api/ephemeral-vm-api.ts b/src/preload/api/ephemeral-vm-api.ts index ddd892058bf..bdf4cda8b16 100644 --- a/src/preload/api/ephemeral-vm-api.ts +++ b/src/preload/api/ephemeral-vm-api.ts @@ -47,8 +47,11 @@ export type EphemeralVmApi = { diagnostics: NonNullable }[] > - /** Names (never values) of this computer's env that recipe create scripts receive. */ - forwardedEnvSummary: () => Promise + /** + * Names (never values) of this computer's env that recipe scripts receive. Given a recipe host's + * environmentId, main applies that recipe's passEnv; otherwise the whole env. + */ + forwardedEnvSummary: (args?: { environmentId?: string }) => Promise /** Credential-like variable names (never values) recipe scripts would inherit from Orca. */ listCredentialEnvNames: () => Promise doctor: (args: { repoId: string; recipeId: string }) => Promise diff --git a/src/preload/api/ephemeral-vm-bridge.ts b/src/preload/api/ephemeral-vm-bridge.ts index 8b6bdd463c0..1f86524a010 100644 --- a/src/preload/api/ephemeral-vm-bridge.ts +++ b/src/preload/api/ephemeral-vm-bridge.ts @@ -14,7 +14,7 @@ import { export const ephemeralVmApi = { listRecipes: (args) => ipcRenderer.invoke('ephemeralVm:listRecipes', args), listRecipeCatalog: () => ipcRenderer.invoke('ephemeralVm:listRecipeCatalog'), - forwardedEnvSummary: () => ipcRenderer.invoke('ephemeralVm:forwardedEnvSummary'), + forwardedEnvSummary: (args) => ipcRenderer.invoke('ephemeralVm:forwardedEnvSummary', args), listCredentialEnvNames: () => ipcRenderer.invoke('ephemeralVm:listCredentialEnvNames'), doctor: (args) => ipcRenderer.invoke('ephemeralVm:doctor', args), provision: (args) => ipcRenderer.invoke('ephemeralVm:provision', args), diff --git a/src/renderer/src/components/agent-host-panel/HostPanelBody.tsx b/src/renderer/src/components/agent-host-panel/HostPanelBody.tsx index 195a7e167ce..056e6cefdab 100644 --- a/src/renderer/src/components/agent-host-panel/HostPanelBody.tsx +++ b/src/renderer/src/components/agent-host-panel/HostPanelBody.tsx @@ -45,20 +45,27 @@ function useHostKeys(hostId: ExecutionHostId): AgentApiKeySummary[] | null { return keys } -function useRecipeEnvSummary(enabled: boolean): RecipeForwardedEnvSummary | null | undefined { +/** Main resolves this recipe host's passEnv from its recipe; the renderer only names the host. */ +function useRecipeEnvSummary( + enabled: boolean, + environmentId: string | null +): RecipeForwardedEnvSummary | null | undefined { const [summary, setSummary] = useState(null) useEffect(() => { if (!enabled) { return } let cancelled = false - void (window.api.ephemeralVm?.forwardedEnvSummary() ?? Promise.resolve(undefined)) + void ( + window.api.ephemeralVm?.forwardedEnvSummary(environmentId ? { environmentId } : undefined) ?? + Promise.resolve(undefined) + ) .catch(() => undefined) .then((next) => !cancelled && setSummary(next)) return () => { cancelled = true } - }, [enabled]) + }, [enabled, environmentId]) return summary } @@ -132,7 +139,7 @@ export function useHostPanelBody( // A primitive snapshot so the selector stays stable between renders. return `${accounts.claude ?? ''}\u0000${accounts.codex ?? ''}` }) - const recipeSummary = useRecipeEnvSummary(host.kind === 'recipe') + const recipeSummary = useRecipeEnvSummary(host.kind === 'recipe', host.environmentId) const [signOutAgent, setSignOutAgent] = useState(null) const unavailable = host.availability !== 'available' const agents = ( diff --git a/src/renderer/src/components/agent-host-panel/RecipeEnvForwardingWarning.fixture.tsx b/src/renderer/src/components/agent-host-panel/RecipeEnvForwardingWarning.fixture.tsx index bd9169135f7..18bbb9582be 100644 --- a/src/renderer/src/components/agent-host-panel/RecipeEnvForwardingWarning.fixture.tsx +++ b/src/renderer/src/components/agent-host-panel/RecipeEnvForwardingWarning.fixture.tsx @@ -4,7 +4,8 @@ import { RecipeEnvForwardingWarning } from './RecipeEnvForwardingWarning' const summary = { count: 42, keyLike: ['ANTHROPIC_API_KEY', 'GITHUB_TOKEN', 'NPM_TOKEN'], - other: ['EDITOR', 'HOME', 'LANG', 'PATH', 'SHELL', 'TERM', 'USER'] + other: ['EDITOR', 'HOME', 'LANG', 'PATH', 'SHELL', 'TERM', 'USER'], + narrowed: false } export const warning = defineFixture({ @@ -29,6 +30,22 @@ export const manyKeys = defineFixture({ ) }) +// A recipe with `passEnv: ['AWS_*']`: only base variables plus the AWS ones reach its scripts. +export const narrowed = defineFixture({ + width: 420, + render: () => ( + + ) +}) + export const noKeys = defineFixture({ width: 420, render: () => diff --git a/src/renderer/src/components/agent-host-panel/RecipeEnvForwardingWarning.tsx b/src/renderer/src/components/agent-host-panel/RecipeEnvForwardingWarning.tsx index ae1ad165c35..abb228f99de 100644 --- a/src/renderer/src/components/agent-host-panel/RecipeEnvForwardingWarning.tsx +++ b/src/renderer/src/components/agent-host-panel/RecipeEnvForwardingWarning.tsx @@ -9,8 +9,9 @@ import type { RecipeForwardedEnvSummary } from '../../../../shared/ephemeral-vm- 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. + * P-4 (agent accounts spec §4, Q9): recipe create scripts get this computer's environment, or only + * what the recipe's passEnv allows (`summary.narrowed`). A warning only; names, never values. + * `summary` null while loading, undefined when unavailable. */ export function RecipeEnvForwardingWarning({ summary, @@ -70,10 +71,15 @@ export function RecipeEnvForwardingWarning({

- {t( - 'agentHostPanel.recipeEnv', - "This recipe's create script gets this computer's environment." - )} + {summary?.narrowed + ? t( + 'agentHostPanel.recipeEnvNarrowed', + "This recipe's create script gets only the variables its passEnv allows." + ) + : t( + 'agentHostPanel.recipeEnv', + "This recipe's create script gets this computer's environment." + )}

{summary === null ? ( diff --git a/src/renderer/src/components/agent-host-panel/agent-host-panel-host-fixtures.tsx b/src/renderer/src/components/agent-host-panel/agent-host-panel-host-fixtures.tsx index 5e13ee0fae6..afc77113843 100644 --- a/src/renderer/src/components/agent-host-panel/agent-host-panel-host-fixtures.tsx +++ b/src/renderer/src/components/agent-host-panel/agent-host-panel-host-fixtures.tsx @@ -90,7 +90,8 @@ export function HostFixture({ summary={{ count: 42, keyLike: ['AWS_SECRET_ACCESS_KEY', 'OPENAI_API_KEY'], - other: ['HOME', 'PATH'] + other: ['HOME', 'PATH'], + narrowed: false }} /> ) : null diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 5b15fcece2f..e74f6cabd73 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -28467,7 +28467,8 @@ "codeLabel": "Code from claude.ai", "deviceStep": "Enter this code on auth.openai.com. The sign-in is saved on {{host}}.", "codePlaceholder": "Paste the code" - } + }, + "recipeEnvNarrowed": "This recipe's create script gets only the variables its passEnv allows." }, "cloudSecretsAsk": { "scopeLabel": "Scope for {{name}}", diff --git a/src/shared/ephemeral-vm-recipe-env.ts b/src/shared/ephemeral-vm-recipe-env.ts index 0f4f7bf5ec5..22a52e1c30e 100644 --- a/src/shared/ephemeral-vm-recipe-env.ts +++ b/src/shared/ephemeral-vm-recipe-env.ts @@ -1,5 +1,6 @@ // Recipe scripts run locally and inherit Orca's environment. These helpers let a recipe opt into a // narrower environment (`passEnv`) and let the UI name the credential-like variables a recipe sees. +import { AGENT_CREDENTIAL_NAMES } from './agent-credentials/agent-credential-names' /** Variables every recipe keeps even with `passEnv`, so shells and provider CLIs still start. */ export const RECIPE_BASE_ENV_NAMES: readonly string[] = [ @@ -63,6 +64,36 @@ export function isCredentialLikeEnvName(name: string): boolean { ) } +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/ + +/** The host panel's classifier: agent credential names, plus key/token/secret-shaped segments. */ +export function looksLikeCredentialEnvName(name: string): boolean { + const upper = name.toUpperCase() + return ( + AGENT_CREDENTIAL_NAME_SET.has(upper) || + CREDENTIAL_SUBSTRINGS.test(upper) || + upper.split(/[_\-.]/).some((segment) => CREDENTIAL_SEGMENTS.has(segment)) + ) +} + +/** The one rule every recipe env warning uses, so the host panel and Settings name the same variables. */ +export function isRecipeCredentialEnvName(name: string): boolean { + return looksLikeCredentialEnvName(name) || isCredentialLikeEnvName(name) +} + /** `passEnv` entries are exact names or a prefix ending in `*` (e.g. `AWS_*`). */ export function isRecipeEnvNamePassed( name: string, @@ -104,7 +135,7 @@ export function buildRecipeInheritedEnv( /** Sorted credential-like names with a non-empty value in `source`. Never returns values. */ export function listCredentialLikeEnvNames(source: NodeJS.ProcessEnv): string[] { return Object.entries(source) - .filter(([name, value]) => Boolean(value) && isCredentialLikeEnvName(name)) + .filter(([name, value]) => Boolean(value) && isRecipeCredentialEnvName(name)) .map(([name]) => name) .sort((a, b) => a.localeCompare(b)) } diff --git a/src/shared/ephemeral-vm-recipe-forwarded-env.test.ts b/src/shared/ephemeral-vm-recipe-forwarded-env.test.ts index 303830a7f88..314ffaa98a6 100644 --- a/src/shared/ephemeral-vm-recipe-forwarded-env.test.ts +++ b/src/shared/ephemeral-vm-recipe-forwarded-env.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { listCredentialLikeEnvNames } from './ephemeral-vm-recipe-env' import { summarizeRecipeForwardedEnv } from './ephemeral-vm-recipe-forwarded-env' describe('summarizeRecipeForwardedEnv', () => { @@ -23,10 +24,53 @@ describe('summarizeRecipeForwardedEnv', () => { 'GITHUB_TOKEN', 'openai_api_key' ], - other: ['HOME', 'KEYCHAIN_PATH', 'PATH'] + other: ['HOME', 'KEYCHAIN_PATH', 'PATH'], + narrowed: false }) expect(JSON.stringify(summary)).not.toMatch( /ghp_secret_value|aws-secret-value|hunter2|sk-lower/ ) }) + + const env = { AWS_SECRET_ACCESS_KEY: 'aws', OPENAI_API_KEY: 'sk', PATH: '/usr/bin' } + + it("summarizes only what the recipe's passEnv lets through", () => { + expect(summarizeRecipeForwardedEnv(env, ['AWS_*'], 'linux')).toEqual({ + count: 2, + keyLike: ['AWS_SECRET_ACCESS_KEY'], + other: ['PATH'], + narrowed: true + }) + }) + + it('summarizes the whole env without passEnv', () => { + expect(summarizeRecipeForwardedEnv(env, undefined, 'linux')).toEqual({ + count: 3, + keyLike: ['AWS_SECRET_ACCESS_KEY', 'OPENAI_API_KEY'], + other: ['PATH'], + narrowed: false + }) + }) + + it('names the same credentials as the Settings credentials notice', () => { + const sample = { + OPENAI_API_KEY: 'v', + GITHUB_TOKEN: 'v', + AWS_REGION: 'v', + MY_PASSWORD: 'v', + ANTHROPIC_AUTH_TOKEN: 'v', + PRIVATE_KEY_PATH: 'v', + PATH: 'v' + } + const keyLike = summarizeRecipeForwardedEnv(sample).keyLike + expect(keyLike).toEqual(listCredentialLikeEnvNames(sample)) + expect(keyLike).toEqual([ + 'ANTHROPIC_AUTH_TOKEN', + 'AWS_REGION', + 'GITHUB_TOKEN', + 'MY_PASSWORD', + 'OPENAI_API_KEY', + 'PRIVATE_KEY_PATH' + ]) + }) }) diff --git a/src/shared/ephemeral-vm-recipe-forwarded-env.ts b/src/shared/ephemeral-vm-recipe-forwarded-env.ts index 2f083e23e8c..352f234b127 100644 --- a/src/shared/ephemeral-vm-recipe-forwarded-env.ts +++ b/src/shared/ephemeral-vm-recipe-forwarded-env.ts @@ -1,46 +1,30 @@ -import { AGENT_CREDENTIAL_NAMES } from './agent-credentials/agent-credential-names' +import { buildRecipeInheritedEnv, isRecipeCredentialEnvName } from './ephemeral-vm-recipe-env' -/** Names only: recipe create scripts get this computer's whole env, so values never leave main. */ +/** Names only: the env a recipe's scripts inherit (passEnv-narrowed or whole); values never leave main. */ export type RecipeForwardedEnvSummary = { count: number - /** Sorted names that look like credentials (key/token/secret/password, or an agent credential). */ + /** Sorted names that look like credentials (isRecipeCredentialEnvName). */ keyLike: string[] /** Every other forwarded name, sorted. */ other: string[] + /** True when the recipe's passEnv narrowed the env; false means this computer's whole env. */ + narrowed: boolean } -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 { +/** Summarizes what buildRecipeEnv would inherit: the whole env, or only what `passEnv` allows. */ +export function summarizeRecipeForwardedEnv( + source: NodeJS.ProcessEnv, + passEnv?: readonly string[], + platform: NodeJS.Platform = process.platform +): RecipeForwardedEnvSummary { + const env = buildRecipeInheritedEnv(source, passEnv, platform) 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)) + keyLike: names.filter(isRecipeCredentialEnvName), + other: names.filter((name) => !isRecipeCredentialEnvName(name)), + narrowed: passEnv !== undefined } } From 38ed468ae48b66e1c2491e13d0f637203f59d5b5 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 17:17:33 -0800 Subject: [PATCH 02/50] =?UTF-8?q?fix(cloud):=20VM=20git=20lines=20say=20Se?= =?UTF-8?q?ttings=20=E2=86=92=20Account=20(glossary=20path=20arrow;=20desi?= =?UTF-8?q?gn-squad)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cloud/apps/sandbox-control/image/vm-scripts.generated.ts | 6 +++--- cloud/apps/sandbox-control/image/vm/git | 4 ++-- .../sandbox-control/test/git-shim-github-no-prompt.test.ts | 2 +- .../sandbox-control/test/git-shim-identity-nudge.test.ts | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/cloud/apps/sandbox-control/image/vm-scripts.generated.ts b/cloud/apps/sandbox-control/image/vm-scripts.generated.ts index f1750ca9eb2..28a207e26dc 100644 --- a/cloud/apps/sandbox-control/image/vm-scripts.generated.ts +++ b/cloud/apps/sandbox-control/image/vm-scripts.generated.ts @@ -3,9 +3,9 @@ import type { BuildInputs, GoldenImageManifest } from './golden-image' export const MANIFEST: GoldenImageManifest = {"imageVersion":"2026-11-09.1","image":"vercel/sandbox/node:24","region":"iad1","vcpus":4,"orcad":{"bundleUrl":null,"sha256":null},"gh":{"version":"2.103.3","sha256":"4c806978ffce1cec70853302a56d70ac6da4f19bae7adca4e1a8ab00633dd91d"},"chrome":{"version":"157.0.8103.38-1","sha256":"a62b277dc03da2cff50cc04589deb66f6875be339c066d46adb49b49eedb629d"},"agentCliPackages":["@anthropic-ai/claude-code@2.1.319","@openai/codex@0.166.0","opencode-ai@1.19.5","@google/gemini-cli@0.67.0","@github/copilot@1.0.108"],"cursorAgent":{"version":"2026.11.05-86047df","sha256":"170b77786cbf4c93064d64fff12b71a178b968730561c64afc2e8e752535442e"},"desktop":{"novnc":{"version":"1.6.0","sha256":"5066103959ef4e9b10f37e5a148627360dd8414e4cf8a7db92bdbd022e728aaa"},"websockify":{"version":"0.13.0","sha256":"b6413e364efd04f3c92ec8c17747e3c4adc20157c2ef1c5d019a26d944a46df8"}}} export const PROVISION_SCRIPT: string = "#!/usr/bin/env bash\n# Runs once inside the build sandbox (as the sandbox user, with sudo). Installs the pinned orcad\n# bundle, agent CLIs and Orca's VM entry points into /opt/orca, then smoke-tests orcad.\n# Inputs (env, from image/golden-image.manifest.json via build-golden-image.ts):\n# ORCAD_BUNDLE_URL ORCAD_BUNDLE_SHA256 [GH_VERSION GH_SHA256] [CHROME_VERSION CHROME_SHA256]\n# AGENT_NPM_PACKAGES (space-separated\n# name@version), [CURSOR_AGENT_VERSION CURSOR_AGENT_SHA256], [NOVNC_VERSION NOVNC_SHA256 WEBSOCKIFY_VERSION WEBSOCKIFY_SHA256] and ORCA_VM_SCRIPT_ (base64) for each /opt/orca/bin entry point.\n# [ORCA_VM_SCRIPTS_STAMP] (VM_SCRIPTS_STAMP of those scripts; written to /opt/orca/bin/.scripts-stamp).\nset -euo pipefail\nORCA_HOME=/opt/orca\nlog() { printf '[golden] %s\\n' \"$*\" >&2; }\n\nsudo mkdir -p \"$ORCA_HOME/bin\" \"$ORCA_HOME/runtime\" \"$ORCA_HOME/tools\"\nsudo chown -R \"$(id -u):$(id -g)\" \"$ORCA_HOME\"\n\nlog 'system packages'\nif command -v apt-get >/dev/null; then\n # Transient mirror errors and dpkg lock contention made apt exit 100 (2 of ~10 golden builds):\n # retry downloads and wait for the lock instead of failing the build.\n printf 'Acquire::Retries \"5\";\\nDPkg::Lock::Timeout \"180\";\\n' | sudo tee /etc/apt/apt.conf.d/80-orca-retries >/dev/null\n # Sandbox egress is TLS-only: plain-http apt mirrors time out at runtime (W2, wake on SSH).\n sudo sed -i 's#http://#https://#g' /etc/apt/sources.list.d/*.sources /etc/apt/sources.list 2>/dev/null || true\n sudo DEBIAN_FRONTEND=noninteractive apt-get update -qq\n # Developer basics agents expect (Conductor parity): rg, tmux, vim, git-lfs alongside the build tools.\n sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \\\n ca-certificates curl git git-lfs jq ripgrep tmux vim-nox less openssh-client openssh-server build-essential python3 \\\n unzip zip util-linux procps >/dev/null\n git lfs install --system --skip-repo >/dev/null\n # sshd is started on demand by the wake-on-SSH bridge (key-only, loopback); never at boot.\n sudo systemctl disable ssh.service ssh.socket >/dev/null 2>&1 || true\n # A locked password field (\"!\") makes sshd refuse even key logins; '*' = no password, not locked.\n sudo usermod -p '*' \"$(id -un)\"\nelif command -v dnf >/dev/null; then\n sudo dnf install -y -q ca-certificates curl-minimal git git-lfs jq tmux vim-enhanced gcc gcc-c++ make python3 \\\n unzip util-linux procps-ng >/dev/null\nelse\n log 'no supported package manager'; exit 1\nfi\n\n# Managed images have no /vercel/sandbox; the project lives at /vercel/sandbox/project on every image.\nsudo install -d -o \"$(id -u)\" -g \"$(id -g)\" /vercel/sandbox\n\nfetch_verified() { # url sha256 dest\n curl -fsSL --retry 3 -o \"$3\" \"$1\"\n echo \"$2 $3\" | sha256sum -c --quiet -\n}\n\nlog 'orcad bundle'\nbundle=\"$(mktemp -d)/orcad.tgz\"\nfetch_verified \"$ORCAD_BUNDLE_URL\" \"$ORCAD_BUNDLE_SHA256\" \"$bundle\"\ntar -xzf \"$bundle\" -C \"$ORCA_HOME/runtime\"\nrm -rf \"$(dirname \"$bundle\")\"\n# Release tarballs (orca-server-linux-*) may nest the server; normalize to runtime/orcad + runtime/runtimes.\nif [ ! -s \"$ORCA_HOME/runtime/orcad/orcad-server.js\" ]; then\n server=\"$(find \"$ORCA_HOME/runtime\" -name orcad-server.js -type f | head -n 1)\"\n [ -n \"$server\" ] || { log 'bundle has no orcad-server.js'; exit 1; }\n root=\"$(dirname \"$(dirname \"$server\")\")\"\n ln -sfn \"$(dirname \"$server\")\" \"$ORCA_HOME/runtime/orcad\"\n [ -d \"$ORCA_HOME/runtime/runtimes\" ] || ln -sfn \"$root/runtimes\" \"$ORCA_HOME/runtime/runtimes\"\nfi\ntest -s \"$ORCA_HOME/runtime/orcad/orcad-server.js\"\nnode=\"$(find \"$ORCA_HOME/runtime/runtimes\" -type f -name node | head -n 1)\"\ntest -x \"$node\"\n\nif [ -n \"${GH_VERSION:-}\" ]; then\n log \"gh $GH_VERSION\"\n fetch_verified \"https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_amd64.tar.gz\" \\\n \"$GH_SHA256\" /tmp/gh.tgz\n tar -xzf /tmp/gh.tgz -C /tmp\n mkdir -p \"$ORCA_HOME/tools/bin\"\n install -m 0755 \"/tmp/gh_${GH_VERSION}_linux_amd64/bin/gh\" \"$ORCA_HOME/tools/bin/gh\"\n rm -rf /tmp/gh.tgz \"/tmp/gh_${GH_VERSION}_linux_amd64\"\nfi\n\nif [ -n \"${CHROME_VERSION:-}\" ] && command -v apt-get >/dev/null; then\n log \"google-chrome $CHROME_VERSION (headless browser for agents and Orca browser automation)\"\n deb=\"$(mktemp -d)/chrome.deb\"\n fetch_verified \"https://dl.google.com/linux/chrome/deb/pool/main/g/google-chrome-stable/google-chrome-stable_${CHROME_VERSION}_amd64.deb\" \\\n \"$CHROME_SHA256\" \"$deb\"\n sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \"$deb\" >/dev/null\n rm -rf \"$(dirname \"$deb\")\"\n # The .deb adds Google's apt source; the image stays pinned, so drop it.\n sudo rm -f /etc/apt/sources.list.d/google-chrome.list /etc/cron.daily/google-chrome\n google-chrome --headless=new --no-sandbox --disable-gpu --dump-dom 'data:text/html,

ok

' 2>/dev/null | grep -q '

ok

'\nfi\n\nlog 'agent CLIs'\n# The image's system npm installs them; agents later run on whatever node orcad puts on PATH.\n# shellcheck disable=SC2086 # word splitting is the package list\nnpm install --global --no-fund --no-audit --prefix \"$ORCA_HOME/tools\" $AGENT_NPM_PACKAGES\n# opencode-ai installs every linux-x64 build variant (~205 MB each) and hardlinks the one its\n# postinstall picked into bin/opencode.exe, a standalone binary: drop the variants it did not pick.\nopencode_pkg=\"$ORCA_HOME/tools/lib/node_modules/opencode-ai\"\nif [ -x \"$opencode_pkg/bin/opencode.exe\" ]; then\n picked=\"$(stat -c %i \"$opencode_pkg/bin/opencode.exe\")\"\n for variant in \"$opencode_pkg\"/node_modules/opencode-*; do\n [ -d \"$variant\" ] || continue\n [ \"$(stat -c %i \"$variant/bin/opencode\" 2>/dev/null)\" = \"$picked\" ] || rm -rf \"$variant\"\n done\n \"$opencode_pkg/bin/opencode.exe\" --version >/dev/null\nfi\n\n# Copilot CLI prefers GH_TOKEN/GITHUB_TOKEN over the user's own /login. In Orca Cloud those hold the\n# workspace's GitHub App placeholder, which Copilot can't use (\"Resource not accessible by\n# integration\"), so a signed-in user could never get past it. The wrapper drops them for Copilot\n# only (COPILOT_GITHUB_TOKEN, a /login and BYOK still work), unless BYOK is set up, which needs no\n# GitHub sign-in and keeps them so `gh` in Copilot's shell still works.\ncopilot_loader=\"$ORCA_HOME/tools/lib/node_modules/@github/copilot/npm-loader.js\"\nif [ -f \"$copilot_loader\" ]; then\n rm -f \"$ORCA_HOME/tools/bin/copilot\"\n cat >\"$ORCA_HOME/tools/bin/copilot\" </dev/null; fi\ndone\n\nif [ -n \"${CURSOR_AGENT_VERSION:-}\" ]; then\n # Cursor's agent CLI is a pinned tarball (its own node + bundle), not an npm package. The image\n # copy lives under /opt/orca (root-owned) and its release channel is set to \"static\" at boot\n # (orca-sandbox-env), so it never replaces itself mid-session.\n log \"cursor-agent $CURSOR_AGENT_VERSION\"\n tgz=\"$(mktemp -d)/cursor-agent.tgz\"\n fetch_verified \"https://downloads.cursor.com/lab/${CURSOR_AGENT_VERSION}/linux/x64/agent-cli-package.tar.gz\" \\\n \"$CURSOR_AGENT_SHA256\" \"$tgz\"\n rm -rf \"$ORCA_HOME/tools/cursor-agent\"\n mkdir -p \"$ORCA_HOME/tools/cursor-agent\" \"$ORCA_HOME/tools/bin\"\n tar --strip-components=1 -xzf \"$tgz\" -C \"$ORCA_HOME/tools/cursor-agent\"\n rm -rf \"$(dirname \"$tgz\")\"\n ln -sfn ../cursor-agent/cursor-agent \"$ORCA_HOME/tools/bin/cursor-agent\"\n \"$ORCA_HOME/tools/bin/cursor-agent\" --version >/dev/null\nfi\n\nlog 'openssh server (binaries only)'\n# For `orca ssh`: an unprivileged sshd on loopback (see vm/orca-sshd). Only the server binaries,\n# kept out of /usr: the packages would pull in systemd units, SELinux policy (~38 MB on AL2023) and\n# system host keys a user-run sshd never uses. Its libraries are already in the base image, except\n# tcp-wrappers on Ubuntu.\nmkdir -p \"$ORCA_HOME/openssh\"\npkgs=\"$(mktemp -d)\"\nif command -v dnf >/dev/null; then\n sudo dnf install -y -q --setopt=install_weak_deps=False cpio >/dev/null\n sudo dnf install -y -q --downloadonly --downloaddir=\"$pkgs\" openssh-server >/dev/null\n (cd \"$pkgs\" && rpm2cpio openssh-server-[0-9]*.rpm | cpio -idm --quiet)\n sudo dnf clean all -q >/dev/null 2>&1 || true\n libexec=\"$pkgs/usr/libexec/openssh\"\nelse\n sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends libwrap0 >/dev/null\n (cd \"$pkgs\" && apt-get download -qq openssh-server openssh-sftp-server >/dev/null)\n for deb in \"$pkgs\"/*.deb; do dpkg-deb -x \"$deb\" \"$pkgs\"; done\n libexec=\"$pkgs/usr/lib/openssh\"\nfi\ninstall -m 0755 \"$pkgs/usr/sbin/sshd\" \"$libexec/sshd-session\" \"$libexec/sftp-server\" \"$ORCA_HOME/openssh/\"\n# OpenSSH 10 splits authentication into its own binary.\nif [ -x \"$libexec/sshd-auth\" ]; then install -m 0755 \"$libexec/sshd-auth\" \"$ORCA_HOME/openssh/\"; fi\nsudo rm -rf \"$pkgs\"\n\"$ORCA_HOME/openssh/sshd\" -V 2>&1 | head -n 1 >&2 || true\n# sshd refuses a locked account ('!') even for key logins; '*' unlocks it with no usable password.\nsudo usermod -p '*' \"$(id -un)\"\n\ninstall_desktop() {\n # Remote desktop (~80 MB): Xvnc + openbox + xterm from the distro, noVNC + websockify pinned\n # tarballs. Only orca-desktop-start runs it, on demand; nothing starts at boot. Every step says\n # `|| return 1`: errexit does not apply inside an `if` condition.\n command -v apt-get >/dev/null || { log 'remote desktop: apt only'; return 1; }\n sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \\\n tigervnc-standalone-server tigervnc-tools openbox xterm x11-xserver-utils >/dev/null || return 1\n local tmp dir=\"$ORCA_HOME/desktop\"\n tmp=\"$(mktemp -d)\" || return 1\n fetch_verified \"https://github.com/novnc/noVNC/archive/refs/tags/v${NOVNC_VERSION}.tar.gz\" \\\n \"$NOVNC_SHA256\" \"$tmp/novnc.tgz\" || return 1\n fetch_verified \"https://github.com/novnc/websockify/archive/refs/tags/v${WEBSOCKIFY_VERSION}.tar.gz\" \\\n \"$WEBSOCKIFY_SHA256\" \"$tmp/websockify.tgz\" || return 1\n rm -rf \"$dir\" && mkdir -p \"$dir/novnc\" \"$dir/websockify\" || return 1\n tar -xzf \"$tmp/novnc.tgz\" -C \"$dir/novnc\" --strip-components 1 || return 1\n tar -xzf \"$tmp/websockify.tgz\" -C \"$dir/websockify\" --strip-components 1 || return 1\n # Orca tab fit: title \"Remote desktop\" (Xvnc's -desktop name), no noVNC favicon, control bar on\n # the right (away from Orca's sidebar edge); a stale per-boot password ends the session with one\n # plain message instead of a reconnect loop. \"Unencrypted\" is noVNC's view of plain ws:// to\n # localhost; the hop to the sandbox rides the end-to-end encrypted runtime connection.\n sed -i -e 's/document.title = e.detail.name + \" - \" + PAGE_TITLE;/document.title = e.detail.name;/' \\\n -e \"s/WebUtil.readSetting('controlbar_pos') === 'right'/WebUtil.readSetting('controlbar_pos', 'right') === 'right'/\" \\\n -e 's/securityFailed(e) {/securityFailed(e) { UI.inhibitReconnect = true; UI.showStatus(\"This remote desktop session ended. Reopen it from the workspace menu.\", \"error\"); return;/' \\\n -e 's/_(\"Connected (unencrypted) to \")/_(\"Connected to \")/' \\\n \"$dir/novnc/app/ui.js\" || return 1\n sed -i '/rel=\"icon\"/d; /rel=\"apple-touch-icon\"/d' \"$dir/novnc/vnc.html\" || return 1\n rm -rf \"$tmp\"\n PYTHONPATH=\"$dir/websockify\" python3 -P -c 'import websockify' >/dev/null || return 1\n command -v Xvnc >/dev/null && test -s \"$dir/novnc/vnc.html\"\n}\ndesktop=false\nif [ -n \"${NOVNC_VERSION:-}\" ]; then\n log 'remote desktop'\n # Optional: a failure leaves an image without the desktop, never a failed build.\n if install_desktop; then\n desktop=true\n else\n log 'remote desktop install failed; continuing without it'\n rm -rf \"$ORCA_HOME/desktop\"\n fi\nfi\n\nlog 'Orca entry points'\nfor name in orca-sandbox-start orca-sandbox-drain orca-sandbox-env orca-session-env.sh orca-env 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\n var=\"ORCA_VM_SCRIPT_${name//[-.]/_}\"\n printf '%s' \"${!var}\" | base64 -d >\"$ORCA_HOME/bin/$name\"\n chmod 0755 \"$ORCA_HOME/bin/$name\"\ndone\n# Which entry points this image carries (VM_SCRIPTS_STAMP): a Worker with older scripts never\n# reinstalls over them. Absent (old callers) reads as 0, so any Worker may still update the image.\nrm -f \"$ORCA_HOME/bin/.scripts-stamp\" \"$ORCA_HOME\"/bin/.entry-points-*\nif [[ \"${ORCA_VM_SCRIPTS_STAMP:-}\" =~ ^[0-9]+$ ]]; then\n printf '%s\\n' \"$ORCA_VM_SCRIPTS_STAMP\" >\"$ORCA_HOME/bin/.scripts-stamp\"\nfi\n# The orca CLI (agents drive worktrees, terminals and the browser through it) talks to this VM's orcad.\ncli=\"$(find \"$ORCA_HOME/runtime\" -name orca-cli.cjs -type f | head -n 1)\"\nif [ -n \"$cli\" ]; then\n printf '#!/usr/bin/env bash\\nexport ORCA_USER_DATA_PATH=\"${ORCA_USER_DATA_PATH:-$HOME/.orcad-data}\"\\nexec \"%s\" \"%s\" \"$@\"\\n' \"$node\" \"$cli\" >\"$ORCA_HOME/bin/orca\"\n chmod 0755 \"$ORCA_HOME/bin/orca\"\nelse\n log 'bundle has no orca CLI (orca-cli.cjs): agents will not have `orca` on PATH'\nfi\n# The image's node/npm/npx live only on the build command's PATH; append them so agents (codex's\n# `env node` shebang) and stdio MCP servers launched with npx resolve them in Orca's login shells.\nsystem_node=\"$(command -v npx || command -v node || true)\"\nif [ -z \"$system_node\" ]; then\n echo 'no node/npx on the build PATH' >&2\n exit 1\nfi\nprintf 'export PATH=%s/tools/bin:%s/bin:$PATH:%s\\n' \"$ORCA_HOME\" \"$ORCA_HOME\" \"$(dirname \"$system_node\")\" |\n sudo tee /etc/profile.d/orca.sh >/dev/null\n# Every new shell (login or not: /etc/bashrc sources profile.d) re-reads the session env file.\nsudo install -m 0644 \"$ORCA_HOME/bin/orca-session-env.sh\" /etc/profile.d/zz-orca-session-env.sh\nif [ -f /etc/bash.bashrc ]; then\n # Ubuntu's bash.bashrc does not source profile.d for non-login interactive shells (AL2023's bashrc does).\n grep -q zz-orca-session-env /etc/bash.bashrc ||\n echo '[ -r /etc/profile.d/zz-orca-session-env.sh ] && . /etc/profile.d/zz-orca-session-env.sh' | sudo tee -a /etc/bash.bashrc >/dev/null\nfi\nif [ -f /etc/bash.bashrc ]; then\n # Ubuntu: zsh reads /etc/zsh/zshenv for every shell, so new zsh shells match bash (`orca-env` covers\n # shells that were already open).\n sudo install -d -m 0755 /etc/zsh\n sudo touch /etc/zsh/zshenv\n grep -q zz-orca-session-env /etc/zsh/zshenv ||\n echo '[ -r /etc/profile.d/zz-orca-session-env.sh ] && . /etc/profile.d/zz-orca-session-env.sh' | sudo tee -a /etc/zsh/zshenv >/dev/null\nfi\nsudo chown -R root:root \"$ORCA_HOME\"\n\nlog 'smoke: orcad reaches ready on loopback (and warms the V8 compile cache: ~1.5 s off every boot)'\nsudo install -d -o \"$(id -u)\" -g \"$(id -g)\" \"$ORCA_HOME/compile-cache\"\n# The runtime is root-owned, so its startup-qualified marker lives here. The smoke run leaves one,\n# so every boot from the image skips orcad's disposable startup preflight (~0.6 s of wake).\nsudo install -d -o \"$(id -u)\" -g \"$(id -g)\" \"$ORCA_HOME/startup-qualification\"\nsmoke_data=\"$(mktemp -d)\"\n# orcad-boot also writes orcad's full code cache as the smoke run exits (~0.3 s more off every boot).\nNODE_COMPILE_CACHE=\"$ORCA_HOME/compile-cache\" ORCA_ORCAD_STARTUP_QUALIFICATION_DIR=\"$ORCA_HOME/startup-qualification\" \\\n ORCA_USER_DATA=\"$smoke_data\" timeout 60 \"$node\" \"$ORCA_HOME/bin/orcad-boot\" \\\n --code-cache \"$ORCA_HOME/compile-cache/orcad-server.v8\" --write-code-cache \"$ORCA_HOME/runtime/orcad/orcad-server.js\" \\\n --port 6799 --json >/tmp/orcad-smoke.out 2>/tmp/orcad-smoke.err &\nsmoke_pid=$!\nready=0\nfor _ in $(seq 1 600); do\n if grep -q '\"orca_server_ready\"' /tmp/orcad-smoke.out 2>/dev/null; then ready=1; break; fi\n sleep 0.1\ndone\n# orcad writes the marker just after readiness; best effort, a boot without one only re-qualifies.\nfor _ in $(seq 1 30); do\n [ \"$ready\" = 1 ] && [ ! -s \"$ORCA_HOME/startup-qualification/.orcad-startup-qualified\" ] || break\n sleep 0.1\ndone\nkill \"$smoke_pid\" 2>/dev/null || true\nwait \"$smoke_pid\" 2>/dev/null || true\nrm -rf \"$smoke_data\" /tmp/orcad-smoke.out\nif [ \"$ready\" != 1 ]; then\n tail -n 40 /tmp/orcad-smoke.err >&2; exit 1\nfi\n# Best effort: without it every boot loads orcad as before.\nif [ -s \"$ORCA_HOME/compile-cache/orcad-server.v8\" ]; then\n log \"orcad code cache: $(stat -c %s \"$ORCA_HOME/compile-cache/orcad-server.v8\") bytes\"\nelse\n log 'orcad code cache was not written; boots compile orcad without it'\n grep 'orcad-boot' /tmp/orcad-smoke.err >&2 || true\nfi\n\nlog 'versions'\nversions=\"{}\"\nfor bin in claude codex opencode gemini cursor-agent copilot gh; do\n if [ -x \"$ORCA_HOME/tools/bin/$bin\" ]; then\n v=\"$(\"$ORCA_HOME/tools/bin/$bin\" --version 2>/dev/null | head -n 1 || echo unknown)\"\n versions=\"$(jq -c --arg k \"$bin\" --arg v \"$v\" '. + {($k): $v}' <<<\"$versions\")\"\n fi\ndone\nfor sys in rg vim git-lfs google-chrome; do\n if command -v \"$sys\" >/dev/null; then\n v=\"$(\"$sys\" --version 2>/dev/null | head -n 1 || echo unknown)\"\n versions=\"$(jq -c --arg k \"$sys\" --arg v \"$v\" '. + {($k): $v}' <<<\"$versions\")\"\n fi\ndone\ncommand -v tmux >/dev/null && versions=\"$(jq -c --arg v \"$(tmux -V)\" '. + {tmux: $v}' <<<\"$versions\")\"\n[ -x \"$ORCA_HOME/bin/orca\" ] && versions=\"$(jq -c '. + {orca: \"bundled\"}' <<<\"$versions\")\"\njq -c --arg node \"$(\"$node\" --version)\" --arg disk \"$(df -h / | awk 'NR==2{print $2}')\" \\\n --argjson desktop \"$desktop\" '{node: $node, disk: $disk, tools: ., desktop: $desktop}' <<<\"$versions\"\n" -export const VM_SCRIPT_SOURCES: BuildInputs['vmScripts'] = {"orca-sandbox-start":"#!/usr/bin/env bash\n# Starts orcad for this boot (idempotent) and prints its recipe line:\n# {\"schemaVersion\":1,\"pairingCode\":\"orca://pair?...\",\"projectRoot\":\"/vercel/sandbox/project\"}\n# Env from the control plane: ORCA_SANDBOX_ID, ORCA_PUBLIC_WSS, optional ORCA_REPO_URL/ORCA_REPO_REF,\n# ORCA_GITHUB_PLACEHOLDER, ORCA_SECRET_ENV (`NAME=` lines: org/personal/repo secrets for\n# orcad and its agents), ORCA_SHARE_PROXY_KEY (this session's share-link proxy key, port 6769).\n# Never prints anything else on stdout's last line.\n# With ORCA_READY_VIA=file it prints {\"schemaVersion\":1,\"readyFile\":\"\"} instead and the control\n# plane reads the offer with the files API: the provider keeps command stdout as logs, not files.\n# With ORCA_READY_KEY too (64 hex chars, this call's key; env is not kept) the pointer also carries\n# \"sealedOffer\": \".\", which saves that round trip.\n# Managed env for every process orcad starts: ORCA_IS_CLOUD=1, ORCA_SANDBOX_ID, ORCA_ROOT_PATH,\n# ORCA_WORKTREE_PATH, ORCA_WORKSPACE_PATH, ORCA_WORKSPACE_NAME, ORCA_DEFAULT_BRANCH, ORCA_PORT and the\n# CONDUCTOR_* names imported Conductor scripts read.\nset -euo pipefail\numask 077\nORCA_HOME=/opt/orca\nSTATE=\"$HOME/.orca-sandbox\"\nPROJECT=/vercel/sandbox/project\nmkdir -p \"$STATE\"\n# Only print_ready uses the key; nothing this script starts inherits it.\nready_key=\"${ORCA_READY_KEY:-}\"\nunset ORCA_READY_KEY\n[[ \"$ready_key\" =~ ^[0-9a-f]{64}$ ]] || ready_key=''\nexec 9>\"$STATE/start.lock\"\nflock -w 60 9\n# Background children close fd 9 (`9>&-`): an orcad that inherited it would hold the lock for its whole\n# life and every later start (resume of a running sandbox, a retried create) would wait out the 60 s.\n\n# orcad only lists a project once a client registers it (the desktop does on connect), so API, MCP,\n# routine and phone sessions saw no repo. Register the workspace project at every start, in the\n# background (ready is not delayed); `repo add` of an already-registered path is a no-op error.\nregister_project() {\n [ -x \"$ORCA_HOME/bin/orca\" ] && [ -d \"$PROJECT\" ] || return 0\n local kind=() name=()\n [ -e \"$PROJECT/.git\" ] || kind=(--kind folder)\n # The checkout lives at .../project; name it after the repository (owner/name.git -> name).\n if [ -n \"${ORCA_REPO_URL:-}\" ]; then\n local base=\"${ORCA_REPO_URL%/}\"\n base=\"${base##*/}\"\n base=\"${base%.git}\"\n [ -n \"$base\" ] && name=(--display-name \"$base\")\n fi\n setsid \"$ORCA_HOME/bin/orca\" repo add --path \"$PROJECT\" \"${kind[@]}\" \"${name[@]}\" --json \\\n >\"$STATE/register-project.log\" 2>&1 &- &\n}\n\nprint_ready() {\n if [ \"${ORCA_READY_VIA:-}\" = file ]; then\n local sealed='' iv\n if [ -n \"$ready_key\" ] && command -v openssl >/dev/null; then\n iv=\"$(od -An -N16 -tx1 /dev/urandom | tr -d ' \\n')\"\n # Best effort: without it the control plane reads the file, as before.\n sealed=\"$(tail -n 1 \"$STATE/ready.json\" | openssl enc -aes-256-ctr -K \"$ready_key\" -iv \"$iv\" -nosalt | base64 -w0)\" &&\n sealed=\"$iv.$sealed\" || sealed=''\n fi\n printf '{\"schemaVersion\":1,\"readyFile\":\"%s\"%s}\\n' \"$STATE/ready.json\" \"${sealed:+,\\\"sealedOffer\\\":\\\"$sealed\\\"}\"\n else\n tail -n 1 \"$STATE/ready.json\"\n fi\n}\n# Command stderr is kept by the provider: strip injected values and credential shapes from log tails.\nredact_log() {\n local text kv value\n text=\"$(cat)\"\n for kv in ${agent_env[@]+\"${agent_env[@]}\"}; do\n value=\"${kv#*=}\"\n if [ \"${#value}\" -ge 8 ]; then\n text=\"${text//\"$value\"/[REDACTED]}\"\n fi\n done\n printf '%s\\n' \"$text\" | sed -E \\\n -e 's#orca://[^[:space:]]+#[REDACTED_PAIRING]#g' \\\n -e 's#(gh[pousr]_|github_pat_|sk-|vc[kpiar]_)[A-Za-z0-9._-]{16,}#[REDACTED_TOKEN]#g' \\\n -e 's#eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}#[REDACTED_JWT]#g' \\\n -e 's#(Authorization|authorization):[[:space:]]*[A-Za-z]+[[:space:]]+[^[:space:]]+#\\1: [REDACTED]#g'\n}\n\nnode=\"$(find \"$ORCA_HOME/runtime/runtimes\" -type f -name node | head -n 1)\"\n\n# Share-link proxy (port 6769): answers only requests the control plane signed with this session's\n# key, so its public route is useless on its own. The key goes in on stdin (printf is a builtin), so\n# it never appears in any argv or in the proxy's /proc//environ.\nstart_share_proxy() {\n [ -n \"${ORCA_SHARE_PROXY_KEY:-}\" ] && [ -x \"$ORCA_HOME/bin/orca-share-proxy\" ] || return 0\n if [ \"$(cat \"$STATE/share-proxy.boot\" 2>/dev/null || true)\" = \"$boot_id\" ] \\\n && kill -0 \"$(cat \"$STATE/share-proxy.pid\" 2>/dev/null || echo 0)\" 2>/dev/null; then\n return 0\n fi\n printf '%s' \"$ORCA_SHARE_PROXY_KEY\" | env -i HOME=\"$HOME\" LANG=C.UTF-8 PATH=/usr/bin:/bin \\\n setsid \"$node\" \"$ORCA_HOME/bin/orca-share-proxy\" >\"$STATE/share-proxy.log\" 2>&1 9>&- &\n echo $! >\"$STATE/share-proxy.pid\"\n printf '%s' \"$boot_id\" >\"$STATE/share-proxy.boot\"\n}\n\nboot_id=\"$(cat /proc/sys/kernel/random/boot_id)\"\n# CREATE-GIT-ASYNC: a create answers before member identities are installed; `git` holds commits and\n# pushes until orca-sandbox-members (or the control plane) marks this setup ready or failed. Marked\n# before the already-started exit below: a pooled VM's orcad may be up before its create claims it.\nif [ \"${ORCA_GIT_SETUP:-}\" = pending ]; then\n rm -f \"$STATE/git-setup.ready\" \"$STATE/git-setup.failed\"\n date +%s >\"$STATE/git-setup.pending\"\nfi\nstart_share_proxy\nunset ORCA_SHARE_PROXY_KEY\nif [ \"$(cat \"$STATE/boot-id\" 2>/dev/null || true)\" = \"$boot_id\" ] && [ -s \"$STATE/ready.json\" ] \\\n && kill -0 \"$(cat \"$STATE/orcad.pid\" 2>/dev/null || echo 0)\" 2>/dev/null; then\n print_ready\n exit 0\nfi\n# Resume = reboot from disk: locks and pids from the previous session name dead processes.\nrm -f \"$STATE/ready.json\" \"$STATE/orcad.pid\"\nprintf '%s' \"$boot_id\" >\"$STATE/boot-id\"\n# Any other boot (wake, restart) installs identities before it answers: a resumed disk's marks are stale.\n[ \"${ORCA_GIT_SETUP:-}\" = pending ] || rm -f \"$STATE/git-setup.pending\" \"$STATE/git-setup.ready\" \"$STATE/git-setup.failed\"\n\nif [ -n \"${ORCA_GIT_EXTRAHEADER:-}\" ]; then\n # Every github.com request carries the placeholder; the egress policy swaps in a 1h repo token.\n git config --global http.https://github.com/.extraheader \"$ORCA_GIT_EXTRAHEADER\"\nfi\n# Commits outside a teammate's turn (or before any member connected GitHub) need an author too.\n# The fallback moved to orca.dev with the domain; a workspace still on the old default follows it.\n[ \"$(git config --global user.email 2>/dev/null || true)\" = 'cloud@users.noreply.onorca.dev' ] \\\n && git config --global user.email 'cloud@users.noreply.orca.dev'\ngit config --global user.email >/dev/null 2>&1 || git config --global user.email 'cloud@users.noreply.orca.dev'\ngit config --global user.name >/dev/null 2>&1 || git config --global user.name 'Orca Cloud'\n# First boot clones while orcad starts (orcad only needs the directory to exist); ready waits for both.\nclone_pid=''\nif [ ! -d \"$PROJECT\" ]; then\n mkdir -p \"$PROJECT\"\n if [ -n \"${ORCA_REPO_URL:-}\" ]; then\n git clone --quiet ${ORCA_REPO_REF:+--branch \"$ORCA_REPO_REF\"} -- \"$ORCA_REPO_URL\" \"$PROJECT\" 2>\"$STATE/clone.log\" 9>&- &\n clone_pid=$!\n fi\nfi\n\n# The clone may still be running here, so the name comes from the URL; the default branch is\n# filled in lazily by the session-env shell hook once the clone exists.\nif [ -n \"${ORCA_REPO_URL:-}\" ]; then\n workspace_name=\"$(basename \"${ORCA_REPO_URL%/}\" .git)\"\nelse\n workspace_name=\"$(basename \"$(git -C \"$PROJECT\" remote get-url origin 2>/dev/null || echo \"$PROJECT\")\" .git)\"\nfi\n# One workspace per VM: its preview ports start at 3000 (Conductor allots CONDUCTOR_PORT..+9).\nmanaged_env=(\n ORCA_IS_CLOUD=1\n ORCA_ROOT_PATH=\"$PROJECT\" ORCA_WORKTREE_PATH=\"$PROJECT\" ORCA_WORKSPACE_PATH=\"$PROJECT\"\n ORCA_WORKSPACE_NAME=\"$workspace_name\" ORCA_PORT=3000\n CONDUCTOR_IS_CLOUD=1\n # Agent CLIs are pinned by the image (smoke-tested); a self-update mid-session can break a running agent.\n DISABLE_AUTOUPDATER=1 OPENCODE_DISABLE_AUTOUPDATE=1 COPILOT_AUTO_UPDATE=false\n CONDUCTOR_ROOT_PATH=\"$PROJECT\" CONDUCTOR_WORKSPACE_PATH=\"$PROJECT\"\n CONDUCTOR_WORKSPACE_NAME=\"$workspace_name\" CONDUCTOR_PORT=3000\n)\n\nagent_env=()\nif command -v google-chrome >/dev/null; then\n # Orca's browser automation and agent browser tools use the image's headless Chrome.\n agent_env+=(\"ORCA_BROWSER_EXECUTABLE=$(command -v google-chrome)\")\nfi\nif [ -n \"${ORCA_GITHUB_PLACEHOLDER:-}\" ]; then\n # The egress layer swaps the placeholder for a short-lived GitHub token; none lives in the VM.\n agent_env+=(\"GH_TOKEN=$ORCA_GITHUB_PLACEHOLDER\" \"GITHUB_TOKEN=$ORCA_GITHUB_PLACEHOLDER\")\nfi\n\nsecret_names=()\nif [ -n \"${ORCA_SECRET_ENV:-}\" ]; then\n # Secrets live only in memory: orcad's process env and a tmpfs file new shells re-read\n # (orca-sandbox-env), never under $HOME or the project, so the stop snapshot never carries them.\n # Resolved per start (and per edit) by the control plane: no image rebuild, no restart.\n while IFS= read -r line; do\n name=\"${line%%=*}\" b64=\"${line#*=}\" # not IFS='=': read would strip base64 padding\n case \"$name\" in '' | *[!A-Z0-9_]* | [0-9]* | ORCA_* | PATH | HOME | LD_* | DYLD_*) continue ;; esac\n agent_env+=(\"$name=$(printf '%s' \"$b64\" | base64 -d)\")\n secret_names+=(\"$name\")\n done < <(printf '%s\\n' \"$ORCA_SECRET_ENV\") # a pipe, not a here-string temp file\nfi\n# Always rewrite the file, so a resume with fewer secrets drops the old names too.\n\"$ORCA_HOME/bin/orca-sandbox-env\" >/dev/null || echo 'session env file unavailable; secrets apply at boot only' >&2\nunset ORCA_SECRET_ENV\n\n# Agent CLIs installed with the image's npm (codex, gemini, ...) are `#!/usr/bin/env node` scripts:\n# terminals need that node on PATH, not only orcad's bundled runtime.\nsystem_node=\"$(command -v node 2>/dev/null || echo \"$node\")\"\n# The egress proxy answers for hosts with credential rules (model APIs, api.github.com) with a\n# certificate from Vercel's CA, which this command's env trusts (Vercel sets these). Pure-Node clients\n# use Node's bundled roots otherwise: Gemini CLI retried forever (\"taking a bit longer\") in every\n# terminal while the same call worked here. Pass the provider's CA settings on to orcad and its shells.\nca_env=(NODE_USE_SYSTEM_CA=\"${NODE_USE_SYSTEM_CA:-1}\")\nfor name in NODE_EXTRA_CA_CERTS SSL_CERT_FILE; do\n if [ -n \"${!name:-}\" ]; then ca_env+=(\"$name=${!name}\"); fi\ndone\n# N-02: the host is a cloud terminal's source of truth across app reopens, so orcad and its\n# daemon keep a local terminal's depth (5000 rows) instead of the desktop daemon's 1000-row live\n# window. Scoped to the VM: local desktop daemons keep their default.\n# Ports panel: programs under $ORCA_HOME are the image's own services, and 23456 is Vercel's\n# sandbox-init (root, pid invisible to this user); neither is listed as the user's port.\nenv -i HOME=\"$HOME\" USER=\"$(id -un)\" LANG=C.UTF-8 TERM=xterm-256color \"${ca_env[@]}\" \\\n PATH=\"$ORCA_HOME/tools/bin:$ORCA_HOME/bin:$(dirname \"$system_node\"):/usr/local/bin:/usr/bin:/bin\" \\\n ORCA_USER_DATA=\"$HOME/.orcad-data\" ORCA_SANDBOX_ID=\"$ORCA_SANDBOX_ID\" \"${managed_env[@]}\" \\\n ORCA_HOST_MANAGED_LISTENER_PATHS=\"$ORCA_HOME\" ORCA_HOST_MANAGED_UNOWNED_PORTS=23456 \\\n NODE_COMPILE_CACHE=\"$ORCA_HOME/compile-cache\" ORCA_ORCAD_STARTUP_QUALIFICATION_DIR=\"$ORCA_HOME/startup-qualification\" \\\n ORCA_DAEMON_SESSION_SCROLLBACK_ROWS=5000 \\\n ORCA_SESSION_ENV_NAMES=\"${secret_names[*]}\" \"${agent_env[@]}\" \\\n setsid nohup \"$node\" \"$ORCA_HOME/bin/orcad-boot\" --code-cache \"$ORCA_HOME/compile-cache/orcad-server.v8\" \\\n \"$ORCA_HOME/runtime/orcad/orcad-server.js\" --port 6768 --bind 0.0.0.0 --pairing-address \"$ORCA_PUBLIC_WSS\" \\\n --recipe-json --project-root \"$PROJECT\" \\\n >\"$STATE/ready.json\" 2>\"$STATE/orcad.log\" &- &\necho $! >\"$STATE/orcad.pid\"\n\n# sshd for `orca ssh` (loopback only). Best effort: a workspace without it still serves orcad.\n\"$ORCA_HOME/bin/orca-sshd\" >/dev/null 2>\"$STATE/sshd-start.err\" || true\n\n# Polled every 10 ms (at least 60 s in all): ready is on the wake path, so detect it promptly.\nfor _ in $(seq 1 6000); do\n if grep -q '\"pairingCode\"' \"$STATE/ready.json\" 2>/dev/null; then\n if [ -n \"$clone_pid\" ] && ! wait \"$clone_pid\"; then\n echo 'repository clone failed' >&2\n tail -n 5 \"$STATE/clone.log\" | redact_log >&2\n exit 1\n fi\n register_project\n print_ready\n exit 0\n fi\n if ! kill -0 \"$(cat \"$STATE/orcad.pid\")\" 2>/dev/null; then\n echo 'orcad exited before ready' >&2\n tail -n 40 \"$STATE/orcad.log\" | redact_log >&2\n exit 1\n fi\n sleep 0.01\ndone\necho 'orcad start timed out' >&2\nexit 1\n","orca-sandbox-drain":"#!/usr/bin/env bash\n# Asks orcad to quit before the VM stops: its shutdown turns working agent chats into durable\n# restart offers and flushes terminal restore hints, which the desktop continues after resume.\nset -euo pipefail\nSTATE=\"$HOME/.orca-sandbox\"\nWAIT_SECONDS=\"${1:-280}\"\npid=\"$(cat \"$STATE/orcad.pid\" 2>/dev/null || true)\"\nif [ -z \"$pid\" ] || ! kill -0 \"$pid\" 2>/dev/null; then\n exit 0\nfi\nkill -TERM \"$pid\"\nfor _ in $(seq 1 $((WAIT_SECONDS * 10))); do\n if ! kill -0 \"$pid\" 2>/dev/null; then\n rm -f \"$STATE/orcad.pid\" \"$STATE/ready.json\"\n exit 0\n fi\n sleep 0.1\ndone\n# Leave it running: the provider stop ends it, and the drain already had its window.\necho 'orcad still draining at deadline' >&2\nexit 0\n","orca-sandbox-env":"#!/usr/bin/env bash\n# Writes this session's secret env where new shells and agent launches read it (orca-session-env.sh).\n# Input: ORCA_SECRET_ENV (`NAME=` lines) from the control plane, at boot (orca-sandbox-start)\n# and whenever a secret changes (POST /v1/sandboxes/:id/env/refresh). The file lives on tmpfs only:\n# never on the sandbox disk, so the stop snapshot never carries it. Prints {\"names\":N} on success.\n# Also keeps Codex pointed at a configured OpenAI-compatible provider (OPENAI_BASE_URL), since Codex\n# does not read that variable: a managed block in ~/.codex/config.toml naming only the env var.\nset -euo pipefail\numask 077\nHOME=\"${HOME:-$(getent passwd \"$(id -u)\" | cut -d: -f6)}\"\n# ORCA_SESSION_ENV_DIR: tests only (a scratch dir off tmpfs); the control plane never sets it.\ndir=\"${ORCA_SESSION_ENV_DIR:-/dev/shm/orca-env-$(id -u)}\"\nif [ -z \"${ORCA_SESSION_ENV_DIR:-}\" ] && [ \"$(stat -f -c %T /dev/shm 2>/dev/null || true)\" != tmpfs ]; then\n # No memory-only place to keep it: refuse rather than write secrets to disk.\n echo 'no tmpfs for the session env' >&2\n exit 3\nfi\nmkdir -p \"$dir\"\nchmod 700 \"$dir\"\n# Writes are ordered by the control plane's resolve time: an older refresh that lost a race is dropped.\ngeneration=\"${ORCA_ENV_GENERATION:-0}\"\ncase \"$generation\" in '' | *[!0-9]*) generation=0 ;; esac\ncurrent=\"$(cat \"$dir/generation\" 2>/dev/null || echo 0)\"\ncase \"$current\" in '' | *[!0-9]*) current=0 ;; esac\nif [ \"$generation\" -lt \"$current\" ]; then\n printf '{\"stale\":true}\\n'\n exit 0\nfi\ntmp=\"$(mktemp \"$dir/.session.env.XXXXXX\")\"\ncount=0\nopenai_base_url=''\nopenai_model=''\nanthropic_model=''\nanthropic_key_tail=''\ngateway_key=''\ngemini_key=''\ncodex_placeholder=''\nwhile IFS= read -r line; do\n name=\"${line%%=*}\" b64=\"${line#*=}\"\n # Not for shells: the Codex account placeholder becomes ~/.codex/auth.json below.\n if [ \"$name\" = ORCA_CODEX_AUTH_PLACEHOLDER ]; then\n codex_placeholder=\"$(printf '%s' \"$b64\" | base64 -d)\"\n case \"$codex_placeholder\" in *[!A-Za-z0-9._-]*) codex_placeholder='' ;; esac\n fi\n case \"$name\" in '' | *[!A-Z0-9_]* | [0-9]* | ORCA_* | PATH | HOME | LD_* | DYLD_*) continue ;; esac\n # Values stay base64 in the file; the shell hook decodes them.\n printf '%s=%s\\n' \"$name\" \"$b64\" >>\"$tmp\"\n count=$((count + 1))\n case \"$name\" in\n OPENAI_BASE_URL) openai_base_url=\"$(printf '%s' \"$b64\" | base64 -d)\" ;;\n OPENAI_MODEL) openai_model=\"$(printf '%s' \"$b64\" | base64 -d)\" ;;\n ANTHROPIC_MODEL) anthropic_model=\"$(printf '%s' \"$b64\" | base64 -d)\" ;;\n AI_GATEWAY_API_KEY) gateway_key=1 ;;\n GEMINI_API_KEY | GOOGLE_API_KEY) gemini_key=\"${gemini_key:-gemini-api-key}\" ;;\n GOOGLE_GENAI_USE_VERTEXAI) case \"$(printf '%s' \"$b64\" | base64 -d)\" in true | 1) gemini_key=vertex-ai ;; esac ;;\n # Claude Code asks once per key whether to use ANTHROPIC_API_KEY; it remembers the last 20 chars.\n ANTHROPIC_API_KEY) anthropic_key_tail=\"$(printf '%s' \"$b64\" | base64 -d | tail -c 20)\" ;;\n esac\ndone < <(printf '%s\\n' \"${ORCA_SECRET_ENV:-}\")\nmv -f \"$tmp\" \"$dir/session.env\"\nprintf '%s' \"$generation\" >\"$dir/generation\"\n\n# ---- Codex: trust the workspace and use the configured provider (no secret values) ----\nPROJECT=/vercel/sandbox/project\ncodex_config=\"$HOME/.codex/config.toml\"\ntoml_string() { # TOML basic string; the control plane only accepts https URLs and plain model ids\n local value=\"${1//\\\\/\\\\\\\\}\"\n printf '\"%s\"' \"${value//\\\"/\\\\\\\"}\"\n}\nstrip_managed() { # drop the managed blocks and the blank lines written before them (no growth per refresh)\n awk '/^# >>> orca-cloud managed/{skip=1; blank=\"\"} skip{if (/^# <<< orca-cloud managed/) skip=0; next}\n /^[[:space:]]*$/{blank=blank $0 \"\\n\"; next} {printf \"%s\", blank; blank=\"\"; print}' \"$1\"\n}\n# Always managed: an agent launched from the phone or the API has no one to answer Codex's\n# \"trust this folder?\" screen, so the workspace project is trusted up front.\nif true; then\n mkdir -p \"$HOME/.codex\"\n user_part=\"$(mktemp \"$HOME/.codex/.config.XXXXXX\")\"\n if [ -f \"$codex_config\" ]; then strip_managed \"$codex_config\" >\"$user_part\"; fi\n next=\"$(mktemp \"$HOME/.codex/.config.XXXXXX\")\"\n openai_host=\"${openai_base_url#https://}\"\n openai_host=\"${openai_host%%/*}\"\n custom_provider=''\n if [ -n \"$openai_base_url\" ] && [ \"$openai_host\" != api.openai.com ]; then custom_provider=1; fi\n # Top-level keys must precede every table; each is skipped when the user already set it.\n head_keys=\"$(awk '/^\\[/{exit} {print}' \"$user_part\")\"\n has_key() { grep -Eq \"^[[:space:]]*$1[[:space:]]*=\" <<<\"$head_keys\"; }\n {\n printf '# >>> orca-cloud managed (Orca Cloud; edit in Orca settings)\\n'\n # The sandbox is the isolation boundary: Codex's own bwrap sandbox can't start in it (\"Unexpected\n # capabilities\"), and a prompt delivered from the phone or the API has no one to approve commands.\n has_key sandbox_mode || printf 'sandbox_mode = \"danger-full-access\"\\n'\n has_key approval_policy || printf 'approval_policy = \"never\"\\n'\n if [ -n \"$custom_provider\" ]; then\n has_key model_provider || printf 'model_provider = \"orca_cloud\"\\n'\n if [ -n \"$openai_model\" ] && ! has_key model; then\n printf 'model = %s\\n' \"$(toml_string \"$openai_model\")\"\n fi\n elif [ -n \"$codex_placeholder\" ]; then\n has_key model_provider || printf 'model_provider = \"orca_chatgpt\"\\n'\n fi\n printf '# <<< orca-cloud managed\\n'\n cat \"$user_part\"\n if [ -n \"$custom_provider\" ]; then\n printf '\\n# >>> orca-cloud managed provider\\n[model_providers.orca_cloud]\\nname = \"Orca Cloud\"\\n'\n printf 'base_url = %s\\nenv_key = \"OPENAI_API_KEY\"\\nwire_api = \"responses\"\\n' \"$(toml_string \"$openai_base_url\")\"\n printf '# <<< orca-cloud managed\\n'\n elif [ -n \"$codex_placeholder\" ]; then\n # Codex via the Orca account: the ChatGPT backend over HTTPS. Its default Responses WebSocket can't\n # pass the egress proxy, where the placeholder becomes the real token (found live, Codex 0.166).\n printf '\\n# >>> orca-cloud managed provider\\n[model_providers.orca_chatgpt]\\nname = \"OpenAI\"\\n'\n printf 'base_url = \"https://chatgpt.com/backend-api/codex\"\\nwire_api = \"responses\"\\nrequires_openai_auth = true\\nsupports_websockets = false\\n'\n printf '# <<< orca-cloud managed\\n'\n fi\n } >\"$next\"\n if ! grep -qF \"[projects.\\\"$PROJECT\\\"]\" \"$user_part\"; then\n printf '\\n# >>> orca-cloud managed trust\\n[projects.\"%s\"]\\ntrust_level = \"trusted\"\\n# <<< orca-cloud managed\\n' \"$PROJECT\" >>\"$next\"\n fi\n chmod 600 \"$next\"\n mv -f \"$next\" \"$codex_config\"\n rm -f \"$user_part\"\nfi\n# ---- Codex via the person's Orca account (contract D11): a placeholder ChatGPT login, no refresh token.\n# The egress proxy swaps the real access token on chatgpt.com; the real tokens never reach this VM. The\n# marker remembers which placeholder Orca wrote (a clone gets its own), so a login of the person's own\n# (kill-switch fallback, or `codex login` run here) is set aside while the account applies and put back after.\ncodex_auth=\"$HOME/.codex/auth.json\"\ncodex_saved=\"$HOME/.codex/auth.json.orca-saved\"\ncodex_marker=\"$HOME/.codex/.orca-account-placeholder\"\ncodex_ours=''\n# Ours: the marker's placeholder, or any Orca placeholder (all end in `.orca-placeholder`; a real token never does).\nif [ -f \"$codex_auth\" ] && { { [ -f \"$codex_marker\" ] && grep -qF \"\\\"$(cat \"$codex_marker\")\\\"\" \"$codex_auth\"; } || grep -q '\\.orca-placeholder\"' \"$codex_auth\"; }; then\n codex_ours=1\nfi\nif [ -n \"$codex_placeholder\" ]; then\n mkdir -p \"$HOME/.codex\"\n if [ -f \"$codex_auth\" ] && [ -z \"$codex_ours\" ]; then\n # Never overwrite a login already set aside: a second one gets its own name.\n if [ -e \"$codex_saved\" ]; then mv -f \"$codex_auth\" \"$codex_saved.$(date -u +%Y%m%d%H%M%S)\"; else mv -f \"$codex_auth\" \"$codex_saved\"; fi\n fi\n # Marker first: a run cut short after the swap still recognises the file as Orca's.\n printf '%s' \"$codex_placeholder\" >\"$codex_marker\"\n codex_tmp=\"$(mktemp \"$HOME/.codex/.auth.XXXXXX\")\"\n printf '{\\n \"OPENAI_API_KEY\": null,\\n \"tokens\": {\\n \"id_token\": \"%s\",\\n \"access_token\": \"%s\",\\n \"refresh_token\": \"\",\\n \"account_id\": null\\n },\\n \"last_refresh\": \"%s\"\\n}\\n' \\\n \"$codex_placeholder\" \"$codex_placeholder\" \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" >\"$codex_tmp\"\n chmod 600 \"$codex_tmp\"\n mv -f \"$codex_tmp\" \"$codex_auth\"\nelif [ -f \"$codex_marker\" ] || [ -n \"$codex_ours\" ]; then\n if [ -n \"$codex_ours\" ]; then rm -f \"$codex_auth\"; fi\n rm -f \"$codex_marker\"\n if [ -f \"$codex_saved\" ] && [ ! -f \"$codex_auth\" ]; then mv -f \"$codex_saved\" \"$codex_auth\"; fi\nfi\n# ---- Claude Code, Gemini CLI, Cursor agent, Copilot CLI, OpenCode: first-run screens answered once\n# (Claude: theme picker, folder trust, bypass-mode warning, the \"use this API key?\" question; others:\n# folder trust, auth-method picker, self-update). A prompt delivered from the phone or the API has no one to click\n# through them, and the agent would sit there doing nothing. Merges; never touches credentials.\nnode_bin=\"$(command -v node || find /opt/orca/runtime/runtimes -type f -name node 2>/dev/null | head -n 1)\"\nif [ -n \"$node_bin\" ]; then\n ORCA_PROJECT=\"$PROJECT\" ORCA_KEY_TAIL=\"$anthropic_key_tail\" ORCA_GEMINI_KEY=\"$gemini_key\" \\\n ORCA_GATEWAY_MODEL=\"${gateway_key:+${anthropic_model:-$openai_model}}\" \"$node_bin\" -e '\nconst fs = require(\"fs\"), path = require(\"path\"), os = require(\"os\")\nconst home = os.homedir()\nconst readJson = (file) => { try { const v = JSON.parse(fs.readFileSync(file, \"utf8\")); return v && typeof v === \"object\" && !Array.isArray(v) ? v : {} } catch { return {} } }\nconst write = (file, value) => {\n fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })\n const tmp = `${file}.orca-${process.pid}`\n fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + \"\\n\", { mode: 0o600 })\n fs.renameSync(tmp, file)\n}\nconst claudeFile = path.join(process.env.CLAUDE_CONFIG_DIR || home, \".claude.json\")\nconst c = readJson(claudeFile)\nconst before = JSON.stringify(c)\nc.hasCompletedOnboarding = true\nc.theme = c.theme || \"dark\"\nc.bypassPermissionsModeAccepted = true\nc.projects = c.projects && typeof c.projects === \"object\" ? c.projects : {}\nconst p = process.env.ORCA_PROJECT\nc.projects[p] = { ...(c.projects[p] || {}), hasTrustDialogAccepted: true, hasCompletedProjectOnboarding: true }\nconst tail = process.env.ORCA_KEY_TAIL\nif (tail) {\n const r = c.customApiKeyResponses && typeof c.customApiKeyResponses === \"object\" ? c.customApiKeyResponses : {}\n const approved = Array.isArray(r.approved) ? r.approved : []\n const rejected = Array.isArray(r.rejected) ? r.rejected.filter((t) => t !== tail) : []\n c.customApiKeyResponses = { ...r, approved: approved.includes(tail) ? approved : [...approved, tail], rejected }\n}\nif (JSON.stringify(c) !== before) write(claudeFile, c)\nconst settingsFile = path.join(home, \".claude\", \"settings.json\")\nconst s = readJson(settingsFile)\nif (s.skipDangerousModePermissionPrompt !== true) write(settingsFile, { ...s, skipDangerousModePermissionPrompt: true })\n\n// Each block below only fills in what is unset and leaves a file it cannot parse alone.\nconst readStrict = (file) => { try { return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, \"utf8\")) : {} } catch { return null } }\nconst isObject = (v) => v !== null && typeof v === \"object\" && !Array.isArray(v)\nconst section = (o, key) => (isObject(o[key]) ? o[key] : (o[key] = {}))\n\n// Gemini CLI: folder trust and the auth-method picker (only when a Gemini key is configured; a\n// Google sign-in picks its own method), no self-update, no usage stats (its host is not on the\n// egress allowlist, so every send would fail).\nconst geminiTrustFile = path.join(home, \".gemini\", \"trustedFolders.json\")\nconst geminiTrust = readStrict(geminiTrustFile)\nif (isObject(geminiTrust) && !geminiTrust[p]) write(geminiTrustFile, { ...geminiTrust, [p]: \"TRUST_FOLDER\" })\nconst geminiSettingsFile = path.join(home, \".gemini\", \"settings.json\")\nconst g = readStrict(geminiSettingsFile)\nif (isObject(g)) {\n const gBefore = JSON.stringify(g)\n const general = section(g, \"general\")\n general.disableAutoUpdate ??= true\n general.disableUpdateNag ??= true\n section(g, \"privacy\").usageStatisticsEnabled ??= false\n // gemini-api-key or vertex-ai (GOOGLE_GENAI_USE_VERTEXAI=true with an API key): headless runs\n // refuse to start without a method (\"Invalid auth method selected\").\n if (process.env.ORCA_GEMINI_KEY) section(section(g, \"security\"), \"auth\").selectedType ??= process.env.ORCA_GEMINI_KEY\n if (JSON.stringify(g) !== gBefore) write(geminiSettingsFile, g)\n}\n\n// Cursor agent: trust the workspace (cursor-agent keys trust by a slug of the path) and pin the\n// release channel to \"static\", which turns off its background self-update.\nconst cursorTrust = path.join(home, \".cursor\", \"projects\", p.replace(/^\\/+/, \"\").replace(/[^A-Za-z0-9]+/g, \"-\"), \".workspace-trusted\")\nif (!fs.existsSync(cursorTrust)) {\n fs.mkdirSync(path.dirname(cursorTrust), { recursive: true, mode: 0o700 })\n fs.writeFileSync(cursorTrust, JSON.stringify({ trustedAt: new Date().toISOString(), workspacePath: p }, null, 2) + \"\\n\", { mode: 0o600 })\n}\nconst cursorConfigFile = path.join(home, \".cursor\", \"cli-config.json\")\nconst cc = readStrict(cursorConfigFile)\nif (isObject(cc) && cc.channel === undefined) write(cursorConfigFile, { version: 1, ...cc, channel: \"static\" })\n\n// GitHub Copilot CLI: trusted folders live in ~/.copilot/config.json, which Copilot prefixes with\n// \"//\" comment lines; keep them.\nconst copilotFile = path.join(home, \".copilot\", \"config.json\")\nlet copilotHead = \"\"\nlet co = {}\ntry {\n const raw = fs.existsSync(copilotFile) ? fs.readFileSync(copilotFile, \"utf8\") : \"\"\n const lines = raw.split(\"\\n\")\n let i = 0\n while (i < lines.length && /^\\s*\\/\\//.test(lines[i])) i++\n copilotHead = lines.slice(0, i).map((l) => l + \"\\n\").join(\"\")\n const body = lines.slice(i).join(\"\\n\").trim()\n co = body ? JSON.parse(body) : {}\n} catch { co = null }\nif (isObject(co)) {\n const folders = Array.isArray(co.trustedFolders) ? co.trustedFolders : []\n if (!folders.includes(p)) {\n fs.mkdirSync(path.dirname(copilotFile), { recursive: true, mode: 0o700 })\n const tmp = `${copilotFile}.orca-${process.pid}`\n fs.writeFileSync(tmp, copilotHead + JSON.stringify({ ...co, trustedFolders: [...folders, p] }, null, 2) + \"\\n\", { mode: 0o600 })\n fs.renameSync(tmp, copilotFile)\n }\n}\n\n// OpenCode: with the AI Gateway preset and a model secret (ANTHROPIC_MODEL, else OPENAI_MODEL), use\n// that model through the gateway unless the user picked one. A ledger remembers what Orca wrote, so a\n// changed model secret updates it and a model the user set is never touched.\nconst ocDir = path.join(home, \".config\", \"opencode\")\nconst ocFile = path.join(ocDir, \"opencode.json\")\nconst ocLedger = path.join(ocDir, \".orca-managed-model\")\nconst managedModel = process.env.ORCA_GATEWAY_MODEL ? `vercel/${process.env.ORCA_GATEWAY_MODEL}` : \"\"\nconst oc = readStrict(ocFile)\nlet jsoncHasModel = false\ntry { jsoncHasModel = /\"model\"\\s*:/.test(fs.readFileSync(path.join(ocDir, \"opencode.jsonc\"), \"utf8\")) } catch {}\nif (isObject(oc) && !jsoncHasModel) {\n let ours = \"\"\n try { ours = fs.readFileSync(ocLedger, \"utf8\").trim() } catch {}\n const userModel = typeof oc.model === \"string\" && oc.model !== ours\n if (!userModel && (oc.model ?? \"\") !== managedModel) {\n const next = { $schema: \"https://opencode.ai/config.json\", ...oc }\n if (managedModel) next.model = managedModel\n else delete next.model\n write(ocFile, next)\n }\n if (!userModel) {\n if (managedModel) fs.writeFileSync(ocLedger, managedModel + \"\\n\", { mode: 0o600 })\n else fs.rmSync(ocLedger, { force: true })\n }\n}\n\n// OpenCode: two built-in providers cannot work in Orca Cloud, and with no other credential OpenCode\n// picks one and fails (\"Authorization header is badly formatted\" / opencode.ai unreachable) instead\n// of showing \"Connect a provider\". GitHub Copilot reads GITHUB_TOKEN, which here is the stand-in for the\n// GitHub App token of the workspace, never a Copilot token; OpenCode Zen needs opencode.ai, which is not on the\n// egress allowlist. Both stay off unless the user connected them in OpenCode itself (auth.json).\nconst ocAuth = readStrict(path.join(process.env.XDG_DATA_HOME || path.join(home, \".local\", \"share\"), \"opencode\", \"auth.json\"))\nconst connected = new Set(isObject(ocAuth) ? Object.keys(ocAuth) : [])\nconst ocDisabledLedger = path.join(ocDir, \".orca-managed-disabled-providers\")\nconst ocNow = readStrict(ocFile)\nif (isObject(ocNow) && !/\"disabled_providers\"\\s*:/.test((() => { try { return fs.readFileSync(path.join(ocDir, \"opencode.jsonc\"), \"utf8\") } catch { return \"\" } })())) {\n let ours = []\n try { ours = fs.readFileSync(ocDisabledLedger, \"utf8\").split(\"\\n\").filter(Boolean) } catch {}\n const userList = (Array.isArray(ocNow.disabled_providers) ? ocNow.disabled_providers : []).filter((id) => typeof id === \"string\" && !ours.includes(id))\n const managed = [\"github-copilot\", \"opencode\"].filter((id) => !connected.has(id) && !userList.includes(id))\n const next = [...userList, ...managed]\n const before = JSON.stringify(ocNow.disabled_providers ?? [])\n if (JSON.stringify(next) !== before) {\n const updated = { $schema: \"https://opencode.ai/config.json\", ...ocNow }\n if (next.length > 0) updated.disabled_providers = next\n else delete updated.disabled_providers\n write(ocFile, updated)\n }\n if (managed.length > 0) fs.writeFileSync(ocDisabledLedger, managed.join(\"\\n\") + \"\\n\", { mode: 0o600 })\n else fs.rmSync(ocDisabledLedger, { force: true })\n}\n' || echo 'agent first-run state not seeded' >&2\nfi\nprintf '{\"names\":%d}\\n' \"$count\"\n","orca-session-env.sh":"# Orca Cloud: sourced by every new bash/zsh (via /etc/profile.d) so terminals and agents started\n# after a secret edit get the current values with no restart. The file is written by\n# /opt/orca/bin/orca-sandbox-env on tmpfs; names it no longer lists are unset. Cloud secrets win\n# over same-named values already in the environment.\n__orca_env_file=\"/dev/shm/orca-env-$(id -u 2>/dev/null)/session.env\"\nif [ -r \"$__orca_env_file\" ]; then\n # $(...) word-splits in both bash and zsh.\n for __orca_n in $(printf '%s' \"${ORCA_SESSION_ENV_NAMES-}\"); do\n unset \"$__orca_n\" 2>/dev/null || true\n done\n __orca_names=''\n while IFS= read -r __orca_line || [ -n \"$__orca_line\" ]; do\n __orca_n=\"${__orca_line%%=*}\"\n case \"$__orca_n\" in '' | *[!A-Z0-9_]* | [0-9]* | ORCA_* | PATH | HOME | LD_* | DYLD_*) continue ;; esac\n export \"$__orca_n=$(printf '%s' \"${__orca_line#*=}\" | base64 -d 2>/dev/null)\"\n __orca_names=\"$__orca_names $__orca_n\"\n done <\"$__orca_env_file\"\n export ORCA_SESSION_ENV_NAMES=\"${__orca_names# }\"\nfi\n# Default branch of the cloud workspace repo, once its first-boot clone exists (computed once per shell tree).\nif [ -n \"${ORCA_WORKSPACE_PATH-}\" ] && [ -z \"${ORCA_DEFAULT_BRANCH-}\" ]; then\n __orca_branch=\"$(git -C \"$ORCA_WORKSPACE_PATH\" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)\"\n if [ -n \"$__orca_branch\" ]; then\n export ORCA_DEFAULT_BRANCH=\"${__orca_branch#origin/}\" CONDUCTOR_DEFAULT_BRANCH=\"${__orca_branch#origin/}\"\n fi\nfi\n# CR-cmd-quiet: Orca's staged agent launch (` . '/tmp/orca-launch-….sh'`) stays out of zsh history.\n# zsh records a line before running it, so the hook must exist first; the staged script itself\n# drops the line from bash's history. eval: bash parses this file too.\nif [ -n \"${ZSH_VERSION-}\" ]; then\n eval '__orca_skip_launch_history() { [[ $1 == *orca-launch-* ]] && return 1; return 0 }\n (( ${zshaddhistory_functions[(Ie)__orca_skip_launch_history]} )) || zshaddhistory_functions+=(__orca_skip_launch_history)'\nfi\nunset __orca_env_file __orca_line __orca_n __orca_names __orca_branch\n","orca-env":"#!/usr/bin/env bash\n# Prints shell lines that bring the current shell up to date with this session's cloud env (secrets\n# saved after the shell started): eval \"$(orca-env)\" or, in fish: orca-env --fish | source\n# Reads /dev/shm/orca-env-/session.env (written by orca-sandbox-env, `NAME=` lines) and\n# prints an export for each name, plus an unset for each name in $ORCA_SESSION_ENV_NAMES that is gone.\n# Never writes a file. Values are single-quoted, so quotes, newlines and $ in them stay literal.\nset -euo pipefail\nfish=''\ncase \"${1-}\" in\n '') ;;\n --fish) fish=1 ;;\n -h | --help)\n printf 'usage: eval \"$(orca-env)\" (fish: orca-env --fish | source)\\n'\n exit 0\n ;;\n *)\n printf 'orca-env: unknown option %s\\n' \"$1\" >&2\n exit 2\n ;;\nesac\n# ORCA_SESSION_ENV_FILE is for tests; secrets can't set ORCA_* names.\nfile=\"${ORCA_SESSION_ENV_FILE:-/dev/shm/orca-env-$(id -u)/session.env}\"\nset -f # names are split below, never globbed\n# Not written yet (or not a cloud VM): nothing to change, like the shell hook.\n[ -r \"$file\" ] || exit 0\n\n# 'it'\\''s' for POSIX shells; fish single quotes escape only \\ and '.\nquote() {\n local v=\"$1\"\n if [ -n \"$fish\" ]; then\n v=\"${v//\\\\/\\\\\\\\}\"\n v=\"${v//\\'/\\\\\\'}\"\n printf \"'%s'\" \"$v\"\n else\n printf \"'%s'\" \"${v//\\'/\\'\\\\\\'\\'}\"\n fi\n}\n\n# The control plane's reserved names and prefixes (cloud/apps/sandbox-control/src/secrets.ts; a test\n# keeps this in sync): a session file never sets what would hijack the shell or Orca's own processes.\nskip_name() {\n case \"$1\" in\n '' | *[!A-Z0-9_]* | [0-9]*) return 0 ;;\n LD_* | DYLD_* | ORCA_* | VERCEL_* | BASH_FUNC_* | GIT_CONFIG*) return 0 ;;\n PATH | HOME | USER | LOGNAME | SHELL | PWD | OLDPWD | LANG | TERM | TMPDIR | IFS | ENV | \\\n BASH_ENV | PS4 | PROMPT_COMMAND | SHELLOPTS | BASHOPTS | NODE_OPTIONS | NODE_PATH | \\\n NODE_EXTRA_CA_CERTS | NODE_TLS_REJECT_UNAUTHORIZED | SSL_CERT_FILE | SSL_CERT_DIR | \\\n CURL_CA_BUNDLE | REQUESTS_CA_BUNDLE | HTTP_PROXY | HTTPS_PROXY | ALL_PROXY | NO_PROXY | \\\n PYTHONPATH | PYTHONSTARTUP | PERL5OPT | RUBYOPT | JAVA_TOOL_OPTIONS | GIT_SSH | \\\n GIT_SSH_COMMAND | GIT_ASKPASS | GIT_EXEC_PATH | GIT_PROXY_COMMAND | SSH_AUTH_SOCK | \\\n GH_TOKEN | GITHUB_TOKEN) return 0 ;;\n # CLAUDE_CODE_OAUTH_TOKEN is reserved for users but set by the control plane itself: only ever the\n # Orca-account placeholder (contract D10), never a stored value.\n esac\n return 1\n}\n\nnames=' '\nwhile IFS= read -r line || [ -n \"$line\" ]; do\n name=\"${line%%=*}\"\n skip_name \"$name\" && continue\n # $(...) drops trailing newlines; the x keeps them.\n value=\"$(printf '%s' \"${line#*=}\" | base64 -d 2>/dev/null; printf x)\"\n value=\"${value%x}\"\n if [ -n \"$fish\" ]; then\n printf 'set -gx %s %s\\n' \"$name\" \"$(quote \"$value\")\"\n else\n printf 'export %s=%s\\n' \"$name\" \"$(quote \"$value\")\"\n fi\n names=\"$names$name \"\ndone <\"$file\"\nfor name in ${ORCA_SESSION_ENV_NAMES-}; do\n skip_name \"$name\" && continue\n case \"$names\" in *\" $name \"*) continue ;; esac\n if [ -n \"$fish\" ]; then\n printf 'set -e %s\\n' \"$name\"\n else\n printf 'unset %s\\n' \"$name\"\n fi\ndone\nnames=\"${names# }\"\nnames=\"${names% }\"\nif [ -n \"$fish\" ]; then\n printf 'set -gx ORCA_SESSION_ENV_NAMES %s\\n' \"$(quote \"$names\")\"\nelse\n printf 'export ORCA_SESSION_ENV_NAMES=%s\\n' \"$(quote \"$names\")\"\nfi\n","orca-egress-probe":"#!/usr/bin/env bash\n# Bounded Node fetch against each credential host. Vercel transform rules have made undici hang on\n# response bodies (no content-length / transfer-encoding); report it instead of letting agents hang.\n# Prints one JSON line: {\"host\":\"api.github.com\",\"status\":\"ok\"|\"egress_framing_hang\"|\"error\"}.\nset -euo pipefail\nnode=\"$(find /opt/orca/runtime/runtimes -type f -name node | head -n 1)\"\nfor host in \"$@\"; do\n \"$node\" -e '\n const host = process.argv[1]\n const timer = setTimeout(() => { console.log(JSON.stringify({ host, status: \"egress_framing_hang\" })); process.exit(0) }, 8000)\n fetch(`https://${host}/`, { headers: { authorization: process.env.ORCA_PROBE_AUTHORIZATION ?? \"\" } })\n .then((r) => r.arrayBuffer())\n .then(() => { clearTimeout(timer); console.log(JSON.stringify({ host, status: \"ok\" })) })\n .catch(() => { clearTimeout(timer); console.log(JSON.stringify({ host, status: \"error\" })) })\n ' \"$host\"\ndone\n","orca-image-wipe":"#!/usr/bin/env bash\n# Runs last before the golden snapshot. Every sandbox cloned from the image must start without an\n# orcad identity, device registry, agent sign-in or shell history (clone-safe identity, risk #1).\nset -euo pipefail\npkill -f orcad-server.js 2>/dev/null || true\nfor path in \\\n \"$HOME/.orcad-data\" \"$HOME/.orca-sandbox\" \"$HOME/.orca-desktop\" \"$HOME/.config/orca\" \"$HOME/.config/orca-dev\" \\\n \"$HOME/.claude\" \"$HOME/.claude.json\" \"$HOME/.codex\" \"$HOME/.config/gh\" \"$HOME/.gemini\" \\\n \"$HOME/.local/share/opencode\" \"$HOME/.config/opencode\" \"$HOME/.cursor\" \"$HOME/.config/cursor\" \\\n \"$HOME/.local/share/cursor-agent\" \"$HOME/.copilot\" \"$HOME/.npm\" \"$HOME/.cache\" \"$HOME/.config/google-chrome\" \"$HOME/.bash_history\" \\\n /vercel/sandbox/project; do\n rm -rf -- \"$path\"\ndone\nsudo rm -rf /tmp/* /var/tmp/* 2>/dev/null || true\nfor leftover in \"$HOME/.orcad-data\" \"$HOME/.claude\" \"$HOME/.codex\" \"$HOME/.config/gh\" \"$HOME/.cursor\" \"$HOME/.copilot\" \"$HOME/.gemini\"; do\n if [ -e \"$leftover\" ]; then\n echo \"identity survived wipe: $leftover\" >&2\n exit 1\n fi\ndone\necho '{\"wiped\":true}'\n","orca-agent-signout":"#!/usr/bin/env bash\n# Signs every agent out on this sandbox before Orca deletes it (A9): each agent's own logout, then\n# its credential files. Covers logins made inside the sandbox (the kill-switch fallback path).\n# Never fails: the sandbox is deleted next whatever happens. Values are never printed.\nset -u\nHOME=\"${HOME:-$(getent passwd \"$(id -u)\" | cut -d: -f6)}\"\nexport PATH=\"/opt/orca/tools/bin:$HOME/.local/bin:$HOME/.npm-global/bin:/usr/local/bin:/usr/bin:/bin:/opt/orca/bin:${PATH:-}\"\nclaude_dir=\"${CLAUDE_CONFIG_DIR:-$HOME/.claude}\"\nclaude_json=\"${CLAUDE_CONFIG_DIR:-$HOME}/.claude.json\"\ncodex_dir=\"${CODEX_HOME:-$HOME/.codex}\"\n\nlogged_out=0\n# Each logout gets a few seconds; a hung CLI must not hold up the delete.\nif command -v claude >/dev/null 2>&1 && timeout 8 claude auth logout /dev/null 2>&1; then\n logged_out=$((logged_out + 1))\nfi\nif command -v codex >/dev/null 2>&1 && timeout 8 codex logout /dev/null 2>&1; then\n logged_out=$((logged_out + 1))\nfi\n\n# The files go whether or not a logout ran: an older CLI, a missing binary or no network leaves them.\nrm -f -- \"$claude_dir\"/.credentials* \"$HOME/.claude\"/.credentials* \"$codex_dir/auth.json\" \"$HOME/.codex/auth.json\" 2>/dev/null\n# Orca-managed Codex homes keep their own auth.json under orcad's data dir.\nif [ -d \"$HOME/.orcad-data\" ]; then\n find \"$HOME/.orcad-data\" -path '*codex*' -type f -name auth.json -delete 2>/dev/null\nfi\n# ~/.claude.json keeps settings Orca wrote (onboarding, trust); only the sign-in fields go.\nnode_bin=\"$(command -v node || find /opt/orca/runtime/runtimes -type f -name node 2>/dev/null | head -n 1)\"\nfor file in \"$claude_json\" \"$HOME/.claude.json\"; do\n [ -f \"$file\" ] || continue\n if [ -n \"$node_bin\" ]; then\n ORCA_FILE=\"$file\" \"$node_bin\" -e '\nconst fs = require(\"fs\")\nconst file = process.env.ORCA_FILE\nlet c\ntry { c = JSON.parse(fs.readFileSync(file, \"utf8\")) } catch { process.exit(2) }\nif (!c || typeof c !== \"object\" || Array.isArray(c)) process.exit(2)\nlet changed = false\nfor (const key of Object.keys(c)) {\n if (/oauth/i.test(key) || key === \"primaryApiKey\") { delete c[key]; changed = true }\n}\nif (changed) {\n const tmp = `${file}.orca-${process.pid}`\n fs.writeFileSync(tmp, JSON.stringify(c, null, 2) + \"\\n\", { mode: 0o600 })\n fs.renameSync(tmp, file)\n}\n' 2>/dev/null || rm -f -- \"$file\"\n else\n # No node to edit it safely: losing Orca's settings beats leaving a sign-in behind.\n rm -f -- \"$file\"\n fi\ndone\n\nleft=0\nfor file in \"$claude_dir\"/.credentials* \"$HOME/.claude\"/.credentials* \"$codex_dir/auth.json\" \"$HOME/.codex/auth.json\"; do\n [ -e \"$file\" ] && left=$((left + 1))\ndone\nprintf '{\"loggedOut\":%d,\"left\":%d}\\n' \"$logged_out\" \"$left\"\n","orca-share-proxy":"#!/usr/bin/env node\n// Share-link proxy for this VM. Listens on 0.0.0.0:6769 (its public *.vercel.run route) and only\n// serves requests the control plane's share gateway signed with this session's key:\n// x-orca-share: v2.....\n// A request must start before `expiresAt` (60s windows); its response or WebSocket is closed at\n// `until` (the link's expiry, at most 30 minutes out), so a revoked or expired link also loses open\n// streams. Signed requests are reverse-proxied to 127.0.0.1:: streaming both ways, no\n// buffering, WebSocket upgrades piped raw. Anything unsigned gets a bare 404.\n// The key arrives on stdin (orca-sandbox-start pipes it), so it is never in this process's\n// environment or argv; ORCA_SHARE_PROXY_KEY in the env is accepted for tests. Optional\n// ORCA_SHARE_PROXY_PORT (default 6769). Plain Node, no dependencies.\n// getBuiltinModule (Node >= 22.3) works whether node loads this extensionless file as CommonJS\n// (in the VM) or as an ES module (under this repo's type:module package, in tests).\nconst crypto = process.getBuiltinModule('node:crypto')\nconst fs = process.getBuiltinModule('node:fs')\nconst http = process.getBuiltinModule('node:http')\nconst net = process.getBuiltinModule('node:net')\n\nconst LISTEN_PORT = Number(process.env.ORCA_SHARE_PROXY_PORT || 6769)\nconst KEY = Buffer.from((process.env.ORCA_SHARE_PROXY_KEY || fs.readFileSync(0, 'utf8')).trim(), 'base64url')\ndelete process.env.ORCA_SHARE_PROXY_KEY\n// Never proxy to the VM's sshd, orcad or this proxy itself.\n// 6080: the remote desktop (VNC behind a password) is for its owner's forward only.\nconst BLOCKED_PORTS = new Set([2222, 6080, 6768, LISTEN_PORT])\nconst MAX_SKEW_SECONDS = 300\nconst MAX_CONNECTION_SECONDS = 3600\nconst HOP_BY_HOP = ['connection', 'keep-alive', 'proxy-connection', 'transfer-encoding', 'te', 'trailer', 'upgrade']\n\nif (KEY.length < 32) {\n console.error('orca-share-proxy: share proxy key missing or short')\n process.exit(2)\n}\n\n/** The target port and close-by time (ms) for a validly signed request, else null. */\nfunction verify(header, nowMs) {\n if (typeof header !== 'string') {\n return null\n }\n const match = /^v2\\.(\\d{1,5})\\.(\\d{1,12})\\.(\\d{1,12})\\.([0-9a-f]{32})\\.([A-Za-z0-9_-]{43})$/.exec(header)\n if (!match) {\n return null\n }\n const now = Math.floor(nowMs / 1000)\n const port = Number(match[1])\n const expiresAt = Number(match[2])\n const until = Number(match[3])\n if (port < 1 || port > 65535 || BLOCKED_PORTS.has(port)) {\n return null\n }\n if (expiresAt < now || expiresAt > now + MAX_SKEW_SECONDS || until <= now || until > now + MAX_CONNECTION_SECONDS) {\n return null\n }\n const signed = header.slice(0, header.lastIndexOf('.'))\n const expected = crypto.createHmac('sha256', KEY).update(signed).digest()\n const given = Buffer.from(match[5], 'base64url')\n return given.length === expected.length && crypto.timingSafeEqual(given, expected) ? { port, untilMs: until * 1000 } : null\n}\n\nfunction upstreamHeaders(raw, port) {\n const headers = { ...raw }\n const connectionTokens = String(raw.connection || '')\n .split(',')\n .map((token) => token.trim().toLowerCase())\n for (const name of [...HOP_BY_HOP, ...connectionTokens, 'x-orca-share']) {\n delete headers[name]\n }\n // Dev servers (Vite, Next) allow localhost hosts by default; the public host rides x-forwarded-host.\n headers.host = `localhost:${port}`\n return headers\n}\n\nfunction refuse(res) {\n res.writeHead(404, { 'content-type': 'text/plain', 'cache-control': 'no-store' })\n res.end('not found')\n}\n\n/** Ends `close` at the grant's deadline; the timer goes away with the connection. */\nfunction deadline(untilMs, close, ...emitters) {\n const timer = setTimeout(close, Math.max(0, untilMs - Date.now()))\n for (const emitter of emitters) {\n emitter.once('close', () => clearTimeout(timer))\n }\n}\n\nconst server = http.createServer((req, res) => {\n const grant = verify(req.headers['x-orca-share'], Date.now())\n if (grant === null) {\n return refuse(res)\n }\n const { port } = grant\n const upstream = http.request(\n { host: '127.0.0.1', port, method: req.method, path: req.url, headers: upstreamHeaders(req.headers, port) },\n (answer) => {\n const headers = { ...answer.headers }\n for (const name of HOP_BY_HOP) {\n delete headers[name]\n }\n // Marks the answer as the app's, so the gateway can tell it from a route with no proxy behind it.\n headers['x-orca-share-proxy'] = '1'\n res.writeHead(answer.statusCode || 502, answer.statusMessage, headers)\n answer.pipe(res)\n }\n )\n deadline(grant.untilMs, () => {\n upstream.destroy()\n res.destroy()\n }, res)\n upstream.on('error', (error) => {\n if (res.headersSent) {\n res.destroy()\n return\n }\n // The gateway renders its own page for this; the code tells it why. Status 200 on purpose: a 5xx\n // from the VM reaches a Worker as Vercel's own 502 with our headers gone (found live).\n res.writeHead(200, { 'content-type': 'text/plain', 'x-orca-share-proxy': '1', 'x-orca-share-error': error.code === 'ECONNREFUSED' ? 'no_listener' : 'upstream_failed' })\n res.end('upstream unavailable')\n })\n req.on('aborted', () => upstream.destroy())\n req.pipe(upstream)\n})\n\nserver.on('upgrade', (req, socket, head) => {\n const grant = verify(req.headers['x-orca-share'], Date.now())\n if (grant === null) {\n socket.end('HTTP/1.1 404 Not Found\\r\\nconnection: close\\r\\ncontent-length: 0\\r\\n\\r\\n')\n return\n }\n const { port } = grant\n const headers = upstreamHeaders(req.headers, port)\n const target = net.connect(port, '127.0.0.1', () => {\n const lines = [`${req.method} ${req.url} HTTP/1.1`, `connection: upgrade`, `upgrade: ${req.headers.upgrade || 'websocket'}`]\n for (const [name, value] of Object.entries(headers)) {\n for (const item of Array.isArray(value) ? value : [value]) {\n lines.push(`${name}: ${item}`)\n }\n }\n target.write(`${lines.join('\\r\\n')}\\r\\n\\r\\n`)\n if (head && head.length > 0) {\n target.write(head)\n }\n socket.pipe(target)\n target.pipe(socket)\n })\n const close = () => {\n socket.destroy()\n target.destroy()\n }\n deadline(grant.untilMs, close, socket)\n target.on('error', () => {\n if (socket.writable) {\n socket.end('HTTP/1.1 200 OK\\r\\nx-orca-share-proxy: 1\\r\\nx-orca-share-error: no_listener\\r\\nconnection: close\\r\\ncontent-length: 0\\r\\n\\r\\n')\n }\n close()\n })\n socket.on('error', close)\n target.on('close', () => socket.destroy())\n socket.on('close', () => target.destroy())\n})\n\nserver.keepAliveTimeout = 65_000\nserver.headersTimeout = 70_000\n// No request timeout: previews can stream (SSE, long polls) until the grant's deadline.\nserver.requestTimeout = 0\nserver.listen(LISTEN_PORT, '0.0.0.0', () => {\n console.log(`orca-share-proxy listening on ${LISTEN_PORT}`)\n})\n","orca-sshd":"#!/usr/bin/env bash\n# Starts this boot's unprivileged sshd on 127.0.0.1:2222 (idempotent). Nothing outside the VM can\n# reach that port: the desktop tunnels to it over orcad's runtime connection (`orca ssh-proxy`).\n# Key-only, single user (the sandbox user), no root. Its host key and authorized_keys live under\n# ~/.orca-sandbox/ssh on the persistent disk, so the host key stays the same across stop/resume;\n# orca-image-wipe removes that directory, so every VM cloned from the golden image makes its own.\n# Prints {\"port\":2222,\"user\":\"\",\"hostKey\":\"ssh-ed25519 AAAA...\"} on success.\nset -euo pipefail\numask 077\nOPENSSH=/opt/orca/openssh\nSSH_DIR=\"$HOME/.orca-sandbox/ssh\"\nPORT=2222\nmkdir -p \"$SSH_DIR\"\nchmod 0700 \"$HOME/.orca-sandbox\" \"$SSH_DIR\"\nexec 8>\"$SSH_DIR/sshd.lock\"\nflock -w 30 8\n\nif [ ! -x \"$OPENSSH/sshd\" ]; then\n echo 'sshd is not installed in this image' >&2\n exit 3\nfi\nif [ ! -s \"$SSH_DIR/ssh_host_ed25519_key\" ]; then\n rm -f \"$SSH_DIR/ssh_host_ed25519_key\" \"$SSH_DIR/ssh_host_ed25519_key.pub\"\n ssh-keygen -q -t ed25519 -N '' -C \"orca-cloud-host\" -f \"$SSH_DIR/ssh_host_ed25519_key\"\nfi\ntouch \"$SSH_DIR/authorized_keys\"\nchmod 0600 \"$SSH_DIR/authorized_keys\"\n\n# Rewritten every start so an image update's settings apply to existing workspaces. PAM stays at\n# its compiled-in default (off): an unprivileged sshd cannot use it, and this build warns on `no`.\ncat >\"$SSH_DIR/sshd_config\" </dev/null || echo 0)\"\n# Resume reboots from disk, so a recorded pid can name a dead process or an unrelated one.\nif ! { kill -0 \"$pid\" 2>/dev/null && grep -q sshd \"/proc/$pid/cmdline\" 2>/dev/null; }; then\n rm -f \"$SSH_DIR/sshd.pid\"\n : >\"$SSH_DIR/sshd.log\"\n \"$OPENSSH/sshd\" -t -f \"$SSH_DIR/sshd_config\"\n \"$OPENSSH/sshd\" -f \"$SSH_DIR/sshd_config\" -E \"$SSH_DIR/sshd.log\"\n for _ in $(seq 1 100); do\n [ -s \"$SSH_DIR/sshd.pid\" ] && break\n sleep 0.02\n done\n [ -s \"$SSH_DIR/sshd.pid\" ] || { echo 'sshd did not start' >&2; tail -n 20 \"$SSH_DIR/sshd.log\" >&2; exit 1; }\nfi\n\nhost_key=\"$(cut -d' ' -f1-2 \"$SSH_DIR/ssh_host_ed25519_key.pub\")\"\nprintf '{\"port\":%s,\"user\":\"%s\",\"hostKey\":\"%s\"}\\n' \"$PORT\" \"$(id -un)\" \"$host_key\"\n","orca-ssh-keys":"#!/usr/bin/env bash\n# `orca-ssh-keys add|remove`: adds or removes one desktop's key in the Orca-managed authorized_keys\n# of this VM's sshd, then makes sure sshd runs. The control plane passes the key in\n# ORCA_SSH_PUBLIC_KEY (`ssh-ed25519 `), never on the command line. Keys are\n# matched by their base64 blob, so a renamed comment cannot keep a revoked key alive.\n# Prints orca-sshd's {\"port\":...,\"user\":...,\"hostKey\":...} line on success.\nset -euo pipefail\numask 077\nSSH_DIR=\"$HOME/.orca-sandbox/ssh\"\naction=\"${1:-}\"\nkey=\"${ORCA_SSH_PUBLIC_KEY:-}\"\n# grep matches per line: a second line could carry key options past the check below.\ncase \"$key\" in *$'\\n'* | *$'\\r'*) echo 'invalid public key' >&2; exit 2 ;; esac\n# One line, ed25519 only, a plain comment: nothing that sshd would read as key options.\nif ! printf '%s' \"$key\" | grep -Eq '^ssh-ed25519 [A-Za-z0-9+/]{68} [A-Za-z0-9@._:+-]{1,200}$'; then\n echo 'invalid public key' >&2\n exit 2\nfi\nblob=\"$(printf '%s' \"$key\" | cut -d' ' -f2)\"\nmkdir -p \"$SSH_DIR\"\nchmod 0700 \"$HOME/.orca-sandbox\" \"$SSH_DIR\"\nexec 7>\"$SSH_DIR/keys.lock\"\nflock -w 30 7\ntouch \"$SSH_DIR/authorized_keys\"\nnext=\"$(mktemp \"$SSH_DIR/authorized_keys.XXXXXX\")\"\n# Drop the key wherever it appears (with or without options), then append it on add.\nawk -v blob=\"$blob\" '{ for (i = 1; i <= NF; i++) if ($i == blob) next; print }' \\\n \"$SSH_DIR/authorized_keys\" >\"$next\"\ncase \"$action\" in\n add) printf '%s\\n' \"$key\" >>\"$next\" ;;\n remove) ;;\n *) rm -f \"$next\"; echo 'usage: orca-ssh-keys add|remove' >&2; exit 2 ;;\nesac\nchmod 0600 \"$next\"\nmv -f \"$next\" \"$SSH_DIR/authorized_keys\"\nexec /opt/orca/bin/orca-sshd\n","orca-desktop-start":"#!/usr/bin/env bash\n# Starts the on-demand remote desktop (idempotent) and mints one connection ticket. Prints one line:\n# {\"schemaVersion\":2,\"port\":6080,\"display\":\":1\",\"path\":\"vnc.html\",\"ticket\":\"<43 chars>\",\"expiresInMs\":60000}\n# Xvnc (X server + VNC in one) listens on a 0600 unix socket only, with no VNC auth: only this uid\n# can open it. orca-desktop-proxy (websockify + a ticket gate) serves noVNC on 127.0.0.1 and\n# bridges WebSockets to that socket. Nothing is reachable from outside the VM: the desktop app\n# reaches it through Orca's port forward, a localhost port on the user's computer that any local\n# process or web page can reach, so each WebSocket needs a ticket from here: 32 random bytes,\n# single use, valid for 60 s, stored only as its sha256 (the file name; its content is the expiry).\n# The ticket goes to stdout only (the control plane hands it to the desktop app). Never started at boot.\nset -euo pipefail\numask 077\nDESKTOP=/opt/orca/desktop\nSTATE=\"$HOME/.orca-desktop\"\nPORT=\"${ORCA_DESKTOP_PORT:-6080}\"\nDISPLAY_NUM=\"${ORCA_DESKTOP_DISPLAY:-1}\"\nGEOMETRY=\"${ORCA_DESKTOP_GEOMETRY:-1280x800}\"\nSOCKET=\"$STATE/vnc.sock\"\n# The parent's DISPLAY must not leak into the WM/xterm below; they use ours.\nexport DISPLAY=\":$DISPLAY_NUM\"\n\n# Exit 3 = this image has no desktop (built before it, or its optional install failed).\nfail() { printf 'orca-desktop: %s\\n' \"$1\" >&2; exit \"${2:-1}\"; }\ncase \"$PORT\" in '' | *[!0-9]*) fail 'ORCA_DESKTOP_PORT must be a number' ;; esac\ncase \"$DISPLAY_NUM\" in '' | *[!0-9]*) fail 'ORCA_DESKTOP_DISPLAY must be a number' ;; esac\ncase \"$GEOMETRY\" in [0-9]*x[0-9]*) ;; *) fail 'ORCA_DESKTOP_GEOMETRY must look like 1280x800' ;; esac\ncommand -v Xvnc >/dev/null || fail 'not installed in this image (Xvnc missing)' 3\n[ -s \"$DESKTOP/novnc/vnc.html\" ] || fail 'not installed in this image (noVNC missing)' 3\nPROXY=\"$(dirname \"$(readlink -f \"$0\")\")/orca-desktop-proxy\"\n[ -x \"$PROXY\" ] || fail 'orca-desktop-proxy missing next to this script'\nTICKETS=\"$STATE/tickets\"\nTICKET_TTL_S=60\n# Xvnc's mode: a previous version of this script ran it with VncAuth; restart it in this mode.\nMODE=ticket-v1\n\nmkdir -p \"$STATE\"\nexec 9>\"$STATE/start.lock\"\nflock -w 30 9 || fail 'another start is in progress'\n\npid_of() { # never \"0\": kill 0 targets our own process group\n local pid\n pid=\"$(cat \"$STATE/$1.pid\" 2>/dev/null || true)\"\n case \"$pid\" in '' | 0 | *[!0-9]*) return 1 ;; esac\n printf '%s' \"$pid\"\n}\nalive() { local pid; pid=\"$(pid_of \"$1\")\" && kill -0 \"$pid\" 2>/dev/null; }\nmint_ticket() { # prints the ticket; stores only its sha256, with the expiry as content\n local ticket now digest\n mkdir -p \"$TICKETS\"\n now=\"$(date +%s)\"\n # Expired or spent-but-unswept tickets; keep at most 16 live ones (oldest go first).\n for file in \"$TICKETS\"/*; do\n [ -f \"$file\" ] || continue\n [ \"$(cat \"$file\" 2>/dev/null || echo 0)\" -ge \"$now\" ] 2>/dev/null || rm -f \"$file\"\n done\n # shellcheck disable=SC2012 # names are hex digests\n ls -1t \"$TICKETS\" 2>/dev/null | tail -n +16 | while read -r old; do rm -f \"$TICKETS/$old\"; done\n ticket=\"$(head -c 32 /dev/urandom | base64 | tr '+/' '-_' | tr -d '=\\n')\"\n [ \"${#ticket}\" = 43 ] || fail 'could not generate a ticket'\n digest=\"$(printf '%s' \"$ticket\" | sha256sum | cut -d' ' -f1)\"\n printf '%s' \"$((now + TICKET_TTL_S))\" >\"$TICKETS/.$digest\" && mv \"$TICKETS/.$digest\" \"$TICKETS/$digest\"\n printf '%s' \"$ticket\"\n}\nready_line() {\n local ticket\n ticket=\"$(mint_ticket)\" || exit 1\n printf '{\"schemaVersion\":2,\"port\":%s,\"display\":\":%s\",\"path\":\"vnc.html\",\"ticket\":\"%s\",\"expiresInMs\":%s}\\n' \\\n \"$PORT\" \"$DISPLAY_NUM\" \"$ticket\" \"$((TICKET_TTL_S * 1000))\"\n}\nlistening() { # the proxy answers HTTP on loopback\n curl -fsS -o /dev/null --max-time 1 \"http://127.0.0.1:$PORT/vnc.html\" 2>/dev/null\n}\n\n# Pids from a previous boot (resume = reboot from disk) name dead or unrelated processes.\nboot_id=\"$(cat /proc/sys/kernel/random/boot_id)\"\nif [ \"$(cat \"$STATE/boot-id\" 2>/dev/null || true)\" != \"$boot_id\" ]; then\n rm -rf \"$STATE\"/*.pid \"$SOCKET\" \"$STATE/password\" \"$STATE/passwd\" \"$STATE/mode\" \"$TICKETS\"\n printf '%s' \"$boot_id\" >\"$STATE/boot-id\"\nfi\n\n# Same boot, older script: its websockify had no ticket gate and its Xvnc wanted a VNC password.\nrm -f \"$STATE/password\" \"$STATE/passwd\"\nif alive websockify; then kill \"$(pid_of websockify)\" 2>/dev/null || true; fi\nrm -f \"$STATE/websockify.pid\"\ncurrent_mode() { cat \"$STATE/mode\" 2>/dev/null || true; }\n\nif alive xvnc && alive proxy && listening && [ \"$(current_mode)\" = \"$MODE\" ]; then\n ready_line\n exit 0\nfi\n\nstart() { # name cmd... — detached, logs in $STATE/.log\n local name=$1; shift\n # 9>&-: children must not inherit the start lock, or the next call waits for them to exit.\n setsid nohup \"$@\" >\"$STATE/$name.log\" 2>&1 &- &\n echo $! >\"$STATE/$name.pid\"\n}\n\nif ! alive xvnc || [ \"$(current_mode)\" != \"$MODE\" ]; then\n if alive xvnc; then kill \"$(pid_of xvnc)\" 2>/dev/null || true; sleep 0.2; fi\n rm -f \"$SOCKET\"\n # -rfbport -1: no TCP VNC port at all, only a 0600 unix socket (in the 0700 state dir) for the\n # proxy. SecurityTypes None is safe only because of that: the ticket gate is in the proxy.\n # SendPrimary=0: only explicit copies (xterm copies its selection, below) leave the sandbox.\n # The X display itself takes a cookie from ~/.Xauthority (Xlib's default for this user), so only\n # this uid's processes (agents, apps they start on DISPLAY=:1) can open it.\n auth=()\n if command -v xauth >/dev/null && command -v mcookie >/dev/null; then\n xauth -q -f \"$HOME/.Xauthority\" add \":$DISPLAY_NUM\" . \"$(mcookie)\" 9>&- && auth=(-auth \"$HOME/.Xauthority\")\n fi\n start xvnc Xvnc \":$DISPLAY_NUM\" \"${auth[@]}\" -geometry \"$GEOMETRY\" -depth 24 -nolisten tcp \\\n -rfbport -1 -rfbunixpath \"$SOCKET\" -rfbunixmode 0600 \\\n -SecurityTypes None -SendPrimary=0 \\\n -AlwaysShared -AcceptSetDesktopSize -desktop 'Remote desktop'\n printf '%s' \"$MODE\" >\"$STATE/mode\"\n for _ in $(seq 1 100); do\n [ -S \"$SOCKET\" ] && break\n alive xvnc || { tail -n 20 \"$STATE/xvnc.log\" >&2; fail 'Xvnc exited'; }\n sleep 0.05\n done\n [ -S \"$SOCKET\" ] || fail 'Xvnc did not create its socket'\n if command -v xsetroot >/dev/null; then xsetroot -solid '#1e1f22' 9>&- || true; fi\n if command -v openbox >/dev/null; then\n # Dark, flat theme to match Orca; the first terminal stays maximized as the tab resizes the screen.\n printf '%s\\n' '' \\\n 'Onyx' \\\n 'yes' \\\n '' >\"$STATE/openbox.xml\"\n start wm openbox --config-file \"$STATE/openbox.xml\"\n # Let it manage the screen before the terminal maps, so its maximize rule applies.\n sleep 0.2\n elif command -v twm >/dev/null; then\n start wm twm\n fi\n if command -v xterm >/dev/null; then\n # selectToClipboard: a selection is a copy (to CLIPBOARD, which reaches this computer).\n start xterm xterm -maximized -bg '#1e1f22' -fg '#d4d4d4' -fa 'Liberation Mono,Monospace' -fs 11 \\\n -xrm 'XTerm*selectToClipboard: true'\n fi\nfi\n\nif ! alive proxy || ! listening; then\n if alive proxy; then kill \"$(pid_of proxy)\" 2>/dev/null || true; sleep 0.1; fi\n mkdir -p \"$TICKETS\"\n PYTHONPATH=\"$DESKTOP/websockify\" start proxy python3 -P -W ignore::UserWarning \"$PROXY\" \\\n --listen \"$PORT\" --web \"$DESKTOP/novnc\" --unix-target \"$SOCKET\" --tickets \"$TICKETS\"\n for _ in $(seq 1 100); do\n listening && break\n alive proxy || { tail -n 20 \"$STATE/proxy.log\" >&2; fail 'orca-desktop-proxy exited'; }\n sleep 0.05\n done\n listening || fail \"orca-desktop-proxy is not answering on 127.0.0.1:$PORT\"\nfi\nready_line\n","orca-desktop-proxy":"#!/usr/bin/env python3\n# Remote desktop gateway, started by orca-desktop-start: serves noVNC on 127.0.0.1 and bridges one\n# WebSocket per single-use ticket to Xvnc's 0600 unix socket (Xvnc itself has no VNC auth: only this\n# uid can open the socket). The port is reached only through Orca's localhost forward on the user's\n# computer, where any local process or web page could knock, so every WebSocket must carry an\n# unused, unexpired ticket minted by orca-desktop-start (see consume_ticket), from a page on the same\n# localhost origin. Tickets never reach a log: paths are scrubbed before websockify logs them.\n# orca-desktop-proxy --listen PORT --web DIR --unix-target SOCKET --tickets DIR\n# stdlib + websockify (pinned tarball in /opt/orca/desktop/websockify, on PYTHONPATH) only.\nimport argparse\nimport hashlib\nimport hmac\nimport logging\nimport os\nimport re\nimport sys\nimport time\nfrom urllib.parse import parse_qs, urlsplit\n\nfrom websockify import websocketproxy\n\nTICKET_RE = re.compile(r'^[A-Za-z0-9_-]{43}$') # 32 random bytes, base64url without padding\nSCRUB_RE = re.compile(r'([?&]ticket=)[^&\\s\\'\"]*')\nLOOPBACK_HOST_RE = re.compile(r'^(localhost|127\\.0\\.0\\.1|\\[::1\\])(:[0-9]{1,5})?$')\nWS_PATH = '/websockify'\nPAGE_SCRIPT = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'orca-desktop-page.js')\n# The page may only talk to its own origin (its WebSocket included): even a URL that tries to point\n# noVNC elsewhere cannot carry a ticket or the user's keys off this forward.\nPAGE_CSP = \"connect-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'\"\nPAGE_TAG = b'\\n'\n\n\ndef scrub(text):\n return SCRUB_RE.sub(r'\\1', str(text))\n\n\ndef ticket_digest(ticket):\n return hashlib.sha256(ticket.encode('ascii')).hexdigest()\n\n\ndef consume_ticket(directory, ticket, now=None):\n \"\"\"True once per minted ticket, within its lifetime. The ticket file (named by the ticket's\n sha256, holding its expiry) is unlinked on first use, so a replay or a racing second\n connection finds nothing; each request runs in its own forked process, so state is on disk.\"\"\"\n if not isinstance(ticket, str) or not TICKET_RE.match(ticket):\n return False\n digest = ticket_digest(ticket)\n match = None\n try:\n names = os.listdir(directory)\n except OSError:\n return False\n for name in names:\n # Compare every entry in constant time; never short-circuit on a prefix.\n if hmac.compare_digest(name.encode('ascii', 'replace'), digest.encode('ascii')):\n match = name\n if match is None:\n return False\n path = os.path.join(directory, match)\n try:\n with open(path, 'r', encoding='ascii') as handle:\n expires_at = int(handle.read().strip() or '0')\n except (OSError, ValueError):\n expires_at = 0\n try:\n os.unlink(path) # the consume: only one caller can win this\n except FileNotFoundError:\n return False\n return (now if now is not None else time.time()) <= expires_at\n\n\ndef same_loopback_origin(headers):\n \"\"\"The page and the WebSocket share one localhost origin (blocks other local sites and DNS\n rebinding). The port is the user's local forward, which may differ from ours, so compare the\n Origin with the Host the browser dialed rather than with a fixed port.\"\"\"\n host = (headers.get('Host') or '').strip().lower()\n origin = (headers.get('Origin') or '').strip().lower()\n return loopback_host(host) and origin == 'http://' + host\n\n\ndef loopback_host(host):\n return bool(LOOPBACK_HOST_RE.match(host or ''))\n\n\nclass TicketedProxyHandler(websocketproxy.ProxyRequestHandler):\n \"\"\"websockify's proxy handler plus the ticket + origin gate. Module level (not a closure) so\n websockify's per-connection multiprocessing can pickle it under any start method.\"\"\"\n\n def log_message(self, format, *args): # noqa: A002 (websockify's signature)\n super().log_message('%s', scrub(format % args if args else format))\n\n def reject(self, code, reason):\n self.send_response(code)\n self.send_header('Content-Length', '0')\n self.send_header('Connection', 'close')\n self.end_headers()\n self.close_connection = True\n raise self.server.EClose('desktop: websocket refused (%s)' % reason)\n\n def validate_connection(self):\n parts = urlsplit(self.path)\n ticket = (parse_qs(parts.query).get('ticket') or [''])[0]\n # Drop it before anything below (websockify's own logging included) can see self.path.\n self.path = parts.path\n if parts.path != WS_PATH:\n self.reject(404, 'path')\n if not same_loopback_origin(self.headers):\n self.reject(403, 'origin')\n if not consume_ticket(self.server.orca_tickets_dir, ticket):\n self.reject(403, 'ticket')\n\n def loopback_request(self):\n if loopback_host((self.headers.get('Host') or '').strip().lower()):\n return True\n self.send_error(403)\n return False\n\n def do_GET(self):\n if not self.loopback_request():\n return None\n path = urlsplit(self.path).path\n if path == '/orca-desktop-page.js':\n return self.send_bytes(read_file(PAGE_SCRIPT), 'text/javascript; charset=utf-8')\n if path == '/vnc.html':\n page = read_file(os.path.join(self.server.web, 'vnc.html'))\n return self.send_bytes(page.replace(b'', PAGE_TAG, 1), 'text/html; charset=utf-8', PAGE_CSP)\n return super().do_GET()\n\n def do_HEAD(self):\n if not self.loopback_request():\n return None\n return super().do_HEAD()\n\n def send_bytes(self, body, content_type, csp=None):\n self.send_response(200)\n self.send_header('Content-Type', content_type)\n if csp:\n self.send_header('Content-Security-Policy', csp)\n self.send_header('Content-Length', str(len(body)))\n self.send_header('Cache-Control', 'no-store')\n self.send_header('Referrer-Policy', 'no-referrer')\n self.end_headers()\n self.wfile.write(body)\n return None\n\n\ndef read_file(path):\n with open(path, 'rb') as handle:\n return handle.read()\n\n\ndef main(argv):\n parser = argparse.ArgumentParser()\n parser.add_argument('--listen', type=int, required=True)\n parser.add_argument('--web', required=True)\n parser.add_argument('--unix-target', required=True)\n parser.add_argument('--tickets', required=True)\n args = parser.parse_args(argv)\n logging.basicConfig(level=logging.INFO, format='%(message)s')\n server = websocketproxy.WebSocketProxy(\n RequestHandlerClass=TicketedProxyHandler,\n listen_host='127.0.0.1',\n listen_port=args.listen,\n web=args.web,\n file_only=True,\n unix_target=args.unix_target,\n )\n server.orca_tickets_dir = os.path.abspath(args.tickets)\n server.start_server()\n\n\nif __name__ == '__main__':\n main(sys.argv[1:])\n","orca-desktop-page.js":"// Orca's glue for noVNC, injected into vnc.html by orca-desktop-proxy. Orca (the desktop app's\n// main process) drives it through window.orcaDesktop:\n// - tickets: the page never connects by itself. Each WebSocket needs a fresh single-use ticket,\n// which Orca hands over with connect(ticket) after nextTicketRequest() resolves: 'ticket' on\n// load or after a session dropped, 'retry' after an attempt that never connected. Tickets live\n// only in memory here: never in the URL, storage or noVNC's settings.\n// - pause(message, label): Orca stopped retrying; show why, with a Reconnect button. resume()\n// (Orca reopened the desktop) or the button asks for a ticket again.\n// - fit: resize=remote makes Xvnc follow the tab; a server without SetDesktopSize gets scaling.\n// - clipboard: a copy inside the desktop right after user input there goes to this computer's\n// clipboard; paste(text, chord) sets the sandbox's clipboard first, then replays the paste key.\nimport UI from './app/ui.js';\nimport * as WebUtil from './app/webutil.js';\n\nconst WS_PATH = 'websockify';\nconst RESIZE_FALLBACK_MS = 1500;\n// A copy inside the desktop follows a copy key or a selection (drag or double-click) there; other\n// clipboard changes (an agent in the sandbox running xclip) must not overwrite this computer's\n// clipboard. Plain typing does not open the window.\nconst USER_COPY_WINDOW_MS = 1500;\nconst DRAG_PX = 4;\nconst MAX_CLIPBOARD_CHARS = 1 << 20;\nconst TICKET_RE = /^[A-Za-z0-9_-]{43}$/;\nconst XK = { v: 0x0076, V: 0x0056, Insert: 0xff63, Shift_L: 0xffe1, Super_L: 0xffeb, Super_R: 0xffec };\n\nlet state = 'idle'; // idle | connecting | connected | disconnected | paused\nlet attemptConnected = true;\nlet ticketWaiters = [];\nlet lastCopyIntent = 0;\nlet pointerStart = null;\n\nconst style = document.createElement('style');\n// Orca owns connecting and the chrome: noVNC's Connect button would only try without a ticket,\n// and its control bar duplicates what the Orca tab does (fit, clipboard, keyboard release).\n// The pause card follows Orca's theme: Orca sets Electron's theme source from its own setting, so\n// prefers-color-scheme here is Orca's light or dark, and it follows a switch while paused. The\n// page cannot read Orca's tokens, so these mirror them (popover, border, focus ring).\nstyle.textContent = `\n#noVNC_connect_dlg, #noVNC_control_bar_anchor { display: none !important; }\n#orca_pause { --orca-scrim: rgb(10 10 10 / 0.88); --orca-card: #171717; --orca-text: #f0f0f0;\n --orca-border: rgb(255 255 255 / 0.1); --orca-control: rgb(255 255 255 / 0.25);\n --orca-control-hover: rgb(255 255 255 / 0.08); --orca-focus: color-mix(in oklab, #f0f0f0 65%, #0a0a0a);\n color-scheme: dark; position: fixed; inset: 0; display: none; align-items: center;\n justify-content: center; padding: 16px; background: var(--orca-scrim); z-index: 1000;\n font: 13px/1.5 system-ui, sans-serif; }\n@media (prefers-color-scheme: light) {\n #orca_pause { --orca-scrim: rgb(255 255 255 / 0.88); --orca-card: #fff; --orca-text: #0a0a0a;\n --orca-border: #e5e5e5; --orca-control: #a3a3a3; --orca-control-hover: #f5f5f5;\n --orca-focus: color-mix(in oklab, #0a0a0a 55%, #fff); color-scheme: light; }\n}\n#orca_pause.open { display: flex; }\n#orca_pause > div { max-width: 360px; padding: 16px; border: 1px solid var(--orca-border);\n border-radius: 8px; background: var(--orca-card); color: var(--orca-text); text-align: center;\n box-shadow: 0 8px 24px rgb(0 0 0 / 0.16); }\n#orca_pause p { margin: 0; }\n#orca_pause button { margin-top: 12px; padding: 4px 12px; border: 1px solid var(--orca-control);\n border-radius: 6px; background: transparent; color: var(--orca-text); font: inherit; cursor: pointer; }\n#orca_pause button:hover { background: var(--orca-control-hover); }\n#orca_pause button:focus-visible { outline: 2px solid var(--orca-focus); outline-offset: 2px; }\n@media (prefers-reduced-motion: no-preference) {\n #orca_pause.open > div { animation: orca-pause-in 120ms ease-out; }\n}\n@keyframes orca-pause-in { from { opacity: 0; transform: translateY(4px); } }\n`;\ndocument.head.appendChild(style);\n\nconst overlay = document.createElement('div');\noverlay.id = 'orca_pause';\noverlay.innerHTML = '

';\noverlay.querySelector('button').addEventListener('click', () => resume());\ndocument.addEventListener('DOMContentLoaded', () => document.body.appendChild(overlay));\n\nwindow.addEventListener('keydown', (event) => {\n if ((event.ctrlKey || event.metaKey) && !event.altKey && (event.key === 'c' || event.key === 'C' || event.key === 'Insert')) {\n lastCopyIntent = Date.now();\n }\n}, true);\nwindow.addEventListener('pointerdown', (event) => {\n pointerStart = { x: event.clientX, y: event.clientY };\n}, true);\n// A double- or triple-click selects a word or line (pointer events carry no click count).\nwindow.addEventListener('mousedown', (event) => {\n if (event.detail >= 2) {\n lastCopyIntent = Date.now();\n }\n}, true);\nwindow.addEventListener('pointerup', (event) => {\n if (pointerStart && Math.hypot(event.clientX - pointerStart.x, event.clientY - pointerStart.y) >= DRAG_PX) {\n lastCopyIntent = Date.now();\n }\n pointerStart = null;\n}, true);\n\nfunction requestTicket() {\n const answer = attemptConnected ? 'ticket' : 'retry';\n const waiters = ticketWaiters;\n ticketWaiters = [];\n waiters.forEach((resolve) => resolve(answer));\n}\n\nasync function whenStarted() {\n while (document.documentElement.classList.contains('noVNC_loading')) {\n await new Promise((resolve) => setTimeout(resolve, 25));\n }\n}\n\nfunction onConnect() {\n state = 'connected';\n attemptConnected = true;\n UI.hideStatus();\n setTimeout(() => {\n // Xvnc answers SetDesktopSize; anything that does not gets scaled into the tab instead.\n if (UI.rfb && state === 'connected' && !UI.rfb._supportsSetDesktopSize) {\n UI.rfb.scaleViewport = true;\n }\n }, RESIZE_FALLBACK_MS);\n}\n\nfunction onDisconnect() {\n if (state === 'paused') {\n return;\n }\n state = 'disconnected';\n // The ticket is spent: ask Orca for a new one (a dropped forward, a restart of the desktop).\n requestTicket();\n}\n\nfunction onClipboard(event) {\n const text = event.detail && event.detail.text;\n if (typeof text !== 'string' || text.length > MAX_CLIPBOARD_CHARS) {\n return;\n }\n if (Date.now() - lastCopyIntent > USER_COPY_WINDOW_MS || !document.hasFocus()) {\n return;\n }\n navigator.clipboard.writeText(text).catch(() => {});\n}\n\nasync function connect(ticket) {\n if (typeof ticket !== 'string' || !TICKET_RE.test(ticket)) {\n return 'invalid';\n }\n if (state === 'connecting' || state === 'connected' || state === 'paused') {\n return state;\n }\n await whenStarted();\n if (UI.rfb) {\n return 'connecting';\n }\n state = 'connecting';\n attemptConnected = false;\n // UI.connect reads the path setting synchronously and opens the WebSocket in RFB's constructor;\n // the in-memory setting is restored right after, so the ticket is never kept or shown.\n // Only ever this page's own origin: noVNC also reads host, port, encrypt, repeater and logging\n // from the URL, which must never send a ticketed session (and the user's keys) elsewhere.\n WebUtil.setSetting('host', '');\n WebUtil.setSetting('port', '');\n WebUtil.setSetting('encrypt', false);\n WebUtil.setSetting('repeaterID', '');\n WebUtil.setSetting('path', `${WS_PATH}?ticket=${ticket}`);\n try {\n UI.connect();\n } finally {\n WebUtil.setSetting('path', WS_PATH);\n }\n const rfb = UI.rfb;\n if (!rfb) {\n state = 'disconnected';\n requestTicket();\n return 'failed';\n }\n rfb.addEventListener('connect', onConnect);\n rfb.addEventListener('disconnect', onDisconnect);\n rfb.addEventListener('clipboard', onClipboard);\n return 'connecting';\n}\n\n/** Pastes this computer's clipboard: the sandbox's clipboard first, then the paste key itself. */\nfunction paste(text, chord) {\n const rfb = UI.rfb;\n if (!rfb || state !== 'connected' || typeof text !== 'string' || text.length > MAX_CLIPBOARD_CHARS) {\n return false;\n }\n rfb.clipboardPasteFrom(text);\n if (chord && chord.kind === 'mac') {\n // Cmd+V: Linux apps do not paste on Super+V, so release Super and send Shift+Insert.\n rfb.sendKey(XK.Super_L, 'MetaLeft', false);\n rfb.sendKey(XK.Super_R, 'MetaRight', false);\n rfb.sendKey(XK.Shift_L, 'ShiftLeft', true);\n rfb.sendKey(XK.Insert, 'Insert');\n rfb.sendKey(XK.Shift_L, 'ShiftLeft', false);\n } else if (chord && chord.kind === 'replay') {\n // Ctrl+V, Ctrl+Shift+V, Shift+Insert: the held modifiers already reached the sandbox.\n const insert = chord.key === 'Insert';\n rfb.sendKey(insert ? XK.Insert : chord.key === 'V' ? XK.V : XK.v, insert ? 'Insert' : 'KeyV');\n }\n return true;\n}\n\nfunction pause(message, label, tone) {\n state = 'paused';\n if (UI.rfb) {\n UI.rfb.disconnect();\n }\n UI.hideStatus();\n overlay.querySelector('p').textContent = String(message || '');\n overlay.querySelector('button').textContent = String(label || 'Reconnect');\n // Asleep and starting are states, not errors: announce them politely.\n overlay.firstElementChild.setAttribute('role', tone === 'neutral' ? 'status' : 'alert');\n overlay.classList.add('open');\n overlay.querySelector('button').focus();\n return true;\n}\n\nfunction resume() {\n if (state !== 'paused') {\n return false;\n }\n overlay.classList.remove('open');\n state = 'disconnected';\n attemptConnected = true; // a person asked: start the retry budget over\n requestTicket();\n return true;\n}\n\nObject.defineProperty(window, 'orcaDesktop', {\n value: Object.freeze({\n version: 1,\n state: () => state,\n /** Resolves when the page needs a ticket: now if it is not connected, else after a drop. */\n nextTicketRequest: () => new Promise((resolve) => {\n ticketWaiters.push(resolve);\n if (state === 'idle' || state === 'disconnected') {\n requestTicket();\n }\n }),\n connect,\n paste,\n pause,\n resume\n })\n});\n","orca-sandbox-grant":"#!/usr/bin/env bash\n# Mints a fresh, single-device orcad pairing offer for one org member's device and seals it to that\n# device's X25519 key, so the control plane relays only ciphertext (the VM owner's E2EE model holds:\n# every member device gets its own revocable orcad credential, nobody else can read it).\n# Env from the control plane: ORCA_GRANT_ID, ORCA_GRANT_RECIPIENT_KEY (base64 raw X25519 public\n# key), ORCA_GRANT_KIND (desktop|mobile), ORCA_PUBLIC_WSS. Prints one JSON line:\n# {\"deviceId\":\"…\",\"sealed\":{\"v\":1,\"ephemeralPublicKeyB64\":\"…\",\"nonceB64\":\"…\",\"ciphertextB64\":\"…\"}}\n# Sealing: ECDH(ephemeral, recipient) -> HKDF-SHA256(salt = eph||recipient, info\n# \"orca-cloud-grant-v1\") -> AES-256-GCM, AAD \"orca-cloud-grant-v1:\".\nset -euo pipefail\numask 077\nORCA_HOME=/opt/orca\nSTATE=\"$HOME/.orca-sandbox\"\nDATA=\"$HOME/.orcad-data\"\nmkdir -p \"$STATE\"\nexec 9>\"$STATE/grant.lock\"\n# One mint at a time: --rotate retires unredeemed offers, so two members never share a credential.\nflock -w 30 9\n\ncase \"${ORCA_GRANT_ID:-}\" in '' | *[!A-Za-z0-9_-]*) echo 'bad grant id' >&2; exit 2 ;; esac\ncase \"${ORCA_GRANT_KIND:-}\" in desktop) scope=() ;; mobile) scope=(--mobile) ;; *) echo 'bad kind' >&2; exit 2 ;; esac\ncase \"${ORCA_GRANT_RECIPIENT_KEY:-}\" in '' | *[!A-Za-z0-9+/=]*) echo 'bad key' >&2; exit 2 ;; esac\ncase \"${ORCA_PUBLIC_WSS:-}\" in wss://*) ;; *) echo 'bad endpoint' >&2; exit 2 ;; esac\n\nnode=\"$(find \"$ORCA_HOME/runtime/runtimes\" -type f -name node | head -n 1)\"\ncli=\"$ORCA_HOME/runtime/orcad/out/cli/index.js\"\n# The workspace's project must exist on this orcad before a phone opens it: phones may not add\n# projects themselves (mobile RPC scope), and a phone may be the first client this VM ever sees.\nproject=/vercel/sandbox/project\nif [ -d \"$project\" ]; then\n kind=folder\n [ -d \"$project/.git\" ] && kind=git\n ORCA_USER_DATA_PATH=\"$DATA\" timeout 20 \"$node\" \"$cli\" repo add --path \"$project\" --kind \"$kind\" --json \\\n >/dev/null 2>&1 || true\nfi\noffer=\"$(ORCA_USER_DATA_PATH=\"$DATA\" timeout 30 \"$node\" \"$cli\" pair --json --rotate \"${scope[@]}\" \\\n --address \"$ORCA_PUBLIC_WSS\" 2>/dev/null)\" || { echo 'pair failed' >&2; exit 1; }\n# This workspace's read-snapshot signer (public half), so the paired device pins it; best effort.\nsigner=\"$(\"$node\" \"$ORCA_HOME/bin/orca-read-snapshot.cjs\" --signer-public-key 2>/dev/null || true)\"\n# The offer goes to the sealer on stdin (never argv or disk); only the sealed form is printed.\n# Granted devices are listed so orcad can keep owner-only features (local commands) from them.\nprintf '%s' \"$offer\" | ORCA_GRANT_ID=\"$ORCA_GRANT_ID\" ORCA_GRANT_RECIPIENT_KEY=\"$ORCA_GRANT_RECIPIENT_KEY\" ORCA_READ_SIGNER=\"$signer\" \\\n ORCA_GRANT_LEDGER=\"$STATE/grant-devices\" \"$node\" -e '\nconst crypto = require(\"node:crypto\")\nlet input = \"\"\nprocess.stdin.on(\"data\", (c) => (input += c)).on(\"end\", () => {\n const res = JSON.parse(input).result\n if (!res || res.available !== true || !res.pairingUrl || !res.deviceId) {\n console.error(\"offer unavailable\")\n process.exit(1)\n }\n if (process.env.ORCA_GRANT_LEDGER) require(\"node:fs\").appendFileSync(process.env.ORCA_GRANT_LEDGER, res.deviceId + \"\\n\", { mode: 0o600 })\n const recipient = Buffer.from(process.env.ORCA_GRANT_RECIPIENT_KEY, \"base64\")\n if (recipient.length !== 32) { console.error(\"bad key length\"); process.exit(2) }\n const recipientKey = crypto.createPublicKey({ key: { kty: \"OKP\", crv: \"X25519\", x: recipient.toString(\"base64url\") }, format: \"jwk\" })\n const eph = crypto.generateKeyPairSync(\"x25519\")\n const ephRaw = Buffer.from(eph.publicKey.export({ format: \"jwk\" }).x, \"base64url\")\n const shared = crypto.diffieHellman({ privateKey: eph.privateKey, publicKey: recipientKey })\n const key = Buffer.from(crypto.hkdfSync(\"sha256\", shared, Buffer.concat([ephRaw, recipient]), \"orca-cloud-grant-v1\", 32))\n const nonce = crypto.randomBytes(12)\n const cipher = crypto.createCipheriv(\"aes-256-gcm\", key, nonce)\n cipher.setAAD(Buffer.from(\"orca-cloud-grant-v1:\" + process.env.ORCA_GRANT_ID))\n const signer = /^[A-Za-z0-9+/]{43}=$/.test(process.env.ORCA_READ_SIGNER || \"\") ? process.env.ORCA_READ_SIGNER : undefined\n const body = Buffer.from(JSON.stringify({ pairingUrl: res.pairingUrl, deviceId: res.deviceId, scope: res.scope, ...(signer ? { readSignerPublicKeyB64: signer } : {}) }))\n const ct = Buffer.concat([cipher.update(body), cipher.final(), cipher.getAuthTag()])\n process.stdout.write(JSON.stringify({ deviceId: res.deviceId, sealed: { v: 1, ephemeralPublicKeyB64: ephRaw.toString(\"base64\"), nonceB64: nonce.toString(\"base64\"), ciphertextB64: ct.toString(\"base64\") } }) + \"\\n\")\n})'\n","orca-sandbox-revoke":"#!/usr/bin/env bash\n# Revokes member devices from orcad's device registry. orcad adopts on-disk removals at every auth\n# (an admin revoke, device-registry-store.ts), so this works whether or not orcad is running; the\n# control plane also runs it at boot, before orcad starts, for revokes queued while suspended.\n# Env: ORCA_REVOKE_DEVICE_IDS (comma list of orcad device ids). Prints {\"revoked\":,\"live\":}.\nset -euo pipefail\numask 077\nSTATE=\"$HOME/.orca-sandbox\"\nREGISTRY=\"$HOME/.orcad-data/orca-devices.json\"\nmkdir -p \"$STATE\"\nexec 9>\"$STATE/grant.lock\"\nflock -w 30 9\n\nids=\"${ORCA_REVOKE_DEVICE_IDS:-}\"\ncase \"$ids\" in '' | *[!A-Za-z0-9,_-]*) echo 'bad device ids' >&2; exit 2 ;; esac\n# Live orcad first: `orca pair --revoke` also closes the device's open sockets now. Older orcad\n# builds lack it; the on-disk removal below still blocks their next sign-in.\nORCA_HOME=/opt/orca\nnode=\"$(find \"$ORCA_HOME/runtime/runtimes\" -type f -name node 2>/dev/null | head -n 1)\"\ncli=\"$ORCA_HOME/runtime/orcad/out/cli/index.js\"\nlive=0\nif [ -n \"$node\" ] && [ -f \"$cli\" ] && [ -s \"$HOME/.orcad-data/orca-runtime.json\" ]; then\n for id in ${ids//,/ }; do\n if ORCA_USER_DATA_PATH=\"$HOME/.orcad-data\" timeout 15 \"$node\" \"$cli\" pair --revoke \"$id\" --json 2>/dev/null \\\n | jq -e '.ok == true and .result.revoked == true' >/dev/null 2>&1; then\n live=$((live + 1))\n fi\n done\nfi\nif [ ! -s \"$REGISTRY\" ]; then\n echo \"{\\\"revoked\\\":$live,\\\"live\\\":$live}\"\n exit 0\nfi\nids_json=\"$(jq -cn --arg ids \"$ids\" '$ids | split(\",\") | map(select(length > 0))')\"\nbefore=\"$(jq 'length' \"$REGISTRY\")\"\ntmp=\"$(mktemp \"$REGISTRY.revoke.XXXXXX\")\"\n# Removes the rows and any phones delegated through them (parentDeviceId), like orcad's own revoke.\njq --argjson ids \"$ids_json\" \\\n '[.[] | select((.deviceId as $d | $ids | index($d)) == null and ((.parentDeviceId // \"\") as $p | $ids | index($p)) == null)]' \\\n \"$REGISTRY\" >\"$tmp\"\nchmod 0600 \"$tmp\"\nsync \"$tmp\" 2>/dev/null || true\nmv -f \"$tmp\" \"$REGISTRY\"\nafter=\"$(jq 'length' \"$REGISTRY\")\"\necho \"{\\\"revoked\\\":$((live + before - after)),\\\"live\\\":$live}\"\n","orca-sandbox-members":"#!/usr/bin/env bash\n# Runs as root (control plane, sudo). Installs who-may-push-as-whom for `orca-git-as`:\n# /etc/orca/members/.gitconfig egress placeholder header + GitHub author identity\n# /etc/orca/members/devices.json {\"\": \"\"} (each member's paired devices)\n# Both root-only: agents never read another member's placeholder. `orca-git-identity` (root, via a\n# sudoers rule) hands an agent only the identity of the member whose device drove its turn, as orcad\n# attests it. No token lives here: the placeholder is swapped at egress, for this repository only.\n# Env: ORCA_MEMBERS_B64 = base64 JSON [{userId, name, email, authorization, deviceIds:[...]}]\n# The VM user agents run as is orcad's own user (detected). Prints {\"members\":,\"devices\":}.\nset -euo pipefail\numask 077\nDIR=/etc/orca/members\norcad_pid=\"$(pgrep -f orcad-server.js | head -n 1 || true)\"\nUSER_NAME=\"$( [ -n \"$orcad_pid\" ] && ps -o user= -p \"$orcad_pid\" | tr -d ' ' || true)\"\nUSER_NAME=\"${USER_NAME:-${ORCA_SANDBOX_USER:-${SUDO_USER:-}}}\"\ncase \"$USER_NAME\" in '' | *[!a-z0-9_-]*) echo 'bad user' >&2; exit 2 ;; esac\nmkdir -p /etc/orca\njson=\"$(printf '%s' \"${ORCA_MEMBERS_B64:-W10=}\" | base64 -d)\"\ntmp=\"$(mktemp -d /etc/orca/members.XXXXXX)\"\ncount=0\nwhile IFS= read -r row; do\n user=\"$(jq -r '.userId' <<<\"$row\")\"\n case \"$user\" in '' | *[!A-Za-z0-9_.:-]*) continue ;; esac\n file=\"$tmp/$(printf '%s' \"$user\" | tr ':' '_').gitconfig\"\n # An empty extraheader first resets the image-wide (app identity) header for this member's git.\n git config -f \"$file\" --add http.https://github.com/.extraheader ''\n git config -f \"$file\" --add http.https://github.com/.extraheader \"Authorization: $(jq -r '.authorization' <<<\"$row\")\"\n git config -f \"$file\" user.name \"$(jq -r '.name' <<<\"$row\")\"\n git config -f \"$file\" user.email \"$(jq -r '.email' <<<\"$row\")\"\n count=$((count + 1))\ndone < <(jq -c '.[]' <<<\"$json\")\njq -c '[.[] | select(.userId | test(\"^[A-Za-z0-9_.:-]+$\")) | .userId as $u | (.deviceIds // [])[] | select(test(\"^[A-Za-z0-9_-]{1,128}$\")) | {key: ., value: $u}] | from_entries' <<<\"$json\" >\"$tmp/devices.json\"\ndevices=\"$(jq 'length' \"$tmp/devices.json\")\"\nchmod 0700 \"$tmp\" && chmod 0600 \"$tmp\"/*\nrm -rf \"$DIR.old\"\n[ -d \"$DIR\" ] && mv \"$DIR\" \"$DIR.old\"\nmv \"$tmp\" \"$DIR\"\nrm -rf \"$DIR.old\"\n# Agents may ask for their own sender's identity, and only that (the helper checks with orcad).\nrule=\"$USER_NAME ALL=(root) NOPASSWD:SETENV: /opt/orca/bin/orca-git-identity\"\nprintf '%s\\n' \"$rule\" >/etc/sudoers.d/orca-git-identity.new\nchmod 0440 /etc/sudoers.d/orca-git-identity.new\nif visudo -cf /etc/sudoers.d/orca-git-identity.new >/dev/null; then\n mv -f /etc/sudoers.d/orca-git-identity.new /etc/sudoers.d/orca-git-identity\nelse\n rm -f /etc/sudoers.d/orca-git-identity.new\n echo 'sudoers rule rejected' >&2\n exit 1\nfi\n# Pre-attestation layout (agent-readable member files) must not linger.\nuser_home=\"$(getent passwd \"$USER_NAME\" | cut -d: -f6)\"\n[ -n \"$orcad_pid\" ] && user_home=\"$(tr '\\0' '\\n' <\"/proc/$orcad_pid/environ\" | sed -n 's/^HOME=//p' | head -n 1)\"\n[ -n \"$user_home\" ] && rm -rf \"$user_home/.orca-sandbox/members\"\n# Commits outside a teammate's turn still need an author (images older than orca-sandbox-start's default).\nif [ -n \"$user_home\" ]; then\n sudo -u \"$USER_NAME\" env HOME=\"$user_home\" git config --global user.email >/dev/null 2>&1 \\\n || sudo -u \"$USER_NAME\" env HOME=\"$user_home\" git config --global user.email 'cloud@users.noreply.orca.dev'\n sudo -u \"$USER_NAME\" env HOME=\"$user_home\" git config --global user.name >/dev/null 2>&1 \\\n || sudo -u \"$USER_NAME\" env HOME=\"$user_home\" git config --global user.name 'Orca Cloud'\nfi\n# CREATE-GIT-ASYNC: identities are in place, so `git` stops holding commits and pushes for this boot.\nif [ -n \"$user_home\" ] && [ -d \"$user_home/.orca-sandbox\" ]; then\n sudo -u \"$USER_NAME\" sh -c 'rm -f \"$1/git-setup.pending\" \"$1/git-setup.failed\" && date +%s >\"$1/git-setup.ready\"' \\\n _ \"$user_home/.orca-sandbox\" || true\nfi\necho \"{\\\"members\\\":$count,\\\"devices\\\":$devices}\"\n","orca-sandbox-read-snapshot":"#!/usr/bin/env bash\n# Seals a read-only snapshot of this workspace (chats, agent status, git, PR) to the reader keys the\n# control plane passes, right before the drain. Only ciphertext is printed (one JSON line); see\n# orca-read-snapshot.cjs. Exit 3: orcad isn't running (nothing to read).\nset -euo pipefail\numask 077\nORCA_HOME=/opt/orca\nexport ORCA_USER_DATA_PATH=\"${ORCA_USER_DATA_PATH:-$HOME/.orcad-data}\"\nnode=\"$(find \"$ORCA_HOME/runtime/runtimes\" -type f -name node | head -n 1)\"\n[ -n \"$node\" ] || { echo 'node not found' >&2; exit 1; }\nexec timeout 35 \"$node\" \"$ORCA_HOME/bin/orca-read-snapshot.cjs\"\n","orca-read-snapshot.cjs":"#!/usr/bin/env node\n// Gathers what a sleeping workspace should still show (recent chats per agent session, agent\n// status, git status/diff summary, PR link) from the running orcad over its local socket, then seals\n// it to the reader keys the control plane passed, so only ciphertext leaves the VM.\n// Run by orca-sandbox-read-snapshot right before the drain. Env:\n// ORCA_READ_SNAPSHOT_ID rsn_<24 hex> ORCA_READ_WORKSPACE_ID sbx_<24 hex>\n// ORCA_READ_RECIPIENTS JSON [{keyId, publicKeyB64}] ORCA_READ_REASON sleep|rollover\n// ORCA_USER_DATA_PATH orcad's data dir (orca-runtime.json lives there)\n// Prints one JSON line: {v:1, snapshotId, workspaceId, nonceB64, ciphertextB64, recipients[], stats}.\n// Construction (keep in sync with src/shared/cloud-workspaces/read-snapshot.ts in the app):\n// body = AES-256-GCM(dek, nonce, JSON, AAD \"orca-cloud-read-v1::\")\n// wrapped = AES-256-GCM(HKDF-SHA256(X25519(eph, reader), eph||reader, \"orca-cloud-read-wrap-v1\"),\n// nonce_i, dek, AAD \"orca-cloud-read-wrap-v1:::\")\n'use strict'\nconst crypto = require('node:crypto')\nconst fs = require('node:fs')\nconst net = require('node:net')\nconst path = require('node:path')\n\nconst INFO = 'orca-cloud-read-v1'\nconst WRAP_INFO = 'orca-cloud-read-wrap-v1'\nconst PROJECT = '/vercel/sandbox/project'\nconst LIMITS = { sessions: 12, messagesPerSession: 60, messageChars: 4000, changes: 200, commits: 10 }\n/** Plaintext budget: sealed + base64 it stays under the control plane's 220 kB and the 256 kB stdout cap. */\nconst MAX_PLAIN_BYTES = 150_000\nconst CALL_TIMEOUT_MS = 5_000\nconst TOTAL_BUDGET_MS = 20_000\n\nfunction b64(bytes) {\n return Buffer.from(bytes).toString('base64')\n}\n\nfunction x25519PublicKey(raw) {\n return crypto.createPublicKey({ key: { kty: 'OKP', crv: 'X25519', x: Buffer.from(raw).toString('base64url') }, format: 'jwk' })\n}\n\nfunction gcmSeal(key, nonce, plain, aad) {\n const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce)\n cipher.setAAD(Buffer.from(aad))\n return Buffer.concat([cipher.update(plain), cipher.final(), cipher.getAuthTag()])\n}\n\nconst SIG_INFO = 'orca-cloud-read-sig-v1'\n\n/**\n * This VM's snapshot signing key (Ed25519), created once and kept on the VM's own disk (0600, never\n * printed). Devices pin its public half per workspace, so a snapshot the control plane forged or\n * swapped doesn't verify. Same file for the grant script, which hands the public half to pairing devices.\n */\nfunction signingKey(stateDir) {\n if (!path.isAbsolute(stateDir)) {\n throw new Error('no state dir')\n }\n const file = path.join(stateDir, 'read-signing-key.pem')\n const load = () => {\n try {\n const privateKey = crypto.createPrivateKey(fs.readFileSync(file, 'utf8'))\n return privateKey.asymmetricKeyType === 'ed25519' ? privateKey : null\n } catch {\n return null\n }\n }\n let privateKey = load()\n if (!privateKey) {\n fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 })\n // Written whole to a temp file, then linked into place: atomic, and a concurrent first run\n // (grant + capture) keeps whichever key landed first. A broken file is replaced (devices that\n // pinned the old signer then see \"signer changed\" until they trust the new one).\n const temp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}`\n fs.writeFileSync(temp, crypto.generateKeyPairSync('ed25519').privateKey.export({ format: 'pem', type: 'pkcs8' }), { mode: 0o600 })\n try {\n fs.linkSync(temp, file)\n } catch {\n if (!load()) {\n fs.renameSync(temp, file)\n }\n } finally {\n fs.rmSync(temp, { force: true })\n }\n privateKey = load()\n if (!privateKey) {\n throw new Error('signing key unavailable')\n }\n }\n const publicKeyB64 = Buffer.from(crypto.createPublicKey(privateKey).export({ format: 'jwk' }).x, 'base64url').toString('base64')\n return { privateKey, publicKeyB64 }\n}\n\n/** Signs the sealed body: \"orca-cloud-read-sig-v1::::\" || ciphertext bytes. */\nfunction sign(out, key) {\n const message = Buffer.concat([\n Buffer.from(`${SIG_INFO}:${out.workspaceId}:${out.snapshotId}:${out.nonceB64}:`),\n Buffer.from(out.ciphertextB64, 'base64')\n ])\n return { publicKeyB64: key.publicKeyB64, signatureB64: crypto.sign(null, message, key.privateKey).toString('base64') }\n}\n\n/** Seals `payload` (an object) to every recipient; a malformed key is skipped, not fatal. */\nfunction seal(payload, recipients, workspaceId, snapshotId) {\n const scope = `${workspaceId}:${snapshotId}`\n const dek = crypto.randomBytes(32)\n const nonce = crypto.randomBytes(12)\n const ciphertext = gcmSeal(dek, nonce, Buffer.from(JSON.stringify(payload)), `${INFO}:${scope}`)\n const sealedTo = []\n for (const recipient of recipients) {\n const reader = Buffer.from(String(recipient.publicKeyB64 || ''), 'base64')\n if (reader.length !== 32 || !/^rk_[0-9a-f]{24}$/.test(String(recipient.keyId || ''))) {\n continue\n }\n let shared\n const eph = crypto.generateKeyPairSync('x25519')\n try {\n shared = crypto.diffieHellman({ privateKey: eph.privateKey, publicKey: x25519PublicKey(reader) })\n } catch {\n continue\n }\n if (shared.every((byte) => byte === 0)) {\n continue\n }\n const ephRaw = Buffer.from(eph.publicKey.export({ format: 'jwk' }).x, 'base64url')\n const kek = Buffer.from(crypto.hkdfSync('sha256', shared, Buffer.concat([ephRaw, reader]), WRAP_INFO, 32))\n const wrapNonce = crypto.randomBytes(12)\n const wrapped = gcmSeal(kek, wrapNonce, dek, `${WRAP_INFO}:${scope}:${recipient.keyId}`)\n sealedTo.push({ keyId: recipient.keyId, ephemeralPublicKeyB64: b64(ephRaw), nonceB64: b64(wrapNonce), wrappedKeyB64: b64(wrapped) })\n }\n dek.fill(0)\n return { v: 1, snapshotId, workspaceId, nonceB64: b64(nonce), ciphertextB64: b64(ciphertext), recipients: sealedTo }\n}\n\n// --- gathering -------------------------------------------------------------------------------\n\n/** One request per connection, like the CLI. A streaming method resolves on its first frame. */\nfunction rpc(metadata, method, params, timeoutMs = CALL_TIMEOUT_MS) {\n return new Promise((resolve) => {\n const transport = (metadata.transports || [metadata.transport]).find((t) => t && (t.kind === 'unix' || t.kind === 'named-pipe'))\n if (!transport) {\n resolve(null)\n return\n }\n const socket = net.createConnection(transport.endpoint)\n const id = crypto.randomUUID()\n let buffer = ''\n let done = false\n const finish = (value) => {\n if (!done) {\n done = true\n clearTimeout(timer)\n socket.destroy()\n resolve(value)\n }\n }\n const timer = setTimeout(() => finish(null), timeoutMs)\n socket.setEncoding('utf8')\n socket.on('error', () => finish(null))\n socket.on('close', () => finish(null))\n socket.on('connect', () => socket.write(`${JSON.stringify({ id, authToken: metadata.authToken, method, params })}\\n`))\n socket.on('data', (chunk) => {\n buffer += chunk\n let newline\n while ((newline = buffer.indexOf('\\n')) !== -1) {\n const line = buffer.slice(0, newline)\n buffer = buffer.slice(newline + 1)\n let frame\n try {\n frame = JSON.parse(line)\n } catch {\n continue\n }\n if (!frame || frame.id !== id) {\n continue\n }\n finish(frame.ok === true ? frame.result : null)\n return\n }\n })\n })\n}\n\nconst isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v)\nconst str = (v, max) => (typeof v === 'string' ? v.slice(0, max) : null)\nconst num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null)\n\nfunction messagesOf(history) {\n const items = isObj(history) && isObj(history.page) && Array.isArray(history.page.items) ? history.page.items : []\n const out = []\n for (const item of items) {\n const body = isObj(item) && isObj(item.body) ? item.body : null\n if (!body || body.kind !== 'message' || !Array.isArray(body.blocks)) {\n continue\n }\n const role = body.role\n if (role !== 'user' && role !== 'assistant' && role !== 'system') {\n continue\n }\n const text = body.blocks\n .filter((b) => isObj(b) && b.type === 'text' && typeof b.text === 'string')\n .map((b) => b.text)\n .join('\\n')\n .trim()\n if (text) {\n out.push({ itemId: String(item.itemId).slice(0, 200), role, text: text.slice(0, LIMITS.messageChars), at: num(item.observedAt) ?? 0 })\n }\n }\n return { messages: out, hasOlder: isObj(history) && isObj(history.page) && history.page.hasOlder === true }\n}\n\nfunction gitOf(status, log) {\n if (!isObj(status) && !isObj(log)) {\n return null\n }\n const entries = isObj(status) && Array.isArray(status.entries) ? status.entries.filter(isObj) : []\n let added = 0\n let removed = 0\n let counted = false\n const changes = entries.slice(0, LIMITS.changes).map((e) => {\n const a = num(e.added)\n const r = num(e.removed)\n if (a !== null || r !== null) {\n counted = true\n }\n return { path: String(e.path).slice(0, 1024), status: str(e.status, 20) || 'modified', area: str(e.area, 20) || 'unstaged', added: a, removed: r }\n })\n for (const e of entries) {\n added += num(e.added) || 0\n removed += num(e.removed) || 0\n }\n const items = isObj(log) && Array.isArray(log.items) ? log.items.filter(isObj) : []\n return {\n branch: isObj(status) && typeof status.branch === 'string' ? status.branch.replace(/^refs\\/heads\\//, '') : null,\n head: isObj(status) ? str(status.head, 64) : null,\n changes,\n changeCount: entries.length,\n added: counted ? added : null,\n removed: counted ? removed : null,\n commits: items.slice(0, LIMITS.commits).map((c) => ({\n id: String(c.id).slice(0, 64),\n subject: String(c.subject ?? '').slice(0, 300),\n author: str(c.author, 120),\n at: num(c.timestamp)\n }))\n }\n}\n\nfunction prOf(linked) {\n if (!isObj(linked) || num(linked.number) === null || typeof linked.url !== 'string' || !linked.url.startsWith('https://')) {\n return null\n }\n return {\n number: linked.number,\n title: String(linked.title ?? '').slice(0, 300),\n state: str(linked.state, 20) || 'open',\n url: linked.url.slice(0, 500),\n checksStatus: str(linked.checksStatus, 20),\n checksSummary: str(linked.checksSummary, 200)\n }\n}\n\nconst STATUSES = new Set(['working', 'attention', 'idle'])\nconst PANE_PREFIX = 'structured-agent-session-'\n\n/**\n * Chats as orcad stores them (agent-session-journal.db, read-only): every session's provider, name\n * and last turn, open tabs first. The local socket can't stream agentSession.subscribeStatus, and\n * worktree.ps lists only agents that are live right now, so this is the complete list.\n */\nfunction storedSessions(dataDir) {\n let db\n try {\n const { DatabaseSync } = require('node:sqlite')\n db = new DatabaseSync(path.join(dataDir, 'agent-session-journal.db'), { readOnly: true })\n const open = new Set(db.prepare('SELECT session_id FROM agent_session_tabs').all().map((r) => r.session_id))\n const out = []\n for (const row of db.prepare('SELECT session_id, record_json FROM agent_session_records').all()) {\n let record\n try {\n record = JSON.parse(row.record_json)\n } catch {\n continue\n }\n const last = isObj(record.lastTurn) ? record.lastTurn : {}\n out.push({\n sessionId: row.session_id,\n agent: str(record.provider, 40),\n title: str(record.conversationName, 200) || str(last.prompt, 200),\n status: last.state === 'running' || last.state === 'submitted' ? 'working' : last.state === 'settled' ? 'idle' : 'unknown',\n turnOutcome: str(last.outcome, 40),\n updatedAt: num(record.updatedAt),\n open: open.has(row.session_id),\n lastPrompt: str(last.prompt, LIMITS.messageChars),\n lastReply: str(last.reply, LIMITS.messageChars),\n lastAt: num(last.at),\n // \"::\": which checkout this chat works in.\n worktreePath: isObj(record.location) && typeof record.location.workspaceId === 'string' ? record.location.workspaceId.split('::').pop() : null\n })\n }\n return out\n } catch {\n return []\n } finally {\n try {\n db && db.close()\n } catch {}\n }\n}\n\n/** Live agent rows from worktree.ps, keyed by session id (their status wins over the stored one). */\nfunction liveAgents(worktrees) {\n const out = new Map()\n for (const w of worktrees) {\n for (const a of Array.isArray(w.agents) ? w.agents.filter(isObj) : []) {\n const tab = typeof a.paneKey === 'string' ? a.paneKey.split(':')[0] : ''\n if (!tab.startsWith(PANE_PREFIX)) {\n continue\n }\n const state = a.mainAgent && typeof a.mainAgent.state === 'string' ? a.mainAgent.state : a.state\n out.set(tab.slice(PANE_PREFIX.length), {\n agent: str(a.agentType, 40),\n title: str(a.conversationName, 200) || str(a.taskTitle, 200) || str(a.displayName, 200),\n status: state === 'working' ? 'working' : state === 'blocked' || state === 'waiting' || state === 'attention' ? 'attention' : 'idle',\n updatedAt: num(a.updatedAt)\n })\n }\n }\n return out\n}\n\n/**\n * The checkout this workspace is about: the one its newest chat works in, else the most recently\n * active one (a workspace on its own branch is a separate worktree, not the base clone at\n * /vercel/sandbox/project), else the base clone.\n */\nfunction workspaceCheckout(worktrees, sessions) {\n const byPath = new Map(worktrees.filter((w) => typeof w.path === 'string').map((w) => [w.path, w]))\n const newest = sessions\n .filter((s) => s.worktreePath && byPath.has(s.worktreePath))\n .sort((a, b) => (num(b.updatedAt) || 0) - (num(a.updatedAt) || 0))[0]\n if (newest) {\n return byPath.get(newest.worktreePath)\n }\n const activity = (w) => Math.max(num(w.lastActivityAt) || 0, num(w.sortOrder) || 0)\n const ranked = [...byPath.values()].sort(\n (a, b) => activity(b) - activity(a) || Number(a.isMainWorktree === true) - Number(b.isMainWorktree === true)\n )\n return ranked[0] || null\n}\n\n/** `call(method, params)` -> result | null. Never throws: a missing piece is left out. */\nasync function gather(call, { workspaceId, snapshotId, reason, now, deadline, listSessions = () => [] }) {\n const timeLeft = () => deadline - Date.now()\n const ps = await call('worktree.ps', { limit: 20 })\n const worktrees = isObj(ps) && Array.isArray(ps.worktrees) ? ps.worktrees.filter(isObj) : []\n const stored = listSessions()\n const project = workspaceCheckout(worktrees, stored)\n const selector = project && typeof project.path === 'string' ? `path:${project.path}` : `path:${PROJECT}`\n const [status, log] = await Promise.all([\n call('git.status', { worktree: selector, includeLineStats: true }),\n call('git.history', { worktree: selector, limit: LIMITS.commits })\n ])\n const live = liveAgents(worktrees)\n const byId = new Map()\n for (const s of stored) {\n byId.set(s.sessionId, s)\n }\n for (const [sessionId, a] of live) {\n byId.set(sessionId, { ...(byId.get(sessionId) || { sessionId, open: true }), ...Object.fromEntries(Object.entries(a).filter(([, v]) => v !== null)) })\n }\n // Open chats first, then newest activity.\n const picked = [...byId.values()]\n .filter((s) => typeof s.sessionId === 'string')\n .sort((a, b) => Number(Boolean(b.open)) - Number(Boolean(a.open)) || (num(b.updatedAt) || 0) - (num(a.updatedAt) || 0))\n .slice(0, LIMITS.sessions)\n const sessions = []\n for (const s of picked) {\n const history = timeLeft() > 1_000 ? await call('agentSession.history', { sessionId: s.sessionId, direction: 'tail', limit: LIMITS.messagesPerSession }) : null\n let { messages, hasOlder } = messagesOf(history)\n if (messages.length === 0) {\n // No readable history (or out of time): the stored last turn still says what happened.\n const at = s.lastAt || s.updatedAt || 0\n messages = [\n ...(s.lastPrompt ? [{ itemId: `${s.sessionId}:last-prompt`, role: 'user', text: s.lastPrompt, at }] : []),\n ...(s.lastReply ? [{ itemId: `${s.sessionId}:last-reply`, role: 'assistant', text: s.lastReply, at }] : [])\n ]\n hasOlder = hasOlder || messages.length > 0\n }\n sessions.push({\n sessionId: s.sessionId.slice(0, 200),\n agent: s.agent || null,\n title: s.title || null,\n status: STATUSES.has(s.status) ? s.status : 'unknown',\n turnOutcome: s.turnOutcome || null,\n updatedAt: num(s.updatedAt),\n messages: messages.slice(-LIMITS.messagesPerSession),\n truncated: hasOlder || messages.length > LIMITS.messagesPerSession\n })\n }\n return {\n v: 1,\n workspaceId,\n snapshotId,\n capturedAt: now,\n reason,\n git: gitOf(status, log),\n pr: prOf(project && project.linkedPR),\n sessions\n }\n}\n\n/** Cuts the oldest messages (then long texts, then sessions) until the JSON fits the budget. */\nfunction fitToBudget(payload, maxBytes = MAX_PLAIN_BYTES) {\n const size = () => Buffer.byteLength(JSON.stringify(payload))\n let guard = 0\n while (size() > maxBytes && guard++ < 10_000) {\n const longest = payload.sessions.reduce((best, s) => (!best || s.messages.length > best.messages.length ? s : best), null)\n if (longest && longest.messages.length > 4) {\n longest.messages.shift()\n longest.truncated = true\n continue\n }\n const longText = payload.sessions.flatMap((s) => s.messages).find((m) => m.text.length > 500)\n if (longText) {\n longText.text = `${longText.text.slice(0, 500)}…`\n continue\n }\n if (payload.git && payload.git.changes.length > 20) {\n payload.git.changes = payload.git.changes.slice(0, 20)\n continue\n }\n if (payload.sessions.length > 1) {\n payload.sessions.pop()\n continue\n }\n payload.sessions = []\n break\n }\n return payload\n}\n\n// Fails closed without HOME (signingKey refuses a relative path).\nconst stateDir = process.env.HOME ? path.join(process.env.HOME, '.orca-sandbox') : ''\n\nasync function main() {\n const started = Date.now()\n const env = process.env\n if (process.argv[2] === '--signer-public-key') {\n // For orca-sandbox-grant: pairing devices learn (and pin) this workspace's signer.\n process.stdout.write(`${signingKey(stateDir).publicKeyB64}\\n`)\n return\n }\n const snapshotId = env.ORCA_READ_SNAPSHOT_ID || ''\n const workspaceId = env.ORCA_READ_WORKSPACE_ID || ''\n if (!/^rsn_[0-9a-f]{24}$/.test(snapshotId) || !/^sbx_[0-9a-f]{24}$/.test(workspaceId)) {\n process.stderr.write('bad ids\\n')\n process.exit(2)\n }\n let recipients\n try {\n recipients = JSON.parse(env.ORCA_READ_RECIPIENTS || '[]')\n } catch {\n recipients = null\n }\n if (!Array.isArray(recipients) || recipients.length === 0) {\n process.stderr.write('no recipients\\n')\n process.exit(2)\n }\n const dataDir = env.ORCA_USER_DATA_PATH || path.join(env.HOME || '', '.orcad-data')\n let metadata\n try {\n metadata = JSON.parse(fs.readFileSync(path.join(dataDir, 'orca-runtime.json'), 'utf8'))\n } catch {\n metadata = null\n }\n if (!metadata || !metadata.authToken) {\n process.stderr.write('orcad not running\\n')\n process.exit(3)\n }\n const deadline = started + TOTAL_BUDGET_MS\n const call = (method, params) => rpc(metadata, method, params, Math.max(250, Math.min(CALL_TIMEOUT_MS, deadline - Date.now())))\n const payload = fitToBudget(\n await gather(call, { workspaceId, snapshotId, reason: (env.ORCA_READ_REASON || 'sleep').slice(0, 20), now: Date.now(), deadline, listSessions: () => storedSessions(dataDir) })\n )\n const out = seal(payload, recipients, workspaceId, snapshotId)\n out.signature = sign(out, signingKey(stateDir))\n // Timing only: counts and sizes would describe the plaintext to the control plane's logs.\n out.stats = { collectMs: Date.now() - started }\n process.stdout.write(`${JSON.stringify(out)}\\n`)\n}\n\nmodule.exports = { workspaceCheckout, seal, sign, signingKey, gather, fitToBudget, messagesOf, gitOf, prOf, rpc, storedSessions, liveAgents }\n\nif (require.main === module) {\n // A fixed message: an error text could carry workspace content into the control plane's logs.\n main().catch(() => {\n process.stderr.write('read snapshot failed\\n')\n process.exit(1)\n })\n}\n","orca-git-as":"#!/usr/bin/env bash\n# Runs git as the teammate whose message started this agent's turn: their GitHub identity for\n# pushes/PRs (this repository only) and their commit author.\n# orca-git-as e.g. orca-git-as push origin HEAD\n# orca-git-as --as refuses unless that member really sent the message\n# Orca attests the sender from the paired device that sent the message, never from arguments or\n# message text. Without an attested sender (not run from an agent, or the owner's local session) it\n# runs plain git with the workspace identity.\nset -euo pipefail\nexpect=''\nif [ \"${1:-}\" = '--as' ]; then\n expect=\"${2:-}\"\n shift 2 || { echo 'usage: orca-git-as [--as ] ' >&2; exit 2; }\n case \"$expect\" in '' | *[!A-Za-z0-9_.:-]*) echo 'orca-git-as: bad member id' >&2; exit 2 ;; esac\nfi\nset +e\nfile=\"$(ORCA_GIT_AS=\"$expect\" sudo -n --preserve-env=ORCA_GIT_AS,ORCA_TERMINAL_HANDLE,ORCA_PANE_KEY,ORCA_AGENT_LAUNCH_TOKEN,ORCA_AGENT_SESSION_ID \\\n /opt/orca/bin/orca-git-identity)\"\nstatus=$?\nset -e\ncase \"$status\" in\n 0)\n trap 'rm -f \"$file\"' EXIT\n git -c \"include.path=$file\" \"$@\"\n ;;\n 3)\n echo 'orca-git-as: no teammate identity for this turn; using the workspace identity' >&2\n exec git \"$@\"\n ;;\n *) exit 4 ;;\nesac\n","orca-git-identity":"#!/usr/bin/env bash\n# Root helper behind `orca-git-as` (sudoers: the VM user may run only this). Asks orcad, as the\n# calling agent, which paired device sent the message that started the agent's latest turn\n# (`orca sender`: orcad identifies the agent from its launch evidence, never from arguments), maps\n# that device to a workspace member, and prints that member's git config file for the caller to\n# include. Optional ORCA_GIT_AS: the member the agent expects; any other answer is refused.\n# Exit codes: 0 printed a config path; 3 no member identity (use the workspace identity); 4 refused.\nset -euo pipefail\nDIR=/etc/orca/members\ncaller=\"${SUDO_USER:-}\"\ncase \"$caller\" in '' | root | *[!a-z0-9_-]*) echo 'orca-git-identity: run through orca-git-as' >&2; exit 4 ;; esac\n# orcad's own data dir and HOME (the agent's user may not use its passwd home, e.g. HOME=/vercel).\norcad_pid=\"$(pgrep -u \"$caller\" -f orcad-server.js | head -n 1 || true)\"\n[ -n \"$orcad_pid\" ] || exit 3\norcad_env() { tr '\\0' '\\n' <\"/proc/$orcad_pid/environ\" | sed -n \"s/^$1=//p\" | head -n 1; }\nhome=\"$(orcad_env HOME)\"\ndata=\"$(orcad_env ORCA_USER_DATA)\"\ndata=\"${data:-$home/.orcad-data}\"\nnode=\"$(find /opt/orca/runtime/runtimes -type f -name node | head -n 1)\"\ncli=/opt/orca/runtime/orcad/out/cli/index.js\n# Only the evidence variables orcad needs reach the query, which runs as the caller, not root.\nanswer=\"$(sudo -u \"$caller\" env -i HOME=\"$home\" PATH=/usr/bin:/bin ORCA_USER_DATA_PATH=\"$data\" \\\n ${ORCA_TERMINAL_HANDLE:+ORCA_TERMINAL_HANDLE=\"$ORCA_TERMINAL_HANDLE\"} \\\n ${ORCA_PANE_KEY:+ORCA_PANE_KEY=\"$ORCA_PANE_KEY\"} \\\n ${ORCA_AGENT_LAUNCH_TOKEN:+ORCA_AGENT_LAUNCH_TOKEN=\"$ORCA_AGENT_LAUNCH_TOKEN\"} \\\n ${ORCA_AGENT_SESSION_ID:+ORCA_AGENT_SESSION_ID=\"$ORCA_AGENT_SESSION_ID\"} \\\n timeout 15 \"$node\" \"$cli\" sender --json 2>/dev/null || true)\"\ndevice=\"$(jq -r '.result.sender.deviceId // empty' <<<\"$answer\" 2>/dev/null || true)\"\nif [ -z \"$device\" ] || [ ! -s \"$DIR/devices.json\" ]; then\n if [ -n \"${ORCA_GIT_AS:-}\" ]; then\n echo \"orca-git-as: Orca cannot confirm that $ORCA_GIT_AS sent this agent's latest message; refusing to push as them.\" >&2\n exit 4\n fi\n exit 3\nfi\nmember=\"$(jq -r --arg d \"$device\" '.[$d] // empty' \"$DIR/devices.json\")\"\nif [ -z \"$member\" ]; then\n [ -n \"${ORCA_GIT_AS:-}\" ] && { echo \"orca-git-as: the sender's device is not linked to a workspace member; refusing.\" >&2; exit 4; }\n exit 3\nfi\nif [ -n \"${ORCA_GIT_AS:-}\" ] && [ \"$ORCA_GIT_AS\" != \"$member\" ]; then\n echo \"orca-git-as: this agent's latest message came from $member, not $ORCA_GIT_AS; refusing to push as someone else.\" >&2\n exit 4\nfi\nfile=\"$DIR/$(printf '%s' \"$member\" | tr ':' '_').gitconfig\"\n[ -s \"$file\" ] || exit 3\n# The caller reads the identity through this one-shot copy (owned by them, removed by orca-git-as).\nout=\"$(sudo -u \"$caller\" mktemp)\"\ncat \"$file\" >\"$out\"\necho \"$out\"\n","orcad-boot":"#!/usr/bin/env node\n// Starts orcad from a V8 code cache taken after a full startup, so a boot skips compiling the\n// functions startup runs. NODE_COMPILE_CACHE only stores what V8 compiles before the bundle runs\n// (about 5 of its 15 MB); every other function compiled lazily on every boot (~300 ms of wake).\n// orcad-boot --code-cache [--write-code-cache] [orcad args...]\n// orcad sees the argv `node [orcad args...]`, as if started directly.\n// --write-code-cache (the image's smoke run) writes the cache when orcad exits. The cache is used\n// only when its header matches this bundle's bytes and this Node; otherwise orcad loads exactly as\n// before (plain require, which NODE_COMPILE_CACHE still serves). Plain Node, no dependencies.\n// getBuiltinModule (Node >= 22.3) works whether node loads this extensionless file as CommonJS\n// (in the VM) or as an ES module (under this repo's type:module package, in tests).\nconst fs = process.getBuiltinModule('node:fs')\nconst path = process.getBuiltinModule('node:path')\nconst vm = process.getBuiltinModule('node:vm')\nconst zlib = process.getBuiltinModule('node:zlib')\nconst { Module, createRequire } = process.getBuiltinModule('node:module')\n\nconst MAGIC = 'orcad-code-cache-v1'\n\nconst args = process.argv.slice(2)\nlet cacheFile = null\nlet writeCache = false\nwhile (args[0] === '--code-cache' || args[0] === '--write-code-cache') {\n if (args[0] === '--code-cache') {\n cacheFile = args[1]\n args.splice(0, 2)\n } else {\n writeCache = true\n args.shift()\n }\n}\nconst entry = args.shift()\nif (!entry || !cacheFile) {\n console.error('usage: orcad-boot --code-cache [--write-code-cache] [args...]')\n process.exit(2)\n}\n// As for `node `: argv[1] is the resolved path, __filename the real one.\nprocess.argv = [process.argv[0], path.resolve(entry), ...args]\nlet file = process.argv[1]\ntry {\n file = fs.realpathSync(file)\n} catch {\n // A missing bundle fails in require below, as it would under plain node.\n}\n\n// One line of identity: a cache from another bundle or Node must never run (V8 only checks length).\nfunction cacheHeader(bytes) {\n return `${MAGIC} ${process.version} ${process.arch} ${bytes.length} ${zlib.crc32(bytes)}\\n`\n}\n\nfunction readCache(header) {\n let data\n try {\n data = fs.readFileSync(cacheFile)\n } catch {\n return null\n }\n const headerLength = Buffer.byteLength(header)\n if (data.length <= headerLength || data.toString('latin1', 0, headerLength) !== header) {\n console.error('orcad-boot: code cache is for another orcad build; loading without it')\n return null\n }\n return data.subarray(headerLength)\n}\n\nfunction runCompiled(header, source, cachedData) {\n // A vm.Script can serialize what startup compiled; the CommonJS loader cannot. Same wrapper,\n // filename and require as the loader, and import() resolves from the bundle like it does there.\n const options = {\n filename: file,\n cachedData: cachedData ?? undefined,\n importModuleDynamically: vm.constants.USE_MAIN_CONTEXT_DEFAULT_LOADER\n }\n // Node flags the loader option experimental once, on the first import() through it. Spend that\n // one-time warning here, unprinted, so orcad's stderr stays as it was.\n const emitWarning = process.emitWarning\n process.emitWarning = (warning, ...rest) => {\n if (!String(warning?.message ?? warning).includes('USE_MAIN_CONTEXT_DEFAULT_LOADER')) {\n emitWarning.call(process, warning, ...rest)\n }\n }\n let script\n try {\n new vm.Script('import(\"node:path\")', {\n filename: file,\n importModuleDynamically: options.importModuleDynamically\n })\n .runInThisContext()\n .catch(() => {})\n script = new vm.Script(Module.wrap(source), options)\n } finally {\n process.emitWarning = emitWarning\n }\n if (cachedData && script.cachedDataRejected) {\n console.error('orcad-boot: V8 rejected the code cache (other V8 flags?); compiled without it')\n }\n if (writeCache) {\n process.on('exit', () => {\n try {\n const data = script.createCachedData()\n const temporary = `${cacheFile}.${process.pid}.tmp`\n fs.writeFileSync(temporary, Buffer.concat([Buffer.from(header, 'latin1'), data]))\n fs.renameSync(temporary, cacheFile)\n console.error(`orcad-boot: wrote ${data.length} bytes of code cache`)\n } catch (error) {\n console.error(`orcad-boot: could not write the code cache: ${error.message}`)\n }\n })\n }\n const module = new Module(file, null)\n module.filename = file\n module.paths = Module._nodeModulePaths(path.dirname(file))\n const compiled = script.runInThisContext()\n compiled.call(module.exports, module.exports, createRequire(file), module, file, path.dirname(file))\n}\n\n// Locals, so neither the bundle bytes nor the cache outlive the compile.\nfunction boot() {\n // Older Node (no crc32 or main-context import()) loads as before.\n if (typeof zlib.crc32 === 'function' && vm.constants?.USE_MAIN_CONTEXT_DEFAULT_LOADER) {\n let bytes = null\n try {\n bytes = fs.readFileSync(file)\n } catch {\n // Missing or unreadable: require reports it as plain node would.\n }\n if (bytes) {\n const header = cacheHeader(bytes)\n const cachedData = readCache(header)\n if (cachedData || writeCache) {\n runCompiled(header, bytes.toString('utf8'), cachedData)\n return\n }\n }\n }\n createRequire(file)(file)\n}\n\nboot()\n","git":"#!/usr/bin/env bash\n# git for agents in Orca Cloud workspaces (first on orcad's PATH). Commands that author or publish\n# work run as the teammate whose message started this agent's turn (orca-git-as: orcad attests the\n# sender's paired device; root-only member identities). Everything else is plain git, untouched.\nreal=\"${ORCA_REAL_GIT:-/usr/bin/git}\"\nsub=''\nskip=0\nfor arg in \"$@\"; do\n if [ \"$skip\" = 1 ]; then skip=0; continue; fi\n case \"$arg\" in\n -C | -c | --git-dir | --work-tree | --namespace | --exec-path | --config-env) skip=1 ;;\n -*) ;;\n *) sub=\"$arg\"; break ;;\n esac\ndone\n# CREATE-GIT-ASYNC: a new workspace answers before teammates' GitHub identities are installed. Until\n# they are (orca-sandbox-members writes git-setup.ready), commits and pushes wait up to 15 s rather\n# than go out under the workspace identity; a failed or stalled setup stops them with what to do.\n# Read-only git and fetches never wait (they use the workspace's own credentials, set before ready).\nhold_for_member_setup() {\n local state=\"${HOME:-/nonexistent}/.orca-sandbox\" ticks=0 since\n # A failed install on any boot (create or a later restart) refuses even with nothing pending.\n [ -f \"$state/git-setup.pending\" ] || [ -f \"$state/git-setup.failed\" ] || return 0\n since=\"$(cat \"$state/git-setup.pending\" 2>/dev/null || echo 0)\"\n case \"$since\" in '' | *[!0-9]*) since=0 ;; esac\n while [ -f \"$state/git-setup.pending\" ] && [ ! -f \"$state/git-setup.ready\" ] && [ ! -f \"$state/git-setup.failed\" ]; do\n # 15 s per command; a mark older than 2 min is a setup that died, not one still running.\n if [ \"$ticks\" -ge \"${ORCA_GIT_SETUP_WAIT_TICKS:-150}\" ] || [ $(($(date +%s) - since)) -gt 120 ]; then\n break\n fi\n sleep 0.1\n ticks=$((ticks + 1))\n done\n if [ -f \"$state/git-setup.ready\" ] || { [ ! -f \"$state/git-setup.pending\" ] && [ ! -f \"$state/git-setup.failed\" ]; }; then\n return 0\n fi\n if [ -f \"$state/git-setup.failed\" ]; then\n printf '%s\\n' \"orca: Orca couldn't set up your GitHub identity in this sandbox, so it stopped \\`git $sub\\` rather than use the wrong identity. Restart this workspace (right-click it in the sidebar > Restart), then try again.\" >&2\n else\n printf '%s\\n' \"orca: Orca is still setting up your GitHub identity in this sandbox, so it stopped \\`git $sub\\` rather than use the wrong identity. Try again in a few seconds. If this keeps happening, restart this workspace (right-click it in the sidebar > Restart).\" >&2\n fi\n exit 1\n}\n# A commit under the workspace identity (Orca Cloud's name) says once per session how to use your own\n# (design-squad). /dev/shm is per boot: a wake or restart says it again.\nnudge_once() {\n [ \"$sub\" = commit ] || return 0\n local mark=\"${ORCA_GIT_NUDGE_DIR:-/dev/shm}/orca-git-nudge-$(id -u)\"\n [ -e \"$mark\" ] && return 0\n : >\"$mark\" 2>/dev/null || return 0\n printf '%s\\n' \"orca: This commit uses Orca Cloud's name. Connect your GitHub in Settings › Account to use yours.\" >&2\n}\n# PUSH-RAW-PROMPT: a push, pull or fetch to github.com that no credential covers (neither the person's\n# connected GitHub nor the App installation) must never fall through to git's own \"Username for\n# 'https://github.com':\" prompt. Prompts are off for that remote, and git's auth failure becomes one\n# plain line saying what to do.\ngithub_slug=''\ngithub_remote_slug() {\n local arg skip=0 seen=0 remote='' url\n for arg in \"$@\"; do\n if [ \"$skip\" = 1 ]; then skip=0; continue; fi\n if [ \"$seen\" = 0 ]; then\n case \"$arg\" in\n -C | -c | --git-dir | --work-tree | --namespace | --exec-path | --config-env) skip=1 ;;\n \"$sub\") seen=1 ;;\n esac\n continue\n fi\n case \"$arg\" in\n --repo=*) remote=\"${arg#--repo=}\"; break ;;\n -o | --push-option | --receive-pack | --upload-pack | --exec | --depth | --deepen | --shallow-since | --shallow-exclude | --refmap | --recurse-submodules-default | -j | --jobs) skip=1 ;;\n -*) ;;\n *) remote=\"$arg\"; break ;;\n esac\n done\n [ -n \"$remote\" ] || remote=\"$(\"$real\" config --get \"branch.$(\"$real\" symbolic-ref --short -q HEAD 2>/dev/null).remote\" 2>/dev/null || true)\"\n [ -n \"$remote\" ] || remote=origin\n case \"$remote\" in\n *://*) url=\"$remote\" ;;\n *) url=\"$(\"$real\" remote get-url \"$remote\" 2>/dev/null || printf '%s' \"$remote\")\" ;;\n esac\n case \"$url\" in\n https://github.com/* | https://*@github.com/*) url=\"${url#*github.com/}\" ;;\n *) return 0 ;;\n esac\n url=\"${url%.git}\"\n url=\"${url%/}\"\n case \"$url\" in */*/*) url=\"${url%%/*}/$(printf '%s' \"${url#*/}\" | cut -d/ -f1)\" ;; esac\n case \"$url\" in [A-Za-z0-9_.-]*/[A-Za-z0-9_.-]*) github_slug=\"$url\" ;; esac\n}\nno_access_line() {\n local owner=\"${github_slug%%/*}\" verb='push to'\n [ \"$sub\" = push ] || verb='read'\n printf '%s\\n' \"orca: Orca Cloud can't $verb $github_slug. Install the Orca GitHub App on $owner, or connect your GitHub in Settings › Account.\" >&2\n}\n# GitHub's \"not found\" is a typo'd name or a private repo it won't confirm: say which half fits whom.\nnot_found_line() {\n printf '%s\\n' \"orca: Orca Cloud can't find $github_slug. Check the name, or install the Orca GitHub App on ${github_slug%%/*} if it's private.\" >&2\n}\n# orca-git-as's own fallback line is replaced by the nudge; GitHub auth failures by the plain line.\nfallback_filter() {\n local line said=0\n while IFS= read -r line || [ -n \"$line\" ]; do\n case \"$line\" in\n 'orca-git-as: no teammate identity'*) nudge_once ;;\n 'remote: Repository not found.'* | \"fatal: repository 'https://github.com/\"*\"' not found\"*)\n if [ -n \"$github_slug\" ]; then\n [ \"$said\" = 1 ] || not_found_line\n said=1\n else\n printf '%s\\n' \"$line\"\n fi\n ;;\n *\"could not read Username for 'https://github.com'\"* | *\"could not read Password for 'https://\"*\"github.com'\"* | \\\n *\"Authentication failed for 'https://github.com/\"* | 'remote: Permission to '*' denied to '* | \\\n *\"The requested URL returned error: 403\"*)\n if [ -n \"$github_slug\" ]; then\n [ \"$said\" = 1 ] || no_access_line\n said=1\n else\n printf '%s\\n' \"$line\"\n fi\n ;;\n *) printf '%s\\n' \"$line\" ;;\n esac\n done\n}\ncase \"$sub\" in\n push | pull | fetch)\n github_remote_slug \"$@\"\n if [ -n \"$github_slug\" ]; then\n export GIT_TERMINAL_PROMPT=0 GCM_INTERACTIVE=never\n fi\n ;;\nesac\ngit_as=\"${ORCA_GIT_AS:-/opt/orca/bin/orca-git-as}\"\ncase \"$sub\" in\n commit | push | merge | rebase | cherry-pick | revert | am | pull | tag)\n hold_for_member_setup\n # Outside an agent turn (no attested sender) orca-git-as falls back to the workspace identity.\n if [ -n \"${ORCA_TERMINAL_HANDLE:-}${ORCA_AGENT_SESSION_ID:-}\" ] && [ -x \"$git_as\" ]; then\n PATH=\"/usr/bin:/bin:$PATH\" exec \"$git_as\" \"$@\" 2> >(fallback_filter >&2)\n fi\n # No terminal or agent turn to attribute it to: the workspace identity.\n nudge_once\n ;;\nesac\nif [ -n \"$github_slug\" ]; then\n exec \"$real\" \"$@\" 2> >(fallback_filter >&2)\nfi\nexec \"$real\" \"$@\"\n"} -export const VM_SCRIPTS_HASH: string = "9bc77cc5" -export const VM_SCRIPTS_STAMP: number = 1794530964 +export const VM_SCRIPT_SOURCES: BuildInputs['vmScripts'] = {"orca-sandbox-start":"#!/usr/bin/env bash\n# Starts orcad for this boot (idempotent) and prints its recipe line:\n# {\"schemaVersion\":1,\"pairingCode\":\"orca://pair?...\",\"projectRoot\":\"/vercel/sandbox/project\"}\n# Env from the control plane: ORCA_SANDBOX_ID, ORCA_PUBLIC_WSS, optional ORCA_REPO_URL/ORCA_REPO_REF,\n# ORCA_GITHUB_PLACEHOLDER, ORCA_SECRET_ENV (`NAME=` lines: org/personal/repo secrets for\n# orcad and its agents), ORCA_SHARE_PROXY_KEY (this session's share-link proxy key, port 6769).\n# Never prints anything else on stdout's last line.\n# With ORCA_READY_VIA=file it prints {\"schemaVersion\":1,\"readyFile\":\"\"} instead and the control\n# plane reads the offer with the files API: the provider keeps command stdout as logs, not files.\n# With ORCA_READY_KEY too (64 hex chars, this call's key; env is not kept) the pointer also carries\n# \"sealedOffer\": \".\", which saves that round trip.\n# Managed env for every process orcad starts: ORCA_IS_CLOUD=1, ORCA_SANDBOX_ID, ORCA_ROOT_PATH,\n# ORCA_WORKTREE_PATH, ORCA_WORKSPACE_PATH, ORCA_WORKSPACE_NAME, ORCA_DEFAULT_BRANCH, ORCA_PORT and the\n# CONDUCTOR_* names imported Conductor scripts read.\nset -euo pipefail\numask 077\nORCA_HOME=/opt/orca\nSTATE=\"$HOME/.orca-sandbox\"\nPROJECT=/vercel/sandbox/project\nmkdir -p \"$STATE\"\n# Only print_ready uses the key; nothing this script starts inherits it.\nready_key=\"${ORCA_READY_KEY:-}\"\nunset ORCA_READY_KEY\n[[ \"$ready_key\" =~ ^[0-9a-f]{64}$ ]] || ready_key=''\nexec 9>\"$STATE/start.lock\"\nflock -w 60 9\n# Background children close fd 9 (`9>&-`): an orcad that inherited it would hold the lock for its whole\n# life and every later start (resume of a running sandbox, a retried create) would wait out the 60 s.\n\n# orcad only lists a project once a client registers it (the desktop does on connect), so API, MCP,\n# routine and phone sessions saw no repo. Register the workspace project at every start, in the\n# background (ready is not delayed); `repo add` of an already-registered path is a no-op error.\nregister_project() {\n [ -x \"$ORCA_HOME/bin/orca\" ] && [ -d \"$PROJECT\" ] || return 0\n local kind=() name=()\n [ -e \"$PROJECT/.git\" ] || kind=(--kind folder)\n # The checkout lives at .../project; name it after the repository (owner/name.git -> name).\n if [ -n \"${ORCA_REPO_URL:-}\" ]; then\n local base=\"${ORCA_REPO_URL%/}\"\n base=\"${base##*/}\"\n base=\"${base%.git}\"\n [ -n \"$base\" ] && name=(--display-name \"$base\")\n fi\n setsid \"$ORCA_HOME/bin/orca\" repo add --path \"$PROJECT\" \"${kind[@]}\" \"${name[@]}\" --json \\\n >\"$STATE/register-project.log\" 2>&1 &- &\n}\n\nprint_ready() {\n if [ \"${ORCA_READY_VIA:-}\" = file ]; then\n local sealed='' iv\n if [ -n \"$ready_key\" ] && command -v openssl >/dev/null; then\n iv=\"$(od -An -N16 -tx1 /dev/urandom | tr -d ' \\n')\"\n # Best effort: without it the control plane reads the file, as before.\n sealed=\"$(tail -n 1 \"$STATE/ready.json\" | openssl enc -aes-256-ctr -K \"$ready_key\" -iv \"$iv\" -nosalt | base64 -w0)\" &&\n sealed=\"$iv.$sealed\" || sealed=''\n fi\n printf '{\"schemaVersion\":1,\"readyFile\":\"%s\"%s}\\n' \"$STATE/ready.json\" \"${sealed:+,\\\"sealedOffer\\\":\\\"$sealed\\\"}\"\n else\n tail -n 1 \"$STATE/ready.json\"\n fi\n}\n# Command stderr is kept by the provider: strip injected values and credential shapes from log tails.\nredact_log() {\n local text kv value\n text=\"$(cat)\"\n for kv in ${agent_env[@]+\"${agent_env[@]}\"}; do\n value=\"${kv#*=}\"\n if [ \"${#value}\" -ge 8 ]; then\n text=\"${text//\"$value\"/[REDACTED]}\"\n fi\n done\n printf '%s\\n' \"$text\" | sed -E \\\n -e 's#orca://[^[:space:]]+#[REDACTED_PAIRING]#g' \\\n -e 's#(gh[pousr]_|github_pat_|sk-|vc[kpiar]_)[A-Za-z0-9._-]{16,}#[REDACTED_TOKEN]#g' \\\n -e 's#eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}#[REDACTED_JWT]#g' \\\n -e 's#(Authorization|authorization):[[:space:]]*[A-Za-z]+[[:space:]]+[^[:space:]]+#\\1: [REDACTED]#g'\n}\n\nnode=\"$(find \"$ORCA_HOME/runtime/runtimes\" -type f -name node | head -n 1)\"\n\n# Share-link proxy (port 6769): answers only requests the control plane signed with this session's\n# key, so its public route is useless on its own. The key goes in on stdin (printf is a builtin), so\n# it never appears in any argv or in the proxy's /proc//environ.\nstart_share_proxy() {\n [ -n \"${ORCA_SHARE_PROXY_KEY:-}\" ] && [ -x \"$ORCA_HOME/bin/orca-share-proxy\" ] || return 0\n if [ \"$(cat \"$STATE/share-proxy.boot\" 2>/dev/null || true)\" = \"$boot_id\" ] \\\n && kill -0 \"$(cat \"$STATE/share-proxy.pid\" 2>/dev/null || echo 0)\" 2>/dev/null; then\n return 0\n fi\n printf '%s' \"$ORCA_SHARE_PROXY_KEY\" | env -i HOME=\"$HOME\" LANG=C.UTF-8 PATH=/usr/bin:/bin \\\n setsid \"$node\" \"$ORCA_HOME/bin/orca-share-proxy\" >\"$STATE/share-proxy.log\" 2>&1 9>&- &\n echo $! >\"$STATE/share-proxy.pid\"\n printf '%s' \"$boot_id\" >\"$STATE/share-proxy.boot\"\n}\n\nboot_id=\"$(cat /proc/sys/kernel/random/boot_id)\"\n# CREATE-GIT-ASYNC: a create answers before member identities are installed; `git` holds commits and\n# pushes until orca-sandbox-members (or the control plane) marks this setup ready or failed. Marked\n# before the already-started exit below: a pooled VM's orcad may be up before its create claims it.\nif [ \"${ORCA_GIT_SETUP:-}\" = pending ]; then\n rm -f \"$STATE/git-setup.ready\" \"$STATE/git-setup.failed\"\n date +%s >\"$STATE/git-setup.pending\"\nfi\nstart_share_proxy\nunset ORCA_SHARE_PROXY_KEY\nif [ \"$(cat \"$STATE/boot-id\" 2>/dev/null || true)\" = \"$boot_id\" ] && [ -s \"$STATE/ready.json\" ] \\\n && kill -0 \"$(cat \"$STATE/orcad.pid\" 2>/dev/null || echo 0)\" 2>/dev/null; then\n print_ready\n exit 0\nfi\n# Resume = reboot from disk: locks and pids from the previous session name dead processes.\nrm -f \"$STATE/ready.json\" \"$STATE/orcad.pid\"\nprintf '%s' \"$boot_id\" >\"$STATE/boot-id\"\n# Any other boot (wake, restart) installs identities before it answers: a resumed disk's marks are stale.\n[ \"${ORCA_GIT_SETUP:-}\" = pending ] || rm -f \"$STATE/git-setup.pending\" \"$STATE/git-setup.ready\" \"$STATE/git-setup.failed\"\n\nif [ -n \"${ORCA_GIT_EXTRAHEADER:-}\" ]; then\n # Every github.com request carries the placeholder; the egress policy swaps in a 1h repo token.\n git config --global http.https://github.com/.extraheader \"$ORCA_GIT_EXTRAHEADER\"\nfi\n# Commits outside a teammate's turn (or before any member connected GitHub) need an author too.\n# The fallback moved to orca.dev with the domain; a workspace still on the old default follows it.\n[ \"$(git config --global user.email 2>/dev/null || true)\" = 'cloud@users.noreply.onorca.dev' ] \\\n && git config --global user.email 'cloud@users.noreply.orca.dev'\ngit config --global user.email >/dev/null 2>&1 || git config --global user.email 'cloud@users.noreply.orca.dev'\ngit config --global user.name >/dev/null 2>&1 || git config --global user.name 'Orca Cloud'\n# First boot clones while orcad starts (orcad only needs the directory to exist); ready waits for both.\nclone_pid=''\nif [ ! -d \"$PROJECT\" ]; then\n mkdir -p \"$PROJECT\"\n if [ -n \"${ORCA_REPO_URL:-}\" ]; then\n git clone --quiet ${ORCA_REPO_REF:+--branch \"$ORCA_REPO_REF\"} -- \"$ORCA_REPO_URL\" \"$PROJECT\" 2>\"$STATE/clone.log\" 9>&- &\n clone_pid=$!\n fi\nfi\n\n# The clone may still be running here, so the name comes from the URL; the default branch is\n# filled in lazily by the session-env shell hook once the clone exists.\nif [ -n \"${ORCA_REPO_URL:-}\" ]; then\n workspace_name=\"$(basename \"${ORCA_REPO_URL%/}\" .git)\"\nelse\n workspace_name=\"$(basename \"$(git -C \"$PROJECT\" remote get-url origin 2>/dev/null || echo \"$PROJECT\")\" .git)\"\nfi\n# One workspace per VM: its preview ports start at 3000 (Conductor allots CONDUCTOR_PORT..+9).\nmanaged_env=(\n ORCA_IS_CLOUD=1\n ORCA_ROOT_PATH=\"$PROJECT\" ORCA_WORKTREE_PATH=\"$PROJECT\" ORCA_WORKSPACE_PATH=\"$PROJECT\"\n ORCA_WORKSPACE_NAME=\"$workspace_name\" ORCA_PORT=3000\n CONDUCTOR_IS_CLOUD=1\n # Agent CLIs are pinned by the image (smoke-tested); a self-update mid-session can break a running agent.\n DISABLE_AUTOUPDATER=1 OPENCODE_DISABLE_AUTOUPDATE=1 COPILOT_AUTO_UPDATE=false\n CONDUCTOR_ROOT_PATH=\"$PROJECT\" CONDUCTOR_WORKSPACE_PATH=\"$PROJECT\"\n CONDUCTOR_WORKSPACE_NAME=\"$workspace_name\" CONDUCTOR_PORT=3000\n)\n\nagent_env=()\nif command -v google-chrome >/dev/null; then\n # Orca's browser automation and agent browser tools use the image's headless Chrome.\n agent_env+=(\"ORCA_BROWSER_EXECUTABLE=$(command -v google-chrome)\")\nfi\nif [ -n \"${ORCA_GITHUB_PLACEHOLDER:-}\" ]; then\n # The egress layer swaps the placeholder for a short-lived GitHub token; none lives in the VM.\n agent_env+=(\"GH_TOKEN=$ORCA_GITHUB_PLACEHOLDER\" \"GITHUB_TOKEN=$ORCA_GITHUB_PLACEHOLDER\")\nfi\n\nsecret_names=()\nif [ -n \"${ORCA_SECRET_ENV:-}\" ]; then\n # Secrets live only in memory: orcad's process env and a tmpfs file new shells re-read\n # (orca-sandbox-env), never under $HOME or the project, so the stop snapshot never carries them.\n # Resolved per start (and per edit) by the control plane: no image rebuild, no restart.\n while IFS= read -r line; do\n name=\"${line%%=*}\" b64=\"${line#*=}\" # not IFS='=': read would strip base64 padding\n case \"$name\" in '' | *[!A-Z0-9_]* | [0-9]* | ORCA_* | PATH | HOME | LD_* | DYLD_*) continue ;; esac\n agent_env+=(\"$name=$(printf '%s' \"$b64\" | base64 -d)\")\n secret_names+=(\"$name\")\n done < <(printf '%s\\n' \"$ORCA_SECRET_ENV\") # a pipe, not a here-string temp file\nfi\n# Always rewrite the file, so a resume with fewer secrets drops the old names too.\n\"$ORCA_HOME/bin/orca-sandbox-env\" >/dev/null || echo 'session env file unavailable; secrets apply at boot only' >&2\nunset ORCA_SECRET_ENV\n\n# Agent CLIs installed with the image's npm (codex, gemini, ...) are `#!/usr/bin/env node` scripts:\n# terminals need that node on PATH, not only orcad's bundled runtime.\nsystem_node=\"$(command -v node 2>/dev/null || echo \"$node\")\"\n# The egress proxy answers for hosts with credential rules (model APIs, api.github.com) with a\n# certificate from Vercel's CA, which this command's env trusts (Vercel sets these). Pure-Node clients\n# use Node's bundled roots otherwise: Gemini CLI retried forever (\"taking a bit longer\") in every\n# terminal while the same call worked here. Pass the provider's CA settings on to orcad and its shells.\nca_env=(NODE_USE_SYSTEM_CA=\"${NODE_USE_SYSTEM_CA:-1}\")\nfor name in NODE_EXTRA_CA_CERTS SSL_CERT_FILE; do\n if [ -n \"${!name:-}\" ]; then ca_env+=(\"$name=${!name}\"); fi\ndone\n# N-02: the host is a cloud terminal's source of truth across app reopens, so orcad and its\n# daemon keep a local terminal's depth (5000 rows) instead of the desktop daemon's 1000-row live\n# window. Scoped to the VM: local desktop daemons keep their default.\n# Ports panel: programs under $ORCA_HOME are the image's own services, and 23456 is Vercel's\n# sandbox-init (root, pid invisible to this user); neither is listed as the user's port.\nenv -i HOME=\"$HOME\" USER=\"$(id -un)\" LANG=C.UTF-8 TERM=xterm-256color \"${ca_env[@]}\" \\\n PATH=\"$ORCA_HOME/tools/bin:$ORCA_HOME/bin:$(dirname \"$system_node\"):/usr/local/bin:/usr/bin:/bin\" \\\n ORCA_USER_DATA=\"$HOME/.orcad-data\" ORCA_SANDBOX_ID=\"$ORCA_SANDBOX_ID\" \"${managed_env[@]}\" \\\n ORCA_HOST_MANAGED_LISTENER_PATHS=\"$ORCA_HOME\" ORCA_HOST_MANAGED_UNOWNED_PORTS=23456 \\\n NODE_COMPILE_CACHE=\"$ORCA_HOME/compile-cache\" ORCA_ORCAD_STARTUP_QUALIFICATION_DIR=\"$ORCA_HOME/startup-qualification\" \\\n ORCA_DAEMON_SESSION_SCROLLBACK_ROWS=5000 \\\n ORCA_SESSION_ENV_NAMES=\"${secret_names[*]}\" \"${agent_env[@]}\" \\\n setsid nohup \"$node\" \"$ORCA_HOME/bin/orcad-boot\" --code-cache \"$ORCA_HOME/compile-cache/orcad-server.v8\" \\\n \"$ORCA_HOME/runtime/orcad/orcad-server.js\" --port 6768 --bind 0.0.0.0 --pairing-address \"$ORCA_PUBLIC_WSS\" \\\n --recipe-json --project-root \"$PROJECT\" \\\n >\"$STATE/ready.json\" 2>\"$STATE/orcad.log\" &- &\necho $! >\"$STATE/orcad.pid\"\n\n# sshd for `orca ssh` (loopback only). Best effort: a workspace without it still serves orcad.\n\"$ORCA_HOME/bin/orca-sshd\" >/dev/null 2>\"$STATE/sshd-start.err\" || true\n\n# Polled every 10 ms (at least 60 s in all): ready is on the wake path, so detect it promptly.\nfor _ in $(seq 1 6000); do\n if grep -q '\"pairingCode\"' \"$STATE/ready.json\" 2>/dev/null; then\n if [ -n \"$clone_pid\" ] && ! wait \"$clone_pid\"; then\n echo 'repository clone failed' >&2\n tail -n 5 \"$STATE/clone.log\" | redact_log >&2\n exit 1\n fi\n register_project\n print_ready\n exit 0\n fi\n if ! kill -0 \"$(cat \"$STATE/orcad.pid\")\" 2>/dev/null; then\n echo 'orcad exited before ready' >&2\n tail -n 40 \"$STATE/orcad.log\" | redact_log >&2\n exit 1\n fi\n sleep 0.01\ndone\necho 'orcad start timed out' >&2\nexit 1\n","orca-sandbox-drain":"#!/usr/bin/env bash\n# Asks orcad to quit before the VM stops: its shutdown turns working agent chats into durable\n# restart offers and flushes terminal restore hints, which the desktop continues after resume.\nset -euo pipefail\nSTATE=\"$HOME/.orca-sandbox\"\nWAIT_SECONDS=\"${1:-280}\"\npid=\"$(cat \"$STATE/orcad.pid\" 2>/dev/null || true)\"\nif [ -z \"$pid\" ] || ! kill -0 \"$pid\" 2>/dev/null; then\n exit 0\nfi\nkill -TERM \"$pid\"\nfor _ in $(seq 1 $((WAIT_SECONDS * 10))); do\n if ! kill -0 \"$pid\" 2>/dev/null; then\n rm -f \"$STATE/orcad.pid\" \"$STATE/ready.json\"\n exit 0\n fi\n sleep 0.1\ndone\n# Leave it running: the provider stop ends it, and the drain already had its window.\necho 'orcad still draining at deadline' >&2\nexit 0\n","orca-sandbox-env":"#!/usr/bin/env bash\n# Writes this session's secret env where new shells and agent launches read it (orca-session-env.sh).\n# Input: ORCA_SECRET_ENV (`NAME=` lines) from the control plane, at boot (orca-sandbox-start)\n# and whenever a secret changes (POST /v1/sandboxes/:id/env/refresh). The file lives on tmpfs only:\n# never on the sandbox disk, so the stop snapshot never carries it. Prints {\"names\":N} on success.\n# Also keeps Codex pointed at a configured OpenAI-compatible provider (OPENAI_BASE_URL), since Codex\n# does not read that variable: a managed block in ~/.codex/config.toml naming only the env var.\nset -euo pipefail\numask 077\nHOME=\"${HOME:-$(getent passwd \"$(id -u)\" | cut -d: -f6)}\"\n# ORCA_SESSION_ENV_DIR: tests only (a scratch dir off tmpfs); the control plane never sets it.\ndir=\"${ORCA_SESSION_ENV_DIR:-/dev/shm/orca-env-$(id -u)}\"\nif [ -z \"${ORCA_SESSION_ENV_DIR:-}\" ] && [ \"$(stat -f -c %T /dev/shm 2>/dev/null || true)\" != tmpfs ]; then\n # No memory-only place to keep it: refuse rather than write secrets to disk.\n echo 'no tmpfs for the session env' >&2\n exit 3\nfi\nmkdir -p \"$dir\"\nchmod 700 \"$dir\"\n# Writes are ordered by the control plane's resolve time: an older refresh that lost a race is dropped.\ngeneration=\"${ORCA_ENV_GENERATION:-0}\"\ncase \"$generation\" in '' | *[!0-9]*) generation=0 ;; esac\ncurrent=\"$(cat \"$dir/generation\" 2>/dev/null || echo 0)\"\ncase \"$current\" in '' | *[!0-9]*) current=0 ;; esac\nif [ \"$generation\" -lt \"$current\" ]; then\n printf '{\"stale\":true}\\n'\n exit 0\nfi\ntmp=\"$(mktemp \"$dir/.session.env.XXXXXX\")\"\ncount=0\nopenai_base_url=''\nopenai_model=''\nanthropic_model=''\nanthropic_key_tail=''\ngateway_key=''\ngemini_key=''\ncodex_placeholder=''\nwhile IFS= read -r line; do\n name=\"${line%%=*}\" b64=\"${line#*=}\"\n # Not for shells: the Codex account placeholder becomes ~/.codex/auth.json below.\n if [ \"$name\" = ORCA_CODEX_AUTH_PLACEHOLDER ]; then\n codex_placeholder=\"$(printf '%s' \"$b64\" | base64 -d)\"\n case \"$codex_placeholder\" in *[!A-Za-z0-9._-]*) codex_placeholder='' ;; esac\n fi\n case \"$name\" in '' | *[!A-Z0-9_]* | [0-9]* | ORCA_* | PATH | HOME | LD_* | DYLD_*) continue ;; esac\n # Values stay base64 in the file; the shell hook decodes them.\n printf '%s=%s\\n' \"$name\" \"$b64\" >>\"$tmp\"\n count=$((count + 1))\n case \"$name\" in\n OPENAI_BASE_URL) openai_base_url=\"$(printf '%s' \"$b64\" | base64 -d)\" ;;\n OPENAI_MODEL) openai_model=\"$(printf '%s' \"$b64\" | base64 -d)\" ;;\n ANTHROPIC_MODEL) anthropic_model=\"$(printf '%s' \"$b64\" | base64 -d)\" ;;\n AI_GATEWAY_API_KEY) gateway_key=1 ;;\n GEMINI_API_KEY | GOOGLE_API_KEY) gemini_key=\"${gemini_key:-gemini-api-key}\" ;;\n GOOGLE_GENAI_USE_VERTEXAI) case \"$(printf '%s' \"$b64\" | base64 -d)\" in true | 1) gemini_key=vertex-ai ;; esac ;;\n # Claude Code asks once per key whether to use ANTHROPIC_API_KEY; it remembers the last 20 chars.\n ANTHROPIC_API_KEY) anthropic_key_tail=\"$(printf '%s' \"$b64\" | base64 -d | tail -c 20)\" ;;\n esac\ndone < <(printf '%s\\n' \"${ORCA_SECRET_ENV:-}\")\nmv -f \"$tmp\" \"$dir/session.env\"\nprintf '%s' \"$generation\" >\"$dir/generation\"\n\n# ---- Codex: trust the workspace and use the configured provider (no secret values) ----\nPROJECT=/vercel/sandbox/project\ncodex_config=\"$HOME/.codex/config.toml\"\ntoml_string() { # TOML basic string; the control plane only accepts https URLs and plain model ids\n local value=\"${1//\\\\/\\\\\\\\}\"\n printf '\"%s\"' \"${value//\\\"/\\\\\\\"}\"\n}\nstrip_managed() { # drop the managed blocks and the blank lines written before them (no growth per refresh)\n awk '/^# >>> orca-cloud managed/{skip=1; blank=\"\"} skip{if (/^# <<< orca-cloud managed/) skip=0; next}\n /^[[:space:]]*$/{blank=blank $0 \"\\n\"; next} {printf \"%s\", blank; blank=\"\"; print}' \"$1\"\n}\n# Always managed: an agent launched from the phone or the API has no one to answer Codex's\n# \"trust this folder?\" screen, so the workspace project is trusted up front.\nif true; then\n mkdir -p \"$HOME/.codex\"\n user_part=\"$(mktemp \"$HOME/.codex/.config.XXXXXX\")\"\n if [ -f \"$codex_config\" ]; then strip_managed \"$codex_config\" >\"$user_part\"; fi\n next=\"$(mktemp \"$HOME/.codex/.config.XXXXXX\")\"\n openai_host=\"${openai_base_url#https://}\"\n openai_host=\"${openai_host%%/*}\"\n custom_provider=''\n if [ -n \"$openai_base_url\" ] && [ \"$openai_host\" != api.openai.com ]; then custom_provider=1; fi\n # Top-level keys must precede every table; each is skipped when the user already set it.\n head_keys=\"$(awk '/^\\[/{exit} {print}' \"$user_part\")\"\n has_key() { grep -Eq \"^[[:space:]]*$1[[:space:]]*=\" <<<\"$head_keys\"; }\n {\n printf '# >>> orca-cloud managed (Orca Cloud; edit in Orca settings)\\n'\n # The sandbox is the isolation boundary: Codex's own bwrap sandbox can't start in it (\"Unexpected\n # capabilities\"), and a prompt delivered from the phone or the API has no one to approve commands.\n has_key sandbox_mode || printf 'sandbox_mode = \"danger-full-access\"\\n'\n has_key approval_policy || printf 'approval_policy = \"never\"\\n'\n if [ -n \"$custom_provider\" ]; then\n has_key model_provider || printf 'model_provider = \"orca_cloud\"\\n'\n if [ -n \"$openai_model\" ] && ! has_key model; then\n printf 'model = %s\\n' \"$(toml_string \"$openai_model\")\"\n fi\n elif [ -n \"$codex_placeholder\" ]; then\n has_key model_provider || printf 'model_provider = \"orca_chatgpt\"\\n'\n fi\n printf '# <<< orca-cloud managed\\n'\n cat \"$user_part\"\n if [ -n \"$custom_provider\" ]; then\n printf '\\n# >>> orca-cloud managed provider\\n[model_providers.orca_cloud]\\nname = \"Orca Cloud\"\\n'\n printf 'base_url = %s\\nenv_key = \"OPENAI_API_KEY\"\\nwire_api = \"responses\"\\n' \"$(toml_string \"$openai_base_url\")\"\n printf '# <<< orca-cloud managed\\n'\n elif [ -n \"$codex_placeholder\" ]; then\n # Codex via the Orca account: the ChatGPT backend over HTTPS. Its default Responses WebSocket can't\n # pass the egress proxy, where the placeholder becomes the real token (found live, Codex 0.166).\n printf '\\n# >>> orca-cloud managed provider\\n[model_providers.orca_chatgpt]\\nname = \"OpenAI\"\\n'\n printf 'base_url = \"https://chatgpt.com/backend-api/codex\"\\nwire_api = \"responses\"\\nrequires_openai_auth = true\\nsupports_websockets = false\\n'\n printf '# <<< orca-cloud managed\\n'\n fi\n } >\"$next\"\n if ! grep -qF \"[projects.\\\"$PROJECT\\\"]\" \"$user_part\"; then\n printf '\\n# >>> orca-cloud managed trust\\n[projects.\"%s\"]\\ntrust_level = \"trusted\"\\n# <<< orca-cloud managed\\n' \"$PROJECT\" >>\"$next\"\n fi\n chmod 600 \"$next\"\n mv -f \"$next\" \"$codex_config\"\n rm -f \"$user_part\"\nfi\n# ---- Codex via the person's Orca account (contract D11): a placeholder ChatGPT login, no refresh token.\n# The egress proxy swaps the real access token on chatgpt.com; the real tokens never reach this VM. The\n# marker remembers which placeholder Orca wrote (a clone gets its own), so a login of the person's own\n# (kill-switch fallback, or `codex login` run here) is set aside while the account applies and put back after.\ncodex_auth=\"$HOME/.codex/auth.json\"\ncodex_saved=\"$HOME/.codex/auth.json.orca-saved\"\ncodex_marker=\"$HOME/.codex/.orca-account-placeholder\"\ncodex_ours=''\n# Ours: the marker's placeholder, or any Orca placeholder (all end in `.orca-placeholder`; a real token never does).\nif [ -f \"$codex_auth\" ] && { { [ -f \"$codex_marker\" ] && grep -qF \"\\\"$(cat \"$codex_marker\")\\\"\" \"$codex_auth\"; } || grep -q '\\.orca-placeholder\"' \"$codex_auth\"; }; then\n codex_ours=1\nfi\nif [ -n \"$codex_placeholder\" ]; then\n mkdir -p \"$HOME/.codex\"\n if [ -f \"$codex_auth\" ] && [ -z \"$codex_ours\" ]; then\n # Never overwrite a login already set aside: a second one gets its own name.\n if [ -e \"$codex_saved\" ]; then mv -f \"$codex_auth\" \"$codex_saved.$(date -u +%Y%m%d%H%M%S)\"; else mv -f \"$codex_auth\" \"$codex_saved\"; fi\n fi\n # Marker first: a run cut short after the swap still recognises the file as Orca's.\n printf '%s' \"$codex_placeholder\" >\"$codex_marker\"\n codex_tmp=\"$(mktemp \"$HOME/.codex/.auth.XXXXXX\")\"\n printf '{\\n \"OPENAI_API_KEY\": null,\\n \"tokens\": {\\n \"id_token\": \"%s\",\\n \"access_token\": \"%s\",\\n \"refresh_token\": \"\",\\n \"account_id\": null\\n },\\n \"last_refresh\": \"%s\"\\n}\\n' \\\n \"$codex_placeholder\" \"$codex_placeholder\" \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" >\"$codex_tmp\"\n chmod 600 \"$codex_tmp\"\n mv -f \"$codex_tmp\" \"$codex_auth\"\nelif [ -f \"$codex_marker\" ] || [ -n \"$codex_ours\" ]; then\n if [ -n \"$codex_ours\" ]; then rm -f \"$codex_auth\"; fi\n rm -f \"$codex_marker\"\n if [ -f \"$codex_saved\" ] && [ ! -f \"$codex_auth\" ]; then mv -f \"$codex_saved\" \"$codex_auth\"; fi\nfi\n# ---- Claude Code, Gemini CLI, Cursor agent, Copilot CLI, OpenCode: first-run screens answered once\n# (Claude: theme picker, folder trust, bypass-mode warning, the \"use this API key?\" question; others:\n# folder trust, auth-method picker, self-update). A prompt delivered from the phone or the API has no one to click\n# through them, and the agent would sit there doing nothing. Merges; never touches credentials.\nnode_bin=\"$(command -v node || find /opt/orca/runtime/runtimes -type f -name node 2>/dev/null | head -n 1)\"\nif [ -n \"$node_bin\" ]; then\n ORCA_PROJECT=\"$PROJECT\" ORCA_KEY_TAIL=\"$anthropic_key_tail\" ORCA_GEMINI_KEY=\"$gemini_key\" \\\n ORCA_GATEWAY_MODEL=\"${gateway_key:+${anthropic_model:-$openai_model}}\" \"$node_bin\" -e '\nconst fs = require(\"fs\"), path = require(\"path\"), os = require(\"os\")\nconst home = os.homedir()\nconst readJson = (file) => { try { const v = JSON.parse(fs.readFileSync(file, \"utf8\")); return v && typeof v === \"object\" && !Array.isArray(v) ? v : {} } catch { return {} } }\nconst write = (file, value) => {\n fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })\n const tmp = `${file}.orca-${process.pid}`\n fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + \"\\n\", { mode: 0o600 })\n fs.renameSync(tmp, file)\n}\nconst claudeFile = path.join(process.env.CLAUDE_CONFIG_DIR || home, \".claude.json\")\nconst c = readJson(claudeFile)\nconst before = JSON.stringify(c)\nc.hasCompletedOnboarding = true\nc.theme = c.theme || \"dark\"\nc.bypassPermissionsModeAccepted = true\nc.projects = c.projects && typeof c.projects === \"object\" ? c.projects : {}\nconst p = process.env.ORCA_PROJECT\nc.projects[p] = { ...(c.projects[p] || {}), hasTrustDialogAccepted: true, hasCompletedProjectOnboarding: true }\nconst tail = process.env.ORCA_KEY_TAIL\nif (tail) {\n const r = c.customApiKeyResponses && typeof c.customApiKeyResponses === \"object\" ? c.customApiKeyResponses : {}\n const approved = Array.isArray(r.approved) ? r.approved : []\n const rejected = Array.isArray(r.rejected) ? r.rejected.filter((t) => t !== tail) : []\n c.customApiKeyResponses = { ...r, approved: approved.includes(tail) ? approved : [...approved, tail], rejected }\n}\nif (JSON.stringify(c) !== before) write(claudeFile, c)\nconst settingsFile = path.join(home, \".claude\", \"settings.json\")\nconst s = readJson(settingsFile)\nif (s.skipDangerousModePermissionPrompt !== true) write(settingsFile, { ...s, skipDangerousModePermissionPrompt: true })\n\n// Each block below only fills in what is unset and leaves a file it cannot parse alone.\nconst readStrict = (file) => { try { return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, \"utf8\")) : {} } catch { return null } }\nconst isObject = (v) => v !== null && typeof v === \"object\" && !Array.isArray(v)\nconst section = (o, key) => (isObject(o[key]) ? o[key] : (o[key] = {}))\n\n// Gemini CLI: folder trust and the auth-method picker (only when a Gemini key is configured; a\n// Google sign-in picks its own method), no self-update, no usage stats (its host is not on the\n// egress allowlist, so every send would fail).\nconst geminiTrustFile = path.join(home, \".gemini\", \"trustedFolders.json\")\nconst geminiTrust = readStrict(geminiTrustFile)\nif (isObject(geminiTrust) && !geminiTrust[p]) write(geminiTrustFile, { ...geminiTrust, [p]: \"TRUST_FOLDER\" })\nconst geminiSettingsFile = path.join(home, \".gemini\", \"settings.json\")\nconst g = readStrict(geminiSettingsFile)\nif (isObject(g)) {\n const gBefore = JSON.stringify(g)\n const general = section(g, \"general\")\n general.disableAutoUpdate ??= true\n general.disableUpdateNag ??= true\n section(g, \"privacy\").usageStatisticsEnabled ??= false\n // gemini-api-key or vertex-ai (GOOGLE_GENAI_USE_VERTEXAI=true with an API key): headless runs\n // refuse to start without a method (\"Invalid auth method selected\").\n if (process.env.ORCA_GEMINI_KEY) section(section(g, \"security\"), \"auth\").selectedType ??= process.env.ORCA_GEMINI_KEY\n if (JSON.stringify(g) !== gBefore) write(geminiSettingsFile, g)\n}\n\n// Cursor agent: trust the workspace (cursor-agent keys trust by a slug of the path) and pin the\n// release channel to \"static\", which turns off its background self-update.\nconst cursorTrust = path.join(home, \".cursor\", \"projects\", p.replace(/^\\/+/, \"\").replace(/[^A-Za-z0-9]+/g, \"-\"), \".workspace-trusted\")\nif (!fs.existsSync(cursorTrust)) {\n fs.mkdirSync(path.dirname(cursorTrust), { recursive: true, mode: 0o700 })\n fs.writeFileSync(cursorTrust, JSON.stringify({ trustedAt: new Date().toISOString(), workspacePath: p }, null, 2) + \"\\n\", { mode: 0o600 })\n}\nconst cursorConfigFile = path.join(home, \".cursor\", \"cli-config.json\")\nconst cc = readStrict(cursorConfigFile)\nif (isObject(cc) && cc.channel === undefined) write(cursorConfigFile, { version: 1, ...cc, channel: \"static\" })\n\n// GitHub Copilot CLI: trusted folders live in ~/.copilot/config.json, which Copilot prefixes with\n// \"//\" comment lines; keep them.\nconst copilotFile = path.join(home, \".copilot\", \"config.json\")\nlet copilotHead = \"\"\nlet co = {}\ntry {\n const raw = fs.existsSync(copilotFile) ? fs.readFileSync(copilotFile, \"utf8\") : \"\"\n const lines = raw.split(\"\\n\")\n let i = 0\n while (i < lines.length && /^\\s*\\/\\//.test(lines[i])) i++\n copilotHead = lines.slice(0, i).map((l) => l + \"\\n\").join(\"\")\n const body = lines.slice(i).join(\"\\n\").trim()\n co = body ? JSON.parse(body) : {}\n} catch { co = null }\nif (isObject(co)) {\n const folders = Array.isArray(co.trustedFolders) ? co.trustedFolders : []\n if (!folders.includes(p)) {\n fs.mkdirSync(path.dirname(copilotFile), { recursive: true, mode: 0o700 })\n const tmp = `${copilotFile}.orca-${process.pid}`\n fs.writeFileSync(tmp, copilotHead + JSON.stringify({ ...co, trustedFolders: [...folders, p] }, null, 2) + \"\\n\", { mode: 0o600 })\n fs.renameSync(tmp, copilotFile)\n }\n}\n\n// OpenCode: with the AI Gateway preset and a model secret (ANTHROPIC_MODEL, else OPENAI_MODEL), use\n// that model through the gateway unless the user picked one. A ledger remembers what Orca wrote, so a\n// changed model secret updates it and a model the user set is never touched.\nconst ocDir = path.join(home, \".config\", \"opencode\")\nconst ocFile = path.join(ocDir, \"opencode.json\")\nconst ocLedger = path.join(ocDir, \".orca-managed-model\")\nconst managedModel = process.env.ORCA_GATEWAY_MODEL ? `vercel/${process.env.ORCA_GATEWAY_MODEL}` : \"\"\nconst oc = readStrict(ocFile)\nlet jsoncHasModel = false\ntry { jsoncHasModel = /\"model\"\\s*:/.test(fs.readFileSync(path.join(ocDir, \"opencode.jsonc\"), \"utf8\")) } catch {}\nif (isObject(oc) && !jsoncHasModel) {\n let ours = \"\"\n try { ours = fs.readFileSync(ocLedger, \"utf8\").trim() } catch {}\n const userModel = typeof oc.model === \"string\" && oc.model !== ours\n if (!userModel && (oc.model ?? \"\") !== managedModel) {\n const next = { $schema: \"https://opencode.ai/config.json\", ...oc }\n if (managedModel) next.model = managedModel\n else delete next.model\n write(ocFile, next)\n }\n if (!userModel) {\n if (managedModel) fs.writeFileSync(ocLedger, managedModel + \"\\n\", { mode: 0o600 })\n else fs.rmSync(ocLedger, { force: true })\n }\n}\n\n// OpenCode: two built-in providers cannot work in Orca Cloud, and with no other credential OpenCode\n// picks one and fails (\"Authorization header is badly formatted\" / opencode.ai unreachable) instead\n// of showing \"Connect a provider\". GitHub Copilot reads GITHUB_TOKEN, which here is the stand-in for the\n// GitHub App token of the workspace, never a Copilot token; OpenCode Zen needs opencode.ai, which is not on the\n// egress allowlist. Both stay off unless the user connected them in OpenCode itself (auth.json).\nconst ocAuth = readStrict(path.join(process.env.XDG_DATA_HOME || path.join(home, \".local\", \"share\"), \"opencode\", \"auth.json\"))\nconst connected = new Set(isObject(ocAuth) ? Object.keys(ocAuth) : [])\nconst ocDisabledLedger = path.join(ocDir, \".orca-managed-disabled-providers\")\nconst ocNow = readStrict(ocFile)\nif (isObject(ocNow) && !/\"disabled_providers\"\\s*:/.test((() => { try { return fs.readFileSync(path.join(ocDir, \"opencode.jsonc\"), \"utf8\") } catch { return \"\" } })())) {\n let ours = []\n try { ours = fs.readFileSync(ocDisabledLedger, \"utf8\").split(\"\\n\").filter(Boolean) } catch {}\n const userList = (Array.isArray(ocNow.disabled_providers) ? ocNow.disabled_providers : []).filter((id) => typeof id === \"string\" && !ours.includes(id))\n const managed = [\"github-copilot\", \"opencode\"].filter((id) => !connected.has(id) && !userList.includes(id))\n const next = [...userList, ...managed]\n const before = JSON.stringify(ocNow.disabled_providers ?? [])\n if (JSON.stringify(next) !== before) {\n const updated = { $schema: \"https://opencode.ai/config.json\", ...ocNow }\n if (next.length > 0) updated.disabled_providers = next\n else delete updated.disabled_providers\n write(ocFile, updated)\n }\n if (managed.length > 0) fs.writeFileSync(ocDisabledLedger, managed.join(\"\\n\") + \"\\n\", { mode: 0o600 })\n else fs.rmSync(ocDisabledLedger, { force: true })\n}\n' || echo 'agent first-run state not seeded' >&2\nfi\nprintf '{\"names\":%d}\\n' \"$count\"\n","orca-session-env.sh":"# Orca Cloud: sourced by every new bash/zsh (via /etc/profile.d) so terminals and agents started\n# after a secret edit get the current values with no restart. The file is written by\n# /opt/orca/bin/orca-sandbox-env on tmpfs; names it no longer lists are unset. Cloud secrets win\n# over same-named values already in the environment.\n__orca_env_file=\"/dev/shm/orca-env-$(id -u 2>/dev/null)/session.env\"\nif [ -r \"$__orca_env_file\" ]; then\n # $(...) word-splits in both bash and zsh.\n for __orca_n in $(printf '%s' \"${ORCA_SESSION_ENV_NAMES-}\"); do\n unset \"$__orca_n\" 2>/dev/null || true\n done\n __orca_names=''\n while IFS= read -r __orca_line || [ -n \"$__orca_line\" ]; do\n __orca_n=\"${__orca_line%%=*}\"\n case \"$__orca_n\" in '' | *[!A-Z0-9_]* | [0-9]* | ORCA_* | PATH | HOME | LD_* | DYLD_*) continue ;; esac\n export \"$__orca_n=$(printf '%s' \"${__orca_line#*=}\" | base64 -d 2>/dev/null)\"\n __orca_names=\"$__orca_names $__orca_n\"\n done <\"$__orca_env_file\"\n export ORCA_SESSION_ENV_NAMES=\"${__orca_names# }\"\nfi\n# Default branch of the cloud workspace repo, once its first-boot clone exists (computed once per shell tree).\nif [ -n \"${ORCA_WORKSPACE_PATH-}\" ] && [ -z \"${ORCA_DEFAULT_BRANCH-}\" ]; then\n __orca_branch=\"$(git -C \"$ORCA_WORKSPACE_PATH\" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)\"\n if [ -n \"$__orca_branch\" ]; then\n export ORCA_DEFAULT_BRANCH=\"${__orca_branch#origin/}\" CONDUCTOR_DEFAULT_BRANCH=\"${__orca_branch#origin/}\"\n fi\nfi\n# CR-cmd-quiet: Orca's staged agent launch (` . '/tmp/orca-launch-….sh'`) stays out of zsh history.\n# zsh records a line before running it, so the hook must exist first; the staged script itself\n# drops the line from bash's history. eval: bash parses this file too.\nif [ -n \"${ZSH_VERSION-}\" ]; then\n eval '__orca_skip_launch_history() { [[ $1 == *orca-launch-* ]] && return 1; return 0 }\n (( ${zshaddhistory_functions[(Ie)__orca_skip_launch_history]} )) || zshaddhistory_functions+=(__orca_skip_launch_history)'\nfi\nunset __orca_env_file __orca_line __orca_n __orca_names __orca_branch\n","orca-env":"#!/usr/bin/env bash\n# Prints shell lines that bring the current shell up to date with this session's cloud env (secrets\n# saved after the shell started): eval \"$(orca-env)\" or, in fish: orca-env --fish | source\n# Reads /dev/shm/orca-env-/session.env (written by orca-sandbox-env, `NAME=` lines) and\n# prints an export for each name, plus an unset for each name in $ORCA_SESSION_ENV_NAMES that is gone.\n# Never writes a file. Values are single-quoted, so quotes, newlines and $ in them stay literal.\nset -euo pipefail\nfish=''\ncase \"${1-}\" in\n '') ;;\n --fish) fish=1 ;;\n -h | --help)\n printf 'usage: eval \"$(orca-env)\" (fish: orca-env --fish | source)\\n'\n exit 0\n ;;\n *)\n printf 'orca-env: unknown option %s\\n' \"$1\" >&2\n exit 2\n ;;\nesac\n# ORCA_SESSION_ENV_FILE is for tests; secrets can't set ORCA_* names.\nfile=\"${ORCA_SESSION_ENV_FILE:-/dev/shm/orca-env-$(id -u)/session.env}\"\nset -f # names are split below, never globbed\n# Not written yet (or not a cloud VM): nothing to change, like the shell hook.\n[ -r \"$file\" ] || exit 0\n\n# 'it'\\''s' for POSIX shells; fish single quotes escape only \\ and '.\nquote() {\n local v=\"$1\"\n if [ -n \"$fish\" ]; then\n v=\"${v//\\\\/\\\\\\\\}\"\n v=\"${v//\\'/\\\\\\'}\"\n printf \"'%s'\" \"$v\"\n else\n printf \"'%s'\" \"${v//\\'/\\'\\\\\\'\\'}\"\n fi\n}\n\n# The control plane's reserved names and prefixes (cloud/apps/sandbox-control/src/secrets.ts; a test\n# keeps this in sync): a session file never sets what would hijack the shell or Orca's own processes.\nskip_name() {\n case \"$1\" in\n '' | *[!A-Z0-9_]* | [0-9]*) return 0 ;;\n LD_* | DYLD_* | ORCA_* | VERCEL_* | BASH_FUNC_* | GIT_CONFIG*) return 0 ;;\n PATH | HOME | USER | LOGNAME | SHELL | PWD | OLDPWD | LANG | TERM | TMPDIR | IFS | ENV | \\\n BASH_ENV | PS4 | PROMPT_COMMAND | SHELLOPTS | BASHOPTS | NODE_OPTIONS | NODE_PATH | \\\n NODE_EXTRA_CA_CERTS | NODE_TLS_REJECT_UNAUTHORIZED | SSL_CERT_FILE | SSL_CERT_DIR | \\\n CURL_CA_BUNDLE | REQUESTS_CA_BUNDLE | HTTP_PROXY | HTTPS_PROXY | ALL_PROXY | NO_PROXY | \\\n PYTHONPATH | PYTHONSTARTUP | PERL5OPT | RUBYOPT | JAVA_TOOL_OPTIONS | GIT_SSH | \\\n GIT_SSH_COMMAND | GIT_ASKPASS | GIT_EXEC_PATH | GIT_PROXY_COMMAND | SSH_AUTH_SOCK | \\\n GH_TOKEN | GITHUB_TOKEN) return 0 ;;\n # CLAUDE_CODE_OAUTH_TOKEN is reserved for users but set by the control plane itself: only ever the\n # Orca-account placeholder (contract D10), never a stored value.\n esac\n return 1\n}\n\nnames=' '\nwhile IFS= read -r line || [ -n \"$line\" ]; do\n name=\"${line%%=*}\"\n skip_name \"$name\" && continue\n # $(...) drops trailing newlines; the x keeps them.\n value=\"$(printf '%s' \"${line#*=}\" | base64 -d 2>/dev/null; printf x)\"\n value=\"${value%x}\"\n if [ -n \"$fish\" ]; then\n printf 'set -gx %s %s\\n' \"$name\" \"$(quote \"$value\")\"\n else\n printf 'export %s=%s\\n' \"$name\" \"$(quote \"$value\")\"\n fi\n names=\"$names$name \"\ndone <\"$file\"\nfor name in ${ORCA_SESSION_ENV_NAMES-}; do\n skip_name \"$name\" && continue\n case \"$names\" in *\" $name \"*) continue ;; esac\n if [ -n \"$fish\" ]; then\n printf 'set -e %s\\n' \"$name\"\n else\n printf 'unset %s\\n' \"$name\"\n fi\ndone\nnames=\"${names# }\"\nnames=\"${names% }\"\nif [ -n \"$fish\" ]; then\n printf 'set -gx ORCA_SESSION_ENV_NAMES %s\\n' \"$(quote \"$names\")\"\nelse\n printf 'export ORCA_SESSION_ENV_NAMES=%s\\n' \"$(quote \"$names\")\"\nfi\n","orca-egress-probe":"#!/usr/bin/env bash\n# Bounded Node fetch against each credential host. Vercel transform rules have made undici hang on\n# response bodies (no content-length / transfer-encoding); report it instead of letting agents hang.\n# Prints one JSON line: {\"host\":\"api.github.com\",\"status\":\"ok\"|\"egress_framing_hang\"|\"error\"}.\nset -euo pipefail\nnode=\"$(find /opt/orca/runtime/runtimes -type f -name node | head -n 1)\"\nfor host in \"$@\"; do\n \"$node\" -e '\n const host = process.argv[1]\n const timer = setTimeout(() => { console.log(JSON.stringify({ host, status: \"egress_framing_hang\" })); process.exit(0) }, 8000)\n fetch(`https://${host}/`, { headers: { authorization: process.env.ORCA_PROBE_AUTHORIZATION ?? \"\" } })\n .then((r) => r.arrayBuffer())\n .then(() => { clearTimeout(timer); console.log(JSON.stringify({ host, status: \"ok\" })) })\n .catch(() => { clearTimeout(timer); console.log(JSON.stringify({ host, status: \"error\" })) })\n ' \"$host\"\ndone\n","orca-image-wipe":"#!/usr/bin/env bash\n# Runs last before the golden snapshot. Every sandbox cloned from the image must start without an\n# orcad identity, device registry, agent sign-in or shell history (clone-safe identity, risk #1).\nset -euo pipefail\npkill -f orcad-server.js 2>/dev/null || true\nfor path in \\\n \"$HOME/.orcad-data\" \"$HOME/.orca-sandbox\" \"$HOME/.orca-desktop\" \"$HOME/.config/orca\" \"$HOME/.config/orca-dev\" \\\n \"$HOME/.claude\" \"$HOME/.claude.json\" \"$HOME/.codex\" \"$HOME/.config/gh\" \"$HOME/.gemini\" \\\n \"$HOME/.local/share/opencode\" \"$HOME/.config/opencode\" \"$HOME/.cursor\" \"$HOME/.config/cursor\" \\\n \"$HOME/.local/share/cursor-agent\" \"$HOME/.copilot\" \"$HOME/.npm\" \"$HOME/.cache\" \"$HOME/.config/google-chrome\" \"$HOME/.bash_history\" \\\n /vercel/sandbox/project; do\n rm -rf -- \"$path\"\ndone\nsudo rm -rf /tmp/* /var/tmp/* 2>/dev/null || true\nfor leftover in \"$HOME/.orcad-data\" \"$HOME/.claude\" \"$HOME/.codex\" \"$HOME/.config/gh\" \"$HOME/.cursor\" \"$HOME/.copilot\" \"$HOME/.gemini\"; do\n if [ -e \"$leftover\" ]; then\n echo \"identity survived wipe: $leftover\" >&2\n exit 1\n fi\ndone\necho '{\"wiped\":true}'\n","orca-agent-signout":"#!/usr/bin/env bash\n# Signs every agent out on this sandbox before Orca deletes it (A9): each agent's own logout, then\n# its credential files. Covers logins made inside the sandbox (the kill-switch fallback path).\n# Never fails: the sandbox is deleted next whatever happens. Values are never printed.\nset -u\nHOME=\"${HOME:-$(getent passwd \"$(id -u)\" | cut -d: -f6)}\"\nexport PATH=\"/opt/orca/tools/bin:$HOME/.local/bin:$HOME/.npm-global/bin:/usr/local/bin:/usr/bin:/bin:/opt/orca/bin:${PATH:-}\"\nclaude_dir=\"${CLAUDE_CONFIG_DIR:-$HOME/.claude}\"\nclaude_json=\"${CLAUDE_CONFIG_DIR:-$HOME}/.claude.json\"\ncodex_dir=\"${CODEX_HOME:-$HOME/.codex}\"\n\nlogged_out=0\n# Each logout gets a few seconds; a hung CLI must not hold up the delete.\nif command -v claude >/dev/null 2>&1 && timeout 8 claude auth logout /dev/null 2>&1; then\n logged_out=$((logged_out + 1))\nfi\nif command -v codex >/dev/null 2>&1 && timeout 8 codex logout /dev/null 2>&1; then\n logged_out=$((logged_out + 1))\nfi\n\n# The files go whether or not a logout ran: an older CLI, a missing binary or no network leaves them.\nrm -f -- \"$claude_dir\"/.credentials* \"$HOME/.claude\"/.credentials* \"$codex_dir/auth.json\" \"$HOME/.codex/auth.json\" 2>/dev/null\n# Orca-managed Codex homes keep their own auth.json under orcad's data dir.\nif [ -d \"$HOME/.orcad-data\" ]; then\n find \"$HOME/.orcad-data\" -path '*codex*' -type f -name auth.json -delete 2>/dev/null\nfi\n# ~/.claude.json keeps settings Orca wrote (onboarding, trust); only the sign-in fields go.\nnode_bin=\"$(command -v node || find /opt/orca/runtime/runtimes -type f -name node 2>/dev/null | head -n 1)\"\nfor file in \"$claude_json\" \"$HOME/.claude.json\"; do\n [ -f \"$file\" ] || continue\n if [ -n \"$node_bin\" ]; then\n ORCA_FILE=\"$file\" \"$node_bin\" -e '\nconst fs = require(\"fs\")\nconst file = process.env.ORCA_FILE\nlet c\ntry { c = JSON.parse(fs.readFileSync(file, \"utf8\")) } catch { process.exit(2) }\nif (!c || typeof c !== \"object\" || Array.isArray(c)) process.exit(2)\nlet changed = false\nfor (const key of Object.keys(c)) {\n if (/oauth/i.test(key) || key === \"primaryApiKey\") { delete c[key]; changed = true }\n}\nif (changed) {\n const tmp = `${file}.orca-${process.pid}`\n fs.writeFileSync(tmp, JSON.stringify(c, null, 2) + \"\\n\", { mode: 0o600 })\n fs.renameSync(tmp, file)\n}\n' 2>/dev/null || rm -f -- \"$file\"\n else\n # No node to edit it safely: losing Orca's settings beats leaving a sign-in behind.\n rm -f -- \"$file\"\n fi\ndone\n\nleft=0\nfor file in \"$claude_dir\"/.credentials* \"$HOME/.claude\"/.credentials* \"$codex_dir/auth.json\" \"$HOME/.codex/auth.json\"; do\n [ -e \"$file\" ] && left=$((left + 1))\ndone\nprintf '{\"loggedOut\":%d,\"left\":%d}\\n' \"$logged_out\" \"$left\"\n","orca-share-proxy":"#!/usr/bin/env node\n// Share-link proxy for this VM. Listens on 0.0.0.0:6769 (its public *.vercel.run route) and only\n// serves requests the control plane's share gateway signed with this session's key:\n// x-orca-share: v2.....\n// A request must start before `expiresAt` (60s windows); its response or WebSocket is closed at\n// `until` (the link's expiry, at most 30 minutes out), so a revoked or expired link also loses open\n// streams. Signed requests are reverse-proxied to 127.0.0.1:: streaming both ways, no\n// buffering, WebSocket upgrades piped raw. Anything unsigned gets a bare 404.\n// The key arrives on stdin (orca-sandbox-start pipes it), so it is never in this process's\n// environment or argv; ORCA_SHARE_PROXY_KEY in the env is accepted for tests. Optional\n// ORCA_SHARE_PROXY_PORT (default 6769). Plain Node, no dependencies.\n// getBuiltinModule (Node >= 22.3) works whether node loads this extensionless file as CommonJS\n// (in the VM) or as an ES module (under this repo's type:module package, in tests).\nconst crypto = process.getBuiltinModule('node:crypto')\nconst fs = process.getBuiltinModule('node:fs')\nconst http = process.getBuiltinModule('node:http')\nconst net = process.getBuiltinModule('node:net')\n\nconst LISTEN_PORT = Number(process.env.ORCA_SHARE_PROXY_PORT || 6769)\nconst KEY = Buffer.from((process.env.ORCA_SHARE_PROXY_KEY || fs.readFileSync(0, 'utf8')).trim(), 'base64url')\ndelete process.env.ORCA_SHARE_PROXY_KEY\n// Never proxy to the VM's sshd, orcad or this proxy itself.\n// 6080: the remote desktop (VNC behind a password) is for its owner's forward only.\nconst BLOCKED_PORTS = new Set([2222, 6080, 6768, LISTEN_PORT])\nconst MAX_SKEW_SECONDS = 300\nconst MAX_CONNECTION_SECONDS = 3600\nconst HOP_BY_HOP = ['connection', 'keep-alive', 'proxy-connection', 'transfer-encoding', 'te', 'trailer', 'upgrade']\n\nif (KEY.length < 32) {\n console.error('orca-share-proxy: share proxy key missing or short')\n process.exit(2)\n}\n\n/** The target port and close-by time (ms) for a validly signed request, else null. */\nfunction verify(header, nowMs) {\n if (typeof header !== 'string') {\n return null\n }\n const match = /^v2\\.(\\d{1,5})\\.(\\d{1,12})\\.(\\d{1,12})\\.([0-9a-f]{32})\\.([A-Za-z0-9_-]{43})$/.exec(header)\n if (!match) {\n return null\n }\n const now = Math.floor(nowMs / 1000)\n const port = Number(match[1])\n const expiresAt = Number(match[2])\n const until = Number(match[3])\n if (port < 1 || port > 65535 || BLOCKED_PORTS.has(port)) {\n return null\n }\n if (expiresAt < now || expiresAt > now + MAX_SKEW_SECONDS || until <= now || until > now + MAX_CONNECTION_SECONDS) {\n return null\n }\n const signed = header.slice(0, header.lastIndexOf('.'))\n const expected = crypto.createHmac('sha256', KEY).update(signed).digest()\n const given = Buffer.from(match[5], 'base64url')\n return given.length === expected.length && crypto.timingSafeEqual(given, expected) ? { port, untilMs: until * 1000 } : null\n}\n\nfunction upstreamHeaders(raw, port) {\n const headers = { ...raw }\n const connectionTokens = String(raw.connection || '')\n .split(',')\n .map((token) => token.trim().toLowerCase())\n for (const name of [...HOP_BY_HOP, ...connectionTokens, 'x-orca-share']) {\n delete headers[name]\n }\n // Dev servers (Vite, Next) allow localhost hosts by default; the public host rides x-forwarded-host.\n headers.host = `localhost:${port}`\n return headers\n}\n\nfunction refuse(res) {\n res.writeHead(404, { 'content-type': 'text/plain', 'cache-control': 'no-store' })\n res.end('not found')\n}\n\n/** Ends `close` at the grant's deadline; the timer goes away with the connection. */\nfunction deadline(untilMs, close, ...emitters) {\n const timer = setTimeout(close, Math.max(0, untilMs - Date.now()))\n for (const emitter of emitters) {\n emitter.once('close', () => clearTimeout(timer))\n }\n}\n\nconst server = http.createServer((req, res) => {\n const grant = verify(req.headers['x-orca-share'], Date.now())\n if (grant === null) {\n return refuse(res)\n }\n const { port } = grant\n const upstream = http.request(\n { host: '127.0.0.1', port, method: req.method, path: req.url, headers: upstreamHeaders(req.headers, port) },\n (answer) => {\n const headers = { ...answer.headers }\n for (const name of HOP_BY_HOP) {\n delete headers[name]\n }\n // Marks the answer as the app's, so the gateway can tell it from a route with no proxy behind it.\n headers['x-orca-share-proxy'] = '1'\n res.writeHead(answer.statusCode || 502, answer.statusMessage, headers)\n answer.pipe(res)\n }\n )\n deadline(grant.untilMs, () => {\n upstream.destroy()\n res.destroy()\n }, res)\n upstream.on('error', (error) => {\n if (res.headersSent) {\n res.destroy()\n return\n }\n // The gateway renders its own page for this; the code tells it why. Status 200 on purpose: a 5xx\n // from the VM reaches a Worker as Vercel's own 502 with our headers gone (found live).\n res.writeHead(200, { 'content-type': 'text/plain', 'x-orca-share-proxy': '1', 'x-orca-share-error': error.code === 'ECONNREFUSED' ? 'no_listener' : 'upstream_failed' })\n res.end('upstream unavailable')\n })\n req.on('aborted', () => upstream.destroy())\n req.pipe(upstream)\n})\n\nserver.on('upgrade', (req, socket, head) => {\n const grant = verify(req.headers['x-orca-share'], Date.now())\n if (grant === null) {\n socket.end('HTTP/1.1 404 Not Found\\r\\nconnection: close\\r\\ncontent-length: 0\\r\\n\\r\\n')\n return\n }\n const { port } = grant\n const headers = upstreamHeaders(req.headers, port)\n const target = net.connect(port, '127.0.0.1', () => {\n const lines = [`${req.method} ${req.url} HTTP/1.1`, `connection: upgrade`, `upgrade: ${req.headers.upgrade || 'websocket'}`]\n for (const [name, value] of Object.entries(headers)) {\n for (const item of Array.isArray(value) ? value : [value]) {\n lines.push(`${name}: ${item}`)\n }\n }\n target.write(`${lines.join('\\r\\n')}\\r\\n\\r\\n`)\n if (head && head.length > 0) {\n target.write(head)\n }\n socket.pipe(target)\n target.pipe(socket)\n })\n const close = () => {\n socket.destroy()\n target.destroy()\n }\n deadline(grant.untilMs, close, socket)\n target.on('error', () => {\n if (socket.writable) {\n socket.end('HTTP/1.1 200 OK\\r\\nx-orca-share-proxy: 1\\r\\nx-orca-share-error: no_listener\\r\\nconnection: close\\r\\ncontent-length: 0\\r\\n\\r\\n')\n }\n close()\n })\n socket.on('error', close)\n target.on('close', () => socket.destroy())\n socket.on('close', () => target.destroy())\n})\n\nserver.keepAliveTimeout = 65_000\nserver.headersTimeout = 70_000\n// No request timeout: previews can stream (SSE, long polls) until the grant's deadline.\nserver.requestTimeout = 0\nserver.listen(LISTEN_PORT, '0.0.0.0', () => {\n console.log(`orca-share-proxy listening on ${LISTEN_PORT}`)\n})\n","orca-sshd":"#!/usr/bin/env bash\n# Starts this boot's unprivileged sshd on 127.0.0.1:2222 (idempotent). Nothing outside the VM can\n# reach that port: the desktop tunnels to it over orcad's runtime connection (`orca ssh-proxy`).\n# Key-only, single user (the sandbox user), no root. Its host key and authorized_keys live under\n# ~/.orca-sandbox/ssh on the persistent disk, so the host key stays the same across stop/resume;\n# orca-image-wipe removes that directory, so every VM cloned from the golden image makes its own.\n# Prints {\"port\":2222,\"user\":\"\",\"hostKey\":\"ssh-ed25519 AAAA...\"} on success.\nset -euo pipefail\numask 077\nOPENSSH=/opt/orca/openssh\nSSH_DIR=\"$HOME/.orca-sandbox/ssh\"\nPORT=2222\nmkdir -p \"$SSH_DIR\"\nchmod 0700 \"$HOME/.orca-sandbox\" \"$SSH_DIR\"\nexec 8>\"$SSH_DIR/sshd.lock\"\nflock -w 30 8\n\nif [ ! -x \"$OPENSSH/sshd\" ]; then\n echo 'sshd is not installed in this image' >&2\n exit 3\nfi\nif [ ! -s \"$SSH_DIR/ssh_host_ed25519_key\" ]; then\n rm -f \"$SSH_DIR/ssh_host_ed25519_key\" \"$SSH_DIR/ssh_host_ed25519_key.pub\"\n ssh-keygen -q -t ed25519 -N '' -C \"orca-cloud-host\" -f \"$SSH_DIR/ssh_host_ed25519_key\"\nfi\ntouch \"$SSH_DIR/authorized_keys\"\nchmod 0600 \"$SSH_DIR/authorized_keys\"\n\n# Rewritten every start so an image update's settings apply to existing workspaces. PAM stays at\n# its compiled-in default (off): an unprivileged sshd cannot use it, and this build warns on `no`.\ncat >\"$SSH_DIR/sshd_config\" </dev/null || echo 0)\"\n# Resume reboots from disk, so a recorded pid can name a dead process or an unrelated one.\nif ! { kill -0 \"$pid\" 2>/dev/null && grep -q sshd \"/proc/$pid/cmdline\" 2>/dev/null; }; then\n rm -f \"$SSH_DIR/sshd.pid\"\n : >\"$SSH_DIR/sshd.log\"\n \"$OPENSSH/sshd\" -t -f \"$SSH_DIR/sshd_config\"\n \"$OPENSSH/sshd\" -f \"$SSH_DIR/sshd_config\" -E \"$SSH_DIR/sshd.log\"\n for _ in $(seq 1 100); do\n [ -s \"$SSH_DIR/sshd.pid\" ] && break\n sleep 0.02\n done\n [ -s \"$SSH_DIR/sshd.pid\" ] || { echo 'sshd did not start' >&2; tail -n 20 \"$SSH_DIR/sshd.log\" >&2; exit 1; }\nfi\n\nhost_key=\"$(cut -d' ' -f1-2 \"$SSH_DIR/ssh_host_ed25519_key.pub\")\"\nprintf '{\"port\":%s,\"user\":\"%s\",\"hostKey\":\"%s\"}\\n' \"$PORT\" \"$(id -un)\" \"$host_key\"\n","orca-ssh-keys":"#!/usr/bin/env bash\n# `orca-ssh-keys add|remove`: adds or removes one desktop's key in the Orca-managed authorized_keys\n# of this VM's sshd, then makes sure sshd runs. The control plane passes the key in\n# ORCA_SSH_PUBLIC_KEY (`ssh-ed25519 `), never on the command line. Keys are\n# matched by their base64 blob, so a renamed comment cannot keep a revoked key alive.\n# Prints orca-sshd's {\"port\":...,\"user\":...,\"hostKey\":...} line on success.\nset -euo pipefail\numask 077\nSSH_DIR=\"$HOME/.orca-sandbox/ssh\"\naction=\"${1:-}\"\nkey=\"${ORCA_SSH_PUBLIC_KEY:-}\"\n# grep matches per line: a second line could carry key options past the check below.\ncase \"$key\" in *$'\\n'* | *$'\\r'*) echo 'invalid public key' >&2; exit 2 ;; esac\n# One line, ed25519 only, a plain comment: nothing that sshd would read as key options.\nif ! printf '%s' \"$key\" | grep -Eq '^ssh-ed25519 [A-Za-z0-9+/]{68} [A-Za-z0-9@._:+-]{1,200}$'; then\n echo 'invalid public key' >&2\n exit 2\nfi\nblob=\"$(printf '%s' \"$key\" | cut -d' ' -f2)\"\nmkdir -p \"$SSH_DIR\"\nchmod 0700 \"$HOME/.orca-sandbox\" \"$SSH_DIR\"\nexec 7>\"$SSH_DIR/keys.lock\"\nflock -w 30 7\ntouch \"$SSH_DIR/authorized_keys\"\nnext=\"$(mktemp \"$SSH_DIR/authorized_keys.XXXXXX\")\"\n# Drop the key wherever it appears (with or without options), then append it on add.\nawk -v blob=\"$blob\" '{ for (i = 1; i <= NF; i++) if ($i == blob) next; print }' \\\n \"$SSH_DIR/authorized_keys\" >\"$next\"\ncase \"$action\" in\n add) printf '%s\\n' \"$key\" >>\"$next\" ;;\n remove) ;;\n *) rm -f \"$next\"; echo 'usage: orca-ssh-keys add|remove' >&2; exit 2 ;;\nesac\nchmod 0600 \"$next\"\nmv -f \"$next\" \"$SSH_DIR/authorized_keys\"\nexec /opt/orca/bin/orca-sshd\n","orca-desktop-start":"#!/usr/bin/env bash\n# Starts the on-demand remote desktop (idempotent) and mints one connection ticket. Prints one line:\n# {\"schemaVersion\":2,\"port\":6080,\"display\":\":1\",\"path\":\"vnc.html\",\"ticket\":\"<43 chars>\",\"expiresInMs\":60000}\n# Xvnc (X server + VNC in one) listens on a 0600 unix socket only, with no VNC auth: only this uid\n# can open it. orca-desktop-proxy (websockify + a ticket gate) serves noVNC on 127.0.0.1 and\n# bridges WebSockets to that socket. Nothing is reachable from outside the VM: the desktop app\n# reaches it through Orca's port forward, a localhost port on the user's computer that any local\n# process or web page can reach, so each WebSocket needs a ticket from here: 32 random bytes,\n# single use, valid for 60 s, stored only as its sha256 (the file name; its content is the expiry).\n# The ticket goes to stdout only (the control plane hands it to the desktop app). Never started at boot.\nset -euo pipefail\numask 077\nDESKTOP=/opt/orca/desktop\nSTATE=\"$HOME/.orca-desktop\"\nPORT=\"${ORCA_DESKTOP_PORT:-6080}\"\nDISPLAY_NUM=\"${ORCA_DESKTOP_DISPLAY:-1}\"\nGEOMETRY=\"${ORCA_DESKTOP_GEOMETRY:-1280x800}\"\nSOCKET=\"$STATE/vnc.sock\"\n# The parent's DISPLAY must not leak into the WM/xterm below; they use ours.\nexport DISPLAY=\":$DISPLAY_NUM\"\n\n# Exit 3 = this image has no desktop (built before it, or its optional install failed).\nfail() { printf 'orca-desktop: %s\\n' \"$1\" >&2; exit \"${2:-1}\"; }\ncase \"$PORT\" in '' | *[!0-9]*) fail 'ORCA_DESKTOP_PORT must be a number' ;; esac\ncase \"$DISPLAY_NUM\" in '' | *[!0-9]*) fail 'ORCA_DESKTOP_DISPLAY must be a number' ;; esac\ncase \"$GEOMETRY\" in [0-9]*x[0-9]*) ;; *) fail 'ORCA_DESKTOP_GEOMETRY must look like 1280x800' ;; esac\ncommand -v Xvnc >/dev/null || fail 'not installed in this image (Xvnc missing)' 3\n[ -s \"$DESKTOP/novnc/vnc.html\" ] || fail 'not installed in this image (noVNC missing)' 3\nPROXY=\"$(dirname \"$(readlink -f \"$0\")\")/orca-desktop-proxy\"\n[ -x \"$PROXY\" ] || fail 'orca-desktop-proxy missing next to this script'\nTICKETS=\"$STATE/tickets\"\nTICKET_TTL_S=60\n# Xvnc's mode: a previous version of this script ran it with VncAuth; restart it in this mode.\nMODE=ticket-v1\n\nmkdir -p \"$STATE\"\nexec 9>\"$STATE/start.lock\"\nflock -w 30 9 || fail 'another start is in progress'\n\npid_of() { # never \"0\": kill 0 targets our own process group\n local pid\n pid=\"$(cat \"$STATE/$1.pid\" 2>/dev/null || true)\"\n case \"$pid\" in '' | 0 | *[!0-9]*) return 1 ;; esac\n printf '%s' \"$pid\"\n}\nalive() { local pid; pid=\"$(pid_of \"$1\")\" && kill -0 \"$pid\" 2>/dev/null; }\nmint_ticket() { # prints the ticket; stores only its sha256, with the expiry as content\n local ticket now digest\n mkdir -p \"$TICKETS\"\n now=\"$(date +%s)\"\n # Expired or spent-but-unswept tickets; keep at most 16 live ones (oldest go first).\n for file in \"$TICKETS\"/*; do\n [ -f \"$file\" ] || continue\n [ \"$(cat \"$file\" 2>/dev/null || echo 0)\" -ge \"$now\" ] 2>/dev/null || rm -f \"$file\"\n done\n # shellcheck disable=SC2012 # names are hex digests\n ls -1t \"$TICKETS\" 2>/dev/null | tail -n +16 | while read -r old; do rm -f \"$TICKETS/$old\"; done\n ticket=\"$(head -c 32 /dev/urandom | base64 | tr '+/' '-_' | tr -d '=\\n')\"\n [ \"${#ticket}\" = 43 ] || fail 'could not generate a ticket'\n digest=\"$(printf '%s' \"$ticket\" | sha256sum | cut -d' ' -f1)\"\n printf '%s' \"$((now + TICKET_TTL_S))\" >\"$TICKETS/.$digest\" && mv \"$TICKETS/.$digest\" \"$TICKETS/$digest\"\n printf '%s' \"$ticket\"\n}\nready_line() {\n local ticket\n ticket=\"$(mint_ticket)\" || exit 1\n printf '{\"schemaVersion\":2,\"port\":%s,\"display\":\":%s\",\"path\":\"vnc.html\",\"ticket\":\"%s\",\"expiresInMs\":%s}\\n' \\\n \"$PORT\" \"$DISPLAY_NUM\" \"$ticket\" \"$((TICKET_TTL_S * 1000))\"\n}\nlistening() { # the proxy answers HTTP on loopback\n curl -fsS -o /dev/null --max-time 1 \"http://127.0.0.1:$PORT/vnc.html\" 2>/dev/null\n}\n\n# Pids from a previous boot (resume = reboot from disk) name dead or unrelated processes.\nboot_id=\"$(cat /proc/sys/kernel/random/boot_id)\"\nif [ \"$(cat \"$STATE/boot-id\" 2>/dev/null || true)\" != \"$boot_id\" ]; then\n rm -rf \"$STATE\"/*.pid \"$SOCKET\" \"$STATE/password\" \"$STATE/passwd\" \"$STATE/mode\" \"$TICKETS\"\n printf '%s' \"$boot_id\" >\"$STATE/boot-id\"\nfi\n\n# Same boot, older script: its websockify had no ticket gate and its Xvnc wanted a VNC password.\nrm -f \"$STATE/password\" \"$STATE/passwd\"\nif alive websockify; then kill \"$(pid_of websockify)\" 2>/dev/null || true; fi\nrm -f \"$STATE/websockify.pid\"\ncurrent_mode() { cat \"$STATE/mode\" 2>/dev/null || true; }\n\nif alive xvnc && alive proxy && listening && [ \"$(current_mode)\" = \"$MODE\" ]; then\n ready_line\n exit 0\nfi\n\nstart() { # name cmd... — detached, logs in $STATE/.log\n local name=$1; shift\n # 9>&-: children must not inherit the start lock, or the next call waits for them to exit.\n setsid nohup \"$@\" >\"$STATE/$name.log\" 2>&1 &- &\n echo $! >\"$STATE/$name.pid\"\n}\n\nif ! alive xvnc || [ \"$(current_mode)\" != \"$MODE\" ]; then\n if alive xvnc; then kill \"$(pid_of xvnc)\" 2>/dev/null || true; sleep 0.2; fi\n rm -f \"$SOCKET\"\n # -rfbport -1: no TCP VNC port at all, only a 0600 unix socket (in the 0700 state dir) for the\n # proxy. SecurityTypes None is safe only because of that: the ticket gate is in the proxy.\n # SendPrimary=0: only explicit copies (xterm copies its selection, below) leave the sandbox.\n # The X display itself takes a cookie from ~/.Xauthority (Xlib's default for this user), so only\n # this uid's processes (agents, apps they start on DISPLAY=:1) can open it.\n auth=()\n if command -v xauth >/dev/null && command -v mcookie >/dev/null; then\n xauth -q -f \"$HOME/.Xauthority\" add \":$DISPLAY_NUM\" . \"$(mcookie)\" 9>&- && auth=(-auth \"$HOME/.Xauthority\")\n fi\n start xvnc Xvnc \":$DISPLAY_NUM\" \"${auth[@]}\" -geometry \"$GEOMETRY\" -depth 24 -nolisten tcp \\\n -rfbport -1 -rfbunixpath \"$SOCKET\" -rfbunixmode 0600 \\\n -SecurityTypes None -SendPrimary=0 \\\n -AlwaysShared -AcceptSetDesktopSize -desktop 'Remote desktop'\n printf '%s' \"$MODE\" >\"$STATE/mode\"\n for _ in $(seq 1 100); do\n [ -S \"$SOCKET\" ] && break\n alive xvnc || { tail -n 20 \"$STATE/xvnc.log\" >&2; fail 'Xvnc exited'; }\n sleep 0.05\n done\n [ -S \"$SOCKET\" ] || fail 'Xvnc did not create its socket'\n if command -v xsetroot >/dev/null; then xsetroot -solid '#1e1f22' 9>&- || true; fi\n if command -v openbox >/dev/null; then\n # Dark, flat theme to match Orca; the first terminal stays maximized as the tab resizes the screen.\n printf '%s\\n' '' \\\n 'Onyx' \\\n 'yes' \\\n '' >\"$STATE/openbox.xml\"\n start wm openbox --config-file \"$STATE/openbox.xml\"\n # Let it manage the screen before the terminal maps, so its maximize rule applies.\n sleep 0.2\n elif command -v twm >/dev/null; then\n start wm twm\n fi\n if command -v xterm >/dev/null; then\n # selectToClipboard: a selection is a copy (to CLIPBOARD, which reaches this computer).\n start xterm xterm -maximized -bg '#1e1f22' -fg '#d4d4d4' -fa 'Liberation Mono,Monospace' -fs 11 \\\n -xrm 'XTerm*selectToClipboard: true'\n fi\nfi\n\nif ! alive proxy || ! listening; then\n if alive proxy; then kill \"$(pid_of proxy)\" 2>/dev/null || true; sleep 0.1; fi\n mkdir -p \"$TICKETS\"\n PYTHONPATH=\"$DESKTOP/websockify\" start proxy python3 -P -W ignore::UserWarning \"$PROXY\" \\\n --listen \"$PORT\" --web \"$DESKTOP/novnc\" --unix-target \"$SOCKET\" --tickets \"$TICKETS\"\n for _ in $(seq 1 100); do\n listening && break\n alive proxy || { tail -n 20 \"$STATE/proxy.log\" >&2; fail 'orca-desktop-proxy exited'; }\n sleep 0.05\n done\n listening || fail \"orca-desktop-proxy is not answering on 127.0.0.1:$PORT\"\nfi\nready_line\n","orca-desktop-proxy":"#!/usr/bin/env python3\n# Remote desktop gateway, started by orca-desktop-start: serves noVNC on 127.0.0.1 and bridges one\n# WebSocket per single-use ticket to Xvnc's 0600 unix socket (Xvnc itself has no VNC auth: only this\n# uid can open the socket). The port is reached only through Orca's localhost forward on the user's\n# computer, where any local process or web page could knock, so every WebSocket must carry an\n# unused, unexpired ticket minted by orca-desktop-start (see consume_ticket), from a page on the same\n# localhost origin. Tickets never reach a log: paths are scrubbed before websockify logs them.\n# orca-desktop-proxy --listen PORT --web DIR --unix-target SOCKET --tickets DIR\n# stdlib + websockify (pinned tarball in /opt/orca/desktop/websockify, on PYTHONPATH) only.\nimport argparse\nimport hashlib\nimport hmac\nimport logging\nimport os\nimport re\nimport sys\nimport time\nfrom urllib.parse import parse_qs, urlsplit\n\nfrom websockify import websocketproxy\n\nTICKET_RE = re.compile(r'^[A-Za-z0-9_-]{43}$') # 32 random bytes, base64url without padding\nSCRUB_RE = re.compile(r'([?&]ticket=)[^&\\s\\'\"]*')\nLOOPBACK_HOST_RE = re.compile(r'^(localhost|127\\.0\\.0\\.1|\\[::1\\])(:[0-9]{1,5})?$')\nWS_PATH = '/websockify'\nPAGE_SCRIPT = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'orca-desktop-page.js')\n# The page may only talk to its own origin (its WebSocket included): even a URL that tries to point\n# noVNC elsewhere cannot carry a ticket or the user's keys off this forward.\nPAGE_CSP = \"connect-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'\"\nPAGE_TAG = b'\\n'\n\n\ndef scrub(text):\n return SCRUB_RE.sub(r'\\1', str(text))\n\n\ndef ticket_digest(ticket):\n return hashlib.sha256(ticket.encode('ascii')).hexdigest()\n\n\ndef consume_ticket(directory, ticket, now=None):\n \"\"\"True once per minted ticket, within its lifetime. The ticket file (named by the ticket's\n sha256, holding its expiry) is unlinked on first use, so a replay or a racing second\n connection finds nothing; each request runs in its own forked process, so state is on disk.\"\"\"\n if not isinstance(ticket, str) or not TICKET_RE.match(ticket):\n return False\n digest = ticket_digest(ticket)\n match = None\n try:\n names = os.listdir(directory)\n except OSError:\n return False\n for name in names:\n # Compare every entry in constant time; never short-circuit on a prefix.\n if hmac.compare_digest(name.encode('ascii', 'replace'), digest.encode('ascii')):\n match = name\n if match is None:\n return False\n path = os.path.join(directory, match)\n try:\n with open(path, 'r', encoding='ascii') as handle:\n expires_at = int(handle.read().strip() or '0')\n except (OSError, ValueError):\n expires_at = 0\n try:\n os.unlink(path) # the consume: only one caller can win this\n except FileNotFoundError:\n return False\n return (now if now is not None else time.time()) <= expires_at\n\n\ndef same_loopback_origin(headers):\n \"\"\"The page and the WebSocket share one localhost origin (blocks other local sites and DNS\n rebinding). The port is the user's local forward, which may differ from ours, so compare the\n Origin with the Host the browser dialed rather than with a fixed port.\"\"\"\n host = (headers.get('Host') or '').strip().lower()\n origin = (headers.get('Origin') or '').strip().lower()\n return loopback_host(host) and origin == 'http://' + host\n\n\ndef loopback_host(host):\n return bool(LOOPBACK_HOST_RE.match(host or ''))\n\n\nclass TicketedProxyHandler(websocketproxy.ProxyRequestHandler):\n \"\"\"websockify's proxy handler plus the ticket + origin gate. Module level (not a closure) so\n websockify's per-connection multiprocessing can pickle it under any start method.\"\"\"\n\n def log_message(self, format, *args): # noqa: A002 (websockify's signature)\n super().log_message('%s', scrub(format % args if args else format))\n\n def reject(self, code, reason):\n self.send_response(code)\n self.send_header('Content-Length', '0')\n self.send_header('Connection', 'close')\n self.end_headers()\n self.close_connection = True\n raise self.server.EClose('desktop: websocket refused (%s)' % reason)\n\n def validate_connection(self):\n parts = urlsplit(self.path)\n ticket = (parse_qs(parts.query).get('ticket') or [''])[0]\n # Drop it before anything below (websockify's own logging included) can see self.path.\n self.path = parts.path\n if parts.path != WS_PATH:\n self.reject(404, 'path')\n if not same_loopback_origin(self.headers):\n self.reject(403, 'origin')\n if not consume_ticket(self.server.orca_tickets_dir, ticket):\n self.reject(403, 'ticket')\n\n def loopback_request(self):\n if loopback_host((self.headers.get('Host') or '').strip().lower()):\n return True\n self.send_error(403)\n return False\n\n def do_GET(self):\n if not self.loopback_request():\n return None\n path = urlsplit(self.path).path\n if path == '/orca-desktop-page.js':\n return self.send_bytes(read_file(PAGE_SCRIPT), 'text/javascript; charset=utf-8')\n if path == '/vnc.html':\n page = read_file(os.path.join(self.server.web, 'vnc.html'))\n return self.send_bytes(page.replace(b'', PAGE_TAG, 1), 'text/html; charset=utf-8', PAGE_CSP)\n return super().do_GET()\n\n def do_HEAD(self):\n if not self.loopback_request():\n return None\n return super().do_HEAD()\n\n def send_bytes(self, body, content_type, csp=None):\n self.send_response(200)\n self.send_header('Content-Type', content_type)\n if csp:\n self.send_header('Content-Security-Policy', csp)\n self.send_header('Content-Length', str(len(body)))\n self.send_header('Cache-Control', 'no-store')\n self.send_header('Referrer-Policy', 'no-referrer')\n self.end_headers()\n self.wfile.write(body)\n return None\n\n\ndef read_file(path):\n with open(path, 'rb') as handle:\n return handle.read()\n\n\ndef main(argv):\n parser = argparse.ArgumentParser()\n parser.add_argument('--listen', type=int, required=True)\n parser.add_argument('--web', required=True)\n parser.add_argument('--unix-target', required=True)\n parser.add_argument('--tickets', required=True)\n args = parser.parse_args(argv)\n logging.basicConfig(level=logging.INFO, format='%(message)s')\n server = websocketproxy.WebSocketProxy(\n RequestHandlerClass=TicketedProxyHandler,\n listen_host='127.0.0.1',\n listen_port=args.listen,\n web=args.web,\n file_only=True,\n unix_target=args.unix_target,\n )\n server.orca_tickets_dir = os.path.abspath(args.tickets)\n server.start_server()\n\n\nif __name__ == '__main__':\n main(sys.argv[1:])\n","orca-desktop-page.js":"// Orca's glue for noVNC, injected into vnc.html by orca-desktop-proxy. Orca (the desktop app's\n// main process) drives it through window.orcaDesktop:\n// - tickets: the page never connects by itself. Each WebSocket needs a fresh single-use ticket,\n// which Orca hands over with connect(ticket) after nextTicketRequest() resolves: 'ticket' on\n// load or after a session dropped, 'retry' after an attempt that never connected. Tickets live\n// only in memory here: never in the URL, storage or noVNC's settings.\n// - pause(message, label): Orca stopped retrying; show why, with a Reconnect button. resume()\n// (Orca reopened the desktop) or the button asks for a ticket again.\n// - fit: resize=remote makes Xvnc follow the tab; a server without SetDesktopSize gets scaling.\n// - clipboard: a copy inside the desktop right after user input there goes to this computer's\n// clipboard; paste(text, chord) sets the sandbox's clipboard first, then replays the paste key.\nimport UI from './app/ui.js';\nimport * as WebUtil from './app/webutil.js';\n\nconst WS_PATH = 'websockify';\nconst RESIZE_FALLBACK_MS = 1500;\n// A copy inside the desktop follows a copy key or a selection (drag or double-click) there; other\n// clipboard changes (an agent in the sandbox running xclip) must not overwrite this computer's\n// clipboard. Plain typing does not open the window.\nconst USER_COPY_WINDOW_MS = 1500;\nconst DRAG_PX = 4;\nconst MAX_CLIPBOARD_CHARS = 1 << 20;\nconst TICKET_RE = /^[A-Za-z0-9_-]{43}$/;\nconst XK = { v: 0x0076, V: 0x0056, Insert: 0xff63, Shift_L: 0xffe1, Super_L: 0xffeb, Super_R: 0xffec };\n\nlet state = 'idle'; // idle | connecting | connected | disconnected | paused\nlet attemptConnected = true;\nlet ticketWaiters = [];\nlet lastCopyIntent = 0;\nlet pointerStart = null;\n\nconst style = document.createElement('style');\n// Orca owns connecting and the chrome: noVNC's Connect button would only try without a ticket,\n// and its control bar duplicates what the Orca tab does (fit, clipboard, keyboard release).\n// The pause card follows Orca's theme: Orca sets Electron's theme source from its own setting, so\n// prefers-color-scheme here is Orca's light or dark, and it follows a switch while paused. The\n// page cannot read Orca's tokens, so these mirror them (popover, border, focus ring).\nstyle.textContent = `\n#noVNC_connect_dlg, #noVNC_control_bar_anchor { display: none !important; }\n#orca_pause { --orca-scrim: rgb(10 10 10 / 0.88); --orca-card: #171717; --orca-text: #f0f0f0;\n --orca-border: rgb(255 255 255 / 0.1); --orca-control: rgb(255 255 255 / 0.25);\n --orca-control-hover: rgb(255 255 255 / 0.08); --orca-focus: color-mix(in oklab, #f0f0f0 65%, #0a0a0a);\n color-scheme: dark; position: fixed; inset: 0; display: none; align-items: center;\n justify-content: center; padding: 16px; background: var(--orca-scrim); z-index: 1000;\n font: 13px/1.5 system-ui, sans-serif; }\n@media (prefers-color-scheme: light) {\n #orca_pause { --orca-scrim: rgb(255 255 255 / 0.88); --orca-card: #fff; --orca-text: #0a0a0a;\n --orca-border: #e5e5e5; --orca-control: #a3a3a3; --orca-control-hover: #f5f5f5;\n --orca-focus: color-mix(in oklab, #0a0a0a 55%, #fff); color-scheme: light; }\n}\n#orca_pause.open { display: flex; }\n#orca_pause > div { max-width: 360px; padding: 16px; border: 1px solid var(--orca-border);\n border-radius: 8px; background: var(--orca-card); color: var(--orca-text); text-align: center;\n box-shadow: 0 8px 24px rgb(0 0 0 / 0.16); }\n#orca_pause p { margin: 0; }\n#orca_pause button { margin-top: 12px; padding: 4px 12px; border: 1px solid var(--orca-control);\n border-radius: 6px; background: transparent; color: var(--orca-text); font: inherit; cursor: pointer; }\n#orca_pause button:hover { background: var(--orca-control-hover); }\n#orca_pause button:focus-visible { outline: 2px solid var(--orca-focus); outline-offset: 2px; }\n@media (prefers-reduced-motion: no-preference) {\n #orca_pause.open > div { animation: orca-pause-in 120ms ease-out; }\n}\n@keyframes orca-pause-in { from { opacity: 0; transform: translateY(4px); } }\n`;\ndocument.head.appendChild(style);\n\nconst overlay = document.createElement('div');\noverlay.id = 'orca_pause';\noverlay.innerHTML = '

';\noverlay.querySelector('button').addEventListener('click', () => resume());\ndocument.addEventListener('DOMContentLoaded', () => document.body.appendChild(overlay));\n\nwindow.addEventListener('keydown', (event) => {\n if ((event.ctrlKey || event.metaKey) && !event.altKey && (event.key === 'c' || event.key === 'C' || event.key === 'Insert')) {\n lastCopyIntent = Date.now();\n }\n}, true);\nwindow.addEventListener('pointerdown', (event) => {\n pointerStart = { x: event.clientX, y: event.clientY };\n}, true);\n// A double- or triple-click selects a word or line (pointer events carry no click count).\nwindow.addEventListener('mousedown', (event) => {\n if (event.detail >= 2) {\n lastCopyIntent = Date.now();\n }\n}, true);\nwindow.addEventListener('pointerup', (event) => {\n if (pointerStart && Math.hypot(event.clientX - pointerStart.x, event.clientY - pointerStart.y) >= DRAG_PX) {\n lastCopyIntent = Date.now();\n }\n pointerStart = null;\n}, true);\n\nfunction requestTicket() {\n const answer = attemptConnected ? 'ticket' : 'retry';\n const waiters = ticketWaiters;\n ticketWaiters = [];\n waiters.forEach((resolve) => resolve(answer));\n}\n\nasync function whenStarted() {\n while (document.documentElement.classList.contains('noVNC_loading')) {\n await new Promise((resolve) => setTimeout(resolve, 25));\n }\n}\n\nfunction onConnect() {\n state = 'connected';\n attemptConnected = true;\n UI.hideStatus();\n setTimeout(() => {\n // Xvnc answers SetDesktopSize; anything that does not gets scaled into the tab instead.\n if (UI.rfb && state === 'connected' && !UI.rfb._supportsSetDesktopSize) {\n UI.rfb.scaleViewport = true;\n }\n }, RESIZE_FALLBACK_MS);\n}\n\nfunction onDisconnect() {\n if (state === 'paused') {\n return;\n }\n state = 'disconnected';\n // The ticket is spent: ask Orca for a new one (a dropped forward, a restart of the desktop).\n requestTicket();\n}\n\nfunction onClipboard(event) {\n const text = event.detail && event.detail.text;\n if (typeof text !== 'string' || text.length > MAX_CLIPBOARD_CHARS) {\n return;\n }\n if (Date.now() - lastCopyIntent > USER_COPY_WINDOW_MS || !document.hasFocus()) {\n return;\n }\n navigator.clipboard.writeText(text).catch(() => {});\n}\n\nasync function connect(ticket) {\n if (typeof ticket !== 'string' || !TICKET_RE.test(ticket)) {\n return 'invalid';\n }\n if (state === 'connecting' || state === 'connected' || state === 'paused') {\n return state;\n }\n await whenStarted();\n if (UI.rfb) {\n return 'connecting';\n }\n state = 'connecting';\n attemptConnected = false;\n // UI.connect reads the path setting synchronously and opens the WebSocket in RFB's constructor;\n // the in-memory setting is restored right after, so the ticket is never kept or shown.\n // Only ever this page's own origin: noVNC also reads host, port, encrypt, repeater and logging\n // from the URL, which must never send a ticketed session (and the user's keys) elsewhere.\n WebUtil.setSetting('host', '');\n WebUtil.setSetting('port', '');\n WebUtil.setSetting('encrypt', false);\n WebUtil.setSetting('repeaterID', '');\n WebUtil.setSetting('path', `${WS_PATH}?ticket=${ticket}`);\n try {\n UI.connect();\n } finally {\n WebUtil.setSetting('path', WS_PATH);\n }\n const rfb = UI.rfb;\n if (!rfb) {\n state = 'disconnected';\n requestTicket();\n return 'failed';\n }\n rfb.addEventListener('connect', onConnect);\n rfb.addEventListener('disconnect', onDisconnect);\n rfb.addEventListener('clipboard', onClipboard);\n return 'connecting';\n}\n\n/** Pastes this computer's clipboard: the sandbox's clipboard first, then the paste key itself. */\nfunction paste(text, chord) {\n const rfb = UI.rfb;\n if (!rfb || state !== 'connected' || typeof text !== 'string' || text.length > MAX_CLIPBOARD_CHARS) {\n return false;\n }\n rfb.clipboardPasteFrom(text);\n if (chord && chord.kind === 'mac') {\n // Cmd+V: Linux apps do not paste on Super+V, so release Super and send Shift+Insert.\n rfb.sendKey(XK.Super_L, 'MetaLeft', false);\n rfb.sendKey(XK.Super_R, 'MetaRight', false);\n rfb.sendKey(XK.Shift_L, 'ShiftLeft', true);\n rfb.sendKey(XK.Insert, 'Insert');\n rfb.sendKey(XK.Shift_L, 'ShiftLeft', false);\n } else if (chord && chord.kind === 'replay') {\n // Ctrl+V, Ctrl+Shift+V, Shift+Insert: the held modifiers already reached the sandbox.\n const insert = chord.key === 'Insert';\n rfb.sendKey(insert ? XK.Insert : chord.key === 'V' ? XK.V : XK.v, insert ? 'Insert' : 'KeyV');\n }\n return true;\n}\n\nfunction pause(message, label, tone) {\n state = 'paused';\n if (UI.rfb) {\n UI.rfb.disconnect();\n }\n UI.hideStatus();\n overlay.querySelector('p').textContent = String(message || '');\n overlay.querySelector('button').textContent = String(label || 'Reconnect');\n // Asleep and starting are states, not errors: announce them politely.\n overlay.firstElementChild.setAttribute('role', tone === 'neutral' ? 'status' : 'alert');\n overlay.classList.add('open');\n overlay.querySelector('button').focus();\n return true;\n}\n\nfunction resume() {\n if (state !== 'paused') {\n return false;\n }\n overlay.classList.remove('open');\n state = 'disconnected';\n attemptConnected = true; // a person asked: start the retry budget over\n requestTicket();\n return true;\n}\n\nObject.defineProperty(window, 'orcaDesktop', {\n value: Object.freeze({\n version: 1,\n state: () => state,\n /** Resolves when the page needs a ticket: now if it is not connected, else after a drop. */\n nextTicketRequest: () => new Promise((resolve) => {\n ticketWaiters.push(resolve);\n if (state === 'idle' || state === 'disconnected') {\n requestTicket();\n }\n }),\n connect,\n paste,\n pause,\n resume\n })\n});\n","orca-sandbox-grant":"#!/usr/bin/env bash\n# Mints a fresh, single-device orcad pairing offer for one org member's device and seals it to that\n# device's X25519 key, so the control plane relays only ciphertext (the VM owner's E2EE model holds:\n# every member device gets its own revocable orcad credential, nobody else can read it).\n# Env from the control plane: ORCA_GRANT_ID, ORCA_GRANT_RECIPIENT_KEY (base64 raw X25519 public\n# key), ORCA_GRANT_KIND (desktop|mobile), ORCA_PUBLIC_WSS. Prints one JSON line:\n# {\"deviceId\":\"…\",\"sealed\":{\"v\":1,\"ephemeralPublicKeyB64\":\"…\",\"nonceB64\":\"…\",\"ciphertextB64\":\"…\"}}\n# Sealing: ECDH(ephemeral, recipient) -> HKDF-SHA256(salt = eph||recipient, info\n# \"orca-cloud-grant-v1\") -> AES-256-GCM, AAD \"orca-cloud-grant-v1:\".\nset -euo pipefail\numask 077\nORCA_HOME=/opt/orca\nSTATE=\"$HOME/.orca-sandbox\"\nDATA=\"$HOME/.orcad-data\"\nmkdir -p \"$STATE\"\nexec 9>\"$STATE/grant.lock\"\n# One mint at a time: --rotate retires unredeemed offers, so two members never share a credential.\nflock -w 30 9\n\ncase \"${ORCA_GRANT_ID:-}\" in '' | *[!A-Za-z0-9_-]*) echo 'bad grant id' >&2; exit 2 ;; esac\ncase \"${ORCA_GRANT_KIND:-}\" in desktop) scope=() ;; mobile) scope=(--mobile) ;; *) echo 'bad kind' >&2; exit 2 ;; esac\ncase \"${ORCA_GRANT_RECIPIENT_KEY:-}\" in '' | *[!A-Za-z0-9+/=]*) echo 'bad key' >&2; exit 2 ;; esac\ncase \"${ORCA_PUBLIC_WSS:-}\" in wss://*) ;; *) echo 'bad endpoint' >&2; exit 2 ;; esac\n\nnode=\"$(find \"$ORCA_HOME/runtime/runtimes\" -type f -name node | head -n 1)\"\ncli=\"$ORCA_HOME/runtime/orcad/out/cli/index.js\"\n# The workspace's project must exist on this orcad before a phone opens it: phones may not add\n# projects themselves (mobile RPC scope), and a phone may be the first client this VM ever sees.\nproject=/vercel/sandbox/project\nif [ -d \"$project\" ]; then\n kind=folder\n [ -d \"$project/.git\" ] && kind=git\n ORCA_USER_DATA_PATH=\"$DATA\" timeout 20 \"$node\" \"$cli\" repo add --path \"$project\" --kind \"$kind\" --json \\\n >/dev/null 2>&1 || true\nfi\noffer=\"$(ORCA_USER_DATA_PATH=\"$DATA\" timeout 30 \"$node\" \"$cli\" pair --json --rotate \"${scope[@]}\" \\\n --address \"$ORCA_PUBLIC_WSS\" 2>/dev/null)\" || { echo 'pair failed' >&2; exit 1; }\n# This workspace's read-snapshot signer (public half), so the paired device pins it; best effort.\nsigner=\"$(\"$node\" \"$ORCA_HOME/bin/orca-read-snapshot.cjs\" --signer-public-key 2>/dev/null || true)\"\n# The offer goes to the sealer on stdin (never argv or disk); only the sealed form is printed.\n# Granted devices are listed so orcad can keep owner-only features (local commands) from them.\nprintf '%s' \"$offer\" | ORCA_GRANT_ID=\"$ORCA_GRANT_ID\" ORCA_GRANT_RECIPIENT_KEY=\"$ORCA_GRANT_RECIPIENT_KEY\" ORCA_READ_SIGNER=\"$signer\" \\\n ORCA_GRANT_LEDGER=\"$STATE/grant-devices\" \"$node\" -e '\nconst crypto = require(\"node:crypto\")\nlet input = \"\"\nprocess.stdin.on(\"data\", (c) => (input += c)).on(\"end\", () => {\n const res = JSON.parse(input).result\n if (!res || res.available !== true || !res.pairingUrl || !res.deviceId) {\n console.error(\"offer unavailable\")\n process.exit(1)\n }\n if (process.env.ORCA_GRANT_LEDGER) require(\"node:fs\").appendFileSync(process.env.ORCA_GRANT_LEDGER, res.deviceId + \"\\n\", { mode: 0o600 })\n const recipient = Buffer.from(process.env.ORCA_GRANT_RECIPIENT_KEY, \"base64\")\n if (recipient.length !== 32) { console.error(\"bad key length\"); process.exit(2) }\n const recipientKey = crypto.createPublicKey({ key: { kty: \"OKP\", crv: \"X25519\", x: recipient.toString(\"base64url\") }, format: \"jwk\" })\n const eph = crypto.generateKeyPairSync(\"x25519\")\n const ephRaw = Buffer.from(eph.publicKey.export({ format: \"jwk\" }).x, \"base64url\")\n const shared = crypto.diffieHellman({ privateKey: eph.privateKey, publicKey: recipientKey })\n const key = Buffer.from(crypto.hkdfSync(\"sha256\", shared, Buffer.concat([ephRaw, recipient]), \"orca-cloud-grant-v1\", 32))\n const nonce = crypto.randomBytes(12)\n const cipher = crypto.createCipheriv(\"aes-256-gcm\", key, nonce)\n cipher.setAAD(Buffer.from(\"orca-cloud-grant-v1:\" + process.env.ORCA_GRANT_ID))\n const signer = /^[A-Za-z0-9+/]{43}=$/.test(process.env.ORCA_READ_SIGNER || \"\") ? process.env.ORCA_READ_SIGNER : undefined\n const body = Buffer.from(JSON.stringify({ pairingUrl: res.pairingUrl, deviceId: res.deviceId, scope: res.scope, ...(signer ? { readSignerPublicKeyB64: signer } : {}) }))\n const ct = Buffer.concat([cipher.update(body), cipher.final(), cipher.getAuthTag()])\n process.stdout.write(JSON.stringify({ deviceId: res.deviceId, sealed: { v: 1, ephemeralPublicKeyB64: ephRaw.toString(\"base64\"), nonceB64: nonce.toString(\"base64\"), ciphertextB64: ct.toString(\"base64\") } }) + \"\\n\")\n})'\n","orca-sandbox-revoke":"#!/usr/bin/env bash\n# Revokes member devices from orcad's device registry. orcad adopts on-disk removals at every auth\n# (an admin revoke, device-registry-store.ts), so this works whether or not orcad is running; the\n# control plane also runs it at boot, before orcad starts, for revokes queued while suspended.\n# Env: ORCA_REVOKE_DEVICE_IDS (comma list of orcad device ids). Prints {\"revoked\":,\"live\":}.\nset -euo pipefail\numask 077\nSTATE=\"$HOME/.orca-sandbox\"\nREGISTRY=\"$HOME/.orcad-data/orca-devices.json\"\nmkdir -p \"$STATE\"\nexec 9>\"$STATE/grant.lock\"\nflock -w 30 9\n\nids=\"${ORCA_REVOKE_DEVICE_IDS:-}\"\ncase \"$ids\" in '' | *[!A-Za-z0-9,_-]*) echo 'bad device ids' >&2; exit 2 ;; esac\n# Live orcad first: `orca pair --revoke` also closes the device's open sockets now. Older orcad\n# builds lack it; the on-disk removal below still blocks their next sign-in.\nORCA_HOME=/opt/orca\nnode=\"$(find \"$ORCA_HOME/runtime/runtimes\" -type f -name node 2>/dev/null | head -n 1)\"\ncli=\"$ORCA_HOME/runtime/orcad/out/cli/index.js\"\nlive=0\nif [ -n \"$node\" ] && [ -f \"$cli\" ] && [ -s \"$HOME/.orcad-data/orca-runtime.json\" ]; then\n for id in ${ids//,/ }; do\n if ORCA_USER_DATA_PATH=\"$HOME/.orcad-data\" timeout 15 \"$node\" \"$cli\" pair --revoke \"$id\" --json 2>/dev/null \\\n | jq -e '.ok == true and .result.revoked == true' >/dev/null 2>&1; then\n live=$((live + 1))\n fi\n done\nfi\nif [ ! -s \"$REGISTRY\" ]; then\n echo \"{\\\"revoked\\\":$live,\\\"live\\\":$live}\"\n exit 0\nfi\nids_json=\"$(jq -cn --arg ids \"$ids\" '$ids | split(\",\") | map(select(length > 0))')\"\nbefore=\"$(jq 'length' \"$REGISTRY\")\"\ntmp=\"$(mktemp \"$REGISTRY.revoke.XXXXXX\")\"\n# Removes the rows and any phones delegated through them (parentDeviceId), like orcad's own revoke.\njq --argjson ids \"$ids_json\" \\\n '[.[] | select((.deviceId as $d | $ids | index($d)) == null and ((.parentDeviceId // \"\") as $p | $ids | index($p)) == null)]' \\\n \"$REGISTRY\" >\"$tmp\"\nchmod 0600 \"$tmp\"\nsync \"$tmp\" 2>/dev/null || true\nmv -f \"$tmp\" \"$REGISTRY\"\nafter=\"$(jq 'length' \"$REGISTRY\")\"\necho \"{\\\"revoked\\\":$((live + before - after)),\\\"live\\\":$live}\"\n","orca-sandbox-members":"#!/usr/bin/env bash\n# Runs as root (control plane, sudo). Installs who-may-push-as-whom for `orca-git-as`:\n# /etc/orca/members/.gitconfig egress placeholder header + GitHub author identity\n# /etc/orca/members/devices.json {\"\": \"\"} (each member's paired devices)\n# Both root-only: agents never read another member's placeholder. `orca-git-identity` (root, via a\n# sudoers rule) hands an agent only the identity of the member whose device drove its turn, as orcad\n# attests it. No token lives here: the placeholder is swapped at egress, for this repository only.\n# Env: ORCA_MEMBERS_B64 = base64 JSON [{userId, name, email, authorization, deviceIds:[...]}]\n# The VM user agents run as is orcad's own user (detected). Prints {\"members\":,\"devices\":}.\nset -euo pipefail\numask 077\nDIR=/etc/orca/members\norcad_pid=\"$(pgrep -f orcad-server.js | head -n 1 || true)\"\nUSER_NAME=\"$( [ -n \"$orcad_pid\" ] && ps -o user= -p \"$orcad_pid\" | tr -d ' ' || true)\"\nUSER_NAME=\"${USER_NAME:-${ORCA_SANDBOX_USER:-${SUDO_USER:-}}}\"\ncase \"$USER_NAME\" in '' | *[!a-z0-9_-]*) echo 'bad user' >&2; exit 2 ;; esac\nmkdir -p /etc/orca\njson=\"$(printf '%s' \"${ORCA_MEMBERS_B64:-W10=}\" | base64 -d)\"\ntmp=\"$(mktemp -d /etc/orca/members.XXXXXX)\"\ncount=0\nwhile IFS= read -r row; do\n user=\"$(jq -r '.userId' <<<\"$row\")\"\n case \"$user\" in '' | *[!A-Za-z0-9_.:-]*) continue ;; esac\n file=\"$tmp/$(printf '%s' \"$user\" | tr ':' '_').gitconfig\"\n # An empty extraheader first resets the image-wide (app identity) header for this member's git.\n git config -f \"$file\" --add http.https://github.com/.extraheader ''\n git config -f \"$file\" --add http.https://github.com/.extraheader \"Authorization: $(jq -r '.authorization' <<<\"$row\")\"\n git config -f \"$file\" user.name \"$(jq -r '.name' <<<\"$row\")\"\n git config -f \"$file\" user.email \"$(jq -r '.email' <<<\"$row\")\"\n count=$((count + 1))\ndone < <(jq -c '.[]' <<<\"$json\")\njq -c '[.[] | select(.userId | test(\"^[A-Za-z0-9_.:-]+$\")) | .userId as $u | (.deviceIds // [])[] | select(test(\"^[A-Za-z0-9_-]{1,128}$\")) | {key: ., value: $u}] | from_entries' <<<\"$json\" >\"$tmp/devices.json\"\ndevices=\"$(jq 'length' \"$tmp/devices.json\")\"\nchmod 0700 \"$tmp\" && chmod 0600 \"$tmp\"/*\nrm -rf \"$DIR.old\"\n[ -d \"$DIR\" ] && mv \"$DIR\" \"$DIR.old\"\nmv \"$tmp\" \"$DIR\"\nrm -rf \"$DIR.old\"\n# Agents may ask for their own sender's identity, and only that (the helper checks with orcad).\nrule=\"$USER_NAME ALL=(root) NOPASSWD:SETENV: /opt/orca/bin/orca-git-identity\"\nprintf '%s\\n' \"$rule\" >/etc/sudoers.d/orca-git-identity.new\nchmod 0440 /etc/sudoers.d/orca-git-identity.new\nif visudo -cf /etc/sudoers.d/orca-git-identity.new >/dev/null; then\n mv -f /etc/sudoers.d/orca-git-identity.new /etc/sudoers.d/orca-git-identity\nelse\n rm -f /etc/sudoers.d/orca-git-identity.new\n echo 'sudoers rule rejected' >&2\n exit 1\nfi\n# Pre-attestation layout (agent-readable member files) must not linger.\nuser_home=\"$(getent passwd \"$USER_NAME\" | cut -d: -f6)\"\n[ -n \"$orcad_pid\" ] && user_home=\"$(tr '\\0' '\\n' <\"/proc/$orcad_pid/environ\" | sed -n 's/^HOME=//p' | head -n 1)\"\n[ -n \"$user_home\" ] && rm -rf \"$user_home/.orca-sandbox/members\"\n# Commits outside a teammate's turn still need an author (images older than orca-sandbox-start's default).\nif [ -n \"$user_home\" ]; then\n sudo -u \"$USER_NAME\" env HOME=\"$user_home\" git config --global user.email >/dev/null 2>&1 \\\n || sudo -u \"$USER_NAME\" env HOME=\"$user_home\" git config --global user.email 'cloud@users.noreply.orca.dev'\n sudo -u \"$USER_NAME\" env HOME=\"$user_home\" git config --global user.name >/dev/null 2>&1 \\\n || sudo -u \"$USER_NAME\" env HOME=\"$user_home\" git config --global user.name 'Orca Cloud'\nfi\n# CREATE-GIT-ASYNC: identities are in place, so `git` stops holding commits and pushes for this boot.\nif [ -n \"$user_home\" ] && [ -d \"$user_home/.orca-sandbox\" ]; then\n sudo -u \"$USER_NAME\" sh -c 'rm -f \"$1/git-setup.pending\" \"$1/git-setup.failed\" && date +%s >\"$1/git-setup.ready\"' \\\n _ \"$user_home/.orca-sandbox\" || true\nfi\necho \"{\\\"members\\\":$count,\\\"devices\\\":$devices}\"\n","orca-sandbox-read-snapshot":"#!/usr/bin/env bash\n# Seals a read-only snapshot of this workspace (chats, agent status, git, PR) to the reader keys the\n# control plane passes, right before the drain. Only ciphertext is printed (one JSON line); see\n# orca-read-snapshot.cjs. Exit 3: orcad isn't running (nothing to read).\nset -euo pipefail\numask 077\nORCA_HOME=/opt/orca\nexport ORCA_USER_DATA_PATH=\"${ORCA_USER_DATA_PATH:-$HOME/.orcad-data}\"\nnode=\"$(find \"$ORCA_HOME/runtime/runtimes\" -type f -name node | head -n 1)\"\n[ -n \"$node\" ] || { echo 'node not found' >&2; exit 1; }\nexec timeout 35 \"$node\" \"$ORCA_HOME/bin/orca-read-snapshot.cjs\"\n","orca-read-snapshot.cjs":"#!/usr/bin/env node\n// Gathers what a sleeping workspace should still show (recent chats per agent session, agent\n// status, git status/diff summary, PR link) from the running orcad over its local socket, then seals\n// it to the reader keys the control plane passed, so only ciphertext leaves the VM.\n// Run by orca-sandbox-read-snapshot right before the drain. Env:\n// ORCA_READ_SNAPSHOT_ID rsn_<24 hex> ORCA_READ_WORKSPACE_ID sbx_<24 hex>\n// ORCA_READ_RECIPIENTS JSON [{keyId, publicKeyB64}] ORCA_READ_REASON sleep|rollover\n// ORCA_USER_DATA_PATH orcad's data dir (orca-runtime.json lives there)\n// Prints one JSON line: {v:1, snapshotId, workspaceId, nonceB64, ciphertextB64, recipients[], stats}.\n// Construction (keep in sync with src/shared/cloud-workspaces/read-snapshot.ts in the app):\n// body = AES-256-GCM(dek, nonce, JSON, AAD \"orca-cloud-read-v1::\")\n// wrapped = AES-256-GCM(HKDF-SHA256(X25519(eph, reader), eph||reader, \"orca-cloud-read-wrap-v1\"),\n// nonce_i, dek, AAD \"orca-cloud-read-wrap-v1:::\")\n'use strict'\nconst crypto = require('node:crypto')\nconst fs = require('node:fs')\nconst net = require('node:net')\nconst path = require('node:path')\n\nconst INFO = 'orca-cloud-read-v1'\nconst WRAP_INFO = 'orca-cloud-read-wrap-v1'\nconst PROJECT = '/vercel/sandbox/project'\nconst LIMITS = { sessions: 12, messagesPerSession: 60, messageChars: 4000, changes: 200, commits: 10 }\n/** Plaintext budget: sealed + base64 it stays under the control plane's 220 kB and the 256 kB stdout cap. */\nconst MAX_PLAIN_BYTES = 150_000\nconst CALL_TIMEOUT_MS = 5_000\nconst TOTAL_BUDGET_MS = 20_000\n\nfunction b64(bytes) {\n return Buffer.from(bytes).toString('base64')\n}\n\nfunction x25519PublicKey(raw) {\n return crypto.createPublicKey({ key: { kty: 'OKP', crv: 'X25519', x: Buffer.from(raw).toString('base64url') }, format: 'jwk' })\n}\n\nfunction gcmSeal(key, nonce, plain, aad) {\n const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce)\n cipher.setAAD(Buffer.from(aad))\n return Buffer.concat([cipher.update(plain), cipher.final(), cipher.getAuthTag()])\n}\n\nconst SIG_INFO = 'orca-cloud-read-sig-v1'\n\n/**\n * This VM's snapshot signing key (Ed25519), created once and kept on the VM's own disk (0600, never\n * printed). Devices pin its public half per workspace, so a snapshot the control plane forged or\n * swapped doesn't verify. Same file for the grant script, which hands the public half to pairing devices.\n */\nfunction signingKey(stateDir) {\n if (!path.isAbsolute(stateDir)) {\n throw new Error('no state dir')\n }\n const file = path.join(stateDir, 'read-signing-key.pem')\n const load = () => {\n try {\n const privateKey = crypto.createPrivateKey(fs.readFileSync(file, 'utf8'))\n return privateKey.asymmetricKeyType === 'ed25519' ? privateKey : null\n } catch {\n return null\n }\n }\n let privateKey = load()\n if (!privateKey) {\n fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 })\n // Written whole to a temp file, then linked into place: atomic, and a concurrent first run\n // (grant + capture) keeps whichever key landed first. A broken file is replaced (devices that\n // pinned the old signer then see \"signer changed\" until they trust the new one).\n const temp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}`\n fs.writeFileSync(temp, crypto.generateKeyPairSync('ed25519').privateKey.export({ format: 'pem', type: 'pkcs8' }), { mode: 0o600 })\n try {\n fs.linkSync(temp, file)\n } catch {\n if (!load()) {\n fs.renameSync(temp, file)\n }\n } finally {\n fs.rmSync(temp, { force: true })\n }\n privateKey = load()\n if (!privateKey) {\n throw new Error('signing key unavailable')\n }\n }\n const publicKeyB64 = Buffer.from(crypto.createPublicKey(privateKey).export({ format: 'jwk' }).x, 'base64url').toString('base64')\n return { privateKey, publicKeyB64 }\n}\n\n/** Signs the sealed body: \"orca-cloud-read-sig-v1::::\" || ciphertext bytes. */\nfunction sign(out, key) {\n const message = Buffer.concat([\n Buffer.from(`${SIG_INFO}:${out.workspaceId}:${out.snapshotId}:${out.nonceB64}:`),\n Buffer.from(out.ciphertextB64, 'base64')\n ])\n return { publicKeyB64: key.publicKeyB64, signatureB64: crypto.sign(null, message, key.privateKey).toString('base64') }\n}\n\n/** Seals `payload` (an object) to every recipient; a malformed key is skipped, not fatal. */\nfunction seal(payload, recipients, workspaceId, snapshotId) {\n const scope = `${workspaceId}:${snapshotId}`\n const dek = crypto.randomBytes(32)\n const nonce = crypto.randomBytes(12)\n const ciphertext = gcmSeal(dek, nonce, Buffer.from(JSON.stringify(payload)), `${INFO}:${scope}`)\n const sealedTo = []\n for (const recipient of recipients) {\n const reader = Buffer.from(String(recipient.publicKeyB64 || ''), 'base64')\n if (reader.length !== 32 || !/^rk_[0-9a-f]{24}$/.test(String(recipient.keyId || ''))) {\n continue\n }\n let shared\n const eph = crypto.generateKeyPairSync('x25519')\n try {\n shared = crypto.diffieHellman({ privateKey: eph.privateKey, publicKey: x25519PublicKey(reader) })\n } catch {\n continue\n }\n if (shared.every((byte) => byte === 0)) {\n continue\n }\n const ephRaw = Buffer.from(eph.publicKey.export({ format: 'jwk' }).x, 'base64url')\n const kek = Buffer.from(crypto.hkdfSync('sha256', shared, Buffer.concat([ephRaw, reader]), WRAP_INFO, 32))\n const wrapNonce = crypto.randomBytes(12)\n const wrapped = gcmSeal(kek, wrapNonce, dek, `${WRAP_INFO}:${scope}:${recipient.keyId}`)\n sealedTo.push({ keyId: recipient.keyId, ephemeralPublicKeyB64: b64(ephRaw), nonceB64: b64(wrapNonce), wrappedKeyB64: b64(wrapped) })\n }\n dek.fill(0)\n return { v: 1, snapshotId, workspaceId, nonceB64: b64(nonce), ciphertextB64: b64(ciphertext), recipients: sealedTo }\n}\n\n// --- gathering -------------------------------------------------------------------------------\n\n/** One request per connection, like the CLI. A streaming method resolves on its first frame. */\nfunction rpc(metadata, method, params, timeoutMs = CALL_TIMEOUT_MS) {\n return new Promise((resolve) => {\n const transport = (metadata.transports || [metadata.transport]).find((t) => t && (t.kind === 'unix' || t.kind === 'named-pipe'))\n if (!transport) {\n resolve(null)\n return\n }\n const socket = net.createConnection(transport.endpoint)\n const id = crypto.randomUUID()\n let buffer = ''\n let done = false\n const finish = (value) => {\n if (!done) {\n done = true\n clearTimeout(timer)\n socket.destroy()\n resolve(value)\n }\n }\n const timer = setTimeout(() => finish(null), timeoutMs)\n socket.setEncoding('utf8')\n socket.on('error', () => finish(null))\n socket.on('close', () => finish(null))\n socket.on('connect', () => socket.write(`${JSON.stringify({ id, authToken: metadata.authToken, method, params })}\\n`))\n socket.on('data', (chunk) => {\n buffer += chunk\n let newline\n while ((newline = buffer.indexOf('\\n')) !== -1) {\n const line = buffer.slice(0, newline)\n buffer = buffer.slice(newline + 1)\n let frame\n try {\n frame = JSON.parse(line)\n } catch {\n continue\n }\n if (!frame || frame.id !== id) {\n continue\n }\n finish(frame.ok === true ? frame.result : null)\n return\n }\n })\n })\n}\n\nconst isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v)\nconst str = (v, max) => (typeof v === 'string' ? v.slice(0, max) : null)\nconst num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null)\n\nfunction messagesOf(history) {\n const items = isObj(history) && isObj(history.page) && Array.isArray(history.page.items) ? history.page.items : []\n const out = []\n for (const item of items) {\n const body = isObj(item) && isObj(item.body) ? item.body : null\n if (!body || body.kind !== 'message' || !Array.isArray(body.blocks)) {\n continue\n }\n const role = body.role\n if (role !== 'user' && role !== 'assistant' && role !== 'system') {\n continue\n }\n const text = body.blocks\n .filter((b) => isObj(b) && b.type === 'text' && typeof b.text === 'string')\n .map((b) => b.text)\n .join('\\n')\n .trim()\n if (text) {\n out.push({ itemId: String(item.itemId).slice(0, 200), role, text: text.slice(0, LIMITS.messageChars), at: num(item.observedAt) ?? 0 })\n }\n }\n return { messages: out, hasOlder: isObj(history) && isObj(history.page) && history.page.hasOlder === true }\n}\n\nfunction gitOf(status, log) {\n if (!isObj(status) && !isObj(log)) {\n return null\n }\n const entries = isObj(status) && Array.isArray(status.entries) ? status.entries.filter(isObj) : []\n let added = 0\n let removed = 0\n let counted = false\n const changes = entries.slice(0, LIMITS.changes).map((e) => {\n const a = num(e.added)\n const r = num(e.removed)\n if (a !== null || r !== null) {\n counted = true\n }\n return { path: String(e.path).slice(0, 1024), status: str(e.status, 20) || 'modified', area: str(e.area, 20) || 'unstaged', added: a, removed: r }\n })\n for (const e of entries) {\n added += num(e.added) || 0\n removed += num(e.removed) || 0\n }\n const items = isObj(log) && Array.isArray(log.items) ? log.items.filter(isObj) : []\n return {\n branch: isObj(status) && typeof status.branch === 'string' ? status.branch.replace(/^refs\\/heads\\//, '') : null,\n head: isObj(status) ? str(status.head, 64) : null,\n changes,\n changeCount: entries.length,\n added: counted ? added : null,\n removed: counted ? removed : null,\n commits: items.slice(0, LIMITS.commits).map((c) => ({\n id: String(c.id).slice(0, 64),\n subject: String(c.subject ?? '').slice(0, 300),\n author: str(c.author, 120),\n at: num(c.timestamp)\n }))\n }\n}\n\nfunction prOf(linked) {\n if (!isObj(linked) || num(linked.number) === null || typeof linked.url !== 'string' || !linked.url.startsWith('https://')) {\n return null\n }\n return {\n number: linked.number,\n title: String(linked.title ?? '').slice(0, 300),\n state: str(linked.state, 20) || 'open',\n url: linked.url.slice(0, 500),\n checksStatus: str(linked.checksStatus, 20),\n checksSummary: str(linked.checksSummary, 200)\n }\n}\n\nconst STATUSES = new Set(['working', 'attention', 'idle'])\nconst PANE_PREFIX = 'structured-agent-session-'\n\n/**\n * Chats as orcad stores them (agent-session-journal.db, read-only): every session's provider, name\n * and last turn, open tabs first. The local socket can't stream agentSession.subscribeStatus, and\n * worktree.ps lists only agents that are live right now, so this is the complete list.\n */\nfunction storedSessions(dataDir) {\n let db\n try {\n const { DatabaseSync } = require('node:sqlite')\n db = new DatabaseSync(path.join(dataDir, 'agent-session-journal.db'), { readOnly: true })\n const open = new Set(db.prepare('SELECT session_id FROM agent_session_tabs').all().map((r) => r.session_id))\n const out = []\n for (const row of db.prepare('SELECT session_id, record_json FROM agent_session_records').all()) {\n let record\n try {\n record = JSON.parse(row.record_json)\n } catch {\n continue\n }\n const last = isObj(record.lastTurn) ? record.lastTurn : {}\n out.push({\n sessionId: row.session_id,\n agent: str(record.provider, 40),\n title: str(record.conversationName, 200) || str(last.prompt, 200),\n status: last.state === 'running' || last.state === 'submitted' ? 'working' : last.state === 'settled' ? 'idle' : 'unknown',\n turnOutcome: str(last.outcome, 40),\n updatedAt: num(record.updatedAt),\n open: open.has(row.session_id),\n lastPrompt: str(last.prompt, LIMITS.messageChars),\n lastReply: str(last.reply, LIMITS.messageChars),\n lastAt: num(last.at),\n // \"::\": which checkout this chat works in.\n worktreePath: isObj(record.location) && typeof record.location.workspaceId === 'string' ? record.location.workspaceId.split('::').pop() : null\n })\n }\n return out\n } catch {\n return []\n } finally {\n try {\n db && db.close()\n } catch {}\n }\n}\n\n/** Live agent rows from worktree.ps, keyed by session id (their status wins over the stored one). */\nfunction liveAgents(worktrees) {\n const out = new Map()\n for (const w of worktrees) {\n for (const a of Array.isArray(w.agents) ? w.agents.filter(isObj) : []) {\n const tab = typeof a.paneKey === 'string' ? a.paneKey.split(':')[0] : ''\n if (!tab.startsWith(PANE_PREFIX)) {\n continue\n }\n const state = a.mainAgent && typeof a.mainAgent.state === 'string' ? a.mainAgent.state : a.state\n out.set(tab.slice(PANE_PREFIX.length), {\n agent: str(a.agentType, 40),\n title: str(a.conversationName, 200) || str(a.taskTitle, 200) || str(a.displayName, 200),\n status: state === 'working' ? 'working' : state === 'blocked' || state === 'waiting' || state === 'attention' ? 'attention' : 'idle',\n updatedAt: num(a.updatedAt)\n })\n }\n }\n return out\n}\n\n/**\n * The checkout this workspace is about: the one its newest chat works in, else the most recently\n * active one (a workspace on its own branch is a separate worktree, not the base clone at\n * /vercel/sandbox/project), else the base clone.\n */\nfunction workspaceCheckout(worktrees, sessions) {\n const byPath = new Map(worktrees.filter((w) => typeof w.path === 'string').map((w) => [w.path, w]))\n const newest = sessions\n .filter((s) => s.worktreePath && byPath.has(s.worktreePath))\n .sort((a, b) => (num(b.updatedAt) || 0) - (num(a.updatedAt) || 0))[0]\n if (newest) {\n return byPath.get(newest.worktreePath)\n }\n const activity = (w) => Math.max(num(w.lastActivityAt) || 0, num(w.sortOrder) || 0)\n const ranked = [...byPath.values()].sort(\n (a, b) => activity(b) - activity(a) || Number(a.isMainWorktree === true) - Number(b.isMainWorktree === true)\n )\n return ranked[0] || null\n}\n\n/** `call(method, params)` -> result | null. Never throws: a missing piece is left out. */\nasync function gather(call, { workspaceId, snapshotId, reason, now, deadline, listSessions = () => [] }) {\n const timeLeft = () => deadline - Date.now()\n const ps = await call('worktree.ps', { limit: 20 })\n const worktrees = isObj(ps) && Array.isArray(ps.worktrees) ? ps.worktrees.filter(isObj) : []\n const stored = listSessions()\n const project = workspaceCheckout(worktrees, stored)\n const selector = project && typeof project.path === 'string' ? `path:${project.path}` : `path:${PROJECT}`\n const [status, log] = await Promise.all([\n call('git.status', { worktree: selector, includeLineStats: true }),\n call('git.history', { worktree: selector, limit: LIMITS.commits })\n ])\n const live = liveAgents(worktrees)\n const byId = new Map()\n for (const s of stored) {\n byId.set(s.sessionId, s)\n }\n for (const [sessionId, a] of live) {\n byId.set(sessionId, { ...(byId.get(sessionId) || { sessionId, open: true }), ...Object.fromEntries(Object.entries(a).filter(([, v]) => v !== null)) })\n }\n // Open chats first, then newest activity.\n const picked = [...byId.values()]\n .filter((s) => typeof s.sessionId === 'string')\n .sort((a, b) => Number(Boolean(b.open)) - Number(Boolean(a.open)) || (num(b.updatedAt) || 0) - (num(a.updatedAt) || 0))\n .slice(0, LIMITS.sessions)\n const sessions = []\n for (const s of picked) {\n const history = timeLeft() > 1_000 ? await call('agentSession.history', { sessionId: s.sessionId, direction: 'tail', limit: LIMITS.messagesPerSession }) : null\n let { messages, hasOlder } = messagesOf(history)\n if (messages.length === 0) {\n // No readable history (or out of time): the stored last turn still says what happened.\n const at = s.lastAt || s.updatedAt || 0\n messages = [\n ...(s.lastPrompt ? [{ itemId: `${s.sessionId}:last-prompt`, role: 'user', text: s.lastPrompt, at }] : []),\n ...(s.lastReply ? [{ itemId: `${s.sessionId}:last-reply`, role: 'assistant', text: s.lastReply, at }] : [])\n ]\n hasOlder = hasOlder || messages.length > 0\n }\n sessions.push({\n sessionId: s.sessionId.slice(0, 200),\n agent: s.agent || null,\n title: s.title || null,\n status: STATUSES.has(s.status) ? s.status : 'unknown',\n turnOutcome: s.turnOutcome || null,\n updatedAt: num(s.updatedAt),\n messages: messages.slice(-LIMITS.messagesPerSession),\n truncated: hasOlder || messages.length > LIMITS.messagesPerSession\n })\n }\n return {\n v: 1,\n workspaceId,\n snapshotId,\n capturedAt: now,\n reason,\n git: gitOf(status, log),\n pr: prOf(project && project.linkedPR),\n sessions\n }\n}\n\n/** Cuts the oldest messages (then long texts, then sessions) until the JSON fits the budget. */\nfunction fitToBudget(payload, maxBytes = MAX_PLAIN_BYTES) {\n const size = () => Buffer.byteLength(JSON.stringify(payload))\n let guard = 0\n while (size() > maxBytes && guard++ < 10_000) {\n const longest = payload.sessions.reduce((best, s) => (!best || s.messages.length > best.messages.length ? s : best), null)\n if (longest && longest.messages.length > 4) {\n longest.messages.shift()\n longest.truncated = true\n continue\n }\n const longText = payload.sessions.flatMap((s) => s.messages).find((m) => m.text.length > 500)\n if (longText) {\n longText.text = `${longText.text.slice(0, 500)}…`\n continue\n }\n if (payload.git && payload.git.changes.length > 20) {\n payload.git.changes = payload.git.changes.slice(0, 20)\n continue\n }\n if (payload.sessions.length > 1) {\n payload.sessions.pop()\n continue\n }\n payload.sessions = []\n break\n }\n return payload\n}\n\n// Fails closed without HOME (signingKey refuses a relative path).\nconst stateDir = process.env.HOME ? path.join(process.env.HOME, '.orca-sandbox') : ''\n\nasync function main() {\n const started = Date.now()\n const env = process.env\n if (process.argv[2] === '--signer-public-key') {\n // For orca-sandbox-grant: pairing devices learn (and pin) this workspace's signer.\n process.stdout.write(`${signingKey(stateDir).publicKeyB64}\\n`)\n return\n }\n const snapshotId = env.ORCA_READ_SNAPSHOT_ID || ''\n const workspaceId = env.ORCA_READ_WORKSPACE_ID || ''\n if (!/^rsn_[0-9a-f]{24}$/.test(snapshotId) || !/^sbx_[0-9a-f]{24}$/.test(workspaceId)) {\n process.stderr.write('bad ids\\n')\n process.exit(2)\n }\n let recipients\n try {\n recipients = JSON.parse(env.ORCA_READ_RECIPIENTS || '[]')\n } catch {\n recipients = null\n }\n if (!Array.isArray(recipients) || recipients.length === 0) {\n process.stderr.write('no recipients\\n')\n process.exit(2)\n }\n const dataDir = env.ORCA_USER_DATA_PATH || path.join(env.HOME || '', '.orcad-data')\n let metadata\n try {\n metadata = JSON.parse(fs.readFileSync(path.join(dataDir, 'orca-runtime.json'), 'utf8'))\n } catch {\n metadata = null\n }\n if (!metadata || !metadata.authToken) {\n process.stderr.write('orcad not running\\n')\n process.exit(3)\n }\n const deadline = started + TOTAL_BUDGET_MS\n const call = (method, params) => rpc(metadata, method, params, Math.max(250, Math.min(CALL_TIMEOUT_MS, deadline - Date.now())))\n const payload = fitToBudget(\n await gather(call, { workspaceId, snapshotId, reason: (env.ORCA_READ_REASON || 'sleep').slice(0, 20), now: Date.now(), deadline, listSessions: () => storedSessions(dataDir) })\n )\n const out = seal(payload, recipients, workspaceId, snapshotId)\n out.signature = sign(out, signingKey(stateDir))\n // Timing only: counts and sizes would describe the plaintext to the control plane's logs.\n out.stats = { collectMs: Date.now() - started }\n process.stdout.write(`${JSON.stringify(out)}\\n`)\n}\n\nmodule.exports = { workspaceCheckout, seal, sign, signingKey, gather, fitToBudget, messagesOf, gitOf, prOf, rpc, storedSessions, liveAgents }\n\nif (require.main === module) {\n // A fixed message: an error text could carry workspace content into the control plane's logs.\n main().catch(() => {\n process.stderr.write('read snapshot failed\\n')\n process.exit(1)\n })\n}\n","orca-git-as":"#!/usr/bin/env bash\n# Runs git as the teammate whose message started this agent's turn: their GitHub identity for\n# pushes/PRs (this repository only) and their commit author.\n# orca-git-as e.g. orca-git-as push origin HEAD\n# orca-git-as --as refuses unless that member really sent the message\n# Orca attests the sender from the paired device that sent the message, never from arguments or\n# message text. Without an attested sender (not run from an agent, or the owner's local session) it\n# runs plain git with the workspace identity.\nset -euo pipefail\nexpect=''\nif [ \"${1:-}\" = '--as' ]; then\n expect=\"${2:-}\"\n shift 2 || { echo 'usage: orca-git-as [--as ] ' >&2; exit 2; }\n case \"$expect\" in '' | *[!A-Za-z0-9_.:-]*) echo 'orca-git-as: bad member id' >&2; exit 2 ;; esac\nfi\nset +e\nfile=\"$(ORCA_GIT_AS=\"$expect\" sudo -n --preserve-env=ORCA_GIT_AS,ORCA_TERMINAL_HANDLE,ORCA_PANE_KEY,ORCA_AGENT_LAUNCH_TOKEN,ORCA_AGENT_SESSION_ID \\\n /opt/orca/bin/orca-git-identity)\"\nstatus=$?\nset -e\ncase \"$status\" in\n 0)\n trap 'rm -f \"$file\"' EXIT\n git -c \"include.path=$file\" \"$@\"\n ;;\n 3)\n echo 'orca-git-as: no teammate identity for this turn; using the workspace identity' >&2\n exec git \"$@\"\n ;;\n *) exit 4 ;;\nesac\n","orca-git-identity":"#!/usr/bin/env bash\n# Root helper behind `orca-git-as` (sudoers: the VM user may run only this). Asks orcad, as the\n# calling agent, which paired device sent the message that started the agent's latest turn\n# (`orca sender`: orcad identifies the agent from its launch evidence, never from arguments), maps\n# that device to a workspace member, and prints that member's git config file for the caller to\n# include. Optional ORCA_GIT_AS: the member the agent expects; any other answer is refused.\n# Exit codes: 0 printed a config path; 3 no member identity (use the workspace identity); 4 refused.\nset -euo pipefail\nDIR=/etc/orca/members\ncaller=\"${SUDO_USER:-}\"\ncase \"$caller\" in '' | root | *[!a-z0-9_-]*) echo 'orca-git-identity: run through orca-git-as' >&2; exit 4 ;; esac\n# orcad's own data dir and HOME (the agent's user may not use its passwd home, e.g. HOME=/vercel).\norcad_pid=\"$(pgrep -u \"$caller\" -f orcad-server.js | head -n 1 || true)\"\n[ -n \"$orcad_pid\" ] || exit 3\norcad_env() { tr '\\0' '\\n' <\"/proc/$orcad_pid/environ\" | sed -n \"s/^$1=//p\" | head -n 1; }\nhome=\"$(orcad_env HOME)\"\ndata=\"$(orcad_env ORCA_USER_DATA)\"\ndata=\"${data:-$home/.orcad-data}\"\nnode=\"$(find /opt/orca/runtime/runtimes -type f -name node | head -n 1)\"\ncli=/opt/orca/runtime/orcad/out/cli/index.js\n# Only the evidence variables orcad needs reach the query, which runs as the caller, not root.\nanswer=\"$(sudo -u \"$caller\" env -i HOME=\"$home\" PATH=/usr/bin:/bin ORCA_USER_DATA_PATH=\"$data\" \\\n ${ORCA_TERMINAL_HANDLE:+ORCA_TERMINAL_HANDLE=\"$ORCA_TERMINAL_HANDLE\"} \\\n ${ORCA_PANE_KEY:+ORCA_PANE_KEY=\"$ORCA_PANE_KEY\"} \\\n ${ORCA_AGENT_LAUNCH_TOKEN:+ORCA_AGENT_LAUNCH_TOKEN=\"$ORCA_AGENT_LAUNCH_TOKEN\"} \\\n ${ORCA_AGENT_SESSION_ID:+ORCA_AGENT_SESSION_ID=\"$ORCA_AGENT_SESSION_ID\"} \\\n timeout 15 \"$node\" \"$cli\" sender --json 2>/dev/null || true)\"\ndevice=\"$(jq -r '.result.sender.deviceId // empty' <<<\"$answer\" 2>/dev/null || true)\"\nif [ -z \"$device\" ] || [ ! -s \"$DIR/devices.json\" ]; then\n if [ -n \"${ORCA_GIT_AS:-}\" ]; then\n echo \"orca-git-as: Orca cannot confirm that $ORCA_GIT_AS sent this agent's latest message; refusing to push as them.\" >&2\n exit 4\n fi\n exit 3\nfi\nmember=\"$(jq -r --arg d \"$device\" '.[$d] // empty' \"$DIR/devices.json\")\"\nif [ -z \"$member\" ]; then\n [ -n \"${ORCA_GIT_AS:-}\" ] && { echo \"orca-git-as: the sender's device is not linked to a workspace member; refusing.\" >&2; exit 4; }\n exit 3\nfi\nif [ -n \"${ORCA_GIT_AS:-}\" ] && [ \"$ORCA_GIT_AS\" != \"$member\" ]; then\n echo \"orca-git-as: this agent's latest message came from $member, not $ORCA_GIT_AS; refusing to push as someone else.\" >&2\n exit 4\nfi\nfile=\"$DIR/$(printf '%s' \"$member\" | tr ':' '_').gitconfig\"\n[ -s \"$file\" ] || exit 3\n# The caller reads the identity through this one-shot copy (owned by them, removed by orca-git-as).\nout=\"$(sudo -u \"$caller\" mktemp)\"\ncat \"$file\" >\"$out\"\necho \"$out\"\n","orcad-boot":"#!/usr/bin/env node\n// Starts orcad from a V8 code cache taken after a full startup, so a boot skips compiling the\n// functions startup runs. NODE_COMPILE_CACHE only stores what V8 compiles before the bundle runs\n// (about 5 of its 15 MB); every other function compiled lazily on every boot (~300 ms of wake).\n// orcad-boot --code-cache [--write-code-cache] [orcad args...]\n// orcad sees the argv `node [orcad args...]`, as if started directly.\n// --write-code-cache (the image's smoke run) writes the cache when orcad exits. The cache is used\n// only when its header matches this bundle's bytes and this Node; otherwise orcad loads exactly as\n// before (plain require, which NODE_COMPILE_CACHE still serves). Plain Node, no dependencies.\n// getBuiltinModule (Node >= 22.3) works whether node loads this extensionless file as CommonJS\n// (in the VM) or as an ES module (under this repo's type:module package, in tests).\nconst fs = process.getBuiltinModule('node:fs')\nconst path = process.getBuiltinModule('node:path')\nconst vm = process.getBuiltinModule('node:vm')\nconst zlib = process.getBuiltinModule('node:zlib')\nconst { Module, createRequire } = process.getBuiltinModule('node:module')\n\nconst MAGIC = 'orcad-code-cache-v1'\n\nconst args = process.argv.slice(2)\nlet cacheFile = null\nlet writeCache = false\nwhile (args[0] === '--code-cache' || args[0] === '--write-code-cache') {\n if (args[0] === '--code-cache') {\n cacheFile = args[1]\n args.splice(0, 2)\n } else {\n writeCache = true\n args.shift()\n }\n}\nconst entry = args.shift()\nif (!entry || !cacheFile) {\n console.error('usage: orcad-boot --code-cache [--write-code-cache] [args...]')\n process.exit(2)\n}\n// As for `node `: argv[1] is the resolved path, __filename the real one.\nprocess.argv = [process.argv[0], path.resolve(entry), ...args]\nlet file = process.argv[1]\ntry {\n file = fs.realpathSync(file)\n} catch {\n // A missing bundle fails in require below, as it would under plain node.\n}\n\n// One line of identity: a cache from another bundle or Node must never run (V8 only checks length).\nfunction cacheHeader(bytes) {\n return `${MAGIC} ${process.version} ${process.arch} ${bytes.length} ${zlib.crc32(bytes)}\\n`\n}\n\nfunction readCache(header) {\n let data\n try {\n data = fs.readFileSync(cacheFile)\n } catch {\n return null\n }\n const headerLength = Buffer.byteLength(header)\n if (data.length <= headerLength || data.toString('latin1', 0, headerLength) !== header) {\n console.error('orcad-boot: code cache is for another orcad build; loading without it')\n return null\n }\n return data.subarray(headerLength)\n}\n\nfunction runCompiled(header, source, cachedData) {\n // A vm.Script can serialize what startup compiled; the CommonJS loader cannot. Same wrapper,\n // filename and require as the loader, and import() resolves from the bundle like it does there.\n const options = {\n filename: file,\n cachedData: cachedData ?? undefined,\n importModuleDynamically: vm.constants.USE_MAIN_CONTEXT_DEFAULT_LOADER\n }\n // Node flags the loader option experimental once, on the first import() through it. Spend that\n // one-time warning here, unprinted, so orcad's stderr stays as it was.\n const emitWarning = process.emitWarning\n process.emitWarning = (warning, ...rest) => {\n if (!String(warning?.message ?? warning).includes('USE_MAIN_CONTEXT_DEFAULT_LOADER')) {\n emitWarning.call(process, warning, ...rest)\n }\n }\n let script\n try {\n new vm.Script('import(\"node:path\")', {\n filename: file,\n importModuleDynamically: options.importModuleDynamically\n })\n .runInThisContext()\n .catch(() => {})\n script = new vm.Script(Module.wrap(source), options)\n } finally {\n process.emitWarning = emitWarning\n }\n if (cachedData && script.cachedDataRejected) {\n console.error('orcad-boot: V8 rejected the code cache (other V8 flags?); compiled without it')\n }\n if (writeCache) {\n process.on('exit', () => {\n try {\n const data = script.createCachedData()\n const temporary = `${cacheFile}.${process.pid}.tmp`\n fs.writeFileSync(temporary, Buffer.concat([Buffer.from(header, 'latin1'), data]))\n fs.renameSync(temporary, cacheFile)\n console.error(`orcad-boot: wrote ${data.length} bytes of code cache`)\n } catch (error) {\n console.error(`orcad-boot: could not write the code cache: ${error.message}`)\n }\n })\n }\n const module = new Module(file, null)\n module.filename = file\n module.paths = Module._nodeModulePaths(path.dirname(file))\n const compiled = script.runInThisContext()\n compiled.call(module.exports, module.exports, createRequire(file), module, file, path.dirname(file))\n}\n\n// Locals, so neither the bundle bytes nor the cache outlive the compile.\nfunction boot() {\n // Older Node (no crc32 or main-context import()) loads as before.\n if (typeof zlib.crc32 === 'function' && vm.constants?.USE_MAIN_CONTEXT_DEFAULT_LOADER) {\n let bytes = null\n try {\n bytes = fs.readFileSync(file)\n } catch {\n // Missing or unreadable: require reports it as plain node would.\n }\n if (bytes) {\n const header = cacheHeader(bytes)\n const cachedData = readCache(header)\n if (cachedData || writeCache) {\n runCompiled(header, bytes.toString('utf8'), cachedData)\n return\n }\n }\n }\n createRequire(file)(file)\n}\n\nboot()\n","git":"#!/usr/bin/env bash\n# git for agents in Orca Cloud workspaces (first on orcad's PATH). Commands that author or publish\n# work run as the teammate whose message started this agent's turn (orca-git-as: orcad attests the\n# sender's paired device; root-only member identities). Everything else is plain git, untouched.\nreal=\"${ORCA_REAL_GIT:-/usr/bin/git}\"\nsub=''\nskip=0\nfor arg in \"$@\"; do\n if [ \"$skip\" = 1 ]; then skip=0; continue; fi\n case \"$arg\" in\n -C | -c | --git-dir | --work-tree | --namespace | --exec-path | --config-env) skip=1 ;;\n -*) ;;\n *) sub=\"$arg\"; break ;;\n esac\ndone\n# CREATE-GIT-ASYNC: a new workspace answers before teammates' GitHub identities are installed. Until\n# they are (orca-sandbox-members writes git-setup.ready), commits and pushes wait up to 15 s rather\n# than go out under the workspace identity; a failed or stalled setup stops them with what to do.\n# Read-only git and fetches never wait (they use the workspace's own credentials, set before ready).\nhold_for_member_setup() {\n local state=\"${HOME:-/nonexistent}/.orca-sandbox\" ticks=0 since\n # A failed install on any boot (create or a later restart) refuses even with nothing pending.\n [ -f \"$state/git-setup.pending\" ] || [ -f \"$state/git-setup.failed\" ] || return 0\n since=\"$(cat \"$state/git-setup.pending\" 2>/dev/null || echo 0)\"\n case \"$since\" in '' | *[!0-9]*) since=0 ;; esac\n while [ -f \"$state/git-setup.pending\" ] && [ ! -f \"$state/git-setup.ready\" ] && [ ! -f \"$state/git-setup.failed\" ]; do\n # 15 s per command; a mark older than 2 min is a setup that died, not one still running.\n if [ \"$ticks\" -ge \"${ORCA_GIT_SETUP_WAIT_TICKS:-150}\" ] || [ $(($(date +%s) - since)) -gt 120 ]; then\n break\n fi\n sleep 0.1\n ticks=$((ticks + 1))\n done\n if [ -f \"$state/git-setup.ready\" ] || { [ ! -f \"$state/git-setup.pending\" ] && [ ! -f \"$state/git-setup.failed\" ]; }; then\n return 0\n fi\n if [ -f \"$state/git-setup.failed\" ]; then\n printf '%s\\n' \"orca: Orca couldn't set up your GitHub identity in this sandbox, so it stopped \\`git $sub\\` rather than use the wrong identity. Restart this workspace (right-click it in the sidebar > Restart), then try again.\" >&2\n else\n printf '%s\\n' \"orca: Orca is still setting up your GitHub identity in this sandbox, so it stopped \\`git $sub\\` rather than use the wrong identity. Try again in a few seconds. If this keeps happening, restart this workspace (right-click it in the sidebar > Restart).\" >&2\n fi\n exit 1\n}\n# A commit under the workspace identity (Orca Cloud's name) says once per session how to use your own\n# (design-squad). /dev/shm is per boot: a wake or restart says it again.\nnudge_once() {\n [ \"$sub\" = commit ] || return 0\n local mark=\"${ORCA_GIT_NUDGE_DIR:-/dev/shm}/orca-git-nudge-$(id -u)\"\n [ -e \"$mark\" ] && return 0\n : >\"$mark\" 2>/dev/null || return 0\n printf '%s\\n' \"orca: This commit uses Orca Cloud's name. Connect your GitHub in Settings → Account to use yours.\" >&2\n}\n# PUSH-RAW-PROMPT: a push, pull or fetch to github.com that no credential covers (neither the person's\n# connected GitHub nor the App installation) must never fall through to git's own \"Username for\n# 'https://github.com':\" prompt. Prompts are off for that remote, and git's auth failure becomes one\n# plain line saying what to do.\ngithub_slug=''\ngithub_remote_slug() {\n local arg skip=0 seen=0 remote='' url\n for arg in \"$@\"; do\n if [ \"$skip\" = 1 ]; then skip=0; continue; fi\n if [ \"$seen\" = 0 ]; then\n case \"$arg\" in\n -C | -c | --git-dir | --work-tree | --namespace | --exec-path | --config-env) skip=1 ;;\n \"$sub\") seen=1 ;;\n esac\n continue\n fi\n case \"$arg\" in\n --repo=*) remote=\"${arg#--repo=}\"; break ;;\n -o | --push-option | --receive-pack | --upload-pack | --exec | --depth | --deepen | --shallow-since | --shallow-exclude | --refmap | --recurse-submodules-default | -j | --jobs) skip=1 ;;\n -*) ;;\n *) remote=\"$arg\"; break ;;\n esac\n done\n [ -n \"$remote\" ] || remote=\"$(\"$real\" config --get \"branch.$(\"$real\" symbolic-ref --short -q HEAD 2>/dev/null).remote\" 2>/dev/null || true)\"\n [ -n \"$remote\" ] || remote=origin\n case \"$remote\" in\n *://*) url=\"$remote\" ;;\n *) url=\"$(\"$real\" remote get-url \"$remote\" 2>/dev/null || printf '%s' \"$remote\")\" ;;\n esac\n case \"$url\" in\n https://github.com/* | https://*@github.com/*) url=\"${url#*github.com/}\" ;;\n *) return 0 ;;\n esac\n url=\"${url%.git}\"\n url=\"${url%/}\"\n case \"$url\" in */*/*) url=\"${url%%/*}/$(printf '%s' \"${url#*/}\" | cut -d/ -f1)\" ;; esac\n case \"$url\" in [A-Za-z0-9_.-]*/[A-Za-z0-9_.-]*) github_slug=\"$url\" ;; esac\n}\nno_access_line() {\n local owner=\"${github_slug%%/*}\" verb='push to'\n [ \"$sub\" = push ] || verb='read'\n printf '%s\\n' \"orca: Orca Cloud can't $verb $github_slug. Install the Orca GitHub App on $owner, or connect your GitHub in Settings → Account.\" >&2\n}\n# GitHub's \"not found\" is a typo'd name or a private repo it won't confirm: say which half fits whom.\nnot_found_line() {\n printf '%s\\n' \"orca: Orca Cloud can't find $github_slug. Check the name, or install the Orca GitHub App on ${github_slug%%/*} if it's private.\" >&2\n}\n# orca-git-as's own fallback line is replaced by the nudge; GitHub auth failures by the plain line.\nfallback_filter() {\n local line said=0\n while IFS= read -r line || [ -n \"$line\" ]; do\n case \"$line\" in\n 'orca-git-as: no teammate identity'*) nudge_once ;;\n 'remote: Repository not found.'* | \"fatal: repository 'https://github.com/\"*\"' not found\"*)\n if [ -n \"$github_slug\" ]; then\n [ \"$said\" = 1 ] || not_found_line\n said=1\n else\n printf '%s\\n' \"$line\"\n fi\n ;;\n *\"could not read Username for 'https://github.com'\"* | *\"could not read Password for 'https://\"*\"github.com'\"* | \\\n *\"Authentication failed for 'https://github.com/\"* | 'remote: Permission to '*' denied to '* | \\\n *\"The requested URL returned error: 403\"*)\n if [ -n \"$github_slug\" ]; then\n [ \"$said\" = 1 ] || no_access_line\n said=1\n else\n printf '%s\\n' \"$line\"\n fi\n ;;\n *) printf '%s\\n' \"$line\" ;;\n esac\n done\n}\ncase \"$sub\" in\n push | pull | fetch)\n github_remote_slug \"$@\"\n if [ -n \"$github_slug\" ]; then\n export GIT_TERMINAL_PROMPT=0 GCM_INTERACTIVE=never\n fi\n ;;\nesac\ngit_as=\"${ORCA_GIT_AS:-/opt/orca/bin/orca-git-as}\"\ncase \"$sub\" in\n commit | push | merge | rebase | cherry-pick | revert | am | pull | tag)\n hold_for_member_setup\n # Outside an agent turn (no attested sender) orca-git-as falls back to the workspace identity.\n if [ -n \"${ORCA_TERMINAL_HANDLE:-}${ORCA_AGENT_SESSION_ID:-}\" ] && [ -x \"$git_as\" ]; then\n PATH=\"/usr/bin:/bin:$PATH\" exec \"$git_as\" \"$@\" 2> >(fallback_filter >&2)\n fi\n # No terminal or agent turn to attribute it to: the workspace identity.\n nudge_once\n ;;\nesac\nif [ -n \"$github_slug\" ]; then\n exec \"$real\" \"$@\" 2> >(fallback_filter >&2)\nfi\nexec \"$real\" \"$@\"\n"} +export const VM_SCRIPTS_HASH: string = "f4d18985" +export const VM_SCRIPTS_STAMP: number = 1794532639 export const SOURCE_BUILD_SCRIPT: string = "#!/usr/bin/env bash\n# Golden build from source, run by the Worker inside the build sandbox: orcad + the orca CLI from one\n# commit of the Orca repo (cloud/integration carries host RPCs no upstream release has), then the same\n# provision-golden.sh every image uses. Inputs (env):\n# ORCA_SRC_SHA (40-hex) and either ORCA_SRC_DIR (source already uploaded) or ORCA_SRC_REPO\n# (https://github.com//) + ORCA_SRC_AUTH (an\n# `Authorization: Basic …` header value for a 1 h read-only installation token; never printed),\n# ORCA_PROVISION_SCRIPT (base64 of provision-golden.sh) + everything provision-golden.sh reads.\nset -euo pipefail\n# Vercel can report a detached build command as still running after it exits, so the outcome is also\n# a file the caller polls: /tmp/source-build.exit (exit code), /tmp/source-build.report (the\n# provision report line).\nrm -f /tmp/source-build.exit /tmp/source-build.report\ntrap 'echo $? >/tmp/source-build.exit' EXIT\nlog() { printf '[source-build] %s\\n' \"$*\" >&2; }\nstart=$(date +%s)\n[[ \"$ORCA_SRC_SHA\" =~ ^[0-9a-f]{40}$ ]] || { log 'ORCA_SRC_SHA must be a full commit sha'; exit 2; }\n\nlog 'build tools'\nif command -v apt-get >/dev/null; then\n # Transient mirror errors and dpkg lock contention made apt exit 100 (2 of ~10 golden builds):\n # retry downloads and wait for the lock instead of failing the build.\n printf 'Acquire::Retries \"5\";\\nDPkg::Lock::Timeout \"180\";\\n' | sudo tee /etc/apt/apt.conf.d/80-orca-retries >/dev/null\n sudo apt-get update -qq\n sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends build-essential python3 git ca-certificates >/dev/null\nelse\n sudo dnf install -y -q gcc gcc-c++ make tar gzip python3 git >/dev/null\nfi\nsudo corepack enable >/dev/null 2>&1 || true\n\nsrc=\"$HOME/orca-src\"\nif [ -n \"${ORCA_SRC_DIR:-}\" ]; then\n # Operator rotation (image/rotate-golden-from-source.ts) uploaded `git archive ` here already.\n log \"uploaded source for $ORCA_SRC_SHA\"\n src=\"$ORCA_SRC_DIR\"\n cd \"$src\" && test -f package.json\n git init -q && git add -A && git -c user.email=build@orca -c user.name=build commit -qm \"$ORCA_SRC_SHA\"\nelse\n log \"fetch $ORCA_SRC_SHA\"\n rm -rf \"$src\" && mkdir -p \"$src\" && cd \"$src\"\n git init -q\n # The header lives only in this one command's config, not in .git/config.\n git -c \"http.https://github.com/.extraheader=$ORCA_SRC_AUTH\" fetch -q --depth 1 \"$ORCA_SRC_REPO\" \"$ORCA_SRC_SHA\"\n unset ORCA_SRC_AUTH\n git -c advice.detachedHead=false checkout -q FETCH_HEAD\n test \"$(git rev-parse HEAD)\" = \"$ORCA_SRC_SHA\"\nfi\n\n# The image files come from this commit too (VM scripts, provision, manifest pins). The env the\n# caller passed is only a fallback for commits from before commit-provision-env.mjs existed.\nimage_dir=\"$src/cloud/apps/sandbox-control/image\"\nif [ -f \"$image_dir/commit-provision-env.mjs\" ]; then\n # The stamp must describe this commit's scripts, never the Worker's: a commit from before stamps\n # leaves it unset (the image then reads as stamp 0).\n unset ORCA_VM_SCRIPTS_STAMP\n node \"$image_dir/commit-provision-env.mjs\" \"$image_dir\" >/tmp/commit-provision.env\n # shellcheck disable=SC1091\n . /tmp/commit-provision.env\n rm -f /tmp/commit-provision.env\n log \"image files from $ORCA_SRC_SHA\"\nfi\n\nlog 'build orcad + CLI'\nexport CI=1 ELECTRON_SKIP_BINARY_DOWNLOAD=1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1\ncorepack pnpm install --frozen-lockfile >/tmp/src-install.log 2>&1 || { tail -40 /tmp/src-install.log >&2; exit 1; }\ncorepack pnpm -C mobile install --frozen-lockfile >/tmp/src-mobile.log 2>&1 || { tail -40 /tmp/src-mobile.log >&2; exit 1; }\ncorepack pnpm build:mobile-web >/tmp/src-mobile-web.log 2>&1 || { tail -40 /tmp/src-mobile-web.log >&2; exit 1; }\ncorepack pnpm build:orcad >/tmp/src-orcad.log 2>&1 || { tail -60 /tmp/src-orcad.log >&2; exit 1; }\ncorepack pnpm build:cli >/tmp/src-cli.log 2>&1 || { tail -40 /tmp/src-cli.log >&2; exit 1; }\nmkdir -p out/orca-cli\nnode_modules/.bin/esbuild out/cli/index.js --bundle --platform=node --target=node22 --format=cjs \\\n --outfile=out/orca-cli/orca-cli.cjs --log-level=error\nprintf '%s\\n' \"$ORCA_SRC_SHA\" >out/orcad/SOURCE_COMMIT\ntar czf /tmp/orcad.tgz -C out orcad runtimes orca-cli\nlog \"built in $(( $(date +%s) - start ))s\"\n\nexport ORCAD_BUNDLE_URL=file:///tmp/orcad.tgz\nORCAD_BUNDLE_SHA256=\"$(sha256sum /tmp/orcad.tgz | cut -d' ' -f1)\"\nexport ORCAD_BUNDLE_SHA256\ncd \"$HOME\"\nprintf '%s' \"$ORCA_PROVISION_SCRIPT\" | base64 -d >/tmp/provision-golden.sh\nunset ORCA_PROVISION_SCRIPT\nbash /tmp/provision-golden.sh | tee /tmp/source-build.report\n# Build leftovers stay out of the image.\nrm -rf \"$src\" \"$HOME/.local/share/pnpm\" \"$HOME/.cache/pnpm\" /tmp/orcad.tgz /tmp/provision-golden.sh /tmp/src-*.log\nlog \"done in $(( $(date +%s) - start ))s\"\n" export const ENVIRONMENT_BUILD_SCRIPT: string = "#!/usr/bin/env bash\n# Environment build (\"Cloud Computer\"), run by the control plane in a VM booted from the golden image.\n# Clones every repo, runs the org's install script once with `set -euo pipefail`, then records what it\n# built from (commit + lockfile blob ids per repo) so the control plane can tell when a rebuild is due.\n# Everything printed here streams live to the desktop build log.\n#\n# Env from the control plane (validated there): ORCA_BUILD_REPOS (`|||\n# ` lines: each private repo gets its own placeholder the egress swaps), ORCA_INSTALL_SCRIPT_B64, ORCA_LOCKFILES (space list), optional ORCA_GIT_EXTRAHEADER and ORCA_CLONE_FILTER, plus\n# the org secrets the environment names. `::orca-step:: ` lines mark progress.\n# Exit codes: 10 clone, 20 install script, 30 recording the result.\nset -uo pipefail\numask 022\n# One stream: the provider replays stdout and stderr separately after the fact, so a failed build's\n# log would read out of order. `::orca-error::` lines carry Orca's own failure explanations.\nexec 2>&1\nOUT=/tmp/orca-build\nROOT=/vercel/sandbox/repos\nmkdir -p \"$OUT\" \"$ROOT\"\nstep() { printf '\\n::orca-step:: %s\\n' \"$1\"; }\nstarted=$(date +%s)\nexport PATH=\"/opt/orca/tools/bin:/opt/orca/bin:$PATH\"\nexport ORCA_REPOS_ROOT=\"$ROOT\" ORCA_ENVIRONMENT_BUILD=1 CI=1 GIT_TERMINAL_PROMPT=0\nrepos() { printf '%s\\n' \"$ORCA_BUILD_REPOS\"; }\n\n# Tell the script author (often an agent) what they are installing onto.\npm=$(for c in apt-get dnf yum apk; do command -v \"$c\" >/dev/null 2>&1 && { echo \"$c\"; break; }; done)\necho \"Base image: $(. /etc/os-release 2>/dev/null && echo \"$PRETTY_NAME\"), $(nproc) vCPU, package manager: ${pm:-none} (with sudo)\"\n\nstep clone\nfirst=\"\"\nwhile IFS='|' read -r url branch dir header; do\n [ -n \"$url\" ] || continue\n dest=\"$ROOT/$dir\"\n [ -n \"$first\" ] || first=\"$dest\"\n # The egress layer swaps the placeholder for a short-lived repo token; nothing real is on disk,\n # and the header is never written to the repo's config.\n git_auth=()\n [ -z \"${header:-}\" ] || git_auth=(-c \"http.https://github.com/.extraheader=$header\")\n echo \"Cloning $url${branch:+ ($branch)}\"\n rm -rf -- \"$dest\"\n # The clone's own error shows only when it really failed (an empty repo is recovered below).\n # ORCA_CLONE_FILTER (automatic environments: blob:none) keeps history and trees, not every old blob.\n if ! git \"${git_auth[@]}\" clone --quiet ${ORCA_CLONE_FILTER:+--filter=\"$ORCA_CLONE_FILTER\"} ${branch:+--branch \"$branch\"} -- \"$url\" \"$dest\" 2>\"$OUT/clone.err\"; then\n refs=0\n git \"${git_auth[@]}\" ls-remote --exit-code -- \"$url\" >/dev/null 2>&1 || refs=$?\n if [ -n \"$branch\" ] && [ \"$refs\" -eq 2 ]; then\n # Reachable but no refs at all (ls-remote exits 2): a repo with no commits yet.\n echo \"$url has no commits yet; starting it empty on $branch.\"\n rm -rf -- \"$dest\"\n { git \"${git_auth[@]}\" clone --quiet -- \"$url\" \"$dest\" 2>/dev/null && git -C \"$dest\" checkout --quiet -b \"$branch\"; } || exit 10\n else\n cat \"$OUT/clone.err\"\n echo \"::orca-error:: Could not clone $url${branch:+ at $branch}. Check that the branch exists and Orca Cloud can reach the repository.\" \n exit 10\n fi\n fi\n git -C \"$dest\" log -1 --format=' at %h %s' 2>/dev/null || echo ' (no commits yet)'\ndone < <(repos)\n\nstep install\nprintf '%s' \"${ORCA_INSTALL_SCRIPT_B64:-}\" | base64 -d >\"$OUT/install.sh\"\nif [ -s \"$OUT/install.sh\" ]; then\n echo \"Running the install script in ${first:-$ROOT}\"\n (cd \"${first:-$ROOT}\" && exec bash -euo pipefail \"$OUT/install.sh\") /dev/null)\" || continue\n git -C \"$dest\" ls-files --others --ignored --exclude-standard --directory 2>/dev/null \\\n | sed 's#/$##' | grep -E \"$deps_re\" | head -n 500 >\"$common/orca-env-deps\" || true\n if [ -n \"${ORCA_DEPS_HOOK_B64:-}\" ]; then\n # A relative core.hooksPath (husky: .husky/_) names a gitignored folder a new worktree lacks; pin\n # it to the primary checkout so every worktree runs the same hooks, ours included.\n rel=\"$(git -C \"$dest\" config --get core.hooksPath 2>/dev/null || true)\"\n case \"$rel\" in '' | /*) ;; *) git -C \"$dest\" config core.hooksPath \"$dest/$rel\" ;; esac\n hooks=\"$(git -C \"$dest\" rev-parse --path-format=absolute --git-path hooks)\"\n mkdir -p \"$hooks\"\n if [ -e \"$hooks/post-checkout\" ] && ! grep -q 'orca-env-deps' \"$hooks/post-checkout\"; then\n mv -- \"$hooks/post-checkout\" \"$hooks/post-checkout.orca-chained\"\n fi\n printf '%s' \"$ORCA_DEPS_HOOK_B64\" | base64 -d >\"$hooks/post-checkout\" && chmod 0755 \"$hooks/post-checkout\"\n fi\n n=$(wc -l <\"$common/orca-env-deps\" | tr -d ' ')\n [ \"$n\" -gt 0 ] && echo \"$dir: $n installed folder(s) carried into new workspaces.\"\ndone < <(repos)\n{\n printf '{\"durationSec\":%d,\"repos\":[' \"$(( $(date +%s) - started ))\"\n sep=\"\"\n while IFS='|' read -r url branch dir _; do\n [ -n \"$url\" ] || continue\n dest=\"$ROOT/$dir\"\n commit=$(git -C \"$dest\" rev-parse --verify --quiet HEAD 2>/dev/null || true)\n head=$(git -C \"$dest\" symbolic-ref --quiet --short HEAD 2>/dev/null || true)\n printf '%s{\"url\":\"%s\",\"dir\":\"%s\",\"branch\":\"%s\",\"commit\":\"%s\",\"lockfiles\":{' \"$sep\" \"$url\" \"$dir\" \"$head\" \"$commit\"\n lsep=\"\"\n if [ -n \"$commit\" ]; then\n for f in ${ORCA_LOCKFILES:-}; do\n blob=$(git -C \"$dest\" rev-parse --verify --quiet \"HEAD:$f\" 2>/dev/null || true)\n if [ -n \"$blob\" ]; then printf '%s\"%s\":\"%s\"' \"$lsep\" \"$f\" \"$blob\"; lsep=\",\"; fi\n done\n fi\n printf '}}'\n sep=\",\"\n done < <(repos)\n printf '],\"diskUsedBytes\":%s,\"diskTotalBytes\":%s}\\n' \\\n \"$(df -B1 --output=used / | tail -n 1 | tr -d ' ')\" \"$(df -B1 --output=size / | tail -n 1 | tr -d ' ')\"\n} >\"$OUT/result.json\" || exit 30\n# Nothing from this VM's own session belongs in the image, only what the script installed.\nrm -rf -- \"$OUT/install.sh\" \"$HOME/.bash_history\" \"$HOME/.orcad-data\" \"$HOME/.orca-sandbox\"\necho \"Disk used: $(df -h --output=used / | tail -n 1 | tr -d ' ') of $(df -h --output=size / | tail -n 1 | tr -d ' ')\"\n" export const ENVIRONMENT_DEPS_HOOK: string = "#!/usr/bin/env bash\n# Installed by Orca environment builds as the repo's post-checkout hook. When git creates a new linked\n# worktree (`git worktree add` passes the null commit as the previous HEAD), give it the dependency\n# folders the build installed in the primary checkout, as hard links: near-instant, real paths (not\n# symlinks, so bundlers and native modules behave). Hard links share file contents: tools that rewrite\n# a file in place inside these folders change it for every worktree on this VM (one workspace per VM).\n# Any post-checkout hook the repo already had runs afterwards, unchanged.\nprev=\"${1:-}\"\nhere=\"$(dirname \"$0\")\"\nif [ \"$prev\" = \"0000000000000000000000000000000000000000\" ] && [ \"${ORCA_SKIP_DEPS_LINK:-}\" != 1 ]; then\n common=\"$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)\"\n top=\"$(git rev-parse --show-toplevel 2>/dev/null || true)\"\n primary=\"$(dirname \"$common\")\"\n list=\"$common/orca-env-deps\"\n if [ -n \"$top\" ] && [ \"$top\" != \"$primary\" ] && [ -f \"$list\" ]; then\n started=$(date +%s%N)\n linked=0\n while IFS= read -r rel; do\n case \"$rel\" in '' | /* | *..*) continue ;; esac\n [ -e \"$primary/$rel\" ] && [ ! -e \"$top/$rel\" ] || continue\n mkdir -p \"$(dirname \"$top/$rel\")\"\n if [ -d \"$primary/$rel\" ]; then\n if ! cp -al -- \"$primary/$rel\" \"$top/$rel\" 2>/dev/null; then\n # A partial hard-link copy would make the fallback nest inside it: start clean.\n rm -rf -- \"$top/$rel\"\n cp -a -- \"$primary/$rel\" \"$top/$rel\" 2>/dev/null || { rm -rf -- \"$top/$rel\"; continue; }\n fi\n else\n cp -a -- \"$primary/$rel\" \"$top/$rel\" 2>/dev/null || continue\n fi\n linked=$((linked + 1))\n done <\"$list\"\n if [ \"$linked\" -gt 0 ]; then\n echo \"Orca: reused $linked installed dependency folder(s) from the environment build ($(( ($(date +%s%N) - started) / 1000000 )) ms).\" >&2\n fi\n fi\nfi\nif [ -x \"$here/post-checkout.orca-chained\" ]; then\n if head -n 1 \"$here/post-checkout.orca-chained\" | grep -Eq '^#!.*(ba|da|z)?sh'; then\n # Sourced, so $0 still names `post-checkout` (husky v9 finds the user's hook by basename \"$0\").\n ( . \"$here/post-checkout.orca-chained\" \"$@\" )\n exit $?\n fi\n exec \"$here/post-checkout.orca-chained\" \"$@\"\nfi\nexit 0\n" diff --git a/cloud/apps/sandbox-control/image/vm/git b/cloud/apps/sandbox-control/image/vm/git index 2baa0706287..bba55f41db4 100755 --- a/cloud/apps/sandbox-control/image/vm/git +++ b/cloud/apps/sandbox-control/image/vm/git @@ -48,7 +48,7 @@ nudge_once() { local mark="${ORCA_GIT_NUDGE_DIR:-/dev/shm}/orca-git-nudge-$(id -u)" [ -e "$mark" ] && return 0 : >"$mark" 2>/dev/null || return 0 - printf '%s\n' "orca: This commit uses Orca Cloud's name. Connect your GitHub in Settings › Account to use yours." >&2 + printf '%s\n' "orca: This commit uses Orca Cloud's name. Connect your GitHub in Settings → Account to use yours." >&2 } # PUSH-RAW-PROMPT: a push, pull or fetch to github.com that no credential covers (neither the person's # connected GitHub nor the App installation) must never fall through to git's own "Username for @@ -91,7 +91,7 @@ github_remote_slug() { no_access_line() { local owner="${github_slug%%/*}" verb='push to' [ "$sub" = push ] || verb='read' - printf '%s\n' "orca: Orca Cloud can't $verb $github_slug. Install the Orca GitHub App on $owner, or connect your GitHub in Settings › Account." >&2 + printf '%s\n' "orca: Orca Cloud can't $verb $github_slug. Install the Orca GitHub App on $owner, or connect your GitHub in Settings → Account." >&2 } # GitHub's "not found" is a typo'd name or a private repo it won't confirm: say which half fits whom. not_found_line() { diff --git a/cloud/apps/sandbox-control/test/git-shim-github-no-prompt.test.ts b/cloud/apps/sandbox-control/test/git-shim-github-no-prompt.test.ts index b328489de5d..e5cda0b3b75 100644 --- a/cloud/apps/sandbox-control/test/git-shim-github-no-prompt.test.ts +++ b/cloud/apps/sandbox-control/test/git-shim-github-no-prompt.test.ts @@ -63,7 +63,7 @@ describe('the git shim never lets a github.com push prompt for credentials', () expect(err).not.toContain("Username for 'https://github.com'") expect(err).not.toContain('could not read Username') expect(err).toContain( - "orca: Orca Cloud can't push to octocat/Hello-World. Install the Orca GitHub App on octocat, or connect your GitHub in Settings › Account." + "orca: Orca Cloud can't push to octocat/Hello-World. Install the Orca GitHub App on octocat, or connect your GitHub in Settings → Account." ) }) diff --git a/cloud/apps/sandbox-control/test/git-shim-identity-nudge.test.ts b/cloud/apps/sandbox-control/test/git-shim-identity-nudge.test.ts index 0c3c6518104..6a3a88fc090 100644 --- a/cloud/apps/sandbox-control/test/git-shim-identity-nudge.test.ts +++ b/cloud/apps/sandbox-control/test/git-shim-identity-nudge.test.ts @@ -1,5 +1,5 @@ // #503 follow-up (design-squad): a commit that goes out under the workspace identity (Orca Cloud's -// name) says once per session how to use your own: connect GitHub in Settings › Account. +// name) says once per session how to use your own: connect GitHub in Settings → Account. // Pushes and read-only git never say it; orca-git-as's own fallback line is replaced by it. import { spawnSync } from 'node:child_process' import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' @@ -9,7 +9,7 @@ import { fileURLToPath } from 'node:url' import { afterEach, beforeEach, describe, expect, it } from 'vitest' const SHIM = fileURLToPath(new URL('../image/vm/git', import.meta.url).href) -const NUDGE = "orca: This commit uses Orca Cloud's name. Connect your GitHub in Settings › Account to use yours." +const NUDGE = "orca: This commit uses Orca Cloud's name. Connect your GitHub in Settings → Account to use yours." let home = '' let cwd = '' From d7549cf53021c5a07a5d03bb56328adee9454690 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 17:20:03 -0800 Subject: [PATCH 03/50] fix(cloud): SEC-6 secret writes need a fresh admin token, hints only for writers, redacting log sink for secret code --- .../apps/sandbox-control/src/egress-proxy.ts | 7 +- cloud/apps/sandbox-control/src/env-refresh.ts | 9 +- cloud/apps/sandbox-control/src/secret-log.ts | 87 +++++++++ cloud/apps/sandbox-control/src/secrets.ts | 63 ++++++- cloud/apps/sandbox-control/src/session-env.ts | 3 +- .../test/secrets-hardening.test.ts | 165 ++++++++++++++++++ .../cloud-secrets-client.test.ts | 36 ++++ src/main/cloud-secrets/cloud-secrets-ipc.ts | 4 +- .../hosted-sandbox/hosted-sandbox-request.ts | 34 +++- 9 files changed, 389 insertions(+), 19 deletions(-) create mode 100644 cloud/apps/sandbox-control/src/secret-log.ts create mode 100644 cloud/apps/sandbox-control/test/secrets-hardening.test.ts diff --git a/cloud/apps/sandbox-control/src/egress-proxy.ts b/cloud/apps/sandbox-control/src/egress-proxy.ts index ed81aeb48f6..b99bde5f4a6 100644 --- a/cloud/apps/sandbox-control/src/egress-proxy.ts +++ b/cloud/apps/sandbox-control/src/egress-proxy.ts @@ -4,6 +4,7 @@ // enter the VM or Vercel's policy store. import { createRemoteJWKSet, decodeJwt, jwtVerify, type JWTVerifyGetKey } from 'jose' import type { SandboxRow } from './store' +import { secretLog } from './secret-log' const VERCEL_OIDC_HOSTNAME = 'oidc.vercel.com' /** Hosts whose clients are Node (undici); git/curl hosts use transform rules instead. Model hosts are per session. */ @@ -144,7 +145,7 @@ export async function handleEgressProxy(request: Request, deps: EgressProxyDeps) } if (typeof credential !== 'string') { if (credential.answer) { - console.log(JSON.stringify({ event: 'egress_denied', sandboxId: row.id, host, reason: credential.deny })) + secretLog('egress_denied', { sandboxId: row.id, host, reason: credential.deny }) return new Response(credential.answer.body, { status: credential.answer.status, headers: { 'content-type': credential.answer.contentType } @@ -174,9 +175,7 @@ export async function handleEgressProxy(request: Request, deps: EgressProxyDeps) redirect: 'manual' }) // Names only: which sandbox, host and credential were swapped, and what the provider answered. - console.log( - JSON.stringify({ event: 'egress_swap', sandboxId: row.id, host, credential: route.label ?? route.header, status: upstream.status }) - ) + secretLog('egress_swap', { sandboxId: row.id, host, credential: route.label ?? route.header, status: upstream.status }, [credential]) route.onUpstreamStatus?.(upstream.status, { method: request.method, path }) if (route.keepResponseFields) { return onlyFields(upstream, route.keepResponseFields) diff --git a/cloud/apps/sandbox-control/src/env-refresh.ts b/cloud/apps/sandbox-control/src/env-refresh.ts index 6f386cc50d0..effb185137c 100644 --- a/cloud/apps/sandbox-control/src/env-refresh.ts +++ b/cloud/apps/sandbox-control/src/env-refresh.ts @@ -9,6 +9,7 @@ import { refreshSessionEnv } from './sandbox-vm-commands' import { normalizeRepo, type SecretChange } from './secrets' import { forgetProviderEnv, sessionSecretEnv } from './session-env' import { transition, type SandboxRow } from './store' +import { secretLog } from './secret-log' /** Running sandboxes whose sessions read secrets of this owner. */ export async function sandboxesForSecretOwner(db: D1Database, change: SecretChange): Promise { @@ -50,7 +51,7 @@ export async function envChangedSinceBoot(deps: LifecycleDeps, row: SandboxRow, const { env } = await sessionSecretEnv(deps, row) const same = Object.keys(env).length === Object.keys(bootEnv).length && Object.entries(env).every(([k, v]) => bootEnv[k] === v) if (!same) { - console.log(JSON.stringify({ event: 'session_env_changed_during_boot', sandboxId: row.id })) + secretLog('session_env_changed_during_boot', { sandboxId: row.id }) } return !same } @@ -89,11 +90,11 @@ export async function refreshSandboxEnv(deps: LifecycleDeps, row: SandboxRow): P try { const applied = await refreshSessionEnv(deps.provider, current.session_id!, secrets.env, generation) // Counts only. - console.log(JSON.stringify({ event: 'session_env_refreshed', sandboxId: row.id, applied, names: Object.keys(secrets.env).length })) + secretLog('session_env_refreshed', { sandboxId: row.id, applied, names: Object.keys(secrets.env).length }) return applied } catch (error) { if (error instanceof ProviderError) { - console.log(JSON.stringify({ event: 'session_env_refresh_failed', sandboxId: row.id, code: error.code })) + secretLog('session_env_refresh_failed', { sandboxId: row.id, code: error.code }) return false } throw error @@ -119,7 +120,7 @@ export async function refreshAfterSecretChange(deps: LifecycleDeps, changes: rea refreshed++ } } catch { - console.log(JSON.stringify({ event: 'session_env_refresh_failed', sandboxId: row.id })) + secretLog('session_env_refresh_failed', { sandboxId: row.id }) } } } diff --git a/cloud/apps/sandbox-control/src/secret-log.ts b/cloud/apps/sandbox-control/src/secret-log.ts new file mode 100644 index 00000000000..cdf56bac9d3 --- /dev/null +++ b/cloud/apps/sandbox-control/src/secret-log.ts @@ -0,0 +1,87 @@ +// SEC-6c: the only log sink for code that handles secret values (secrets.ts, session-env.ts, +// env-refresh.ts, egress-proxy.ts, secret-events.ts). Lines are structured JSON with names and +// counts by design; this layer is the backstop when a field slips through anyway, for example an +// error message or upstream body that quotes a key. Two passes over the serialized line: +// 1. exact values the caller is handling right now (`known`), replaced wherever they appear; +// 2. credential shapes (provider API keys, GitHub tokens, AWS key ids, JWTs, bearer headers). +// test/secret-log.test.ts fails when one of these files calls console directly, and when a +// planted value reaches the sink. + +const REDACTED = '[redacted]' + +/** Known credential shapes. Kept specific so names, ids and counts in log lines survive. */ +const PATTERNS: readonly RegExp[] = [ + // Anthropic, OpenAI (incl. sk-proj-), OpenRouter and other `sk-` keys. + /\bsk-[A-Za-z0-9_-]{16,}/g, + // GitHub tokens: classic, OAuth, app installation/user, refresh and fine-grained PATs. + /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})/g, + // AWS access key ids. + /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g, + // Google API keys. + /\bAIza[A-Za-z0-9_-]{30,}/g, + // Slack tokens. + /\bxox[abposr]-[A-Za-z0-9-]{10,}/g, + // Vercel AI Gateway keys. + /\bvck_[A-Za-z0-9]{20,}/g, + // JWTs (session tokens, OIDC tokens). + /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, + // Authorization header values. + /\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{12,}/gi +] + +/** Shorter values are not matched by exact text: they would erase ordinary words and ids. */ +const MIN_KNOWN_LENGTH = 8 + +function escapeJson(value: string): string { + // The value as it appears inside a JSON string (quotes, backslashes, control characters escaped). + return JSON.stringify(value).slice(1, -1) +} + +export function redactLine(line: string, known: Iterable = []): string { + let out = line + const values = [...new Set(known)].filter((value) => value.length >= MIN_KNOWN_LENGTH) + // Longest first, so a value that contains another is removed whole. + values.sort((a, b) => b.length - a.length) + for (const value of values) { + for (const form of new Set([value, escapeJson(value)])) { + out = out.split(form).join(REDACTED) + } + } + for (const pattern of PATTERNS) { + out = out.replace(pattern, (match, scheme?: string) => + typeof scheme === 'string' && /^(bearer|basic)$/i.test(scheme) ? `${scheme} ${REDACTED}` : REDACTED + ) + } + return out +} + +/** Error-like values become their name and message (redacted with the rest), never their stack. */ +function serializable(value: unknown): unknown { + if (value instanceof Error) { + return { name: value.name, message: value.message } + } + return value +} + +export type SecretLogSink = (line: string) => void +let sink: SecretLogSink = (line) => console.log(line) + +/** Tests only: capture lines exactly as they would reach Workers logs. Returns a restore function. */ +export function setSecretLogSink(next: SecretLogSink): () => void { + const previous = sink + sink = next + return () => { + sink = previous + } +} + +/** One structured log line. `known`: values in scope at the call site that must never be written. */ +export function secretLog(event: string, fields: Record = {}, known: Iterable = []): void { + let line: string + try { + line = JSON.stringify({ event, ...fields }, (_key, value: unknown) => serializable(value)) + } catch { + line = JSON.stringify({ event, unserializable: true }) + } + sink(redactLine(line, known)) +} diff --git a/cloud/apps/sandbox-control/src/secrets.ts b/cloud/apps/sandbox-control/src/secrets.ts index 81b765b1d5d..1fc5b089235 100644 --- a/cloud/apps/sandbox-control/src/secrets.ts +++ b/cloud/apps/sandbox-control/src/secrets.ts @@ -9,8 +9,10 @@ import { z } from 'zod' import type { SandboxCaller } from './auth' import type { Env } from './env' import { createKeyring, type Keyring, type SecretBinding } from './secrets-crypto' +import { AUTH_IAT_BACKDATE_MS } from './member-changes' import { BASE_URL_NAMES, baseUrlHost } from './provider-env' import type { SandboxRow } from './store' +import { secretLog } from './secret-log' export type SecretScope = 'org' | 'personal' | 'repo' export type SecretKind = 'env' | 'agent_key' @@ -122,10 +124,30 @@ export function checkValue(name: string, value: string): void { } } +/** + * SEC-6b: the last 4 characters of values of 16+ characters, stored in plaintext beside the ciphertext. + * Kept deliberately: owners and admins use it to tell which key is saved (as GitHub, Stripe and AWS + * consoles do), it reveals at most 4 characters of a value with at least 12 more, and it is useless for + * calling a provider. Shorter values get no hint. Only people who can write a scope see its hints: team + * members get `hint: null` for Shared and team Repository secrets (`hintVisible`). Encrypting it under + * the row's DEK would add nothing against someone holding the DB and the KEK, who can open the value. + */ function hintFor(value: string): string | null { return value.length >= 16 ? value.slice(-4) : null } +/** Whether `caller` may see last-4 hints in `scope`: the same people who may write it. */ +export function hintVisible(caller: SandboxCaller, scope: SecretScope): boolean { + switch (scope) { + case 'personal': + return true + case 'org': + return isOrgAdmin(caller) + case 'repo': + return !caller.orgId || isOrgAdmin(caller) + } +} + const REPO_PART = /^[A-Za-z0-9_.-]{1,100}$/ /** `owner/name`, `github.com/owner/name` or an https GitHub URL -> `github.com/owner/name` (lowercase). */ @@ -342,13 +364,13 @@ export async function resolveSandboxSecrets( } catch { skipped++ // Name and scope only: never the value or ciphertext. - console.log(JSON.stringify({ event: 'secret_unreadable', sandboxId: row.id, scope: record.scope, name: record.name })) + secretLog('secret_unreadable', { sandboxId: row.id, scope: record.scope, name: record.name }) continue } bytes += record.name.length + value.length if (bytes > MAX_INJECTED_BYTES) { skipped++ - console.log(JSON.stringify({ event: 'secret_injection_capped', sandboxId: row.id, name: record.name })) + secretLog('secret_injection_capped', { sandboxId: row.id, name: record.name }, [value]) continue } env[record.name] = value @@ -398,13 +420,14 @@ export async function effectiveSecrets(db: D1Database, caller: SandboxCaller, re const kind = kindOf(name) const rank = RANK[kind] const [winner, ...rest] = records.sort((a, b) => rank[b.scope] - rank[a.scope]) + const hint = (record: SecretRecord) => (hintVisible(caller, record.scope) ? record.hint : null) return { name, kind, scope: winner!.scope, - hint: winner!.hint, + hint: hint(winner!), updatedAt: winner!.updated_at, - shadowed: rest.map((record) => ({ scope: record.scope, hint: record.hint })) + shadowed: rest.map((record) => ({ scope: record.scope, hint: hint(record) })) } }) } @@ -427,6 +450,21 @@ export function keyringFor(env: Env): Promise | null { return cachedKeyring.keyring } +/** + * SEC-6a: admin-only writes (Shared, and a team's Repository secrets) need a token minted within the + * last minute. Auth's role-changed push already makes older tokens stale at once (index.ts, + * tokenMintedBeforeMemberChange); this bounds a demoted admin to under a minute even if that push is + * late or lost. The desktop re-mints and retries once on `stale_token` (hosted-sandbox-request.ts). + */ +export const ADMIN_WRITE_TOKEN_MAX_AGE_MS = 60_000 + +function tooOldForAdminWrite(caller: SandboxCaller, scope: SecretScope, now: number): boolean { + if (scope === 'personal' || !caller.orgId || !isOrgAdmin(caller) || caller.tokenIssuedAt === undefined) { + return false + } + return now - (caller.tokenIssuedAt + AUTH_IAT_BACKDATE_MS) > ADMIN_WRITE_TOKEN_MAX_AGE_MS +} + const ScopeSchema = z.enum(['org', 'personal', 'repo']) const PutSchema = z.object({ value: z.string() }).strict() const MAX_BATCH = 100 @@ -453,6 +491,8 @@ export async function handleSecretsRoute( onChange: (change: SecretChange) => void = () => {} ): Promise { const json = (body: unknown, status = 200) => Response.json(body, { status, headers: { 'cache-control': 'no-store' } }) + // Same shape as index.ts ORG-TOKEN-STALE, so the desktop re-mints and retries once. + const staleToken = () => json({ error: 'unauthorized', reason: 'stale_token' }, 401) const repoQuery = url.searchParams.get('repo') if (url.pathname === '/v1/secrets/effective') { if (request.method !== 'GET') { @@ -476,6 +516,9 @@ export async function handleSecretsRoute( if (!scope.success || request.method !== 'POST') { return json({ error: 'not_found' }, 404) } + if (tooOldForAdminWrite(caller, scope.data, now)) { + return staleToken() + } const owner = ownerFor(caller, scope.data, repoQuery, true) const body = BatchSchema.safeParse(await readJson(request)) if (!body.success) { @@ -508,7 +551,7 @@ export async function handleSecretsRoute( }) } // Names only. - console.log(JSON.stringify({ event: 'secrets_batch', scope: scope.data, written: written.map((s) => s.name), deleted, failed: errors.length, by: caller.userId })) + secretLog('secrets_batch', { scope: scope.data, written: written.map((s) => s.name), deleted, failed: errors.length, by: caller.userId }, Object.values(body.data.set ?? {})) if (written.length > 0 || deleted.length > 0) { onChange({ scope: scope.data, ownerId: owner }) } @@ -529,12 +572,16 @@ export async function handleSecretsRoute( return json({ error: 'invalid_request', reason: 'invalid_scope' }, 400) } const owner = ownerFor(caller, scope.data, repoParam, false) - return json({ secrets: await listSecrets(db, scope.data, owner) }) + const secrets = await listSecrets(db, scope.data, owner) + return json({ secrets: hintVisible(caller, scope.data) ? secrets : secrets.map((secret) => ({ ...secret, hint: null })) }) } const scope = ScopeSchema.safeParse(scopeSegment) if (!scope.success || !name) { return json({ error: 'not_found' }, 404) } + if ((request.method === 'PUT' || request.method === 'DELETE') && tooOldForAdminWrite(caller, scope.data, now)) { + return staleToken() + } if (request.method === 'PUT') { const owner = ownerFor(caller, scope.data, repoParam, true) const body = PutSchema.safeParse(await readJson(request)) @@ -542,7 +589,7 @@ export async function handleSecretsRoute( return json({ error: 'invalid_request' }, 400) } const secret = await putSecret(db, keyring, { scope: scope.data, ownerId: owner, name, value: body.data.value, updatedBy: caller.userId }, now) - console.log(JSON.stringify({ event: 'secret_written', scope: scope.data, name, by: caller.userId })) + secretLog('secret_written', { scope: scope.data, name, by: caller.userId }, [body.data.value]) onChange({ scope: scope.data, ownerId: owner }) return json({ secret }) } @@ -552,7 +599,7 @@ export async function handleSecretsRoute( if (!(await deleteSecret(db, scope.data, owner, name))) { return json({ error: 'not_found' }, 404) } - console.log(JSON.stringify({ event: 'secret_deleted', scope: scope.data, name, by: caller.userId })) + secretLog('secret_deleted', { scope: scope.data, name, by: caller.userId }) onChange({ scope: scope.data, ownerId: owner }) return json({ deleted: true }) } diff --git a/cloud/apps/sandbox-control/src/session-env.ts b/cloud/apps/sandbox-control/src/session-env.ts index 92d2a15c485..db6140c18be 100644 --- a/cloud/apps/sandbox-control/src/session-env.ts +++ b/cloud/apps/sandbox-control/src/session-env.ts @@ -10,6 +10,7 @@ import { applySecretPlaceholders, encodeAccountRoute, encodeEgressSecretHosts } import { resolveSandboxSecrets } from './secrets' import type { Keyring } from './secrets-crypto' import type { SandboxRow } from './store' +import { secretLog } from './secret-log' export type SessionSecrets = { env: Record @@ -104,7 +105,7 @@ export async function sessionSecretEnv( const scopes = deriveProviderScopes(stored, env, storedScopes) const egressAllowHosts = providerHosts(env).join(',') || null // Counts only. - console.log(JSON.stringify({ event: 'session_secrets', sandboxId: row.id, injected: Object.keys(env).length, skipped })) + secretLog('session_secrets', { sandboxId: row.id, injected: Object.keys(env).length, skipped }) if (!swap) { return { env, egressSecretHosts: null, egressAllowHosts } } diff --git a/cloud/apps/sandbox-control/test/secrets-hardening.test.ts b/cloud/apps/sandbox-control/test/secrets-hardening.test.ts new file mode 100644 index 00000000000..4e3a108803d --- /dev/null +++ b/cloud/apps/sandbox-control/test/secrets-hardening.test.ts @@ -0,0 +1,165 @@ +// SEC-6: (a) a demoted admin can't write team secrets with an old token, and admin writes need a token +// under a minute old; (b) last-4 hints are shown only to people who can write the scope; (c) secret +// handling code logs through secretLog, which redacts, and no planted value reaches the log sink. +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AUTH_IAT_BACKDATE_MS, recordMemberChange } from '../src/member-changes' +import { ADMIN_WRITE_TOKEN_MAX_AGE_MS } from '../src/secrets' +import { redactLine, secretLog, setSecretLogSink } from '../src/secret-log' +import { createHarness } from './harness' + +const KEK = Buffer.alloc(32, 7).toString('base64') +// Fake values only. +const TEAM_KEY = 'sk-ant-api03-HARDENING-fake-0000-aaaa' +const PLAIN_VALUE = 'unshaped-database-password-0042' + +let sink: string[] = [] +let consoleLines: string[] = [] +let restoreSink: () => void = () => {} +beforeEach(() => { + sink = [] + consoleLines = [] + for (const method of ['log', 'info', 'warn', 'error', 'debug'] as const) { + vi.spyOn(console, method).mockImplementation((...args: unknown[]) => { + consoleLines.push(args.map((arg) => (typeof arg === 'string' ? arg : JSON.stringify(arg))).join(' ')) + }) + } + restoreSink = setSecretLogSink((line) => sink.push(line)) +}) +afterEach(() => { + restoreSink() + for (const value of [TEAM_KEY, PLAIN_VALUE]) { + expect([...sink, ...consoleLines].join('\n')).not.toContain(value) + } + vi.restoreAllMocks() +}) + +async function setup() { + const h = await createHarness({ SECRETS_KEK: KEK }) + const put = (scope: string, name: string, value: string, bearer: string, repo?: string) => + h.call('PUT', `/v1/secrets/${scope}/${name}${repo ? `?repo=${encodeURIComponent(repo)}` : ''}`, { body: { value }, bearer }) + const rows = () => h.raw.prepare('SELECT scope, owner_id, name, updated_at FROM secrets ORDER BY name').all() + return { h, put, rows } +} + +describe('SEC-6a: role changes reach secret writes within a minute', () => { + it('a demoted admin’s earlier token can’t put, delete or batch team secrets once auth reports the change', async () => { + const { h, put, rows } = await setup() + const admin = await h.token('ada', 'acme', 'sandbox', { org_role: 'admin' }) + expect((await put('org', 'TEAM_KEY', TEAM_KEY, admin)).status).toBe(200) + const before = rows() + h.advance(AUTH_IAT_BACKDATE_MS + 1_000) + await recordMemberChange(h.env.SANDBOX_DB, 'org:acme', 'ada', 'role', h.now()) + const attempts = [ + await put('org', 'TEAM_KEY', 'replacement-value-123', admin), + await put('repo', 'DATABASE_URL', PLAIN_VALUE, admin, 'acme/web'), + await h.call('DELETE', '/v1/secrets/org/TEAM_KEY', { bearer: admin }), + await h.call('POST', '/v1/secrets/org/batch', { bearer: admin, body: { delete: ['TEAM_KEY'] } }) + ] + for (const res of attempts) { + expect(res.status).toBe(401) + expect(res.body).toMatchObject({ reason: 'stale_token' }) + } + expect(rows()).toEqual(before) + // The re-minted token carries the new role and is refused for its role, not its age. + h.advance(1_000) + const member = await h.token('ada', 'acme', 'sandbox', { org_role: 'member' }) + expect((await put('org', 'TEAM_KEY', 'replacement-value-123', member)).body).toMatchObject({ reason: 'org_admin_required' }) + }) + + it('without the push, an admin write needs a token minted in the last minute; reads and personal writes don’t', async () => { + const { h, put } = await setup() + const admin = await h.token('ada', 'acme', 'sandbox', { org_role: 'admin' }) + h.advance(ADMIN_WRITE_TOKEN_MAX_AGE_MS - 1_000) + expect((await put('org', 'TEAM_KEY', TEAM_KEY, admin)).status).toBe(200) + h.advance(2_000) + for (const res of [ + await put('org', 'TEAM_KEY', TEAM_KEY, admin), + await put('repo', 'DATABASE_URL', PLAIN_VALUE, admin, 'acme/web'), + await h.call('DELETE', '/v1/secrets/org/TEAM_KEY', { bearer: admin }), + await h.call('POST', '/v1/secrets/repo/batch?repo=acme/web', { bearer: admin, body: { set: { A: PLAIN_VALUE } } }) + ]) { + expect(res.status).toBe(401) + expect(res.body).toMatchObject({ error: 'unauthorized', reason: 'stale_token' }) + } + expect((await h.call('GET', '/v1/secrets?scope=org', { bearer: admin })).status).toBe(200) + expect((await put('personal', 'MY_VAR', PLAIN_VALUE, admin)).status).toBe(200) + // A personal account's repository secrets have no role to lose. + const solo = await h.token('carol') + h.advance(10 * 60_000) + expect((await put('repo', 'DATABASE_URL', PLAIN_VALUE, solo, 'carol/app')).status).toBe(200) + // A fresh admin token writes again. + const fresh = await h.token('ada', 'acme', 'sandbox', { org_role: 'admin' }) + expect((await put('org', 'TEAM_KEY', TEAM_KEY, fresh)).status).toBe(200) + }) +}) + +describe('SEC-6b: last-4 hints only for people who can write the scope', () => { + it('team members get no hints for Shared or team Repository secrets; admins and owners of personal ones do', async () => { + const { h, put } = await setup() + const admin = await h.token('ada', 'acme', 'sandbox', { org_role: 'admin' }) + const member = await h.token('mia', 'acme', 'sandbox', { org_role: 'member' }) + await put('org', 'TEAM_KEY', TEAM_KEY, admin) + await put('repo', 'DATABASE_URL', PLAIN_VALUE, admin, 'acme/web') + await put('personal', 'MY_VAR', PLAIN_VALUE, member) + const list = async (bearer: string, query: string) => + (await h.call('GET', `/v1/secrets?${query}`, { bearer })).body.secrets.map((s: any) => s.hint) + expect(await list(admin, 'scope=org')).toEqual(['aaaa']) + expect(await list(member, 'scope=org')).toEqual([null]) + expect(await list(admin, 'scope=repo&repo=acme/web')).toEqual(['0042']) + expect(await list(member, 'scope=repo&repo=acme/web')).toEqual([null]) + expect(await list(member, 'scope=personal')).toEqual(['0042']) + const effective = (await h.call('GET', '/v1/secrets/effective?repo=acme/web', { bearer: member })).body.secrets + expect(effective.map((s: any) => [s.name, s.hint])).toEqual([ + ['DATABASE_URL', null], + ['MY_VAR', '0042'], + ['TEAM_KEY', null] + ]) + }) +}) + +describe('SEC-6c: secret-handling code logs only through the redacting sink', () => { + const FILES = ['secrets.ts', 'session-env.ts', 'env-refresh.ts', 'egress-proxy.ts', 'secret-egress.ts', 'secrets-crypto.ts'] + + it('the secret-handling modules never call console directly', () => { + for (const file of FILES) { + const source = readFileSync(fileURLToPath(new URL(`../src/${file}`, import.meta.url)), 'utf8') + expect({ file, direct: /\bconsole\s*\.\s*[a-z]+\s*\(/.test(source) }).toEqual({ file, direct: false }) + } + }) + + it('redacts values passed as known, in raw and JSON-escaped form, and credential shapes', () => { + const quoted = 'pa"ss\\word-with-quotes-99' + secretLog('probe', { error: new Error(`upstream said ${PLAIN_VALUE}`), body: { echoed: quoted } }, [PLAIN_VALUE, quoted]) + secretLog('probe', { + message: `bad key ${TEAM_KEY}; auth=Bearer abcdefghijklmnop.qrstuvwx; gh=ghp_${'a'.repeat(36)}; aws=AKIAABCDEFGHIJKLMNOP` + }) + const text = sink.join('\n') + expect(text).not.toContain(PLAIN_VALUE) + expect(text).not.toContain('ss\\\\word-with-quotes') + expect(text).not.toContain(TEAM_KEY) + expect(text).not.toContain('abcdefghijklmnop') + expect(text).not.toContain('ghp_aaaa') + expect(text).not.toContain('AKIAABCDEFGHIJKLMNOP') + expect(text).toContain('Bearer [redacted]') + // Names, ids and counts survive. + expect(redactLine('{"event":"secret_written","name":"ANTHROPIC_API_KEY","sandboxId":"sbx_123","count":4}')).toBe( + '{"event":"secret_written","name":"ANTHROPIC_API_KEY","sandboxId":"sbx_123","count":4}' + ) + // Errors never carry their stack. + expect(text).not.toContain('at ') + }) + + it('a full write, batch and session start leave no planted value in the sink', async () => { + const { h, put } = await setup() + const admin = await h.token('ada', 'acme', 'sandbox', { org_role: 'admin' }) + await put('org', 'ANTHROPIC_API_KEY', TEAM_KEY, admin) + await h.call('POST', '/v1/secrets/org/batch', { bearer: admin, body: { set: { DB_PASSWORD: PLAIN_VALUE } } }) + const created = await h.call('POST', '/v1/sandboxes', { body: { workspaceId: 'ws-1', machineSize: 'standard' }, bearer: admin }) + expect(created.status).toBe(200) + expect(sink.some((line) => line.includes('secrets_batch'))).toBe(true) + expect(sink.some((line) => line.includes('session_secrets'))).toBe(true) + // afterEach checks the sink and console for every planted value. + }) +}) diff --git a/src/main/cloud-secrets/cloud-secrets-client.test.ts b/src/main/cloud-secrets/cloud-secrets-client.test.ts index 03410c45d03..54c68298f37 100644 --- a/src/main/cloud-secrets/cloud-secrets-client.test.ts +++ b/src/main/cloud-secrets/cloud-secrets-client.test.ts @@ -34,6 +34,42 @@ const VIEW = { } describe('createCloudSecretsClient', () => { + it('re-mints once on 401 stale_token (admin write window), and not for other 401s', async () => { + const tokens = ['old', 'new'] + let invalidated = 0 + const seen: string[] = [] + let answers: Response[] = [] + const client = createCloudSecretsClient({ + baseUrl: 'https://control.test', + getAccessToken: async () => tokens[Math.min(invalidated, 1)]!, + invalidateAccessToken: () => { + invalidated++ + }, + fetch: async (_input, init) => { + seen.push(new Headers(init?.headers).get('authorization') ?? '') + return answers.shift()! + } + }) + answers = [ + Response.json({ error: 'unauthorized', reason: 'stale_token' }, { status: 401 }), + Response.json({ secret: VIEW }) + ] + await client.put({ scope: 'org' }, 'DATABASE_URL', 'value-not-logged') + expect(seen).toEqual(['Bearer old', 'Bearer new']) + expect(invalidated).toBe(1) + + answers = [ + Response.json({ error: 'unauthorized', reason: 'stale_token' }, { status: 401 }), + Response.json({ error: 'unauthorized', reason: 'stale_token' }, { status: 401 }) + ] + await expect(client.remove({ scope: 'org' }, 'DATABASE_URL')).rejects.toThrow() + expect(invalidated).toBe(2) + + answers = [Response.json({ error: 'unauthorized' }, { status: 401 })] + await expect(client.remove({ scope: 'org' }, 'DATABASE_URL')).rejects.toThrow() + expect(invalidated).toBe(2) + }) + 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 }, diff --git a/src/main/cloud-secrets/cloud-secrets-ipc.ts b/src/main/cloud-secrets/cloud-secrets-ipc.ts index 5c936e5d704..3a75ba41dad 100644 --- a/src/main/cloud-secrets/cloud-secrets-ipc.ts +++ b/src/main/cloud-secrets/cloud-secrets-ipc.ts @@ -101,7 +101,9 @@ export function registerCloudSecretsHandlers(userDataPath: string): void { const client = config ? createCloudSecretsClient({ baseUrl: config.controlPlaneUrl, - getAccessToken: config.getAccessToken + getAccessToken: config.getAccessToken, + // Admin-only writes need a token under a minute old; a stale one is re-minted once. + invalidateAccessToken: config.invalidateAccessToken }) : null // Agent API keys saved while signed in are written to personal secrets through the same client. diff --git a/src/main/hosted-sandbox/hosted-sandbox-request.ts b/src/main/hosted-sandbox/hosted-sandbox-request.ts index 7ec3d4c0223..57bf1f0cb8e 100644 --- a/src/main/hosted-sandbox/hosted-sandbox-request.ts +++ b/src/main/hosted-sandbox/hosted-sandbox-request.ts @@ -12,6 +12,11 @@ export type HostedSandboxControlPlaneClientOptions = { baseUrl: string /** Mints an Orca Cloud token with aud=orca-sandbox; never a provider credential. */ getAccessToken: (signal?: AbortSignal) => Promise + /** + * Drops the cached token. When set, a 401 `stale_token` (the token predates a role change, or is too + * old for an admin-only write) is retried once with a freshly minted token. + */ + invalidateAccessToken?: () => void fetch?: typeof fetch /** Per-call ceilings; create/resume/rollover include a VM boot and orcad start. */ timeouts?: Partial> @@ -41,7 +46,7 @@ export function createHostedSandboxRequest( const doFetch = options.fetch ?? fetch const timeouts = { ...DEFAULT_TIMEOUTS, ...options.timeouts } - return async function call( + const attempt = async function attempt( method: 'GET' | 'POST' | 'PUT' | 'DELETE', path: string, schema: z.ZodType, @@ -98,6 +103,33 @@ export function createHostedSandboxRequest( } return parsed.data } + + return async function call( + method: 'GET' | 'POST' | 'PUT' | 'DELETE', + path: string, + schema: z.ZodType, + init: Parameters[3] + ): Promise { + try { + return await attempt(method, path, schema, init) + } catch (error) { + if (!options.invalidateAccessToken || !isStaleToken(error)) { + throw error + } + // The control plane refused this token as older than a role change or the write window; the + // request did nothing, so one retry with a newly minted token is safe. + options.invalidateAccessToken() + return attempt(method, path, schema, init) + } + } +} + +function isStaleToken(error: unknown): boolean { + return ( + error instanceof HostedSandboxRefusalError && + error.httpStatus === 401 && + error.reason === 'stale_token' + ) } function parseBaseUrl(value: string): URL { From 399ef26f196638e4f16384f0c6694df1b323c369 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 17:50:38 -0800 Subject: [PATCH 04/50] test(cloud): member effective view hides team hints --- cloud/apps/sandbox-control/test/agent-providers.test.ts | 5 ++++- cloud/apps/sandbox-control/test/secrets-hardening.test.ts | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/cloud/apps/sandbox-control/test/agent-providers.test.ts b/cloud/apps/sandbox-control/test/agent-providers.test.ts index 422ad721ba3..c3d521a0b82 100644 --- a/cloud/apps/sandbox-control/test/agent-providers.test.ts +++ b/cloud/apps/sandbox-control/test/agent-providers.test.ts @@ -121,7 +121,10 @@ describe('secret store routes for the desktop UI', () => { expect(res.body.caller).toEqual({ userId: 'mia', orgId: 'acme', orgRole: 'member', canWrite: { personal: true, org: false, repo: false } }) expect(res.body.precedence.agent_key[0]).toBe('org') const byName = Object.fromEntries(res.body.secrets.map((s: { name: string }) => [s.name, s])) - expect(byName.ANTHROPIC_API_KEY).toMatchObject({ kind: 'agent_key', scope: 'org', hint: 'aaaa', shadowed: [{ scope: 'personal', hint: 'bbbb' }] }) + expect(byName.ANTHROPIC_API_KEY).toMatchObject({ kind: 'agent_key', scope: 'org', hint: null, shadowed: [{ scope: 'personal', hint: 'bbbb' }] }) + // SEC-6b: a member sees no hint for a team value; the admin who can write it does. + const forAdmin = await h.call('GET', '/v1/secrets/effective?repo=acme/app', { bearer: ada }) + expect(forAdmin.body.secrets.find((s: { name: string }) => s.name === 'ANTHROPIC_API_KEY').hint).toBe('aaaa') expect(byName.SHARED_FLAG).toMatchObject({ kind: 'env', scope: 'repo', shadowed: [{ scope: 'personal' }, { scope: 'org' }] }) expect(JSON.stringify(res.body)).not.toContain(ORG_KEY) }) diff --git a/cloud/apps/sandbox-control/test/secrets-hardening.test.ts b/cloud/apps/sandbox-control/test/secrets-hardening.test.ts index 4e3a108803d..7584ccb0d93 100644 --- a/cloud/apps/sandbox-control/test/secrets-hardening.test.ts +++ b/cloud/apps/sandbox-control/test/secrets-hardening.test.ts @@ -87,7 +87,7 @@ describe('SEC-6a: role changes reach secret writes within a minute', () => { expect((await put('personal', 'MY_VAR', PLAIN_VALUE, admin)).status).toBe(200) // A personal account's repository secrets have no role to lose. const solo = await h.token('carol') - h.advance(10 * 60_000) + h.advance(4 * 60_000) expect((await put('repo', 'DATABASE_URL', PLAIN_VALUE, solo, 'carol/app')).status).toBe(200) // A fresh admin token writes again. const fresh = await h.token('ada', 'acme', 'sandbox', { org_role: 'admin' }) From f1ee56a2cdaffe9597acfc4ac5e7b8b5c94c5f8c Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 15:24:53 -0800 Subject: [PATCH 05/50] fix(cloud): HELD-STALE-COVERED a held launch re-decides on every account change, Start re-reads the account before launching, the sidebar mark follows --- .../cloud-chat-banners.test.tsx | 15 +- .../cloud-setup-strip-actions.test.ts | 11 +- .../cloud-setup-strip-actions.ts | 8 +- .../use-cloud-held-strip.ts | 47 +++--- .../orca-cloud/CloudAgentHeldMark.test.tsx | 25 +++- .../cloud-agent-launch-hold-stale.test.ts | 136 ++++++++++++++++++ .../lib/orca-cloud/cloud-agent-launch-hold.ts | 136 +++++++++++------- 7 files changed, 300 insertions(+), 78 deletions(-) create mode 100644 src/renderer/src/lib/orca-cloud/cloud-agent-launch-hold-stale.test.ts diff --git a/src/renderer/src/components/agent-sign-in-strip/cloud-chat-banners.test.tsx b/src/renderer/src/components/agent-sign-in-strip/cloud-chat-banners.test.tsx index c437665c201..e4a650cbedd 100644 --- a/src/renderer/src/components/agent-sign-in-strip/cloud-chat-banners.test.tsx +++ b/src/renderer/src/components/agent-sign-in-strip/cloud-chat-banners.test.tsx @@ -16,7 +16,12 @@ import type * as LaunchHoldModule from '@/lib/orca-cloud/cloud-agent-launch-hold const stub = vi.hoisted(() => { const state: { - hold: { agent: 'claude'; reason: 'notSetUp' | 'accountExpired'; prompt: string | null } | null + hold: { + agent: 'claude' + reason: 'notSetUp' | 'accountExpired' | 'awaitingStart' + awaiting?: 'setUp' + prompt: string | null + } | null setup: OrcaCloudAgentSetup } = { hold: null, setup: { phase: 'idle' } } return state @@ -200,6 +205,14 @@ describe('the held-launch strip in the chat view', () => { ).toBeTruthy() }) + it('HELD-STALE-COVERED: set up here: "is set up · Start", not "Set up once"', () => { + stub.hold = { agent: 'claude', reason: 'awaitingStart', awaiting: 'setUp', prompt: null } + const slot = inChat() + expect(within(slot).getByText('Claude is set up for Orca Cloud')).toBeTruthy() + expect(within(slot).getByRole('button', { name: /Start Claude|Start/ })).toBeTruthy() + expect(within(slot).queryByRole('button', { name: 'Set up once' })).toBeNull() + }) + it('sign-in expired: Renew', () => { stub.hold = { agent: 'claude', reason: 'accountExpired', prompt: null } const slot = inChat() diff --git a/src/renderer/src/components/agent-sign-in-strip/cloud-setup-strip-actions.test.ts b/src/renderer/src/components/agent-sign-in-strip/cloud-setup-strip-actions.test.ts index baa7cef4c6a..6cbac0377a1 100644 --- a/src/renderer/src/components/agent-sign-in-strip/cloud-setup-strip-actions.test.ts +++ b/src/renderer/src/components/agent-sign-in-strip/cloud-setup-strip-actions.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const store = vi.hoisted(() => ({ stop: vi.fn(), run: vi.fn(), reload: vi.fn() })) -const hold = vi.hoisted(() => ({ release: vi.fn() })) +const hold = vi.hoisted(() => ({ release: vi.fn(), start: vi.fn(async () => 'held') })) vi.mock('@/lib/orca-cloud/orca-cloud-agent-accounts-store', () => ({ runOrcaCloudAgentSetups: store.run, @@ -10,7 +10,8 @@ vi.mock('@/lib/orca-cloud/orca-cloud-agent-accounts-store', () => ({ reloadOrcaCloudAgentAccounts: store.reload })) vi.mock('@/lib/orca-cloud/cloud-agent-launch-hold', () => ({ - releaseCloudAgentHold: hold.release + releaseCloudAgentHold: hold.release, + startCloudAgentHold: hold.start })) // The strip reads the agents bridge at build time; these tests never reach it. @@ -33,6 +34,7 @@ describe('cloudSetupStripActions (PC-3b, RR-a)', () => { beforeEach(() => { store.stop.mockReset() hold.release.mockReset() + hold.start.mockClear() }) it('Cancel during a setup stops only the setup: the launch stays held', () => { @@ -47,8 +49,9 @@ describe('cloudSetupStripActions (PC-3b, RR-a)', () => { expect(hold.release).toHaveBeenCalledExactlyOnceWith('tab:1', 'cancel') }) - it('Start releases the held launch', () => { + it('Start re-checks the account before it releases the held launch (HELD-STALE-COVERED)', () => { actions(false).primary({ kind: 'start' }) - expect(hold.release).toHaveBeenCalledExactlyOnceWith('tab:1', 'proceed') + expect(hold.start).toHaveBeenCalledExactlyOnceWith('tab:1') + expect(hold.release).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/components/agent-sign-in-strip/cloud-setup-strip-actions.ts b/src/renderer/src/components/agent-sign-in-strip/cloud-setup-strip-actions.ts index 36404a1ecf7..e75392e8dd9 100644 --- a/src/renderer/src/components/agent-sign-in-strip/cloud-setup-strip-actions.ts +++ b/src/renderer/src/components/agent-sign-in-strip/cloud-setup-strip-actions.ts @@ -3,7 +3,10 @@ // setup stops only that setup: the launch stays held on "Set up once", and a setup finished from // anywhere (here or Settings) turns the strip into "Start". Cancel with no setup running gives up // the held launch and opens a plain shell, like cancelling any held launch (RR-a). -import { releaseCloudAgentHold } from '@/lib/orca-cloud/cloud-agent-launch-hold' +import { + releaseCloudAgentHold, + startCloudAgentHold +} from '@/lib/orca-cloud/cloud-agent-launch-hold' import { reloadOrcaCloudAgentAccounts, runOrcaCloudAgentSetups, @@ -38,7 +41,8 @@ export function cloudSetupStripActions(args: { args.copyText(action.code) args.openLink(action.url) } else if (action.kind === 'start') { - releaseCloudAgentHold(holdKey, 'proceed') + // HELD-STALE-COVERED: Start reads the account again; a revoked sign-in shows Renew instead. + void startCloudAgentHold(holdKey) } }, submitCode: (token) => { diff --git a/src/renderer/src/components/agent-sign-in-strip/use-cloud-held-strip.ts b/src/renderer/src/components/agent-sign-in-strip/use-cloud-held-strip.ts index a4e6e11260c..fa9989f86ae 100644 --- a/src/renderer/src/components/agent-sign-in-strip/use-cloud-held-strip.ts +++ b/src/renderer/src/components/agent-sign-in-strip/use-cloud-held-strip.ts @@ -69,27 +69,32 @@ export function useCloudHeldStrip( // V3-01: a restored hold waiting for Start shares the setup hold's actions (Start, cancel). const setupHold = hold?.reason === 'notSetUp' || awaiting !== null const setup = accounts.setups[agent] - const model = awaiting - ? cloudAgentAwaitingStartStripModel({ - agent, - awaiting, - queuedPrompt: Boolean(hold?.prompt), - t - }) - : setupHold - ? cloudAgentSetupStripModel({ - agent, - setup, - ready: accounts.load.kind === 'ready' && cloudAgentHoldReason(agent, accounts) === null, - t - }) - : cloudAgentStripModel({ - agent, - reason, - view: hold ? null : (cloudGate?.view ?? null), - hostLabel: cloudGate?.hostLabel ?? t('agentSignInStrip.sandbox', 'the sandbox'), - t - }) + // HELD-STALE-COVERED: the setup just finished here: "is set up · Start" (the account, read fresh). + const model = + awaiting === 'setUp' + ? cloudAgentSetupStripModel({ agent, setup: accounts.setups[agent], ready: true, t }) + : awaiting + ? cloudAgentAwaitingStartStripModel({ + agent, + awaiting, + queuedPrompt: Boolean(hold?.prompt), + t + }) + : setupHold + ? cloudAgentSetupStripModel({ + agent, + setup, + ready: + accounts.load.kind === 'ready' && cloudAgentHoldReason(agent, accounts) === null, + t + }) + : cloudAgentStripModel({ + agent, + reason, + view: hold ? null : (cloudGate?.view ?? null), + hostLabel: cloudGate?.hostLabel ?? t('agentSignInStrip.sandbox', 'the sandbox'), + t + }) const setupRunning = setup.phase !== 'idle' && setup.phase !== 'saved' && setup.phase !== 'failed' const holdActions: CloudStripActions | null = setupHold ? cloudSetupStripActions({ diff --git a/src/renderer/src/components/orca-cloud/CloudAgentHeldMark.test.tsx b/src/renderer/src/components/orca-cloud/CloudAgentHeldMark.test.tsx index 63a88faeb73..5f7286a3a58 100644 --- a/src/renderer/src/components/orca-cloud/CloudAgentHeldMark.test.tsx +++ b/src/renderer/src/components/orca-cloud/CloudAgentHeldMark.test.tsx @@ -13,13 +13,15 @@ import { } from '@/lib/orca-cloud/orca-cloud-agent-accounts-store' import { CloudAgentHeldMark } from './CloudAgentHeldMark' -function accountsNotSetUp(): OrcaCloudAgentAccountsState { +function accountsNotSetUp( + claude: 'none' | 'active' | 'revoked' = 'none' +): OrcaCloudAgentAccountsState { return { load: { kind: 'ready', status: { credentials: { - claude: { state: 'none' }, + claude: { state: claude }, codex: { state: 'none' }, providerEnabled: { claude: true, codex: true } }, @@ -83,4 +85,23 @@ describe('sidebar mark for a held cloud agent (N-03)', () => { act(() => releaseCloudAgentHold('tab:web-terminal-1', 'proceed')) expect(queryByRole('img')).toBeNull() }) + + it('HELD-STALE-COVERED: the mark follows the hold: key while not set up, muted once set up, key on a revoke', async () => { + await act(async () => { + void holdCloudAgentLaunch({ + decided: true, + paneKey: 'tab:web-terminal-1', + agent: 'claude', + prompt: null + }) + }) + const { getByRole } = renderMark('wt1') + expect(getByRole('img').getAttribute('data-cloud-agent-held')).toBe('notSetUp') + await act(async () => setOrcaCloudAgentAccountsStateForTest(accountsNotSetUp('active'))) + expect(getByRole('img').getAttribute('data-cloud-agent-held')).toBe('awaitingStart') + expect(getByRole('img').getAttribute('class')).toContain('text-muted-foreground') + await act(async () => setOrcaCloudAgentAccountsStateForTest(accountsNotSetUp('revoked'))) + expect(getByRole('img').getAttribute('data-cloud-agent-held')).toBe('accountExpired') + expect(getByRole('img').getAttribute('aria-label')).toBe('Claude sign-in expired') + }) }) diff --git a/src/renderer/src/lib/orca-cloud/cloud-agent-launch-hold-stale.test.ts b/src/renderer/src/lib/orca-cloud/cloud-agent-launch-hold-stale.test.ts new file mode 100644 index 00000000000..207a13c3a7c --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/cloud-agent-launch-hold-stale.test.ts @@ -0,0 +1,136 @@ +// @vitest-environment happy-dom +// HELD-STALE-COVERED: a held launch re-decides on every account change, and Start never trusts the +// cached read. Real repro (m4air, #568 proof P3b): set up -> "Start Claude" -> the sign-in is revoked +// -> the strip still said "Start Claude", and Start launched Claude onto the revoked account. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { OrcaCloudAgentsStatus } from '../../../../shared/orca-cloud-agents/orca-cloud-agents-wire' +import { + holdCloudAgentLaunch, + readCloudAgentHold, + releaseCloudAgentHold, + startCloudAgentHold +} from './cloud-agent-launch-hold' +import { + setOrcaCloudAgentAccountsStateForTest, + type OrcaCloudAgentAccountsState +} from './orca-cloud-agent-accounts-store' + +type Claude = OrcaCloudAgentsStatus['credentials']['claude'] + +function status(claude: Claude): OrcaCloudAgentsStatus { + return { + credentials: { + claude, + codex: { state: 'none' }, + providerEnabled: { claude: true, codex: true } + }, + sources: { defaults: {}, coveredByKey: { claude: false, codex: false } } + } +} + +function accounts(claude: Claude): OrcaCloudAgentAccountsState { + return { + load: { kind: 'ready', status: status(claude) }, + setups: { claude: { phase: 'idle' }, codex: { phase: 'idle' } }, + keyKinds: { claude: null, codex: null }, + queue: { agents: [], index: 0 } + } +} + +const main = vi.hoisted(() => { + const value: { answer: { ok: true; value: unknown } | { ok: false; error: 'unknown' } } = { + answer: { ok: false, error: 'unknown' } + } + return value +}) + +const settle = async (): Promise => { + for (let i = 0; i < 3; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } +} + +beforeEach(() => { + Object.defineProperty(window, 'api', { + configurable: true, + value: { orcaCloudAgents: { status: async () => main.answer } } + }) +}) +afterEach(() => { + for (const key of ['tab:s1', 'tab:s2', 'tab:s3', 'tab:s4', 'tab:s5']) { + releaseCloudAgentHold(key, 'cancel') + } +}) + +async function setUpHold(paneKey: string): Promise<{ outcome: () => string }> { + setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'none' })) + let outcome = 'pending' + void holdCloudAgentLaunch({ paneKey, agent: 'claude', prompt: null }).then((o) => { + outcome = o + }) + await vi.waitFor(() => expect(readCloudAgentHold(paneKey)).toMatchObject({ reason: 'notSetUp' })) + return { outcome: () => outcome } +} + +describe('HELD-STALE-COVERED', () => { + it('setup finished here: "is set up · Start" (muted, not the amber key), and it waits for Start', async () => { + const hold = await setUpHold('tab:s1') + setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'active' })) + await settle() + expect(readCloudAgentHold('tab:s1')).toMatchObject({ + reason: 'awaitingStart', + awaiting: 'setUp' + }) + expect(hold.outcome()).toBe('pending') + }) + + it('covered -> revoked: the hold goes back to Renew (accountExpired), and Start does not launch', async () => { + const hold = await setUpHold('tab:s2') + setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'active' })) + await settle() + setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'revoked' })) + await settle() + expect(readCloudAgentHold('tab:s2')).toMatchObject({ reason: 'accountExpired' }) + main.answer = { ok: true, value: status({ state: 'revoked' }) } + await expect(startCloudAgentHold('tab:s2')).resolves.toBe('held') + await settle() + expect(readCloudAgentHold('tab:s2')).toMatchObject({ reason: 'accountExpired' }) + expect(hold.outcome()).toBe('pending') + }) + + it('Start with a stale "active" cache and a fresh read showing revoked: no launch, Renew shown', async () => { + const hold = await setUpHold('tab:s3') + setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'active' })) + await settle() + expect(readCloudAgentHold('tab:s3')).toMatchObject({ awaiting: 'setUp' }) + main.answer = { ok: true, value: status({ state: 'revoked' }) } + await expect(startCloudAgentHold('tab:s3')).resolves.toBe('held') + await settle() + expect(readCloudAgentHold('tab:s3')).toMatchObject({ reason: 'accountExpired' }) + expect(hold.outcome()).toBe('pending') + }) + + it('Start with a fresh read that still covers the agent launches it', async () => { + const hold = await setUpHold('tab:s4') + setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'active' })) + await settle() + main.answer = { ok: true, value: status({ state: 'active' }) } + await expect(startCloudAgentHold('tab:s4')).resolves.toBe('started') + await settle() + expect(hold.outcome()).toBe('proceed') + expect(readCloudAgentHold('tab:s4')).toBeNull() + }) + + it('a removed sign-in goes back to "Set up once"; a failed fresh read keeps it held', async () => { + const hold = await setUpHold('tab:s5') + setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'active' })) + await settle() + setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'none' })) + await settle() + expect(readCloudAgentHold('tab:s5')).toMatchObject({ reason: 'notSetUp' }) + main.answer = { ok: false, error: 'unknown' } + await expect(startCloudAgentHold('tab:s5')).resolves.toBe('held') + expect(readCloudAgentHold('tab:s5')).not.toBeNull() + expect(hold.outcome()).toBe('pending') + }) +}) diff --git a/src/renderer/src/lib/orca-cloud/cloud-agent-launch-hold.ts b/src/renderer/src/lib/orca-cloud/cloud-agent-launch-hold.ts index aba12915a06..d107c903465 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-agent-launch-hold.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-agent-launch-hold.ts @@ -11,6 +11,7 @@ import { ensureFreshOrcaCloudAgentAccounts, isOrcaCloudAgentAccountsFresh, readOrcaCloudAgentAccounts, + reloadOrcaCloudAgentAccounts, subscribeOrcaCloudAgentAccounts, type OrcaCloudAgentAccountsState } from './orca-cloud-agent-accounts-store' @@ -89,9 +90,10 @@ export type CloudAgentHold = { reason: CloudAgentHoldReason /** * Why an 'awaitingStart' hold waits: the account covers the agent now, Orca couldn't read it, - * or an expired sign-in was renewed (V3-01). Absent for every other reason. + * or an expired sign-in was renewed (V3-01), or the one-time setup just finished here ('setUp', + * HELD-STALE-COVERED: "is set up · Start", no longer the amber key). Absent for every other reason. */ - awaiting?: 'covered' | 'unread' | 'renewed' + awaiting?: 'covered' | 'unread' | 'renewed' | 'setUp' } /** The hold key of a host-published tab (RA-1): its first pane shows the strip. */ @@ -110,7 +112,11 @@ type HoldRequest = Omit & { restored?: boolean } -type Entry = CloudAgentHold & { release: (outcome: 'proceed' | 'cancel') => void } +type Entry = CloudAgentHold & { + release: (outcome: 'proceed' | 'cancel') => void + /** Renewed or covered, it waits for the person's Start instead of starting (restored, or setup). */ + askFirst: boolean +} const holds = new Map() /** Each live hold's outcome, so a second launch for the same key joins it instead of holding twice. */ @@ -269,57 +275,15 @@ async function placeCloudAgentHold({ reason === 'awaitingStart' ? (accountsRead ? ('covered' as const) : ('unread' as const)) : null return new Promise<'proceed' | 'cancel'>((resolve) => { const stop = subscribeOrcaCloudAgentAccounts(() => { - // Renewed (or a key now covers it): every pane held on recovery starts. A first-time setup - // waits for its "Start {{agent}}" click instead (PC-3b), so it never starts unasked. const entry = holds.get(hold.paneKey) if (entry?.release !== finish) { return } - // V3-01b: a restored hold placed before the account read could land ('unread') takes the - // read's answer once it arrives: "isn't set up", "expired" or covered. It still never starts. - if (entry.reason === 'awaitingStart' && entry.awaiting === 'unread') { - const read = readOrcaCloudAgentAccounts() - if (read.load.kind !== 'ready') { - return - } - const answer = cloudAgentHoldReason(hold.agent, read) - holds.set( - hold.paneKey, - answer - ? { ...entry, reason: answer, awaiting: undefined } - : { ...entry, awaiting: 'covered' } - ) - notify() - return - } - if (entry.reason !== 'accountExpired') { - return - } const accounts = readOrcaCloudAgentAccounts() - const now = accounts.load.kind === 'ready' ? cloudAgentHoldReason(hold.agent, accounts) : null - if (accounts.load.kind !== 'ready' || now === 'accountExpired') { + if (accounts.load.kind !== 'ready') { return } - if (now === 'notSetUp') { - // The sign-in was removed, not renewed: nothing covers the agent, so it never starts here; - // the hold becomes the one-time setup. - holds.set(hold.paneKey, { ...entry, reason: 'notSetUp' }) - notify() - return - } - // Only a real renewal (the account or a key covers it) starts it; a paused or withdrawn agent - // (kill switch, never offered) or an unknown coverage stays held. - const coverage = orcaCloudAgentCoverageFromAccounts(hold.agent, accounts)?.kind - if (coverage !== 'account' && coverage !== 'key') { - return - } - if (restored) { - // After a restart the person starts it: renewed, it waits for Start (V3-01). - holds.set(hold.paneKey, { ...entry, reason: 'awaitingStart', awaiting: 'renewed' }) - notify() - return - } - finish('proceed') + redecide(entry, accounts) }) function finish(outcome: 'proceed' | 'cancel'): void { if (holds.get(hold.paneKey)?.release !== finish) { @@ -330,11 +294,87 @@ async function placeCloudAgentHold({ notify() resolve(outcome) } - holds.set(hold.paneKey, { ...hold, reason, ...(awaiting ? { awaiting } : {}), release: finish }) + holds.set(hold.paneKey, { + ...hold, + reason, + ...(awaiting ? { awaiting } : {}), + release: finish, + askFirst: Boolean(restored) || reason !== 'accountExpired' + }) notify() }) } +/** + * HELD-STALE-COVERED: every account change re-decides a live hold, whatever it waits on. A sign-in + * that expires or is revoked under "is set up · Start" goes back to Renew; one removed goes back to + * "Set up once"; covered again, it waits for Start (or, held on recovery in this session, starts). + * A paused or withdrawn agent (kill switch, never offered) keeps the hold as it is. + */ +function redecide(entry: Entry, accounts: OrcaCloudAgentAccountsState): void { + const now = cloudAgentHoldReason(entry.agent, accounts) + if (now !== null) { + if (now !== entry.reason) { + holds.set(entry.paneKey, { ...entry, reason: now, awaiting: undefined }) + notify() + } + return + } + const coverage = orcaCloudAgentCoverageFromAccounts(entry.agent, accounts)?.kind + if (coverage !== 'account' && coverage !== 'key') { + return + } + if (entry.reason === 'awaitingStart') { + // V3-01b: an unread restored hold takes the read's answer; it still never starts. + if (entry.awaiting === 'unread') { + holds.set(entry.paneKey, { ...entry, awaiting: 'covered' }) + notify() + } + return + } + if (!entry.askFirst) { + // Renewed in this session: every pane held on recovery starts. + entry.release('proceed') + return + } + const awaiting = entry.reason === 'notSetUp' ? 'setUp' : 'renewed' + holds.set(entry.paneKey, { ...entry, reason: 'awaitingStart', awaiting }) + notify() +} + +/** + * The strip's Start (HELD-STALE-COVERED): never trusts the cached read. It reads the account again + * and starts only if that read covers the agent; otherwise the hold takes the read's answer (Renew, + * Set up once) and nothing launches. A failed read keeps it held ("couldn't check", Start to retry). + */ +export async function startCloudAgentHold(paneKey: string): Promise<'started' | 'held'> { + if (!holds.has(paneKey)) { + return 'held' + } + await reloadOrcaCloudAgentAccounts().catch(() => undefined) + const entry = holds.get(paneKey) + if (!entry) { + return 'held' + } + const accounts = readOrcaCloudAgentAccounts() + if (accounts.load.kind !== 'ready' || !isOrcaCloudAgentAccountsFresh()) { + holds.set(paneKey, { ...entry, reason: 'awaitingStart', awaiting: 'unread' }) + notify() + return 'held' + } + const now = cloudAgentHoldReason(entry.agent, accounts) + const coverage = orcaCloudAgentCoverageFromAccounts(entry.agent, accounts)?.kind + if (now !== null || (coverage !== 'account' && coverage !== 'key')) { + if (now !== null && now !== entry.reason) { + holds.set(paneKey, { ...entry, reason: now, awaiting: undefined }) + notify() + } + return 'held' + } + releaseCloudAgentHold(paneKey, 'proceed') + return 'started' +} + /** * The outcome of the hold already placed under this key, or null. V3-01b: a structured chat's hold * put back at startup is joined by its launch's retry, so one Start (or Cancel) settles both. From 18ae6463c6d88a7b290bc51815eced8bcad63434 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 15:26:23 -0800 Subject: [PATCH 06/50] test(cloud): HELD-STALE-COVERED fixtures for the four sidebar mark states --- .../orca-cloud/CloudAgentHeldMark.fixture.tsx | 36 ++++++++++++++----- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/src/renderer/src/components/orca-cloud/CloudAgentHeldMark.fixture.tsx b/src/renderer/src/components/orca-cloud/CloudAgentHeldMark.fixture.tsx index 982913affc7..9dc60ed985b 100644 --- a/src/renderer/src/components/orca-cloud/CloudAgentHeldMark.fixture.tsx +++ b/src/renderer/src/components/orca-cloud/CloudAgentHeldMark.fixture.tsx @@ -5,14 +5,15 @@ import { holdCloudAgentLaunch } from '@/lib/orca-cloud/cloud-agent-launch-hold' import { setOrcaCloudAgentAccountsStateForTest } from '@/lib/orca-cloud/orca-cloud-agent-accounts-store' import { CloudAgentHeldMark } from './CloudAgentHeldMark' -// A cloud workspace row whose Claude waits on its pane's "Set up once" strip (N-03). -function seed(restored = false): void { - setOrcaCloudAgentAccountsStateForTest({ +type Scenario = 'notSetUp' | 'restored' | 'setUp' | 'expired' + +function accounts(claude: 'none' | 'active' | 'revoked') { + return { load: { kind: 'ready', status: { credentials: { - claude: { state: restored ? 'active' : 'none' }, + claude: { state: claude }, codex: { state: 'none' }, providerEnabled: { claude: true, codex: true } }, @@ -22,7 +23,14 @@ function seed(restored = false): void { setups: { claude: { phase: 'idle' }, codex: { phase: 'idle' } }, keyKinds: { claude: null, codex: null }, queue: { agents: [], index: 0 } - }) + } as const +} + +// A cloud workspace row whose Claude waits on its pane's strip (N-03). HELD-STALE-COVERED: the mark +// follows the hold's reason as the account changes under it (set up here, then revoked). +function seed(scenario: Scenario): void { + const restored = scenario === 'restored' + setOrcaCloudAgentAccountsStateForTest(accounts(restored ? 'active' : 'none')) useAppStore.setState({ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mark reads only tab ids tabsByWorktree: { gudgeon: [{ id: 'web-terminal-g' }] } as never @@ -34,6 +42,12 @@ function seed(restored = false): void { agent: 'claude', prompt: null }) + if (scenario === 'setUp' || scenario === 'expired') { + setOrcaCloudAgentAccountsStateForTest(accounts('active')) + } + if (scenario === 'expired') { + setOrcaCloudAgentAccountsStateForTest(accounts('revoked')) + } } function Row(): React.JSX.Element { @@ -46,11 +60,11 @@ function Row(): React.JSX.Element { ) } -const markFixture = (restored: boolean) => +const markFixture = (scenario: Scenario) => defineFixture({ width: 340, render: () => { - seed(restored) + seed(scenario) return (
@@ -66,9 +80,13 @@ const markFixture = (restored: boolean) => } }) -export const sidebarRowMark = markFixture(false) +export const sidebarRowMark = markFixture('notSetUp') /** * V3-01: a launch put back after a restart that only waits for Start: the muted play mark, never * the amber key (the account here is covered: no "not set up"). */ -export const sidebarRowMarkNotStarted = markFixture(true) +export const sidebarRowMarkNotStarted = markFixture('restored') +/** HELD-STALE-COVERED: set up in this session, waiting for Start: muted play (not the amber key). */ +export const sidebarRowMarkSetUp = markFixture('setUp') +/** HELD-STALE-COVERED: the sign-in was revoked under the hold: the key, "sign-in expired". */ +export const sidebarRowMarkExpired = markFixture('expired') From 91507eafd0b4d4765693384b8d7ec1bff38e6d2a Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 18:26:59 -0800 Subject: [PATCH 07/50] fix(cloud): HELD-STALE-COVERED a failed cloud-host confirm warns and leaves a breadcrumb; not-started toast fixture --- .../CloudAgentNotStartedToast.fixture.tsx | 41 +++++++++++++++++++ .../orca-cloud-environment-id.test.ts | 29 +++++++++++++ .../orca-cloud/orca-cloud-environment-id.ts | 11 ++++- 3 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/components/orca-cloud/CloudAgentNotStartedToast.fixture.tsx diff --git a/src/renderer/src/components/orca-cloud/CloudAgentNotStartedToast.fixture.tsx b/src/renderer/src/components/orca-cloud/CloudAgentNotStartedToast.fixture.tsx new file mode 100644 index 00000000000..0d957489587 --- /dev/null +++ b/src/renderer/src/components/orca-cloud/CloudAgentNotStartedToast.fixture.tsx @@ -0,0 +1,41 @@ +import { useEffect } from 'react' +import { toast } from 'sonner' +import { defineFixture } from '../../component-fixtures/component-fixture' +import { Toaster } from '@/components/ui/sonner' +import { agentNotStartedNotice } from '@/lib/orca-cloud/github-cloud-first-agent' + +// #568: a GitHub-only Orca Cloud create whose workspace wasn't ready within the wait: the agent +// didn't start, and the toast says so (it goes away if the workspace becomes ready after all). +function NotStartedToast(): React.JSX.Element { + useEffect(() => { + // After the Toaster mounts, which listens only from then on. + const timer = window.setTimeout(() => { + agentNotStartedNotice('claude', 'acme/web', undefined) + }, 50) + return () => { + window.clearTimeout(timer) + toast.dismiss('cloud-agent-not-started:acme/web') + } + }, []) + return ( + <> +
+ + + ) +} + +export const agentNotStartedToast = defineFixture({ + width: 1280, + render: () => , + interact: async () => { + const deadline = performance.now() + 3000 + while ( + document.querySelector('[data-sonner-toast]')?.getAttribute('data-mounted') !== 'true' && + performance.now() < deadline + ) { + await new Promise((resolve) => setTimeout(resolve, 50)) + } + await new Promise((resolve) => setTimeout(resolve, 400)) + } +}) diff --git a/src/renderer/src/lib/orca-cloud/orca-cloud-environment-id.test.ts b/src/renderer/src/lib/orca-cloud/orca-cloud-environment-id.test.ts index 719a52a8cfa..d1af8604456 100644 --- a/src/renderer/src/lib/orca-cloud/orca-cloud-environment-id.test.ts +++ b/src/renderer/src/lib/orca-cloud/orca-cloud-environment-id.test.ts @@ -1,5 +1,14 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store/index' + +const crumbs = vi.hoisted(() => { + const value: { recorded: { name: string; data: unknown }[] } = { recorded: [] } + return value +}) +vi.mock('@/diagnostics/crash-reporting/crash-breadcrumb-recorder', () => ({ + recordRendererCrashBreadcrumb: (name: string, data: unknown) => + crumbs.recorded.push({ name, data }) +})) import { isOrcaCloudEnvironmentForLaunch, isOrcaCloudExecutionHostId @@ -62,3 +71,23 @@ describe('isOrcaCloudEnvironmentForLaunch (FAIL-CLOSED, #568)', () => { expect(await isOrcaCloudEnvironmentForLaunch('env-server')).toBe(false) }) }) + +describe('isOrcaCloudEnvironmentForLaunch when main cannot answer', () => { + it('a thrown confirm warns, leaves a breadcrumb, and returns false (kept open, logged)', async () => { + useAppStore.setState({ runtimeEnvironments: [] }) + stubMain(async () => { + throw new Error('ipc gone') + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + crumbs.recorded = [] + expect(await isOrcaCloudEnvironmentForLaunch('env-x')).toBe(false) + expect(warn).toHaveBeenCalledWith( + '[orca-cloud] cloud-host confirm failed; launching unheld', + expect.any(Error) + ) + expect(crumbs.recorded).toEqual([ + { name: 'cloud-host confirm failed; launching unheld', data: { environmentId: 'env-x' } } + ]) + warn.mockRestore() + }) +}) diff --git a/src/renderer/src/lib/orca-cloud/orca-cloud-environment-id.ts b/src/renderer/src/lib/orca-cloud/orca-cloud-environment-id.ts index 21f6108a58d..816c4576450 100644 --- a/src/renderer/src/lib/orca-cloud/orca-cloud-environment-id.ts +++ b/src/renderer/src/lib/orca-cloud/orca-cloud-environment-id.ts @@ -1,6 +1,7 @@ import { useAppStore } from '@/store/index' import { isOrcaCloudRuntimeEnvironment } from '../../../../shared/remote-runtime/host-catalog/orca-cloud-environment-name' import { parseExecutionHostId } from '../../../../shared/execution-host' +import { recordRendererCrashBreadcrumb } from '@/diagnostics/crash-reporting/crash-breadcrumb-recorder' /** True when this runtime environment is an Orca Cloud sandbox (never guessed from its name alone). */ export function isOrcaCloudEnvironmentId(environmentId: string): boolean { @@ -20,7 +21,15 @@ export async function isOrcaCloudEnvironmentForLaunch(environmentId: string): Pr } const answer = await window.api.orcaCloudAgents ?.isCloudEnvironment?.({ runtimeEnvironmentId: environmentId }) - .catch(() => false) + .catch((error: unknown) => { + // Kept open (team-lead): a failed confirm must not block every non-cloud server's agents, + // so it is logged where a report will find it. + console.warn('[orca-cloud] cloud-host confirm failed; launching unheld', error) + recordRendererCrashBreadcrumb('cloud-host confirm failed; launching unheld', { + environmentId + }) + return false + }) return answer === true } From 532ae42cb0602fe48dc204f0e27174ba15d72a36 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 18:37:45 -0800 Subject: [PATCH 08/50] fix(cloud): HELD-STALE-COVERED a hold that becomes the one-time setup never auto-starts (askFirst on redecide); set-up mark tooltip says it's set up --- .../orca-cloud/CloudAgentHeldMark.test.tsx | 3 +++ .../orca-cloud/CloudAgentHeldMark.tsx | 20 ++++++++++------ src/renderer/src/i18n/locales/en.json | 1 + .../cloud-agent-launch-hold-stale.test.ts | 23 ++++++++++++++++++- .../lib/orca-cloud/cloud-agent-launch-hold.ts | 16 +++++++++++-- 5 files changed, 53 insertions(+), 10 deletions(-) diff --git a/src/renderer/src/components/orca-cloud/CloudAgentHeldMark.test.tsx b/src/renderer/src/components/orca-cloud/CloudAgentHeldMark.test.tsx index 5f7286a3a58..c1607b5edc7 100644 --- a/src/renderer/src/components/orca-cloud/CloudAgentHeldMark.test.tsx +++ b/src/renderer/src/components/orca-cloud/CloudAgentHeldMark.test.tsx @@ -100,6 +100,9 @@ describe('sidebar mark for a held cloud agent (N-03)', () => { await act(async () => setOrcaCloudAgentAccountsStateForTest(accountsNotSetUp('active'))) expect(getByRole('img').getAttribute('data-cloud-agent-held')).toBe('awaitingStart') expect(getByRole('img').getAttribute('class')).toContain('text-muted-foreground') + expect(getByRole('img').getAttribute('aria-label')).toBe( + 'Claude is set up. Open the workspace to start it.' + ) await act(async () => setOrcaCloudAgentAccountsStateForTest(accountsNotSetUp('revoked'))) expect(getByRole('img').getAttribute('data-cloud-agent-held')).toBe('accountExpired') expect(getByRole('img').getAttribute('aria-label')).toBe('Claude sign-in expired') diff --git a/src/renderer/src/components/orca-cloud/CloudAgentHeldMark.tsx b/src/renderer/src/components/orca-cloud/CloudAgentHeldMark.tsx index 1c8e15bc7c4..8b81805c79c 100644 --- a/src/renderer/src/components/orca-cloud/CloudAgentHeldMark.tsx +++ b/src/renderer/src/components/orca-cloud/CloudAgentHeldMark.tsx @@ -42,13 +42,19 @@ export function CloudAgentHeldMark({ const agent = agentName(hold.agent, t) const waiting = hold.reason === 'awaitingStart' const label = waiting - ? t( - 'orcaCloud.agentHold.notStarted', - '{{agent}} hasn’t started. Open the workspace to start it.', - { - agent - } - ) + ? hold.awaiting === 'setUp' + ? t( + 'orcaCloud.agentHold.setUpNotStarted', + '{{agent}} is set up. Open the workspace to start it.', + { agent } + ) + : t( + 'orcaCloud.agentHold.notStarted', + '{{agent}} hasn’t started. Open the workspace to start it.', + { + agent + } + ) : hold.reason === 'accountExpired' ? t('orcaCloud.agentHold.expired', '{{agent}} sign-in expired', { agent }) : t('orcaCloud.agentHold.notSetUp', '{{agent}} isn’t set up for Orca Cloud', { agent }) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index ddea89ddbcf..825abfdb6c7 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -28253,6 +28253,7 @@ "notSetUp": "{{agent}} isn’t set up for Orca Cloud", "expired": "{{agent}} sign-in expired", "notStarted": "{{agent}} hasn’t started. Open the workspace to start it.", + "setUpNotStarted": "{{agent}} is set up. Open the workspace to start it.", "notStartedRow": "{{agent}} hasn’t started" }, "chatSignIn": { diff --git a/src/renderer/src/lib/orca-cloud/cloud-agent-launch-hold-stale.test.ts b/src/renderer/src/lib/orca-cloud/cloud-agent-launch-hold-stale.test.ts index 207a13c3a7c..21b0301c112 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-agent-launch-hold-stale.test.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-agent-launch-hold-stale.test.ts @@ -57,7 +57,7 @@ beforeEach(() => { }) }) afterEach(() => { - for (const key of ['tab:s1', 'tab:s2', 'tab:s3', 'tab:s4', 'tab:s5']) { + for (const key of ['tab:s1', 'tab:s2', 'tab:s3', 'tab:s4', 'tab:s5', 'tab:s6']) { releaseCloudAgentHold(key, 'cancel') } }) @@ -133,4 +133,25 @@ describe('HELD-STALE-COVERED', () => { expect(readCloudAgentHold('tab:s5')).not.toBeNull() expect(hold.outcome()).toBe('pending') }) + + it('in-session expired -> sign-in removed -> set up again: waits for Start, never auto-starts (PC-3b)', async () => { + setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'expired' })) + let outcome = 'pending' + void holdCloudAgentLaunch({ paneKey: 'tab:s6', agent: 'claude', prompt: null }).then((o) => { + outcome = o + }) + await vi.waitFor(() => + expect(readCloudAgentHold('tab:s6')).toMatchObject({ reason: 'accountExpired' }) + ) + setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'none' })) + await settle() + expect(readCloudAgentHold('tab:s6')).toMatchObject({ reason: 'notSetUp' }) + setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'active' })) + await settle() + expect(readCloudAgentHold('tab:s6')).toMatchObject({ + reason: 'awaitingStart', + awaiting: 'setUp' + }) + expect(outcome).toBe('pending') + }) }) diff --git a/src/renderer/src/lib/orca-cloud/cloud-agent-launch-hold.ts b/src/renderer/src/lib/orca-cloud/cloud-agent-launch-hold.ts index d107c903465..a3aa2cfb59f 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-agent-launch-hold.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-agent-launch-hold.ts @@ -315,7 +315,14 @@ function redecide(entry: Entry, accounts: OrcaCloudAgentAccountsState): void { const now = cloudAgentHoldReason(entry.agent, accounts) if (now !== null) { if (now !== entry.reason) { - holds.set(entry.paneKey, { ...entry, reason: now, awaiting: undefined }) + // A removed sign-in becomes the one-time setup, which never starts unasked (PC-3b), even + // for a hold placed on an expired sign-in in this session. + holds.set(entry.paneKey, { + ...entry, + reason: now, + awaiting: undefined, + askFirst: entry.askFirst || now === 'notSetUp' + }) notify() } return @@ -366,7 +373,12 @@ export async function startCloudAgentHold(paneKey: string): Promise<'started' | const coverage = orcaCloudAgentCoverageFromAccounts(entry.agent, accounts)?.kind if (now !== null || (coverage !== 'account' && coverage !== 'key')) { if (now !== null && now !== entry.reason) { - holds.set(paneKey, { ...entry, reason: now, awaiting: undefined }) + holds.set(paneKey, { + ...entry, + reason: now, + awaiting: undefined, + askFirst: entry.askFirst || now === 'notSetUp' + }) notify() } return 'held' From 571074d55d9bc15fafee5bce1bc7e14bf1cac6cc Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 19:58:00 -0800 Subject: [PATCH 09/50] fix(billing): let overage be turned on again after turning it off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turning overage off left the card's save flag set, so the off view showed a stuck check-mark "Saving…" in place of the Turn on button. Replace the flag with a save state (saving -> saved | error) that always ends with the request, and keep Turn on rendered in the off view at all times. Co-Authored-By: Claude --- .../apps/billing/test/overage-opt-out.test.ts | 17 ++ .../PlanBillingOverageSave.fixture.tsx | 117 ++++++++++++ .../billing/PlanBillingOverageSave.test.tsx | 179 ++++++++++++++++++ .../components/billing/PlanBillingUsage.tsx | 120 ++++++------ .../components/billing/overage-save-state.tsx | 123 ++++++++++++ src/renderer/src/i18n/locales/en.json | 1 + 6 files changed, 491 insertions(+), 66 deletions(-) create mode 100644 src/renderer/src/components/billing/PlanBillingOverageSave.fixture.tsx create mode 100644 src/renderer/src/components/billing/PlanBillingOverageSave.test.tsx create mode 100644 src/renderer/src/components/billing/overage-save-state.tsx diff --git a/cloud/apps/billing/test/overage-opt-out.test.ts b/cloud/apps/billing/test/overage-opt-out.test.ts index 544550aad18..c36d156e976 100644 --- a/cloud/apps/billing/test/overage-opt-out.test.ts +++ b/cloud/apps/billing/test/overage-opt-out.test.ts @@ -44,6 +44,23 @@ async function optedInTeam() { return { h, auth, subscription, overage, prices, use, stored } } +describe('overage on, off and on again (the settings card)', () => { + it('reads back off after turning off, and on with a cap after turning on again', async () => { + const { h, auth, overage } = await optedInTeam() + const read = async () => (await h.call('GET', '/v1/billing/entitlements', { headers: auth })).body.overage + expect((await overage({ enabled: true, spendCapCents: 4000 })).status).toBe(200) + expect(await read()).toMatchObject({ optedIn: true, spendCapCents: 4000 }) + expect((await overage({ enabled: false })).status).toBe(200) + // Off keeps the cap the admin chose, so turning on again without one restores it. + expect(await read()).toMatchObject({ optedIn: false, active: false, spendCapCents: 4000 }) + expect((await overage({ enabled: true, spendCapCents: 2500 })).status).toBe(200) + expect(await read()).toMatchObject({ optedIn: true, spendCapCents: 2500 }) + expect((await overage({ enabled: false })).status).toBe(200) + expect((await overage({ enabled: true })).status).toBe(200) + expect(await read()).toMatchObject({ optedIn: true, spendCapCents: 2500 }) + }) +}) + describe('overage opt-out', () => { it('removes the unused metered item at once, and opting back in adds it again', async () => { const { h, overage, prices, use, stored } = await optedInTeam() diff --git a/src/renderer/src/components/billing/PlanBillingOverageSave.fixture.tsx b/src/renderer/src/components/billing/PlanBillingOverageSave.fixture.tsx new file mode 100644 index 00000000000..10f5f4d8886 --- /dev/null +++ b/src/renderer/src/components/billing/PlanBillingOverageSave.fixture.tsx @@ -0,0 +1,117 @@ +// OVERAGE-REENABLE: the overage card through on → off → on, and each save state it can show. +import { useState } from 'react' +import { defineFixture } from '../../component-fixtures/component-fixture' +import type { + OrcaBillingEntitlement, + OrcaBillingOverageArgs +} from '../../../../shared/orca-billing' +import { PlanBillingView, type PlanBillingNotice } from './PlanBillingSection' +import { sampleTeam } from './plan-billing-fixtures' + +type Outcome = 'ok' | 'failed' | 'pending' + +const overageOn: OrcaBillingEntitlement = { + ...sampleTeam, + overage: { ...sampleTeam.overage, optedIn: true, active: true, spendCapCents: 4000 } +} + +/** Plays PlanBillingSection with a fake billing: busy while out, then a re-read or a failure. */ +function OverageCard({ + initial, + outcome +}: { + initial: OrcaBillingEntitlement + outcome: Outcome +}): React.JSX.Element { + const [entitlement, setEntitlement] = useState(initial) + const [busy, setBusy] = useState(false) + const [failures, setFailures] = useState(0) + const [notice, setNotice] = useState(null) + const setOverage = (args: OrcaBillingOverageArgs): void => { + setBusy(true) + setNotice(null) + if (outcome === 'pending') { + return + } + setTimeout(() => { + setBusy(false) + if (outcome === 'failed') { + setNotice({ kind: 'error', text: 'Could not reach billing. Try again.' }) + setFailures((count) => count + 1) + return + } + setEntitlement((current) => ({ + ...current, + overage: { + ...current.overage, + optedIn: args.enabled, + active: args.enabled, + spendCapCents: args.spendCapCents ?? current.overage.spendCapCents + } + })) + }, 20) + } + return ( + {}} + onPortal={() => {}} + onSetOverage={setOverage} + onSeePlans={() => {}} + /> + ) +} + +function clickButton(frame: HTMLElement, label: RegExp): void { + const button = [...frame.querySelectorAll('button')].find((candidate) => + label.test(candidate.textContent ?? '') + ) + button?.click() +} + +const settle = (): Promise => new Promise((resolve) => setTimeout(resolve, 150)) + +/** Turned on, then off: the off line, a working "Turn on with a $25 cap" and "Saved". */ +export const overageOffAfterOn = defineFixture({ + width: 680, + render: () => , + interact: async (frame) => { + clickButton(frame, /^Turn off overage$/) + await settle() + } +}) + +/** In flight: a spinner with "Saving…" (no check), Turn on still there but inert. */ +export const overageSaving = defineFixture({ + width: 680, + render: () => , + interact: async (frame) => { + clickButton(frame, /^Turn on with a/) + await settle() + } +}) + +/** Turned on again from off: the $25 cap field, focused, with "✓ Saved". */ +export const overageSavedOnAgain = defineFixture({ + width: 680, + render: () => , + interact: async (frame) => { + clickButton(frame, /^Turn on with a/) + await settle() + } +}) + +/** Turn off failed: overage stays on at its cap, "Couldn’t save", and billing's reason below. */ +export const overageSaveError = defineFixture({ + width: 680, + render: () => , + interact: async (frame) => { + clickButton(frame, /^Turn off overage$/) + await settle() + } +}) diff --git a/src/renderer/src/components/billing/PlanBillingOverageSave.test.tsx b/src/renderer/src/components/billing/PlanBillingOverageSave.test.tsx new file mode 100644 index 00000000000..055a899e9ec --- /dev/null +++ b/src/renderer/src/components/billing/PlanBillingOverageSave.test.tsx @@ -0,0 +1,179 @@ +// @vitest-environment happy-dom +// OVERAGE-REENABLE: after Turn off the card showed "✓ Saving…" forever and no way to turn on again. +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { useState } from 'react' +import { afterEach, describe, expect, it } from 'vitest' +import type { + OrcaBillingEntitlement, + OrcaBillingOverageArgs +} from '../../../../shared/orca-billing' +import { OverageControls } from './PlanBillingUsage' +import { sampleTeam } from './plan-billing-fixtures' + +afterEach(cleanup) + +type Pending = { args: OrcaBillingOverageArgs; settle: (ok: boolean) => void } + +/** Plays PlanBillingSection: busy while the request is out, then a re-read or a failure bump. */ +function renderCard(initial: OrcaBillingEntitlement = sampleTeam): { pending: Pending[] } { + const pending: Pending[] = [] + function Card(): React.JSX.Element { + const [entitlement, setEntitlement] = useState(initial) + const [busy, setBusy] = useState(false) + const [failures, setFailures] = useState(0) + return ( + { + setBusy(true) + pending.push({ + args, + settle: (ok) => { + setBusy(false) + if (!ok) { + setFailures((count) => count + 1) + return + } + // Billing keeps the cap through an opt-out; an omitted cap keeps the current one. + setEntitlement((current) => ({ + ...current, + overage: { + ...current.overage, + optedIn: args.enabled, + active: args.enabled, + spendCapCents: args.spendCapCents ?? current.overage.spendCapCents + } + })) + } + }) + }} + /> + ) + } + render() + return { pending } +} + +const settleLast = (pending: Pending[], ok: boolean): void => { + act(() => pending.at(-1)!.settle(ok)) +} +const turnOnButton = (): HTMLElement => + screen.getByRole('button', { name: 'Turn on with a $25 cap' }) +const statusText = (): string | null => screen.queryByRole('status')?.textContent ?? null + +describe('overage on, off and on again', () => { + it('turns on, off and on again with the same control, ending each change in Saved', () => { + const { pending } = renderCard() + fireEvent.click(turnOnButton()) + expect(pending.at(-1)!.args).toEqual({ enabled: true, spendCapCents: 2500 }) + expect(statusText()).toBe('Saving…') + settleLast(pending, true) + expect(screen.getByLabelText('Monthly spend cap').value).toBe('25') + expect(statusText()).toBe('Saved') + + fireEvent.click(screen.getByRole('button', { name: 'Turn off overage' })) + expect(pending.at(-1)!.args).toEqual({ enabled: false }) + expect(statusText()).toBe('Saving…') + settleLast(pending, true) + expect(screen.getByText(/^Overage is off\./)).toBeTruthy() + // The bug: the off view kept a stale flag and showed "✓ Saving…" in place of Turn on. + expect(statusText()).toBe('Saved') + expect(turnOnButton().getAttribute('aria-disabled')).toBeNull() + expect(document.activeElement).toBe(turnOnButton()) + + fireEvent.click(turnOnButton()) + expect(pending).toHaveLength(3) + expect(pending.at(-1)!.args).toEqual({ enabled: true, spendCapCents: 2500 }) + settleLast(pending, true) + expect(screen.getByLabelText('Monthly spend cap')).toBeTruthy() + expect(document.activeElement).toBe(screen.getByLabelText('Monthly spend cap')) + expect(statusText()).toBe('Saved') + }) + + it('keeps Turn on visible but inert while saving, so one click sends one change', () => { + const { pending } = renderCard() + fireEvent.click(turnOnButton()) + expect(turnOnButton().getAttribute('aria-disabled')).toBe('true') + fireEvent.click(turnOnButton()) + expect(pending).toHaveLength(1) + }) + + it('a cap change goes Saving… then Saved, and editing again clears it', () => { + const { pending } = renderCard({ + ...sampleTeam, + overage: { ...sampleTeam.overage, optedIn: true, active: true, spendCapCents: 4000 } + }) + const field = screen.getByLabelText('Monthly spend cap') + fireEvent.change(field, { target: { value: '60' } }) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + expect(statusText()).toBe('Saving…') + expect(screen.queryByRole('button', { name: 'Save' })).toBeNull() + settleLast(pending, true) + expect(statusText()).toBe('Saved') + expect(field.value).toBe('60') + fireEvent.change(field, { target: { value: '70' } }) + expect(statusText()).toBeNull() + expect(screen.getByRole('button', { name: 'Save' })).toBeTruthy() + }) +}) + +describe('overage save state', () => { + it('a failed Turn off ends in Couldn’t save and leaves overage on and changeable', () => { + const { pending } = renderCard({ + ...sampleTeam, + overage: { ...sampleTeam.overage, optedIn: true, active: true, spendCapCents: 4000 } + }) + fireEvent.click(screen.getByRole('button', { name: 'Turn off overage' })) + settleLast(pending, false) + expect(statusText()).toBe('Couldn’t save') + expect(screen.getByLabelText('Monthly spend cap').value).toBe('40') + const off = screen.getByRole('button', { name: 'Turn off overage' }) + expect(off.getAttribute('aria-disabled')).toBeNull() + fireEvent.click(off) + expect(pending).toHaveLength(2) + }) + + it('a failed Turn on ends in Couldn’t save next to a working Turn on', () => { + const { pending } = renderCard() + fireEvent.click(turnOnButton()) + settleLast(pending, false) + expect(statusText()).toBe('Couldn’t save') + expect(screen.queryByLabelText('Monthly spend cap')).toBeNull() + fireEvent.click(turnOnButton()) + expect(pending).toHaveLength(2) + settleLast(pending, true) + expect(statusText()).toBe('Saved') + }) + + it('never shows the check with Saving…', () => { + const { pending } = renderCard() + fireEvent.click(turnOnButton()) + const saving = screen.getByRole('status') + expect(saving.textContent).toBe('Saving…') + expect(saving.querySelector('.lucide-check')).toBeNull() + settleLast(pending, true) + expect(screen.getByRole('status').querySelector('.lucide-check')).toBeTruthy() + }) + + // A host that never reports busy still resolves once the card reads the new state. + it('resolves Saving… from the re-read when busy never changes', () => { + const view = render( + {}} /> + ) + fireEvent.click(turnOnButton()) + expect(statusText()).toBe('Saving…') + view.rerender( + {}} + /> + ) + expect(statusText()).toBe('Saved') + }) +}) diff --git a/src/renderer/src/components/billing/PlanBillingUsage.tsx b/src/renderer/src/components/billing/PlanBillingUsage.tsx index c02871a199a..bcd1ce8bc78 100644 --- a/src/renderer/src/components/billing/PlanBillingUsage.tsx +++ b/src/renderer/src/components/billing/PlanBillingUsage.tsx @@ -1,5 +1,4 @@ import { useEffect, useId, useRef, useState } from 'react' -import { Check } from 'lucide-react' import { Button } from '@/components/ui/button' import { SettingsDisabledControlTooltip } from '@/components/settings/form-controls/SettingsDisabledControlTooltip' import { Input } from '@/components/ui/input' @@ -18,6 +17,7 @@ import { } from './plan-billing-usage-copy' import { formatCents, formatRateCents } from './plans/plans-pricing' import { capTooHighCopy } from './plan-billing-copy' +import { OverageSaveStatus, useOverageSaveState } from './overage-save-state' import { ORCA_BILLING_OVERAGE_SECTION_ID } from '../new-workspace/orca-cloud-run-target-access' /** "Resets and renews Dec 2" when both land the same day (monthly); separate parts otherwise. */ @@ -117,15 +117,19 @@ export function OverageControls({ const capId = useId() const hintId = useId() const capErrorId = useId() - const savedRef = useRef(null) const capInputRef = useRef(null) - // Set by Turn on: once the cap field appears, it takes focus (N-09). - const focusCapOnOpen = useRef(false) + const turnOnRef = useRef(null) + // Set by Turn on / Turn off: once the card switches, focus lands on the new view's control (N-09). + const focusOnSwitch = useRef(false) const { overage } = entitlement useEffect(() => { - if (overage.optedIn && focusCapOnOpen.current) { - focusCapOnOpen.current = false - capInputRef.current?.focus() + if (focusOnSwitch.current) { + focusOnSwitch.current = false + if (overage.optedIn) { + capInputRef.current?.focus() + } else { + turnOnRef.current?.focus() + } } }, [overage.optedIn]) // One field: the cap while overage is on, $0 while it's off. @@ -133,20 +137,20 @@ export function OverageControls({ // Billing's default cap; unknown (no catalog) sends none and billing applies its own. const defaultCapCents = entitlement.offers?.team.defaultSpendCapCents ?? null const [capDollars, setCapDollars] = useState(savedDollars) - // Shows "Saved" after a successful change until the field is edited again (review M13). - const [savedFlash, setSavedFlash] = useState(false) // Re-seed from the server whenever the saved values change (render-time adjustment, no effect). const [seededFrom, setSeededFrom] = useState(savedDollars) if (seededFrom !== savedDollars) { setSeededFrom(savedDollars) setCapDollars(savedDollars) } - const [seenFailures, setSeenFailures] = useState(failures) - if (seenFailures !== failures) { - setSeenFailures(failures) - setCapDollars(savedDollars) - setSavedFlash(false) - } + const { status, begin, clear } = useOverageSaveState({ + overage, + busy, + failures, + // Roll back to what billing has. + onFailure: () => setCapDollars(savedDollars) + }) + const saving = status === 'saving' const parsedCap = Number(capDollars) const maxCapCents = entitlement.offers?.team.maxSpendCapCents ?? null const capWhole = capDollars.trim() !== '' && Number.isInteger(parsedCap) && parsedCap >= 0 @@ -208,6 +212,7 @@ export function OverageControls({ } const save = (enabled: boolean, cents: number | null): void => { + begin({ optedIn: enabled, capCents: enabled ? cents : null }) onSetOverage( !enabled ? { enabled: false } @@ -215,10 +220,9 @@ export function OverageControls({ ? { enabled: true } : { enabled: true, spendCapCents: cents } ) - setSavedFlash(true) - // The action turns into "Saving…" in place; move focus onto it. - window.requestAnimationFrame(() => savedRef.current?.focus()) } + const blocked = busy || saving || paymentFailed + const statusLine = const blockedReason = paymentFailed ? t( 'auto.components.billing.overagePaymentFailed', @@ -241,13 +245,13 @@ export function OverageControls({

) const turnOn = (): void => { - if (busy) { + if (blocked) { return } if (defaultCapCents !== null) { setCapDollars(String(Math.round(defaultCapCents / 100))) } - focusCapOnOpen.current = true + focusOnSwitch.current = true save(true, defaultCapCents) } @@ -265,33 +269,24 @@ export function OverageControls({ 'Overage is off. Running sandboxes go to sleep and new ones can’t start when your team runs out of sandbox hours.' )}

+ {/* Turn on is always here, whatever the last change did (OVERAGE-REENABLE). */}
- {savedFlash ? ( - + - - )} + {defaultCapCents === null + ? t('auto.components.billing.overage.turnOnNoCap', 'Turn on overage') + : t('auto.components.billing.overage.turnOn', 'Turn on with a {{cap}} cap', { + cap: formatCents(defaultCapCents, locale) + })} + + + {statusLine}

{hint}

{alertsNote} @@ -310,8 +305,10 @@ export function OverageControls({ className="flex flex-wrap items-start gap-2" onSubmit={(event) => { event.preventDefault() - if (changed && !busy && !paymentFailed) { - save(parsedCap > 0, parsedCap * 100) + if (changed && !blocked) { + save(true, parsedCap * 100) + // Save turns into the status in place; keep focus in the card on the field. + capInputRef.current?.focus() } }} > @@ -339,7 +336,7 @@ export function OverageControls({ aria-describedby={capValid ? hintId : `${capErrorId} ${hintId}`} onChange={(event) => { setCapDollars(event.target.value) - setSavedFlash(false) + clear() }} />
@@ -354,23 +351,13 @@ export function OverageControls({

)}
- {savedFlash ? ( - - - {busy || changed - ? t('auto.components.billing.capSaving', 'Saving…') - : t('auto.components.billing.capSaved', 'Saved')} - - ) : changed ? ( - - ) : null} + )}

{hint}

@@ -389,10 +376,11 @@ export function OverageControls({ type="button" variant="link-muted" size="inline" - aria-disabled={busy || paymentFailed || undefined} + aria-disabled={blocked || undefined} onClick={() => { - if (!busy && !paymentFailed) { - save(false, 0) + if (!blocked) { + focusOnSwitch.current = true + save(false, null) } }} > diff --git a/src/renderer/src/components/billing/overage-save-state.tsx b/src/renderer/src/components/billing/overage-save-state.tsx new file mode 100644 index 00000000000..a6d5baf16de --- /dev/null +++ b/src/renderer/src/components/billing/overage-save-state.tsx @@ -0,0 +1,123 @@ +import { useState } from 'react' +import { Check, LoaderCircle } from 'lucide-react' +import { useTranslate } from '@/i18n/use-translate' +import type { OrcaBillingEntitlement } from '../../../../shared/orca-billing' + +/** + * Where an overage change stands. `saving` lasts while the request is in flight and always ends: + * in `saved` once billing accepts it (or the card already reads the new state), or in `error`. + * It never outlives the request: a flag that stayed set after Turn off hid the Turn on button + * behind a stuck "✓ Saving…" (OVERAGE-REENABLE). + */ +export type OverageSaveState = 'idle' | 'saving' | 'saved' | 'error' + +/** What a pending change asks for; a null cap means billing's default (or, when off, none). */ +export type OverageTarget = { optedIn: boolean; capCents: number | null } + +export function overageMatches( + overage: OrcaBillingEntitlement['overage'], + target: OverageTarget +): boolean { + if (overage.optedIn !== target.optedIn) { + return false + } + return !target.optedIn || target.capCents === null || overage.spendCapCents === target.capCents +} + +/** + * Tracks one change from Save / Turn on / Turn off to its end. `busy` is the parent's request + * flag and `failures` its failure count; `onFailure` runs (during render) when a change fails. + */ +export function useOverageSaveState({ + overage, + busy, + failures, + onFailure +}: { + overage: OrcaBillingEntitlement['overage'] + busy: boolean + failures: number + onFailure: () => void +}): { + status: OverageSaveState + begin: (target: OverageTarget) => void + /** Back to no status, e.g. once the field is edited again (review M13). */ + clear: () => void +} { + const [saveState, setSaveState] = useState('idle') + const [target, setTarget] = useState(null) + // Whether the parent has reported the request in flight since this change began. + const [sawBusy, setSawBusy] = useState(false) + const [seenFailures, setSeenFailures] = useState(failures) + // Render-time adjustment (no effect), so no frame shows a finished change as "Saving…". + let status = saveState + if (seenFailures !== failures) { + setSeenFailures(failures) + onFailure() + if (status === 'saving') { + status = 'error' + setSaveState('error') + setTarget(null) + } + } else if (status === 'saving') { + if (busy) { + if (!sawBusy) { + setSawBusy(true) + } + } else if (sawBusy || (target !== null && overageMatches(overage, target))) { + // The request finished without a failure, or the card already reads the new state. + status = 'saved' + setSaveState('saved') + setTarget(null) + } + } + return { + status, + begin: (next) => { + setSaveState('saving') + setSawBusy(false) + setTarget(next) + }, + clear: () => { + if (status !== 'saving') { + setSaveState('idle') + } + } + } +} + +/** One status for both views: a spinner while saving; the check only ever goes with "Saved". */ +export function OverageSaveStatus({ + status +}: { + status: OverageSaveState +}): React.JSX.Element | null { + const t = useTranslate() + if (status === 'idle') { + return null + } + return ( + + {status === 'saving' ? ( + <> + + {t('auto.components.billing.capSaving', 'Saving…')} + + ) : status === 'saved' ? ( + <> + + {t('auto.components.billing.capSaved', 'Saved')} + + ) : ( + t('auto.components.billing.capNotSaved', 'Couldn’t save') + )} + + ) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 0c962d060cc..f32bf3161a8 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -21289,6 +21289,7 @@ "featureOverageNoRate": "Optional overage up to a monthly spend cap you set" }, "capLabel2": "Monthly spend cap", + "capNotSaved": "Couldn’t save", "capSaved": "Saved", "checkout": { "attention2": "Stripe needs one more step", From 4a17238c7d0f6f9640071cfada45046ad05e3233 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 20:16:02 -0800 Subject: [PATCH 10/50] fix(cloud): COMPARE-PLANS-SWEEP one label for the plans action; billing blocker's portal button gets Copy link "See plans" becomes "Compare plans" (the one key, auto.components.billing.comparePlans) on Plan & billing's and the team plan's upgrade button, the VM build failure card and toast, and the cloud-session create toast, as design-squad asked. The create sheet's billing blocker "Update payment method" opens the billing portal in the browser, so it becomes an OpenExternalSplitButton whose chevron copies the same portal link (COPY-LINK-RULE). Drops the two private keys it replaced. --- .../src/components/billing/PlanBillingSection.tsx | 2 +- .../src/components/billing/team/TeamPlanSummary.tsx | 2 +- .../worktree-creation/VmBuildBillingBlocker.tsx | 11 +++++++++-- .../worktree-creation/VmBuildFailureActions.tsx | 2 +- .../worktree-creation/VmBuildFailureCard.test.tsx | 6 +++--- .../worktree-creation/VmBuildSheetGitHub.fixture.tsx | 2 +- .../worktree-creation/vm-build-failure-toast.ts | 2 +- src/renderer/src/i18n/locales/en.json | 2 -- src/renderer/src/lib/orca-cloud-session-events.ts | 4 ++-- 9 files changed, 19 insertions(+), 14 deletions(-) diff --git a/src/renderer/src/components/billing/PlanBillingSection.tsx b/src/renderer/src/components/billing/PlanBillingSection.tsx index a719d090fd6..058d0a0450c 100644 --- a/src/renderer/src/components/billing/PlanBillingSection.tsx +++ b/src/renderer/src/components/billing/PlanBillingSection.tsx @@ -280,7 +280,7 @@ export function PlanBillingView({
{hasPlan || onTrial ? null : ( )} {hasPlan && entitlement.canManageBilling && entitlement.payment.pastDueSince === null ? ( diff --git a/src/renderer/src/components/billing/team/TeamPlanSummary.tsx b/src/renderer/src/components/billing/team/TeamPlanSummary.tsx index 131d14a71b4..b728168ebf5 100644 --- a/src/renderer/src/components/billing/team/TeamPlanSummary.tsx +++ b/src/renderer/src/components/billing/team/TeamPlanSummary.tsx @@ -261,7 +261,7 @@ export function TeamUpgradeCard({
{entitlement.canManageBilling ? ( ) : null} diff --git a/src/renderer/src/components/worktree-creation/VmBuildBillingBlocker.tsx b/src/renderer/src/components/worktree-creation/VmBuildBillingBlocker.tsx index b7eb6b6f8e7..452ed36a461 100644 --- a/src/renderer/src/components/worktree-creation/VmBuildBillingBlocker.tsx +++ b/src/renderer/src/components/worktree-creation/VmBuildBillingBlocker.tsx @@ -11,6 +11,8 @@ import { overageFailureCopy } from '../billing/plan-billing-copy' import { openPlansPage } from '../billing/plans/plans-page-store' import { requestOrcaCloudAfterCheckout } from '@/lib/orca-cloud-after-checkout' import { formatCents } from '../billing/plans/plans-pricing' +import { billingPortalLink } from '../billing/billing-link' +import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton' /** The four ways billing can refuse a create (JA-21 progressive billing). */ export type BillingBlockKind = 'plan' | 'hoursUsed' | 'spendCap' | 'payment' @@ -213,12 +215,17 @@ export function VmBuildBillingBlockerView({ ) : null} {kind === 'payment' ? ( - + ) : ( +
+ ) : ( + + )} + + + {needsOwnMfa ? ( + + {t('auto.components.teamMfa.setUp', 'Set up MFA')} + + } + /> + ) : null} + + {active === null && grace === 'now' && pendingCount > 0 && !needsOwnMfa ? ( +

+ {t( + 'auto.components.teamMfa.nowWarning', + '{{count}} members don’t have it yet and will lose access to this team right away.', + { + count: pendingCount, + defaultValue_one: + '{{count}} member doesn’t have it yet and will lose access to this team right away.' + } + )} +

+ ) : null} + + {active !== null ? ( +
+
+

+ {pendingCount === 0 + ? t('auto.components.teamMfa.allSet', 'Every member has it set up.') + : t('auto.components.teamMfa.pendingCount', '{{count}} members still need it', { + count: pendingCount, + defaultValue_one: '{{count}} member still needs it' + })} +

+ {pendingCount > 0 ? ( + + ) : null} +
+ {pending && pending.length > 0 ? ( +
    + {pending.map((member) => ( +
  • +
    +

    {member.displayName ?? member.email}

    + {member.displayName ? ( +

    {member.email}

    + ) : null} +
    + + {member.mfa === 'blocked' + ? t('auto.components.teamMfa.memberBlocked', 'No access') + : t('auto.components.teamMfa.memberPending', 'Pending')} + + {member.userId ? ( + + ) : null} +
  • + ))} +
+ ) : null} +
+ ) : null} + + {notice?.kind === 'error' ? : null} + {notice?.kind === 'reminded' ? ( +

+ {notice.emailed + ? t('auto.components.teamMfa.reminded', 'Reminder sent to {{count}} members.', { + count: notice.recipients, + defaultValue_one: 'Reminder sent to {{count}} member.' + }) + : t( + 'auto.components.teamMfa.remindedNoEmail', + 'Reminder recorded, but email delivery is off on this server, so nothing was sent.' + )} +

+ ) : null} + + ) +} diff --git a/src/renderer/src/components/team-mfa/TeamMfa.fixture.tsx b/src/renderer/src/components/team-mfa/TeamMfa.fixture.tsx new file mode 100644 index 00000000000..0ee240a5a28 --- /dev/null +++ b/src/renderer/src/components/team-mfa/TeamMfa.fixture.tsx @@ -0,0 +1,128 @@ +import { defineFixture } from '../../component-fixtures/component-fixture' +import type { + OrcaTeamMfaPendingMember, + OrcaTeamMfaPolicy +} from '../../../../shared/orca-team-mfa-policy' +import { RequireMfaRow, type RequireMfaRowProps } from './RequireMfaRow' +import { TeamMfaBanner } from './TeamMfaBanner' +import { TeamMfaBlockedScreen } from './TeamMfaBlockedScreen' + +const NOW = Date.UTC(2026, 10, 18, 15) +const DAY = 86_400_000 +const setupUrl = 'https://cloud.orca.dev/v1/account/security' +const noop = (): void => {} +const width = 760 + +function frame(children: React.ReactNode, frameWidth = width) { + return defineFixture({ + width: frameWidth, + render: () =>
{children}
+ }) +} + +// ---- Banner: a member without MFA, before the deadline ---- + +const banner = (daysLeft: number, frameWidth = width) => + frame( + , + frameWidth + ) + +export const bannerTwoWeeks = banner(14) +export const bannerOneDay = banner(1) +export const bannerNarrow = banner(6, 420) + +// ---- Blocked screen: after the deadline ---- + +const blocked = (props: { checking?: boolean; stillMissing?: boolean; personal?: boolean }) => + frame( + + ) + +export const blockedScreen = blocked({}) +export const blockedChecking = blocked({ checking: true }) +/** "I've set it up" found no MFA yet. */ +export const blockedStillMissing = blocked({ stillMissing: true }) + +// ---- Settings, Enterprise, Multi-factor authentication ---- + +const pendingMembers: OrcaTeamMfaPendingMember[] = [ + { userId: 'u-ada', email: 'ada@acme.dev', displayName: 'Ada Lovelace', mfa: 'pending' }, + { userId: 'u-grace', email: 'grace@acme.dev', displayName: 'Grace Hopper', mfa: 'pending' }, + { userId: null, email: 'linus@acme.dev', mfa: 'pending' } +] + +const off: OrcaTeamMfaPolicy = { + policy: null, + self: 'on', + setupUrl, + manage: { available: true, pendingMembers: 3 } +} +const scheduled: OrcaTeamMfaPolicy = { + ...off, + policy: { enforceAt: NOW + 6 * DAY, setAt: NOW - 8 * DAY } +} + +const row = (props: Partial) => + frame( + + ) + +/** Not required yet: pick when, then Require MFA. */ +export const rowOff = row({}) +/** The admin has no MFA themselves: the change is held back until they add it. */ +export const rowAdminNeedsOwnMfa = row({ policy: { ...off, self: 'off' } }) +/** Scheduled: the date, the countdown, who is pending, and Remind. */ +export const rowScheduled = row({ policy: scheduled, pending: pendingMembers }) +/** Past the deadline: pending members have no access. */ +export const rowEnforced = row({ + policy: { ...off, policy: { enforceAt: NOW - 2 * DAY, setAt: NOW - 16 * DAY } }, + pending: pendingMembers.map((member) => ({ ...member, mfa: 'blocked' as const })) +}) +export const rowEveryoneSet = row({ + policy: { ...scheduled, manage: { available: true, pendingMembers: 0 } }, + pending: [] +}) +export const rowSaving = row({ policy: scheduled, pending: pendingMembers, busy: true }) +export const rowReminded = row({ + policy: scheduled, + pending: pendingMembers, + notice: { kind: 'reminded', recipients: 3, emailed: true } +}) +/** The server is in log mode: say plainly that no email went out. */ +export const rowRemindedNoEmail = row({ + policy: scheduled, + pending: pendingMembers, + notice: { kind: 'reminded', recipients: 3, emailed: false } +}) +export const rowError = row({ + policy: scheduled, + pending: pendingMembers, + notice: { kind: 'error', message: 'Couldn’t save the change. Try again.' } +}) diff --git a/src/renderer/src/components/team-mfa/TeamMfaBanner.tsx b/src/renderer/src/components/team-mfa/TeamMfaBanner.tsx new file mode 100644 index 00000000000..5fac6fbd8b9 --- /dev/null +++ b/src/renderer/src/components/team-mfa/TeamMfaBanner.tsx @@ -0,0 +1,48 @@ +import { InlineError } from '@/components/ui/inline-error' +import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton' +import { useIntlLocale, useTranslate } from '@/i18n/use-translate' +import { mfaCountdownLabel, mfaDeadlineDate } from './team-mfa-copy' + +export type TeamMfaBannerProps = { + teamName: string + /** Epoch ms from which the team is closed to members without MFA. */ + enforceAt: number + now: number + setupUrl: string + onSetUp: () => void +} + +/** + * Shown to a member whose team requires MFA and who has none yet, until the deadline: how long + * is left and the way to set it up. After the deadline the blocked screen takes over. + */ +export function TeamMfaBanner({ + teamName, + enforceAt, + now, + setupUrl, + onSetUp +}: TeamMfaBannerProps): React.JSX.Element { + const t = useTranslate() + const locale = useIntlLocale() + return ( + + {t('auto.components.teamMfa.setUp', 'Set up MFA')} + + } + /> + ) +} diff --git a/src/renderer/src/components/team-mfa/TeamMfaBlockedScreen.tsx b/src/renderer/src/components/team-mfa/TeamMfaBlockedScreen.tsx new file mode 100644 index 00000000000..b2ea07b51ce --- /dev/null +++ b/src/renderer/src/components/team-mfa/TeamMfaBlockedScreen.tsx @@ -0,0 +1,93 @@ +import { Loader2, ShieldAlert } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton' +import { useIntlLocale, useTranslate } from '@/i18n/use-translate' +import { mfaDeadlineDate } from './team-mfa-copy' + +export type TeamMfaBlockedScreenProps = { + teamName: string + enforceAt: number + setupUrl: string + /** A re-check is running (after "I've set it up", or on returning from the browser). */ + checking: boolean + /** The last re-check still found no MFA. */ + stillMissing: boolean + onSetUp: () => void + onRecheck: () => void + /** Switch to the personal account, which the team's policy does not affect. */ + onUsePersonal?: () => void +} + +/** + * In place of a team's content once its MFA deadline has passed for a member without MFA: what + * happened, the one thing to do, and a way to keep working outside the team meanwhile. + */ +export function TeamMfaBlockedScreen({ + teamName, + enforceAt, + setupUrl, + checking, + stillMissing, + onSetUp, + onRecheck, + onUsePersonal +}: TeamMfaBlockedScreenProps): React.JSX.Element { + const t = useTranslate() + const locale = useIntlLocale() + return ( +
+
+ +
+
+

+ {t( + 'auto.components.teamMfa.blockedTitle', + 'Set up two-factor authentication to use {{value0}}', + { value0: teamName } + )} +

+

+ {t( + 'auto.components.teamMfa.blockedBody', + '{{value0}} has required it for all members since {{value1}}. Your workspaces and files are safe, and open again as soon as you add an authenticator app.', + { value0: teamName, value1: mfaDeadlineDate(enforceAt, locale) } + )} +

+
+
+ + {t('auto.components.teamMfa.setUp', 'Set up MFA')} + + +
+

+ {stillMissing && !checking + ? t( + 'auto.components.teamMfa.stillMissing', + 'Your account still has no two-factor authentication. Finish the setup in your browser, then try again.' + ) + : null} +

+ {onUsePersonal ? ( + + ) : null} +
+ ) +} diff --git a/src/renderer/src/components/team-mfa/team-mfa-copy.ts b/src/renderer/src/components/team-mfa/team-mfa-copy.ts new file mode 100644 index 00000000000..417d0974d06 --- /dev/null +++ b/src/renderer/src/components/team-mfa/team-mfa-copy.ts @@ -0,0 +1,59 @@ +// Copy and small rules shared by the MFA banner, the blocked screen and the Enterprise row. Pure, +// so the views and their tests agree on every boundary. +import type { Translate } from '@/i18n/use-translate' +import type { OrcaTeamMfaGrace, OrcaTeamMfaPolicy } from '../../../../shared/orca-team-mfa-policy' + +const DAY_MS = 24 * 60 * 60 * 1000 + +/** Whole days left, rounded up; 0 once the deadline has passed. Matches the server and the emails. */ +export function mfaDaysLeft(enforceAt: number, now: number): number { + return Math.max(0, Math.ceil((enforceAt - now) / DAY_MS)) +} + +/** + * What the person should see for this team: + * - `banner`: the team requires MFA, they have none, and there is still time. + * - `blocked`: the deadline passed; the team is closed to them until they set it up. + * - `none`: nothing to show (no policy, they have MFA, or their state is unknown). + * Only the server's `blocked` blocks: the desktop never decides that from the clock. + */ +export function teamMfaSurface(policy: OrcaTeamMfaPolicy | null): 'none' | 'banner' | 'blocked' { + if (!policy?.policy) { + return 'none' + } + if (policy.self === 'blocked') { + return 'blocked' + } + return policy.self === 'pending' ? 'banner' : 'none' +} + +/** "6 days left", "1 day left", "Due now". i18next plurals, never ternaries on the count. */ +export function mfaCountdownLabel(enforceAt: number, now: number, t: Translate): string { + const days = mfaDaysLeft(enforceAt, now) + if (days <= 0) { + return t('auto.components.teamMfa.dueNow', 'Due now') + } + return t('auto.components.teamMfa.daysLeft', '{{count}} days left', { + count: days, + defaultValue_one: '{{count}} day left' + }) +} + +export function mfaDeadlineDate(enforceAt: number, locale: string): string { + return new Intl.DateTimeFormat(locale, { month: 'long', day: 'numeric', year: 'numeric' }).format( + enforceAt + ) +} + +export function mfaGraceLabel(grace: OrcaTeamMfaGrace, t: Translate): string { + switch (grace) { + case 'now': + return t('auto.components.teamMfa.graceNow', 'Immediately') + case 7: + return t('auto.components.teamMfa.grace7', 'In 7 days') + case 14: + return t('auto.components.teamMfa.grace14', 'In 14 days') + case 30: + return t('auto.components.teamMfa.grace30', 'In 30 days') + } +} diff --git a/src/renderer/src/components/team-mfa/team-mfa.test.tsx b/src/renderer/src/components/team-mfa/team-mfa.test.tsx new file mode 100644 index 00000000000..90a77e21667 --- /dev/null +++ b/src/renderer/src/components/team-mfa/team-mfa.test.tsx @@ -0,0 +1,203 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { translate } from '@/i18n/i18n' +import type { OrcaTeamMfaPolicy } from '../../../../shared/orca-team-mfa-policy' +import { RequireMfaRow, type RequireMfaRowProps } from './RequireMfaRow' +import { TeamMfaBanner } from './TeamMfaBanner' +import { TeamMfaBlockedScreen } from './TeamMfaBlockedScreen' +import { mfaCountdownLabel, mfaDaysLeft, teamMfaSurface } from './team-mfa-copy' + +afterEach(cleanup) + +const DAY = 86_400_000 +const NOW = Date.UTC(2026, 10, 18, 15) +const setupUrl = 'https://cloud.orca.dev/v1/account/security' + +const policy = (over: Partial = {}): OrcaTeamMfaPolicy => ({ + policy: { enforceAt: NOW + 6 * DAY, setAt: NOW - DAY }, + self: 'pending', + setupUrl, + ...over +}) + +describe('countdown', () => { + it('rounds up to whole days and stops at zero', () => { + expect(mfaDaysLeft(NOW + 7 * DAY, NOW)).toBe(7) + expect(mfaDaysLeft(NOW + 6 * DAY + 1, NOW)).toBe(7) + expect(mfaDaysLeft(NOW + DAY / 2, NOW)).toBe(1) + expect(mfaDaysLeft(NOW, NOW)).toBe(0) + expect(mfaDaysLeft(NOW - DAY, NOW)).toBe(0) + }) + + it('reads as days left, one day left, then due now', () => { + expect(mfaCountdownLabel(NOW + 6 * DAY, NOW, translate)).toBe('6 days left') + expect(mfaCountdownLabel(NOW + DAY, NOW, translate)).toBe('1 day left') + expect(mfaCountdownLabel(NOW, NOW, translate)).toBe('Due now') + }) +}) + +describe('what the member sees', () => { + it('is the banner while pending and the blocked screen only on the server’s word', () => { + expect(teamMfaSurface(policy())).toBe('banner') + expect(teamMfaSurface(policy({ self: 'blocked' }))).toBe('blocked') + // Past the deadline by the local clock, but the server still says pending: not blocked. + expect( + teamMfaSurface(policy({ policy: { enforceAt: NOW - DAY, setAt: NOW - 8 * DAY } })) + ).toBe('banner') + }) + + it('is nothing with MFA on, an unknown state, or no policy', () => { + expect(teamMfaSurface(policy({ self: 'on' }))).toBe('none') + expect(teamMfaSurface(policy({ self: 'unknown' }))).toBe('none') + expect(teamMfaSurface(policy({ policy: null, self: 'off' }))).toBe('none') + expect(teamMfaSurface(policy({ policy: null, self: 'blocked' }))).toBe('none') + expect(teamMfaSurface(null)).toBe('none') + }) +}) + +describe('banner', () => { + it('names the team, the countdown and the date, and offers the setup action', () => { + const onSetUp = vi.fn() + const { container, getByRole } = render( + + ) + expect(container.textContent).toContain('Acme requires two-factor authentication. 6 days left.') + expect(container.textContent).toContain('November 24, 2026') + fireEvent.click(getByRole('button', { name: 'Set up MFA' })) + expect(onSetUp).toHaveBeenCalledTimes(1) + }) +}) + +describe('blocked screen', () => { + const screen = (props: { checking?: boolean; stillMissing?: boolean } = {}) => { + const handlers = { onSetUp: vi.fn(), onRecheck: vi.fn(), onUsePersonal: vi.fn() } + const view = render( + + ) + return { ...view, ...handlers } + } + + it('says what to do, not a generic error, and keeps the personal account reachable', () => { + const view = screen() + expect(view.container.textContent).toContain('Set up two-factor authentication to use Acme') + expect(view.container.textContent).toContain('Your workspaces and files are safe') + fireEvent.click(view.getByRole('button', { name: 'Set up MFA' })) + fireEvent.click(view.getByRole('button', { name: 'I’ve set it up' })) + fireEvent.click(view.getByRole('button', { name: 'Use my personal account for now' })) + expect(view.onSetUp).toHaveBeenCalledTimes(1) + expect(view.onRecheck).toHaveBeenCalledTimes(1) + expect(view.onUsePersonal).toHaveBeenCalledTimes(1) + }) + + it('ignores a second re-check while one runs, and says when MFA is still missing', () => { + const checking = screen({ checking: true }) + fireEvent.click(checking.getByRole('button', { name: 'I’ve set it up' })) + expect(checking.onRecheck).not.toHaveBeenCalled() + cleanup() + expect(screen({ stillMissing: true }).container.textContent).toContain( + 'Your account still has no two-factor authentication' + ) + }) +}) + +describe('Require MFA row', () => { + const manage = { available: true, pendingMembers: 2 } + const row = (props: Partial = {}) => { + const handlers = { + onRequire: vi.fn(), + onTurnOff: vi.fn(), + onRemind: vi.fn(), + onSetUpOwn: vi.fn() + } + const view = render( + + ) + return { ...view, ...handlers } + } + + it('requires MFA with the chosen grace period, 14 days by default', () => { + const view = row() + fireEvent.click(view.getByRole('button', { name: 'Require MFA' })) + expect(view.onRequire).toHaveBeenCalledWith(14) + }) + + it('holds the change back from an admin without MFA, and points them to setup', () => { + const view = row({ policy: policy({ policy: null, self: 'off', manage }) }) + fireEvent.click(view.getByRole('button', { name: 'Require MFA' })) + expect(view.onRequire).not.toHaveBeenCalled() + expect(view.container.textContent).toContain( + 'Set up two-factor authentication on your own account first' + ) + fireEvent.click(view.getByRole('button', { name: 'Set up MFA' })) + expect(view.onSetUpOwn).toHaveBeenCalledTimes(1) + }) + + it('holds turning it off back from an admin without MFA too', () => { + const view = row({ policy: policy({ self: 'pending', manage }) }) + fireEvent.click(view.getByRole('button', { name: 'Stop requiring' })) + expect(view.onTurnOff).not.toHaveBeenCalled() + }) + + it('shows the date, who is pending, and reminds everyone or one member', () => { + const view = row({ + policy: policy({ self: 'on', manage }), + pending: [ + { userId: 'u-ada', email: 'ada@acme.dev', displayName: 'Ada Lovelace', mfa: 'pending' }, + { userId: null, email: 'linus@acme.dev', mfa: 'pending' } + ] + }) + expect(view.container.textContent).toContain('Required from November 24, 2026. 6 days left.') + expect(view.container.textContent).toContain('2 members still need it') + fireEvent.click(view.getByRole('button', { name: 'Remind everyone' })) + expect(view.onRemind).toHaveBeenLastCalledWith() + // A member who never signed in to Orca has no id to remind by; only Ada gets a button. + const single = view.getAllByRole('button', { name: 'Remind' }) + expect(single).toHaveLength(1) + fireEvent.click(single[0]!) + expect(view.onRemind).toHaveBeenLastCalledWith('u-ada') + }) + + it('says members have no access once the deadline passed', () => { + const view = row({ + policy: policy({ + self: 'on', + manage, + policy: { enforceAt: NOW - DAY, setAt: NOW - 8 * DAY } + }), + pending: [{ userId: 'u-ada', email: 'ada@acme.dev', mfa: 'blocked' }] + }) + expect(view.container.textContent).toContain('Required since November 17, 2026') + expect(view.container.textContent).toContain('No access') + }) + + it('says plainly when a reminder was recorded but not emailed', () => { + const view = row({ + policy: policy({ self: 'on', manage }), + notice: { kind: 'reminded', recipients: 2, emailed: false } + }) + expect(view.container.textContent).toContain('nothing was sent') + expect(view.container.textContent).not.toContain('Reminder sent') + }) +}) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 069072a01e9..1b6b7909019 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -21700,6 +21700,45 @@ }, "NewWorkspaceComposerFooter": { "create": "Create" + }, + "teamMfa": { + "dueNow": "Due now", + "daysLeft_one": "{{count}} day left", + "daysLeft_other": "{{count}} days left", + "graceNow": "Immediately", + "grace7": "In 7 days", + "grace14": "In 14 days", + "grace30": "In 30 days", + "bannerTitle": "{{value0}} requires two-factor authentication. {{value1}}.", + "bannerBody": "Set it up by {{value0}}. After that you can’t use this team’s workspaces until you do.", + "setUp": "Set up MFA", + "blockedTitle": "Set up two-factor authentication to use {{value0}}", + "blockedBody": "{{value0}} has required it for all members since {{value1}}. Your workspaces and files are safe, and open again as soon as you add an authenticator app.", + "recheck": "I’ve set it up", + "stillMissing": "Your account still has no two-factor authentication. Finish the setup in your browser, then try again.", + "usePersonal": "Use my personal account for now", + "rowTitle": "Require two-factor authentication", + "rowOff": "Members without it lose access to this team’s workspaces after the date you pick, until they set it up. They get an email now and reminders before the date.", + "rowEnforced": "Required since {{value0}}. Members without it can’t use this team until they set it up.", + "rowScheduled": "Required from {{value0}}. {{value1}}.", + "graceLabel": "When to start requiring it", + "require": "Require MFA", + "turnOff": "Stop requiring", + "ownMfaTitle": "Set up two-factor authentication on your own account first", + "ownMfaBody": "Only someone who already has it can change this, so nobody locks the team out.", + "nowWarning_one": "{{count}} member doesn’t have it yet and will lose access to this team right away.", + "nowWarning_other": "{{count}} members don’t have it yet and will lose access to this team right away.", + "allSet": "Every member has it set up.", + "pendingCount_one": "{{count}} member still needs it", + "pendingCount_other": "{{count}} members still need it", + "remindAll": "Remind everyone", + "pendingList": "Members without MFA", + "memberBlocked": "No access", + "memberPending": "Pending", + "remindOne": "Remind", + "reminded_one": "Reminder sent to {{count}} member.", + "reminded_other": "Reminder sent to {{count}} members.", + "remindedNoEmail": "Reminder recorded, but email delivery is off on this server, so nothing was sent." } }, "i18n": { diff --git a/src/renderer/src/web/preload-api/web-orca-profiles-api.ts b/src/renderer/src/web/preload-api/web-orca-profiles-api.ts index 99d7016d68b..32bd36d44d1 100644 --- a/src/renderer/src/web/preload-api/web-orca-profiles-api.ts +++ b/src/renderer/src/web/preload-api/web-orca-profiles-api.ts @@ -66,6 +66,10 @@ export function createWebOrcaProfilesApi(): Partial { auth: await webOrcaProfileAuthStatus() }), accountMfa: async () => ({ status: 'unconfigured' }), + teamMfaPolicy: async () => ({ status: 'unconfigured' }), + teamMfaPolicySet: async () => ({ status: 'unconfigured' }), + teamMfaPending: async () => ({ status: 'unconfigured' }), + teamMfaRemind: async () => ({ status: 'unconfigured' }), orgMembersList: async () => ({ status: 'unconfigured' }), orgMemberInvite: async () => ({ status: 'unconfigured' }), orgInviteRevoke: async () => ({ status: 'unconfigured' }), diff --git a/src/shared/orca-team-mfa-policy.ts b/src/shared/orca-team-mfa-policy.ts new file mode 100644 index 00000000000..7e86956b4ed --- /dev/null +++ b/src/shared/orca-team-mfa-policy.ts @@ -0,0 +1,66 @@ +// A team's "require MFA" policy (Enterprise). The auth service owns it; the desktop reads the +// deadline and the person's own state for the banner and the blocked screen, and owners and +// admins set it from Settings, Enterprise. + +/** Enforce now, or after a grace period in days. Mirrors the server's fixed choices. */ +export const ORCA_TEAM_MFA_GRACE_CHOICES = ['now', 7, 14, 30] as const +export type OrcaTeamMfaGrace = (typeof ORCA_TEAM_MFA_GRACE_CHOICES)[number] + +/** + * The person's own state in this team: + * - `pending`: the team requires MFA, they have none, the deadline has not passed. + * - `blocked`: the deadline passed; the team's workspaces and settings are closed to them. + * - `unknown`: the identity provider did not say; never treated as blocked. + */ +export type OrcaTeamMfaSelfState = 'on' | 'off' | 'pending' | 'blocked' | 'unknown' + +export type OrcaTeamMfaPolicy = { + /** Null when the team has no policy in force. */ + policy: { enforceAt: number; setAt: number } | null + self: OrcaTeamMfaSelfState + /** The account's security page, on Orca Cloud's own origin (checked in main). */ + setupUrl: string + /** Owners and admins only. */ + manage?: { + /** The team's plan includes MFA enforcement. */ + available: boolean + pendingMembers: number + /** A stored policy the plan no longer covers: it enforces nothing. */ + inactiveEnforceAt?: number + } +} + +export type OrcaTeamMfaPendingMember = { + userId: string | null + email: string + displayName?: string + mfa: 'pending' | 'blocked' | 'off' +} + +type Unavailable = + | { status: 'unconfigured' | 'reconnect-required' | 'unsupported' | 'forbidden' | 'rate-limited' } + | { status: 'failed'; error: string } + +export type OrcaTeamMfaPolicyResult = ({ status: 'ok' } & OrcaTeamMfaPolicy) | Unavailable + +export type OrcaTeamMfaPolicySetResult = + | { status: 'ok'; policy: { enforceAt: number; setAt: number } | null; pendingMembers: number } + /** The person changing the policy has no MFA themselves; the policy read has the setup link. */ + | { status: 'actor-needs-mfa' } + /** The team's plan does not include MFA enforcement. */ + | { status: 'not-entitled' } + | Unavailable + +export type OrcaTeamMfaPendingResult = + | { status: 'ok'; members: OrcaTeamMfaPendingMember[] } + | Unavailable + +export type OrcaTeamMfaRemindResult = + /** `delivery: 'log'` means the server recorded the reminder but sent no email. */ + | { status: 'ok'; recipients: number; delivery: 'log' | 'test-inbox' | 'unknown' } + | { status: 'no-policy' | 'not-found' } + | Unavailable + +export type OrcaTeamMfaPolicyArgs = { orgId: string } +export type OrcaTeamMfaPolicySetArgs = { orgId: string; enforce: OrcaTeamMfaGrace | 'off' } +export type OrcaTeamMfaRemindArgs = { orgId: string; userId?: string } From 4e7a04c50a6fb57c9d66ddf6ecd7dfa8412be87b Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 20:36:13 -0800 Subject: [PATCH 13/50] feat(cloud): ENTERPRISE-AUDIT (2/2) Settings > Enterprise with the team audit log; remove Recent activity --- src/main/cloud-api/cloud-api-client.test.ts | 88 +++- src/main/cloud-api/cloud-api-client.ts | 48 ++- src/main/cloud-api/cloud-api-ipc.ts | 15 +- src/preload/api/cloud-api-bridge.ts | 18 +- .../billing/team/TeamDialogHost.tsx | 1 - .../billing/team/TeamPageView.fixture.tsx | 3 +- .../components/billing/team/TeamPageView.tsx | 34 +- .../settings/cloud-api/CloudApiKeysPane.tsx | 19 - .../cloud-api/CloudApiKeysSections.tsx | 194 +-------- .../cloud-api/CloudApiKeysView.fixture.tsx | 40 -- .../settings/cloud-api/CloudApiKeysView.tsx | 16 +- .../settings/cloud-api/cloud-api-copy.ts | 5 + .../settings/cloud-api/cloud-api-search.ts | 1 - .../enterprise/EnterpriseAuditLog.tsx | 382 ++++++++++++++++++ .../enterprise/EnterpriseSettingsPane.tsx | 62 +++ .../EnterpriseSettingsView.fixture.tsx | 104 +++++ .../EnterpriseSettingsView.test.tsx | 313 ++++++++++++++ .../enterprise/EnterpriseSettingsView.tsx | 58 +++ .../settings/enterprise/EnterpriseUpsell.tsx | 43 ++ .../enterprise/enterprise-audit-copy.ts | 311 ++++++++++++++ .../enterprise/enterprise-fixture-data.ts | 152 +++++++ .../enterprise/use-enterprise-audit-log.ts | 196 +++++++++ .../navigation/settings-navigation-types.ts | 1 + .../orca-cloud/OrcaCloudSettingsPanes.tsx | 6 + .../orca-cloud/orca-cloud-settings-search.ts | 18 + .../settings-orca-cloud-section-renderers.tsx | 10 +- .../settings-navigation-build-options.ts | 2 + ...ngs-navigation-orca-cloud-sections.test.ts | 27 ++ ...settings-navigation-orca-cloud-sections.ts | 25 +- .../hooks/useSettingsNavigationMetadata.ts | 7 + src/renderer/src/i18n/locales/en.json | 142 ++++++- .../lib/orca-cloud/active-team-role.test.ts | 58 +++ .../src/lib/orca-cloud/active-team-role.ts | 29 ++ src/shared/cloud-api/cloud-api-types.ts | 89 +++- 34 files changed, 2152 insertions(+), 365 deletions(-) create mode 100644 src/renderer/src/components/settings/enterprise/EnterpriseAuditLog.tsx create mode 100644 src/renderer/src/components/settings/enterprise/EnterpriseSettingsPane.tsx create mode 100644 src/renderer/src/components/settings/enterprise/EnterpriseSettingsView.fixture.tsx create mode 100644 src/renderer/src/components/settings/enterprise/EnterpriseSettingsView.test.tsx create mode 100644 src/renderer/src/components/settings/enterprise/EnterpriseSettingsView.tsx create mode 100644 src/renderer/src/components/settings/enterprise/EnterpriseUpsell.tsx create mode 100644 src/renderer/src/components/settings/enterprise/enterprise-audit-copy.ts create mode 100644 src/renderer/src/components/settings/enterprise/enterprise-fixture-data.ts create mode 100644 src/renderer/src/components/settings/enterprise/use-enterprise-audit-log.ts create mode 100644 src/renderer/src/lib/orca-cloud/active-team-role.test.ts create mode 100644 src/renderer/src/lib/orca-cloud/active-team-role.ts diff --git a/src/main/cloud-api/cloud-api-client.test.ts b/src/main/cloud-api/cloud-api-client.test.ts index 6cae58ad6fb..dfb1bed7995 100644 --- a/src/main/cloud-api/cloud-api-client.test.ts +++ b/src/main/cloud-api/cloud-api-client.test.ts @@ -147,19 +147,87 @@ describe('teammate names', () => { expect(keys.map((key) => key.createdByName)).toEqual(['Ada Lovelace', null, undefined]) }) - it('reads audit actor names', async () => { - const entry = { - id: 'a1', - actor: { type: 'user', id: 'u_ada', userId: 'u_ada', userName: 'Ada Lovelace' }, - action: 'api_key.create', - target: null, - createdAt: 1 + it('reads the team audit log: filters go in the query, unknown kinds and categories still parse', async () => { + const event = { + id: 'aud_1', + at: 5, + category: 'something_new', + action: 'secret.updated', + actor: { kind: 'robot', userId: 'u_ada', name: 'Ada Lovelace', via: 'carrier_pigeon' }, + target: { type: 'secret', id: 'STRIPE_KEY', label: 'STRIPE_KEY' }, + detail: { scope: 'org' } } const fetchImpl = vi.fn(async () => - respond(200, { entries: [entry], nextBefore: null }) + respond(200, { + events: [event], + nextCursor: '5.aud_1', + actors: [event.actor], + categories: ['secrets'] + }) ) - const page = await client(fetchImpl).listAudit() - expect(page.entries[0]?.actor.userName).toBe('Ada Lovelace') + const page = await client(fetchImpl).listAuditLog({ + category: 'secrets', + actor: 'u_ada', + limit: 50 + }) + expect(String(fetchImpl.mock.calls[0]?.[0])).toBe( + 'https://cp.example.dev/v1/audit-log?category=secrets&actor=u_ada&limit=50' + ) + expect(page.nextCursor).toBe('5.aud_1') + expect(page.events[0]).toMatchObject({ + category: 'other', + actor: { kind: 'person', name: 'Ada Lovelace', via: null }, + target: { label: 'STRIPE_KEY' } + }) + }) + + it('tells a plan without the audit log apart from a missing role and an older control plane', async () => { + const answer = (status: number, body: unknown) => + codeOf(client(vi.fn(async () => respond(status, body))).listAuditLog()) + expect(await answer(403, { error: 'entitlement_denied', reason: 'audit_log' })).toBe( + 'plan-required' + ) + expect(await answer(403, { error: 'forbidden', reason: 'admin_required' })).toBe( + 'admin-required' + ) + expect(await answer(403, { error: 'insufficient_scope', reason: 'audit:read' })).toBe( + 'forbidden' + ) + expect(await answer(404, { error: 'not_found' })).toBe('not-available') + expect(await answer(503, { error: 'billing_unavailable' })).toBe('server') + const refused = await cloudApiErrorOf( + client( + vi.fn(async () => + respond(403, { error: 'entitlement_denied', reason: 'audit_log' }) + ) + ).listAuditLog() + ) + expect(refused.detail).toBe('audit_log') + }) + + it('exports the audit log as CSV text and says when the export was cut short', async () => { + const csv = 'time,category,action\r\n2026-11-12T00:00:00.000Z,secrets,secret.updated\r\n' + const fetchImpl = vi.fn( + async () => + new Response(csv, { + status: 200, + headers: { 'content-type': 'text/csv', 'x-orca-audit-truncated': 'true' } + }) + ) + const exported = await client(fetchImpl).exportAuditLogCsv({ category: 'secrets' }) + expect(String(fetchImpl.mock.calls[0]?.[0])).toBe( + 'https://cp.example.dev/v1/audit-log?category=secrets&format=csv' + ) + expect(exported).toEqual({ csv, truncated: true }) + expect( + await codeOf( + client( + vi.fn(async () => + respond(403, { error: 'entitlement_denied', reason: 'audit_log' }) + ) + ).exportAuditLogCsv() + ) + ).toBe('plan-required') }) }) diff --git a/src/main/cloud-api/cloud-api-client.ts b/src/main/cloud-api/cloud-api-client.ts index 7b2a1b83390..272b025d3b6 100644 --- a/src/main/cloud-api/cloud-api-client.ts +++ b/src/main/cloud-api/cloud-api-client.ts @@ -4,7 +4,7 @@ import { z } from 'zod' import { CloudApiApprovalSchema, - CloudApiAuditPageSchema, + CloudAuditLogPageSchema, CloudApiCreatedKeySchema, CloudApiGrantListSchema, CloudApiInstallationListSchema, @@ -20,6 +20,8 @@ import { RoutineUpdatedSchema, RoutineWebhookSchema, type CloudApiErrorCode, + type CloudAuditLogExport, + type CloudAuditLogQuery, type CreateCloudApiApprovalInput, type CreateCloudApiKeyInput, type RoutineInput, @@ -29,6 +31,8 @@ import { const REQUEST_TIMEOUT_MS = 20_000 const MAX_DETAIL_CHARS = 200 +// 5000 events is about 1.5 MB; anything far past that is not the export this client asked for. +const MAX_EXPORT_CHARS = 16 * 1024 * 1024 export class CloudApiError extends Error { constructor( @@ -80,6 +84,9 @@ export function classifyCloudApiResponse( return { code: 'signed-out' } } if (status === 403) { + if (error === 'entitlement_denied') { + return { code: 'plan-required', ...(reason ? { detail: reason } : {}) } + } if (reason === 'admin_required' || reason?.startsWith('admin_scope:')) { return { code: 'admin-required' } } @@ -104,12 +111,12 @@ export function createCloudApiClient(deps: CloudApiClientDeps) { const fetchImpl = deps.fetchImpl ?? fetch const base = `${deps.controlPlaneUrl.replace(/\/+$/, '')}/` - async function request( + /** The authenticated call: a 2xx response, or a CloudApiError worded from the answer. */ + async function send( method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', path: string, - schema: S, options: RequestOptions = {} - ): Promise> { + ): Promise { let token: string try { token = await deps.getAccessToken() @@ -138,8 +145,8 @@ export function createCloudApiClient(deps: CloudApiClientDeps) { } catch { throw new CloudApiError('network') } - const body: unknown = await response.json().catch(() => null) if (!response.ok) { + const body: unknown = await response.json().catch(() => null) const { code, detail } = classifyCloudApiResponse(response.status, body, options) const retryAfter = Number(response.headers.get('retry-after')) throw new CloudApiError( @@ -150,6 +157,17 @@ export function createCloudApiClient(deps: CloudApiClientDeps) { : undefined ) } + return response + } + + async function request( + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', + path: string, + schema: S, + options: RequestOptions = {} + ): Promise> { + const response = await send(method, path, options) + const body: unknown = await response.json().catch(() => null) const parsed = schema.safeParse(body) if (!parsed.success) { throw new CloudApiError('invalid-response') @@ -183,8 +201,24 @@ export function createCloudApiClient(deps: CloudApiClientDeps) { getSettings: () => request('GET', 'v1/settings', CloudApiSettingsSchema), putSettings: (settings: { vmCliEnabled: boolean }) => request('PUT', 'v1/settings', CloudApiSettingsSchema, { body: settings }), - listAudit: (query: { limit?: number; before?: number } = {}) => - request('GET', 'v1/audit', CloudApiAuditPageSchema, { query }), + listAuditLog: (query: CloudAuditLogQuery = {}) => + request('GET', 'v1/audit-log', CloudAuditLogPageSchema, { + query, + notFoundMeansUnavailable: true + }), + exportAuditLogCsv: async ( + query: Pick = {} + ): Promise => { + const response = await send('GET', 'v1/audit-log', { + query: { ...query, format: 'csv' }, + notFoundMeansUnavailable: true + }) + const csv = await response.text().catch(() => null) + if (csv === null || csv.length > MAX_EXPORT_CHARS) { + throw new CloudApiError('invalid-response') + } + return { csv, truncated: response.headers.get('x-orca-audit-truncated') === 'true' } + }, listProjects: () => request('GET', 'v1/projects', CloudApiProjectListSchema), listInstallations: () => request('GET', 'v1/github/installations', CloudApiInstallationListSchema, { diff --git a/src/main/cloud-api/cloud-api-ipc.ts b/src/main/cloud-api/cloud-api-ipc.ts index 494cac86df7..abb599316eb 100644 --- a/src/main/cloud-api/cloud-api-ipc.ts +++ b/src/main/cloud-api/cloud-api-ipc.ts @@ -1,6 +1,7 @@ import { ipcMain } from 'electron' import { z } from 'zod' import { + CloudAuditLogQuerySchema, CreateCloudApiApprovalInputSchema, CreateCloudApiKeyInputSchema, RoutineInputSchema, @@ -16,12 +17,7 @@ import { } from './cloud-api-service' const Id = z.string().min(1).max(128) -const AuditQuery = z - .object({ - limit: z.number().int().min(1).max(200).optional(), - before: z.number().int().nonnegative().optional() - }) - .strict() +const AuditExportQuery = CloudAuditLogQuerySchema.pick({ category: true, actor: true }) const SettingsInput = z.object({ vmCliEnabled: z.boolean() }).strict() type Handler = (...args: unknown[]) => Promise> @@ -59,7 +55,12 @@ const HANDLERS: Record = { 'cloudApi:revokeGrant': call([Id], (c, id) => c.revokeGrant(id)), 'cloudApi:getSettings': call([], (c) => c.getSettings()), 'cloudApi:putSettings': call([SettingsInput], (c, input) => c.putSettings(input)), - 'cloudApi:listAudit': call([AuditQuery.optional()], (c, q) => c.listAudit(q ?? {})), + 'cloudApi:listAuditLog': call([CloudAuditLogQuerySchema.optional()], (c, q) => + c.listAuditLog(q ?? {}) + ), + 'cloudApi:exportAuditLogCsv': call([AuditExportQuery.optional()], (c, q) => + c.exportAuditLogCsv(q ?? {}) + ), 'cloudApi:listProjects': call([], (c) => c.listProjects()), 'cloudApi:listInstallations': call([], (c) => c.listInstallations()), 'cloudApi:listRoutines': call([], (c) => c.listRoutines()), diff --git a/src/preload/api/cloud-api-bridge.ts b/src/preload/api/cloud-api-bridge.ts index 918dafee590..cd0d514a037 100644 --- a/src/preload/api/cloud-api-bridge.ts +++ b/src/preload/api/cloud-api-bridge.ts @@ -1,7 +1,6 @@ import { ipcRenderer } from 'electron' import type { CloudApiApproval, - CloudApiAuditPage, CloudApiAvailability, CloudApiCreatedKey, CloudApiGrant, @@ -11,6 +10,9 @@ import type { CloudApiProject, CloudApiResult, CloudApiSettings, + CloudAuditLogExport, + CloudAuditLogPage, + CloudAuditLogQuery, CreateCloudApiApprovalInput, CreateCloudApiKeyInput, Routine, @@ -23,7 +25,7 @@ import type { RoutineUpdated } from '../../shared/cloud-api/cloud-api-types' -/** Desktop only: Orca Cloud API keys, org settings, audit and routines. The session token stays in main. */ +/** Desktop only: Orca Cloud API keys, org settings, the team audit log and routines. The session token stays in main. */ export type CloudApiBridge = { availability: () => Promise /** The desktop workspace running on this Orca Cloud sandbox, if this computer has it. */ @@ -37,10 +39,11 @@ export type CloudApiBridge = { revokeGrant: (id: string) => Promise> getSettings: () => Promise> putSettings: (input: { vmCliEnabled: boolean }) => Promise> - listAudit: (query?: { - limit?: number - before?: number - }) => Promise> + /** The team audit log: owners and admins of a team whose plan includes it (else `plan-required`). */ + listAuditLog: (query?: CloudAuditLogQuery) => Promise> + exportAuditLogCsv: ( + query?: Pick + ) => Promise> listProjects: () => Promise> listInstallations: () => Promise> listRoutines: () => Promise> @@ -69,7 +72,8 @@ export const cloudApiBridge: CloudApiBridge = { revokeGrant: invoke('cloudApi:revokeGrant'), getSettings: invoke('cloudApi:getSettings'), putSettings: invoke('cloudApi:putSettings'), - listAudit: invoke('cloudApi:listAudit'), + listAuditLog: invoke('cloudApi:listAuditLog'), + exportAuditLogCsv: invoke('cloudApi:exportAuditLogCsv'), listProjects: invoke('cloudApi:listProjects'), listInstallations: invoke('cloudApi:listInstallations'), listRoutines: invoke('cloudApi:listRoutines'), diff --git a/src/renderer/src/components/billing/team/TeamDialogHost.tsx b/src/renderer/src/components/billing/team/TeamDialogHost.tsx index 8b17fb6737d..8fe3fbdf520 100644 --- a/src/renderer/src/components/billing/team/TeamDialogHost.tsx +++ b/src/renderer/src/components/billing/team/TeamDialogHost.tsx @@ -240,7 +240,6 @@ export function TeamPageBody({ openTeammateProfile(userId) }} onDismissArrival={clearTeamPageArrival} - onContactSales={(url) => void window.api.shell.openUrl(url)} inline={inline} dangerZone={ org && teamDelete.check ? ( diff --git a/src/renderer/src/components/billing/team/TeamPageView.fixture.tsx b/src/renderer/src/components/billing/team/TeamPageView.fixture.tsx index 4bf0fcf7a5b..10c8f2bc118 100644 --- a/src/renderer/src/components/billing/team/TeamPageView.fixture.tsx +++ b/src/renderer/src/components/billing/team/TeamPageView.fixture.tsx @@ -84,8 +84,7 @@ const base: TeamPageViewProps = { onSeePlans: noop, onManageBilling: noop, onCreateWorkspace: noop, - onDismissArrival: noop, - onContactSales: noop + onDismissArrival: noop } function Page(props: Partial): React.JSX.Element { diff --git a/src/renderer/src/components/billing/team/TeamPageView.tsx b/src/renderer/src/components/billing/team/TeamPageView.tsx index 0689adc66fa..c747f6507ac 100644 --- a/src/renderer/src/components/billing/team/TeamPageView.tsx +++ b/src/renderer/src/components/billing/team/TeamPageView.tsx @@ -1,7 +1,6 @@ import { useEffect, useId, useRef } from 'react' -import { ExternalLink, LogOut, RefreshCw } from 'lucide-react' +import { LogOut, RefreshCw } from 'lucide-react' import { Button } from '@/components/ui/button' -import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton' import { InlineError } from '@/components/ui/inline-error' import { Skeleton } from '@/components/ui/skeleton' import { SettingsDisabledControlTooltip } from '@/components/settings/form-controls/SettingsDisabledControlTooltip' @@ -50,8 +49,6 @@ export type TeamPageViewProps = { onManageBilling: () => void onCreateWorkspace: () => void onDismissArrival: () => void - /** Opens the Enterprise contact page (SSO/SCIM are Enterprise; nothing here pretends otherwise). */ - onContactSales: (url: string) => void /** Settings → Team renders the page inline: seats and invoices stay on Plan & billing (linked). */ inline?: { onOpenPlanBilling: () => void } /** Owners in Settings › Team: "Delete ", after "Leave " (TEAM-DELETE). */ @@ -115,7 +112,6 @@ function SkeletonRows(): React.JSX.Element { export function TeamPageView(props: TeamPageViewProps): React.JSX.Element { const t = useTranslate() const inviteInput = useRef(null) - const ssoTitleId = useId() const hasRoster = props.roster !== null const inline = props.inline ?? null useEffect(() => { @@ -142,7 +138,6 @@ export function TeamPageView(props: TeamPageViewProps): React.JSX.Element { const roster = props.roster?.status === 'ok' ? props.roster.roster : null const entitlement = props.billing?.status === 'ok' ? props.billing.entitlement : null - const enterprise = entitlement?.offers?.enterprise ?? null const rows = roster ? teamRows(roster, props.viewerUserId) : [] const selfRow = rows.find((row) => row.self) const viewerRole = roster?.viewerRole ?? 'member' @@ -313,33 +308,8 @@ export function TeamPageView(props: TeamPageViewProps): React.JSX.Element { ) : null} - {(roster?.canManageMembers && enterprise) || selfRow ? ( + {selfRow ? (
- {roster?.canManageMembers && enterprise ? ( -
-
-

- {t('auto.components.billing.team.ssoTitle', 'Single sign-on and provisioning')} -

-

- {t( - 'auto.components.billing.team.ssoBody', - 'SAML SSO and SCIM user provisioning are part of {{value0}}.', - { value0: enterprise.label } - )} -

-
- props.onContactSales(enterprise.contactUrl)} - > - {t('auto.components.billing.enterpriseContactSales', 'Contact sales')} - - -
- ) : null} {selfRow ? (
>({ status: 'loading' }) const [settings, setSettings] = useState>({ status: 'loading' }) const [grants, setGrants] = useState>({ status: 'loading' }) - const [audit, setAudit] = useState>({ status: 'loading' }) const [now, setNow] = useState(() => Date.now()) const begin = useRequestGeneration() const [refreshing, setRefreshing] = useState(false) @@ -85,19 +83,6 @@ function CloudApiKeysPaneBody(): React.JSX.Element | null { setNow(Date.now()) }, [api, begin, word]) - const loadAudit = useCallback(async (): Promise => { - if (!api) { - return - } - const isCurrent = begin('audit') - setAudit({ status: 'loading' }) - const result = await api.listAudit({ limit: 25 }) - if (isCurrent()) { - setAudit(toCloudLoadState(result, word)) - setNow(Date.now()) - } - }, [api, begin, word]) - useEffect(() => { if (availability?.status === 'ready') { void load() @@ -116,9 +101,6 @@ function CloudApiKeysPaneBody(): React.JSX.Element | null { viewer={viewer} keys={keys} settings={settings} - audit={ - viewer && viewer.role !== 'member' && viewer.scopes.includes('audit:read') ? audit : null - } now={now} onRefresh={() => void load()} refreshing={refreshing} @@ -158,7 +140,6 @@ function CloudApiKeysPaneBody(): React.JSX.Element | null { setSettings({ status: 'ok', data: result.data }) return null }} - onLoadAudit={() => void loadAudit()} onCreateApproval={(input) => api.createApproval(input)} grants={grants} onRefreshGrants={() => { diff --git a/src/renderer/src/components/settings/cloud-api/CloudApiKeysSections.tsx b/src/renderer/src/components/settings/cloud-api/CloudApiKeysSections.tsx index 9e4060a6536..5357e364674 100644 --- a/src/renderer/src/components/settings/cloud-api/CloudApiKeysSections.tsx +++ b/src/renderer/src/components/settings/cloud-api/CloudApiKeysSections.tsx @@ -1,20 +1,13 @@ import { useState } from 'react' -import { ExternalLink, History, ShieldCheck } from 'lucide-react' +import { ExternalLink, ShieldCheck } from 'lucide-react' import { Button } from '@/components/ui/button' import { InlineError } from '@/components/ui/inline-error' import { Skeleton } from '@/components/ui/skeleton' import { Switch } from '@/components/ui/switch' -import { useIntlLocale, useTranslate, type Translate } from '@/i18n/use-translate' -import type { - CloudApiAuditEntry, - CloudApiAuditPage, - CloudApiMe, - CloudApiSettings -} from '../../../../../shared/cloud-api/cloud-api-types' +import { useTranslate } from '@/i18n/use-translate' +import type { CloudApiSettings } from '../../../../../shared/cloud-api/cloud-api-types' import { SettingsGroup } from '../section-layout/SettingsGroup' -import { PersonSentence } from './PersonName' -import { formatAbsoluteTime, formatRelativeFromNow } from './cloud-api-format' -import { CloudApiErrorNotice, CloudApiSkeletonRows, CopyValueButton } from './cloud-api-parts' +import { CopyValueButton } from './cloud-api-parts' import type { CloudLoadState } from './cloud-load-state' function LinkRow({ @@ -184,182 +177,3 @@ export function OrgSettings({ ) } - -const AUDIT_ACTIONS: Record = { - 'api_key.create': ['auto.components.settings.cloudApi.audit.action.keyCreate', 'Created API key'], - 'api_key.revoke': ['auto.components.settings.cloudApi.audit.action.keyRevoke', 'Revoked API key'], - 'settings.update': [ - 'auto.components.settings.cloudApi.audit.action.settings', - 'Changed agent access' - ], - 'workspace.create': [ - 'auto.components.settings.cloudApi.audit.action.wsCreate', - 'Started a workspace' - ], - 'workspace.destroy': [ - 'auto.components.settings.cloudApi.audit.action.wsDestroy', - 'Deleted a workspace' - ], - 'workspace.sleep': [ - 'auto.components.settings.cloudApi.audit.action.wsSleep', - 'Put a workspace to sleep' - ], - 'workspace.wake': ['auto.components.settings.cloudApi.audit.action.wsWake', 'Woke a workspace'], - 'workspace.archive': [ - 'auto.components.settings.cloudApi.audit.action.wsArchive', - 'Archived a workspace' - ], - 'workspace.message': [ - 'auto.components.settings.cloudApi.audit.action.wsMessage', - 'Messaged an agent' - ], - 'project.create': [ - 'auto.components.settings.cloudApi.audit.action.projectCreate', - 'Created a project' - ], - 'project.update': [ - 'auto.components.settings.cloudApi.audit.action.projectUpdate', - 'Edited a project' - ], - 'project.delete': [ - 'auto.components.settings.cloudApi.audit.action.projectDelete', - 'Deleted a project' - ], - 'mcp.approval_create': [ - 'auto.components.settings.cloudApi.audit.action.approval', - 'Made an app approval code' - ] -} - -/** "Created API key “CI deploy”"; unknown actions fall back to their machine name. */ -function auditActionLabel(entry: CloudApiAuditEntry, t: Translate): string { - const known = AUDIT_ACTIONS[entry.action] - const base = known ? t(known[0], known[1]) : entry.action - const name = entry.detail?.name - return typeof name === 'string' && name.length > 0 - ? t('auto.components.settings.cloudApi.audit.actionNamed', '{{action}} “{{name}}”', { - action: base, - name: name.slice(0, 80) - }) - : base -} - -function ActorLabel({ - entry, - viewer -}: { - entry: CloudApiAuditEntry - viewer: CloudApiMe | null -}): React.JSX.Element { - const t = useTranslate() - if (entry.actor.type === 'workspace_token') { - return ( - <>{t('auto.components.settings.cloudApi.audit.viaAgent', 'Agent in a cloud workspace')} - ) - } - const mine = Boolean(entry.actor.userId) && entry.actor.userId === viewer?.userId - const personId = entry.actor.userId ?? entry.actor.id - const sentence = - entry.actor.type === 'api_key' - ? mine - ? t('auto.components.settings.cloudApi.audit.youViaKey', 'You via API key') - : t('auto.components.settings.cloudApi.audit.personViaKey', ' via API key') - : entry.actor.type === 'mcp' - ? mine - ? t('auto.components.settings.cloudApi.audit.youViaMcp', 'You via MCP') - : t('auto.components.settings.cloudApi.audit.personViaMcp', ' via MCP') - : mine - ? t('auto.components.settings.cloudApi.audit.you', 'You') - : '' - return mine ? ( - <>{sentence} - ) : ( - - ) -} - -export function AuditLog({ - audit, - now, - viewer, - initiallyOpen, - onLoad -}: { - audit: CloudLoadState - now: number - viewer: CloudApiMe | null - initiallyOpen: boolean - onLoad: () => void -}): React.JSX.Element { - const t = useTranslate() - const locale = useIntlLocale() - const [open, setOpen] = useState(initiallyOpen) - return ( - { - if (!open) { - onLoad() - } - setOpen(!open) - }} - > - - {open - ? t('auto.components.settings.cloudApi.audit.hide', 'Hide') - : t('auto.components.settings.cloudApi.audit.show', 'Show activity')} - - } - bodyClassName={open ? undefined : 'hidden'} - > - {!open ? null : audit.status === 'loading' ? ( - - ) : audit.status === 'error' ? ( -
- -
- ) : audit.data.entries.length === 0 ? ( -

- {t('auto.components.settings.cloudApi.audit.empty', 'Nothing has happened yet.')} -

- ) : ( -
    - {audit.data.entries.slice(0, 25).map((entry) => ( -
  • - - {auditActionLabel(entry, t)} - - - - - - {formatRelativeFromNow(entry.createdAt, now, locale)} - -
  • - ))} -
- )} -
- ) -} diff --git a/src/renderer/src/components/settings/cloud-api/CloudApiKeysView.fixture.tsx b/src/renderer/src/components/settings/cloud-api/CloudApiKeysView.fixture.tsx index a31156a5809..b18f289c8f0 100644 --- a/src/renderer/src/components/settings/cloud-api/CloudApiKeysView.fixture.tsx +++ b/src/renderer/src/components/settings/cloud-api/CloudApiKeysView.fixture.tsx @@ -1,7 +1,6 @@ import { defineFixture } from '../../../component-fixtures/component-fixture' import type { CloudApiGrant, - CloudApiAuditPage, CloudApiKeyList, CloudApiMe, CloudApiSettings @@ -65,34 +64,6 @@ const KEYS: CloudApiKeyList = { } const SETTINGS: CloudApiSettings = { settings: { vmCliEnabled: true }, canEdit: true } -const AUDIT: CloudApiAuditPage = { - nextBefore: null, - entries: [ - { - id: 'a1', - actor: { type: 'user', id: 'u_7f3k', userId: 'u_7f3k', userName: null }, - action: 'api_key.create', - target: { type: 'api_key', id: 'key_ci' }, - detail: { name: 'GitHub Actions deploy' }, - createdAt: NOW - 3 * DAY - }, - { - id: 'a2', - actor: { type: 'api_key', id: 'key_dash', userId: 'u_grace', userName: 'Grace Hopper' }, - action: 'workspace.create', - target: { type: 'workspace', id: 'sbx_7Hq2' }, - createdAt: NOW - 5 * MIN - }, - { - id: 'a3', - actor: { type: 'workspace_token', id: 'key_ws', userId: 'u_ada' }, - action: 'workspace.message', - target: { type: 'workspace', id: 'sbx_7Hq2' }, - createdAt: NOW - 2 * MIN - } - ] -} - const GRANTS: CloudApiGrant[] = [ { id: 'fam_claude', @@ -126,13 +97,11 @@ const base: CloudApiKeysViewProps = { viewer: ADMIN, keys: { status: 'ok', data: KEYS }, settings: { status: 'ok', data: SETTINGS }, - audit: { status: 'ok', data: AUDIT }, now: NOW, onRefresh: () => {}, onCreate: async () => ({ ok: false, error: 'server' }), onRevoke: async () => null, onToggleVmCli: async () => null, - onLoadAudit: () => {}, onCreateApproval: async () => ({ ok: false, error: 'server' }), grants: { status: 'ok', data: [] }, onRevokeGrant: async () => null, @@ -155,11 +124,6 @@ export const disconnectAppConfirm = defineFixture({ ) }) -export const listWithActivity = defineFixture({ - width: 760, - render: () => -}) - export const empty = defineFixture({ width: 760, render: () => ( @@ -180,7 +144,6 @@ export const loading = defineFixture({ keys={{ status: 'loading' }} grants={{ status: 'loading' }} settings={{ status: 'loading' }} - audit={null} /> ) }) @@ -196,7 +159,6 @@ export const error = defineFixture({ }} settings={{ status: 'error', message: 'Couldn’t reach Orca Cloud.' }} grants={{ status: 'error', message: 'Couldn’t reach Orca Cloud.' }} - audit={null} /> ) }) @@ -212,7 +174,6 @@ export const memberView = defineFixture({ data: { availableScopes: MEMBER.scopes, keys: [{ ...KEYS.keys[0]!, createdBy: 'u_linus' }] } }} settings={{ status: 'ok', data: { settings: { vmCliEnabled: false }, canEdit: false } }} - audit={null} /> ) }) @@ -229,7 +190,6 @@ export const createDialogMemberCustom = defineFixture({ {...base} viewer={MEMBER} keys={{ status: 'ok', data: { availableScopes: MEMBER.scopes, keys: [] } }} - audit={null} initial={{ create: true }} /> ), diff --git a/src/renderer/src/components/settings/cloud-api/CloudApiKeysView.tsx b/src/renderer/src/components/settings/cloud-api/CloudApiKeysView.tsx index 73a6718cbf7..67ba455d68c 100644 --- a/src/renderer/src/components/settings/cloud-api/CloudApiKeysView.tsx +++ b/src/renderer/src/components/settings/cloud-api/CloudApiKeysView.tsx @@ -7,7 +7,6 @@ import { useTranslate } from '@/i18n/use-translate' import { cn } from '@/lib/class-names' import type { CloudApiApproval, - CloudApiAuditPage, CloudApiCreatedKey, CloudApiGrant, CloudApiKey, @@ -20,7 +19,7 @@ import type { } from '../../../../../shared/cloud-api/cloud-api-types' import { SettingsGroup } from '../section-layout/SettingsGroup' import { ApproveAppDialog } from './ApproveAppDialog' -import { AccessLinks, AuditLog, OrgSettings } from './CloudApiKeysSections' +import { AccessLinks, OrgSettings } from './CloudApiKeysSections' import { CloudConfirmDialog } from './CloudConfirmDialog' import { CloudConnectedApps } from './CloudConnectedApps' import type { CloudLoadState } from './cloud-load-state' @@ -37,7 +36,6 @@ export type CloudApiKeysViewProps = { keys: CloudLoadState settings: CloudLoadState /** Null hides the activity log (members cannot read it). */ - audit: CloudLoadState | null now: number onRefresh: () => void /** A refresh is running while the current data stays on screen. */ @@ -46,7 +44,6 @@ export type CloudApiKeysViewProps = { /** Resolves to an error message or null. */ onRevoke: (key: CloudApiKey) => Promise onToggleVmCli: (enabled: boolean) => Promise - onLoadAudit: () => void grants: CloudLoadState onRevokeGrant: (grant: CloudApiGrant) => Promise onRefreshGrants: () => void @@ -58,7 +55,6 @@ export type CloudApiKeysViewProps = { create?: boolean created?: CloudApiCreatedKey revokeKeyId?: string - auditOpen?: boolean approve?: boolean approval?: CloudApiApproval revokeGrantId?: string @@ -252,16 +248,6 @@ export function CloudApiKeysView(props: CloudApiKeysViewProps): React.JSX.Elemen settings={props.settings} onToggle={props.onToggleVmCli} /> - - {props.audit ? ( - - ) : null}
[ ...translateSearchKeyword('auto.components.settings.cloudApi.keywordToken', 'token'), ...translateSearchKeyword('auto.components.settings.cloudApi.keywordMcp', 'mcp'), ...translateSearchKeyword('auto.components.settings.cloudApi.keywordCli', 'cli'), - ...translateSearchKeyword('auto.components.settings.cloudApi.keywordAudit', 'audit log'), ...translateSearchKeyword('auto.components.settings.cloudApi.keywordCloud', 'cloud') ] } diff --git a/src/renderer/src/components/settings/enterprise/EnterpriseAuditLog.tsx b/src/renderer/src/components/settings/enterprise/EnterpriseAuditLog.tsx new file mode 100644 index 00000000000..b008d341eca --- /dev/null +++ b/src/renderer/src/components/settings/enterprise/EnterpriseAuditLog.tsx @@ -0,0 +1,382 @@ +// Settings › Enterprise › Audit log: every recorded change for the team in one list, filtered by +// category and by person, with CSV export. Owners and admins only. A team whose plan does not +// include it sees the upsell instead; the control plane refuses the data either way. +import { Download, History, Loader2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { InlineError } from '@/components/ui/inline-error' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select' +import { useIntlLocale, useTranslate, type Translate } from '@/i18n/use-translate' +import { + CLOUD_AUDIT_CATEGORIES, + type CloudAuditActor, + type CloudAuditCategory, + type CloudAuditEvent +} from '../../../../../shared/cloud-api/cloud-api-types' +import { PersonName, PersonSentence } from '../cloud-api/PersonName' +import { formatAbsoluteTime, formatRelativeFromNow } from '../cloud-api/cloud-api-format' +import { + CloudApiEmptyState, + CloudApiErrorNotice, + CloudApiSkeletonRows +} from '../cloud-api/cloud-api-parts' +import { SettingsGroup } from '../section-layout/SettingsGroup' +import { EnterpriseUpsell, type EnterpriseContact } from './EnterpriseUpsell' +import { auditCategoryLabel, auditEventSentence } from './enterprise-audit-copy' + +export type AuditLogFilters = { category: CloudAuditCategory | null; actor: string | null } + +export type AuditLogState = + | { status: 'loading' } + /** The team's plan does not include the audit log. */ + | { status: 'upsell' } + | { status: 'error'; message: string } + | { + status: 'ok' + events: CloudAuditEvent[] + /** More events exist past the ones loaded. */ + hasMore: boolean + loadingMore: boolean + /** A failed "Show more" keeps the list and says so under it. */ + moreError: string | null + /** A filter change is loading; the previous list stays on screen, dimmed. */ + refreshing: boolean + } + +export type AuditLogExportState = + | { status: 'idle' } + | { status: 'exporting' } + /** Saved; `truncated` means the file holds the newest events only. */ + | { status: 'done'; truncated: boolean } + | { status: 'error'; message: string } + +export type EnterpriseAuditLogProps = { + log: AuditLogState + filters: AuditLogFilters + /** Everyone who appears in the log, for the person filter. */ + actors: readonly CloudAuditActor[] + exportState: AuditLogExportState + now: number + viewerUserId: string | null + contact: EnterpriseContact + onFilter: (filters: AuditLogFilters) => void + onLoadMore: () => void + onRetry: () => void + onExport: () => void + onContactSales: (url: string) => void +} + +const ALL = '__all__' +const SYSTEM = 'system' + +function ActorCell({ + event, + viewerUserId +}: { + event: CloudAuditEvent + viewerUserId: string | null +}): React.JSX.Element { + const t = useTranslate() + const { actor } = event + if (actor.kind === 'system' || !actor.userId) { + return <>{t('settings.enterprise.audit.actor.system', 'Orca')} + } + const mine = actor.userId === viewerUserId + const sentence = + actor.via === 'api_key' + ? mine + ? t('auto.components.settings.cloudApi.audit.youViaKey', 'You via API key') + : t('auto.components.settings.cloudApi.audit.personViaKey', ' via API key') + : actor.via === 'mcp' + ? mine + ? t('auto.components.settings.cloudApi.audit.youViaMcp', 'You via MCP') + : t('auto.components.settings.cloudApi.audit.personViaMcp', ' via MCP') + : actor.via === 'agent' + ? mine + ? t('settings.enterprise.audit.actor.youViaAgent', 'Your agent') + : t('settings.enterprise.audit.actor.personViaAgent', '’s agent') + : actor.via === 'routine' + ? mine + ? t('settings.enterprise.audit.actor.youViaRoutine', 'Your routine') + : t('settings.enterprise.audit.actor.personViaRoutine', '’s routine') + : mine + ? t('auto.components.settings.cloudApi.audit.you', 'You') + : '' + if (mine) { + return <>{sentence} + } + return ( + <> + + {/* A named person who has left: say so, or they read as a current teammate. */} + {actor.kind === 'former_member' && actor.name ? ( + + {' · '} + {t('settings.enterprise.audit.actor.former', 'Former teammate')} + + ) : null} + + ) +} + +function actorOptionLabel(actor: CloudAuditActor, viewerUserId: string | null, t: Translate) { + if (actor.userId && actor.userId === viewerUserId) { + return t('auto.components.settings.cloudApi.audit.you', 'You') + } + return ( + <> + + {actor.kind === 'former_member' && actor.name ? ( + + {t('settings.enterprise.audit.actor.former', 'Former teammate')} + + ) : null} + + ) +} + +function Filters({ + filters, + actors, + viewerUserId, + disabled, + onFilter +}: Pick & { + disabled: boolean +}): React.JSX.Element { + const t = useTranslate() + return ( +
+ + +
+ ) +} + +export function EnterpriseAuditLog(props: EnterpriseAuditLogProps): React.JSX.Element { + const t = useTranslate() + const locale = useIntlLocale() + const { log, filters, exportState } = props + const filtered = filters.category !== null || filters.actor !== null + const canExport = log.status === 'ok' && log.events.length > 0 + return ( + (exportState.status === 'exporting' ? undefined : props.onExport())} + > + {exportState.status === 'exporting' ? ( + + ) : ( + + )} + {t('settings.enterprise.audit.export', 'Export CSV')} + + ) : undefined + } + > + {log.status === 'upsell' ? ( + + ) : log.status === 'loading' ? ( + + ) : log.status === 'error' ? ( +
+ +
+ ) : ( + <> + {/* Nothing to narrow down in a log with nothing in it. */} + {filtered || log.events.length > 0 ? ( + + ) : null} + {exportState.status === 'error' ? ( +
+ +
+ ) : exportState.status === 'done' && exportState.truncated ? ( +

+ {t( + 'settings.enterprise.audit.exportTruncated', + 'The file has the newest 5,000 events. Filter by activity or person to export the rest.' + )} +

+ ) : null} + {log.events.length === 0 ? ( +
+ props.onFilter({ category: null, actor: null })} + > + {t('settings.enterprise.audit.clearFilters', 'Clear filters')} + + ) : undefined + } + /> +
+ ) : ( +
    + {log.events.map((event) => ( +
  • + + {auditEventSentence(event, t)} + + + {auditCategoryLabel(event.category, t)} + + + + + + {formatRelativeFromNow(event.at, props.now, locale)} + +
  • + ))} +
+ )} + {log.moreError ? ( +
+ +
+ ) : log.hasMore ? ( +
+ +
+ ) : null} + + )} +
+ ) +} diff --git a/src/renderer/src/components/settings/enterprise/EnterpriseSettingsPane.tsx b/src/renderer/src/components/settings/enterprise/EnterpriseSettingsPane.tsx new file mode 100644 index 00000000000..c284aaebf03 --- /dev/null +++ b/src/renderer/src/components/settings/enterprise/EnterpriseSettingsPane.tsx @@ -0,0 +1,62 @@ +// Settings › Enterprise, wired to the account: who is looking, billing's Enterprise contact page, +// and the audit log's data. Shown only to a team's owners and admins (the nav hides it otherwise, +// and the control plane refuses the data regardless). +import { useEffect } from 'react' +import { useOrcaAccountKey } from '@/hooks/use-orca-account-key' +import { useOrcaCloudApiAvailability } from '@/hooks/useOrcaCloudApiAvailability' +import { activeTeamRole } from '@/lib/orca-cloud/active-team-role' +import { useAppStore } from '@/store' +import { refreshBillingSummary, useBillingState } from '../../billing/billing-summary-store' +import { canManageMembers } from '../../billing/team/team-roles' +import { EnterpriseSettingsView } from './EnterpriseSettingsView' +import { ENTERPRISE_CONTACT_URL, type EnterpriseContact } from './EnterpriseUpsell' +import { useEnterpriseAuditLog } from './use-enterprise-audit-log' + +/** Billing's own name and contact page for Enterprise; the fixed page until billing has answered. */ +function useEnterpriseContact(): EnterpriseContact { + const billing = useBillingState() + useEffect(() => { + void refreshBillingSummary() + }, []) + const offer = + billing.result?.status === 'ok' ? billing.result.entitlement.offers?.enterprise : null + return { + planName: offer?.label || 'Enterprise', + contactUrl: offer?.contactUrl || ENTERPRISE_CONTACT_URL + } +} + +export function EnterpriseSettingsPane(): React.JSX.Element | null { + // Remount on a team switch so one team's log never shows under another. + return +} + +function EnterpriseSettingsPaneBody(): React.JSX.Element | null { + const availability = useOrcaCloudApiAvailability() + const authStatus = useAppStore((s) => s.orcaProfileAuthStatus) + const role = activeTeamRole(authStatus) + const admin = role !== null && canManageMembers(role) + const contact = useEnterpriseContact() + const audit = useEnterpriseAuditLog(availability?.status === 'ready' && admin) + if (availability?.status !== 'ready' || !admin) { + return null + } + return ( + void window.api.shell.openUrl(url) + }} + /> + ) +} diff --git a/src/renderer/src/components/settings/enterprise/EnterpriseSettingsView.fixture.tsx b/src/renderer/src/components/settings/enterprise/EnterpriseSettingsView.fixture.tsx new file mode 100644 index 00000000000..891889f51ff --- /dev/null +++ b/src/renderer/src/components/settings/enterprise/EnterpriseSettingsView.fixture.tsx @@ -0,0 +1,104 @@ +import { defineFixture } from '../../../component-fixtures/component-fixture' +import { SettingsSection } from '../section-layout/SettingsSection' +import { EnterpriseSettingsView } from './EnterpriseSettingsView' +import { fixtureAuditProps } from './enterprise-fixture-data' +import type { EnterpriseAuditLogProps } from './EnterpriseAuditLog' + +const BARE_BODY = 'rounded-none border-0 bg-transparent p-0 shadow-none' + +/** The pane as Settings draws it: page title and description over the bare body. */ +function Page({ audit }: { audit: EnterpriseAuditLogProps }): React.JSX.Element { + return ( +
+ + + +
+ ) +} + +/** An Enterprise team's owner: the merged log, filters and export. */ +export const auditLog = defineFixture({ + width: 860, + render: () => +}) + +/** A team whose plan does not include the audit log (the control plane answered plan-required). */ +export const upsell = defineFixture({ + width: 860, + render: () => +}) + +/** Entitled, nothing recorded yet. */ +export const empty = defineFixture({ + width: 860, + render: () => ( + + ) +}) + +/** Filters that match nothing: say so and offer the way back. */ +export const emptyFiltered = defineFixture({ + width: 860, + render: () => ( + + ) +}) + +export const loading = defineFixture({ + width: 860, + render: () => +}) + +export const loadFailed = defineFixture({ + width: 860, + render: () => ( + + ) +}) + +/** After an export that hit the 5,000-event cap. */ +export const exportTruncated = defineFixture({ + width: 860, + render: () => ( + + ) +}) diff --git a/src/renderer/src/components/settings/enterprise/EnterpriseSettingsView.test.tsx b/src/renderer/src/components/settings/enterprise/EnterpriseSettingsView.test.tsx new file mode 100644 index 00000000000..c4ccd9f3d64 --- /dev/null +++ b/src/renderer/src/components/settings/enterprise/EnterpriseSettingsView.test.tsx @@ -0,0 +1,313 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { CloudAuditEvent } from '../../../../../shared/cloud-api/cloud-api-types' +import { EnterpriseSettingsView } from './EnterpriseSettingsView' +import { FIXTURE_EVENTS, FIXTURE_NOW, fixtureAuditProps } from './enterprise-fixture-data' +import { auditCategoryLabel, auditEventSentence, isKnownAuditAction } from './enterprise-audit-copy' + +afterEach(cleanup) + +const EMPTY = { + status: 'ok' as const, + events: [], + hasMore: false, + loadingMore: false, + moreError: null, + refreshing: false +} + +function rows(container: HTMLElement): string[] { + return [...container.querySelectorAll('ul[aria-label="Audit log"] > li')].map((row) => + [...row.children].map((cell) => cell.textContent).join(' | ') + ) +} + +describe('audit log', () => { + it('lists who did what to what and when, newest first, from every source', () => { + const { container } = render() + expect(rows(container)).toEqual([ + 'Changed secret STRIPE_SECRET_KEY | Secrets | Grace Hopper | 4 minutes ago', + 'Changed the role of Grace Hopper | Members | You | 38 minutes ago', + 'Shared a link to a port on workspace “checkout-redesign” | Workspaces | Grace Hopper | 3 hours ago', + 'Started workspace “nightly-evals” | Workspaces | Grace Hopper via API key | 5 hours ago', + 'Removed Linus Park from the team | Members | You | yesterday', + 'Turned off a routine | Routines | Orca | yesterday', + 'Created API key “GitHub Actions deploy” | API keys | Linus Park · Former teammate | 2 days ago', + 'Added secret ANTHROPIC_API_KEY | Secrets | You | 3 days ago', + 'Changed agent access | Settings | You | 6 days ago', + 'Connected an app | Connected apps | Grace Hopper via MCP | 9 days ago' + ]) + }) + + it('never renders a secret value, hint or raw user id inline; the id stays one hover away', () => { + const leaky: CloudAuditEvent = { + ...FIXTURE_EVENTS[0]!, + detail: { scope: 'org', hint: 'wxyz', value: 'sk-live-should-not-show' } + } + const { container } = render( + + ) + expect(container.textContent).not.toMatch(/wxyz|should-not-show/) + expect(container.textContent).not.toContain('u_linus') + expect(container.querySelector('[title="u_linus"]')).not.toBeNull() + }) + + it('shows an action this version does not know by its machine name instead of hiding it', () => { + const unknown: CloudAuditEvent = { + ...FIXTURE_EVENTS[0]!, + id: 'aud_new', + category: 'security', + action: 'sso.went_live' + } + const { container } = render( + + ) + expect(rows(container)).toEqual(['sso.went_live | Security | Grace Hopper | 4 minutes ago']) + }) + + it('pages with Show more, and keeps the list when a page fails', () => { + const onLoadMore = vi.fn() + const { rerender, container } = render( + + ) + fireEvent.click(screen.getByRole('button', { name: 'Show more' })) + expect(onLoadMore).toHaveBeenCalledTimes(1) + rerender( + + ) + // Busy, not disabled: the button keeps focus, and a second click does nothing. + const busy = screen.getByRole('button', { name: 'Show more' }) + expect(busy.getAttribute('aria-busy')).toBe('true') + fireEvent.click(busy) + expect(onLoadMore).toHaveBeenCalledTimes(1) + rerender( + + ) + expect(rows(container)).toHaveLength(FIXTURE_EVENTS.length) + expect(screen.getByText('Couldn’t reach Orca Cloud.')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Try again' })) + expect(onLoadMore).toHaveBeenCalledTimes(2) + // The last page has no Show more. + rerender( + + ) + expect(screen.queryByRole('button', { name: 'Show more' })).toBeNull() + }) + + it('offers both filters and the export, and exports once per click', () => { + const onExport = vi.fn() + const { rerender } = render() + expect(screen.getByRole('combobox', { name: 'Filter by activity' }).textContent).toBe( + 'All activity' + ) + expect(screen.getByRole('combobox', { name: 'Filter by person' }).textContent).toBe('Everyone') + fireEvent.click(screen.getByRole('button', { name: 'Export CSV' })) + expect(onExport).toHaveBeenCalledTimes(1) + rerender( + + ) + fireEvent.click(screen.getByRole('button', { name: 'Export CSV' })) + expect(onExport).toHaveBeenCalledTimes(1) + rerender( + + ) + expect(screen.getByRole('status').textContent).toContain('newest 5,000 events') + }) + + it('shows the chosen filters, and an empty filtered list offers the way back', () => { + const onFilter = vi.fn() + render( + + ) + expect(screen.getByRole('combobox', { name: 'Filter by activity' }).textContent).toBe('Secrets') + expect(screen.getByRole('combobox', { name: 'Filter by person' }).textContent).toBe( + 'Grace Hopper' + ) + expect(screen.getByText('Nothing matches')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Clear filters' })) + expect(onFilter).toHaveBeenCalledWith({ category: null, actor: null }) + // Nothing to export. + expect(screen.getByRole('button', { name: 'Export CSV' }).hasAttribute('disabled')).toBe(true) + }) + + it('says nothing is recorded yet when the log is empty and unfiltered', () => { + render() + expect(screen.getByText('Nothing recorded yet')).toBeTruthy() + expect(screen.queryByRole('combobox')).toBeNull() + expect(screen.queryByRole('button', { name: 'Clear filters' })).toBeNull() + }) + + it('words a load failure and retries', () => { + const onRetry = vi.fn() + render( + + ) + expect(screen.getByText('Orca Cloud is down.')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Try again' })) + expect(onRetry).toHaveBeenCalledTimes(1) + expect(screen.queryByRole('button', { name: 'Export CSV' })).toBeNull() + }) +}) + +describe('without the Enterprise plan', () => { + it('shows the upsell with Contact sales and no log, filters or export', () => { + const onContactSales = vi.fn() + const { container } = render( + + ) + expect(screen.getByText('Audit log is part of Enterprise')).toBeTruthy() + expect(rows(container)).toEqual([]) + expect(screen.queryByRole('combobox')).toBeNull() + expect(screen.queryByRole('button', { name: 'Export CSV' })).toBeNull() + const contact = screen.getAllByRole('button', { name: 'Contact sales' }) + // One for single sign-on, one for the audit log. + expect(contact).toHaveLength(2) + fireEvent.click(contact[1]!) + expect(onContactSales).toHaveBeenCalledWith('https://orca.dev/enterprise') + // The Copy link chevron sits beside each (the open-in-browser rule). + expect(screen.getAllByRole('button', { name: 'More link options' })).toHaveLength(2) + }) + + it("uses billing's own plan name and contact page", () => { + const onContactSales = vi.fn() + render( + + ) + expect(screen.getByText('Audit log is part of Scale')).toBeTruthy() + expect(screen.getByText('Single sign-on is part of Scale')).toBeTruthy() + fireEvent.click(screen.getAllByRole('button', { name: 'Contact sales' })[0]!) + expect(onContactSales).toHaveBeenCalledWith('https://orca.dev/scale') + }) +}) + +describe('slots', () => { + it('lets the SSO and policy owners replace or add their blocks, in order above the audit log', () => { + const { container } = render( + SSO} + policies={
Require MFA
} + /> + ) + expect(screen.queryByText('Single sign-on is part of Enterprise')).toBeNull() + const order = [...container.querySelectorAll('[id^="cloud-enterprise-"]')].map( + (node) => node.id + ) + expect(order).toEqual([ + 'cloud-enterprise-sso', + 'cloud-enterprise-policies', + 'cloud-enterprise-audit-log' + ]) + }) +}) + +describe('copy', () => { + it('names every category and words every action the control plane records today', () => { + expect(auditCategoryLabel('api_keys')).toBe('API keys') + const recorded = [ + 'api_key.create', + 'api_key.revoke', + 'workspace_token.issue', + 'settings.update', + 'workspace.create', + 'workspace.destroy', + 'workspace.sleep', + 'workspace.wake', + 'workspace.archive', + 'workspace.unarchive', + 'workspace.rename', + 'workspace.share_create', + 'workspace.share_revoke', + 'workspace.message', + 'workspace.attachment', + 'workspace.session.create', + 'workspace.session.cancel', + 'workspace.session.rename', + 'sandbox.ssh_key_added', + 'sandbox.ssh_key_removed', + 'project.create', + 'project.update', + 'project.delete', + 'mcp.approval_create', + 'mcp.authorize', + 'mcp.grant_revoke', + 'mcp.code_replay', + 'mcp.refresh_replay', + 'secret.created', + 'secret.updated', + 'secret.deleted', + 'member.removed', + 'member.left', + 'member.role_changed', + 'member.role_synced', + 'routine.create', + 'routine.update', + 'routine.delete', + 'routine.disable', + 'routine.run', + 'routine.run.start', + 'routine.run.finish', + 'routine.rotate_webhook' + ] + expect(recorded.filter((action) => !isKnownAuditAction(action))).toEqual([]) + }) + + it('falls back to the plain sentence when the target has no name', () => { + const event: CloudAuditEvent = { + id: 'a', + at: FIXTURE_NOW, + category: 'members', + action: 'member.removed', + actor: { kind: 'system', userId: null, name: null, via: null }, + target: { type: 'member', id: 'u_x', label: null }, + detail: null + } + expect(auditEventSentence(event)).toBe('Removed a teammate') + expect( + auditEventSentence({ ...event, target: { type: 'member', id: 'u_x', label: 'Bo' } }) + ).toBe('Removed Bo from the team') + }) +}) diff --git a/src/renderer/src/components/settings/enterprise/EnterpriseSettingsView.tsx b/src/renderer/src/components/settings/enterprise/EnterpriseSettingsView.tsx new file mode 100644 index 00000000000..32c18f36ce4 --- /dev/null +++ b/src/renderer/src/components/settings/enterprise/EnterpriseSettingsView.tsx @@ -0,0 +1,58 @@ +// Settings › Enterprise, for a team's owners and admins: single sign-on and directory sync, the +// team's sign-in policies, and the audit log. Each block shows its own controls when the team's +// plan includes it and the shared upsell (EnterpriseUpsell) when it does not. +// +// Slots for the blocks other owners build (pass a node; nothing renders until one is passed): +// singleSignOn replaces the SSO and directory sync upsell (sso-scim) +// policies sign-in policies, e.g. "Require MFA" (mfa) +import { useTranslate } from '@/i18n/use-translate' +import { SettingsGroup } from '../section-layout/SettingsGroup' +import { EnterpriseAuditLog, type EnterpriseAuditLogProps } from './EnterpriseAuditLog' +import { EnterpriseUpsell } from './EnterpriseUpsell' + +export const ENTERPRISE_SSO_ANCHOR = 'cloud-enterprise-sso' +export const ENTERPRISE_POLICIES_ANCHOR = 'cloud-enterprise-policies' +export const ENTERPRISE_AUDIT_LOG_ANCHOR = 'cloud-enterprise-audit-log' + +export type EnterpriseSettingsViewProps = { + audit: EnterpriseAuditLogProps + singleSignOn?: React.ReactNode + policies?: React.ReactNode +} + +export function EnterpriseSettingsView({ + audit, + singleSignOn, + policies +}: EnterpriseSettingsViewProps): React.JSX.Element { + const t = useTranslate() + return ( +
+
+ {singleSignOn ?? ( + + + + )} +
+ {policies ?
{policies}
: null} +
+ +
+
+ ) +} diff --git a/src/renderer/src/components/settings/enterprise/EnterpriseUpsell.tsx b/src/renderer/src/components/settings/enterprise/EnterpriseUpsell.tsx new file mode 100644 index 00000000000..a01588bbeb1 --- /dev/null +++ b/src/renderer/src/components/settings/enterprise/EnterpriseUpsell.tsx @@ -0,0 +1,43 @@ +// The one locked state for everything on Settings › Enterprise: what the feature is, that it is +// part of Enterprise, and Contact sales. The button opens the page; its chevron copies the link. +import { ExternalLink } from 'lucide-react' +import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton' +import { useTranslate } from '@/i18n/use-translate' + +/** Where Contact sales goes when billing has not answered yet (billing's own Enterprise page). */ +export const ENTERPRISE_CONTACT_URL = 'https://orca.dev/enterprise' + +/** Billing's name and contact page for the plan, so the upsell never drifts from the plans page. */ +export type EnterpriseContact = { planName: string; contactUrl: string } + +export function EnterpriseUpsell({ + title, + body, + contact, + onContactSales +}: { + /** "Audit log is part of Enterprise". */ + title: string + body: string + contact: EnterpriseContact + onContactSales: (url: string) => void +}): React.JSX.Element { + const t = useTranslate() + return ( +
+
+

{title}

+

{body}

+
+ onContactSales(contact.contactUrl)} + > + {t('auto.components.billing.enterpriseContactSales', 'Contact sales')} + + +
+ ) +} diff --git a/src/renderer/src/components/settings/enterprise/enterprise-audit-copy.ts b/src/renderer/src/components/settings/enterprise/enterprise-audit-copy.ts new file mode 100644 index 00000000000..c5915d61a87 --- /dev/null +++ b/src/renderer/src/components/settings/enterprise/enterprise-audit-copy.ts @@ -0,0 +1,311 @@ +// Wording for the team audit log: each recorded action as a sentence an admin can scan, and the +// names of the categories they filter by. An action this version does not know is shown by its +// machine name rather than hidden, so nothing recorded is ever missing from the log. +import { translate } from '@/i18n/i18n' +import type { Translate } from '@/i18n/use-translate' +import type { + CloudAuditCategory, + CloudAuditEvent +} from '../../../../../shared/cloud-api/cloud-api-types' + +export function auditCategoryLabel(category: CloudAuditCategory, t: Translate = translate): string { + switch (category) { + case 'members': + return t('settings.enterprise.audit.category.members', 'Members') + case 'security': + return t('settings.enterprise.audit.category.security', 'Security') + case 'api_keys': + return t('settings.enterprise.audit.category.apiKeys', 'API keys') + case 'apps': + return t('settings.enterprise.audit.category.apps', 'Connected apps') + case 'workspaces': + return t('settings.enterprise.audit.category.workspaces', 'Workspaces') + case 'secrets': + return t('settings.enterprise.audit.category.secrets', 'Secrets') + case 'routines': + return t('settings.enterprise.audit.category.routines', 'Routines') + case 'settings': + return t('settings.enterprise.audit.category.settings', 'Settings') + case 'billing': + return t('settings.enterprise.audit.category.billing', 'Billing') + case 'other': + return t('settings.enterprise.audit.category.other', 'Other') + } +} + +type Sentence = (t: Translate) => string +/** `named` takes the thing's name ({{name}}): a key, a secret, a teammate, a workspace. */ +type ActionCopy = { plain: Sentence; named?: (t: Translate, name: string) => string } + +const ACTIONS: Record = { + 'api_key.create': { + plain: (t) => t('auto.components.settings.cloudApi.audit.action.keyCreate', 'Created API key'), + named: (t, name) => + t('settings.enterprise.audit.action.keyCreateNamed', 'Created API key “{{name}}”', { name }) + }, + 'api_key.revoke': { + plain: (t) => t('auto.components.settings.cloudApi.audit.action.keyRevoke', 'Revoked API key'), + named: (t, name) => + t('settings.enterprise.audit.action.keyRevokeNamed', 'Revoked API key “{{name}}”', { name }) + }, + 'workspace_token.issue': { + plain: (t) => + t('settings.enterprise.audit.action.workspaceToken', 'Gave an agent access to the Orca API') + }, + 'settings.update': { + plain: (t) => + t('auto.components.settings.cloudApi.audit.action.settings', 'Changed agent access') + }, + 'workspace.create': { + plain: (t) => + t('auto.components.settings.cloudApi.audit.action.wsCreate', 'Started a workspace'), + named: (t, name) => + t('settings.enterprise.audit.action.wsCreateNamed', 'Started workspace “{{name}}”', { name }) + }, + 'workspace.destroy': { + plain: (t) => + t('auto.components.settings.cloudApi.audit.action.wsDestroy', 'Deleted a workspace'), + named: (t, name) => + t('settings.enterprise.audit.action.wsDestroyNamed', 'Deleted workspace “{{name}}”', { name }) + }, + 'workspace.sleep': { + plain: (t) => + t('auto.components.settings.cloudApi.audit.action.wsSleep', 'Put a workspace to sleep'), + named: (t, name) => + t('settings.enterprise.audit.action.wsSleepNamed', 'Put workspace “{{name}}” to sleep', { + name + }) + }, + 'workspace.wake': { + plain: (t) => t('auto.components.settings.cloudApi.audit.action.wsWake', 'Woke a workspace'), + named: (t, name) => + t('settings.enterprise.audit.action.wsWakeNamed', 'Woke workspace “{{name}}”', { name }) + }, + 'workspace.archive': { + plain: (t) => + t('auto.components.settings.cloudApi.audit.action.wsArchive', 'Archived a workspace'), + named: (t, name) => + t('settings.enterprise.audit.action.wsArchiveNamed', 'Archived workspace “{{name}}”', { + name + }) + }, + 'workspace.unarchive': { + plain: (t) => t('settings.enterprise.audit.action.wsUnarchive', 'Restored a workspace'), + named: (t, name) => + t('settings.enterprise.audit.action.wsUnarchiveNamed', 'Restored workspace “{{name}}”', { + name + }) + }, + 'workspace.rename': { + plain: (t) => t('settings.enterprise.audit.action.wsRename', 'Renamed a workspace'), + named: (t, name) => + t('settings.enterprise.audit.action.wsRenameNamed', 'Renamed a workspace to “{{name}}”', { + name + }) + }, + 'workspace.share_create': { + plain: (t) => + t('settings.enterprise.audit.action.wsShare', 'Shared a link to a workspace port'), + named: (t, name) => + t( + 'settings.enterprise.audit.action.wsShareNamed', + 'Shared a link to a port on workspace “{{name}}”', + { name } + ) + }, + 'workspace.share_revoke': { + plain: (t) => + t('settings.enterprise.audit.action.wsUnshare', 'Turned off a workspace share link'), + named: (t, name) => + t( + 'settings.enterprise.audit.action.wsUnshareNamed', + 'Turned off a share link on workspace “{{name}}”', + { name } + ) + }, + 'workspace.message': { + plain: (t) => t('auto.components.settings.cloudApi.audit.action.wsMessage', 'Messaged an agent') + }, + 'workspace.attachment': { + plain: (t) => t('settings.enterprise.audit.action.wsAttachment', 'Sent a file to an agent') + }, + 'workspace.session.create': { + plain: (t) => t('settings.enterprise.audit.action.sessionCreate', 'Started an agent') + }, + 'workspace.session.cancel': { + plain: (t) => t('settings.enterprise.audit.action.sessionCancel', 'Stopped an agent') + }, + 'workspace.session.rename': { + plain: (t) => t('settings.enterprise.audit.action.sessionRename', 'Renamed an agent session') + }, + 'sandbox.ssh_key_added': { + plain: (t) => t('settings.enterprise.audit.action.sshAdd', 'Added an SSH key to a workspace'), + named: (t, name) => + t( + 'settings.enterprise.audit.action.sshAddNamed', + 'Added an SSH key to workspace “{{name}}”', + { + name + } + ) + }, + 'sandbox.ssh_key_removed': { + plain: (t) => + t('settings.enterprise.audit.action.sshRemove', 'Removed an SSH key from a workspace'), + named: (t, name) => + t( + 'settings.enterprise.audit.action.sshRemoveNamed', + 'Removed an SSH key from workspace “{{name}}”', + { name } + ) + }, + 'project.create': { + plain: (t) => + t('auto.components.settings.cloudApi.audit.action.projectCreate', 'Created a project'), + named: (t, name) => + t('settings.enterprise.audit.action.projectCreateNamed', 'Created project “{{name}}”', { + name + }) + }, + 'project.update': { + plain: (t) => + t('auto.components.settings.cloudApi.audit.action.projectUpdate', 'Edited a project') + }, + 'project.delete': { + plain: (t) => + t('auto.components.settings.cloudApi.audit.action.projectDelete', 'Deleted a project') + }, + 'mcp.approval_create': { + plain: (t) => + t('auto.components.settings.cloudApi.audit.action.approval', 'Made an app approval code') + }, + 'mcp.authorize': { + plain: (t) => t('settings.enterprise.audit.action.mcpAuthorize', 'Connected an app') + }, + 'mcp.grant_revoke': { + plain: (t) => t('settings.enterprise.audit.action.mcpRevoke', 'Disconnected an app') + }, + 'mcp.code_replay': { + plain: (t) => + t( + 'settings.enterprise.audit.action.mcpCodeReplay', + 'Blocked a reused app sign-in code and disconnected the app' + ) + }, + 'mcp.refresh_replay': { + plain: (t) => + t( + 'settings.enterprise.audit.action.mcpRefreshReplay', + 'Blocked a reused app token and disconnected the app' + ) + }, + 'secret.created': { + plain: (t) => t('settings.enterprise.audit.action.secretCreate', 'Added a secret'), + named: (t, name) => + t('settings.enterprise.audit.action.secretCreateNamed', 'Added secret {{name}}', { name }) + }, + 'secret.updated': { + plain: (t) => t('settings.enterprise.audit.action.secretUpdate', 'Changed a secret'), + named: (t, name) => + t('settings.enterprise.audit.action.secretUpdateNamed', 'Changed secret {{name}}', { name }) + }, + 'secret.deleted': { + plain: (t) => t('settings.enterprise.audit.action.secretDelete', 'Deleted a secret'), + named: (t, name) => + t('settings.enterprise.audit.action.secretDeleteNamed', 'Deleted secret {{name}}', { name }) + }, + 'member.invited': { + plain: (t) => t('settings.enterprise.audit.action.memberInvite', 'Invited a teammate'), + named: (t, name) => + t('settings.enterprise.audit.action.memberInviteNamed', 'Invited {{name}}', { name }) + }, + 'member.invite_revoked': { + plain: (t) => t('settings.enterprise.audit.action.inviteRevoke', 'Canceled an invite'), + named: (t, name) => + t('settings.enterprise.audit.action.inviteRevokeNamed', 'Canceled the invite for {{name}}', { + name + }) + }, + 'member.removed': { + plain: (t) => t('settings.enterprise.audit.action.memberRemove', 'Removed a teammate'), + named: (t, name) => + t('settings.enterprise.audit.action.memberRemoveNamed', 'Removed {{name}} from the team', { + name + }) + }, + 'member.left': { + plain: (t) => t('settings.enterprise.audit.action.memberLeft', 'Left the team') + }, + 'member.role_changed': { + plain: (t) => t('settings.enterprise.audit.action.roleChange', 'Changed a teammate’s role'), + named: (t, name) => + t('settings.enterprise.audit.action.roleChangeNamed', 'Changed the role of {{name}}', { + name + }) + }, + 'member.role_synced': { + plain: (t) => + t( + 'settings.enterprise.audit.action.roleSync', + 'Updated a teammate’s API keys to match their role' + ) + }, + 'team.created': { + plain: (t) => t('settings.enterprise.audit.action.teamCreate', 'Created the team') + }, + 'team.renamed': { + plain: (t) => t('settings.enterprise.audit.action.teamRename', 'Renamed the team'), + named: (t, name) => + t('settings.enterprise.audit.action.teamRenameNamed', 'Renamed the team to “{{name}}”', { + name + }) + }, + 'routine.create': { + plain: (t) => t('settings.enterprise.audit.action.routineCreate', 'Created a routine') + }, + 'routine.update': { + plain: (t) => t('settings.enterprise.audit.action.routineUpdate', 'Edited a routine') + }, + 'routine.delete': { + plain: (t) => t('settings.enterprise.audit.action.routineDelete', 'Deleted a routine') + }, + 'routine.disable': { + plain: (t) => t('settings.enterprise.audit.action.routineDisable', 'Turned off a routine') + }, + 'routine.run': { + plain: (t) => t('settings.enterprise.audit.action.routineRun', 'Ran a routine') + }, + 'routine.run.start': { + plain: (t) => t('settings.enterprise.audit.action.routineRunStart', 'A routine run started') + }, + 'routine.run.finish': { + plain: (t) => t('settings.enterprise.audit.action.routineRunFinish', 'A routine run finished') + }, + 'routine.rotate_webhook': { + plain: (t) => + t('settings.enterprise.audit.action.routineWebhook', 'Replaced a routine’s webhook URL') + } +} + +/** The name to put in the sentence: what the control plane labelled the target, else a recorded name. */ +function targetName(event: CloudAuditEvent): string | null { + const recorded = event.detail?.name + const name = event.target?.label ?? (typeof recorded === 'string' ? recorded : null) + const clean = name?.trim() + return clean ? clean.slice(0, 80) : null +} + +/** "Created API key “CI deploy”"; an unknown action reads as its machine name. */ +export function auditEventSentence(event: CloudAuditEvent, t: Translate = translate): string { + const copy = ACTIONS[event.action] + if (!copy) { + return event.action + } + const name = targetName(event) + return name && copy.named ? copy.named(t, name) : copy.plain(t) +} + +/** Whether this version words the action (the rest show their machine name). */ +export function isKnownAuditAction(action: string): boolean { + return action in ACTIONS +} diff --git a/src/renderer/src/components/settings/enterprise/enterprise-fixture-data.ts b/src/renderer/src/components/settings/enterprise/enterprise-fixture-data.ts new file mode 100644 index 00000000000..96e5206f993 --- /dev/null +++ b/src/renderer/src/components/settings/enterprise/enterprise-fixture-data.ts @@ -0,0 +1,152 @@ +// Sample audit log for the Enterprise settings fixtures and tests: one event from every source the +// control plane merges (API keys, secrets, members, workspaces, settings, apps), by current +// teammates, a former teammate, an API key, an agent and Orca itself. Names only, never values. +import type { + CloudAuditActor, + CloudAuditEvent +} from '../../../../../shared/cloud-api/cloud-api-types' +import type { EnterpriseAuditLogProps } from './EnterpriseAuditLog' + +export const FIXTURE_NOW = Date.UTC(2026, 10, 12, 17, 30) +const MIN = 60_000 +const HOUR = 60 * MIN +const DAY = 24 * HOUR + +const ADA: CloudAuditActor = { kind: 'person', userId: 'u_ada', name: 'Ada Lovelace', via: null } +const GRACE: CloudAuditActor = { + kind: 'person', + userId: 'u_grace', + name: 'Grace Hopper', + via: null +} +const LINUS: CloudAuditActor = { + kind: 'former_member', + userId: 'u_linus', + name: 'Linus Park', + via: null +} +const SYSTEM: CloudAuditActor = { kind: 'system', userId: null, name: null, via: null } + +export const FIXTURE_ACTORS: CloudAuditActor[] = [ADA, GRACE, LINUS] + +export const FIXTURE_EVENTS: CloudAuditEvent[] = [ + { + id: 'sev_9', + at: FIXTURE_NOW - 4 * MIN, + category: 'secrets', + action: 'secret.updated', + actor: GRACE, + target: { type: 'secret', id: 'STRIPE_SECRET_KEY', label: 'STRIPE_SECRET_KEY' }, + detail: { scope: 'org' } + }, + { + id: 'aud_8', + at: FIXTURE_NOW - 38 * MIN, + category: 'members', + action: 'member.role_changed', + actor: ADA, + target: { type: 'member', id: 'u_grace', label: 'Grace Hopper' }, + detail: { role: 'admin' } + }, + { + id: 'aud_7', + at: FIXTURE_NOW - 3 * HOUR, + category: 'workspaces', + action: 'workspace.share_create', + actor: GRACE, + target: { type: 'workspace', id: 'sbx_7Hq2', label: 'checkout-redesign' }, + detail: { port: 3000, audience: 'team', expiresIn: '1d' } + }, + { + id: 'aud_6', + at: FIXTURE_NOW - 5 * HOUR, + category: 'workspaces', + action: 'workspace.create', + actor: { ...GRACE, via: 'api_key' }, + target: { type: 'workspace', id: 'sbx_2Lm9', label: 'nightly-evals' }, + detail: null + }, + { + id: 'aud_5', + at: FIXTURE_NOW - 1 * DAY, + category: 'members', + action: 'member.removed', + actor: ADA, + target: { type: 'member', id: 'u_linus', label: 'Linus Park' }, + detail: { keysRevoked: 2, devicesRevoked: 1 } + }, + { + id: 'aud_4', + at: FIXTURE_NOW - 1 * DAY - 2 * MIN, + category: 'routines', + action: 'routine.disable', + actor: SYSTEM, + target: { type: 'routine', id: 'rtn_41', label: null }, + detail: { reason: 'member_removed' } + }, + { + id: 'aud_3', + at: FIXTURE_NOW - 2 * DAY, + category: 'api_keys', + action: 'api_key.create', + actor: LINUS, + target: { type: 'api_key', id: 'key_ci', label: 'GitHub Actions deploy' }, + detail: { name: 'GitHub Actions deploy' } + }, + { + id: 'sev_2', + at: FIXTURE_NOW - 3 * DAY, + category: 'secrets', + action: 'secret.created', + actor: ADA, + target: { type: 'secret', id: 'ANTHROPIC_API_KEY', label: 'ANTHROPIC_API_KEY' }, + detail: { scope: 'repo', repo: 'github.com/acme/web' } + }, + { + id: 'aud_1', + at: FIXTURE_NOW - 6 * DAY, + category: 'settings', + action: 'settings.update', + actor: ADA, + target: { type: 'account', id: 'org:acme', label: null }, + detail: { vmCliEnabled: true } + }, + { + id: 'aud_0', + at: FIXTURE_NOW - 9 * DAY, + category: 'apps', + action: 'mcp.authorize', + actor: { ...GRACE, via: 'mcp' }, + target: { type: 'oauth_client', id: 'cl_4q', label: null }, + detail: null + } +] + +const noop = (): void => {} + +export function fixtureAuditProps( + patch: Partial = {} +): EnterpriseAuditLogProps { + return { + log: { + status: 'ok', + events: FIXTURE_EVENTS, + hasMore: true, + loadingMore: false, + moreError: null, + refreshing: false + }, + filters: { category: null, actor: null }, + actors: FIXTURE_ACTORS, + exportState: { status: 'idle' }, + now: FIXTURE_NOW, + viewerUserId: 'u_ada', + contact: { planName: 'Enterprise', contactUrl: 'https://orca.dev/enterprise' }, + onFilter: noop, + onLoadMore: noop, + onRetry: noop, + onExport: noop, + onContactSales: noop, + ...patch + } +} diff --git a/src/renderer/src/components/settings/enterprise/use-enterprise-audit-log.ts b/src/renderer/src/components/settings/enterprise/use-enterprise-audit-log.ts new file mode 100644 index 00000000000..8e2be99ccb5 --- /dev/null +++ b/src/renderer/src/components/settings/enterprise/use-enterprise-audit-log.ts @@ -0,0 +1,196 @@ +// Loads the team audit log for Settings › Enterprise: the first page, more pages, filter changes +// and the CSV export. The control plane decides access; `plan-required` becomes the upsell. +import { useCallback, useEffect, useRef, useState } from 'react' +import { useTranslate } from '@/i18n/use-translate' +import type { + CloudAuditActor, + CloudAuditEvent +} from '../../../../../shared/cloud-api/cloud-api-types' +import { cloudApiErrorCopy } from '../cloud-api/cloud-api-copy' +import type { CloudApiFailure } from '../cloud-api/cloud-load-state' +import { useRequestGeneration } from '../cloud-api/use-request-generation' +import type { AuditLogExportState, AuditLogFilters, AuditLogState } from './EnterpriseAuditLog' + +const PAGE_SIZE = 50 +const NO_FILTERS: AuditLogFilters = { category: null, actor: null } + +function queryFor(filters: AuditLogFilters): { + category?: NonNullable + actor?: string +} { + return { + ...(filters.category ? { category: filters.category } : {}), + ...(filters.actor ? { actor: filters.actor } : {}) + } +} + +/** Hands the CSV to the browser's download path (Electron shows its save dialog). */ +export function saveAuditCsv(csv: string, at: number): void { + // A BOM so spreadsheet apps read names with accents as UTF-8. + const url = URL.createObjectURL(new Blob(['', csv], { type: 'text/csv;charset=utf-8' })) + const link = document.createElement('a') + link.href = url + link.download = `orca-audit-log-${new Date(at).toISOString().slice(0, 10)}.csv` + document.body.append(link) + link.click() + link.remove() + // Revoked after the click has been handled, or the download can start from a dead URL. + window.setTimeout(() => URL.revokeObjectURL(url), 10_000) +} + +export function useEnterpriseAuditLog(enabled: boolean): { + log: AuditLogState + filters: AuditLogFilters + actors: CloudAuditActor[] + exportState: AuditLogExportState + now: number + setFilters: (filters: AuditLogFilters) => void + loadMore: () => void + retry: () => void + exportCsv: () => void +} { + const t = useTranslate() + const api = window.api.cloudApi + const begin = useRequestGeneration() + const [log, setLog] = useState({ status: 'loading' }) + const [filters, setFiltersState] = useState(NO_FILTERS) + const [actors, setActors] = useState([]) + const [exportState, setExportState] = useState({ status: 'idle' }) + const [now, setNow] = useState(() => Date.now()) + const cursor = useRef(null) + const loaded = useRef([]) + + const word = useCallback( + (failure: CloudApiFailure) => cloudApiErrorCopy(failure.error, t, failure), + [t] + ) + + const loadFirst = useCallback( + async (next: AuditLogFilters): Promise => { + if (!api) { + return + } + const isCurrent = begin('audit-log') + // A filter change keeps the list on screen until the new one arrives. + setLog((current) => + current.status === 'ok' ? { ...current, refreshing: true } : { status: 'loading' } + ) + const result = await api.listAuditLog({ ...queryFor(next), limit: PAGE_SIZE }) + if (!isCurrent()) { + return + } + setNow(Date.now()) + if (!result.ok) { + cursor.current = null + loaded.current = [] + setLog( + result.error === 'plan-required' + ? { status: 'upsell' } + : { status: 'error', message: word(result) } + ) + return + } + cursor.current = result.data.nextCursor + loaded.current = result.data.events + if (result.data.actors) { + setActors(result.data.actors) + } + setLog({ + status: 'ok', + events: result.data.events, + hasMore: result.data.nextCursor !== null, + loadingMore: false, + moreError: null, + refreshing: false + }) + }, + [api, begin, word] + ) + + useEffect(() => { + if (enabled) { + void loadFirst(NO_FILTERS) + } + }, [enabled, loadFirst]) + + const setFilters = useCallback( + (next: AuditLogFilters): void => { + setFiltersState(next) + setExportState({ status: 'idle' }) + void loadFirst(next) + }, + [loadFirst] + ) + + const loadMore = useCallback((): void => { + const after = cursor.current + if (!api || !after) { + return + } + const isCurrent = begin('audit-log') + setLog((current) => + current.status === 'ok' ? { ...current, loadingMore: true, moreError: null } : current + ) + void api + .listAuditLog({ ...queryFor(filters), cursor: after, limit: PAGE_SIZE }) + .then((result) => { + if (!isCurrent()) { + return + } + if (!result.ok) { + setLog((current) => + current.status === 'ok' + ? { ...current, loadingMore: false, moreError: word(result) } + : current + ) + return + } + cursor.current = result.data.nextCursor + // A new event can shift a page boundary; never show one twice. + const seen = new Set(loaded.current.map((event) => event.id)) + loaded.current = [ + ...loaded.current, + ...result.data.events.filter((event) => !seen.has(event.id)) + ] + setLog({ + status: 'ok', + events: loaded.current, + hasMore: result.data.nextCursor !== null, + loadingMore: false, + moreError: null, + refreshing: false + }) + }) + }, [api, begin, filters, word]) + + const exportCsv = useCallback((): void => { + if (!api) { + return + } + const isCurrent = begin('audit-export') + setExportState({ status: 'exporting' }) + void api.exportAuditLogCsv(queryFor(filters)).then((result) => { + if (!isCurrent()) { + return + } + if (!result.ok) { + setExportState({ status: 'error', message: word(result) }) + return + } + saveAuditCsv(result.data.csv, Date.now()) + setExportState({ status: 'done', truncated: result.data.truncated }) + }) + }, [api, begin, filters, word]) + + return { + log, + filters, + actors, + exportState, + now, + setFilters, + loadMore, + retry: () => void loadFirst(filters), + exportCsv + } +} diff --git a/src/renderer/src/components/settings/navigation/settings-navigation-types.ts b/src/renderer/src/components/settings/navigation/settings-navigation-types.ts index 7dcc261b2b9..5cea2db888b 100644 --- a/src/renderer/src/components/settings/navigation/settings-navigation-types.ts +++ b/src/renderer/src/components/settings/navigation/settings-navigation-types.ts @@ -48,6 +48,7 @@ const SETTINGS_NAV_TARGETS = [ 'orca-account', 'cloud-billing', 'cloud-team', + 'cloud-enterprise', 'cloud-secrets', 'cloud-environments', 'cloud-mcp', diff --git a/src/renderer/src/components/settings/orca-cloud/OrcaCloudSettingsPanes.tsx b/src/renderer/src/components/settings/orca-cloud/OrcaCloudSettingsPanes.tsx index 064d183cd34..76147988a74 100644 --- a/src/renderer/src/components/settings/orca-cloud/OrcaCloudSettingsPanes.tsx +++ b/src/renderer/src/components/settings/orca-cloud/OrcaCloudSettingsPanes.tsx @@ -13,6 +13,7 @@ import { } from '../../cloud-environment/CloudEnvironmentSection' import { OrcaSignInButton, OrcaSignInWaitNotice } from '../../OrcaSignInProgress' import { CloudApiEmptyState } from '../cloud-api/cloud-api-parts' +import { EnterpriseSettingsPane } from '../enterprise/EnterpriseSettingsPane' import { CloudSecretsSection } from '../cloud-secrets/CloudSecretsSection' import { SettingsGroup } from '../section-layout/SettingsGroup' import { CloudPredictiveWakeSetting } from './CloudPredictiveWakeSetting' @@ -104,6 +105,11 @@ function OrcaCloudAccountGate({ ) } +/** Settings › Enterprise: single sign-on, sign-in policies and the team audit log. */ +export function OrcaCloudEnterprisePane(): React.JSX.Element { + return {() => } +} + export function OrcaCloudTeamPane(): React.JSX.Element { const t = useTranslate() return ( diff --git a/src/renderer/src/components/settings/orca-cloud/orca-cloud-settings-search.ts b/src/renderer/src/components/settings/orca-cloud/orca-cloud-settings-search.ts index 25c9981a208..b1ca6546f97 100644 --- a/src/renderer/src/components/settings/orca-cloud/orca-cloud-settings-search.ts +++ b/src/renderer/src/components/settings/orca-cloud/orca-cloud-settings-search.ts @@ -34,6 +34,24 @@ export const getOrcaCloudTeamSearchEntries = createLocalizedCatalog(() => [ } ]) +export const getOrcaCloudEnterpriseSearchEntries = createLocalizedCatalog(() => [ + { + title: translate('settings.enterprise.title', 'Enterprise'), + description: translate( + 'settings.enterprise.searchDescription', + 'Single sign-on and directory sync, and the audit log of changes across your team.' + ), + keywords: [ + ...translateSearchKeyword('settings.enterprise.keywordAudit', 'audit log'), + ...translateSearchKeyword('settings.enterprise.keywordActivity', 'activity'), + ...translateSearchKeyword('settings.enterprise.keywordSso', 'sso'), + ...translateSearchKeyword('settings.enterprise.keywordSaml', 'saml'), + ...translateSearchKeyword('settings.enterprise.keywordScim', 'scim'), + ...translateSearchKeyword('settings.enterprise.keywordCompliance', 'compliance') + ] + } +]) + export const getOrcaCloudSecretsSearchEntries = createLocalizedCatalog(() => [ { title: translate('cloudSecrets.title', 'Cloud secrets'), diff --git a/src/renderer/src/components/settings/page/settings-orca-cloud-section-renderers.tsx b/src/renderer/src/components/settings/page/settings-orca-cloud-section-renderers.tsx index fc73b9264fe..d4d004e20e2 100644 --- a/src/renderer/src/components/settings/page/settings-orca-cloud-section-renderers.tsx +++ b/src/renderer/src/components/settings/page/settings-orca-cloud-section-renderers.tsx @@ -1,4 +1,5 @@ import { + OrcaCloudEnterprisePane, OrcaCloudEnvironmentsPane, OrcaCloudLocalCommandsSettingsPane, OrcaCloudMcpPane, @@ -23,8 +24,10 @@ export function renderOrcaCloudSettingsSections( return null } // Titles and descriptions come from the nav metadata so the pane and the sidebar never disagree. - const sections = [ + const sections: { id: string; pane: React.JSX.Element; navOnly?: boolean }[] = [ { id: 'cloud-team', pane: }, + // Listed in the nav for team owners and admins only; without the entry there is no page. + { id: 'cloud-enterprise', pane: , navOnly: true }, { id: 'cloud-secrets', pane: }, { id: 'cloud-skills', pane: }, { id: 'cloud-environments', pane: }, @@ -33,8 +36,11 @@ export function renderOrcaCloudSettingsSections( ] return ( <> - {sections.map(({ id, pane }) => { + {sections.map(({ id, pane, navOnly }) => { const meta = navigation.navSections.find((section) => section.id === id) + if (navOnly && !meta) { + return null + } return ( { expect(new Set(sections.map((section) => section.group))).toEqual(new Set(['orca-cloud'])) }) + it('adds Enterprise after Team for a team owner or admin only', () => { + const ids = (patch: Parameters[0]) => + buildOrcaCloudSettingsSections(options(patch)).map((section) => section.id) + expect(ids({})).not.toContain('cloud-enterprise') + expect(ids({ isOrcaTeamAdmin: false })).not.toContain('cloud-enterprise') + const admin = ids({ isOrcaTeamAdmin: true }) + expect(admin.slice(0, 4)).toEqual([ + 'cloud-billing', + 'cloud-team', + 'cloud-enterprise', + 'cloud-secrets' + ]) + const section = buildOrcaCloudSettingsSections(options({ isOrcaTeamAdmin: true })).find( + (entry) => entry.id === 'cloud-enterprise' + )! + expect(section).toMatchObject({ title: 'Enterprise', group: 'orca-cloud' }) + // The audit log is found by searching for it; it no longer lives under Orca API keys. + const keywords = (id: string) => + buildOrcaCloudSettingsSections(options({ isOrcaTeamAdmin: true })) + .find((entry) => entry.id === id)! + .searchEntries.flatMap((entry) => entry.keywords ?? []) + expect(keywords('cloud-enterprise')).toContain('audit log') + expect(keywords('cloud-api-keys')).not.toContain('audit log') + // Never without a control plane, whatever the role. + expect(ids({ isOrcaTeamAdmin: true, isOrcaCloudApiAvailable: false })).toEqual([]) + }) + it('is absent without a control plane and on the web client', () => { expect(buildOrcaCloudSettingsSections(options({ isOrcaCloudApiAvailable: false }))).toEqual([]) expect(buildOrcaCloudSettingsSections(options({ isWebClient: true }))).toEqual([]) diff --git a/src/renderer/src/hooks/settings-navigation-orca-cloud-sections.ts b/src/renderer/src/hooks/settings-navigation-orca-cloud-sections.ts index 0d16b505b8e..edc2e5fc797 100644 --- a/src/renderer/src/hooks/settings-navigation-orca-cloud-sections.ts +++ b/src/renderer/src/hooks/settings-navigation-orca-cloud-sections.ts @@ -4,6 +4,7 @@ import { } from '@/components/settings/cloud-api/cloud-api-search' import { getOrcaCloudBillingSearchEntries, + getOrcaCloudEnterpriseSearchEntries, getOrcaCloudEnvironmentsSearchEntries, getOrcaCloudLocalCommandsSearchEntries, getOrcaCloudMcpSearchEntries, @@ -13,11 +14,15 @@ import { } from '@/components/settings/orca-cloud/orca-cloud-settings-search' import { translate } from '@/i18n/i18n' import type { Translate } from '@/i18n/use-translate' -import type { SettingsNavSection, SettingsNavTarget } from '@/components/settings/navigation/settings-navigation-types' +import type { + SettingsNavSection, + SettingsNavTarget +} from '@/components/settings/navigation/settings-navigation-types' import type { OrcaProfileAuthStatus } from '../../../shared/orca-profiles' import { BookOpenText, Boxes, + Building2, CreditCard, KeySquare, LockKeyhole, @@ -31,7 +36,7 @@ import { CLOUD_ROUTINES_UI_ENABLED } from '../../../shared/cloud-api/cloud-routi /** One home for everything Orca Cloud; only builds with a control plane show it. */ export function buildOrcaCloudSettingsSections( - { isWebClient, isOrcaCloudApiAvailable }: SettingsNavigationBuildOptions, + { isWebClient, isOrcaCloudApiAvailable, isOrcaTeamAdmin }: SettingsNavigationBuildOptions, t: Translate = translate ): SettingsNavSection[] { if (isWebClient || !isOrcaCloudApiAvailable) { @@ -61,6 +66,22 @@ export function buildOrcaCloudSettingsSections( searchEntries: getOrcaCloudTeamSearchEntries(), group }, + // Team owners and admins only; members and personal accounts never see the entry. + ...(isOrcaTeamAdmin + ? [ + { + id: 'cloud-enterprise', + title: t('settings.enterprise.title', 'Enterprise'), + description: t( + 'settings.enterprise.description', + 'Single sign-on, sign-in policies and the audit log for your team.' + ), + icon: Building2, + searchEntries: getOrcaCloudEnterpriseSearchEntries(), + group + } + ] + : []), { id: 'cloud-secrets', title: t('cloudSecrets.title', 'Cloud secrets'), diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts index 4095d17fec1..ea7f3a896ba 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts @@ -15,6 +15,7 @@ import { useWindowsTerminalCapabilities } from '@/runtime/hosts/capabilities/windows-terminal-capabilities' import { defaultScopeHost } from '@/runtime/hosts/selection/default-creation-host' +import { isActiveTeamAdmin } from '@/lib/orca-cloud/active-team-role' import { useAppStore } from '@/store/index' import { useProjectHostSetupProjection } from '@/store/projects/use-project-host-setup-projection' import { @@ -62,6 +63,7 @@ export function buildSettingsNavigationMetadata( isDev = import.meta.env.DEV, isLinearConnected = false, isOrcaCloudApiAvailable = false, + isOrcaTeamAdmin = false, repos, projectGrouping }: { @@ -77,6 +79,7 @@ export function buildSettingsNavigationMetadata( isDev?: boolean isLinearConnected?: boolean isOrcaCloudApiAvailable?: boolean + isOrcaTeamAdmin?: boolean repos: readonly Repo[] projectGrouping?: ProjectGroupingModel }, @@ -112,6 +115,7 @@ export function buildSettingsNavigationMetadata( isDev, isLinearConnected, isOrcaCloudApiAvailable, + isOrcaTeamAdmin, repos, projectGrouping } @@ -153,6 +157,7 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] { const isWebClient = isWebClientLocation() const isLinearConnected = useLinearProviderConnected() const isOrcaCloudApiAvailable = useOrcaCloudApiAvailable() + const isOrcaTeamAdmin = useAppStore((state) => isActiveTeamAdmin(state.orcaProfileAuthStatus)) const windowsTerminalCapabilityOwnerKey = useWindowsTerminalCapabilityOwnerKey( settings?.activeRuntimeEnvironmentId ) @@ -197,6 +202,7 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] { isDev: import.meta.env.DEV, isLinearConnected, isOrcaCloudApiAvailable, + isOrcaTeamAdmin, repos, projectGrouping: { projects: projectHostSetupProjection.projects, @@ -218,6 +224,7 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] { mobileEmulatorCreationEnabled, isLinearConnected, isOrcaCloudApiAvailable, + isOrcaTeamAdmin, repos, projectHostSetupProjection, languageRevision, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index f7e081723c5..88851e7e922 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1478,6 +1478,120 @@ "title2": "Account & billing", "accountOnly": "Account", "accountOnlyDescription": "Your Orca account and what’s included with it." + }, + "enterprise": { + "audit": { + "actor": { + "system": "Orca", + "youViaAgent": "Your agent", + "personViaAgent": "’s agent", + "youViaRoutine": "Your routine", + "personViaRoutine": "’s routine", + "former": "Former teammate" + }, + "filter": { + "categoryLabel": "Filter by activity", + "allActivity": "All activity", + "actorLabel": "Filter by person", + "everyone": "Everyone" + }, + "title": "Audit log", + "description": "Member, key, secret, workspace and settings changes across your team. Only owners and admins can see it.", + "export": "Export CSV", + "upsellTitle": "Audit log is part of {{plan}}", + "upsellBody": "Orca records your team’s changes on every plan, so your history is here from the day you upgrade.", + "loading": "Loading the audit log", + "exportTruncated": "The file has the newest 5,000 events. Filter by activity or person to export the rest.", + "emptyFilteredTitle": "Nothing matches", + "emptyTitle": "Nothing recorded yet", + "emptyFilteredBody": "No recorded change matches these filters.", + "emptyBody": "Changes to members, API keys, secrets, workspaces and settings will show up here.", + "clearFilters": "Clear filters", + "showMore": "Show more", + "category": { + "members": "Members", + "security": "Security", + "apiKeys": "API keys", + "apps": "Connected apps", + "workspaces": "Workspaces", + "secrets": "Secrets", + "routines": "Routines", + "settings": "Settings", + "billing": "Billing", + "other": "Other" + }, + "action": { + "keyCreateNamed": "Created API key “{{name}}”", + "keyRevokeNamed": "Revoked API key “{{name}}”", + "workspaceToken": "Gave an agent access to the Orca API", + "wsCreateNamed": "Started workspace “{{name}}”", + "wsDestroyNamed": "Deleted workspace “{{name}}”", + "wsSleepNamed": "Put workspace “{{name}}” to sleep", + "wsWakeNamed": "Woke workspace “{{name}}”", + "wsArchiveNamed": "Archived workspace “{{name}}”", + "wsUnarchive": "Restored a workspace", + "wsUnarchiveNamed": "Restored workspace “{{name}}”", + "wsRename": "Renamed a workspace", + "wsRenameNamed": "Renamed a workspace to “{{name}}”", + "wsShare": "Shared a link to a workspace port", + "wsShareNamed": "Shared a link to a port on workspace “{{name}}”", + "wsUnshare": "Turned off a workspace share link", + "wsUnshareNamed": "Turned off a share link on workspace “{{name}}”", + "wsAttachment": "Sent a file to an agent", + "sessionCreate": "Started an agent", + "sessionCancel": "Stopped an agent", + "sessionRename": "Renamed an agent session", + "sshAdd": "Added an SSH key to a workspace", + "sshAddNamed": "Added an SSH key to workspace “{{name}}”", + "sshRemove": "Removed an SSH key from a workspace", + "sshRemoveNamed": "Removed an SSH key from workspace “{{name}}”", + "projectCreateNamed": "Created project “{{name}}”", + "mcpAuthorize": "Connected an app", + "mcpRevoke": "Disconnected an app", + "mcpCodeReplay": "Blocked a reused app sign-in code and disconnected the app", + "mcpRefreshReplay": "Blocked a reused app token and disconnected the app", + "secretCreate": "Added a secret", + "secretCreateNamed": "Added secret {{name}}", + "secretUpdate": "Changed a secret", + "secretUpdateNamed": "Changed secret {{name}}", + "secretDelete": "Deleted a secret", + "secretDeleteNamed": "Deleted secret {{name}}", + "memberInvite": "Invited a teammate", + "memberInviteNamed": "Invited {{name}}", + "inviteRevoke": "Canceled an invite", + "inviteRevokeNamed": "Canceled the invite for {{name}}", + "memberRemove": "Removed a teammate", + "memberRemoveNamed": "Removed {{name}} from the team", + "memberLeft": "Left the team", + "roleChange": "Changed a teammate’s role", + "roleChangeNamed": "Changed the role of {{name}}", + "roleSync": "Updated a teammate’s API keys to match their role", + "teamCreate": "Created the team", + "teamRename": "Renamed the team", + "teamRenameNamed": "Renamed the team to “{{name}}”", + "routineCreate": "Created a routine", + "routineUpdate": "Edited a routine", + "routineDelete": "Deleted a routine", + "routineDisable": "Turned off a routine", + "routineRun": "Ran a routine", + "routineRunStart": "A routine run started", + "routineRunFinish": "A routine run finished", + "routineWebhook": "Replaced a routine’s webhook URL" + } + }, + "sso": { + "upsellTitle": "Single sign-on is part of {{plan}}", + "upsellBody": "Sign in with your identity provider over SAML, and add or remove teammates with SCIM." + }, + "title": "Enterprise", + "searchDescription": "Single sign-on and directory sync, and the audit log of changes across your team.", + "keywordAudit": "audit log", + "keywordActivity": "activity", + "keywordSso": "sso", + "keywordSaml": "saml", + "keywordScim": "scim", + "keywordCompliance": "compliance", + "description": "Single sign-on, sign-in policies and the audit log for your team." } }, "menu": { @@ -9017,19 +9131,26 @@ "adminOnly": "Only team owners and admins can change this." }, "audit": { - "actionNamed": "{{action}} “{{name}}”", - "viaAgent": "Agent in a cloud workspace", "youViaKey": "You via API key", "personViaKey": " via API key", "youViaMcp": "You via MCP", "personViaMcp": " via MCP", "you": "You", - "title": "Recent activity", - "description": "Key, workspace and settings changes across your team. Visible to admins only.", - "hide": "Hide", - "show": "Show activity", - "loading": "Loading activity", - "empty": "Nothing has happened yet." + "action": { + "keyCreate": "Created API key", + "keyRevoke": "Revoked API key", + "settings": "Changed agent access", + "wsCreate": "Started a workspace", + "wsDestroy": "Deleted a workspace", + "wsSleep": "Put a workspace to sleep", + "wsWake": "Woke a workspace", + "wsArchive": "Archived a workspace", + "wsMessage": "Messaged an agent", + "projectCreate": "Created a project", + "projectUpdate": "Edited a project", + "projectDelete": "Deleted a project", + "approval": "Made an app approval code" + } }, "pageErrorDetail": "Keys, connected apps and team settings are unavailable until Orca Cloud answers.", "retry": "Try again", @@ -9103,7 +9224,8 @@ "rateLimited": "Too many requests. Try again in a minute.", "network": "Couldn’t reach Orca Cloud. Check your connection and try again.", "invalidResponse": "Orca Cloud sent an answer this version of Orca does not understand. Update Orca and try again.", - "server": "Orca Cloud had a problem and nothing was changed. Try again in a moment." + "server": "Orca Cloud had a problem and nothing was changed. Try again in a moment.", + "planRequired": "Your team’s plan does not include this." }, "scope": { "workspacesRead": "List and inspect workspaces", @@ -9125,7 +9247,6 @@ "keywordToken": "token", "keywordMcp": "mcp", "keywordCli": "cli", - "keywordAudit": "audit log", "keywordCloud": "cloud", "sectionDescription": "For the Orca Cloud API, the orca CLI and MCP clients. Claude or Codex keys go in Cloud secrets." }, @@ -21176,7 +21297,6 @@ "youAreMember": "You’re a member", "youAreOwner": "You’re an owner", "yourTeams": "Or open a team you’re in", - "ssoBody": "SAML SSO and SCIM user provisioning are part of {{value0}}.", "ssoTitle": "Single sign-on and provisioning", "createdNotSwitched": "{{value0}} was created, but Orca couldn’t switch to it.", "invitedCount_one": "{{count}} invited", diff --git a/src/renderer/src/lib/orca-cloud/active-team-role.test.ts b/src/renderer/src/lib/orca-cloud/active-team-role.test.ts new file mode 100644 index 00000000000..c54a13e0763 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/active-team-role.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import type { OrcaProfileAuthStatus } from '../../../../shared/orca-profiles' +import { activeTeamRole, isActiveTeamAdmin } from './active-team-role' + +function status(patch: Partial = {}): OrcaProfileAuthStatus { + return { + activeProfileId: 'p1', + configured: true, + state: 'connected', + persistence: 'encrypted', + cloud: { + cloudProfileId: 'cp', + userId: 'u1', + email: 'a@acme.dev', + activeOrgId: 'acme', + linkedAt: 1 + }, + organizations: [ + { orgId: 'acme', name: 'Acme', role: 'Owner' }, + { orgId: 'globex', name: 'Globex', role: 'member' } + ], + ...patch + } +} + +describe('activeTeamRole', () => { + it("reads the role in the active team, whatever case auth's provider spells it in", () => { + expect(activeTeamRole(status())).toBe('owner') + expect(isActiveTeamAdmin(status())).toBe(true) + const admin = status({ organizations: [{ orgId: 'acme', name: 'Acme', role: ' ADMIN ' }] }) + expect(activeTeamRole(admin)).toBe('admin') + expect(isActiveTeamAdmin(admin)).toBe(true) + }) + + it('is the role in the ACTIVE team, not any team the person owns', () => { + const inGlobex = status({ cloud: { ...status().cloud!, activeOrgId: 'globex' } }) + expect(activeTeamRole(inGlobex)).toBe('member') + expect(isActiveTeamAdmin(inGlobex)).toBe(false) + }) + + it('treats an unknown or missing role as member, never as admin', () => { + for (const role of ['Billing manager', '', undefined]) { + const odd = status({ organizations: [{ orgId: 'acme', name: 'Acme', role }] }) + expect(activeTeamRole(odd)).toBe('member') + expect(isActiveTeamAdmin(odd)).toBe(false) + } + expect(isActiveTeamAdmin(status({ organizations: [] }))).toBe(false) + }) + + it('has no team role on a personal account, signed out, or before auth has answered', () => { + expect( + activeTeamRole(status({ cloud: { ...status().cloud!, activeOrgId: undefined } })) + ).toBeNull() + expect(activeTeamRole(status({ state: 'reconnect-required' }))).toBeNull() + expect(activeTeamRole(null)).toBeNull() + expect(isActiveTeamAdmin(undefined)).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/orca-cloud/active-team-role.ts b/src/renderer/src/lib/orca-cloud/active-team-role.ts new file mode 100644 index 00000000000..aff67dc620b --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/active-team-role.ts @@ -0,0 +1,29 @@ +import type { OrcaOrgRole, OrcaProfileAuthStatus } from '../../../../shared/orca-profiles' + +/** + * The signed-in user's role in the team they are acting as; null when signed out or on a personal + * account. Auth reports provider role names, so anything but owner/admin reads as member. This + * only decides what the UI offers: the services check the role on every request. + */ +export function activeTeamRole( + status: OrcaProfileAuthStatus | null | undefined +): OrcaOrgRole | null { + if (status?.state !== 'connected') { + return null + } + const orgId = status.cloud?.activeOrgId + if (!orgId) { + return null + } + const raw = status.organizations + ?.find((org) => org.orgId === orgId) + ?.role?.trim() + .toLowerCase() + return raw === 'owner' || raw === 'admin' ? raw : 'member' +} + +/** Owner or admin of the active team: who sees Settings › Enterprise. */ +export function isActiveTeamAdmin(status: OrcaProfileAuthStatus | null | undefined): boolean { + const role = activeTeamRole(status) + return role === 'owner' || role === 'admin' +} diff --git a/src/shared/cloud-api/cloud-api-types.ts b/src/shared/cloud-api/cloud-api-types.ts index dffaca1ff4b..e1cd0897034 100644 --- a/src/shared/cloud-api/cloud-api-types.ts +++ b/src/shared/cloud-api/cloud-api-types.ts @@ -84,27 +84,76 @@ export const CloudApiSettingsSchema = z.object({ }) export type CloudApiSettings = z.infer -export const CloudApiAuditEntrySchema = z.object({ - id: Id, - actor: z.object({ - type: z.string().max(64), - id: z.string().max(128), - userId: z.string().max(128).nullable(), - userName: PersonName - }), - action: z.string().max(128), - target: z.object({ type: z.string().max(64), id: z.string().max(128).nullable() }).nullable(), - // Free-form per action (e.g. a key's name); only short strings are ever shown. - detail: z.record(z.string(), z.unknown()).nullable().optional(), - createdAt: Millis -}) -export type CloudApiAuditEntry = z.infer +// The team audit log (GET /v1/audit-log): every recorded change in one feed. Owners and admins of +// a team whose plan includes it. Unknown categories, actor kinds and actions from a newer control +// plane still parse; the renderer words what it knows and shows the rest by its machine name. +export const CLOUD_AUDIT_CATEGORIES = [ + 'members', + 'security', + 'api_keys', + 'apps', + 'workspaces', + 'secrets', + 'routines', + 'settings', + 'billing', + 'other' +] as const +export type CloudAuditCategory = (typeof CLOUD_AUDIT_CATEGORIES)[number] -export const CloudApiAuditPageSchema = z.object({ - entries: z.array(CloudApiAuditEntrySchema).max(500), - nextBefore: Millis.nullable() +export const CloudAuditActorSchema = z.object({ + kind: z.enum(['person', 'former_member', 'system']).catch('person'), + userId: z.string().max(128).nullable(), + name: PersonName, + via: z.enum(['api_key', 'mcp', 'agent', 'routine']).nullable().catch(null) }) -export type CloudApiAuditPage = z.infer +export type CloudAuditActor = z.infer + +export const CloudAuditEventSchema = z.object({ + id: Id, + at: Millis, + category: z.enum(CLOUD_AUDIT_CATEGORIES).catch('other'), + action: z.string().max(128), + actor: CloudAuditActorSchema, + target: z + .object({ + type: z.string().max(64), + id: z.string().max(256).nullable(), + label: z.string().max(256).nullable().optional() + }) + .nullable(), + // Free-form per action; only short strings and numbers are ever shown. Never a secret value. + detail: z.record(z.string(), z.unknown()).nullable().optional() +}) +export type CloudAuditEvent = z.infer + +export const CloudAuditLogPageSchema = z.object({ + events: z.array(CloudAuditEventSchema).max(500), + nextCursor: z.string().max(128).nullable(), + /** Everyone who appears in the log, for the person filter; sent with the first page only. */ + actors: z.array(CloudAuditActorSchema).max(500).optional() +}) +export type CloudAuditLogPage = z.infer + +export const CloudAuditLogQuerySchema = z + .object({ + category: z.enum(CLOUD_AUDIT_CATEGORIES).optional(), + /** A user id, or `system`. */ + actor: z + .string() + .regex(/^[A-Za-z0-9_.:-]{1,128}$/) + .optional(), + cursor: z + .string() + .regex(/^\d{1,16}\.[A-Za-z0-9_-]{1,80}$/) + .optional(), + limit: z.number().int().min(1).max(200).optional() + }) + .strict() +export type CloudAuditLogQuery = z.infer + +/** A CSV export: the text, and whether more events exist past the export's cap. */ +export type CloudAuditLogExport = { csv: string; truncated: boolean } export const CloudApiProjectSchema = z.object({ id: Id, @@ -148,6 +197,8 @@ export const CLOUD_API_ERROR_CODES = [ 'signed-out', /** Signed in, but this action needs an org owner/admin. */ 'admin-required', + /** The team's plan does not include this (detail: the entitlement, e.g. `audit_log`). */ + 'plan-required', /** The key or token lacks a scope. */ 'forbidden', 'not-found', From 62c606c4060dfa05b9fd9b8ce588812bf5f45035 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 20:43:36 -0800 Subject: [PATCH 14/50] feat(cloud): wire the team MFA banner, setup screen and mfa_required refusal into cloud workspaces and the team page --- .../hosted-sandbox-config.test.ts | 20 ++++- .../hosted-sandbox/hosted-sandbox-config.ts | 22 ++++++ .../billing/team/TeamDialogHost.tsx | 3 + .../components/billing/team/TeamPageView.tsx | 3 + .../cloud-workspaces/CloudWorkspacesHome.tsx | 14 ++++ .../CloudWorkspacesStates.tsx | 2 +- .../cloud-workspace-errors.ts | 14 ++++ .../src/components/team-mfa/TeamMfaGate.tsx | 77 +++++++++++++++++++ .../src/components/team-mfa/team-mfa.test.tsx | 13 ++++ .../team-mfa/use-team-mfa-policy.ts | 70 +++++++++++++++++ src/renderer/src/i18n/locales/en.json | 3 +- 11 files changed, 238 insertions(+), 3 deletions(-) create mode 100644 src/renderer/src/components/team-mfa/TeamMfaGate.tsx create mode 100644 src/renderer/src/components/team-mfa/use-team-mfa-policy.ts diff --git a/src/main/hosted-sandbox/hosted-sandbox-config.test.ts b/src/main/hosted-sandbox/hosted-sandbox-config.test.ts index 2ff15926d7a..143960b1ab1 100644 --- a/src/main/hosted-sandbox/hosted-sandbox-config.test.ts +++ b/src/main/hosted-sandbox/hosted-sandbox-config.test.ts @@ -12,7 +12,7 @@ import { unlinkOrcaProfileFromCloud } from '../orca-profiles/cloud-auth/profile-cloud-index' import { ensureActiveOrcaProfile } from '../orca-profiles/profile-index-store' -import { getHostedSandboxConfig } from './hosted-sandbox-config' +import { getHostedSandboxConfig, isMfaRequiredRefusal } from './hosted-sandbox-config' const state = { dir: '' } installFakeAppEnvironment({ getPath: () => state.dir }) @@ -118,3 +118,21 @@ describe('getHostedSandboxConfig sandbox tokens', () => { expect(claims(await b.getAccessToken()).sub).toBe('user-b') }) }) + +describe('isMfaRequiredRefusal', () => { + it('recognizes the team MFA refusal and leaves the body readable', async () => { + const response = Response.json( + { error: 'mfa_required', orgId: 'org_1', enforceAt: 5 }, + { status: 403 } + ) + expect(await isMfaRequiredRefusal(response)).toBe(true) + expect(((await response.json()) as { orgId: string }).orgId).toBe('org_1') + }) + + it('is false for other refusals and for bodies that are not JSON', async () => { + for (const body of [{ error: 'not_a_member' }, { error: 'sandbox_not_entitled' }, {}, null]) { + expect(await isMfaRequiredRefusal(Response.json(body, { status: 403 }))).toBe(false) + } + expect(await isMfaRequiredRefusal(new Response('', { status: 403 }))).toBe(false) + }) +}) diff --git a/src/main/hosted-sandbox/hosted-sandbox-config.ts b/src/main/hosted-sandbox/hosted-sandbox-config.ts index a85ed77e2b7..973ab6596e5 100644 --- a/src/main/hosted-sandbox/hosted-sandbox-config.ts +++ b/src/main/hosted-sandbox/hosted-sandbox-config.ts @@ -22,6 +22,19 @@ export type HostedSandboxConfig = { invalidateAccessToken?: () => void } +/** `reason` on the provider error when the team's MFA policy refused the token. */ +export const MFA_REQUIRED_REASON = 'mfa_required' + +/** Reads a copy of the body, so the caller can still read the response. */ +export async function isMfaRequiredRefusal(response: Response): Promise { + try { + const body = (await response.clone().json()) as { error?: unknown } | null + return body?.error === MFA_REQUIRED_REASON + } catch { + return false + } +} + const SandboxTokenBody = z.object({ sandboxToken: z.string().min(1).max(8192), expiresAt: z.number().int().positive() @@ -161,6 +174,15 @@ async function mintSandboxToken( redirect: 'error', signal: AbortSignal.timeout(15_000) }) + if (response.status === 403 && (await isMfaRequiredRefusal(response))) { + // The team requires MFA and this person has none past the deadline. Signing in again + // would not help, so this is not a session error: the reason lets the UI show setup. + throw new EnvironmentProviderError( + 'unauthorized', + 'Your team requires two-factor authentication. Set it up to use its cloud workspaces.', + MFA_REQUIRED_REASON + ) + } if (response.status === 401 || response.status === 403) { throw new OrcaCloudRequestError(response.status) } diff --git a/src/renderer/src/components/billing/team/TeamDialogHost.tsx b/src/renderer/src/components/billing/team/TeamDialogHost.tsx index 8b17fb6737d..f84ece0adf5 100644 --- a/src/renderer/src/components/billing/team/TeamDialogHost.tsx +++ b/src/renderer/src/components/billing/team/TeamDialogHost.tsx @@ -1,3 +1,4 @@ +import { useTeamMfaGate } from '@/components/team-mfa/TeamMfaGate' import { useCallback, useEffect, useRef, useState } from 'react' import { toast } from 'sonner' import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog' @@ -36,6 +37,7 @@ export function TeamPageBody({ const cloud = authStatus?.cloud const orgId = cloud?.activeOrgId ?? null const roster = useOrgRoster(orgId) + const mfa = useTeamMfaGate() const billing = useOrcaBillingStatus(true) const [userRefreshing, setUserRefreshing] = useState(false) const [billingBusy, setBillingBusy] = useState(false) @@ -183,6 +185,7 @@ export function TeamPageBody({ <> + {props.mfaNotice ?? null}
diff --git a/src/renderer/src/components/cloud-workspaces/CloudWorkspacesHome.tsx b/src/renderer/src/components/cloud-workspaces/CloudWorkspacesHome.tsx index fb899cecf65..d2436fcea66 100644 --- a/src/renderer/src/components/cloud-workspaces/CloudWorkspacesHome.tsx +++ b/src/renderer/src/components/cloud-workspaces/CloudWorkspacesHome.tsx @@ -4,6 +4,7 @@ import { useHomeWorkspaces } from './cloud-workspace-local-titles' import React, { useCallback, useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' import { useAppStore } from '@/store' +import { useTeamMfaGate } from '@/components/team-mfa/TeamMfaGate' import { useTranslate } from '@/i18n/use-translate' import { cn } from '@/lib/class-names' import { installWindowVisibilityInterval } from '@/lib/window-activity/window-visibility-interval' @@ -121,6 +122,10 @@ export function CloudWorkspacesHome({ archivedOn: filters.archived, enabled: Boolean(cloud) }) + // The team's MFA requirement: a countdown banner before the deadline, the setup screen after. + const mfa = useTeamMfaGate({ + forceBlocked: state.status === 'error' && state.reason === 'mfa_required' + }) const showFilters = shouldShowCloudHomeFilters(all.length, filters, Boolean(orgId)) const { directory, canAdmin } = useMemberDirectory(orgId, all) const repos = useMemo( @@ -302,8 +307,17 @@ export function CloudWorkspacesHome({ archivedCount={archivedCount} /> + {mfa.banner ?
{mfa.banner}
: null} + + {mfa.blocked ? ( +
{mfa.blocked}
+ ) : null} + ) } -/** An Enterprise team's owner: the merged log, filters and export. */ +/** An Enterprise team's owner: the merged log, filters and export; single sign-on is theirs, so no upsell. */ export const auditLog = defineFixture({ width: 860, render: () => }) -/** A team whose plan does not include the audit log (the control plane answered plan-required). */ +/** A team on neither: one banner with the page's only Contact sales, and a plain line per block. */ export const upsell = defineFixture({ width: 860, render: () => @@ -102,3 +119,11 @@ export const exportTruncated = defineFixture({ ) }) + +/** Only the audit log is missing (single sign-on is included): that block keeps its own button. */ +export const upsellAuditOnly = defineFixture({ + width: 860, + render: () => ( + + ) +}) diff --git a/src/renderer/src/components/settings/enterprise/EnterpriseSettingsView.test.tsx b/src/renderer/src/components/settings/enterprise/EnterpriseSettingsView.test.tsx index c4ccd9f3d64..83d6ac31d37 100644 --- a/src/renderer/src/components/settings/enterprise/EnterpriseSettingsView.test.tsx +++ b/src/renderer/src/components/settings/enterprise/EnterpriseSettingsView.test.tsx @@ -2,12 +2,29 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import type { CloudAuditEvent } from '../../../../../shared/cloud-api/cloud-api-types' -import { EnterpriseSettingsView } from './EnterpriseSettingsView' +import { + EnterpriseSettingsView as RawView, + type EnterpriseSettingsViewProps +} from './EnterpriseSettingsView' import { FIXTURE_EVENTS, FIXTURE_NOW, fixtureAuditProps } from './enterprise-fixture-data' import { auditCategoryLabel, auditEventSentence, isKnownAuditAction } from './enterprise-audit-copy' afterEach(cleanup) +/** The view as the pane feeds it: the audit log's answer says which side of the plan the team is on. */ +function EnterpriseSettingsView( + props: Omit & { ssoIncluded?: boolean | null } +): React.JSX.Element { + const status = props.audit.log.status + const derived = status === 'ok' ? true : status === 'upsell' ? false : null + return ( + + ) +} + const EMPTY = { status: 'ok' as const, events: [], @@ -33,7 +50,7 @@ describe('audit log', () => { 'Started workspace “nightly-evals” | Workspaces | Grace Hopper via API key | 5 hours ago', 'Removed Linus Park from the team | Members | You | yesterday', 'Turned off a routine | Routines | Orca | yesterday', - 'Created API key “GitHub Actions deploy” | API keys | Linus Park · Former teammate | 2 days ago', + 'Created API key “GitHub Actions deploy” | API keys | Linus Park · Former member | 2 days ago', 'Added secret ANTHROPIC_API_KEY | Secrets | You | 3 days ago', 'Changed agent access | Settings | You | 6 days ago', 'Connected an app | Connected apps | Grace Hopper via MCP | 9 days ago' @@ -185,24 +202,42 @@ describe('audit log', () => { }) describe('without the Enterprise plan', () => { - it('shows the upsell with Contact sales and no log, filters or export', () => { + it('a team with neither gets ONE Contact sales, in a banner, and a plain line in each block', () => { const onContactSales = vi.fn() const { container } = render( ) - expect(screen.getByText('Audit log is part of Enterprise')).toBeTruthy() + expect(screen.getByText('Single sign-on and the audit log are part of Enterprise')).toBeTruthy() + // The blocks keep their titles and say what they are, without a second button. + expect(screen.getByText('Single sign-on and provisioning')).toBeTruthy() + expect(screen.getByText('Audit log')).toBeTruthy() + expect(container.textContent).toContain('so your full history is here from the day you upgrade') + expect(screen.queryByText('Audit log is part of Enterprise')).toBeNull() expect(rows(container)).toEqual([]) expect(screen.queryByRole('combobox')).toBeNull() expect(screen.queryByRole('button', { name: 'Export CSV' })).toBeNull() const contact = screen.getAllByRole('button', { name: 'Contact sales' }) - // One for single sign-on, one for the audit log. - expect(contact).toHaveLength(2) - fireEvent.click(contact[1]!) + expect(contact).toHaveLength(1) + fireEvent.click(contact[0]!) + expect(onContactSales).toHaveBeenCalledWith('https://orca.dev/enterprise') + // The Copy link chevron sits beside it (the open-in-browser rule). + expect(screen.getAllByRole('button', { name: 'More link options' })).toHaveLength(1) + }) + + it('a team missing only the audit log keeps the button on that block, with no banner', () => { + const onContactSales = vi.fn() + render( + + ) + expect(screen.queryByText('Single sign-on and the audit log are part of Enterprise')).toBeNull() + expect(screen.getByText('Audit log is part of Enterprise')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Contact sales' })) expect(onContactSales).toHaveBeenCalledWith('https://orca.dev/enterprise') - // The Copy link chevron sits beside each (the open-in-browser rule). - expect(screen.getAllByRole('button', { name: 'More link options' })).toHaveLength(2) }) it("uses billing's own plan name and contact page", () => { @@ -216,13 +251,36 @@ describe('without the Enterprise plan', () => { })} /> ) - expect(screen.getByText('Audit log is part of Scale')).toBeTruthy() - expect(screen.getByText('Single sign-on is part of Scale')).toBeTruthy() - fireEvent.click(screen.getAllByRole('button', { name: 'Contact sales' })[0]!) + expect(screen.getByText('Single sign-on and the audit log are part of Scale')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Contact sales' })) expect(onContactSales).toHaveBeenCalledWith('https://orca.dev/scale') }) }) +describe('on the Enterprise plan', () => { + it('never sends the team to sales: single sign-on is set up with us, with Contact us', () => { + const onContactSales = vi.fn() + const { container } = render( + + ) + expect(container.textContent).not.toMatch(/part of Enterprise|Contact sales/) + expect(screen.getByText('Single sign-on is set up with us')).toBeTruthy() + expect(screen.getByText('To set up or change single sign-on, contact us.')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Contact us' })) + expect(onContactSales).toHaveBeenCalledWith('https://orca.dev/enterprise') + }) + + it('says nothing about single sign-on until the plan is known', () => { + const { container } = render( + + ) + expect(container.textContent).not.toMatch(/Single sign-on|Contact/) + expect(screen.getByText('Audit log')).toBeTruthy() + }) +}) + describe('slots', () => { it('lets the SSO and policy owners replace or add their blocks, in order above the audit log', () => { const { container } = render( @@ -232,7 +290,7 @@ describe('slots', () => { policies={
Require MFA
} /> ) - expect(screen.queryByText('Single sign-on is part of Enterprise')).toBeNull() + expect(screen.queryByText('Single sign-on is set up with us')).toBeNull() const order = [...container.querySelectorAll('[id^="cloud-enterprise-"]')].map( (node) => node.id ) diff --git a/src/renderer/src/components/settings/enterprise/EnterpriseSettingsView.tsx b/src/renderer/src/components/settings/enterprise/EnterpriseSettingsView.tsx index 32c18f36ce4..b6ce0ee72ea 100644 --- a/src/renderer/src/components/settings/enterprise/EnterpriseSettingsView.tsx +++ b/src/renderer/src/components/settings/enterprise/EnterpriseSettingsView.tsx @@ -2,13 +2,18 @@ // team's sign-in policies, and the audit log. Each block shows its own controls when the team's // plan includes it and the shared upsell (EnterpriseUpsell) when it does not. // +// Contact sales appears once per page: a team with neither single sign-on nor the audit log gets +// one banner at the top and a plain line in each block. A team on Enterprise is never sent to sales. +// // Slots for the blocks other owners build (pass a node; nothing renders until one is passed): -// singleSignOn replaces the SSO and directory sync upsell (sso-scim) +// singleSignOn replaces the built-in single sign-on block (sso-scim) // policies sign-in policies, e.g. "Require MFA" (mfa) +import { ExternalLink } from 'lucide-react' +import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton' import { useTranslate } from '@/i18n/use-translate' import { SettingsGroup } from '../section-layout/SettingsGroup' import { EnterpriseAuditLog, type EnterpriseAuditLogProps } from './EnterpriseAuditLog' -import { EnterpriseUpsell } from './EnterpriseUpsell' +import { EnterpriseUpsell, EnterpriseUpsellLine } from './EnterpriseUpsell' export const ENTERPRISE_SSO_ANCHOR = 'cloud-enterprise-sso' export const ENTERPRISE_POLICIES_ANCHOR = 'cloud-enterprise-policies' @@ -16,42 +21,102 @@ export const ENTERPRISE_AUDIT_LOG_ANCHOR = 'cloud-enterprise-audit-log' export type EnterpriseSettingsViewProps = { audit: EnterpriseAuditLogProps + /** + * Whether the team's plan includes single sign-on: true, false, or null while that is not known + * yet (the block waits rather than guess). Ignored once `singleSignOn` is passed. + */ + ssoIncluded: boolean | null singleSignOn?: React.ReactNode policies?: React.ReactNode } export function EnterpriseSettingsView({ audit, + ssoIncluded, singleSignOn, policies }: EnterpriseSettingsViewProps): React.JSX.Element { const t = useTranslate() + const { contact, onContactSales } = audit + const ssoLocked = singleSignOn === undefined && ssoIncluded === false + const auditLocked = audit.log.status === 'upsell' + // Neither: one banner carries the page's only Contact sales. + const banner = ssoLocked && auditLocked + const ssoUpsellBody = t( + 'settings.enterprise.sso.upsellBody', + 'Sign in with your identity provider over SAML, and add or remove teammates with SCIM.' + ) return (
-
- {singleSignOn ?? ( + {banner ? ( + + + + ) : null} + {singleSignOn !== undefined ? ( +
{singleSignOn}
+ ) : ssoIncluded === null ? null : ( +
- + {ssoIncluded ? ( + // On the plan: never an upsell. We set it up with the team until they can do it here. +
+
+

+ {t('settings.enterprise.sso.managedTitle', 'Single sign-on is set up with us')} +

+

+ {t( + 'settings.enterprise.sso.managedBody', + 'To set up or change single sign-on, contact us.' + )} +

+
+ onContactSales(contact.contactUrl)} + > + {t('settings.enterprise.contactUs', 'Contact us')} + + +
+ ) : banner ? ( + {ssoUpsellBody} + ) : ( + + )}
- )} -
+
+ )} {policies ?
{policies}
: null}
- +
) diff --git a/src/renderer/src/components/settings/enterprise/EnterpriseUpsell.tsx b/src/renderer/src/components/settings/enterprise/EnterpriseUpsell.tsx index a01588bbeb1..4f85dd14f16 100644 --- a/src/renderer/src/components/settings/enterprise/EnterpriseUpsell.tsx +++ b/src/renderer/src/components/settings/enterprise/EnterpriseUpsell.tsx @@ -41,3 +41,16 @@ export function EnterpriseUpsell({
) } + +/** The locked state without a button, for a block under the page banner that carries Contact sales. */ +export function EnterpriseUpsellLine({ + children +}: { + children: React.ReactNode +}): React.JSX.Element { + return ( +

+ {children} +

+ ) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 88851e7e922..da83ca7e9ad 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1486,8 +1486,7 @@ "youViaAgent": "Your agent", "personViaAgent": "’s agent", "youViaRoutine": "Your routine", - "personViaRoutine": "’s routine", - "former": "Former teammate" + "personViaRoutine": "’s routine" }, "filter": { "categoryLabel": "Filter by activity", @@ -1499,7 +1498,7 @@ "description": "Member, key, secret, workspace and settings changes across your team. Only owners and admins can see it.", "export": "Export CSV", "upsellTitle": "Audit log is part of {{plan}}", - "upsellBody": "Orca records your team’s changes on every plan, so your history is here from the day you upgrade.", + "upsellBody": "Orca records your team’s changes on every plan, so your full history is here from the day you upgrade.", "loading": "Loading the audit log", "exportTruncated": "The file has the newest 5,000 events. Filter by activity or person to export the rest.", "emptyFilteredTitle": "Nothing matches", @@ -1581,7 +1580,9 @@ }, "sso": { "upsellTitle": "Single sign-on is part of {{plan}}", - "upsellBody": "Sign in with your identity provider over SAML, and add or remove teammates with SCIM." + "upsellBody": "Sign in with your identity provider over SAML, and add or remove teammates with SCIM.", + "managedTitle": "Single sign-on is set up with us", + "managedBody": "To set up or change single sign-on, contact us." }, "title": "Enterprise", "searchDescription": "Single sign-on and directory sync, and the audit log of changes across your team.", @@ -1591,7 +1592,10 @@ "keywordSaml": "saml", "keywordScim": "scim", "keywordCompliance": "compliance", - "description": "Single sign-on, sign-in policies and the audit log for your team." + "description": "Single sign-on, sign-in policies and the audit log for your team.", + "bannerTitle": "Single sign-on and the audit log are part of {{plan}}", + "bannerBody": "Talk to us about moving your team to {{plan}}.", + "contactUs": "Contact us" } }, "menu": { @@ -9225,7 +9229,7 @@ "network": "Couldn’t reach Orca Cloud. Check your connection and try again.", "invalidResponse": "Orca Cloud sent an answer this version of Orca does not understand. Update Orca and try again.", "server": "Orca Cloud had a problem and nothing was changed. Try again in a moment.", - "planRequired": "Your team’s plan does not include this." + "planRequired": "Your team’s plan doesn’t include this." }, "scope": { "workspacesRead": "List and inspect workspaces", From f8d8f301a5472a99fec37fcb92d5c9eea41c53f8 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 20:49:50 -0800 Subject: [PATCH 16/50] feat(cloud): app-level MFA banner in the main window; setup screen where a team resource is refused; no edits to the Home list --- .../src/app-shell/AppWorkspaceShell.tsx | 4 ++ .../cloud-workspaces/CloudWorkspacesHome.tsx | 14 ------ .../CloudWorkspacesStates.tsx | 13 +++++- .../components/team-mfa/TeamMfa.fixture.tsx | 11 +++++ .../components/team-mfa/TeamMfaAppBanner.tsx | 19 ++++++++ .../src/components/team-mfa/TeamMfaBanner.tsx | 45 ++++++++++++++----- .../src/components/team-mfa/TeamMfaGate.tsx | 30 +++++++------ .../src/components/team-mfa/team-mfa.test.tsx | 20 +++++++++ src/renderer/src/i18n/locales/en.json | 2 + 9 files changed, 117 insertions(+), 41 deletions(-) create mode 100644 src/renderer/src/components/team-mfa/TeamMfaAppBanner.tsx diff --git a/src/renderer/src/app-shell/AppWorkspaceShell.tsx b/src/renderer/src/app-shell/AppWorkspaceShell.tsx index d3b03e2b370..7e56d82cf53 100644 --- a/src/renderer/src/app-shell/AppWorkspaceShell.tsx +++ b/src/renderer/src/app-shell/AppWorkspaceShell.tsx @@ -11,6 +11,7 @@ import { RecoverableRenderErrorBoundary } from '../components/error-boundaries/R import { FloatingTerminalToggleButton } from '../components/floating-terminal/FloatingTerminalToggleButton' import { TerminalWorkbenchContainer } from '../components/TerminalWorkbenchContainer' import { WorkspaceLowDiskBanner } from '../components/low-disk/WorkspaceLowDiskBanner' +import { TeamMfaAppBanner } from '../components/team-mfa/TeamMfaAppBanner' import { CloudSetupPageLayer } from '../components/orca-cloud-setup/CloudSetupPageLayer' import { CloudOpenCoverLayer } from '../components/worktree-creation/CloudOpenCoverLayer' import type { VirtualizedScrollAnchor } from '../hooks/useVirtualizedScrollAnchor' @@ -213,6 +214,9 @@ export function AppWorkspaceShell(props: { )}
+ {/* The active team requires MFA and this person has none: a countdown, then + "no access". Renders nothing for almost everyone. */} + {layout.shouldMountTerminalWorkbench ? ( diff --git a/src/renderer/src/components/cloud-workspaces/CloudWorkspacesHome.tsx b/src/renderer/src/components/cloud-workspaces/CloudWorkspacesHome.tsx index d2436fcea66..fb899cecf65 100644 --- a/src/renderer/src/components/cloud-workspaces/CloudWorkspacesHome.tsx +++ b/src/renderer/src/components/cloud-workspaces/CloudWorkspacesHome.tsx @@ -4,7 +4,6 @@ import { useHomeWorkspaces } from './cloud-workspace-local-titles' import React, { useCallback, useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' import { useAppStore } from '@/store' -import { useTeamMfaGate } from '@/components/team-mfa/TeamMfaGate' import { useTranslate } from '@/i18n/use-translate' import { cn } from '@/lib/class-names' import { installWindowVisibilityInterval } from '@/lib/window-activity/window-visibility-interval' @@ -122,10 +121,6 @@ export function CloudWorkspacesHome({ archivedOn: filters.archived, enabled: Boolean(cloud) }) - // The team's MFA requirement: a countdown banner before the deadline, the setup screen after. - const mfa = useTeamMfaGate({ - forceBlocked: state.status === 'error' && state.reason === 'mfa_required' - }) const showFilters = shouldShowCloudHomeFilters(all.length, filters, Boolean(orgId)) const { directory, canAdmin } = useMemberDirectory(orgId, all) const repos = useMemo( @@ -307,17 +302,8 @@ export function CloudWorkspacesHome({ archivedCount={archivedCount} /> - {mfa.banner ?
{mfa.banner}
: null} - - {mfa.blocked ? ( -
{mfa.blocked}
- ) : null} - )}
- {/* The active team requires MFA and this person has none: a countdown, then - "no access". Renders nothing for almost everyone. */} - {layout.shouldMountTerminalWorkbench ? ( diff --git a/src/renderer/src/components/sidebar/index.tsx b/src/renderer/src/components/sidebar/index.tsx index 829cc4890ad..5b22a0a219c 100644 --- a/src/renderer/src/components/sidebar/index.tsx +++ b/src/renderer/src/components/sidebar/index.tsx @@ -22,6 +22,7 @@ import { lazyWithRetry } from '@/app-shell/startup/lazy-with-retry' import { LocalGitToolchainScanBanner } from './LocalGitToolchainScanBanner' import { SeveredTerminalDaemonBanner } from './SeveredTerminalDaemonBanner' import { StalledTerminalDaemonBanner } from './StalledTerminalDaemonBanner' +import { TeamMfaSidebarBanner } from '@/components/team-mfa/TeamMfaAppBanner' import { StartupWorktreeRefreshStatus } from './StartupWorktreeRefreshStatus' // Load the shared Activity badge/list pipeline after the sidebar shell. @@ -193,6 +194,7 @@ function Sidebar({ /> + : null} > diff --git a/src/renderer/src/components/team-mfa/TeamMfa.fixture.tsx b/src/renderer/src/components/team-mfa/TeamMfa.fixture.tsx index eb5207f10d7..61e8216329e 100644 --- a/src/renderer/src/components/team-mfa/TeamMfa.fixture.tsx +++ b/src/renderer/src/components/team-mfa/TeamMfa.fixture.tsx @@ -37,17 +37,32 @@ const banner = (daysLeft: number, frameWidth = width) => export const bannerTwoWeeks = banner(14) export const bannerOneDay = banner(1) export const bannerNarrow = banner(6, 420) -/** The app-level line once the deadline has passed. */ -export const bannerBlocked = frame( - -) +// ---- Sidebar banner stack: the app-level reminder, at sidebar widths ---- + +const sidebar = (daysLeft: number, sidebarWidth: number) => + defineFixture({ + width: sidebarWidth, + render: () => ( +
+ +
+ ) + }) + +export const sidebarTwoWeeks = sidebar(14, 280) +export const sidebarOneDay = sidebar(1, 280) +/** After the deadline: no access until set up. */ +export const sidebarBlocked = sidebar(-2, 280) +/** The narrowest sidebar. */ +export const sidebarBlockedNarrow = sidebar(-2, 220) // ---- Blocked screen: after the deadline ---- diff --git a/src/renderer/src/components/team-mfa/TeamMfaAppBanner.tsx b/src/renderer/src/components/team-mfa/TeamMfaAppBanner.tsx index e9cb6df591a..a7b881aaab3 100644 --- a/src/renderer/src/components/team-mfa/TeamMfaAppBanner.tsx +++ b/src/renderer/src/components/team-mfa/TeamMfaAppBanner.tsx @@ -1,13 +1,16 @@ import { useTeamMfaGate } from './TeamMfaGate' /** - * The app-level MFA notice for the active team: a countdown while there is still time, and "no - * access until you set it up" after the deadline. Renders nothing for a personal account, a team - * with no policy, or a member who already has MFA, which is almost everyone. + * The MFA notice in the sidebar's banner stack, for the active team: a countdown while there is + * still time, and "no access until you set it up" after the deadline. Renders nothing for a + * personal account, a team with no policy, or a member who already has MFA, which is almost + * everyone. + * + * The sidebar can be collapsed, so this is a reminder, not the enforcement surface: a refused + * team resource shows TeamMfaRefused in its own place whether or not the sidebar is visible. */ -export function TeamMfaAppBanner({ className }: { className?: string }): React.JSX.Element | null { - const { appBanner } = useTeamMfaGate() - return appBanner ?
{appBanner}
: null +export function TeamMfaSidebarBanner(): React.JSX.Element | null { + return useTeamMfaGate().sidebarBanner } /** diff --git a/src/renderer/src/components/team-mfa/TeamMfaBanner.tsx b/src/renderer/src/components/team-mfa/TeamMfaBanner.tsx index 6958c788852..6f6fa2055e3 100644 --- a/src/renderer/src/components/team-mfa/TeamMfaBanner.tsx +++ b/src/renderer/src/components/team-mfa/TeamMfaBanner.tsx @@ -1,4 +1,6 @@ +import { ShieldAlert } from 'lucide-react' import { InlineError } from '@/components/ui/inline-error' +import { cn } from '@/lib/class-names' import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton' import { useIntlLocale, useTranslate } from '@/i18n/use-translate' import { mfaCountdownLabel, mfaDeadlineDate } from './team-mfa-copy' @@ -12,6 +14,8 @@ export type TeamMfaBannerProps = { onSetUp: () => void /** The deadline has passed and the team is closed to this person until they set MFA up. */ blocked?: boolean + /** `sidebar`: the narrow stacked layout of the sidebar's banner stack. */ + variant?: 'inline' | 'sidebar' className?: string } @@ -26,44 +30,74 @@ export function TeamMfaBanner({ setupUrl, onSetUp, blocked = false, + variant = 'inline', className }: TeamMfaBannerProps): React.JSX.Element { const t = useTranslate() const locale = useIntlLocale() + const title = blocked + ? t( + 'auto.components.teamMfa.bannerBlockedTitle', + '{{value0}} requires two-factor authentication', + { value0: teamName } + ) + : t( + 'auto.components.teamMfa.bannerTitle', + '{{value0}} requires two-factor authentication. {{value1}}.', + { value0: teamName, value1: mfaCountdownLabel(enforceAt, now, t) } + ) + const detail = blocked + ? t( + 'auto.components.teamMfa.bannerBlockedBody', + 'You can’t use this team’s workspaces until you set it up. Your personal account is not affected.' + ) + : t( + 'auto.components.teamMfa.bannerBody', + 'Set it up by {{value0}}. After that you can’t use this team’s workspaces until you do.', + { value0: mfaDeadlineDate(enforceAt, locale) } + ) + const action = ( + + {t('auto.components.teamMfa.setUp', 'Set up MFA')} + + ) + if (variant === 'sidebar') { + // Same shape as the other banners in the sidebar stack (StalledTerminalDaemonBanner): the + // column is narrow, so the action sits under the text. + return ( +
+
+
+
+ ) + } return ( - {t('auto.components.teamMfa.setUp', 'Set up MFA')} - - } + title={title} + detail={detail} + action={action} /> ) } diff --git a/src/renderer/src/components/team-mfa/TeamMfaGate.tsx b/src/renderer/src/components/team-mfa/TeamMfaGate.tsx index fada7961372..21c4ab21f48 100644 --- a/src/renderer/src/components/team-mfa/TeamMfaGate.tsx +++ b/src/renderer/src/components/team-mfa/TeamMfaGate.tsx @@ -19,8 +19,8 @@ import { useTeamMfaPolicy } from './use-team-mfa-policy' export function useTeamMfaGate(options: { forceBlocked?: boolean } = {}): { banner: React.JSX.Element | null blocked: React.JSX.Element | null - /** One line for the app-level notice: the countdown, or "no access" once blocked. */ - appBanner: React.JSX.Element | null + /** The sidebar notice: the countdown, or "no access" once blocked. */ + sidebarBanner: React.JSX.Element | null } { const cloud = useAppStore((state) => state.orcaProfileAuthStatus?.cloud) const selectOrg = useAppStore((state) => state.selectOrcaProfileOrg) @@ -31,7 +31,7 @@ export function useTeamMfaGate(options: { forceBlocked?: boolean } = {}): { const [stillMissing, setStillMissing] = useState(false) if (!orgId || !policy?.policy) { - return { banner: null, blocked: null, appBanner: null } + return { banner: null, blocked: null, sidebarBanner: null } } const surface = teamMfaSurface(policy) const openSetup = (): void => { @@ -41,8 +41,9 @@ export function useTeamMfaGate(options: { forceBlocked?: boolean } = {}): { // The server's refusal wins over a policy read that has not caught up, unless that read now // says the member has MFA (they just set it up; the next request will go through). const isBlocked = surface === 'blocked' || (options.forceBlocked === true && policy.self !== 'on') - const line = (blocked: boolean): React.JSX.Element => ( + const line = (blocked: boolean, variant: 'inline' | 'sidebar' = 'inline'): React.JSX.Element => ( { setupUrl={setupUrl} onSetUp={() => {}} blocked + variant="sidebar" /> ) + // An alert once blocked; a quiet status while there is still time. + expect(container.querySelector('[data-team-mfa-banner="blocked"]')?.getAttribute('role')).toBe( + 'alert' + ) expect(container.textContent).toContain('Acme requires two-factor authentication') expect(container.textContent).toContain('until you set it up') expect(container.textContent).toContain('Your personal account is not affected') From a99ae6d512e1b34150fba2de7efdda287fb25548 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 20:52:30 -0800 Subject: [PATCH 18/50] fix(cloud): ENTERPRISE-AUDIT lint gates: fixture data filename, no cast in the category filter, fixed clock in the pane test --- .../settings/cloud-api/CloudApiKeysPane.test.tsx | 10 ++++++++-- .../settings/enterprise/EnterpriseAuditLog.tsx | 6 ++++-- .../enterprise/EnterpriseSettingsView.fixture.tsx | 2 +- .../enterprise/EnterpriseSettingsView.test.tsx | 2 +- ...se-fixture-data.ts => enterprise-audit.fixtures.ts} | 0 5 files changed, 14 insertions(+), 6 deletions(-) rename src/renderer/src/components/settings/enterprise/{enterprise-fixture-data.ts => enterprise-audit.fixtures.ts} (100%) diff --git a/src/renderer/src/components/settings/cloud-api/CloudApiKeysPane.test.tsx b/src/renderer/src/components/settings/cloud-api/CloudApiKeysPane.test.tsx index 903ce1ff0d6..c99a44680f2 100644 --- a/src/renderer/src/components/settings/cloud-api/CloudApiKeysPane.test.tsx +++ b/src/renderer/src/components/settings/cloud-api/CloudApiKeysPane.test.tsx @@ -3,7 +3,7 @@ // Enterprise audit log now, so this pane must never call an audit route (the control plane // refuses those without the entitlement) and must never show an error because of one. import { cleanup, render, screen, waitFor } from '@testing-library/react' -import { afterEach, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' import type { CloudApiKeyList, CloudApiMe, @@ -12,12 +12,18 @@ import type { import { TooltipProvider } from '@/components/ui/tooltip' import { CloudApiKeysPane } from './CloudApiKeysPane' +beforeEach(() => { + vi.useFakeTimers({ now: NOW, toFake: ['Date'] }) +}) + afterEach(() => { cleanup() + vi.useRealTimers() vi.unstubAllGlobals() }) -const NOW = Date.now() +// A fixed clock, so "5 minutes ago" never depends on when the test runs. +const NOW = Date.UTC(2026, 10, 12, 17, 30) const ME: CloudApiMe = { accountId: 'org:acme', userId: 'u_ada', diff --git a/src/renderer/src/components/settings/enterprise/EnterpriseAuditLog.tsx b/src/renderer/src/components/settings/enterprise/EnterpriseAuditLog.tsx index 5a7d950a4d2..a93d6afb4df 100644 --- a/src/renderer/src/components/settings/enterprise/EnterpriseAuditLog.tsx +++ b/src/renderer/src/components/settings/enterprise/EnterpriseAuditLog.tsx @@ -158,8 +158,10 @@ function Filters({ value={filters.category ?? ALL} disabled={disabled} onValueChange={(value) => - // SAFETY: every option's value is ALL or a member of CLOUD_AUDIT_CATEGORIES. - onFilter({ ...filters, category: value === ALL ? null : (value as CloudAuditCategory) }) + onFilter({ + ...filters, + category: CLOUD_AUDIT_CATEGORIES.find((category) => category === value) ?? null + }) } > Date: Thu, 12 Nov 2026 20:53:17 -0800 Subject: [PATCH 19/50] feat(cloud): MFA notice escalates in the last three days, a one-time toast per deadline, badge in the members list --- .../billing/team/TeamMemberList.test.tsx | 2 + .../billing/team/TeamMemberList.tsx | 12 +-- .../components/team-mfa/TeamMfa.fixture.tsx | 22 ++++++ .../components/team-mfa/TeamMfaAppBanner.tsx | 55 +++++++++++++- .../src/components/team-mfa/TeamMfaBanner.tsx | 35 +++++++-- .../src/components/team-mfa/TeamMfaGate.tsx | 8 +- .../src/components/team-mfa/team-mfa-copy.ts | 57 ++++++++++++++ .../src/components/team-mfa/team-mfa.test.tsx | 75 ++++++++++++++++++- src/renderer/src/i18n/locales/en.json | 4 + 9 files changed, 252 insertions(+), 18 deletions(-) diff --git a/src/renderer/src/components/billing/team/TeamMemberList.test.tsx b/src/renderer/src/components/billing/team/TeamMemberList.test.tsx index 8448c9c81e6..961ad751716 100644 --- a/src/renderer/src/components/billing/team/TeamMemberList.test.tsx +++ b/src/renderer/src/components/billing/team/TeamMemberList.test.tsx @@ -81,6 +81,8 @@ it.each(['owner', 'admin'] as const)('shows the MFA column to an %s', (viewerRol container.querySelector(`[data-team-row="${key}"] [data-team-mfa]`)! expect(cell('member:u-neil').textContent).toBe('MFA on') expect(cell('member:u-grace').textContent).toBe('MFA due Nov 24') + // A small muted badge, not bare text. + expect(cell('member:u-neil').querySelector('[data-slot="badge"]')).not.toBeNull() // Invites keep an empty cell so the role column stays aligned. expect(cell('invite:alex@acme.dev').textContent).toBe('') }) diff --git a/src/renderer/src/components/billing/team/TeamMemberList.tsx b/src/renderer/src/components/billing/team/TeamMemberList.tsx index b464697560e..822d366283c 100644 --- a/src/renderer/src/components/billing/team/TeamMemberList.tsx +++ b/src/renderer/src/components/billing/team/TeamMemberList.tsx @@ -25,7 +25,6 @@ import { SelectValue } from '@/components/ui/select' import { LoadingSpinner } from '@/components/LoadingSpinner' -import { cn } from '@/lib/class-names' import { useIntlLocale, useTranslate } from '@/i18n/use-translate' import type { OrcaOrgMember, OrcaOrgRole } from '../../../../../shared/orca-profiles' import { PersonAvatar } from './TeamAvatar' @@ -307,14 +306,11 @@ export function TeamMemberList(props: TeamMemberListProps): React.JSX.Element { // Rendered for every row, invites included, so the role column stays aligned. - {row.kind === 'member' ? memberMfaLabel(row.member, locale, t) : null} + {row.kind === 'member' && memberMfaLabel(row.member, locale, t) ? ( + {memberMfaLabel(row.member, locale, t)} + ) : null} ) : null} {row.kind === 'member' ? ( diff --git a/src/renderer/src/components/team-mfa/TeamMfa.fixture.tsx b/src/renderer/src/components/team-mfa/TeamMfa.fixture.tsx index 61e8216329e..fd4509e29fe 100644 --- a/src/renderer/src/components/team-mfa/TeamMfa.fixture.tsx +++ b/src/renderer/src/components/team-mfa/TeamMfa.fixture.tsx @@ -3,6 +3,7 @@ import type { OrcaTeamMfaPendingMember, OrcaTeamMfaPolicy } from '../../../../shared/orca-team-mfa-policy' +import { CloudToastCard } from '@/components/orca-cloud/cloud-toast-stack' import { RequireMfaRow, type RequireMfaRowProps } from './RequireMfaRow' import { TeamMfaBanner } from './TeamMfaBanner' import { TeamMfaBlockedScreen } from './TeamMfaBlockedScreen' @@ -58,12 +59,33 @@ const sidebar = (daysLeft: number, sidebarWidth: number) => }) export const sidebarTwoWeeks = sidebar(14, 280) +/** Three days or fewer: the warning look. */ +export const sidebarThreeDays = sidebar(3, 280) export const sidebarOneDay = sidebar(1, 280) /** After the deadline: no access until set up. */ export const sidebarBlocked = sidebar(-2, 280) /** The narrowest sidebar. */ export const sidebarBlockedNarrow = sidebar(-2, 220) +// ---- The one-time toast, for people who keep the sidebar collapsed ---- + +export const toast = frame( + , + 420 +) + // ---- Blocked screen: after the deadline ---- const blocked = (props: { checking?: boolean; stillMissing?: boolean; personal?: boolean }) => diff --git a/src/renderer/src/components/team-mfa/TeamMfaAppBanner.tsx b/src/renderer/src/components/team-mfa/TeamMfaAppBanner.tsx index a7b881aaab3..f2d02d290be 100644 --- a/src/renderer/src/components/team-mfa/TeamMfaAppBanner.tsx +++ b/src/renderer/src/components/team-mfa/TeamMfaAppBanner.tsx @@ -1,3 +1,7 @@ +import { useEffect } from 'react' +import { hideCloudToast, showCloudToast } from '@/components/orca-cloud/cloud-toast-stack' +import { useIntlLocale, useTranslate } from '@/i18n/use-translate' +import { claimMfaToast, mfaDeadlineDate } from './team-mfa-copy' import { useTeamMfaGate } from './TeamMfaGate' /** @@ -10,7 +14,56 @@ import { useTeamMfaGate } from './TeamMfaGate' * team resource shows TeamMfaRefused in its own place whether or not the sidebar is visible. */ export function TeamMfaSidebarBanner(): React.JSX.Element | null { - return useTeamMfaGate().sidebarBanner + const gate = useTeamMfaGate() + const t = useTranslate() + const locale = useIntlLocale() + const toast = gate.pendingToast + const orgId = toast?.orgId + const enforceAt = toast?.enforceAt + // One toast per team and deadline, for people who keep the sidebar collapsed. Not tied to any + // workspace, so it shows wherever they are; the sidebar card stays as the lasting reminder. + useEffect(() => { + if (!toast || orgId === undefined || enforceAt === undefined) { + return + } + if (!claimMfaToast(readLocalStorage(), orgId, enforceAt)) { + return + } + const id = `team-mfa:${orgId}` + showCloudToast(id, { + priority: 'action', + lines: [ + { + key: 'mfa', + text: t('auto.components.teamMfa.toast', '{{value0}} requires MFA from {{value1}}.', { + value0: toast.teamName, + value1: mfaDeadlineDate(enforceAt, locale) + }), + action: { + label: t('auto.components.teamMfa.setUp', 'Set up MFA'), + primary: true, + // Opens the account page in the browser, so the chevron offers Copy link. + link: toast.setupUrl, + onClick: () => { + void window.api.shell.openUrl(toast.setupUrl) + hideCloudToast(id) + } + } + } + ] + }) + // Deliberately keyed on the team and deadline only: copy or locale changes must not re-show. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [orgId, enforceAt]) + return gate.sidebarBanner +} + +function readLocalStorage(): Storage | null { + try { + return window.localStorage + } catch { + return null + } } /** diff --git a/src/renderer/src/components/team-mfa/TeamMfaBanner.tsx b/src/renderer/src/components/team-mfa/TeamMfaBanner.tsx index 6f6fa2055e3..cd03511e892 100644 --- a/src/renderer/src/components/team-mfa/TeamMfaBanner.tsx +++ b/src/renderer/src/components/team-mfa/TeamMfaBanner.tsx @@ -3,7 +3,8 @@ import { InlineError } from '@/components/ui/inline-error' import { cn } from '@/lib/class-names' import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton' import { useIntlLocale, useTranslate } from '@/i18n/use-translate' -import { mfaCountdownLabel, mfaDeadlineDate } from './team-mfa-copy' +import { BANNER_BODY_TEXT, BANNER_TONE } from '@/components/ui/banner-tone' +import { mfaCountdownLabel, mfaDeadlineDate, mfaSoonTitle, mfaUrgency } from './team-mfa-copy' export type TeamMfaBannerProps = { teamName: string @@ -35,7 +36,10 @@ export function TeamMfaBanner({ }: TeamMfaBannerProps): React.JSX.Element { const t = useTranslate() const locale = useIntlLocale() - const title = blocked + const urgency = mfaUrgency(enforceAt, now, blocked) + const title = urgency === 'soon' + ? mfaSoonTitle(teamName, enforceAt, now, t) + : blocked ? t( 'auto.components.teamMfa.bannerBlockedTitle', '{{value0}} requires two-factor authentication', @@ -67,10 +71,16 @@ export function TeamMfaBanner({ return (
@@ -78,13 +88,24 @@ export function TeamMfaBanner({
diff --git a/src/renderer/src/components/team-mfa/TeamMfaGate.tsx b/src/renderer/src/components/team-mfa/TeamMfaGate.tsx index 21c4ab21f48..b7cdf9657ef 100644 --- a/src/renderer/src/components/team-mfa/TeamMfaGate.tsx +++ b/src/renderer/src/components/team-mfa/TeamMfaGate.tsx @@ -21,6 +21,8 @@ export function useTeamMfaGate(options: { forceBlocked?: boolean } = {}): { blocked: React.JSX.Element | null /** The sidebar notice: the countdown, or "no access" once blocked. */ sidebarBanner: React.JSX.Element | null + /** Set while the member still has time: what the one-time toast needs. */ + pendingToast: { orgId: string; teamName: string; enforceAt: number; setupUrl: string } | null } { const cloud = useAppStore((state) => state.orcaProfileAuthStatus?.cloud) const selectOrg = useAppStore((state) => state.selectOrcaProfileOrg) @@ -31,7 +33,7 @@ export function useTeamMfaGate(options: { forceBlocked?: boolean } = {}): { const [stillMissing, setStillMissing] = useState(false) if (!orgId || !policy?.policy) { - return { banner: null, blocked: null, sidebarBanner: null } + return { banner: null, blocked: null, sidebarBanner: null, pendingToast: null } } const surface = teamMfaSurface(policy) const openSetup = (): void => { @@ -56,6 +58,7 @@ export function useTeamMfaGate(options: { forceBlocked?: boolean } = {}): { return { banner: null, sidebarBanner: line(true, 'sidebar'), + pendingToast: null, blocked: ( | null, + orgId: string, + enforceAt: number +): boolean { + const key = mfaToastSeenKey(orgId, enforceAt) + try { + if (storage?.getItem(key) === '1') { + return false + } + storage?.setItem(key, '1') + } catch { + // Private mode or a full quota: showing it again next launch is the lesser problem. + } + return true +} + /** "6 days left", "1 day left", "Due now". i18next plurals, never ternaries on the count. */ export function mfaCountdownLabel(enforceAt: number, now: number, t: Translate): string { const days = mfaDaysLeft(enforceAt, now) diff --git a/src/renderer/src/components/team-mfa/team-mfa.test.tsx b/src/renderer/src/components/team-mfa/team-mfa.test.tsx index 482b037ca01..6a07012637c 100644 --- a/src/renderer/src/components/team-mfa/team-mfa.test.tsx +++ b/src/renderer/src/components/team-mfa/team-mfa.test.tsx @@ -6,7 +6,14 @@ import type { OrcaTeamMfaPolicy } from '../../../../shared/orca-team-mfa-policy' import { RequireMfaRow, type RequireMfaRowProps } from './RequireMfaRow' import { TeamMfaBanner } from './TeamMfaBanner' import { TeamMfaBlockedScreen } from './TeamMfaBlockedScreen' -import { mfaCountdownLabel, mfaDaysLeft, teamMfaSurface } from './team-mfa-copy' +import { + claimMfaToast, + mfaCountdownLabel, + mfaDaysLeft, + mfaSoonTitle, + mfaUrgency, + teamMfaSurface +} from './team-mfa-copy' import { describeError, describeInlineError } from '../cloud-workspaces/cloud-workspace-errors' afterEach(cleanup) @@ -239,3 +246,69 @@ describe('banner after the deadline', () => { expect(getByRole('button', { name: 'Set up MFA' })).toBeTruthy() }) }) + +describe('escalation', () => { + it('is quiet with more than three days left, a warning in the last three, blocked on the server', () => { + expect(mfaUrgency(NOW + 14 * DAY, NOW, false)).toBe('quiet') + expect(mfaUrgency(NOW + 3 * DAY + 1, NOW, false)).toBe('quiet') + expect(mfaUrgency(NOW + 3 * DAY, NOW, false)).toBe('soon') + expect(mfaUrgency(NOW + DAY, NOW, false)).toBe('soon') + // Past the deadline by the clock but not blocked by the server: still only a warning. + expect(mfaUrgency(NOW - DAY, NOW, false)).toBe('soon') + expect(mfaUrgency(NOW + 14 * DAY, NOW, true)).toBe('blocked') + }) + + it('names the days in the title once it is close', () => { + expect(mfaSoonTitle('Acme', NOW + 2 * DAY, NOW, translate)).toBe('Acme requires MFA in 2 days') + expect(mfaSoonTitle('Acme', NOW + DAY, NOW, translate)).toBe('Acme requires MFA in 1 day') + expect(mfaSoonTitle('Acme', NOW - 1, NOW, translate)).toBe('Acme requires MFA now') + }) + + it('marks the sidebar card with its urgency', () => { + const card = (daysLeft: number, blocked = false): string | null | undefined => + render( + {}} + blocked={blocked} + /> + ) + .container.querySelector('[data-team-mfa-banner]') + ?.getAttribute('data-team-mfa-banner') + expect(card(14)).toBe('quiet') + cleanup() + expect(card(2)).toBe('soon') + cleanup() + expect(card(-2, true)).toBe('blocked') + }) +}) + +describe('one-time toast', () => { + const memory = (): Pick => { + const map = new Map() + return { getItem: (key) => map.get(key) ?? null, setItem: (key, value) => void map.set(key, value) } + } + + it('shows once per team and deadline, and again when the date changes', () => { + const storage = memory() + expect(claimMfaToast(storage, 'org_1', 100)).toBe(true) + expect(claimMfaToast(storage, 'org_1', 100)).toBe(false) + expect(claimMfaToast(storage, 'org_1', 200)).toBe(true) + expect(claimMfaToast(storage, 'org_2', 100)).toBe(true) + }) + + it('shows rather than hides when storage is unavailable', () => { + expect(claimMfaToast(null, 'org_1', 100)).toBe(true) + const broken = { + getItem: () => { + throw new Error('denied') + }, + setItem: () => {} + } + expect(claimMfaToast(broken, 'org_1', 100)).toBe(true) + }) +}) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 8a8737a2325..817cc60f274 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -21712,6 +21712,10 @@ "bannerTitle": "{{value0}} requires two-factor authentication. {{value1}}.", "bannerBody": "Set it up by {{value0}}. After that you can’t use this team’s workspaces until you do.", "bannerBlockedTitle": "{{value0}} requires two-factor authentication", + "soonTitleNow": "{{value0}} requires MFA now", + "soonTitle_one": "{{value0}} requires MFA in {{count}} day", + "soonTitle_other": "{{value0}} requires MFA in {{count}} days", + "toast": "{{value0}} requires MFA from {{value1}}.", "bannerBlockedBody": "You can’t use this team’s workspaces until you set it up. Your personal account is not affected.", "setUp": "Set up MFA", "blockedTitle": "Set up two-factor authentication to use {{value0}}", From 3aa92acaa163e7e6bf3fb6af55bcd530412bee48 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 21:36:46 -0800 Subject: [PATCH 20/50] fix(cloud): COMPARE-PLANS-SWEEP drop the payment-method Copy link (Neil narrowed Copy link to auth surfaces) --- .../worktree-creation/VmBuildBillingBlocker.tsx | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/renderer/src/components/worktree-creation/VmBuildBillingBlocker.tsx b/src/renderer/src/components/worktree-creation/VmBuildBillingBlocker.tsx index 452ed36a461..b7eb6b6f8e7 100644 --- a/src/renderer/src/components/worktree-creation/VmBuildBillingBlocker.tsx +++ b/src/renderer/src/components/worktree-creation/VmBuildBillingBlocker.tsx @@ -11,8 +11,6 @@ import { overageFailureCopy } from '../billing/plan-billing-copy' import { openPlansPage } from '../billing/plans/plans-page-store' import { requestOrcaCloudAfterCheckout } from '@/lib/orca-cloud-after-checkout' import { formatCents } from '../billing/plans/plans-pricing' -import { billingPortalLink } from '../billing/billing-link' -import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton' /** The four ways billing can refuse a create (JA-21 progressive billing). */ export type BillingBlockKind = 'plan' | 'hoursUsed' | 'spendCap' | 'payment' @@ -215,17 +213,12 @@ export function VmBuildBillingBlockerView({ ) : null} {kind === 'payment' ? ( - // Opens the billing portal in the browser: the chevron copies the same link. - + ) : ( ) : undefined } /> diff --git a/src/renderer/src/components/billing/PlanBillingSection.tsx b/src/renderer/src/components/billing/PlanBillingSection.tsx index a719d090fd6..322accf6cfa 100644 --- a/src/renderer/src/components/billing/PlanBillingSection.tsx +++ b/src/renderer/src/components/billing/PlanBillingSection.tsx @@ -14,8 +14,6 @@ import { } from '../../../../shared/orca-billing' import { openPlansPage } from './plans/plans-page-store' import { PaymentIssueNotice } from './PaymentIssueNotice' -import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton' -import { billingPortalLink } from './billing-link' import { OverageControls, UsageSummary } from './PlanBillingUsage' import { denyReasonCopy, openFailureCopy, overageFailureCopy } from './plan-billing-copy' import { PlanStatusBadge } from './PlanStatusBadge' @@ -284,20 +282,16 @@ export function PlanBillingView({ )} {hasPlan && entitlement.canManageBilling && entitlement.payment.pastDueSince === null ? ( - { - if (!busy) { - onPortal() - } - }} + onClick={busy ? undefined : onPortal} > {t('auto.components.billing.manageBilling', 'Manage billing')} - + ) : null} {hasPlan ? ( // Members have no other action here, so it gets the button's chrome (and its alignment). diff --git a/src/renderer/src/components/billing/billing-link.test.ts b/src/renderer/src/components/billing/billing-link.test.ts deleted file mode 100644 index 8bca573d724..00000000000 --- a/src/renderer/src/components/billing/billing-link.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' - -const toastError = vi.fn() -vi.mock('sonner', () => ({ toast: { error: (...args: unknown[]) => toastError(...args) } })) - -import { billingCheckoutLink, billingPortalLink } from './billing-link' - -function stubApi(api: Record): void { - vi.stubGlobal('window', { api: { orcaProfiles: api } }) -} - -afterEach(() => { - toastError.mockReset() - vi.unstubAllGlobals() -}) - -describe('billing "Copy link"', () => { - it('returns the minted Portal URL without opening anything', async () => { - const billingPortalLink$ = vi.fn(async () => ({ - status: 'ok' as const, - url: 'https://billing.stripe.com/p/session/x' - })) - const billingOpenPortal = vi.fn() - stubApi({ billingPortalLink: billingPortalLink$, billingOpenPortal }) - await expect(billingPortalLink()).resolves.toBe('https://billing.stripe.com/p/session/x') - expect(billingOpenPortal).not.toHaveBeenCalled() - expect(toastError).not.toHaveBeenCalled() - }) - - it('mints Checkout for the same order the button would open', async () => { - const mint = vi.fn(async () => ({ - status: 'ok' as const, - url: 'https://checkout.stripe.com/c/pay/cs_1' - })) - stubApi({ billingCheckoutLink: mint }) - const args = { plan: 'team', interval: 'year' as const, seats: 5 } - await expect(billingCheckoutLink(args)).resolves.toBe('https://checkout.stripe.com/c/pay/cs_1') - expect(mint).toHaveBeenCalledWith(args) - }) - - it('says why there is no link with the open path’s copy, then yields null', async () => { - stubApi({ billingPortalLink: async () => ({ status: 'forbidden' }) }) - await expect(billingPortalLink()).resolves.toBeNull() - expect(toastError).toHaveBeenCalledWith('Only an owner or admin can manage billing.') - }) - - it('treats a rejected IPC as a failed billing read', async () => { - stubApi({ - billingPortalLink: async () => { - throw new Error('boom') - } - }) - await expect(billingPortalLink()).resolves.toBeNull() - expect(toastError).toHaveBeenCalledWith('Could not reach billing. Try again.') - }) -}) diff --git a/src/renderer/src/components/billing/billing-link.ts b/src/renderer/src/components/billing/billing-link.ts deleted file mode 100644 index 7bdae0ed29b..00000000000 --- a/src/renderer/src/components/billing/billing-link.ts +++ /dev/null @@ -1,35 +0,0 @@ -// "Copy link" for the Stripe buttons: mints the same Checkout/Portal URL the button opens, without -// opening it. A refusal (signed out, not an admin, no billing account…) is said in a toast with the -// open path's own copy, then null, so the split button adds nothing of its own. -import { toast } from 'sonner' -import { translate } from '@/i18n/i18n' -import type { - OrcaBillingCheckoutArgs, - OrcaBillingLinkResult -} from '../../../../shared/orca-billing' -import { openFailureCopy } from './plan-billing-copy' - -async function linkOrToast(mint: () => Promise): Promise { - const result = await mint().catch((error: unknown): OrcaBillingLinkResult => ({ - status: 'failed', - error: String(error) - })) - if (result.status === 'ok') { - return result.url - } - const failure = openFailureCopy(result, translate) - if (failure) { - toast.error(failure) - } - return null -} - -/** The Customer Portal URL "Manage billing" / "Update payment method" would open. */ -export function billingPortalLink(): Promise { - return linkOrToast(() => window.api.orcaProfiles.billingPortalLink()) -} - -/** The Checkout URL a plan's buy button would open for these args. */ -export function billingCheckoutLink(args: OrcaBillingCheckoutArgs): Promise { - return linkOrToast(() => window.api.orcaProfiles.billingCheckoutLink(args)) -} diff --git a/src/renderer/src/components/billing/checkout/CheckoutPanel.tsx b/src/renderer/src/components/billing/checkout/CheckoutPanel.tsx index 4794fc340e2..8ab5a50efe0 100644 --- a/src/renderer/src/components/billing/checkout/CheckoutPanel.tsx +++ b/src/renderer/src/components/billing/checkout/CheckoutPanel.tsx @@ -2,7 +2,6 @@ import { useEffect, useId, useRef } from 'react' import { AlertCircle, Check, ExternalLink, Lock, RotateCcw, XCircle } from 'lucide-react' import { Button } from '@/components/ui/button' import { LoadingSpinner } from '@/components/LoadingSpinner' -import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton' import { useIntlLocale, useTranslate } from '@/i18n/use-translate' import { cn } from '@/lib/class-names' import { openFailureCopy } from '../plan-billing-copy' @@ -21,8 +20,6 @@ export type CheckoutPanelProps = { /** When the order was placed; the renewal date counts from it. */ now: number onReopen: () => void - /** "Copy link" on Reopen / Try again: a fresh Checkout URL for this order (absent: no chevron). */ - reopenLink?: () => Promise onKeepWaiting: () => void onBack: () => void /** Where "Contact us" goes when billing can't sell the plan (the plan's supportUrl, else Enterprise's contact). */ @@ -224,22 +221,13 @@ export function CheckoutPanel(props: CheckoutPanelProps): React.JSX.Element { break } - const reopenLabel = ( - <> + const reopen = ( + ) const back = ( @@ -287,14 +275,14 @@ export function CheckoutPanel(props: CheckoutPanelProps): React.JSX.Element { phase.result.status === 'plan-unavailable' && props.contactUrl && props.onContact ? ( - props.contactUrl && props.onContact?.(props.contactUrl)} > {t('auto.components.billing.checkout.contactUs', 'Contact us')} - + ) : null} {phase.kind === 'timed-out' ? (

{t( 'auto.components.billing.plans.entHint', diff --git a/src/renderer/src/components/billing/plans/PlansDialogHost.tsx b/src/renderer/src/components/billing/plans/PlansDialogHost.tsx index 3bf8240ec1b..7d5619538f5 100644 --- a/src/renderer/src/components/billing/plans/PlansDialogHost.tsx +++ b/src/renderer/src/components/billing/plans/PlansDialogHost.tsx @@ -88,13 +88,6 @@ export function PlansDialogHost(): React.JSX.Element { setOrderedAt(Date.now()) checkout.start({ planId, interval, seats: count }) }} - checkoutLink={async ({ planId, interval, seats: count }) => { - const url = await checkout.copyLink({ planId, interval, seats: count }) - if (url) { - setOrderedAt(Date.now()) - } - return url - }} onManage={() => void openPortal()} onContactSales={(url) => void window.api.shell.openUrl(url)} onRetry={() => { @@ -126,7 +119,6 @@ export function PlansDialogHost(): React.JSX.Element { } checkout.reopen() }} - reopenLink={checkout.copyReopenLink} contactUrl={plans.state === 'ready' ? planContactUrl(plans.model) : null} onContact={(url) => void window.api.shell.openUrl(url)} onKeepWaiting={checkout.keepWaiting} diff --git a/src/renderer/src/components/billing/plans/PlansView.test.tsx b/src/renderer/src/components/billing/plans/PlansView.test.tsx index 4f5311c5299..397ad211a4d 100644 --- a/src/renderer/src/components/billing/plans/PlansView.test.tsx +++ b/src/renderer/src/components/billing/plans/PlansView.test.tsx @@ -49,13 +49,6 @@ describe("plans dialog from billing's catalog", () => { expect(props.onContactSales).toHaveBeenCalledWith('https://orca.dev/enterprise') }) - it('gives checkout and Contact sales a "Copy link" chevron for the same order', () => { - const checkoutLink = vi.fn(async () => 'https://checkout.stripe.com/c/pay/cs_1') - render() - // One chevron on the checkout button, one on Contact sales. - expect(screen.getAllByRole('button', { name: 'More link options' })).toHaveLength(2) - }) - it('offers only the intervals Stripe prices: no toggle with one', () => { render() expect(screen.queryByRole('radiogroup')).toBeNull() diff --git a/src/renderer/src/components/billing/plans/PlansView.tsx b/src/renderer/src/components/billing/plans/PlansView.tsx index e07ef549b3e..fa1786b4a72 100644 --- a/src/renderer/src/components/billing/plans/PlansView.tsx +++ b/src/renderer/src/components/billing/plans/PlansView.tsx @@ -36,12 +36,6 @@ export type PlansViewProps = { /** Seats Checkout will bill: org members, or 1 for a personal account. */ seats: number onCheckout: (args: { planId: string; interval: OrcaBillingInterval; seats: number }) => void - /** "Copy link" on the checkout button: the Checkout URL it would open (absent: no chevron). */ - checkoutLink?: (args: { - planId: string - interval: OrcaBillingInterval - seats: number - }) => Promise onManage: () => void onContactSales: (url: string) => void onRetry: () => void @@ -209,11 +203,6 @@ export function PlansView(props: PlansViewProps): React.JSX.Element { notice={props.notice} hideManage={paymentIssue} onCheckout={() => props.onCheckout({ planId: team.planId, interval, seats })} - checkoutLink={ - props.checkoutLink - ? () => props.checkoutLink!({ planId: team.planId, interval, seats }) - : undefined - } onManage={props.onManage} /> {enterprise ? ( diff --git a/src/renderer/src/components/billing/plans/TeamPlanCard.tsx b/src/renderer/src/components/billing/plans/TeamPlanCard.tsx index ff2ecd1f74e..dbbde991a8b 100644 --- a/src/renderer/src/components/billing/plans/TeamPlanCard.tsx +++ b/src/renderer/src/components/billing/plans/TeamPlanCard.tsx @@ -2,8 +2,6 @@ import { ExternalLink, Lock, Users } from 'lucide-react' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { InlineError } from '@/components/ui/inline-error' -import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton' -import { billingPortalLink } from '../billing-link' import type { PlanBillingNotice } from '../PlanBillingSection' import { LoadingSpinner } from '@/components/LoadingSpinner' import { useIntlLocale, useTranslate } from '@/i18n/use-translate' @@ -24,7 +22,6 @@ export function TeamPlanCard({ notice, hideManage, onCheckout, - checkoutLink, onManage }: { team: TeamPlanModel @@ -37,8 +34,6 @@ export function TeamPlanCard({ /** A payment banner already offers the same Portal action. */ hideManage: boolean onCheckout: () => void - /** "Copy link": mints the Checkout URL the button opens (absent: a plain button). */ - checkoutLink?: () => Promise onManage: () => void }): React.JSX.Element { const t = useTranslate() @@ -131,41 +126,20 @@ export function TeamPlanCard({

) } else if (mode === 'choose') { - const checkoutLabel = ( - <> - {busy ? : null} - {busy - ? t('auto.components.billing.plans.waiting', 'Waiting for checkout…') - : t('auto.components.billing.plans.checkout', 'Continue to checkout')} - {busy ? null : } - - ) footer = ( <> - {checkoutLink ? ( - { - if (!busy) { - onCheckout() - } - }} - > - {checkoutLabel} - - ) : ( - - )} + {notice?.kind === 'error' ? ( ) : ( @@ -189,21 +163,16 @@ export function TeamPlanCard({

) : ( <> - { - if (!busy) { - onManage() - } - }} + onClick={busy ? undefined : onManage} > {t('auto.components.billing.manageBilling', 'Manage billing')} - + {notice?.kind === 'error' ? ( ) : ( diff --git a/src/renderer/src/components/billing/team/TeamPageView.tsx b/src/renderer/src/components/billing/team/TeamPageView.tsx index c32779daf8f..288d555cd81 100644 --- a/src/renderer/src/components/billing/team/TeamPageView.tsx +++ b/src/renderer/src/components/billing/team/TeamPageView.tsx @@ -1,7 +1,6 @@ import { useEffect, useId, useRef } from 'react' import { CreditCard, ExternalLink, RefreshCw } from 'lucide-react' import { Button } from '@/components/ui/button' -import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton' import { InlineError } from '@/components/ui/inline-error' import { Skeleton } from '@/components/ui/skeleton' import { useTranslate } from '@/i18n/use-translate' @@ -305,15 +304,15 @@ export function TeamPageView(props: TeamPageViewProps): React.JSX.Element {

{t('auto.components.billing.team.ssoTitle', 'Single sign-on and provisioning')}

- props.onContactSales(enterprise.contactUrl)} > {t('auto.components.billing.enterpriseContactSales', 'Contact sales')} - + ) : null} {props.dangerZone} diff --git a/src/renderer/src/components/new-workspace/ProjectComboboxCloudAddProjectEntry.tsx b/src/renderer/src/components/new-workspace/ProjectComboboxCloudAddProjectEntry.tsx index 55d5e1cf011..44ba26c0267 100644 --- a/src/renderer/src/components/new-workspace/ProjectComboboxCloudAddProjectEntry.tsx +++ b/src/renderer/src/components/new-workspace/ProjectComboboxCloudAddProjectEntry.tsx @@ -1,20 +1,12 @@ // PROJ-MENU part 2: "Give Orca Cloud access to more repositories" moves out of the project list into // the Add project menu, in its own "Orca Cloud" group: "Add a GitHub repository…". import React from 'react' -import { Github, Link2 } from 'lucide-react' +import { Github } from 'lucide-react' import { DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu' import { useTranslate } from '@/i18n/use-translate' -import { copyExternalLink } from '@/components/open-external/copy-external-link' -import { gitHubInstallLinkSource } from '@/lib/orca-cloud/github-link-sources' import type { ProjectComboboxAddProjectMenu } from './ProjectComboboxAddProjectOption' -function FromGitHubItem({ - onSelect, - onCopyLink -}: { - onSelect: () => void - onCopyLink: () => void -}): React.JSX.Element { +function FromGitHubItem({ onSelect }: { onSelect: () => void }): React.JSX.Element { const t = useTranslate() return ( <> @@ -33,13 +25,6 @@ function FromGitHubItem({ {t('components.newWorkspace.cloudOnly.addRepositoryMenu', 'Add a GitHub repository…')} - {/* A menu row can't carry a chevron: the same GitHub page, copied for another browser. */} - - - - {t('components.newWorkspace.cloudOnly.copyAddRepositoryLink', 'Copy GitHub link')} - - ) } @@ -60,10 +45,6 @@ export function cloudAddProjectExtraItems( closeMenus() onMoreCloudAccess() }} - onCopyLink={() => { - closeMenus() - void copyExternalLink(gitHubInstallLinkSource()) - }} /> ) } diff --git a/src/renderer/src/components/new-workspace/run-on/use-cloud-start-steps.ts b/src/renderer/src/components/new-workspace/run-on/use-cloud-start-steps.ts index df7f18d4054..23aadd91bce 100644 --- a/src/renderer/src/components/new-workspace/run-on/use-cloud-start-steps.ts +++ b/src/renderer/src/components/new-workspace/run-on/use-cloud-start-steps.ts @@ -78,7 +78,7 @@ export function useCloudStartSteps({ onGranted: () => grantedRef.current?.(repoNameRef.current), ...(setupSessionId ? { setupSessionId } : {}) }) - const { state, access: grant, repoName, owner, loading, openInstall, installLink } = repoAccess + const { state, access: grant, repoName, owner, loading, openInstall } = repoAccess useEffect(() => { repoNameRef.current = repoName }, [repoName]) @@ -120,5 +120,5 @@ export function useCloudStartSteps({ ), [access, agentRow, availability, github, githubOverride, session, state] ) - return { steps, session, openInstall, installLink } + return { steps, session, openInstall } } diff --git a/src/renderer/src/components/open-external/copy-link-auth-only.test.tsx b/src/renderer/src/components/open-external/copy-link-auth-only.test.tsx new file mode 100644 index 00000000000..d47e57d793d --- /dev/null +++ b/src/renderer/src/components/open-external/copy-link-auth-only.test.tsx @@ -0,0 +1,102 @@ +// @vitest-environment happy-dom +// AUTH-ONLY-COPY-LINK: "Copy link" (the split's chevron) is for sign-in pages only, where people paste +// the URL into an incognito window. Billing, help, support and the GitHub App install keep a plain +// button, and nothing adds a "Copy … link" row next to them. +import { cleanup, render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, expect, it, vi } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import { PaymentIssueNotice } from '@/components/billing/PaymentIssueNotice' +import { sampleTeam } from '@/components/billing/plan-billing-fixtures' +import { + FailureActionRow, + type VmBuildFailureActions +} from '@/components/worktree-creation/VmBuildFailureActions' +import { translate } from '@/i18n/i18n' +import { OpenExternalSplitButton } from './OpenExternalSplitButton' + +afterEach(cleanup) + +const chevrons = (): number => document.querySelectorAll('[data-open-external-chevron]').length + +const actions: VmBuildFailureActions = { + onFixWithAgent: vi.fn(), + onRetry: vi.fn(), + onEditRecipe: vi.fn(), + onOpenAnyway: vi.fn(), + onStartAgain: vi.fn(), + onDismiss: vi.fn(), + onSeePlans: vi.fn(), + onOpenBilling: vi.fn(), + onCreateLocally: vi.fn(), + onSignIn: vi.fn(), + onShowWorkspaces: vi.fn(), + onContactSupport: vi.fn(), + onConnectGitHub: vi.fn(), + onPushToGitHub: vi.fn() +} + +function failureRow(kind: 'githubNotConnected' | 'noGithubRemote' | 'snapshotMissing'): void { + render( + + + + ) +} + +it('keeps the chevron on a sign-in button', () => { + render( + {}}> + Open again + + ) + expect(chevrons()).toBe(1) +}) + +it('"Update payment method" is a plain button', () => { + render( + {}} + /> + ) + expect(screen.getByRole('button', { name: /Update payment method/ })).toBeTruthy() + expect(chevrons()).toBe(0) +}) + +it('Connect GitHub (App install) and "How to push to GitHub" are plain buttons', () => { + failureRow('githubNotConnected') + expect(screen.getByRole('button', { name: /Connect GitHub/ })).toBeTruthy() + expect(chevrons()).toBe(0) + cleanup() + failureRow('noGithubRemote') + expect(screen.getByRole('button', { name: /How to push to GitHub/ })).toBeTruthy() + expect(chevrons()).toBe(0) +}) + +it('"Contact support" has no "Copy support link" row beside it', async () => { + failureRow('snapshotMissing') + const more = screen.queryAllByRole('button').find((b) => b.getAttribute('aria-haspopup')) + expect(more).toBeTruthy() + await userEvent.setup().click(more!) + const menu = await screen.findByRole('menu') + const items = within(menu) + .getAllByRole('menuitem') + .map((item) => item.textContent?.trim()) + expect(items.some((label) => label?.includes('Contact support'))).toBe(true) + expect(items.some((label) => /copy/i.test(label ?? ''))).toBe(false) +}) diff --git a/src/renderer/src/components/orca-cloud-setup/CloudCapabilityOffActions.tsx b/src/renderer/src/components/orca-cloud-setup/CloudCapabilityOffActions.tsx index cb02a34cb8d..a8ae019622c 100644 --- a/src/renderer/src/components/orca-cloud-setup/CloudCapabilityOffActions.tsx +++ b/src/renderer/src/components/orca-cloud-setup/CloudCapabilityOffActions.tsx @@ -1,7 +1,6 @@ import React from 'react' import { ExternalLink } from 'lucide-react' import { Button } from '@/components/ui/button' -import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton' import { openAppHttpLink } from '@/lib/browser/navigation/app-http-link-open' import { useTranslate } from '@/i18n/use-translate' @@ -24,14 +23,10 @@ export function CloudCapabilityOffActions({ {t('cloudSetupPage.capability.tryAgain', 'Try again')} {contactUrl ? ( - openAppHttpLink(contactUrl)} - > + ) : null}
) diff --git a/src/renderer/src/components/orca-cloud-setup/CloudSetupPage.fixture.tsx b/src/renderer/src/components/orca-cloud-setup/CloudSetupPage.fixture.tsx index 590ef8896b3..c8aa512131a 100644 --- a/src/renderer/src/components/orca-cloud-setup/CloudSetupPage.fixture.tsx +++ b/src/renderer/src/components/orca-cloud-setup/CloudSetupPage.fixture.tsx @@ -35,8 +35,7 @@ function flow(rows: CloudSetupStep[], teamSwitch = false): CloudSetupFlow { checkAgain: noop, contactUrl: null, reopenBrowser: noop, - reopenLink: null, - installGitHubLink: null + reopenLink: null } } diff --git a/src/renderer/src/components/orca-cloud-setup/CloudSetupPage.tsx b/src/renderer/src/components/orca-cloud-setup/CloudSetupPage.tsx index 8712c75a814..5eed336f99a 100644 --- a/src/renderer/src/components/orca-cloud-setup/CloudSetupPage.tsx +++ b/src/renderer/src/components/orca-cloud-setup/CloudSetupPage.tsx @@ -212,7 +212,7 @@ function StepActions({ ) case 'github-install': - return browser(continueLabel, flow.installGitHub, flow.installGitHubLink) + return browser(continueLabel, flow.installGitHub) case 'start-trial': return ( - ) - )} + {primary.map((action, index) => ( + + ))} {more.length > 0 ? ( diff --git a/src/renderer/src/components/worktree-creation/VmBuildSheetCopyLinks.fixture.tsx b/src/renderer/src/components/worktree-creation/VmBuildSheetCopyLinks.fixture.tsx deleted file mode 100644 index dbf931dff20..00000000000 --- a/src/renderer/src/components/worktree-creation/VmBuildSheetCopyLinks.fixture.tsx +++ /dev/null @@ -1,58 +0,0 @@ -// COPY-LINK-RULE on the build sheet: "How to push to GitHub" as a split button (its chevron menu -// open), and "Copy support link" right below "Contact support" in the More menu. -import { defineFixture } from '../../component-fixtures/component-fixture' -import { - CLOUD_RECIPE, - FAILED_LOG, - T0, - entry, - seedFixtureRepo, - sheet -} from './vm-build-sheet-fixture-support' - -const settle = (): Promise => new Promise((resolve) => setTimeout(resolve, 120)) - -// Radix menus open on pointerdown, not click. -function openMenu(selector: string) { - return async (frame: HTMLElement): Promise => { - frame - .querySelector(selector) - ?.dispatchEvent( - new PointerEvent('pointerdown', { bubbles: true, button: 0, pointerType: 'mouse' }) - ) - await settle() - } -} - -function failed(errorCode: string, error: string, log: string) { - return { - value: entry( - { status: 'error', errorCode, provisioningLog: log, errorAt: T0 + 2_000 }, - CLOUD_RECIPE - ), - error - } -} - -const noRemote = failed( - 'cloudNoGithubRemote', - 'Orca Cloud clones your code from GitHub, and this repository has no GitHub remote.', - '' -) -const snapshot = failed('recipeExited', 'Recipe exited with code 1.', FAILED_LOG) - -/** "How to push to GitHub" with its chevron open: Copy link. */ -export const pushToGitHubCopyLink = defineFixture({ - width: 760, - setup: () => seedFixtureRepo(null), - render: () => sheet(noRemote.value, noRemote.error, T0 + 2_000), - interact: openMenu('button[aria-label="More link options"]') -}) - -/** "Contact support" in More, with "Copy support link" right below it. */ -export const contactSupportCopyLink = defineFixture({ - width: 760, - setup: () => seedFixtureRepo(), - render: () => sheet(snapshot.value, snapshot.error, T0 + 2_000), - interact: openMenu('[data-vm-build-sheet] button[aria-haspopup="menu"]') -}) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index b396585c676..501174779ba 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -4577,8 +4577,7 @@ "trialLimitBodyCount_other": "Your trial can run {{count}} sandboxes at once. Put one to sleep or delete it, then try again. Nothing was created, and you weren’t charged.", "teamLimitBodyNoSeatCount_one": "Your team can run {{count}} sandbox at once. Put it to sleep or delete it, then try again. Nothing was created, and you weren’t charged.", "teamLimitBodyNoSeatCount_other": "Your team can run {{count}} sandboxes at once. Put one to sleep or delete it, then try again. Nothing was created, and you weren’t charged.", - "trialLimitTitle": "You’re at your trial’s limit of running sandboxes", - "copySupportLink": "Copy support link" + "trialLimitTitle": "You’re at your trial’s limit of running sandboxes" }, "VmBuildLog": { "jumpToError": "Jump to first error", @@ -23926,7 +23925,6 @@ "privateLabel": "Private", "menuGroup": "Orca Cloud", "addRepositoryMenu": "Add a GitHub repository…", - "copyAddRepositoryLink": "Copy GitHub link", "waitingForGitHubAccess": "Waiting for GitHub access…", "noNewRepositoryYet": "No new repository yet.", "checkAgain": "Check again", diff --git a/src/renderer/src/lib/orca-cloud/cloud-glossary-allowlist.json b/src/renderer/src/lib/orca-cloud/cloud-glossary-allowlist.json index e9a169b5dae..450bc63eb3b 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-glossary-allowlist.json +++ b/src/renderer/src/lib/orca-cloud/cloud-glossary-allowlist.json @@ -13,7 +13,7 @@ }, { "path": "src/renderer/src/components/worktree-creation/VmBuildFailureActions.tsx", - "line": 412, + "line": 372, "term": "VM", "reason": "recipe-VM branch; cloud branch says sandbox" }, diff --git a/src/renderer/src/lib/orca-cloud/github-link-sources.ts b/src/renderer/src/lib/orca-cloud/github-link-sources.ts index 33030df09d7..40709c97956 100644 --- a/src/renderer/src/lib/orca-cloud/github-link-sources.ts +++ b/src/renderer/src/lib/orca-cloud/github-link-sources.ts @@ -1,19 +1,9 @@ -// "Copy link" for the GitHub handoffs: the same single-use page Connect GitHub (App install) and -// Connect your GitHub (OAuth) open, minted the same way in main but returned instead of opened. +// "Copy link" for connecting your GitHub identity (OAuth sign-in): the same single-use page Connect +// your GitHub opens, minted the same way in main but returned instead of opened. Copy link is for +// sign-in pages only; the GitHub App install keeps a plain button. import { requireLink, type ExternalLinkSource } from '@/components/open-external/copy-external-link' import { cloudWorkspaceApi } from './orca-cloud-workspace-store' -/** The GitHub App install page; with a repository, GitHub preselects it (and a setup session returns). */ -export function gitHubInstallLinkSource(args?: { - repoUrl?: string - setupSessionId?: string -}): ExternalLinkSource { - return async () => { - const result = await cloudWorkspaceApi()?.gitHubInstallLink(args) - return requireLink(result?.ok ? result.value : null) - } -} - /** GitHub's OAuth page for connecting the person's own GitHub identity. */ export function gitHubUserConnectLinkSource(): ExternalLinkSource { return async () => { diff --git a/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store-git-setup.test.ts b/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store-git-setup.test.ts index 0e6b72d3618..911e85d51a9 100644 --- a/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store-git-setup.test.ts +++ b/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store-git-setup.test.ts @@ -47,7 +47,6 @@ function stub( connectGitHub: vi.fn(unused), getGitHubUser: vi.fn(unused), connectGitHubUser: vi.fn(unused), - gitHubInstallLink: vi.fn(unused), gitHubUserConnectLink: vi.fn(unused), disconnectGitHubUser: vi.fn(unused), getRepoAccess: vi.fn(unused), diff --git a/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store-wake.test.ts b/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store-wake.test.ts index 2eaf4be43a6..f6c889b15f4 100644 --- a/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store-wake.test.ts +++ b/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store-wake.test.ts @@ -37,7 +37,6 @@ function stub( connectGitHub: vi.fn(unused), getGitHubUser: vi.fn(unused), connectGitHubUser: vi.fn(unused), - gitHubInstallLink: vi.fn(unused), gitHubUserConnectLink: vi.fn(unused), disconnectGitHubUser: vi.fn(unused), getRepoAccess: vi.fn(unused), diff --git a/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store.test.ts b/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store.test.ts index 2cb54706641..8b440d8b65f 100644 --- a/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store.test.ts +++ b/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store.test.ts @@ -42,7 +42,6 @@ function stubApi(overrides: Partial = {}): OrcaCloudWorks connectGitHub: vi.fn(async () => ok(null)), getGitHubUser: vi.fn(async () => ok({ connected: false as const })), connectGitHubUser: vi.fn(async () => ok(null)), - gitHubInstallLink: vi.fn(async () => ok('https://github.com/apps/x/installations/new')), gitHubUserConnectLink: vi.fn(async () => ok('https://github.com/login/oauth/authorize')), disconnectGitHubUser: vi.fn(async () => ok({ connected: false as const })), getRepoAccess: vi.fn(async () => diff --git a/src/renderer/src/lib/orca-cloud/use-orca-cloud-repo-access.ts b/src/renderer/src/lib/orca-cloud/use-orca-cloud-repo-access.ts index d07a6a21b45..04e74e0aa00 100644 --- a/src/renderer/src/lib/orca-cloud/use-orca-cloud-repo-access.ts +++ b/src/renderer/src/lib/orca-cloud/use-orca-cloud-repo-access.ts @@ -3,14 +3,12 @@ // network call; everything else comes from the control plane's GET /v1/github/repo-access (N2), // cached 5 minutes. While a repository is waiting (an install in the browser, or an org owner's // approval, D2), `poll` re-checks on the spec's cadence: every 2 s for 3 minutes, then every 10 s. -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useAppStore } from '@/store' import { normalizeGitRemoteUrl } from '../../../../shared/git/remotes/git-remote-identity' import type { HostedGithubRepoAccessResponse } from '../../../../shared/hosted-sandbox/hosted-github-wire' import type { Repo } from '../../../../shared/repo-types' -import type { ExternalLinkSource } from '@/components/open-external/copy-external-link' import { cloudWorkspaceApi } from './orca-cloud-workspace-store' -import { gitHubInstallLinkSource } from './github-link-sources' import { useOrcaCloudIdentityKey } from './orca-cloud-identity' export type OrcaCloudRepoAccessState = @@ -139,12 +137,7 @@ export function useOrcaCloudRepoAccess( /** Fired once when a repository that was waiting becomes reachable (D2's "access granted"). */ onGranted?: () => void } = {} -): OrcaCloudRepoAccess & { - refresh: () => void - openInstall: () => Promise - /** "Copy link" for openInstall: the same single-use install page, minted without opening. */ - installLink: ExternalLinkSource | null -} { +): OrcaCloudRepoAccess & { refresh: () => void; openInstall: () => Promise } { const repo = useAppStore((s) => repoId ? s.repos.find((entry) => entry.id === repoId) : undefined ) @@ -218,14 +211,6 @@ export function useOrcaCloudRepoAccess( return Boolean(result?.ok) }, [repoUrl, setupSessionId]) - const installLink = useMemo( - () => - repoUrl - ? gitHubInstallLinkSource({ repoUrl, ...(setupSessionId ? { setupSessionId } : {}) }) - : null, - [repoUrl, setupSessionId] - ) - return { state: enabled || !repoUrl ? state : 'unknown', ...(current?.installUrl ? { installUrl: current.installUrl } : {}), @@ -235,7 +220,6 @@ export function useOrcaCloudRepoAccess( repoUrl, loading: enabled && current === undefined, refresh, - openInstall, - installLink + openInstall } } diff --git a/src/renderer/src/web/preload-api/web-orca-profiles-api.ts b/src/renderer/src/web/preload-api/web-orca-profiles-api.ts index 4fa1c22b21e..b6c17f986d6 100644 --- a/src/renderer/src/web/preload-api/web-orca-profiles-api.ts +++ b/src/renderer/src/web/preload-api/web-orca-profiles-api.ts @@ -85,8 +85,6 @@ export function createWebOrcaProfilesApi(): Partial { cloudSetupGet: async () => ({ status: 'unconfigured' }), cloudSetupLink: async () => ({ status: 'unconfigured' }), billingOpenPortal: async () => ({ status: 'unconfigured' }), - billingPortalLink: async () => ({ status: 'unconfigured' }), - billingCheckoutLink: async () => ({ status: 'unconfigured' }), billingTeamPlans: async () => ({ status: 'unconfigured' }), billingUsageHistory: async () => ({ status: 'unconfigured' }), billingSetOverage: async () => ({ status: 'unconfigured' }), diff --git a/src/shared/hosted-sandbox/cloud-workspace-api.ts b/src/shared/hosted-sandbox/cloud-workspace-api.ts index d7eda6f51e3..a176c7a32ca 100644 --- a/src/shared/hosted-sandbox/cloud-workspace-api.ts +++ b/src/shared/hosted-sandbox/cloud-workspace-api.ts @@ -95,11 +95,6 @@ export type OrcaCloudWorkspaceApi = { repoUrl?: string setupSessionId?: string }) => Promise> - /** "Copy link" for connectGitHub: the same single-use GitHub install page, minted but not opened. */ - gitHubInstallLink: (args?: { - repoUrl?: string - setupSessionId?: string - }) => Promise> /** Whether the signed-in person connected their own GitHub (cloud commit attribution). */ getGitHubUser: () => Promise> /** Opens GitHub's OAuth page in the browser so the person connects their own GitHub identity. */ @@ -146,7 +141,6 @@ export const ORCA_CLOUD_WORKSPACE_CHANNELS = { listCloudWorkspaceIds: 'orcaCloudWorkspace:listIds', getConfig: 'orcaCloudWorkspace:getConfig', connectGitHub: 'orcaCloudWorkspace:connectGitHub', - gitHubInstallLink: 'orcaCloudWorkspace:gitHubInstallLink', getGitHubUser: 'orcaCloudWorkspace:getGitHubUser', connectGitHubUser: 'orcaCloudWorkspace:connectGitHubUser', gitHubUserConnectLink: 'orcaCloudWorkspace:gitHubUserConnectLink', From ffc17e6a4ed9e291d48b130c3ca766702207c79f Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 22:03:23 -0800 Subject: [PATCH 23/50] fix(cloud): separate result members so the team MFA service narrows --- .../cloud-mfa/profile-cloud-team-mfa-service.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/main/orca-profiles/cloud-mfa/profile-cloud-team-mfa-service.ts b/src/main/orca-profiles/cloud-mfa/profile-cloud-team-mfa-service.ts index 5462ad2b15b..ae7f8101976 100644 --- a/src/main/orca-profiles/cloud-mfa/profile-cloud-team-mfa-service.ts +++ b/src/main/orca-profiles/cloud-mfa/profile-cloud-team-mfa-service.ts @@ -19,7 +19,10 @@ import { orgMembersUrl, requestInit } from '../cloud-org/profile-cloud-org-members-client' -import { runOrgMemberCall, type OrgCallResult } from '../cloud-org/profile-cloud-org-members-service' +import { + runOrgMemberCall, + type OrgCallResult +} from '../cloud-org/profile-cloud-org-members-service' import { ensureActiveOrcaProfile } from '../profile-index-store' const SELF_STATES: readonly OrcaTeamMfaSelfState[] = ['on', 'off', 'pending', 'blocked', 'unknown'] @@ -113,7 +116,15 @@ async function request(url: string, init: RequestInit): Promise { type Unavailable = Extract< OrcaTeamMfaPolicyResult, - { status: 'unconfigured' | 'reconnect-required' | 'unsupported' | 'forbidden' | 'rate-limited' | 'failed' } + { + status: + | 'unconfigured' + | 'reconnect-required' + | 'unsupported' + | 'forbidden' + | 'rate-limited' + | 'failed' + } > function unavailable(result: Exclude, { status: 'ok' }>): Unavailable { @@ -141,7 +152,7 @@ async function call( userDataPath: string, run: (apiBaseUrl: string, accessToken: string, url: (path: string) => string) => Promise, orgId: string -): Promise | { status: 'unconfigured' | 'unsupported' }> { +): Promise | { status: 'unconfigured' } | { status: 'unsupported' }> { const active = ensureActiveOrcaProfile(userDataPath) // Local dev auth has no server, so no policy. if (isOrcaCloudDevAuthEnabled()) { From 3adf9a3b043646c389fde904ab03ea1debeecd2b Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 22:04:05 -0800 Subject: [PATCH 24/50] fix(cloud): SEC-5 a shared workspace gets none of its owner's personal secrets ownersForSandbox resolves no personal owner unless the row is private, so a workspace shared with the team gets team and repository secrets only (values and placeholders). The egress proxy's cached keys are keyed by visibility so sharing takes effect at once. Share toast and Personal tab say so. --- cloud/apps/sandbox-control/src/secrets.ts | 22 +- cloud/apps/sandbox-control/src/session-env.ts | 5 +- .../test/shared-workspace-secrets.test.ts | 216 ++++++++++++++++++ .../cloud-workspaces/CloudWorkspacesHome.tsx | 9 +- .../orca-cloud/CloudWorkspaceMenuItems.tsx | 25 +- .../CloudSecretsPane.fixture.tsx | 2 +- .../cloud-secrets/CloudSecretsPane.tsx | 4 +- src/renderer/src/i18n/locales/en.json | 6 +- 8 files changed, 269 insertions(+), 20 deletions(-) create mode 100644 cloud/apps/sandbox-control/test/shared-workspace-secrets.test.ts diff --git a/cloud/apps/sandbox-control/src/secrets.ts b/cloud/apps/sandbox-control/src/secrets.ts index 88f0b5d1fc4..1f7713324ad 100644 --- a/cloud/apps/sandbox-control/src/secrets.ts +++ b/cloud/apps/sandbox-control/src/secrets.ts @@ -165,12 +165,18 @@ function repoOwner(accountId: string, repo: string): string { return `${accountId}#${repo}` } -/** The owners whose secrets a sandbox session receives. */ -export function ownersForSandbox(row: Pick) { +/** + * The owners whose secrets a sandbox session receives. + * SEC-5: a workspace shared with teammates runs their shells too, so anything in its env is theirs to + * read (and a placeholder is theirs to spend). It gets the team's and the repository's secrets only, + * never its owner's personal ones, the same rule as personal accounts (D13). Fails closed: only a row + * known to be private counts as unshared. + */ +export function ownersForSandbox(row: Pick) { const repo = normalizeRepo(row.repo_url) return { org: ORG_ACCOUNT.test(row.account_id) ? row.account_id : null, - personal: personalOwner(row.user_id, row.account_id.startsWith('dev:')), + personal: row.visibility === 'private' ? personalOwner(row.user_id, row.account_id.startsWith('dev:')) : null, repo: repo ? repoOwner(row.account_id, repo) : null } } @@ -329,16 +335,22 @@ export type ResolvedSecrets = { env: Record; scopes: Record + row: Pick ): Promise { const owners = ownersForSandbox(row) - const pairs: [SecretScope, string][] = [['personal', owners.personal]] + const pairs: [SecretScope, string][] = [] + if (owners.personal) { + pairs.push(['personal', owners.personal]) + } if (owners.org) { pairs.push(['org', owners.org]) } if (owners.repo) { pairs.push(['repo', owners.repo]) } + if (pairs.length === 0) { + return { env: {}, scopes: {}, skipped: 0 } + } const where = pairs.map(() => '(scope = ? AND owner_id = ?)').join(' OR ') const { results } = await db .prepare(`SELECT * FROM secrets WHERE ${where}`) diff --git a/cloud/apps/sandbox-control/src/session-env.ts b/cloud/apps/sandbox-control/src/session-env.ts index 92d2a15c485..7f7bb7a8c36 100644 --- a/cloud/apps/sandbox-control/src/session-env.ts +++ b/cloud/apps/sandbox-control/src/session-env.ts @@ -27,7 +27,7 @@ export type ProviderEnv = { env: Record; scopes: Record + row: Pick ): Promise { const { env: stored, scopes } = await resolveSandboxSecrets(db, keyring, row) // The proxy only needs the keys; dropping those of agents not on 'keep' happens at request time @@ -43,7 +43,8 @@ const PROVIDER_ENV_CACHE_MS = 60_000 const providerEnvCache = new Map }>() export function cachedProviderEnv(db: D1Database, keyring: Keyring, row: SandboxRow, now: number): Promise { - const key = `${row.id}:${row.session_id ?? ''}` + // Visibility is part of the key: sharing a workspace stops its owner's personal keys at once (SEC-5). + const key = `${row.id}:${row.session_id ?? ''}:${row.visibility}` const hit = providerEnvCache.get(key) if (hit && now - hit.at < PROVIDER_ENV_CACHE_MS) { return hit.value diff --git a/cloud/apps/sandbox-control/test/shared-workspace-secrets.test.ts b/cloud/apps/sandbox-control/test/shared-workspace-secrets.test.ts new file mode 100644 index 00000000000..e4e92b532ab --- /dev/null +++ b/cloud/apps/sandbox-control/test/shared-workspace-secrets.test.ts @@ -0,0 +1,216 @@ +// SEC-5: a workspace shared with teammates runs their shells too, so nothing personal to its owner may +// be in its env: not a variable's value, and not a placeholder that would spend the owner's model key. +import { SignJWT, createLocalJWKSet, exportJWK, generateKeyPair } from 'jose' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ENV_COMMAND, START_COMMAND } from '../src/sandbox-vm-commands' +import { ownersForSandbox } from '../src/secrets' +import { createHarness } from './harness' + +const KEK = Buffer.alloc(32, 7).toString('base64') +const PROXY_URL = 'https://sandbox.test/v1/egress/proxy' +// Fake values only. +const TEAM_KEY = 'sk-ant-api03-TEAM-fake-0000-aaaa' +const PERSONAL_OPENAI = 'sk-proj-fake-personal-openai-2222' +const PERSONAL_VALUES = [PERSONAL_OPENAI, 'personal-db-password', 'personal-foo-value', 'personal-aws-secret-value'] + +type Harness = Awaited> + +const logs: string[] = [] +beforeEach(() => { + logs.length = 0 + for (const method of ['log', 'info', 'warn', 'error', 'debug'] as const) { + vi.spyOn(console, method).mockImplementation((...args: unknown[]) => { + logs.push(args.map((arg) => (typeof arg === 'string' ? arg : JSON.stringify(arg))).join(' ')) + }) + } +}) +afterEach(() => { + for (const value of [...PERSONAL_VALUES, TEAM_KEY]) { + expect(logs.join('\n')).not.toContain(value) + } + vi.restoreAllMocks() +}) + +function decodeEnv(encoded: string | undefined): Record { + return Object.fromEntries( + (encoded ?? '') + .split('\n') + .filter(Boolean) + .map((line) => { + const at = line.indexOf('=') + return [line.slice(0, at), Buffer.from(line.slice(at + 1), 'base64').toString('utf8')] + }) + ) +} + +/** The env the VM got at its last boot, or from its last live refresh. */ +function vmEnv(h: Harness, command: string = START_COMMAND): Record { + return decodeEnv(h.vercel.commands.filter((c) => c.command === command).at(-1)!.env.ORCA_SECRET_ENV) +} + +/** Everything the control plane ever sent the VM, as one string: no personal value may be in it. */ +function everythingSent(h: Harness, from = 0): string { + return JSON.stringify( + h.vercel.commands.slice(from).map((c) => ({ ...c, env: { ...c.env, ORCA_SECRET_ENV: decodeEnv(c.env.ORCA_SECRET_ENV) } })) + ) +} + +async function seeded(overrides: Record = {}, deps: Parameters[1] = {}) { + const h = await createHarness({ SECRETS_KEK: KEK, ...overrides }, deps) + const admin = await h.token('ada', 'acme', 'sandbox', { orgRole: 'admin' }) + const mia = await h.token('mia', 'acme', 'sandbox', { orgRole: 'member' }) + const put = (scope: string, name: string, value: string, bearer: string, repo?: string) => + h.call('PUT', `/v1/secrets/${scope}/${name}${repo ? `?repo=${encodeURIComponent(repo)}` : ''}`, { body: { value }, bearer }) + await put('org', 'ANTHROPIC_API_KEY', TEAM_KEY, admin) + await put('org', 'FOO', 'team-foo-value', admin) + await put('repo', 'BAR', 'repo-bar-value', admin, 'acme/app') + await put('personal', 'OPENAI_API_KEY', PERSONAL_OPENAI, mia) + await put('personal', 'DB_PASSWORD', 'personal-db-password', mia) + await put('personal', 'FOO', 'personal-foo-value', mia) + await put('personal', 'AWS_SECRET_ACCESS_KEY', 'personal-aws-secret-value', mia) + const create = (visibility?: 'team' | 'private') => + h.call('POST', '/v1/sandboxes', { + body: { workspaceId: 'ws-1', machineSize: 'standard', repo: { url: 'https://github.com/acme/app' }, ...(visibility ? { visibility } : {}) }, + bearer: mia + }) + const share = (id: string, visibility: 'team' | 'private') => + h.call('PATCH', `/v1/team/workspaces/${id}`, { body: { visibility }, bearer: mia }) + return { h, mia, admin, put, create, share } +} + +const TEAM_AND_REPO = { ANTHROPIC_API_KEY: TEAM_KEY, BAR: 'repo-bar-value', FOO: 'team-foo-value' } + +describe('personal secrets in shared workspaces (SEC-5)', () => { + it('resolves no personal owner for a shared row, and fails closed on an unknown visibility', () => { + const row = { account_id: 'org:acme', user_id: 'mia', repo_url: 'https://github.com/acme/app' } + expect(ownersForSandbox({ ...row, visibility: 'private' }).personal).toBe('user:mia') + expect(ownersForSandbox({ ...row, visibility: 'team' })).toEqual({ org: 'org:acme', personal: null, repo: 'org:acme#github.com/acme/app' }) + expect(ownersForSandbox({ ...row, visibility: undefined as never }).personal).toBeNull() + }) + + it('keeps the owner’s personal secrets in their own private workspace', async () => { + const { h, create } = await seeded() + expect((await create()).status).toBe(200) + expect(vmEnv(h)).toEqual({ + ...TEAM_AND_REPO, + AWS_SECRET_ACCESS_KEY: 'personal-aws-secret-value', + DB_PASSWORD: 'personal-db-password', + FOO: 'personal-foo-value', + OPENAI_API_KEY: PERSONAL_OPENAI + }) + }) + + it('starts a shared workspace with team and repository secrets only; the team value replaces a personal override', async () => { + const { h, create } = await seeded() + expect((await create('team')).status).toBe(200) + expect(vmEnv(h)).toEqual(TEAM_AND_REPO) + for (const value of PERSONAL_VALUES) { + expect(everythingSent(h)).not.toContain(value) + } + }) + + it('drops them from a running workspace the moment it is shared, and brings them back when it is private again', async () => { + const { h, create, share, mia } = await seeded() + const id: string = (await create()).body.sandbox.id + expect(vmEnv(h).DB_PASSWORD).toBe('personal-db-password') + const before = h.vercel.commands.length + + expect((await share(id, 'team')).status).toBe(200) + expect(vmEnv(h, ENV_COMMAND)).toEqual(TEAM_AND_REPO) + for (const value of PERSONAL_VALUES) { + expect(everythingSent(h, before)).not.toContain(value) + } + // A wake while shared resolves the same way. + await h.call('POST', `/v1/sandboxes/${id}/suspend`, { bearer: mia }) + await h.call('POST', `/v1/sandboxes/${id}/resume`, { bearer: mia }) + expect(vmEnv(h)).toEqual(TEAM_AND_REPO) + + expect((await share(id, 'private')).status).toBe(200) + expect(vmEnv(h, ENV_COMMAND)).toMatchObject({ DB_PASSWORD: 'personal-db-password', FOO: 'personal-foo-value' }) + }) + + it('does not pick up a personal secret the owner saves while the workspace is shared', async () => { + const { h, create, put, mia } = await seeded() + await create('team') + await put('personal', 'LATER', 'personal-db-password', mia) + const refreshes = h.vercel.commands.filter((c) => c.command === ENV_COMMAND) + for (const refresh of refreshes) { + expect(decodeEnv(refresh.env.ORCA_SECRET_ENV)).toEqual(TEAM_AND_REPO) + } + }) +}) + +describe('personal model keys in shared workspaces with egress placeholders (SEC-5)', () => { + async function placeholderSetup() { + const { privateKey, publicKey } = await generateKeyPair('RS256') + const jwks = { keys: [{ ...(await exportJWK(publicKey)), kid: 'v1', alg: 'RS256' }] } + const upstream: { url: string; headers: Headers }[] = [] + const ctx = await seeded( + { SECRETS_EGRESS_PLACEHOLDERS: 'on', EGRESS_PROXY_URL: PROXY_URL }, + { + egressKeysForIssuer: () => createLocalJWKSet(jwks), + credentials: { authorizationFor: async () => 'Bearer ghs_fake' }, + egressFetch: async (input, init) => { + upstream.push({ url: String(input), headers: new Headers(init?.headers) }) + return new Response('ok', { status: 200 }) + } + } + ) + async function proxied(sandboxId: string, host: string, headers: Record) { + const token = await new SignJWT({ team_id: 'team_orca', project_id: 'prj_orca', sandbox_name: `orca-test-${sandboxId}` }) + .setProtectedHeader({ alg: 'RS256', kid: 'v1' }) + .setIssuer('https://oidc.vercel.com/team_orca') + .setAudience(PROXY_URL) + .setIssuedAt() + .setExpirationTime('5m') + .sign(privateKey) + const response = await ctx.h.send( + new Request(`${PROXY_URL}/v1/messages`, { + method: 'POST', + body: '{"model":"x"}', + headers: { + 'vercel-forwarded-host': host, + 'vercel-forwarded-scheme': 'https', + 'vercel-forwarded-port': '443', + 'vercel-forwarded-path': '/v1/messages', + 'vercel-sandbox-oidc-token': token, + ...headers + } + }) + ) + return response.status + } + return { ...ctx, proxied, upstream } + } + + it('gives a shared workspace no placeholder and no route for the owner’s personal key; the team key still works', async () => { + const { h, create, proxied, upstream } = await placeholderSetup() + const id: string = (await create('team')).body.sandbox.id + const env = vmEnv(h) + expect(env.ANTHROPIC_API_KEY).toBe(`orca-ph-anthropic-api-key-${id}`) + expect(env).not.toHaveProperty('OPENAI_API_KEY') + expect(env).not.toHaveProperty('DB_PASSWORD') + const [box] = [...h.vercel.boxes.values()] + // No forward rule for the personal key: the host is plain allowlisted, nothing is swapped there. + expect(box!.policy.allow['api.openai.com']).toEqual([]) + expect(await proxied(id, 'api.openai.com', { authorization: `Bearer orca-ph-openai-api-key-${id}` })).toBe(403) + expect(await proxied(id, 'api.anthropic.com', { 'x-api-key': `orca-ph-anthropic-api-key-${id}` })).toBe(200) + expect(upstream).toHaveLength(1) + expect(upstream[0]!.headers.get('x-api-key')).toBe(TEAM_KEY) + }) + + it('stops swapping the owner’s personal key at the proxy as soon as a running workspace is shared', async () => { + const { h, create, share, proxied, upstream } = await placeholderSetup() + const id: string = (await create()).body.sandbox.id + const placeholder = `orca-ph-openai-api-key-${id}` + expect(vmEnv(h).OPENAI_API_KEY).toBe(placeholder) + expect(await proxied(id, 'api.openai.com', { authorization: `Bearer ${placeholder}` })).toBe(200) + expect(upstream.at(-1)!.headers.get('authorization')).toBe(`Bearer ${PERSONAL_OPENAI}`) + + expect((await share(id, 'team')).status).toBe(200) + expect(vmEnv(h, ENV_COMMAND)).not.toHaveProperty('OPENAI_API_KEY') + // A process still holding the old placeholder gets nothing for it. + expect(await proxied(id, 'api.openai.com', { authorization: `Bearer ${placeholder}` })).toBe(403) + expect(upstream).toHaveLength(1) + }) +}) diff --git a/src/renderer/src/components/cloud-workspaces/CloudWorkspacesHome.tsx b/src/renderer/src/components/cloud-workspaces/CloudWorkspacesHome.tsx index fb899cecf65..5533b809edb 100644 --- a/src/renderer/src/components/cloud-workspaces/CloudWorkspacesHome.tsx +++ b/src/renderer/src/components/cloud-workspaces/CloudWorkspacesHome.tsx @@ -221,8 +221,15 @@ export function CloudWorkspacesHome({ void refreshCloudMessageSenders().catch(() => undefined) } if (change.visibility === 'team') { + // SEC-5: the owner's personal secrets stop being injected once teammates can open it. toast.success( - t('auto.components.cloudWorkspaces.sharedWithYourTeam', 'Shared with your team') + t('auto.components.cloudWorkspaces.sharedWithYourTeam', 'Shared with your team'), + { + description: t( + 'auto.components.cloudWorkspaces.sharedPersonalSecretsNote', + 'Your personal cloud secrets aren’t used in shared workspaces. Shared and repository secrets still are.' + ) + } ) } else if (change.visibility === 'private') { toast.success( diff --git a/src/renderer/src/components/orca-cloud/CloudWorkspaceMenuItems.tsx b/src/renderer/src/components/orca-cloud/CloudWorkspaceMenuItems.tsx index 22695bdef73..ab4fc3cb34c 100644 --- a/src/renderer/src/components/orca-cloud/CloudWorkspaceMenuItems.tsx +++ b/src/renderer/src/components/orca-cloud/CloudWorkspaceMenuItems.tsx @@ -178,14 +178,25 @@ async function setVisibility( ) return } - toast.success( - visibility === 'team' - ? t('auto.components.cloudWorkspaces.sharedWithYourTeam', 'Shared with your team') - : t( - 'auto.components.cloudWorkspaces.nowPrivate', - 'Now private. Teammates’ devices were disconnected.' + if (visibility === 'team') { + // SEC-5: the owner's personal secrets stop being injected once teammates can open it. + toast.success( + t('auto.components.cloudWorkspaces.sharedWithYourTeam', 'Shared with your team'), + { + description: t( + 'auto.components.cloudWorkspaces.sharedPersonalSecretsNote', + 'Your personal cloud secrets aren’t used in shared workspaces. Shared and repository secrets still are.' ) - ) + } + ) + } else { + toast.success( + t( + 'auto.components.cloudWorkspaces.nowPrivate', + 'Now private. Teammates’ devices were disconnected.' + ) + ) + } reload() } diff --git a/src/renderer/src/components/settings/cloud-secrets/CloudSecretsPane.fixture.tsx b/src/renderer/src/components/settings/cloud-secrets/CloudSecretsPane.fixture.tsx index ad5d0d8ea23..ab2291b3601 100644 --- a/src/renderer/src/components/settings/cloud-secrets/CloudSecretsPane.fixture.tsx +++ b/src/renderer/src/components/settings/cloud-secrets/CloudSecretsPane.fixture.tsx @@ -199,7 +199,7 @@ function list( initialMode={mode} initialSaved={options.saved} onSetUpAgent={noop} - description="Only you get these, in every Orca Cloud workspace you start." + description="Only you get these, in cloud workspaces you haven’t shared. Shared workspaces don’t get them." /> ) diff --git a/src/renderer/src/components/settings/cloud-secrets/CloudSecretsPane.tsx b/src/renderer/src/components/settings/cloud-secrets/CloudSecretsPane.tsx index ca4475202fd..70d236a775e 100644 --- a/src/renderer/src/components/settings/cloud-secrets/CloudSecretsPane.tsx +++ b/src/renderer/src/components/settings/cloud-secrets/CloudSecretsPane.tsx @@ -201,8 +201,8 @@ export function CloudSecretsPane({ ) : null } description={t( - 'cloudSecrets.personal.description', - 'Only you get these, in every Orca Cloud workspace you start.' + 'cloudSecrets.personal.descriptionUnshared', + 'Only you get these, in cloud workspaces you haven’t shared. Shared workspaces don’t get them.' )} emptyTitle={t('cloudSecrets.personal.emptyTitle', 'No personal secrets yet')} emptyDetail={t( diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index b396585c676..aa8947d3d48 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -21606,7 +21606,8 @@ "errorTrialSandboxLimit_one": "Your trial can run {{count}} sandbox at once. Put it to sleep or delete it, then try again.", "errorTrialSandboxLimit_other": "Your trial can run {{count}} sandboxes at once. Put one to sleep or delete it, then try again.", "errorTeamSandboxLimitNoSeat_one": "Your team can run {{count}} sandbox at once. Put it to sleep or delete it, then try again.", - "errorTeamSandboxLimitNoSeat_other": "Your team can run {{count}} sandboxes at once. Put one to sleep or delete it, then try again." + "errorTeamSandboxLimitNoSeat_other": "Your team can run {{count}} sandboxes at once. Put one to sleep or delete it, then try again.", + "sharedPersonalSecretsNote": "Your personal cloud secrets aren’t used in shared workspaces. Shared and repository secrets still are." }, "orcaCloud": { "QueuedPrompt": { @@ -28032,7 +28033,8 @@ "personal": { "description": "Only you get these, in every Orca Cloud workspace you start.", "emptyTitle": "No personal secrets yet", - "emptyDetail": "Add one or paste a .env file. New terminals and agents in your cloud workspaces get them as environment variables." + "emptyDetail": "Add one or paste a .env file. New terminals and agents in your cloud workspaces get them as environment variables.", + "descriptionUnshared": "Only you get these, in cloud workspaces you haven’t shared. Shared workspaces don’t get them." }, "org": { "description": "Everyone on your team gets these in their Orca Cloud workspaces.", From c1af476589e442b7c9a791f9fb803d5f297a70a7 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 12 Nov 2026 22:05:21 -0800 Subject: [PATCH 25/50] fix(cloud): PAIRED-WAKING-OVERLAY a paired wake reports connecting until the VM runs, so the Waking overlay shows (#620) wakePairedCloudWorkspace reported 'ready' as soon as the control plane accepted the wake (well under the overlay's 300ms show delay) while the VM took ~8s more, so the overlay never appeared and the pane sat in Reconnecting. It now reports 'connecting' and re-reads status every 1.5s (bounded at 120s) until it says running, then 'ready'; a VM that never runs fails the wake (stalled copy). --- .../lib/orca-cloud/paired-cloud-wake.test.ts | 53 ++++++++++++++++++- .../src/lib/orca-cloud/paired-cloud-wake.ts | 42 +++++++++++++-- 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/lib/orca-cloud/paired-cloud-wake.test.ts b/src/renderer/src/lib/orca-cloud/paired-cloud-wake.test.ts index 23436ea9625..809d229214e 100644 --- a/src/renderer/src/lib/orca-cloud/paired-cloud-wake.test.ts +++ b/src/renderer/src/lib/orca-cloud/paired-cloud-wake.test.ts @@ -13,6 +13,8 @@ vi.mock('@/lib/worktree-sleep-view', () => ({ isWorktreeSleepViewHeld: () => fal import { isPredictiveWakeCandidate } from './install-cloud-predictive-wake' import { + PAIRED_WAKE_MAX_WAIT_MS, + PAIRED_WAKE_POLL_MS, notePairedCloudSlept, wakePairedCloudWorkspace, wakePairedCloudWorkspaceIfAsleep @@ -39,7 +41,14 @@ beforeEach(() => { status: { [ROW]: statusEntry }, idsLoaded: true }) - wake = vi.fn(async () => ({ ok: true, value: undefined })) + // The control plane accepts the wake; the VM then reports running (what the overlay waits for). + wake = vi.fn(async () => { + window.api.orcaCloudWorkspace.getStatus = async () => ({ + ok: true as const, + value: { ...asleep, state: 'ready' as const } + }) + return { ok: true, value: undefined } + }) vi.stubGlobal('window', { api: { cloudWorkspaces: { @@ -180,3 +189,45 @@ describe('the first open right after Sleep (n38 GATE: it sat in "Reconnecting… expect(wake).not.toHaveBeenCalled() }) }) + +describe('the waking overlay for a paired wake (PAIRED-WAKING-OVERLAY)', () => { + it('stays "waking"/"connecting" until the VM reports running, then ready (not when the request returns)', async () => { + let polls = 0 + wake.mockImplementationOnce(async () => { + // Accepted, but the VM takes a moment: asleep on the first read, running on the next. + window.api.orcaCloudWorkspace.getStatus = async () => { + polls += 1 + return { + ok: true as const, + value: { ...asleep, state: polls > 1 ? ('ready' as const) : ('sleeping' as const) } + } + } + return { ok: true, value: undefined } + }) + vi.useFakeTimers() + try { + const pending = wakePairedCloudWorkspace(ROW) + await vi.advanceTimersByTimeAsync(0) + expect(getCloudWorkspaceSnapshot().wake[ROW]?.phase).toBe('connecting') + await vi.advanceTimersByTimeAsync(PAIRED_WAKE_POLL_MS) + expect(await pending).toEqual({ ok: true }) + // (The store re-reads once more on "ready".) + expect(polls).toBeGreaterThanOrEqual(2) + } finally { + vi.useRealTimers() + } + }) + + it('a VM that never reports running fails the wake (the overlay says so) instead of claiming ready', async () => { + wake.mockImplementationOnce(async () => ({ ok: true, value: undefined })) + vi.useFakeTimers() + try { + const pending = wakePairedCloudWorkspace(ROW) + await vi.advanceTimersByTimeAsync(PAIRED_WAKE_MAX_WAIT_MS + PAIRED_WAKE_POLL_MS) + await pending + expect(getCloudWorkspaceSnapshot().wake[ROW]?.phase).toBe('failed') + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/renderer/src/lib/orca-cloud/paired-cloud-wake.ts b/src/renderer/src/lib/orca-cloud/paired-cloud-wake.ts index 9bddd52ab09..f4b41038646 100644 --- a/src/renderer/src/lib/orca-cloud/paired-cloud-wake.ts +++ b/src/renderer/src/lib/orca-cloud/paired-cloud-wake.ts @@ -28,6 +28,33 @@ export function notePairedCloudSlept(workspaceId: string): void { void refreshCloudWorkspaceStatus(workspaceId) } +/** How often, and for how long, an explicit wake re-reads status until the VM runs. */ +export const PAIRED_WAKE_POLL_MS = 1_500 +export const PAIRED_WAKE_MAX_WAIT_MS = 120_000 + +function wakeStalledMessage(): string { + return translate( + 'orcaCloudWorkspace.overlay.stalled', + 'Orca Cloud stopped responding while waking.' + ) +} + +/** Re-reads status until it says running (true) or the wait runs out (false). Bounded: it stops. */ +async function waitUntilRunning(workspaceId: string): Promise { + const deadline = Date.now() + PAIRED_WAKE_MAX_WAIT_MS + for (;;) { + await refreshCloudWorkspaceStatus(workspaceId) + const entry = getCloudWorkspaceSnapshot().status[workspaceId] + if (!entry?.error && entry?.status?.state === 'ready') { + return true + } + if (Date.now() + PAIRED_WAKE_POLL_MS > deadline) { + return false + } + await new Promise((resolve) => setTimeout(resolve, PAIRED_WAKE_POLL_MS)) + } +} + export type PairedWakeResult = { ok: true } | { ok: false; message: string; reason?: string } /** Is this sidebar id a paired cloud workspace (registered by the paired registry)? */ @@ -57,7 +84,7 @@ export async function wakePairedCloudWorkspace( const startedAt = Date.now() const source = options.source ?? 'open' const report = ( - phase: 'waking' | 'ready' | 'failed', + phase: 'waking' | 'connecting' | 'ready' | 'failed', failure?: { message: string; reason?: string } ): void => { if (!options.quiet) { @@ -83,11 +110,20 @@ export async function wakePairedCloudWorkspace( })) if (result.ok) { sleptHere.delete(workspaceId) - report('ready') + // The control plane accepts the wake before the VM runs (it takes seconds more): "ready" only + // once its status says so, or the overlay never outlasts its show delay (PAIRED-WAKING-OVERLAY). + report('connecting') + if (options.quiet) { + await refreshCloudWorkspaceStatus(workspaceId) + } else if (await waitUntilRunning(workspaceId)) { + report('ready') + } else { + report('failed', { message: wakeStalledMessage() }) + } } else { report('failed', { message: result.error, ...(result.reason ? { reason: result.reason } : {}) }) + await refreshCloudWorkspaceStatus(workspaceId) } - await refreshCloudWorkspaceStatus(workspaceId) return result.ok ? { ok: true } : { ok: false, message: result.error, ...(result.reason ? { reason: result.reason } : {}) } From 85173fa58372e043d9ae3ab65b7814bd555cf8e3 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 17:34:25 -0800 Subject: [PATCH 26/50] =?UTF-8?q?feat(cloud):=20remove=20the=20Home=20clou?= =?UTF-8?q?d=20workspaces=20list;=20add=20Open=20cloud=20workspace?= =?UTF-8?q?=E2=80=A6=20picker=20(HOME-CLOUD-LIST-RM)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Landing is back to upstream's layout; its Add project opens the sidebar's Add project menu. Everything only the Home list did moves to one picker reached from the Add project menus, the palette, and every former Home pointer (paired-wake not-paired card, primary-delete toast, running limit, teammate profile, team delete). Co-Authored-By: Claude --- src/renderer/src/App.tsx | 2 + src/renderer/src/components/Landing.tsx | 96 ++-- .../billing/team/use-team-delete.tsx | 14 +- .../cloud-workspaces/CloudWorkspaceGroup.tsx | 180 -------- .../CloudWorkspacePicker.fixture.tsx | 115 +++++ .../CloudWorkspacePicker.test.tsx | 161 +++++++ .../cloud-workspaces/CloudWorkspacePicker.tsx | 358 +++++++++++++++ .../CloudWorkspaceRow.fixture.tsx | 16 +- .../cloud-workspaces/CloudWorkspaceRow.tsx | 47 +- .../CloudWorkspaceRowMenu.tsx | 85 +--- .../CloudWorkspacesHome.fixture.tsx | 412 ----------------- .../cloud-workspaces/CloudWorkspacesHome.tsx | 433 ------------------ .../CloudWorkspacesStates.fixture.tsx | 18 +- .../CloudWorkspacesStates.test.tsx | 71 ++- .../CloudWorkspacesStates.tsx | 209 +++------ ...oudWorkspacesToolbar.routine-runs.test.tsx | 128 ------ .../CloudWorkspacesToolbar.tsx | 310 ------------- .../OpenCloudWorkspaceMenuItem.tsx | 51 +++ .../cloud-archive-reach.test.tsx | 241 ---------- .../cloud-workspace-action-groups.test.ts | 10 +- .../cloud-workspace-action-groups.ts | 26 +- .../cloud-workspace-home-archive.ts | 5 +- .../cloud-workspaces/cloud-workspace-links.ts | 35 +- .../cloud-workspace-local-titles.ts | 8 +- .../cloud-workspace-picker-model.test.ts | 113 +++++ .../cloud-workspace-picker-model.ts | 110 +++++ .../cloud-workspace-picker-store.ts | 54 +++ .../cloud-workspaces-home-nav.ts | 44 -- .../cloud-workspaces-home-prefs.ts | 60 --- .../cloud-workspaces-in-sidebar.ts | 51 +++ .../cloud-workspaces.test-fixture.ts | 7 +- .../use-cloud-archived-count.ts | 123 ----- .../use-cloud-home-filters.ts | 49 -- .../use-cloud-workspace-arrangement.ts | 182 -------- .../use-cloud-workspace-row-actions.tsx | 208 +++++++++ .../cloud-workspaces/use-cloud-workspaces.ts | 7 + .../open-cloud-workspace-quick-action.test.ts | 44 ++ .../open-cloud-workspace-quick-action.ts | 35 ++ .../components/cmd-j/quick-action-context.ts | 11 + .../src/components/cmd-j/quick-actions.ts | 2 + .../src/components/landing-layout.fixture.tsx | 27 -- .../src/components/landing-layout.tsx | 60 +-- .../landing/LandingAddProjectMenu.test.tsx | 96 ++++ .../landing/LandingAddProjectMenu.tsx | 33 ++ .../components/landing/LandingCloudCard.tsx | 2 +- .../landing/LandingCloudSignIn.fixture.tsx | 40 +- .../landing/LandingCloudSignInCard.tsx | 5 +- .../run-on/CloudStartSurfaces.fixture.tsx | 10 +- .../orca-cloud/CloudNotPairedCard.fixture.tsx | 31 ++ .../orca-cloud/CloudNotPairedCard.tsx | 65 +++ .../orca-cloud/CloudPrimaryDeleteDialog.tsx | 15 +- .../orca-cloud/CloudWakingOverlay.tsx | 5 +- .../sidebar/sidebar-header-actions.tsx | 3 + .../teammates/TeammateProfileDialog.tsx | 4 +- .../worktree-creation/VmBuildSheet.tsx | 6 +- ...use-worktree-jump-palette-quick-actions.ts | 19 +- .../src/i18n/en-runtime-required.json | 12 +- src/renderer/src/i18n/locales/en.json | 89 ++-- .../paired-cloud-wake-not-paired.test.ts | 49 ++ .../src/lib/orca-cloud/paired-cloud-wake.ts | 26 +- .../hosted-sandbox/cloud-workspace-api.ts | 2 + .../languages/protected-copy-keys.json | 3 +- 62 files changed, 1958 insertions(+), 2775 deletions(-) delete mode 100644 src/renderer/src/components/cloud-workspaces/CloudWorkspaceGroup.tsx create mode 100644 src/renderer/src/components/cloud-workspaces/CloudWorkspacePicker.fixture.tsx create mode 100644 src/renderer/src/components/cloud-workspaces/CloudWorkspacePicker.test.tsx create mode 100644 src/renderer/src/components/cloud-workspaces/CloudWorkspacePicker.tsx delete mode 100644 src/renderer/src/components/cloud-workspaces/CloudWorkspacesHome.fixture.tsx delete mode 100644 src/renderer/src/components/cloud-workspaces/CloudWorkspacesHome.tsx delete mode 100644 src/renderer/src/components/cloud-workspaces/CloudWorkspacesToolbar.routine-runs.test.tsx delete mode 100644 src/renderer/src/components/cloud-workspaces/CloudWorkspacesToolbar.tsx create mode 100644 src/renderer/src/components/cloud-workspaces/OpenCloudWorkspaceMenuItem.tsx delete mode 100644 src/renderer/src/components/cloud-workspaces/cloud-archive-reach.test.tsx create mode 100644 src/renderer/src/components/cloud-workspaces/cloud-workspace-picker-model.test.ts create mode 100644 src/renderer/src/components/cloud-workspaces/cloud-workspace-picker-model.ts create mode 100644 src/renderer/src/components/cloud-workspaces/cloud-workspace-picker-store.ts delete mode 100644 src/renderer/src/components/cloud-workspaces/cloud-workspaces-home-nav.ts delete mode 100644 src/renderer/src/components/cloud-workspaces/cloud-workspaces-home-prefs.ts create mode 100644 src/renderer/src/components/cloud-workspaces/cloud-workspaces-in-sidebar.ts delete mode 100644 src/renderer/src/components/cloud-workspaces/use-cloud-archived-count.ts delete mode 100644 src/renderer/src/components/cloud-workspaces/use-cloud-home-filters.ts delete mode 100644 src/renderer/src/components/cloud-workspaces/use-cloud-workspace-arrangement.ts create mode 100644 src/renderer/src/components/cloud-workspaces/use-cloud-workspace-row-actions.tsx create mode 100644 src/renderer/src/components/cmd-j/open-cloud-workspace-quick-action.test.ts create mode 100644 src/renderer/src/components/cmd-j/open-cloud-workspace-quick-action.ts delete mode 100644 src/renderer/src/components/landing-layout.fixture.tsx create mode 100644 src/renderer/src/components/landing/LandingAddProjectMenu.test.tsx create mode 100644 src/renderer/src/components/landing/LandingAddProjectMenu.tsx create mode 100644 src/renderer/src/components/orca-cloud/CloudNotPairedCard.fixture.tsx create mode 100644 src/renderer/src/components/orca-cloud/CloudNotPairedCard.tsx create mode 100644 src/renderer/src/lib/orca-cloud/paired-cloud-wake-not-paired.test.ts diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 0626a79d9c6..9d4f2c684e7 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -15,6 +15,7 @@ import { SecretRequestUnseenNotifier } from './components/agent-pane/SecretReque import { LocalCommandApprovalDialogHost } from './components/local-commands/LocalCommandApprovalDialog' import { TeamDialogHost } from './components/billing/team/TeamDialogHost' import { TeammateProfileDialog } from './components/teammates/TeammateProfileDialog' +import { CloudWorkspacePickerDialog } from './components/cloud-workspaces/CloudWorkspacePicker' import { PokesNotifier } from './components/teammates/PokesNotifier' import { BrowserPagePrompts } from './components/browser-pane/browser-page-prompts' import { LinkRoutingPreferenceDialogProvider } from './components/link-routing-preference-dialog' @@ -137,6 +138,7 @@ function App(): React.JSX.Element { + diff --git a/src/renderer/src/components/Landing.tsx b/src/renderer/src/components/Landing.tsx index 87b6a076a32..3b1e854506f 100644 --- a/src/renderer/src/components/Landing.tsx +++ b/src/renderer/src/components/Landing.tsx @@ -1,12 +1,9 @@ import { LandingCloudCard } from './landing/LandingCloudCard' import { LandingCloudSignInCard, useSignedOutOfOrcaCloud } from './landing/LandingCloudSignInCard' -import { - CloudWorkspacesHome, - useCloudWorkspacesPresent -} from './cloud-workspaces/CloudWorkspacesHome' -import { useCloudWorkspaceHighlight } from './cloud-workspaces/cloud-workspace-links' +import { LandingAddProjectMenu } from './landing/LandingAddProjectMenu' +import { useCloudWorkspacesPresent } from './cloud-workspaces/use-cloud-workspaces' import { useEffect, useEffectEvent, useMemo, useRef, useState } from 'react' -import { AlertTriangle, ExternalLink, Star, X } from 'lucide-react' +import { AlertTriangle, ExternalLink, FolderPlus, GitBranchPlus, Star, X } from 'lucide-react' import { cn } from '../lib/class-names' import { useAppStore } from '../store/index' import { isGitRepoKind } from '../../../shared/repo-kind' @@ -24,7 +21,7 @@ import { useLandingPreflightRuntime } from './landing-preflight-runtime' import { useLandingOrcaStarState, type LandingStarState } from './landing-github-star-state' import { openAppHttpLink } from '@/lib/browser/navigation/app-http-link-open' import { - LandingActions, + LANDING_ACTION_BUTTON_CLASS, LandingBrand, LandingShortcutList, type LandingShortcutItem @@ -222,10 +219,9 @@ function PreflightBanner({ export default function Landing(): React.JSX.Element { const t = useTranslate() - const cloudHighlightId = useCloudWorkspaceHighlight() - const cloudListPresent = useCloudWorkspacesPresent() const repos = useAppStore((s) => s.repos) const openModal = useAppStore((s) => s.openModal) + const cloudPresent = useCloudWorkspacesPresent() const signedOutOfCloud = useSignedOutOfOrcaCloud() const reconnectRequired = useAppStore( (s) => s.orcaProfileAuthStatus?.state === 'reconnect-required' @@ -264,57 +260,47 @@ export default function Landing(): React.JSX.Element { ]) return ( -
- {/* Centered when alone. With the cloud list below, the list is the page (JA-30): no wordmark - or "select a workspace" line, just the primary actions as a top row aligned with it. */} -
-
-
- {!cloudListPresent && } +
+
+
+ - {preflightIssues.length > 0 && ( - - )} + {preflightIssues.length > 0 && } - {/* With the cloud list showing, only the no-project hint stays: the list is what to pick. */} - {(!cloudListPresent || !hasProjects) && ( -

- {hasProjects - ? t( - 'auto.components.Landing.9c00bd4adf', - 'Select a workspace from the sidebar to begin.' - ) - : t('auto.components.Landing.cd21242762', 'Add a project to get started.')} -

- )} +

+ {hasProjects + ? t( + 'auto.components.Landing.9c00bd4adf', + 'Select a workspace from the sidebar to begin.' + ) + : t('auto.components.Landing.cd21242762', 'Add a project to get started.')} +

- openModal('add-repo')} - onCreate={() => openModal('new-workspace-composer', { telemetrySource: 'unknown' })} - /> +
+ + + - {/* V3-08: signed out, the team's cloud workspaces can't be listed; say so where they'd be. */} - {hasProjects && signedOutOfCloud && !cloudListPresent ? ( - - ) : hasProjects ? ( - - ) : null} - - {!cloudListPresent && } +
-
-
- + + {/* V3-08: signed out, the team's cloud workspaces can't be opened; say so here. */} + {hasProjects && signedOutOfCloud ? ( + + ) : hasProjects ? ( + + ) : null} + +
diff --git a/src/renderer/src/components/billing/team/use-team-delete.tsx b/src/renderer/src/components/billing/team/use-team-delete.tsx index 9f7b8da0bb1..77b86d2ec32 100644 --- a/src/renderer/src/components/billing/team/use-team-delete.tsx +++ b/src/renderer/src/components/billing/team/use-team-delete.tsx @@ -9,7 +9,7 @@ import type { OrcaProfileOrgDeleteCheckResult, OrcaProfileOrgDeleteResult } from '../../../../../shared/orca-org-delete' -import { openCloudWorkspacesHome } from '../../cloud-workspaces/cloud-workspaces-home-nav' +import { openCloudWorkspacePicker } from '../../cloud-workspaces/cloud-workspace-picker-store' import { TeamDeleteDialogView } from './TeamDeleteDialog' import type { TeamDeleteCheck } from './TeamDeleteSection' import { teamDeleteFailure, type TeamDeleteDialogError } from './team-delete-copy' @@ -36,13 +36,9 @@ export function teamDeleteCheckFrom( } } -/** Home with every team workspace, and the Archived switch on when any are archived. */ -async function showTeamWorkspaces(): Promise { - const archived = await window.api.cloudWorkspaces - .list({ scope: 'team', archived: 'only' }) - .then((result) => result.ok && result.value.length > 0) - .catch(() => false) - openCloudWorkspacesHome({ scope: 'team', archived }) +/** Every team workspace, archived ones too, so each can be deleted before the team. */ +function showTeamWorkspaces(): void { + openCloudWorkspacePicker({ kind: 'team-cleanup' }) } export function useTeamDelete({ @@ -130,7 +126,7 @@ export function useTeamDelete({ if (code === 'subscription_active') { onManageBilling() } else { - void showTeamWorkspaces() + showTeamWorkspaces() } } diff --git a/src/renderer/src/components/cloud-workspaces/CloudWorkspaceGroup.tsx b/src/renderer/src/components/cloud-workspaces/CloudWorkspaceGroup.tsx deleted file mode 100644 index a4e73f38fe8..00000000000 --- a/src/renderer/src/components/cloud-workspaces/CloudWorkspaceGroup.tsx +++ /dev/null @@ -1,180 +0,0 @@ -// One group on Home: Pinned, a section, or "No section". Named sections collapse; every group's -// rows reorder by dragging (or Move up / Move down in the row menu), and the order syncs to your -// other devices through your prefs. -import React, { useState } from 'react' -import { ChevronRight, MoreHorizontal, Pin } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' -import { useTranslate } from '@/i18n/use-translate' -import { cn } from '@/lib/class-names' -import type { CloudWorkspaceGroup } from '../../../../shared/cloud-workspaces/cloud-workspace-arrangement' -import type { CloudWorkspace } from '../../../../shared/cloud-workspaces/cloud-workspaces-wire' - -type Group = CloudWorkspaceGroup - -export type GroupDrag = { - /** The row being dragged and the group it came from (rows only drop within their own group). */ - dragging: { id: string; group: string } | null - start: (id: string, group: string) => void - drop: (overId: string, group: string) => void - end: () => void -} - -/** Drag props for one row's
  • ; inert while reduced to a single row. */ -export function rowDragProps( - drag: GroupDrag, - group: Group, - id: string -): React.LiHTMLAttributes & { 'data-drop-target'?: string } { - if (group.rows.length < 2) { - return {} - } - return { - draggable: true, - onDragStart: (event) => { - event.dataTransfer.effectAllowed = 'move' - // Some platforms need data set for the drag to start at all. - event.dataTransfer.setData('text/plain', id) - drag.start(id, group.key) - }, - onDragOver: (event) => { - if (drag.dragging?.group === group.key) { - event.preventDefault() - event.dataTransfer.dropEffect = 'move' - } - }, - onDrop: (event) => { - if (drag.dragging?.group === group.key) { - event.preventDefault() - drag.drop(id, group.key) - } - }, - onDragEnd: () => drag.end() - } -} - -export function CloudWorkspaceGroupView({ - group, - index, - showHeading, - canMoveUp, - canMoveDown, - onToggleCollapsed, - onMoveSection, - children -}: { - group: Group - index: number - showHeading: boolean - canMoveUp: boolean - canMoveDown: boolean - onToggleCollapsed: (collapsed: boolean) => void - onMoveSection: (delta: number) => void - children: React.ReactNode -}): React.JSX.Element { - const t = useTranslate() - const [menuOpen, setMenuOpen] = useState(false) - // Section names are free text (spaces), so the id uses the position instead. - const headingId = `cloud-workspaces-section-${index}` - const listId = `cloud-workspaces-section-list-${index}` - const label = - group.kind === 'pinned' - ? t('auto.components.cloudWorkspaces.pinned', 'Pinned') - : (group.section ?? t('auto.components.cloudWorkspaces.otherWorkspaces', 'Other workspaces')) - const count = ( - <> - {/* The bare number is visual; screen readers get the counted phrase. */} - - {group.rows.length} - {' '} - - {t('auto.components.cloudWorkspaces.sectionCount', '{{count}} workspaces', { - count: group.rows.length, - defaultValue_one: '{{count}} workspace' - })} - - - ) - const collapsible = group.kind === 'section' - return ( -
    - {showHeading && ( -
    -

    - {collapsible ? ( - - ) : ( - - {group.kind === 'pinned' && } - {label} - {count} - - )} -

    - {collapsible && (canMoveUp || canMoveDown) && ( - - - - - - - onMoveSection(-1)}> - {t('auto.components.cloudWorkspaces.moveSectionUp', 'Move section up')} - - onMoveSection(1)}> - {t('auto.components.cloudWorkspaces.moveSectionDown', 'Move section down')} - - - - - )} -
    - )} - {!group.collapsed && ( -
      - {children} -
    - )} -
    - ) -} diff --git a/src/renderer/src/components/cloud-workspaces/CloudWorkspacePicker.fixture.tsx b/src/renderer/src/components/cloud-workspaces/CloudWorkspacePicker.fixture.tsx new file mode 100644 index 00000000000..e79af11b3da --- /dev/null +++ b/src/renderer/src/components/cloud-workspaces/CloudWorkspacePicker.fixture.tsx @@ -0,0 +1,115 @@ +// "Open cloud workspace…" (replaces the Home list): a teammate's shared workspace, one started on +// another computer, the empty and error states, and the focused lists other surfaces open it with. +import { defineFixture } from '../../component-fixtures/component-fixture' +import { CloudWorkspacePickerDialog } from './CloudWorkspacePicker' +import { + closeCloudWorkspacePicker, + openCloudWorkspacePicker, + type CloudWorkspacePickerFocus +} from './cloud-workspace-picker-store' +import { + AUTH_STATUS, + FIXTURE_NOW, + presence, + stubCloudWorkspacesApi, + workspace +} from './cloud-workspaces.test-fixture' +import type { CloudWorkspace } from '../../../../shared/cloud-workspaces/cloud-workspaces-wire' + +const WIDTH = 820 + +async function waitFor(check: () => boolean): Promise { + const deadline = performance.now() + 3000 + while (!check() && performance.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 50)) + } +} + +function rows(): CloudWorkspace[] { + return [ + workspace({ + title: 'Refund webhooks', + repo: { url: 'https://github.com/acme/payments.git', branch: 'refund-webhooks' }, + ownerUserId: 'user_grace', + createdBy: 'user_grace', + access: 'can_open', + presence: [presence('user_grace', 'typing')] + }), + workspace({ + title: 'Ledger migration', + ownerUserId: 'user_linus', + createdBy: 'user_linus', + access: 'can_open', + status: 'suspended', + lastActivityAt: FIXTURE_NOW - 3 * 3_600_000 + }), + workspace({ + // Yours, started on your laptop: this computer has no row for it yet. + title: 'Docs search', + repo: { url: 'https://github.com/acme/docs.git', branch: 'search' }, + status: 'suspended', + lastActivityAt: FIXTURE_NOW - 26 * 3_600_000 + }) + ] +} + +function picker( + focus: CloudWorkspacePickerFocus, + list: () => ReturnType[0]>, + ready: string +) { + return defineFixture({ + width: WIDTH, + store: { orcaProfileAuthStatus: AUTH_STATUS }, + setup: () => { + stubCloudWorkspacesApi(list) + closeCloudWorkspacePicker() + openCloudWorkspacePicker(focus) + }, + render: () => ( +
    + +
    + ), + interact: () => waitFor(() => document.querySelector(ready) !== null) + }) +} + +/** From the Add project menu or the palette: a teammate's shared one, and one from another computer. */ +export const sharedByTeammate = picker( + { kind: 'open' }, + () => Promise.resolve({ ok: true, value: rows() }), + '[data-cloud-workspace-row]' +) + +/** Nothing to open yet: start one. */ +export const empty = picker( + { kind: 'open' }, + () => Promise.resolve({ ok: true, value: [] }), + '[data-cloud-workspace-picker] p' +) + +/** The list could not be read: InlineError with Try again. */ +export const error = picker( + { kind: 'open' }, + () => Promise.resolve({ ok: false, error: 'unavailable' }), + '[data-slot="inline-error"]' +) + +/** A teammate's profile, "Show all": their workspaces. */ +export const teammate = picker( + { kind: 'teammate', userId: 'user_grace' }, + () => Promise.resolve({ ok: true, value: rows() }), + '[data-cloud-workspace-row]' +) + +/** At the running-sandbox limit: your running ones, to put one to sleep. */ +export const runningLimit = picker( + { kind: 'running' }, + () => + Promise.resolve({ + ok: true, + value: [workspace({ title: 'Checkout retries' }), workspace({ title: 'Flaky e2e repro' })] + }), + '[data-cloud-workspace-row]' +) diff --git a/src/renderer/src/components/cloud-workspaces/CloudWorkspacePicker.test.tsx b/src/renderer/src/components/cloud-workspaces/CloudWorkspacePicker.test.tsx new file mode 100644 index 00000000000..039c509cca8 --- /dev/null +++ b/src/renderer/src/components/cloud-workspaces/CloudWorkspacePicker.test.tsx @@ -0,0 +1,161 @@ +// @vitest-environment happy-dom +// "Open cloud workspace…": lists what the sidebar doesn't show, opens through openCloudWorkspace, +// and is what every former Home pointer opens now. +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import type { CloudWorkspace } from '../../../../shared/cloud-workspaces/cloud-workspaces-wire' +import { workspace } from './cloud-workspaces.test-fixture' +import type * as OpenCloudWorkspaceNs from './open-cloud-workspace' + +type OpenCloudWorkspaceModule = typeof OpenCloudWorkspaceNs + +const mocks = vi.hoisted(() => { + const state = { + orcaProfileAuthStatus: { + activeProfileId: 'profile-1', + configured: true, + state: 'connected', + persistence: 'encrypted', + cloud: { userId: 'user_ada', activeOrgId: 'org_acme', activeOrgName: 'Acme' } + }, + connectCurrentOrcaProfile: async () => {}, + keybindings: {}, + ephemeralVmRuntimesByWorkspaceId: {} + } + return { + state, + openCloudWorkspace: vi.fn(), + inSidebar: new Set() + } +}) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign( + (selector: (s: typeof mocks.state) => unknown) => selector(mocks.state), + { getState: () => mocks.state, subscribe: () => () => {} } + ) +})) +vi.mock('../billing/team/use-org-roster', async () => { + const { ROSTER } = await import('./cloud-workspaces.test-fixture') + const result = { status: 'ok' as const, roster: ROSTER } + return { useOrgRoster: () => ({ result }) } +}) +vi.mock('../new-workspace/run-on/OrcaCloudTrialUsedUpNotice', () => ({ + OrcaCloudTrialUsedUpBanner: () => null +})) +vi.mock('./cloud-workspaces-in-sidebar', () => ({ + useCloudWorkspaceIdsInSidebar: () => mocks.inSidebar +})) +vi.mock('./open-cloud-workspace', async (importOriginal) => ({ + ...(await importOriginal()), + openCloudWorkspace: mocks.openCloudWorkspace +})) + +import { CloudWorkspacePickerDialog } from './CloudWorkspacePicker' +import { + closeCloudWorkspacePicker, + getCloudWorkspacePickerRequest, + openCloudWorkspacePicker +} from './cloud-workspace-picker-store' +import { CloudNotPairedCard } from '../orca-cloud/CloudNotPairedCard' + +type ListResult = { ok: true; value: CloudWorkspace[] } | { ok: false; error: string } +let listResult: ListResult + +const MINE = workspace({ title: 'Already in my sidebar' }) +const SHARED = workspace({ + title: 'Refund webhooks', + ownerUserId: 'user_grace', + createdBy: 'user_grace', + access: 'can_open' +}) + +beforeEach(() => { + mocks.inSidebar = new Set([MINE.id]) + mocks.openCloudWorkspace.mockReset() + listResult = { ok: true, value: [MINE, SHARED] } + vi.stubGlobal('api', { + cloudWorkspaces: { + available: () => Promise.resolve(true), + list: vi.fn(() => Promise.resolve(listResult)) + } + }) +}) + +afterEach(() => { + act(() => closeCloudWorkspacePicker()) + cleanup() + vi.unstubAllGlobals() +}) + +function renderPicker(): void { + render( + + + + ) +} + +describe('Open cloud workspace…', () => { + it('lists a teammate’s shared workspace and leaves out what the sidebar already shows', async () => { + renderPicker() + act(() => openCloudWorkspacePicker()) + expect(await screen.findByText('Refund webhooks')).toBeTruthy() + expect(screen.queryByText('Already in my sidebar')).toBeNull() + expect(screen.getByText('1 more is already in your sidebar.')).toBeTruthy() + }) + + it('opens through openCloudWorkspace, then closes', async () => { + mocks.openCloudWorkspace.mockResolvedValue({ ok: true, worktreeId: 'wt-1' }) + renderPicker() + act(() => openCloudWorkspacePicker()) + fireEvent.click(await screen.findByRole('button', { name: /^Refund webhooks, Running/ })) + await waitFor(() => expect(getCloudWorkspacePickerRequest()).toBeNull()) + expect(mocks.openCloudWorkspace).toHaveBeenCalledWith(SHARED) + }) + + it('a failed open stays open (the error is a toast), so another can be picked', async () => { + mocks.openCloudWorkspace.mockResolvedValue({ ok: false, message: 'No access' }) + renderPicker() + act(() => openCloudWorkspacePicker()) + fireEvent.click(await screen.findByRole('button', { name: /^Refund webhooks, Running/ })) + await waitFor(() => expect(mocks.openCloudWorkspace).toHaveBeenCalled()) + expect(getCloudWorkspacePickerRequest()).not.toBeNull() + }) + + it('with everything in the sidebar, says so instead of inviting a first workspace', async () => { + listResult = { ok: true, value: [MINE] } + renderPicker() + act(() => openCloudWorkspacePicker()) + expect(await screen.findByText('Your cloud workspaces are already in the sidebar')).toBeTruthy() + expect(screen.queryByRole('button', { name: 'New cloud workspace' })).toBeNull() + }) + + it('a list that could not be read offers Try again', async () => { + listResult = { ok: false, error: 'unavailable' } + renderPicker() + act(() => openCloudWorkspacePicker()) + expect(await screen.findByRole('button', { name: 'Try again' })).toBeTruthy() + }) + + it('a teammate focus lists their workspaces, sidebar ones included', async () => { + mocks.inSidebar = new Set([SHARED.id]) + renderPicker() + act(() => openCloudWorkspacePicker({ kind: 'teammate', userId: 'user_grace' })) + expect(await screen.findByText('Refund webhooks')).toBeTruthy() + expect(screen.getByText('Grace Hopper’s cloud workspaces')).toBeTruthy() + expect(screen.queryByText('Already in my sidebar')).toBeNull() + }) +}) + +describe('pointers that used to send people to Home', () => { + it('a workspace whose pairing is gone offers Open cloud workspace…, not a text pointer', () => { + const onDismiss = vi.fn() + render() + expect(screen.getByText('Refund webhooks isn’t linked to this computer')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Open cloud workspace…' })) + expect(getCloudWorkspacePickerRequest()?.focus).toEqual({ kind: 'open' }) + expect(onDismiss).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/cloud-workspaces/CloudWorkspacePicker.tsx b/src/renderer/src/components/cloud-workspaces/CloudWorkspacePicker.tsx new file mode 100644 index 00000000000..cfba49b2cef --- /dev/null +++ b/src/renderer/src/components/cloud-workspaces/CloudWorkspacePicker.tsx @@ -0,0 +1,358 @@ +// "Open cloud workspace…": cloud workspaces this computer's sidebar doesn't show, as the same rows +// (and ⋯ menu) the team list had. Open goes through openCloudWorkspace and closes the picker. +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { RefreshCw, Search } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Switch } from '@/components/ui/switch' +import { SettingsSegmentedControl } from '@/components/settings/form-controls/SettingsFormControls' +import { useTranslate, type Translate } from '@/i18n/use-translate' +import { cn } from '@/lib/class-names' +import { installWindowVisibilityInterval } from '@/lib/window-activity/window-visibility-interval' +import { openNewCloudWorkspace } from '@/hooks/ipc-events/new-workspace-command' +import { useOrcaAccountKey } from '@/hooks/use-orca-account-key' +import { useAppStore } from '@/store' +import { OrcaCloudTrialUsedUpBanner } from '../new-workspace/run-on/OrcaCloudTrialUsedUpNotice' +import { CloudWorkspaceRow } from './CloudWorkspaceRow' +import { + ErrorState, + PickerEmptyState, + SkeletonRows, + type PickerEmptyReason +} from './CloudWorkspacesStates' +import { useListedCloudWorkspaces } from './cloud-workspace-local-titles' +import { useCloudWorkspaceIdsInSidebar } from './cloud-workspaces-in-sidebar' +import { + initialPickerControls, + pickerHidesSidebarWorkspaces, + pickerListQuery, + pickerRows, + pickerShowsArchived, + pickerShowsScope, + type CloudWorkspacePickerControls +} from './cloud-workspace-picker-model' +import { + closeCloudWorkspacePicker, + useCloudWorkspacePickerRequest, + type CloudWorkspacePickerFocus +} from './cloud-workspace-picker-store' +import { useCloudWorkspaceRowActions, useMemberDirectory } from './use-cloud-workspace-row-actions' +import { useCloudWorkspaces } from './use-cloud-workspaces' + +function pickerCopy( + focus: CloudWorkspacePickerFocus, + teammateName: string | null, + t: Translate +): { title: string; description: string } { + switch (focus.kind) { + case 'open': + return { + title: t('components.cloudWorkspaces.picker.title', 'Open cloud workspace'), + description: t( + 'components.cloudWorkspaces.picker.description', + 'Cloud workspaces that aren’t in your sidebar: shared by teammates, or started on another computer.' + ) + } + case 'teammate': + return { + title: teammateName + ? t('components.cloudWorkspaces.picker.teammateTitle', '{{name}}’s cloud workspaces', { + name: teammateName + }) + : t('components.cloudWorkspaces.picker.title', 'Open cloud workspace'), + description: t( + 'components.cloudWorkspaces.picker.teammateDescription', + 'The ones they own or started that you can see.' + ) + } + case 'running': + return { + title: t('components.cloudWorkspaces.picker.runningTitle', 'Your running cloud workspaces'), + description: t( + 'components.cloudWorkspaces.picker.runningDescription', + 'Put one to sleep or delete it to free a running slot, then try again.' + ) + } + case 'team-cleanup': + return { + title: t('components.cloudWorkspaces.picker.teamTitle', 'Your team’s cloud workspaces'), + description: t( + 'components.cloudWorkspaces.picker.teamDescription', + 'Delete these, archived ones too, before deleting the team.' + ) + } + } +} + +function emptyReason({ + focus, + controls, + hiddenInSidebar, + listedBeforeSearch, + teammateName +}: { + focus: CloudWorkspacePickerFocus + controls: CloudWorkspacePickerControls + hiddenInSidebar: number + listedBeforeSearch: boolean + teammateName: string | null +}): PickerEmptyReason { + if (controls.search.trim() && listedBeforeSearch) { + return { kind: 'search' } + } + if (controls.archived && pickerShowsArchived(focus)) { + return { kind: 'archived' } + } + if (focus.kind === 'running') { + return { kind: 'none-running' } + } + if (focus.kind === 'teammate') { + return { kind: 'teammate', name: teammateName ?? '' } + } + return hiddenInSidebar > 0 ? { kind: 'all-in-sidebar' } : { kind: 'none' } +} + +/** The picker's body; the dialog shell is CloudWorkspacePickerDialog. */ +export function CloudWorkspacePicker({ + focus, + onClose +}: { + focus: CloudWorkspacePickerFocus + onClose: () => void +}): React.JSX.Element { + const t = useTranslate() + const cloud = useAppStore((s) => s.orcaProfileAuthStatus?.cloud) + const signIn = useAppStore((s) => s.connectCurrentOrcaProfile) + const account = useOrcaAccountKey() + const orgId = cloud?.activeOrgId + const hasOrg = Boolean(orgId) + const selfId = cloud?.userId ?? null + const [controls, setControls] = useState(() => initialPickerControls(hasOrg)) + const [now, setNow] = useState(() => Date.now()) + const change = (next: Partial): void => + setControls((previous) => ({ ...previous, ...next })) + const query = pickerListQuery(focus, controls, hasOrg) + const { state, stale, reading, refresh, replace } = useCloudWorkspaces( + query, + Boolean(cloud), + account + ) + const inSidebar = useCloudWorkspaceIdsInSidebar(pickerHidesSidebarWorkspaces(focus)) + + // Relative "last active" times tick while the window is visible. + useEffect( + () => installWindowVisibilityInterval({ run: () => setNow(Date.now()), intervalMs: 30_000 }), + [] + ) + + const all = useListedCloudWorkspaces(state.status === 'ready' ? state.workspaces : null) + const { directory, canAdmin } = useMemberDirectory(orgId, all) + const { rows, hiddenInSidebar } = useMemo( + () => + pickerRows({ + workspaces: all, + focus, + inSidebar: inSidebar ?? new Set(), + search: controls.search, + directory + }), + [all, focus, inSidebar, controls.search, directory] + ) + const listedBeforeSearch = useMemo( + () => + pickerRows({ + workspaces: all, + focus, + inSidebar: inSidebar ?? new Set(), + search: '', + directory + }).rows.length > 0, + [all, focus, inSidebar, directory] + ) + const teammateName = + focus.kind === 'teammate' ? (directory.get(focus.userId)?.name ?? null) : null + const members = useMemo( + () => + [...directory.entries()].map(([userId, m]) => ({ + userId, + name: + userId === selfId + ? t('auto.components.cloudWorkspaces.memberYou', '{{value0}} (you)', { value0: m.name }) + : m.name + })), + [directory, selfId, t] + ) + const { actions, openingId, dialogs } = useCloudWorkspaceRowActions({ + workspaces: all, + directory, + selfId, + canAdmin, + refresh, + replace, + onOpened: onClose + }) + + const copy = pickerCopy(focus, teammateName, t) + const loading = state.status === 'loading' || (state.status === 'ready' && inSidebar === null) + + return ( +
    + + {copy.title} + {copy.description} + +
    +
    + + change({ search: event.target.value })} + /> +
    + {pickerShowsScope(focus, hasOrg) ? ( + change({ scope })} + options={[ + { value: 'team', label: t('auto.components.cloudWorkspaces.scopeTeam', 'Team') }, + { value: 'mine', label: t('auto.components.cloudWorkspaces.scopeMine', 'Mine') } + ]} + /> + ) : null} + {pickerShowsArchived(focus) ? ( + + ) : null} + +
    + {/* T4: the team's trial ran out; running sandboxes sleep until a plan starts. */} + +
    + {!cloud ? ( + // Signed out since it opened (or opened signed out): the list needs the account. + { + void signIn().then(refresh) + }} + /> + ) : state.status === 'error' ? ( + { + void signIn().then(refresh) + }} + /> + ) : loading ? ( + + ) : rows.length === 0 ? ( + { + onClose() + openNewCloudWorkspace(useAppStore.getState(), 'unknown') + }} + /> + ) : ( +
      + {rows.map((w) => ( + + ))} +
    + )} +
    + {hiddenInSidebar > 0 && rows.length > 0 ? ( +

    + {t( + 'components.cloudWorkspaces.picker.inSidebarNote', + '{{count}} more are already in your sidebar.', + { + count: hiddenInSidebar, + defaultValue_one: '{{count}} more is already in your sidebar.' + } + )} +

    + ) : null} + {dialogs} +
    + ) +} + +/** Mounted once in App; any surface opens it with openCloudWorkspacePicker(). */ +export function CloudWorkspacePickerDialog(): React.JSX.Element { + const request = useCloudWorkspacePickerRequest() + const close = useCallback(() => closeCloudWorkspacePicker(), []) + const open = request !== null + return ( + !next && close()}> + {open ? ( + + {/* Remount per request so a new focus starts with its own controls. */} + + + ) : null} + + ) +} diff --git a/src/renderer/src/components/cloud-workspaces/CloudWorkspaceRow.fixture.tsx b/src/renderer/src/components/cloud-workspaces/CloudWorkspaceRow.fixture.tsx index c2877722ee9..3c3d39ec0ec 100644 --- a/src/renderer/src/components/cloud-workspaces/CloudWorkspaceRow.fixture.tsx +++ b/src/renderer/src/components/cloud-workspaces/CloudWorkspaceRow.fixture.tsx @@ -24,13 +24,11 @@ const ACTIONS: RowActions = { sleep: () => {}, remove: () => {} } -const SECTIONS = ['Payments', 'Platform'] const MEMBERS = [...DIRECTORY.entries()].map(([userId, m]) => ({ userId, name: m.name })) -// Shared with the team, a section, and two teammates in it: every menu entry is present. +// Shared with the team and two teammates in it: every menu entry is present. const MENU_ROW = workspace({ title: 'Checkout retries', - section: 'Payments', presence: [presence('user_grace', 'typing'), presence('user_linus')] }) @@ -50,10 +48,8 @@ function Row({ directory={DIRECTORY} selfId={SELF_ID} canAdmin - sections={SECTIONS} members={MEMBERS} opening={opening} - highlighted={false} now={FIXTURE_NOW} actions={actions} /> @@ -149,16 +145,6 @@ export const menuChatFocused = defineFixture({ } }) -export const moveToSection = defineFixture({ - width: WIDTH, - render: () => ( -
    - -
    - ), - interact: (frame) => openSubmenu(frame, 'Move to section') -}) - export const reassignOwner = defineFixture({ width: WIDTH, render: () => ( diff --git a/src/renderer/src/components/cloud-workspaces/CloudWorkspaceRow.tsx b/src/renderer/src/components/cloud-workspaces/CloudWorkspaceRow.tsx index 910b0a3e9f8..59535571a87 100644 --- a/src/renderer/src/components/cloud-workspaces/CloudWorkspaceRow.tsx +++ b/src/renderer/src/components/cloud-workspaces/CloudWorkspaceRow.tsx @@ -1,5 +1,5 @@ import { CloudStatusGlyph } from '@/components/orca-cloud/CloudStatusGlyph' -import React, { useEffect, useId, useRef, useState } from 'react' +import React, { useId, useState } from 'react' import { GitBranch, Loader2 } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' @@ -11,12 +11,7 @@ import { keybindingMatchesAction } from '../../../../shared/keybindings/matching import type { CloudWorkspace } from '../../../../shared/cloud-workspaces/cloud-workspaces-wire' import type { CloudDotState } from '@/lib/orca-cloud/orca-cloud-workspace-state' import { MemberAvatar, PresenceStack } from './CloudMemberAvatars' -import { - CloudWorkspaceRowMenu, - isReadableAsleep, - type RowActions, - type RowArrangement -} from './CloudWorkspaceRowMenu' +import { CloudWorkspaceRowMenu, isReadableAsleep, type RowActions } from './CloudWorkspaceRowMenu' import { formatSandboxHours, formatRelative, @@ -72,7 +67,7 @@ export function statusLabel(w: CloudWorkspace, t: Translate): string { return t('auto.components.cloudWorkspaces.statusCouldntStart', "Couldn't start") case 'destroying': case 'destroyed': - // Home never lists these (see CloudWorkspacesHome); a stray one is not usable. + // The picker never lists these (useListedCloudWorkspaces); a stray one is not usable. return t('auto.components.cloudWorkspaces.statusNotResponding', 'Not responding') } } @@ -82,31 +77,19 @@ export function CloudWorkspaceRow({ directory, selfId, canAdmin, - sections, members, opening, - highlighted, now, - actions, - arrangement, - dragProps, - dragging = false + actions }: { workspace: CloudWorkspace directory: MemberDirectory selfId: string | null canAdmin: boolean - sections: string[] members: { userId: string; name: string }[] opening: boolean - highlighted: boolean now: number actions: RowActions - /** Pinned state and whether Move up / Move down apply (synced prefs). */ - arrangement?: RowArrangement - /** Drag-to-reorder within its group (from CloudWorkspaceGroup's rowDragProps). */ - dragProps?: React.LiHTMLAttributes - dragging?: boolean }): React.JSX.Element { const t = useTranslate() const intlLocale = useIntlLocale() @@ -118,13 +101,6 @@ export function CloudWorkspaceRow({ const canManage = w.access === 'owner' || canAdmin const title = cloudWorkspaceTitle(w, t) const ownerName = memberName(directory, w.ownerUserId, selfId, t) - const rowRef = useRef(null) - // A link brought this row up: make sure it is on screen (the tint fades on its own). - useEffect(() => { - if (highlighted) { - rowRef.current?.scrollIntoView({ block: 'nearest' }) - } - }, [highlighted]) const canOpen = w.access !== 'none' // Asleep: Enter / click reads the saved copy; waking is its own explicit action. const readable = isReadableAsleep(w) @@ -167,9 +143,7 @@ export function CloudWorkspaceRow({ return (
  • { // Menu items render in a portal but still bubble through React; only this row's own DOM counts. if (!(event.target instanceof Node) || !event.currentTarget.contains(event.target)) { @@ -189,18 +163,9 @@ export function CloudWorkspaceRow({ }} className={cn( 'group relative isolate flex items-center gap-3 rounded-lg pr-2 transition-colors hover:bg-accent', - menuOpen && 'bg-accent', - dragging && 'opacity-50' + menuOpen && 'bg-accent' )} > - {/* Opened from a link: an accent tint that fades out (instantly under reduced motion). */} - - - - - - - ) -} - export function SkeletonRows(): React.JSX.Element { const t = useTranslate() return ( @@ -140,89 +78,94 @@ export function SkeletonRows(): React.JSX.Element { ) } -export function EmptyState({ - scope, - archived, - filtered, - hasOrg = true, - archivedCount = 0, - onShowArchived +/** Why the list is empty, in the words of what the picker was opened for. */ +export type PickerEmptyReason = + | { kind: 'search' } + | { kind: 'archived' } + /** Everything you can open is in the sidebar already. */ + | { kind: 'all-in-sidebar' } + | { kind: 'none-running' } + | { kind: 'teammate'; name: string } + | { kind: 'none' } + +export function PickerEmptyState({ + reason, + onCreate }: { - scope: 'team' | 'mine' - archived: boolean - filtered: boolean - /** On a team: an empty Mine list says it's yours that's empty, not that nobody has started one. */ - hasOrg?: boolean - /** Archived workspaces in this scope: an empty list then says so and offers them (ARCHIVE-REACH). */ - archivedCount?: number - onShowArchived?: () => void + reason: PickerEmptyReason + /** Offered only when there are none at all: start one. */ + onCreate?: () => void }): React.JSX.Element { const t = useTranslate() - // Nothing to filter or share yet: say how to start one (JA-30), in the glossary's own words. - const startBody = t( - 'components.cloudWorkspaces.emptyStartBody', - 'It runs in a sandbox, so your agents keep working even when this computer is off.' - ) - const [title, body] = filtered - ? [ - t('auto.components.cloudWorkspaces.emptyFilteredTitle', 'Nothing matches these filters'), - t('auto.components.cloudWorkspaces.emptyFilteredBody', 'Try another owner or repository.') - ] - : archived - ? [ + const [title, body] = ((): [string, string] => { + switch (reason.kind) { + case 'search': + return [ + t('components.cloudWorkspaces.picker.emptySearchTitle', 'Nothing matches your search'), + t( + 'components.cloudWorkspaces.picker.emptySearchBody', + 'Try a title, repository, branch, owner or W-number.' + ) + ] + case 'archived': + return [ t('auto.components.cloudWorkspaces.emptyArchivedTitle', 'No archived workspaces'), t( 'auto.components.cloudWorkspaces.emptyArchivedBody', 'Archived cloud workspaces show up here.' ) ] - : archivedCount > 0 - ? [ - t( - 'components.cloudWorkspaces.emptyAllArchivedTitle', - 'All your cloud workspaces are archived' - ), - t( - 'components.cloudWorkspaces.emptyAllArchivedBody', - 'Unarchive one to keep working, or start a new one.' - ) - ] - : scope === 'mine' && hasOrg - ? [ - t( - 'components.cloudWorkspaces.emptyMineYoursTitle', - 'You don’t have any cloud workspaces yet.' - ), - startBody - ] - : [ - t('components.cloudWorkspaces.emptyFirstTitle', 'Start your first cloud workspace'), - startBody - ] - // Spec E3: an empty Mine or Team list offers a real button, not just copy. - const offerCreate = !filtered && !archived + case 'all-in-sidebar': + return [ + t( + 'components.cloudWorkspaces.picker.emptyAllInSidebarTitle', + 'Your cloud workspaces are already in the sidebar' + ), + t( + 'components.cloudWorkspaces.picker.emptyAllInSidebarBody', + 'Workspaces teammates share, and ones you start on another computer, show up here.' + ) + ] + case 'none-running': + return [ + t('components.cloudWorkspaces.picker.emptyRunningTitle', 'None of yours are running'), + t( + 'components.cloudWorkspaces.picker.emptyRunningBody', + 'Teammates’ running workspaces can count toward the limit too. Try again in a moment.' + ) + ] + case 'teammate': + return [ + t( + 'components.cloudWorkspaces.picker.emptyTeammateTitle', + '{{name}} has no cloud workspaces you can see', + { name: reason.name } + ), + t( + 'components.cloudWorkspaces.picker.emptyTeammateBody', + 'Private workspaces only show to their owner.' + ) + ] + case 'none': + return [ + t('components.cloudWorkspaces.emptyFirstTitle', 'Start your first cloud workspace'), + t( + 'components.cloudWorkspaces.emptyStartBody', + 'It runs in a sandbox, so your agents keep working even when this computer is off.' + ) + ] + } + })() return (

    {title}

    {body}

    - {offerCreate ? ( - ) : null} - {offerCreate && archivedCount > 0 && onShowArchived ? ( - - ) : null}
    ) } diff --git a/src/renderer/src/components/cloud-workspaces/CloudWorkspacesToolbar.routine-runs.test.tsx b/src/renderer/src/components/cloud-workspaces/CloudWorkspacesToolbar.routine-runs.test.tsx deleted file mode 100644 index d9302301b76..00000000000 --- a/src/renderer/src/components/cloud-workspaces/CloudWorkspacesToolbar.routine-runs.test.tsx +++ /dev/null @@ -1,128 +0,0 @@ -// @vitest-environment happy-dom -import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { TooltipProvider } from '@/components/ui/tooltip' -import { - CloudWorkspacesToolbar, - INITIAL_CLOUD_WORKSPACE_FILTERS, - type CloudWorkspacesFilters -} from './CloudWorkspacesToolbar' -import { readHomeSwitches, writeHomeSwitches } from './cloud-workspaces-home-prefs' -import type { CloudWorkspaceRoutineRuns } from '../../../../shared/cloud-workspaces/cloud-workspaces-wire' - -// The toolbar's trial banner and Routines entry read the preload bridge; neither is under test. -vi.mock('../new-workspace/run-on/OrcaCloudTrialUsedUpNotice', () => ({ - OrcaCloudTrialUsedUpBanner: () => null -})) -vi.mock('@/hooks/useOrcaCloudApiAvailability', () => ({ useOrcaCloudApiAvailable: () => true })) -// The switch is behind the parked-Routines flag; flip it per test to cover both states. -const flag = vi.hoisted(() => ({ on: true })) -vi.mock('../../../../shared/cloud-api/cloud-routines-ui', () => ({ - get CLOUD_ROUTINES_UI_ENABLED() { - return flag.on - } -})) - -afterEach(cleanup) - -function toolbar( - routineRuns: CloudWorkspaceRoutineRuns | undefined, - filters: Partial = {}, - onFiltersChange = vi.fn() -) { - render( - - - - ) - return onFiltersChange -} - -const runs = (over: Partial = {}): CloudWorkspaceRoutineRuns => ({ - routines: 1, - hidden: 0, - hiddenRunning: 0, - hiddenComputeMs: 0, - ...over -}) - -describe('Home toolbar while Routines are parked', () => { - it('offers neither the Routines entry nor the Routine runs switch', () => { - flag.on = false - try { - toolbar(runs({ hidden: 2, hiddenRunning: 1 }), { routines: true }) - expect(screen.queryByLabelText('Show routine runs')).toBeNull() - expect(screen.queryByRole('button', { name: 'Routines' })).toBeNull() - } finally { - flag.on = true - } - }) -}) - -describe('Home toolbar: Routine runs switch', () => { - it('is not offered to a team with no routines (or an older control plane)', () => { - toolbar(runs({ routines: 0 })) - expect(screen.queryByLabelText('Show routine runs')).toBeNull() - cleanup() - toolbar(undefined) - expect(screen.queryByLabelText('Show routine runs')).toBeNull() - }) - - it('is off by default and counts the running runs it hides', () => { - const onChange = toolbar(runs({ hidden: 3, hiddenRunning: 2 })) - const toggle = screen.getByLabelText('Show routine runs') - expect(toggle.getAttribute('aria-checked')).toBe('false') - expect(screen.getByText('Routine runs · 2')).toBeTruthy() - fireEvent.click(toggle) - expect(onChange).toHaveBeenCalledWith({ routines: true }) - }) - - it('drops the count once the runs are listed', () => { - toolbar(runs({ hidden: 0 }), { routines: true }) - expect(screen.getByLabelText('Show routine runs').getAttribute('aria-checked')).toBe('true') - expect(screen.getByText('Routine runs')).toBeTruthy() - }) -}) - -describe('Home switch preferences', () => { - it('remembers Running, Archived and Routine runs per account', () => { - const store = new Map() - const storage: Storage = { - getItem: (k: string) => store.get(k) ?? null, - setItem: (k: string, v: string) => void store.set(k, v), - removeItem: (k: string) => void store.delete(k), - clear: () => store.clear(), - key: () => null, - get length() { - return store.size - } - } - const off = { running: false, archived: false, routines: false } - expect(readHomeSwitches('u1:org', storage)).toEqual(off) - writeHomeSwitches('u1:org', { routines: true }, storage) - writeHomeSwitches('u1:org', { running: true }, storage) - expect(readHomeSwitches('u1:org', storage)).toEqual({ - running: true, - archived: false, - routines: true - }) - expect(readHomeSwitches('u2:org', storage)).toEqual(off) - writeHomeSwitches('u1:org', { running: false, routines: false }, storage) - expect(readHomeSwitches('u1:org', storage)).toEqual(off) - store.set('orca.cloudWorkspacesHome.switches', 'not json') - expect(readHomeSwitches('u1:org', storage)).toEqual(off) - }) -}) diff --git a/src/renderer/src/components/cloud-workspaces/CloudWorkspacesToolbar.tsx b/src/renderer/src/components/cloud-workspaces/CloudWorkspacesToolbar.tsx deleted file mode 100644 index 8f64ccec8b4..00000000000 --- a/src/renderer/src/components/cloud-workspaces/CloudWorkspacesToolbar.tsx +++ /dev/null @@ -1,310 +0,0 @@ -import React from 'react' -import { CalendarClock, Cloud, RefreshCw } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from '@/components/ui/select' -import { Switch } from '@/components/ui/switch' -import { SettingsSegmentedControl } from '@/components/settings/form-controls/SettingsFormControls' -import { useOrcaCloudApiAvailable } from '@/hooks/useOrcaCloudApiAvailability' -import { useTranslate } from '@/i18n/use-translate' -import { useAppStore } from '@/store' -import { cn } from '@/lib/class-names' -import { repoLabel, type MemberDirectory } from './cloud-workspace-format' -import type { CloudWorkspaceRoutineRuns } from '../../../../shared/cloud-workspaces/cloud-workspaces-wire' -import { OrcaCloudTrialUsedUpBanner } from '../new-workspace/run-on/OrcaCloudTrialUsedUpNotice' -import { CLOUD_ROUTINES_UI_ENABLED } from '../../../../shared/cloud-api/cloud-routines-ui' - -export const ANY = '__any' - -function openRoutines(): void { - const state = useAppStore.getState() - state.openSettingsTarget({ pane: 'cloud-routines', repoId: null }) - state.openSettingsPage() -} - -export type CloudWorkspacesFilters = { - scope: 'team' | 'mine' - by: string - repo: string - archived: boolean - /** Only sandboxes that are running now (where the team's running slots go). */ - running?: boolean - /** Also list workspaces routine runs started (hidden by default, like Conductor). */ - routines?: boolean -} - -export const INITIAL_CLOUD_WORKSPACE_FILTERS: CloudWorkspacesFilters = { - scope: 'team', - by: ANY, - repo: ANY, - archived: false, - running: false, - routines: false -} - -/** Below this many workspaces there is little to filter: the filters and refresh stay out of the way. */ -export const CLOUD_HOME_FILTERS_MIN = 5 - -/** - * Whether Home shows its filter row (and refresh): only with enough workspaces to filter (JA-30), - * but always while any filter is off its default, so a filter that hides rows can still be undone. - */ -export function shouldShowCloudHomeFilters( - count: number, - filters: CloudWorkspacesFilters, - hasOrg: boolean -): boolean { - const active = - (hasOrg && filters.scope !== INITIAL_CLOUD_WORKSPACE_FILTERS.scope) || - filters.by !== ANY || - filters.repo !== ANY || - // The Archived switch is not a reason for the full row: the row follows the list it shows - // (5+ archived rows: full row; fewer: the switch on its own), so turning it on never floods. - filters.running === true || - filters.routines === true - return active || count >= CLOUD_HOME_FILTERS_MIN -} - -/** - * ARCHIVE-REACH: with the filter row hidden (few workspaces, JA-30), archived workspaces still get - * their Archived switch on its own, so archiving your only workspace never leaves Home without a way - * back to it. - */ -export function shouldShowCloudHomeArchivedOnly({ - showFilters, - archivedCount, - archivedOn, - activeCount -}: { - showFilters: boolean - archivedCount: number | null - /** The switch is on: it stays, so the active list is one click back. */ - archivedOn: boolean - /** With nothing active, the empty state's "Show archived (N)" is the one way in (no second control). */ - activeCount: number -}): boolean { - if (showFilters) { - return false - } - return archivedOn || ((archivedCount ?? 0) > 0 && activeCount > 0) -} - -/** Row 1: title, count and cost, refresh. Row 2: whose, by whom, which repo, running, routine runs, archived. */ -export function CloudWorkspacesToolbar({ - filters, - onFiltersChange, - hasOrg, - owners, - repos, - directory, - selfId, - meta, - metaStale, - reading, - onRefresh, - routineRuns, - showFilters = true, - archivedOnly = false, - archivedCount = null -}: { - filters: CloudWorkspacesFilters - onFiltersChange: (next: Partial) => void - hasOrg: boolean - owners: string[] - repos: string[] - directory: MemberDirectory - selfId: string | null - meta: string | null - /** The meta line belongs to the previous filter until the new read lands. */ - metaStale: boolean - reading: boolean - onRefresh: () => void - /** What the Routine runs switch hides; the switch exists only when the team has routines. */ - routineRuns?: CloudWorkspaceRoutineRuns - /** False with only a few workspaces and no filter on: title and count only (JA-30). */ - showFilters?: boolean - /** With the filters hidden: still show the Archived switch, because archived ones exist. */ - archivedOnly?: boolean - /** How many archived workspaces the switch shows; null while unknown. */ - archivedCount?: number | null -}): React.JSX.Element { - const t = useTranslate() - // Routines start cloud workspaces on their own, so they sit next to the list they fill. - // Routines are parked (cloud-routines-ui.ts): no Routines entry and no Routine runs switch. - const routinesAvailable = useOrcaCloudApiAvailable() && CLOUD_ROUTINES_UI_ENABLED - // The count says why the switch is there and what turning it on shows. - const archivedSwitch = ( - - ) - return ( -
    -
    - -

    - {t('auto.components.cloudWorkspaces.heading', 'Cloud workspaces')} -

    - {meta && ( - - {meta} - - )} - - {routinesAvailable ? ( - - ) : null} - {showFilters ? ( - - ) : null} - -
    -
    - {hasOrg && ( -
    - onFiltersChange({ scope })} - options={[ - { value: 'team', label: t('auto.components.cloudWorkspaces.scopeTeam', 'Team') }, - { value: 'mine', label: t('auto.components.cloudWorkspaces.scopeMine', 'Mine') } - ]} - /> -
    - )} - {hasOrg && filters.scope === 'team' && ( - - )} - - {/* The switches wrap as one group, so none is left alone on a line of its own. */} -
    - - {CLOUD_ROUTINES_UI_ENABLED && - ((routineRuns?.routines ?? 0) > 0 || filters.routines === true) ? ( - - ) : null} - {/* One Archived switch at a time: here, or on its own row below. */} - {showFilters ? archivedSwitch : null} -
    -
    - {!showFilters && archivedOnly ? ( -
    - {archivedSwitch} -
    - ) : null} - {/* T4: the team's trial ran out; running sandboxes sleep until a plan starts. */} - -
    - ) -} diff --git a/src/renderer/src/components/cloud-workspaces/OpenCloudWorkspaceMenuItem.tsx b/src/renderer/src/components/cloud-workspaces/OpenCloudWorkspaceMenuItem.tsx new file mode 100644 index 00000000000..78578c50a15 --- /dev/null +++ b/src/renderer/src/components/cloud-workspaces/OpenCloudWorkspaceMenuItem.tsx @@ -0,0 +1,51 @@ +// "Open cloud workspace…" in the Add project menu (sidebar and Landing), in its own "Orca Cloud" +// group: the way to open a cloud workspace the sidebar doesn't show yet. +import React, { useMemo } from 'react' +import { Cloud } from 'lucide-react' +import { DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu' +import { useTranslate } from '@/i18n/use-translate' +import { openCloudWorkspacePicker } from './cloud-workspace-picker-store' +import { useCloudWorkspacesPresent } from './use-cloud-workspaces' + +function OpenCloudWorkspaceItem({ onSelect }: { onSelect: () => void }): React.JSX.Element { + const t = useTranslate() + return ( + <> + {/* Its own group: the header's host picker doesn't apply to Orca Cloud. */} + +
    + {t('components.newWorkspace.cloudOnly.menuGroup', 'Orca Cloud')} +
    + + + + {t('components.cloudWorkspaces.picker.menuItem', 'Open cloud workspace…')} + + + + ) +} + +/** The Add project menu's `extraItems`, or undefined when signed out or without Orca Cloud. */ +export function useOpenCloudWorkspaceMenuItems(): + | ((closeMenu: () => void) => React.ReactNode) + | undefined { + const present = useCloudWorkspacesPresent() + return useMemo( + () => + present + ? (closeMenu: () => void) => ( + { + closeMenu() + openCloudWorkspacePicker() + }} + /> + ) + : undefined, + [present] + ) +} diff --git a/src/renderer/src/components/cloud-workspaces/cloud-archive-reach.test.tsx b/src/renderer/src/components/cloud-workspaces/cloud-archive-reach.test.tsx deleted file mode 100644 index 673b838ab46..00000000000 --- a/src/renderer/src/components/cloud-workspaces/cloud-archive-reach.test.tsx +++ /dev/null @@ -1,241 +0,0 @@ -// @vitest-environment happy-dom -// ARCHIVE-REACH: Home always reaches archived workspaces, however few are active. -import { - act, - cleanup, - fireEvent, - render, - renderHook, - screen, - waitFor -} from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { TooltipProvider } from '@/components/ui/tooltip' -import { EmptyState } from './CloudWorkspacesStates' -import { - CloudWorkspacesToolbar, - INITIAL_CLOUD_WORKSPACE_FILTERS as DEFAULTS, - shouldShowCloudHomeArchivedOnly, - shouldShowCloudHomeFilters -} from './CloudWorkspacesToolbar' -import { resetCloudArchivedCountCache, useCloudArchivedCount } from './use-cloud-archived-count' -import { noteCloudArchiveChanged } from '@/lib/orca-cloud/cloud-archive-changes' - -vi.mock('../new-workspace/run-on/OrcaCloudTrialUsedUpNotice', () => ({ - OrcaCloudTrialUsedUpBanner: () => null -})) -vi.mock('@/hooks/useOrcaCloudApiAvailability', () => ({ useOrcaCloudApiAvailable: () => false })) - -afterEach(cleanup) - -/** What Home decides for `active` workspaces, `archived` archived ones, and the switch on or off. */ -function homeRow(active: number, archived: number, archivedOn = false) { - // With the switch on, Home lists the archived ones: the row follows that list's size. - const shown = archivedOn ? archived : active - const showFilters = shouldShowCloudHomeFilters(shown, { ...DEFAULTS, archived: archivedOn }, true) - return { - showFilters, - archivedOnly: shouldShowCloudHomeArchivedOnly({ - showFilters, - archivedCount: archived, - archivedOn, - activeCount: active - }) - } -} - -describe('which filter row Home shows', () => { - it.each([ - // Nothing active: the empty state's "Show archived (N)" is the way in, not a second control. - [0, 0, false, { showFilters: false, archivedOnly: false }], - [0, 2, false, { showFilters: false, archivedOnly: false }], - [3, 0, false, { showFilters: false, archivedOnly: false }], - [3, 2, false, { showFilters: false, archivedOnly: true }], - [5, 0, false, { showFilters: true, archivedOnly: false }], - [5, 4, false, { showFilters: true, archivedOnly: false }], - // Switch on: a few archived rows keep the minimal row with the switch; 5+ get the full row. - [0, 2, true, { showFilters: false, archivedOnly: true }], - [3, 2, true, { showFilters: false, archivedOnly: true }], - [3, 6, true, { showFilters: true, archivedOnly: false }] - ])('%i active, %i archived, switch on: %s', (active, archived, on, expected) => { - expect(homeRow(active, archived, on)).toEqual(expected) - }) - - it('an unknown count (read failed or loading) keeps today’s row', () => { - expect( - shouldShowCloudHomeArchivedOnly({ - showFilters: false, - archivedCount: null, - archivedOn: false, - activeCount: 3 - }) - ).toBe(false) - }) -}) - -function toolbar(props: { - showFilters: boolean - archivedOnly: boolean - archivedCount: number | null - archived?: boolean -}) { - const onFiltersChange = vi.fn() - render( - - - - ) - return onFiltersChange -} - -describe('the Archived switch', () => { - it('below 5 active with archived ones: only the switch, with its count, and it turns on', () => { - const onFiltersChange = toolbar({ showFilters: false, archivedOnly: true, archivedCount: 3 }) - expect(screen.getByText('Archived (3)')).toBeTruthy() - // JA-30's minimal row otherwise: no owner/repo filters, no Running switch. - const own = document.querySelector('[data-cloud-home-archived-only]') - expect(own?.contains(screen.getByRole('switch', { name: 'Show archived' }))).toBe(true) - // JA-30's filter row (owner/repo, Running) stays hidden. - const running = screen.getByRole('switch', { name: 'Show running only' }) - expect(running.closest('.hidden')).not.toBeNull() - fireEvent.click(screen.getByRole('switch', { name: 'Show archived' })) - expect(onFiltersChange).toHaveBeenCalledWith({ archived: true }) - }) - - it('below 5 active with none archived: no switch at all (JA-30 unchanged)', () => { - toolbar({ showFilters: false, archivedOnly: false, archivedCount: 0 }) - expect(screen.queryByRole('switch', { name: 'Show archived' })).toBeNull() - }) - - it('at 5+ active: the full row, and the switch shows the count too', () => { - toolbar({ showFilters: true, archivedOnly: false, archivedCount: 4 }) - expect(screen.getByRole('switch', { name: 'Show running only' })).toBeTruthy() - expect(screen.getAllByRole('switch', { name: 'Show archived' })).toHaveLength(1) - expect(screen.getByText('Archived (4)')).toBeTruthy() - }) - - it('at 5+ active with none archived: plain "Archived"', () => { - toolbar({ showFilters: true, archivedOnly: false, archivedCount: 0 }) - expect(screen.getByText('Archived')).toBeTruthy() - }) -}) - -describe('the empty state', () => { - it('every workspace archived: says so, keeps New, and offers Show archived (N)', () => { - const onShowArchived = vi.fn() - render( - - ) - expect(screen.getByText('All your cloud workspaces are archived')).toBeTruthy() - expect(screen.queryByText('You don’t have any cloud workspaces yet.')).toBeNull() - expect(screen.getByText('Unarchive one to keep working, or start a new one.')).toBeTruthy() - // The first-run pitch isn't for someone who already has workspaces. - expect(screen.queryByText(/It runs in a sandbox/)).toBeNull() - expect(screen.getByRole('button', { name: 'New cloud workspace' })).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: 'Show archived (2)' })) - expect(onShowArchived).toHaveBeenCalledOnce() - }) - - it('nothing archived: the first-run invitation, no Show archived link', () => { - render( - - ) - expect(screen.getByText('Start your first cloud workspace')).toBeTruthy() - expect(screen.queryByRole('button', { name: /Show archived/ })).toBeNull() - }) -}) - -describe('useCloudArchivedCount', () => { - const list = vi.fn() - beforeEach(() => { - resetCloudArchivedCountCache() - list.mockReset() - Object.defineProperty(window, 'api', { - configurable: true, - value: { cloudWorkspaces: { list } } - }) - }) - - it('reads archived=only once per active list, and again when the active list changes', async () => { - list.mockResolvedValue({ ok: true, value: [{ id: 'a' }, { id: 'b' }] }) - const { result, rerender } = renderHook( - (ids: string[]) => - useCloudArchivedCount({ account: 'u:o', scope: 'mine', activeIds: ids, enabled: true }), - { initialProps: ['w1'] } - ) - await waitFor(() => expect(result.current).toBe(2)) - expect(list).toHaveBeenCalledWith({ scope: 'mine', archived: 'only' }) - // Home polls: the same active list reuses the cached count. - rerender(['w1']) - renderHook(() => - useCloudArchivedCount({ account: 'u:o', scope: 'mine', activeIds: ['w1'], enabled: true }) - ) - expect(list).toHaveBeenCalledTimes(1) - // Archiving the only workspace empties the active list: read again. - list.mockResolvedValue({ ok: true, value: [{ id: 'a' }, { id: 'b' }, { id: 'w1' }] }) - rerender([]) - await waitFor(() => expect(result.current).toBe(3)) - expect(list).toHaveBeenCalledTimes(2) - }) - - const emptyIds: string[] = [] - it('archiving the only workspace never leaves a cached 0 standing: the archive itself re-reads', async () => { - // Before the first workspace existed, Home read "0 archived" for an empty active list. - list.mockResolvedValue({ ok: true, value: [] }) - const { result, rerender } = renderHook( - (ids: string[]) => - useCloudArchivedCount({ account: 'u:o', scope: 'mine', activeIds: ids, enabled: true }), - { initialProps: emptyIds } - ) - await waitFor(() => expect(list).toHaveBeenCalledTimes(1)) - await waitFor(() => expect(result.current).toBe(0)) - // Created w1, then archived it: the active list is empty again (same key as that cached 0), - // and the archive commits after Sleep, so the server only now lists it. - rerender(['w1']) - await waitFor(() => expect(list).toHaveBeenCalledTimes(2)) - rerender([]) - list.mockResolvedValue({ ok: true, value: [{ id: 'w1' }] }) - act(() => noteCloudArchiveChanged()) - await waitFor(() => expect(result.current).toBe(1)) - }) - - it('stays null on a failed read or while the active list loads, so Home keeps today’s row', async () => { - list.mockResolvedValue({ ok: false, error: 'unavailable' }) - const failed = renderHook(() => - useCloudArchivedCount({ account: 'u:o', scope: 'team', activeIds: [], enabled: true }) - ) - await waitFor(() => expect(list).toHaveBeenCalled()) - expect(failed.result.current).toBeNull() - const loading = renderHook(() => - useCloudArchivedCount({ account: 'u:o', scope: 'team', activeIds: null, enabled: true }) - ) - expect(loading.result.current).toBeNull() - }) -}) diff --git a/src/renderer/src/components/cloud-workspaces/cloud-workspace-action-groups.test.ts b/src/renderer/src/components/cloud-workspaces/cloud-workspace-action-groups.test.ts index ad114048107..bc9be09f8e6 100644 --- a/src/renderer/src/components/cloud-workspaces/cloud-workspace-action-groups.test.ts +++ b/src/renderer/src/components/cloud-workspaces/cloud-workspace-action-groups.test.ts @@ -5,7 +5,7 @@ import { } from './cloud-workspace-action-groups' const base: CloudWorkspaceActionInput = { - surface: 'home', + surface: 'list', status: 'running', archivedAt: null, visibility: 'private', @@ -20,16 +20,16 @@ const flat = (input: Partial) => cloudWorkspaceActionGroups({ ...base, ...input }).map((g) => `${g.id}:${g.actions.join(',')}`) describe('one action list for every surface (JA-14)', () => { - it('Home: Open, Share, Lifecycle, Danger in that order, with Restart', () => { + it('the list: Open, Share, Lifecycle, Danger in that order, with Restart', () => { expect(flat({})).toEqual([ 'open:open,copyLink,copyWorkspaceLink', - 'share:share,reassignOwner,moveToSection', + 'share:share,reassignOwner', 'lifecycle:restart,sleep,archive', 'danger:delete' ]) }) - it('the sidebar gets the same Share and Lifecycle actions, minus Home-only arrangement', () => { + it('the sidebar gets the same Share and Lifecycle actions, minus list-only items', () => { expect(flat({ surface: 'sidebar', visibility: 'team' })).toEqual([ 'open:copyWorkspaceLink', 'share:makePrivate', @@ -42,7 +42,7 @@ describe('one action list for every surface (JA-14)', () => { expect(flat({ archivedAt: 1, status: 'suspended' })).toContain('lifecycle:unarchive') }) - it('asleep on Home reads the saved copy, and Wake is its own action', () => { + it('asleep in the list reads the saved copy, and Wake is its own action', () => { expect(flat({ status: 'suspended', readableAsleep: true, canRestart: false })[0]).toBe( 'open:read,wake,copyLink,copyWorkspaceLink' ) diff --git a/src/renderer/src/components/cloud-workspaces/cloud-workspace-action-groups.ts b/src/renderer/src/components/cloud-workspaces/cloud-workspace-action-groups.ts index eff027862a4..74745a7e3a0 100644 --- a/src/renderer/src/components/cloud-workspaces/cloud-workspace-action-groups.ts +++ b/src/renderer/src/components/cloud-workspaces/cloud-workspace-action-groups.ts @@ -1,7 +1,7 @@ -// One action set per cloud workspace, the same on every surface (JA-14): the Home list's ⋯ menu and -// the sidebar's context menu both render these groups, in this order: Open, Share, Lifecycle, -// Danger. Each surface leaves out only what it already shows elsewhere (the sidebar row *is* the -// open workspace; Home has no local-only items) and never invents its own. +// One action set per cloud workspace, the same on every surface (JA-14): the "Open cloud +// workspace…" list's ⋯ menu and the sidebar's context menu both render these groups, in this order: +// Open, Share, Lifecycle, Danger. Each surface leaves out only what it already shows elsewhere (the +// sidebar row *is* the open workspace; the list has no local-only items) and never invents its own. import type { CloudWorkspace } from '../../../../shared/cloud-workspaces/cloud-workspaces-wire' export type CloudWorkspaceAction = @@ -13,7 +13,6 @@ export type CloudWorkspaceAction = | 'share' | 'makePrivate' | 'reassignOwner' - | 'moveToSection' | 'restart' | 'sleep' | 'archive' @@ -29,27 +28,27 @@ export type CloudWorkspaceActionInput = Pick< CloudWorkspace, 'status' | 'archivedAt' | 'visibility' > & { - surface: 'home' | 'sidebar' + surface: 'list' | 'sidebar' canOpen: boolean canManage: boolean - /** Asleep and readable without waking (Home reads the saved copy). */ + /** Asleep and readable without waking (the list reads the saved copy). */ readableAsleep: boolean /** Restart needs this desktop's own runtime for it. */ canRestart: boolean - /** Teammates to hand it to (Home only). */ + /** Teammates to hand it to (the list only). */ hasMembers?: boolean } export function cloudWorkspaceActionGroups( input: CloudWorkspaceActionInput ): CloudWorkspaceActionGroup[] { - const home = input.surface === 'home' + const list = input.surface === 'list' const archived = input.archivedAt !== null const open: CloudWorkspaceAction[] = [] - if (home && input.canOpen) { + if (list && input.canOpen) { open.push(...(input.readableAsleep ? (['read', 'wake'] as const) : (['open'] as const))) } - if (home) { + if (list) { open.push('copyLink') } open.push('copyWorkspaceLink') @@ -57,12 +56,9 @@ export function cloudWorkspaceActionGroups( const share: CloudWorkspaceAction[] = [] if (input.canManage) { share.push(input.visibility === 'private' ? 'share' : 'makePrivate') - if (home && input.hasMembers) { + if (list && input.hasMembers) { share.push('reassignOwner') } - if (home) { - share.push('moveToSection') - } } const lifecycle: CloudWorkspaceAction[] = [] diff --git a/src/renderer/src/components/cloud-workspaces/cloud-workspace-home-archive.ts b/src/renderer/src/components/cloud-workspaces/cloud-workspace-home-archive.ts index 33f40878a70..0147860ddd7 100644 --- a/src/renderer/src/components/cloud-workspaces/cloud-workspace-home-archive.ts +++ b/src/renderer/src/components/cloud-workspaces/cloud-workspace-home-archive.ts @@ -1,4 +1,4 @@ -// Archive and unarchive from the team list. Both are lifecycle changes, never just a label: +// Archive and unarchive from the cloud workspace list ("Open cloud workspace…"). Both are lifecycle changes, never just a label: // a workspace this desktop runs goes through the same Sleep → archive (with Undo) as the sidebar; // any other one is archived by the control plane, which stops its sandbox and keeps a snapshot. import { noteCloudArchiveChanged } from '@/lib/orca-cloud/cloud-archive-changes' @@ -13,7 +13,6 @@ import { runUnarchiveCloudWorkspace } from '../orca-cloud/cloud-workspace-actions' import { describeError } from './cloud-workspace-errors' -export { usePendingHomeFilters } from './cloud-workspaces-home-nav' import type { Translate } from '@/i18n/use-translate' import { cloudWorkspaceTitle } from './open-cloud-workspace' import type { RowActions } from './CloudWorkspaceRowMenu' @@ -156,7 +155,7 @@ export async function deleteFromHome( export async function destroyFromHome(w: CloudWorkspace, name: string): Promise { const result = await window.api.cloudWorkspaces.destroy(w.id) if (result.ok) { - // It may have been an archived one: Home's archived count reads again. + // It may have been an archived one: anything counting archived workspaces reads again. noteCloudArchiveChanged() toast.success(translate('orcaCloudWorkspace.deletedNamed', 'Deleted {{name}}', { name })) return diff --git a/src/renderer/src/components/cloud-workspaces/cloud-workspace-links.ts b/src/renderer/src/components/cloud-workspaces/cloud-workspace-links.ts index f988e467c7a..5944e754dfa 100644 --- a/src/renderer/src/components/cloud-workspaces/cloud-workspace-links.ts +++ b/src/renderer/src/components/cloud-workspaces/cloud-workspace-links.ts @@ -1,6 +1,5 @@ // App-level wiring for team cloud workspaces: orca://cloud links, presence for the workspace in // front, and the copy-link shortcut. Installed once from main.tsx; no React tree needed. -import { useSyncExternalStore } from 'react' import { toast } from 'sonner' import { useAppStore } from '@/store' import { getShortcutPlatform } from '@/lib/keyboard/shortcut-platform' @@ -14,46 +13,19 @@ import { cloudWorkspacesAvailable } from './use-cloud-workspaces' import { installCloudWorkspaceSync } from './cloud-workspace-sync' import { rememberCloudWorkspaces } from './cloud-workspace-identity' -const HIGHLIGHT_MS = 4_000 /** No input for this long and the desktop stops reporting presence (and renewing a lease). */ const IDLE_MS = 15 * 60_000 const TYPING_REPORT_MS = 3_000 /** Tab and pane switches settle before the chat in front is re-read (layouts churn while resizing). */ const CHAT_SYNC_DEBOUNCE_MS = 300 -let highlightId: string | null = null -let highlightTimer: ReturnType | undefined -const listeners = new Set<() => void>() -function setHighlight(id: string | null, clearAfterMs?: number): void { - clearTimeout(highlightTimer) - highlightTimer = - clearAfterMs === undefined ? undefined : setTimeout(() => setHighlight(null), clearAfterMs) - highlightId = id - listeners.forEach((l) => l()) -} - -/** The workspace a link pointed at, so Home can ring it while it opens. */ -export function useCloudWorkspaceHighlight(): string | null { - return useSyncExternalStore( - (l) => { - listeners.add(l) - return () => listeners.delete(l) - }, - () => highlightId - ) -} - export async function handleCloudWorkspaceLink(link: string): Promise { const parsed = parseCloudWorkspaceLink(link) if (!parsed || !(await cloudWorkspacesAvailable())) { return } - if (parsed.target.kind === 'id') { - setHighlight(parsed.target.workspaceId) - } const resolved = await window.api.cloudWorkspaces.resolveLink(parsed.target, parsed.chatId) if (!resolved.ok) { - setHighlight(null) reportCloudWorkspaceLinkFailure(parsed.target, resolved, () => { void handleCloudWorkspaceLink(link) }) @@ -61,14 +33,12 @@ export async function handleCloudWorkspaceLink(link: string): Promise { } const workspace = resolved.value.workspace rememberCloudWorkspaces([workspace]) - setHighlight(workspace.id) const outcome = await openCloudWorkspace(workspace) if (!outcome.ok) { toast.error(outcome.message) } else if (resolved.value.chatId) { await focusCloudChat(outcome.worktreeId, resolved.value.chatId) } - setHighlight(workspace.id, HIGHLIGHT_MS) } type Front = { @@ -80,8 +50,8 @@ type Front = { let front: Front = null /** - * ⌘⌥⇧L: the workspace's own link (its W-number), like the tab-bar chip and Home's "Copy link to - * W-n". A link to the focused chat or terminal is a separate menu item, never the shortcut. + * ⌘⌥⇧L: the workspace's own link (its W-number), like the tab-bar chip and the cloud workspace + * list's "Copy link to W-n". A link to the focused chat or terminal is a separate menu item, never the shortcut. */ export function copyShortcutLink(sandboxId: string): Promise { return copyCloudWorkspaceLink(sandboxId, null) @@ -285,6 +255,5 @@ export function installCloudWorkspaceLinks(): () => void { const onUnload = (): void => void api.setActive({ sandboxId: null }) window.addEventListener('beforeunload', onUnload) cleanups.push(() => window.removeEventListener('beforeunload', onUnload)) - cleanups.push(() => setHighlight(null)) return () => cleanups.forEach((c) => c()) } diff --git a/src/renderer/src/components/cloud-workspaces/cloud-workspace-local-titles.ts b/src/renderer/src/components/cloud-workspaces/cloud-workspace-local-titles.ts index 5359eb0943a..07da8515c31 100644 --- a/src/renderer/src/components/cloud-workspaces/cloud-workspace-local-titles.ts +++ b/src/renderer/src/components/cloud-workspaces/cloud-workspace-local-titles.ts @@ -71,10 +71,12 @@ export function useBackfillLocalTitles( const NONE: readonly CloudWorkspace[] = [] /** - * What Home lists: a workspace being deleted (or gone) is not something to open or manage, so it - * is left out; untitled ones this computer started read by its name for them, not the repo. + * What the cloud workspace list shows: a workspace being deleted (or gone) is not something to open + * or manage, so it is left out; untitled ones this computer started read by its name, not the repo. */ -export function useHomeWorkspaces(workspaces: readonly CloudWorkspace[] | null): CloudWorkspace[] { +export function useListedCloudWorkspaces( + workspaces: readonly CloudWorkspace[] | null +): CloudWorkspace[] { const listed = useMemo( () => (workspaces ?? NONE).filter((w) => w.status !== 'destroying' && w.status !== 'destroyed'), [workspaces] diff --git a/src/renderer/src/components/cloud-workspaces/cloud-workspace-picker-model.test.ts b/src/renderer/src/components/cloud-workspaces/cloud-workspace-picker-model.test.ts new file mode 100644 index 00000000000..a595804bdff --- /dev/null +++ b/src/renderer/src/components/cloud-workspaces/cloud-workspace-picker-model.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest' +import { + initialPickerControls, + pickerListQuery, + pickerRows, + pickerShowsArchived, + pickerShowsScope +} from './cloud-workspace-picker-model' +import { DIRECTORY, workspace } from './cloud-workspaces.test-fixture' + +const NONE = new Set() +const controls = initialPickerControls(true) + +describe('what the picker reads', () => { + it('Open: the team list on a team, yours without one, archived only behind the switch', () => { + expect(controls).toEqual({ scope: 'team', archived: false, search: '' }) + expect(pickerListQuery({ kind: 'open' }, controls, true)).toEqual({ + scope: 'team', + archived: 'exclude' + }) + expect(pickerListQuery({ kind: 'open' }, { ...controls, archived: true }, true)).toEqual({ + scope: 'team', + archived: 'only' + }) + expect(pickerListQuery({ kind: 'open' }, initialPickerControls(false), false)).toEqual({ + scope: 'mine', + archived: 'exclude' + }) + }) + + it('a focused picker reads a fixed list and offers no scope switch', () => { + expect(pickerListQuery({ kind: 'running' }, controls, true)).toEqual({ + scope: 'mine', + archived: 'exclude' + }) + expect(pickerListQuery({ kind: 'team-cleanup' }, controls, true)).toEqual({ + scope: 'team', + archived: 'include' + }) + expect(pickerShowsScope({ kind: 'open' }, true)).toBe(true) + expect(pickerShowsScope({ kind: 'open' }, false)).toBe(false) + expect(pickerShowsScope({ kind: 'teammate', userId: 'user_grace' }, true)).toBe(false) + expect(pickerShowsArchived({ kind: 'running' })).toBe(false) + expect(pickerShowsArchived({ kind: 'team-cleanup' })).toBe(false) + }) +}) + +describe('what the picker lists', () => { + const mine = workspace({ title: 'Mine here', lastActivityAt: 3 }) + const shared = workspace({ + title: 'Grace’s shared one', + ownerUserId: 'user_grace', + createdBy: 'user_grace', + access: 'can_open', + lastActivityAt: 5 + }) + const elsewhere = workspace({ + title: 'Started on my laptop', + status: 'suspended', + lastActivityAt: 1 + }) + + it('Open leaves out what the sidebar already shows, and counts it', () => { + const { rows, hiddenInSidebar } = pickerRows({ + workspaces: [mine, shared, elsewhere], + focus: { kind: 'open' }, + inSidebar: new Set([mine.id]), + search: '', + directory: DIRECTORY + }) + // Most recently active first. + expect(rows.map((w) => w.title)).toEqual(['Grace’s shared one', 'Started on my laptop']) + expect(hiddenInSidebar).toBe(1) + }) + + it('the running limit lists your running ones, sidebar ones too', () => { + const { rows, hiddenInSidebar } = pickerRows({ + workspaces: [mine, elsewhere], + focus: { kind: 'running' }, + inSidebar: new Set([mine.id]), + search: '', + directory: DIRECTORY + }) + expect(rows).toEqual([mine]) + expect(hiddenInSidebar).toBe(0) + }) + + it('a teammate focus lists only what they own or started', () => { + const { rows } = pickerRows({ + workspaces: [mine, shared], + focus: { kind: 'teammate', userId: 'user_grace' }, + inSidebar: NONE, + search: '', + directory: DIRECTORY + }) + expect(rows).toEqual([shared]) + }) + + it('search matches title, repository, owner name and W-number', () => { + const rows = (search: string): (string | null)[] => + pickerRows({ + workspaces: [mine, shared], + focus: { kind: 'open' }, + inSidebar: NONE, + search, + directory: DIRECTORY + }).rows.map((w) => w.title) + expect(rows('grace hopper')).toEqual(['Grace’s shared one']) + expect(rows('acme/payments')).toHaveLength(2) + expect(rows(`W-${mine.number}`)).toEqual(['Mine here']) + expect(rows('nothing like this')).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/cloud-workspaces/cloud-workspace-picker-model.ts b/src/renderer/src/components/cloud-workspaces/cloud-workspace-picker-model.ts new file mode 100644 index 00000000000..e8df9f74c0b --- /dev/null +++ b/src/renderer/src/components/cloud-workspaces/cloud-workspace-picker-model.ts @@ -0,0 +1,110 @@ +// What "Open cloud workspace…" reads and lists for each focus. Pure, so the rules are tested apart +// from the dialog. +import type { + CloudWorkspace, + CloudWorkspaceListQuery +} from '../../../../shared/cloud-workspaces/cloud-workspaces-wire' +import { formatWorkspaceNumber } from '../../../../shared/cloud-workspaces/cloud-workspaces-wire' +import type { MemberDirectory } from './cloud-workspace-format' +import type { CloudWorkspacePickerFocus } from './cloud-workspace-picker-store' + +export type CloudWorkspacePickerControls = { + scope: 'mine' | 'team' + archived: boolean + search: string +} + +export function initialPickerControls(hasOrg: boolean): CloudWorkspacePickerControls { + // On a team, what you can't open from the sidebar is mostly what teammates shared. + return { scope: hasOrg ? 'team' : 'mine', archived: false, search: '' } +} + +/** Which controls the picker offers: a focused list (limit, teammate, team delete) is fixed. */ +export function pickerShowsScope(focus: CloudWorkspacePickerFocus, hasOrg: boolean): boolean { + return focus.kind === 'open' && hasOrg +} + +export function pickerShowsArchived(focus: CloudWorkspacePickerFocus): boolean { + return focus.kind === 'open' || focus.kind === 'teammate' +} + +export function pickerListQuery( + focus: CloudWorkspacePickerFocus, + controls: CloudWorkspacePickerControls, + hasOrg: boolean +): CloudWorkspaceListQuery { + switch (focus.kind) { + case 'open': + return { + scope: hasOrg ? controls.scope : 'mine', + archived: controls.archived ? 'only' : 'exclude' + } + case 'teammate': + return { scope: 'team', archived: controls.archived ? 'only' : 'exclude' } + case 'running': + return { scope: 'mine', archived: 'exclude' } + case 'team-cleanup': + return { scope: 'team', archived: 'include' } + } +} + +/** Only "Open" leaves out what the sidebar already shows; the other focuses are about acting on them. */ +export function pickerHidesSidebarWorkspaces(focus: CloudWorkspacePickerFocus): boolean { + return focus.kind === 'open' +} + +function matchesSearch(w: CloudWorkspace, search: string, directory: MemberDirectory): boolean { + const needle = search.trim().toLowerCase() + if (!needle) { + return true + } + const haystack = [ + w.title, + w.repo?.url, + w.repo?.branch, + w.routineName, + w.number ? formatWorkspaceNumber(w.number) : null, + directory.get(w.ownerUserId)?.name, + w.memberNames?.[w.ownerUserId] + ] + return haystack.some((value) => typeof value === 'string' && value.toLowerCase().includes(needle)) +} + +export function pickerRows({ + workspaces, + focus, + inSidebar, + search, + directory +}: { + workspaces: readonly CloudWorkspace[] + focus: CloudWorkspacePickerFocus + inSidebar: ReadonlySet + search: string + directory: MemberDirectory +}): { rows: CloudWorkspace[]; hiddenInSidebar: number } { + let hiddenInSidebar = 0 + const rows: CloudWorkspace[] = [] + for (const w of workspaces) { + if ( + focus.kind === 'teammate' && + w.ownerUserId !== focus.userId && + w.createdBy !== focus.userId + ) { + continue + } + if (focus.kind === 'running' && w.status !== 'running') { + continue + } + if (pickerHidesSidebarWorkspaces(focus) && inSidebar.has(w.id)) { + hiddenInSidebar += 1 + continue + } + if (matchesSearch(w, search, directory)) { + rows.push(w) + } + } + // Most recently active first: the one you are looking for is usually the one just used. + rows.sort((a, b) => b.lastActivityAt - a.lastActivityAt) + return { rows, hiddenInSidebar } +} diff --git a/src/renderer/src/components/cloud-workspaces/cloud-workspace-picker-store.ts b/src/renderer/src/components/cloud-workspaces/cloud-workspace-picker-store.ts new file mode 100644 index 00000000000..5b09b02580f --- /dev/null +++ b/src/renderer/src/components/cloud-workspaces/cloud-workspace-picker-store.ts @@ -0,0 +1,54 @@ +// "Open cloud workspace…": the one place to open a cloud workspace this computer's sidebar doesn't +// show (a teammate's shared one, one started on another computer, one whose pairing was removed). +// Every surface that used to send people to the Home list opens this instead, with a focus that +// says why: the running limit lists your running ones, a teammate's profile lists theirs. +import { useSyncExternalStore } from 'react' + +export type CloudWorkspacePickerFocus = + /** Anything not in this sidebar yet (the menu, the palette, a broken pairing). */ + | { kind: 'open' } + /** Everything one teammate has that you can see (their profile's "Show all"). */ + | { kind: 'teammate'; userId: string } + /** At the running-sandbox limit: your running ones, sidebar ones too, to put one to sleep. */ + | { kind: 'running' } + /** Deleting the team: every team workspace, archived ones too, so they can be deleted first. */ + | { kind: 'team-cleanup' } + +export type CloudWorkspacePickerRequest = { id: number; focus: CloudWorkspacePickerFocus } + +let request: CloudWorkspacePickerRequest | null = null +let nextId = 0 +const listeners = new Set<() => void>() + +function emit(): void { + listeners.forEach((listener) => listener()) +} + +export function openCloudWorkspacePicker( + focus: CloudWorkspacePickerFocus = { kind: 'open' } +): void { + // A new id each time: a second open (another focus) starts the picker over. + request = { id: ++nextId, focus } + emit() +} + +export function closeCloudWorkspacePicker(): void { + if (request) { + request = null + emit() + } +} + +export function getCloudWorkspacePickerRequest(): CloudWorkspacePickerRequest | null { + return request +} + +export function useCloudWorkspacePickerRequest(): CloudWorkspacePickerRequest | null { + return useSyncExternalStore( + (listener) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + () => request + ) +} diff --git a/src/renderer/src/components/cloud-workspaces/cloud-workspaces-home-nav.ts b/src/renderer/src/components/cloud-workspaces/cloud-workspaces-home-nav.ts deleted file mode 100644 index 170bebb1a97..00000000000 --- a/src/renderer/src/components/cloud-workspaces/cloud-workspaces-home-nav.ts +++ /dev/null @@ -1,44 +0,0 @@ -// "Show cloud workspaces" from limit errors: go to the team list (Home), filtered to your own, -// so you can put one to sleep or delete it and try again. -import { useEffect } from 'react' -import { useAppStore } from '@/store' -import type { CloudWorkspacesFilters } from './CloudWorkspacesToolbar' - -let pendingFilters: Partial | null = null -const listeners = new Set<() => void>() - -/** Home reads (and clears) this when it mounts or is already showing. Limit errors ask for Mine + Running. */ -export function takePendingHomeFilters(): Partial | null { - const next = pendingFilters - pendingFilters = null - return next -} - -export function onPendingHomeFilters(listener: () => void): () => void { - listeners.add(listener) - return () => listeners.delete(listener) -} - -export function openCloudWorkspacesHome(filters: Partial = {}): void { - pendingFilters = { scope: 'mine', archived: false, ...filters } - const state = useAppStore.getState() - state.setActiveView('terminal') - state.setActiveWorktree(null) - listeners.forEach((listener) => listener()) -} - -/** Applies pending filters on mount and whenever another surface asks for Home again. */ -export function usePendingHomeFilters( - setFilters: React.Dispatch> -): void { - useEffect(() => { - const take = (): void => { - const pending = takePendingHomeFilters() - if (pending) { - setFilters((current) => ({ ...current, ...pending })) - } - } - take() - return onPendingHomeFilters(take) - }, [setFilters]) -} diff --git a/src/renderer/src/components/cloud-workspaces/cloud-workspaces-home-prefs.ts b/src/renderer/src/components/cloud-workspaces/cloud-workspaces-home-prefs.ts deleted file mode 100644 index d8e9f6ebbf0..00000000000 --- a/src/renderer/src/components/cloud-workspaces/cloud-workspaces-home-prefs.ts +++ /dev/null @@ -1,60 +0,0 @@ -// Home's three list switches (Running, Archived, Routine runs) are remembered per signed-in account -// (user and team) on this desktop. Preferences only: they never decide access, so local storage is enough. -const KEY = 'orca.cloudWorkspacesHome.switches' - -export type HomeSwitches = { running: boolean; archived: boolean; routines: boolean } -const OFF: HomeSwitches = { running: false, archived: false, routines: false } - -function read(storage: Storage | undefined): Record> { - try { - const parsed: unknown = JSON.parse(storage?.getItem(KEY) ?? '{}') - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - return {} - } - const out: Record> = {} - for (const [account, saved] of Object.entries(parsed)) { - if (saved && typeof saved === 'object') { - const flags: Record = { ...saved } - out[account] = { - running: flags.running === true, - archived: flags.archived === true, - routines: flags.routines === true - } - } - } - return out - } catch { - return {} - } -} - -export function readHomeSwitches( - account: string, - storage: Storage | undefined = globalThis.localStorage -): HomeSwitches { - const saved = read(storage)[account] ?? {} - return { - running: saved.running === true, - archived: saved.archived === true, - routines: saved.routines === true - } -} - -export function writeHomeSwitches( - account: string, - next: Partial, - storage: Storage | undefined = globalThis.localStorage -): void { - const all = read(storage) - const merged = { ...OFF, ...readHomeSwitches(account, storage), ...next } - if (!merged.running && !merged.archived && !merged.routines) { - delete all[account] - } else { - all[account] = merged - } - try { - storage?.setItem(KEY, JSON.stringify(all)) - } catch { - // Storage full or unavailable: the switches still work for this session. - } -} diff --git a/src/renderer/src/components/cloud-workspaces/cloud-workspaces-in-sidebar.ts b/src/renderer/src/components/cloud-workspaces/cloud-workspaces-in-sidebar.ts new file mode 100644 index 00000000000..0c2bfe97d4f --- /dev/null +++ b/src/renderer/src/components/cloud-workspaces/cloud-workspaces-in-sidebar.ts @@ -0,0 +1,51 @@ +// Which cloud workspaces this window's sidebar already shows: ones this desktop runs (with a row), +// and paired ones whose server has projects in the sidebar. "Open cloud workspace…" leaves these +// out; they open from the sidebar. A pairing whose server is gone is not in the sidebar, so it lists. +import { useEffect, useState } from 'react' +import { toRuntimeExecutionHostId } from '../../../../shared/execution-host' +import { hasLocalSidebarRow } from './cloud-local-row' +import { firstWorktreeOnHost } from './open-cloud-workspace' + +export async function cloudWorkspaceIdsInSidebar(): Promise> { + const api = window.api.cloudWorkspaces + const [local, bindings] = await Promise.all([ + api.localWorkspaces().catch(() => []), + api.environmentBindings().catch(() => ({})) + ]) + const ids = new Set() + for (const entry of local) { + if (hasLocalSidebarRow(entry)) { + ids.add(entry.sandboxId) + } + } + for (const [sandboxId, binding] of Object.entries(bindings)) { + if (firstWorktreeOnHost(toRuntimeExecutionHostId(binding.environmentId))) { + ids.add(sandboxId) + } + } + return ids +} + +const NONE: ReadonlySet = new Set() + +/** Null until read (the picker shows its skeleton meanwhile); read once per mount. */ +export function useCloudWorkspaceIdsInSidebar(enabled: boolean): ReadonlySet | null { + const [ids, setIds] = useState | null>(null) + useEffect(() => { + if (!enabled) { + return + } + let disposed = false + void cloudWorkspaceIdsInSidebar() + .catch(() => new Set()) + .then((next) => { + if (!disposed) { + setIds(next) + } + }) + return () => { + disposed = true + } + }, [enabled]) + return enabled ? ids : NONE +} diff --git a/src/renderer/src/components/cloud-workspaces/cloud-workspaces.test-fixture.ts b/src/renderer/src/components/cloud-workspaces/cloud-workspaces.test-fixture.ts index cec0afee85a..584deb65fe5 100644 --- a/src/renderer/src/components/cloud-workspaces/cloud-workspaces.test-fixture.ts +++ b/src/renderer/src/components/cloud-workspaces/cloud-workspaces.test-fixture.ts @@ -1,5 +1,4 @@ // Static team, workspaces and API stubs shared by the cloud workspaces component fixtures. -import { resetCloudArchivedCountCache } from './use-cloud-archived-count' import type { CloudWorkspace, CloudWorkspacePresence @@ -150,16 +149,14 @@ export function teamWorkspaces(): CloudWorkspace[] { type ListResult = Awaited> -/** Replaces the web build's "not configured" cloud workspaces bridge with static answers. */ /** - * `archived` is what the Archived switch (and Home's archived count, ARCHIVE-REACH) reads with - * `archived=only`; every other query gets `list`. + * Replaces the web build's "not configured" cloud workspaces bridge with static answers. `archived` + * is what the Archived switch reads with `archived=only`; every other query gets `list`. */ export function stubCloudWorkspacesApi( list: () => Promise, archived: CloudWorkspace[] = [] ): void { - resetCloudArchivedCountCache() Object.assign(window.api.cloudWorkspaces, { available: () => Promise.resolve(true), list: (query?: { archived?: string }) => diff --git a/src/renderer/src/components/cloud-workspaces/use-cloud-archived-count.ts b/src/renderer/src/components/cloud-workspaces/use-cloud-archived-count.ts deleted file mode 100644 index be675548c01..00000000000 --- a/src/renderer/src/components/cloud-workspaces/use-cloud-archived-count.ts +++ /dev/null @@ -1,123 +0,0 @@ -// How many archived cloud workspaces Home's scope has, so Home can always reach them (ARCHIVE-REACH): -// with only a few active workspaces Home hides its filter row (JA-30), and that row holds the only -// Archived switch, so archiving your only workspace used to leave nothing on Home to get it back. -// -// One `archived=only` read per account, scope and active list. When the active list changes (a -// workspace archived or unarchived, here or elsewhere), the count is read again. Otherwise a cached -// answer is reused for a few minutes, so Home's polling never doubles its reads. An archive, -// unarchive or delete from this desktop starts a fresh read at once (cloud-archive-changes): the -// active list can change before the archive commits, and its cached 0 must not stand for 5 minutes. -import { useEffect, useState, useSyncExternalStore } from 'react' -import { - cloudArchiveVersion, - subscribeCloudArchiveChanges -} from '@/lib/orca-cloud/cloud-archive-changes' - -const TTL_MS = 5 * 60_000 - -type Cached = { count: number; at: number } -const cache = new Map() -const inflight = new Map>() - -async function readArchivedCount(scope: 'team' | 'mine'): Promise { - try { - const result = await window.api.cloudWorkspaces.list({ scope, archived: 'only' }) - return result.ok ? result.value.length : null - } catch { - return null - } -} - -function load(key: string, scope: 'team' | 'mine', now: number): Promise { - const hit = cache.get(key) - if (hit && now - hit.at < TTL_MS) { - return Promise.resolve(hit.count) - } - const pending = inflight.get(key) - if (pending) { - return pending - } - const request = readArchivedCount(scope).then((count) => { - inflight.delete(key) - if (count !== null) { - cache.set(key, { count, at: Date.now() }) - } - return count - }) - inflight.set(key, request) - return request -} - -/** - * Null until known (or when the read failed): callers then keep today's behavior. `activeIds` is the - * active list Home shows; null while it loads. Disabled while the Archived switch is already on. - */ -export function useCloudArchivedCount({ - account, - scope, - activeIds, - enabled -}: { - account: string - scope: 'team' | 'mine' - activeIds: readonly string[] | null - enabled: boolean -}): number | null { - const signature = activeIds ? [...activeIds].sort().join(',') : null - const changes = useSyncExternalStore(subscribeCloudArchiveChanges, cloudArchiveVersion) - const key = JSON.stringify({ account, scope, active: signature, changes }) - const [state, setState] = useState<{ key: string; count: number | null }>({ key, count: null }) - useEffect(() => { - if (!enabled || signature === null) { - return - } - let disposed = false - void load(key, scope, Date.now()).then((count) => { - if (!disposed) { - setState({ key, count }) - } - }) - return () => { - disposed = true - } - }, [enabled, key, scope, signature]) - if (!enabled) { - return null - } - // A count read for an earlier active list still says whether archived ones exist; keep it until - // the new read lands so the switch doesn't flicker off and on. - return state.count -} - -/** - * Home's two numbers: `archivedRead` (the count while the active list shows, for the empty state) - * and `archivedCount` (the switch's label: with the switch on, the archived rows shown). - */ -export function useHomeArchivedCount({ - account, - scope, - shown, - archivedOn, - enabled -}: { - account: string - scope: 'team' | 'mine' - /** The rows Home shows now; null while they load. */ - shown: readonly { id: string }[] | null - archivedOn: boolean - enabled: boolean -}): { archivedRead: number | null; archivedCount: number | null } { - const archivedRead = useCloudArchivedCount({ - account, - scope, - activeIds: shown ? shown.map((w) => w.id) : null, - enabled: enabled && !archivedOn - }) - return { archivedRead, archivedCount: archivedOn ? (shown?.length ?? null) : archivedRead } -} - -/** Tests: forget every cached count. */ -export function resetCloudArchivedCountCache(): void { - cache.clear() - inflight.clear() -} diff --git a/src/renderer/src/components/cloud-workspaces/use-cloud-home-filters.ts b/src/renderer/src/components/cloud-workspaces/use-cloud-home-filters.ts deleted file mode 100644 index 16477c2df93..00000000000 --- a/src/renderer/src/components/cloud-workspaces/use-cloud-home-filters.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { useCallback, useState } from 'react' -import { - ANY, - INITIAL_CLOUD_WORKSPACE_FILTERS, - type CloudWorkspacesFilters -} from './CloudWorkspacesToolbar' -import { readHomeSwitches, writeHomeSwitches } from './cloud-workspaces-home-prefs' -import { usePendingHomeFilters } from './cloud-workspaces-home-nav' -import { CLOUD_ROUTINES_UI_ENABLED } from '../../../../shared/cloud-api/cloud-routines-ui' - -/** Home's list filters for this account. The three switches are remembered; owner/repo/scope start fresh. */ -export function useCloudHomeFilters(account: string) { - const initialFilters = (key: string): CloudWorkspacesFilters => ({ - ...INITIAL_CLOUD_WORKSPACE_FILTERS, - ...readHomeSwitches(key), - // A switch saved before Routines were parked must not keep listing routine runs. - ...(CLOUD_ROUTINES_UI_ENABLED ? {} : { routines: false }) - }) - const [filters, setFilters] = useState(() => initialFilters(account)) - usePendingHomeFilters(setFilters) - // Owner and repo filters name the previous team's people and repos; a team switch starts over. - const [filtersAccount, setFiltersAccount] = useState(account) - if (filtersAccount !== account) { - setFiltersAccount(account) - setFilters(initialFilters(account)) - } - const changeFilters = useCallback( - (next: Partial) => { - const switches = { - ...(next.running !== undefined ? { running: next.running } : {}), - ...(next.archived !== undefined ? { archived: next.archived } : {}), - ...(next.routines !== undefined ? { routines: next.routines } : {}) - } - if (Object.keys(switches).length > 0) { - writeHomeSwitches(account, switches) - } - setFilters((previous) => { - // A different list (scope or archived) has different owners and repos; start unfiltered. - // Routine runs only add rows, so the owner/repo picks stay. - const reset = - (next.scope !== undefined && next.scope !== previous.scope) || - (next.archived !== undefined && next.archived !== previous.archived) - return { ...previous, ...(reset ? { by: ANY, repo: ANY } : {}), ...next } - }) - }, - [account] - ) - return { filters, changeFilters } -} diff --git a/src/renderer/src/components/cloud-workspaces/use-cloud-workspace-arrangement.ts b/src/renderer/src/components/cloud-workspaces/use-cloud-workspace-arrangement.ts deleted file mode 100644 index 26059499169..00000000000 --- a/src/renderer/src/components/cloud-workspaces/use-cloud-workspace-arrangement.ts +++ /dev/null @@ -1,182 +0,0 @@ -// Home's arrangement state: groups from your synced prefs, plus the edits (pin, move, drag, -// collapse, section order) that save back to them. -import { useCallback, useEffect, useMemo, useState } from 'react' -import { toast } from 'sonner' -import { translate } from '@/i18n/i18n' -import { - formatWorkspaceNumber, - type CloudWorkspace -} from '../../../../shared/cloud-workspaces/cloud-workspaces-wire' -import { - MAX_PINNED, - arrangeCloudWorkspaces, - dropOnto, - moveSection, - nudge, - reorderGroup, - setCollapsed, - setPinned, - type CloudWorkspaceGroup -} from '../../../../shared/cloud-workspaces/cloud-workspace-arrangement' -import { - editCloudWorkspacePrefs, - getCloudWorkspacePrefs, - refreshCloudWorkspacePrefs, - useCloudWorkspacePrefs -} from './cloud-workspace-prefs' -import type { GroupDrag } from './CloudWorkspaceGroup' -import type { RowArrangement } from './CloudWorkspaceRowMenu' -import { cloudWorkspaceTitle } from './open-cloud-workspace' - -/** How a screen reader hears a workspace in move confirmations: its number, else its title. */ -function spoken(w: CloudWorkspace | undefined): string { - return !w ? '' : w.number ? formatWorkspaceNumber(w.number) : cloudWorkspaceTitle(w) -} - -function groupName(group: CloudWorkspaceGroup): string { - return group.kind === 'pinned' - ? translate('auto.components.cloudWorkspaces.pinned', 'Pinned') - : (group.section ?? - translate('auto.components.cloudWorkspaces.otherWorkspaces', 'Other workspaces')) -} - -export function useCloudWorkspaceArrangement(visible: readonly CloudWorkspace[]) { - const prefs = useCloudWorkspacePrefs() - // Drags and menu moves are confirmed politely for screen readers ("Moved W-12 to position 2 in Pinned"). - const [announcement, setAnnouncement] = useState('') - // Opening Home is a good moment to pick up another device's edits without waiting for the poll. - useEffect(() => { - void refreshCloudWorkspacePrefs() - }, []) - const groups = useMemo( - () => arrangeCloudWorkspaces(visible, prefs.document), - [visible, prefs.document] - ) - const groupOf = useCallback( - (id: string): CloudWorkspaceGroup | undefined => - groups.find((group) => group.rows.some((w) => w.id === id)), - [groups] - ) - const reorder = useCallback( - (group: CloudWorkspaceGroup, ids: string[] | null, movedId: string) => { - if (!ids) { - return - } - void editCloudWorkspacePrefs((doc) => reorderGroup(doc, group, ids)) - setAnnouncement( - translate( - 'auto.components.cloudWorkspaces.movedTo', - 'Moved {{name}} to position {{position}} in {{group}}', - { - name: spoken(group.rows.find((w) => w.id === movedId)), - position: ids.indexOf(movedId) + 1, - group: groupName(group) - } - ) - ) - }, - [] - ) - - const [dragging, setDragging] = useState(null) - const drag = useMemo( - () => ({ - dragging, - start: (id, group) => setDragging({ id, group }), - end: () => setDragging(null), - drop: (overId, groupKey) => { - const group = groups.find((g) => g.key === groupKey) - if (dragging && group) { - reorder( - group, - dropOnto( - group.rows.map((w) => w.id), - dragging.id, - overId - ), - dragging.id - ) - } - setDragging(null) - } - }), - [dragging, groups, reorder] - ) - - const pin = useCallback((w: CloudWorkspace, pinned: boolean) => { - if (pinned && getCloudWorkspacePrefs().document.pinned.length >= MAX_PINNED) { - toast.error( - translate( - 'auto.components.cloudWorkspaces.pinLimit', - 'You can pin up to {{count}} workspaces. Unpin one first.', - { count: MAX_PINNED } - ) - ) - return - } - void editCloudWorkspacePrefs((doc) => setPinned(doc, w.id, pinned)) - setAnnouncement( - pinned - ? translate('auto.components.cloudWorkspaces.pinnedName', 'Pinned {{name}}', { - name: spoken(w) - }) - : translate('auto.components.cloudWorkspaces.unpinnedName', 'Unpinned {{name}}', { - name: spoken(w) - }) - ) - }, []) - - const move = useCallback( - (w: CloudWorkspace, delta: number) => { - const group = groupOf(w.id) - if (group) { - reorder( - group, - nudge( - group.rows.map((row) => row.id), - w.id, - delta - ), - w.id - ) - } - }, - [groupOf, reorder] - ) - - const shownSections = useMemo( - () => - groups.flatMap((group) => (group.kind === 'section' && group.section ? [group.section] : [])), - [groups] - ) - const toggleCollapsed = useCallback((section: string, collapsed: boolean) => { - void editCloudWorkspacePrefs((doc) => setCollapsed(doc, section, collapsed)) - }, []) - const moveSectionBy = useCallback( - (section: string, delta: number) => { - void editCloudWorkspacePrefs((doc) => moveSection(doc, shownSections, section, delta) ?? doc) - }, - [shownSections] - ) - - const arrangementFor = useCallback( - (group: CloudWorkspaceGroup, index: number): RowArrangement => ({ - pinned: group.kind === 'pinned', - canMoveUp: index > 0, - canMoveDown: index < group.rows.length - 1 - }), - [] - ) - - return { - announcement, - groups, - drag, - pin, - move, - toggleCollapsed, - moveSection: moveSectionBy, - shownSections, - arrangementFor - } -} diff --git a/src/renderer/src/components/cloud-workspaces/use-cloud-workspace-row-actions.tsx b/src/renderer/src/components/cloud-workspaces/use-cloud-workspace-row-actions.tsx new file mode 100644 index 00000000000..abd7ac863a1 --- /dev/null +++ b/src/renderer/src/components/cloud-workspaces/use-cloud-workspace-row-actions.tsx @@ -0,0 +1,208 @@ +// What a cloud workspace row can do from "Open cloud workspace…": open (or read an Asleep one's +// saved copy), copy its link, share or hand it over, sleep, archive and delete. Opening goes through +// openCloudWorkspace, the same path as a workspace link and a teammate's profile. +import React, { useCallback, useMemo, useState } from 'react' +import { toast } from 'sonner' +import { useTranslate } from '@/i18n/use-translate' +import { refreshCloudMessageSenders } from '@/lib/cloud-message-sender' +import type { + CloudWorkspace, + CloudWorkspacePatch, + CloudWorkspacePresence +} from '../../../../shared/cloud-workspaces/cloud-workspaces-wire' +import { useOrgRoster } from '../billing/team/use-org-roster' +import type { MemberDirectory } from './cloud-workspace-format' +import { describeError } from './cloud-workspace-errors' +import { focusCloudChat } from './cloud-chat-focus' +import * as archive from './cloud-workspace-home-archive' +import { focusedKind, frontCloudChat } from './cloud-workspace-links' +import { copyCloudWorkspaceLink, openCloudWorkspace } from './open-cloud-workspace' +import type { RowActions } from './CloudWorkspaceRowMenu' +import { CloudWorkspaceConfirmDialog, type PendingConfirm } from './CloudWorkspaceConfirmDialog' +import { CloudReadSnapshotSheet } from './CloudReadSnapshotSheet' +import { useCloudReadPanel } from './use-cloud-read-panel' + +/** Names for every member id the rows show, and whether you are a team owner or admin. */ +export function useMemberDirectory( + orgId: string | undefined, + workspaces: readonly CloudWorkspace[] +): { directory: MemberDirectory; canAdmin: boolean } { + const roster = useOrgRoster(orgId ?? '') + return useMemo(() => { + const directory: MemberDirectory = new Map() + let canAdmin = false + if (orgId && roster.result?.status === 'ok') { + for (const m of roster.result.roster.members) { + if (m.userId) { + directory.set(m.userId, { name: m.displayName || m.email, email: m.email }) + } + } + canAdmin = + roster.result.roster.viewerRole === 'owner' || roster.result.roster.viewerRole === 'admin' + } + // Ids the roster does not cover (still loading, a dev org, a member who left) fall back to the + // name each member's own token carries, as the control plane reports it. + for (const w of workspaces) { + for (const [userId, name] of Object.entries(w.memberNames ?? {})) { + if (name && !directory.has(userId)) { + directory.set(userId, { name, email: null }) + } + } + } + return { directory, canAdmin } + }, [orgId, roster.result, workspaces]) +} + +export function useCloudWorkspaceRowActions({ + workspaces, + directory, + selfId, + canAdmin, + refresh, + replace, + onOpened +}: { + /** The listed rows: the read-only sheet follows its row as polling refreshes it. */ + workspaces: readonly CloudWorkspace[] + directory: MemberDirectory + selfId: string | null + canAdmin: boolean + refresh: () => void + replace: (next: CloudWorkspace) => void + /** It opened in the sidebar: the picker closes. */ + onOpened: () => void +}): { actions: RowActions; openingId: string | null; dialogs: React.JSX.Element } { + const t = useTranslate() + const [openingId, setOpeningId] = useState(null) + const [confirm, setConfirm] = useState(null) + const { readingId, readingRef, setReadingId, readWake, setReadWake } = useCloudReadPanel() + + /** Opens the workspace, then (going to a teammate's chat) the chat they have in front. */ + const open = useCallback( + async (w: CloudWorkspace, chatId?: string | null) => { + setOpeningId(w.id) + const fromPanel = readingRef.current === w.id + if (fromPanel) { + setReadWake({ phase: 'waking', startedAt: Date.now() }) + } + try { + const outcome = await openCloudWorkspace(w) + if (!outcome.ok) { + if (fromPanel && readingRef.current === w.id) { + // The panel shows the failure in place (with the reason) instead of a toast. + setReadWake({ ...outcome, phase: 'failed' }) + } else { + toast.error(outcome.message) + } + return + } + setReadingId((current) => (current === w.id ? null : current)) + onOpened() + if (chatId) { + await focusCloudChat(outcome.worktreeId, chatId) + } + } finally { + setOpeningId((current) => (current === w.id ? null : current)) + } + }, + [onOpened, readingRef, setReadWake, setReadingId] + ) + + const patch = useCallback( + async (w: CloudWorkspace, change: CloudWorkspacePatch, confirmed = false) => { + // Making private and handing over ownership take access away; both ask first. + if (!confirmed && change.visibility === 'private') { + setConfirm({ kind: 'private', workspace: w }) + return + } + if (!confirmed && change.ownerUserId) { + setConfirm({ kind: 'owner', workspace: w, ownerUserId: change.ownerUserId }) + return + } + const result = await window.api.cloudWorkspaces.patch(w.id, change) + if (!result.ok) { + toast.error(describeError(result.error, result.reason, t)) + return + } + replace(result.value) + if (change.visibility !== undefined) { + // Shared or private changes whether messages there carry the sender line. + void refreshCloudMessageSenders().catch(() => undefined) + } + if (change.visibility === 'team') { + toast.success( + t('auto.components.cloudWorkspaces.sharedWithYourTeam', 'Shared with your team') + ) + } else if (change.visibility === 'private') { + toast.success( + t( + 'auto.components.cloudWorkspaces.nowPrivate', + 'Now private. Teammates’ devices were disconnected.' + ) + ) + } else if (change.ownerUserId) { + toast.success(t('auto.components.cloudWorkspaces.ownerChanged', 'Owner changed')) + } + refresh() + }, + [refresh, replace, t] + ) + + const actions = useMemo( + () => ({ + open: (w) => void open(w), + read: (w) => setReadingId(w.id), + copyLink: (w) => void copyCloudWorkspaceLink(w.id), + copyWorkspaceLink: (w) => { + const front = frontCloudChat() + if (front?.sandboxId === w.id && front.chatId) { + void copyCloudWorkspaceLink(w.id, front.chatId) + } + }, + focusedItem: (w) => { + const front = frontCloudChat() + return front?.sandboxId === w.id ? focusedKind(front.chatId) : null + }, + goToChat: (w: CloudWorkspace, member: CloudWorkspacePresence) => void open(w, member.chatId), + patch: (w, change) => void patch(w, change), + ...archive.homeArchiveActions({ selfId, t, refresh, confirmArchive: setConfirm }) + }), + [open, patch, refresh, selfId, setReadingId, t] + ) + + const dialogs = ( + <> + w.id === readingId) ?? null) : null} + wake={readWake} + onWake={(w) => void open(w)} + onClose={() => setReadingId(null)} + /> + {confirm && ( + setConfirm(null)} + onConfirm={() => { + if (confirm.kind === 'archive' || confirm.kind === 'delete') { + setConfirm(null) + archive.runConfirmed(confirm, t, refresh) + return + } + const change: CloudWorkspacePatch = + confirm.kind === 'private' + ? { visibility: 'private' } + : { ownerUserId: confirm.ownerUserId } + setConfirm(null) + void patch(confirm.workspace, change, true) + }} + /> + )} + + ) + + return { actions, openingId, dialogs } +} diff --git a/src/renderer/src/components/cloud-workspaces/use-cloud-workspaces.ts b/src/renderer/src/components/cloud-workspaces/use-cloud-workspaces.ts index 072497ba928..15a7b5a958e 100644 --- a/src/renderer/src/components/cloud-workspaces/use-cloud-workspaces.ts +++ b/src/renderer/src/components/cloud-workspaces/use-cloud-workspaces.ts @@ -7,6 +7,7 @@ import type { import { availabilityRetryDelay } from '@/lib/availability-retry' import { installWindowVisibilityTimeoutPoller } from '@/lib/window-activity/window-visibility-timeout-poller' import { rememberCloudWorkspaces } from './cloud-workspace-identity' +import { useAppStore } from '@/store' /** Presence is live; the list re-reads on this beat while visible (and on focus). */ const POLL_MS = 8_000 @@ -84,6 +85,12 @@ export function useCloudWorkspacesAvailable(): boolean | null { ) } +/** Signed in to Orca and this build has a control plane: cloud workspaces can be listed and opened. */ +export function useCloudWorkspacesPresent(): boolean { + const signedIn = useAppStore((s) => Boolean(s.orcaProfileAuthStatus?.cloud)) + return useCloudWorkspacesAvailable() === true && signedIn +} + /** * `account` is the signed-in `${userId}:${activeOrgId}`: it is part of the read's key, so a team * switch re-reads at once and a list read for the previous team is never shown or kept. diff --git a/src/renderer/src/components/cmd-j/open-cloud-workspace-quick-action.test.ts b/src/renderer/src/components/cmd-j/open-cloud-workspace-quick-action.test.ts new file mode 100644 index 00000000000..0af12d8ad3f --- /dev/null +++ b/src/renderer/src/components/cmd-j/open-cloud-workspace-quick-action.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest' +import type { CmdJQuickActionContext } from './quick-action-context' +import { getOpenCloudWorkspaceQuickAction } from './open-cloud-workspace-quick-action' + +function context(overrides: Partial = {}): CmdJQuickActionContext { + return { + activeView: 'terminal', + activeWorktreeId: null, + activeWorktree: null, + isLoading: false, + sshStatus: null, + runtimeMode: 'local-desktop', + activeGroupId: null, + openNewBrowserTab: async () => {}, + openBrowserUrl: async () => {}, + openNewMarkdownFile: async () => {}, + openNewTerminalTab: async () => {}, + openCreateWorkspace: () => {}, + deleteActiveWorkspace: () => {}, + openAddQuickCommand: () => {}, + openCloudWorkspacePicker: vi.fn(), + ...overrides + } +} + +describe('Open cloud workspace… quick action', () => { + const action = getOpenCloudWorkspaceQuickAction() + + it('opens the picker, with no workspace active (it is how you get to one)', async () => { + const ctx = context() + expect(action.isAvailable(ctx)).toEqual({ available: true }) + await expect(action.run(ctx)).resolves.toEqual({ status: 'ok' }) + expect(ctx.openCloudWorkspacePicker).toHaveBeenCalledTimes(1) + }) + + it('is unavailable (so hidden) without Orca Cloud', async () => { + const ctx = context({ openCloudWorkspacePicker: undefined }) + expect(action.isAvailable(ctx)).toEqual({ available: false, reason: 'no-orca-cloud' }) + await expect(action.run(ctx)).resolves.toEqual({ + status: 'unavailable', + reason: 'no-orca-cloud' + }) + }) +}) diff --git a/src/renderer/src/components/cmd-j/open-cloud-workspace-quick-action.ts b/src/renderer/src/components/cmd-j/open-cloud-workspace-quick-action.ts new file mode 100644 index 00000000000..c3907abc4a3 --- /dev/null +++ b/src/renderer/src/components/cmd-j/open-cloud-workspace-quick-action.ts @@ -0,0 +1,35 @@ +import { Cloud } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import type { CmdJQuickAction } from './quick-actions' + +/** "Open cloud workspace…": a teammate's shared one, or one started on another computer. */ +export function getOpenCloudWorkspaceQuickAction(): CmdJQuickAction { + return { + id: 'open-cloud-workspace', + kind: 'action', + title: translate('components.cloudWorkspaces.picker.menuItem', 'Open cloud workspace…'), + description: translate( + 'components.cloudWorkspaces.picker.paletteDescription', + 'Open a cloud workspace that isn’t in your sidebar yet.' + ), + icon: Cloud, + verbKeywords: [ + translate('components.cloudWorkspaces.picker.verbs.cloudWorkspace', 'cloud workspace'), + translate('components.cloudWorkspaces.picker.verbs.sharedWorkspace', 'shared workspace'), + translate('components.cloudWorkspaces.picker.verbs.teammate', 'teammate'), + translate('components.cloudWorkspaces.picker.verbs.orcaCloud', 'orca cloud') + ], + // Unavailable actions are left out of the palette, so this stays hidden without Orca Cloud. + isAvailable: (ctx) => + ctx.openCloudWorkspacePicker + ? { available: true } + : { available: false, reason: 'no-orca-cloud' }, + run: async (ctx) => { + if (!ctx.openCloudWorkspacePicker) { + return { status: 'unavailable', reason: 'no-orca-cloud' } + } + ctx.openCloudWorkspacePicker() + return { status: 'ok' } + } + } +} diff --git a/src/renderer/src/components/cmd-j/quick-action-context.ts b/src/renderer/src/components/cmd-j/quick-action-context.ts index 11d6d750060..a6fe0b636f8 100644 --- a/src/renderer/src/components/cmd-j/quick-action-context.ts +++ b/src/renderer/src/components/cmd-j/quick-action-context.ts @@ -23,6 +23,7 @@ export type CmdJUnavailableReason = | 'no-active-chat' | 'client-action-unsupported' | 'not-cloud-workspace' + | 'no-orca-cloud' export type CmdJQuickActionAvailability = | { available: true } @@ -52,6 +53,8 @@ export type CmdJQuickActionContext = { /** The active workspace's Orca Cloud environment; null for every other workspace. */ activeCloudEnvironmentId?: string | null openCloudRemoteDesktop?: (worktreeId: string, environmentId: string) => void + /** Set only while signed in to Orca Cloud: "Open cloud workspace…". */ + openCloudWorkspacePicker?: () => void canSplitActiveChat?: boolean splitActiveChat?: (direction: NativeChatSplitDirection) => boolean } @@ -167,6 +170,7 @@ export function buildCmdJQuickActionContext(args: { deleteActiveWorkspace: () => void openAddQuickCommand: () => void openCloudRemoteDesktop?: (worktreeId: string, environmentId: string) => void + openCloudWorkspacePicker?: () => void }): CmdJQuickActionContext { const activeWorktreeId = args.state.activeWorktreeId const activeWorktree = activeWorktreeId @@ -214,6 +218,7 @@ export function buildCmdJQuickActionContext(args: { ? activeEnvironmentId : null, openCloudRemoteDesktop: args.openCloudRemoteDesktop, + openCloudWorkspacePicker: args.openCloudWorkspacePicker, canSplitActiveChat: canRunNativeChatSplitTarget(args.state, activeChatTarget), splitActiveChat: (direction) => runActiveNativeChatSplit(activeWorktreeId, activeGroupId, direction) @@ -263,6 +268,12 @@ export function getUnavailableQuickActionMessage( 'Can’t run “{{action}}” — the active workspace isn’t a cloud workspace.', options ) + case 'no-orca-cloud': + return translate( + 'auto.components.cmdJ.quickActionUnavailable.noOrcaCloud', + 'Can’t run “{{action}}” — sign in to Orca Cloud first.', + options + ) case 'client-action-unsupported': return translate( 'auto.components.cmdJ.quickActionUnavailable.clientUnsupported', diff --git a/src/renderer/src/components/cmd-j/quick-actions.ts b/src/renderer/src/components/cmd-j/quick-actions.ts index 2f0a45ec4e7..21e04f4f469 100644 --- a/src/renderer/src/components/cmd-j/quick-actions.ts +++ b/src/renderer/src/components/cmd-j/quick-actions.ts @@ -20,6 +20,7 @@ import { translate } from '@/i18n/i18n' import { createLocalizedCatalog } from '@/i18n/localized-catalog' import { getNativeChatSplitQuickActions } from './native-chat-split-quick-actions' import { getRemoteDesktopQuickAction } from './remote-desktop-quick-action' +import { getOpenCloudWorkspaceQuickAction } from './open-cloud-workspace-quick-action' export type CmdJQuickActionRunResult = | { status: 'ok' } @@ -179,6 +180,7 @@ export const getCmdJQuickActions = createLocalizedCatalog((): CmdJQuickAction[] }, ...getNativeChatSplitQuickActions(), getRemoteDesktopQuickAction(), + getOpenCloudWorkspaceQuickAction(), { id: CREATE_WORKSPACE_QUICK_ACTION_ID, kind: 'action', diff --git a/src/renderer/src/components/landing-layout.fixture.tsx b/src/renderer/src/components/landing-layout.fixture.tsx deleted file mode 100644 index 8ce7e2cb29f..00000000000 --- a/src/renderer/src/components/landing-layout.fixture.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { defineFixture } from '../component-fixtures/component-fixture' -import { LandingActions } from './landing-layout' - -const noop = (): void => {} - -function row(width: number): React.JSX.Element { - return ( -
    -
    - -
    -
    -
    - ) -} - -/** JA-30: over the cloud list in a wide centre column, the full-size top row. */ -export const actionsOverCloudListWide = defineFixture({ width: 800, render: () => row(768) }) - -/** JA-30: a narrow centre column (both side panels open): small buttons, still one row. */ -export const actionsOverCloudListNarrow = defineFixture({ width: 580, render: () => row(540) }) diff --git a/src/renderer/src/components/landing-layout.tsx b/src/renderer/src/components/landing-layout.tsx index bbbeb5f8c3c..66083eebee1 100644 --- a/src/renderer/src/components/landing-layout.tsx +++ b/src/renderer/src/components/landing-layout.tsx @@ -1,5 +1,3 @@ -import { FolderPlus, GitBranchPlus } from 'lucide-react' -import { cn } from '@/lib/class-names' import { ShortcutKeyCombo } from './ShortcutKeyCombo' import type { ShortcutKeyComboDetails } from '@/hooks/useShortcutLabel' import logo from '../../../../resources/logo.svg' @@ -14,30 +12,21 @@ export type LandingShortcutItem = { export const LANDING_ACTION_BUTTON_CLASS = 'inline-flex items-center gap-1.5 bg-secondary/70 border border-border/80 text-foreground font-medium text-sm px-4 py-2 rounded-md cursor-pointer hover:bg-accent transition-colors' -/** `compact` when Home also lists cloud workspaces, so the list starts higher on the page. */ -export function LandingBrand({ compact = false }: { compact?: boolean }): React.JSX.Element { +export function LandingBrand(): React.JSX.Element { const t = useTranslate() return ( <>
    {t('auto.components.Landing.520304a067',
    -

    +

    {t('auto.components.Landing.6ca6ff404e', 'ORCA')}

    @@ -64,44 +53,3 @@ export function LandingShortcutList({
    ) } - -/** - * Home's two primary actions. Over the cloud list they sit as a top row aligned with it; in a - * narrow centre column (both side panels open) they shrink and stay on one row instead of - * stacking large buttons (JA-30). Needs an `@container` ancestor for the narrow case. - */ -export function LandingActions({ - overCloudList, - addProjectLabel, - createLabel, - onAddProject, - onCreate -}: { - overCloudList: boolean - addProjectLabel: string - createLabel: string - onAddProject: () => void - onCreate: () => void -}): React.JSX.Element { - const buttonClass = cn( - LANDING_ACTION_BUTTON_CLASS, - overCloudList && '@max-[600px]:px-2.5 @max-[600px]:py-1 @max-[600px]:text-xs' - ) - return ( -
    - - -
    - ) -} diff --git a/src/renderer/src/components/landing/LandingAddProjectMenu.test.tsx b/src/renderer/src/components/landing/LandingAddProjectMenu.test.tsx new file mode 100644 index 00000000000..e4a6c7e0220 --- /dev/null +++ b/src/renderer/src/components/landing/LandingAddProjectMenu.test.tsx @@ -0,0 +1,96 @@ +// @vitest-environment happy-dom +// Landing's Add project is the sidebar's Add project menu (not the old modal), and both carry +// "Open cloud workspace…" while signed in to Orca Cloud. +import { cleanup, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import { useAppStore } from '@/store' +import { getDefaultSettings } from '../../../../shared/constants' +import { LandingAddProjectMenu } from './LandingAddProjectMenu' +import { SidebarHeaderActions } from '../sidebar/sidebar-header-actions' +import { + closeCloudWorkspacePicker, + getCloudWorkspacePickerRequest +} from '../cloud-workspaces/cloud-workspace-picker-store' + +const cloud = vi.hoisted(() => ({ present: true })) +vi.mock('@/components/cloud-workspaces/use-cloud-workspaces', () => ({ + useCloudWorkspacesPresent: () => cloud.present +})) +vi.mock('@/components/sidebar/host-admission/ssh/AddRemoteHostDialog', () => ({ + AddRemoteHostDialog: () => null +})) + +const initialState = useAppStore.getState() + +beforeEach(() => { + cloud.present = true + useAppStore.setState({ + activeModal: 'none', + modalData: {}, + settings: getDefaultSettings('/tmp/orca-test') + }) +}) + +afterEach(() => { + cleanup() + closeCloudWorkspacePicker() + useAppStore.setState(initialState, true) +}) + +function renderLanding(): void { + render( + + + + + + ) +} + +describe('Landing Add project', () => { + it('opens the Add project menu, not the old add-project modal', async () => { + const user = userEvent.setup() + renderLanding() + await user.click(screen.getByRole('button', { name: 'Add project' })) + expect(await screen.findByRole('menuitem', { name: /Browse folder/ })).toBeTruthy() + expect(screen.getByRole('menuitem', { name: /Create new project/ })).toBeTruthy() + expect(useAppStore.getState().activeModal).toBe('none') + + await user.click(screen.getByRole('menuitem', { name: /Clone from URL/ })) + expect(useAppStore.getState().activeModal).toBe('add-repo') + expect(useAppStore.getState().modalData.entryRequest).toMatchObject({ kind: 'clone' }) + }) + + it('with Orca Cloud, "Open cloud workspace…" opens the picker', async () => { + const user = userEvent.setup() + renderLanding() + await user.click(screen.getByRole('button', { name: 'Add project' })) + await user.click(await screen.findByRole('menuitem', { name: 'Open cloud workspace…' })) + expect(getCloudWorkspacePickerRequest()?.focus).toEqual({ kind: 'open' }) + }) + + it('without Orca Cloud, the menu has no cloud entry', async () => { + cloud.present = false + const user = userEvent.setup() + renderLanding() + await user.click(screen.getByRole('button', { name: 'Add project' })) + await screen.findByRole('menuitem', { name: /Browse folder/ }) + expect(screen.queryByRole('menuitem', { name: 'Open cloud workspace…' })).toBeNull() + }) +}) + +describe('sidebar Add project', () => { + it('carries the same "Open cloud workspace…" entry', async () => { + const user = userEvent.setup() + render( + + + + ) + await user.click(screen.getByRole('button', { name: 'Add project' })) + await user.click(await screen.findByRole('menuitem', { name: 'Open cloud workspace…' })) + expect(getCloudWorkspacePickerRequest()?.focus).toEqual({ kind: 'open' }) + }) +}) diff --git a/src/renderer/src/components/landing/LandingAddProjectMenu.tsx b/src/renderer/src/components/landing/LandingAddProjectMenu.tsx new file mode 100644 index 00000000000..e06f015b766 --- /dev/null +++ b/src/renderer/src/components/landing/LandingAddProjectMenu.tsx @@ -0,0 +1,33 @@ +// Landing's "Add project" opens the same Add project menu as the sidebar's folder button (browse, +// clone, create, a project host, and "Open cloud workspace…" with Orca Cloud), not the old modal. +import React, { useCallback } from 'react' +import { useAppStore } from '@/store' +import { AddProjectMenu } from '@/components/sidebar/project-admission/menu/AddProjectMenu' +import type { AddRepoDialogEntryRequest } from '@/components/sidebar/project-admission/dialog/add-repo-dialog-types' +import { useOpenCloudWorkspaceMenuItems } from '@/components/cloud-workspaces/OpenCloudWorkspaceMenuItem' + +export function LandingAddProjectMenu({ + children +}: { + /** The trigger: Landing's own button, so its look stays as upstream has it. */ + children: React.ReactNode +}): React.JSX.Element { + const openModal = useAppStore((s) => s.openModal) + const cloudItems = useOpenCloudWorkspaceMenuItems() + const handleOpenEntryRequest = useCallback( + (entryRequest: AddRepoDialogEntryRequest) => { + openModal('add-repo', { entryRequest }) + }, + [openModal] + ) + return ( + + {children} + + ) +} diff --git a/src/renderer/src/components/landing/LandingCloudCard.tsx b/src/renderer/src/components/landing/LandingCloudCard.tsx index 5e52ddb437b..7a70ff68e31 100644 --- a/src/renderer/src/components/landing/LandingCloudCard.tsx +++ b/src/renderer/src/components/landing/LandingCloudCard.tsx @@ -33,7 +33,7 @@ export function LandingCloudCard({ cloudWorkspacesPresent, accessOverride }: { - /** The account already has cloud workspaces listed below: the card has done its job. */ + /** Signed in with Orca Cloud: the account can already open cloud workspaces, so no pitch. */ cloudWorkspacesPresent: boolean /** Fixtures pin the account's access. */ accessOverride?: OrcaCloudRunTargetAccess diff --git a/src/renderer/src/components/landing/LandingCloudSignIn.fixture.tsx b/src/renderer/src/components/landing/LandingCloudSignIn.fixture.tsx index 80c3a444514..df7bf325177 100644 --- a/src/renderer/src/components/landing/LandingCloudSignIn.fixture.tsx +++ b/src/renderer/src/components/landing/LandingCloudSignIn.fixture.tsx @@ -1,11 +1,13 @@ -// V3-08: Home signed out of Orca. Cloud builds with projects show the "Cloud workspaces · Sign in" -// card where the list would be; without projects, and in local builds, Home is unchanged. +// V3-08: Landing signed out of Orca. Cloud builds with projects show the "Cloud workspaces · Sign in" +// card; without projects, and in local builds, Landing is unchanged. Signed in, Landing is +// upstream's, and Add project opens the sidebar's Add project menu. import type { Repo } from '../../../../shared/repo-types' import type { OrcaProfileAuthStatus } from '../../../../shared/orca-profiles' import { defineFixture } from '../../component-fixtures/component-fixture' import { resetCloudWorkspacesAvailabilityForFixtures } from '../cloud-workspaces/use-cloud-workspaces' import { makeRepo } from '../worktree-jump/worktree-jump-palette-test-fixtures' import Landing from '../Landing' +import { AUTH_STATUS } from '../cloud-workspaces/cloud-workspaces.test-fixture' const repo: Repo = { ...makeRepo(), displayName: 'orca' } const auth = ( @@ -20,7 +22,7 @@ const build = (cloud: boolean) => (): void => { window.api.cloudWorkspaces = { ...window.api.cloudWorkspaces, available: async () => cloud } resetCloudWorkspacesAvailabilityForFixtures() } -// A healthy machine, so the shots show only Home's cloud area. +// A healthy machine, so the shots show only Landing's cloud area. const healthy = { preflightStatus: { git: { installed: true }, @@ -60,6 +62,38 @@ export const cloudSignedOutNoProjects = defineFixture({ render: home }) +/** Cloud build, signed in, has projects: Landing as upstream has it, no cloud list. */ +export const cloudSignedInWithProjects = defineFixture({ + width: 760, + setup: build(true), + store: { ...healthy, repos: [repo], orcaProfileAuthStatus: AUTH_STATUS }, + render: home +}) + +/** Signed in: Add project opens the sidebar's Add project menu, with "Open cloud workspace…". */ +export const addProjectMenuSignedIn = defineFixture({ + width: 760, + setup: build(true), + store: { ...healthy, repos: [repo], orcaProfileAuthStatus: AUTH_STATUS }, + render: home, + interact: async (frame) => { + const button = [...frame.querySelectorAll('button')].find( + (b) => b.textContent?.trim() === 'Add project' + ) + // Wait for the cloud availability probe so the Orca Cloud group is in the menu. + await new Promise((resolve) => setTimeout(resolve, 150)) + button?.dispatchEvent( + new PointerEvent('pointerdown', { + bubbles: true, + cancelable: true, + button: 0, + pointerType: 'mouse' + }) + ) + await new Promise((resolve) => setTimeout(resolve, 150)) + } +}) + /** Local build with projects: unchanged, no cloud card. */ export const localBuildWithProjects = defineFixture({ width: 760, diff --git a/src/renderer/src/components/landing/LandingCloudSignInCard.tsx b/src/renderer/src/components/landing/LandingCloudSignInCard.tsx index 1885daed00e..82accf4815a 100644 --- a/src/renderer/src/components/landing/LandingCloudSignInCard.tsx +++ b/src/renderer/src/components/landing/LandingCloudSignInCard.tsx @@ -1,6 +1,5 @@ -// V3-08: signed out of Orca in a build that offers Orca Cloud, Home says where the team's cloud -// workspaces went and how to see them, in the place the cloud list would be. Local builds and -// signed-in people never see it. While it shows, it stands in for the trial card (one cloud card). +// V3-08: signed out of Orca in a build that offers Orca Cloud, Landing says where the team's +// cloud workspaces went and how to see them. Local builds and signed-in people never see it. While it shows, it stands in for the trial card (one cloud card). import React from 'react' import { Cloud } from 'lucide-react' import { useAppStore } from '@/store' diff --git a/src/renderer/src/components/new-workspace/run-on/CloudStartSurfaces.fixture.tsx b/src/renderer/src/components/new-workspace/run-on/CloudStartSurfaces.fixture.tsx index 9c69db0f2a4..ecaaa80d7d2 100644 --- a/src/renderer/src/components/new-workspace/run-on/CloudStartSurfaces.fixture.tsx +++ b/src/renderer/src/components/new-workspace/run-on/CloudStartSurfaces.fixture.tsx @@ -1,8 +1,8 @@ -// Cloud-start surfaces outside the composer (spec v2): the Landing trial card (E2), the empty Cloud -// workspaces button (E3), the build sheet's hours chip (A7, O4) and the trial-used-up notice (T4). +// Cloud-start surfaces outside the composer (spec v2): the Landing trial card (E2), the empty "Open +// cloud workspace…" list's button (E3), the build sheet's hours chip (A7, O4) and the trial-used-up notice (T4). import { defineFixture } from '../../../component-fixtures/component-fixture' import { LandingCloudCard } from '../../landing/LandingCloudCard' -import { EmptyState } from '../../cloud-workspaces/CloudWorkspacesStates' +import { PickerEmptyState } from '../../cloud-workspaces/CloudWorkspacesStates' import { OrcaCloudHoursChip } from './OrcaCloudHoursChip' import { OrcaCloudTrialUsedUpNotice } from './OrcaCloudTrialUsedUpNotice' @@ -28,11 +28,11 @@ export const e2LandingTrialCard = defineFixture({
  • ) }) -/** E3: Cloud workspaces empty (Mine) with a real button. */ +/** E3: "Open cloud workspace…" with none at all, with a real button. */ export const e3CloudHomeEmpty = defineFixture({ width: 560, setup, - render: () => + render: () => {}} /> }) /** A7: the build sheet header chip while a trial start spins up. */ export const a7BuildSheetChip = defineFixture({ diff --git a/src/renderer/src/components/orca-cloud/CloudNotPairedCard.fixture.tsx b/src/renderer/src/components/orca-cloud/CloudNotPairedCard.fixture.tsx new file mode 100644 index 00000000000..cbae08047ca --- /dev/null +++ b/src/renderer/src/components/orca-cloud/CloudNotPairedCard.fixture.tsx @@ -0,0 +1,31 @@ +// A paired cloud workspace whose pairing is gone: the waking overlay's failure card offers +// "Open cloud workspace…" (it used to point at "Cloud workspaces on Home" in text). +import { defineFixture } from '../../component-fixtures/component-fixture' +import { CloudWakingOverlay } from './CloudWakingOverlay' + +const noop = (): void => {} + +export const notPairedCard = defineFixture({ + width: 640, + render: () => ( +
    + +
    + ) +}) diff --git a/src/renderer/src/components/orca-cloud/CloudNotPairedCard.tsx b/src/renderer/src/components/orca-cloud/CloudNotPairedCard.tsx new file mode 100644 index 00000000000..669253356ea --- /dev/null +++ b/src/renderer/src/components/orca-cloud/CloudNotPairedCard.tsx @@ -0,0 +1,65 @@ +// The waking overlay's card when this computer's pairing to a cloud workspace is gone: nothing here +// can wake it, so instead of Try again it offers "Open cloud workspace…", which pairs it again. +import React from 'react' +import { AlertTriangle, Cloud, X } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { useTranslate } from '@/i18n/use-translate' +import { openCloudWorkspacePicker } from '@/components/cloud-workspaces/cloud-workspace-picker-store' + +export function CloudNotPairedCard({ + name, + autoFocus = true, + onDismiss +}: { + name: string + autoFocus?: boolean + onDismiss: () => void +}): React.JSX.Element { + const t = useTranslate() + return ( +
    +
    +
    +
    + +
    +
    + ) +} diff --git a/src/renderer/src/components/orca-cloud/CloudPrimaryDeleteDialog.tsx b/src/renderer/src/components/orca-cloud/CloudPrimaryDeleteDialog.tsx index 30525dc1b97..8644608a2de 100644 --- a/src/renderer/src/components/orca-cloud/CloudPrimaryDeleteDialog.tsx +++ b/src/renderer/src/components/orca-cloud/CloudPrimaryDeleteDialog.tsx @@ -1,7 +1,7 @@ // CLOUD-PRIMARY-DELETE: Delete for a cloud row that IS the cloud workspace (the sandbox's own // checkout, shown as "primary"), including paired ones this desktop did not create. A local removal // cannot delete it (Git keeps a primary checkout; a paired sandbox isn't this desktop's to destroy), -// so this is the cloud delete dialog from Home and the sidebar, acting through the control plane: +// so this is the cloud delete dialog from the sidebar, acting through the control plane: // Delete destroys the sandbox, Archive instead archives it (with Undo). import React, { useRef, useState, useSyncExternalStore } from 'react' import { toast } from 'sonner' @@ -26,14 +26,15 @@ import { subscribeCloudPrimaryDelete, type CloudPrimaryDeleteTarget } from '@/lib/orca-cloud/cloud-primary-delete-request' +import { openCloudWorkspacePicker } from '@/components/cloud-workspaces/cloud-workspace-picker-store' type Target = CloudPrimaryDeleteTarget const setRequested = requestCloudPrimaryDelete const NO_SANDBOX = (): string => translate( - 'orcaCloudWorkspace.primaryDelete.noSandbox', - "Orca couldn't find this workspace's sandbox. Delete it from Cloud workspaces." + 'orcaCloudWorkspace.primaryDelete.noSandbox2', + "Orca couldn't find this workspace's sandbox. Find it in Open cloud workspace… to delete it." ) async function withSandbox(target: Target, run: (sandboxId: string) => Promise) { @@ -43,7 +44,13 @@ async function withSandbox(target: Target, run: (sandboxId: string) => Promise openCloudWorkspacePicker() + } + } ) return } diff --git a/src/renderer/src/components/orca-cloud/CloudWakingOverlay.tsx b/src/renderer/src/components/orca-cloud/CloudWakingOverlay.tsx index f2278a90d1f..5a2eba25244 100644 --- a/src/renderer/src/components/orca-cloud/CloudWakingOverlay.tsx +++ b/src/renderer/src/components/orca-cloud/CloudWakingOverlay.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useRef } from 'react' import { AlertTriangle, Cloud, X } from 'lucide-react' import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' +import { CloudNotPairedCard } from './CloudNotPairedCard' import { formatElapsedTime } from '@/components/worktree-creation/worktree-creation-clock' import { useNow } from '@/hooks/use-now' import { useTranslate } from '@/i18n/use-translate' @@ -201,7 +202,9 @@ export function CloudWakingOverlay({ } data-cloud-waking-anchor={passThrough ? 'terminal' : undefined} > - {failed ? ( + {failed && wake.notPaired ? ( + + ) : failed ? (
    s.openModal) const label = t('auto.components.sidebar.SidebarHeader.addProject', 'Add project') + const cloudItems = useOpenCloudWorkspaceMenuItems() const handleOpenEntryRequest = useCallback( (entryRequest: AddRepoDialogEntryRequest) => { openModal('add-repo', { entryRequest }) @@ -36,6 +38,7 @@ function AddProjectButton({ preserveWorkspaceBoardOpen={preserveWorkspaceBoardOpen} onOpenChange={onMenuOpenChange} onOpenEntryRequest={handleOpenEntryRequest} + extraItems={cloudItems} > ) : null} diff --git a/src/renderer/src/components/team-mfa/team-mfa.test.tsx b/src/renderer/src/components/team-mfa/team-mfa.test.tsx index 6a07012637c..18999d7b3b2 100644 --- a/src/renderer/src/components/team-mfa/team-mfa.test.tsx +++ b/src/renderer/src/components/team-mfa/team-mfa.test.tsx @@ -50,9 +50,9 @@ describe('what the member sees', () => { expect(teamMfaSurface(policy())).toBe('banner') expect(teamMfaSurface(policy({ self: 'blocked' }))).toBe('blocked') // Past the deadline by the local clock, but the server still says pending: not blocked. - expect( - teamMfaSurface(policy({ policy: { enforceAt: NOW - DAY, setAt: NOW - 8 * DAY } })) - ).toBe('banner') + expect(teamMfaSurface(policy({ policy: { enforceAt: NOW - DAY, setAt: NOW - 8 * DAY } }))).toBe( + 'banner' + ) }) it('is nothing with MFA on, an unknown state, or no policy', () => { @@ -241,7 +241,7 @@ describe('banner after the deadline', () => { ) expect(container.textContent).toContain('Acme requires two-factor authentication') expect(container.textContent).toContain('until you set it up') - expect(container.textContent).toContain('Your personal account is not affected') + expect(container.textContent).toContain('Your personal account isn’t affected') expect(container.textContent).not.toContain('days left') expect(getByRole('button', { name: 'Set up MFA' })).toBeTruthy() }) @@ -290,7 +290,10 @@ describe('escalation', () => { describe('one-time toast', () => { const memory = (): Pick => { const map = new Map() - return { getItem: (key) => map.get(key) ?? null, setItem: (key, value) => void map.set(key, value) } + return { + getItem: (key) => map.get(key) ?? null, + setItem: (key, value) => void map.set(key, value) + } } it('shows once per team and deadline, and again when the date changes', () => { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index af32b910741..312e56a8eae 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -21712,7 +21712,7 @@ "soonTitle_one": "{{value0}} requires MFA in {{count}} day", "soonTitle_other": "{{value0}} requires MFA in {{count}} days", "toast": "{{value0}} requires MFA from {{value1}}.", - "bannerBlockedBody": "You can’t use this team’s workspaces until you set it up. Your personal account is not affected.", + "bannerBlockedBody": "You can’t use this team’s workspaces until you set it up. Your personal account isn’t affected.", "setUp": "Set up MFA", "blockedTitle": "Set up two-factor authentication to use {{value0}}", "blockedBody": "{{value0}} has required it for all members since {{value1}}. Your workspaces and files are safe, and open again as soon as you add an authenticator app.", From 10a6999ef7a98f9eedd44255aef58e1401f78535 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 22:16:40 -0800 Subject: [PATCH 30/50] fix(cloud): SEC-5 share toast through one helper; drop the replaced private catalog key --- .../cloud-workspaces/CloudWorkspacesHome.tsx | 12 ++---------- .../cloud-workspaces/shared-with-team-toast.ts | 15 +++++++++++++++ .../orca-cloud/CloudWorkspaceMenuItems.tsx | 12 ++---------- src/renderer/src/i18n/locales/en.json | 1 - 4 files changed, 19 insertions(+), 21 deletions(-) create mode 100644 src/renderer/src/components/cloud-workspaces/shared-with-team-toast.ts diff --git a/src/renderer/src/components/cloud-workspaces/CloudWorkspacesHome.tsx b/src/renderer/src/components/cloud-workspaces/CloudWorkspacesHome.tsx index 5533b809edb..094144d9230 100644 --- a/src/renderer/src/components/cloud-workspaces/CloudWorkspacesHome.tsx +++ b/src/renderer/src/components/cloud-workspaces/CloudWorkspacesHome.tsx @@ -32,6 +32,7 @@ import { useCloudReadPanel } from './use-cloud-read-panel' import type { MemberDirectory } from './cloud-workspace-format' import { refreshCloudMessageSenders } from '@/lib/cloud-message-sender' import { describeError } from './cloud-workspace-errors' +import { toastSharedWithTeam } from './shared-with-team-toast' import { focusCloudChat } from './cloud-chat-focus' import * as homeArchive from './cloud-workspace-home-archive' import { focusedKind, frontCloudChat } from './cloud-workspace-links' @@ -221,16 +222,7 @@ export function CloudWorkspacesHome({ void refreshCloudMessageSenders().catch(() => undefined) } if (change.visibility === 'team') { - // SEC-5: the owner's personal secrets stop being injected once teammates can open it. - toast.success( - t('auto.components.cloudWorkspaces.sharedWithYourTeam', 'Shared with your team'), - { - description: t( - 'auto.components.cloudWorkspaces.sharedPersonalSecretsNote', - 'Your personal cloud secrets aren’t used in shared workspaces. Shared and repository secrets still are.' - ) - } - ) + toastSharedWithTeam(t) } else if (change.visibility === 'private') { toast.success( t( diff --git a/src/renderer/src/components/cloud-workspaces/shared-with-team-toast.ts b/src/renderer/src/components/cloud-workspaces/shared-with-team-toast.ts new file mode 100644 index 00000000000..b40318b1ab7 --- /dev/null +++ b/src/renderer/src/components/cloud-workspaces/shared-with-team-toast.ts @@ -0,0 +1,15 @@ +import { toast } from 'sonner' +import type { Translate } from '@/i18n/use-translate' + +/** + * "Shared with your team", with the one thing that changes for the owner (SEC-5): their personal + * secrets stop being injected once teammates can open the workspace. + */ +export function toastSharedWithTeam(t: Translate): void { + toast.success(t('auto.components.cloudWorkspaces.sharedWithYourTeam', 'Shared with your team'), { + description: t( + 'auto.components.cloudWorkspaces.sharedPersonalSecretsNote', + 'Your personal cloud secrets aren’t used in shared workspaces. Shared and repository secrets still are.' + ) + }) +} diff --git a/src/renderer/src/components/orca-cloud/CloudWorkspaceMenuItems.tsx b/src/renderer/src/components/orca-cloud/CloudWorkspaceMenuItems.tsx index ab4fc3cb34c..11e46733bc3 100644 --- a/src/renderer/src/components/orca-cloud/CloudWorkspaceMenuItems.tsx +++ b/src/renderer/src/components/orca-cloud/CloudWorkspaceMenuItems.tsx @@ -12,6 +12,7 @@ import { } from 'lucide-react' import { DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu' import { useTranslate } from '@/i18n/use-translate' +import { toastSharedWithTeam } from '@/components/cloud-workspaces/shared-with-team-toast' import { isCloudSyncAvailable, requestCloudSyncFocus } from '@/lib/orca-cloud/cloud-sync-store' import { ConfirmationDialogContext, @@ -179,16 +180,7 @@ async function setVisibility( return } if (visibility === 'team') { - // SEC-5: the owner's personal secrets stop being injected once teammates can open it. - toast.success( - t('auto.components.cloudWorkspaces.sharedWithYourTeam', 'Shared with your team'), - { - description: t( - 'auto.components.cloudWorkspaces.sharedPersonalSecretsNote', - 'Your personal cloud secrets aren’t used in shared workspaces. Shared and repository secrets still are.' - ) - } - ) + toastSharedWithTeam(t) } else { toast.success( t( diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index c44781927fa..2c9dbbad384 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -28032,7 +28032,6 @@ }, "setUpAgent": "Set up an agent", "personal": { - "description": "Only you get these, in every Orca Cloud workspace you start.", "emptyTitle": "No personal secrets yet", "emptyDetail": "Add one or paste a .env file. New terminals and agents in your cloud workspaces get them as environment variables.", "descriptionUnshared": "Only you get these, in cloud workspaces you haven’t shared. Shared workspaces don’t get them." From 689b5be5fc4105a4235bad64a16b0b71e0952cb2 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 22:20:53 -0800 Subject: [PATCH 31/50] test(sidebar): derive the local host label instead of hard-coding 'Local Mac' The label comes from process.platform, so the test failed on the Linux premerge lane (omarchy) with 'Local Linux' for unrelated PRs (#623). --- .../controls/sidebar-orca-cloud-host.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/sidebar/worktree-list/controls/sidebar-orca-cloud-host.test.ts b/src/renderer/src/components/sidebar/worktree-list/controls/sidebar-orca-cloud-host.test.ts index 4d728a3d532..b95497b2372 100644 --- a/src/renderer/src/components/sidebar/worktree-list/controls/sidebar-orca-cloud-host.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list/controls/sidebar-orca-cloud-host.test.ts @@ -38,6 +38,11 @@ function hostOptions() { ) } +// The local host's label comes from process.platform ("Local Mac" on macOS, "Local Linux" on Linux): read it from the +// built options instead of hard-coding it, so the test passes on any OS (it failed on the Linux premerge lane). +const localLabel = (): string => + hostOptions().find((host) => host.id === 'local')?.label ?? 'Local Mac' + const worktrees: Worktree[] = [ { ...worktree, id: 'wt-local', displayName: 'local work' }, { ...worktree, id: 'wt-a', hostId: 'runtime:env-a', displayName: 'fix login' }, @@ -80,7 +85,7 @@ function groupedByHost() { // The Hosts filter with every host checked: the view that draws host headers. visibleWorkspaceHostIds: everyHost, defaultHostId: 'local', - localHostLabel: 'Local Mac', + localHostLabel: localLabel(), preferProjectGrouping: true }) } @@ -89,7 +94,7 @@ describe('Orca Cloud is one host (RA-2)', () => { it('collapses two sandboxes into one "Orca Cloud" section with an aggregate state', () => { const rows = groupedByHost() const hostHeaders = rows.filter((row) => row.type === 'host-header') - expect(hostHeaders.map((row) => row.label)).toEqual(['Local Mac', 'Orca Cloud']) + expect(hostHeaders.map((row) => row.label)).toEqual([localLabel(), 'Orca Cloud']) const cloud = hostHeaders[1] expect(cloud?.count).toBe(2) // Never one sandbox's state. @@ -107,7 +112,7 @@ describe('Orca Cloud is one host (RA-2)', () => { it('lists Orca Cloud once in the Hosts filter, and checking it selects every sandbox', () => { const hosts = hostOptions() const scope = buildSidebarHostScopeOptions(hosts, t) - expect(scope.map((option) => option.label)).toEqual(['All hosts', 'Local Mac', 'Orca Cloud']) + expect(scope.map((option) => option.label)).toEqual(['All hosts', localLabel(), 'Orca Cloud']) const cloudRow = scope.find((option) => option.label === 'Orca Cloud') expect(cloudRow?.detail).toBe('1 running · 1 asleep') expect( From 34bbe8d1bcb6aee28068380573a77790fe0a1bdf Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 22:31:00 -0800 Subject: [PATCH 32/50] fix(billing): show the overage status by the control that changed; Couldn't reach billing design-squad on #629: a failed Turn off says "Couldn't turn off overage." by its link (and Turn on likewise), "Couldn't save" stays by the cap field for cap edits; the billing reason line uses the contraction at its source. Co-Authored-By: Claude --- .../PlanBillingOverageSave.fixture.tsx | 2 +- .../billing/PlanBillingOverageSave.test.tsx | 28 +++++++-- .../components/billing/PlanBillingUsage.tsx | 62 +++++++++++-------- .../billing/checkout/CheckoutPanel.tsx | 2 +- .../components/billing/overage-save-state.tsx | 18 +++++- .../components/billing/plan-billing-copy.ts | 4 +- src/renderer/src/i18n/locales/en.json | 6 +- 7 files changed, 82 insertions(+), 40 deletions(-) diff --git a/src/renderer/src/components/billing/PlanBillingOverageSave.fixture.tsx b/src/renderer/src/components/billing/PlanBillingOverageSave.fixture.tsx index 10f5f4d8886..98c0d1b1442 100644 --- a/src/renderer/src/components/billing/PlanBillingOverageSave.fixture.tsx +++ b/src/renderer/src/components/billing/PlanBillingOverageSave.fixture.tsx @@ -36,7 +36,7 @@ function OverageCard({ setTimeout(() => { setBusy(false) if (outcome === 'failed') { - setNotice({ kind: 'error', text: 'Could not reach billing. Try again.' }) + setNotice({ kind: 'error', text: 'Couldn’t reach billing. Try again.' }) setFailures((count) => count + 1) return } diff --git a/src/renderer/src/components/billing/PlanBillingOverageSave.test.tsx b/src/renderer/src/components/billing/PlanBillingOverageSave.test.tsx index 055a899e9ec..270502d213a 100644 --- a/src/renderer/src/components/billing/PlanBillingOverageSave.test.tsx +++ b/src/renderer/src/components/billing/PlanBillingOverageSave.test.tsx @@ -120,26 +120,30 @@ describe('overage on, off and on again', () => { }) describe('overage save state', () => { - it('a failed Turn off ends in Couldn’t save and leaves overage on and changeable', () => { + // design-squad: the failure sits by the control that failed, not by the cap field. + it('a failed Turn off says so by its link and leaves overage on and changeable', () => { const { pending } = renderCard({ ...sampleTeam, overage: { ...sampleTeam.overage, optedIn: true, active: true, spendCapCents: 4000 } }) fireEvent.click(screen.getByRole('button', { name: 'Turn off overage' })) settleLast(pending, false) - expect(statusText()).toBe('Couldn’t save') - expect(screen.getByLabelText('Monthly spend cap').value).toBe('40') + expect(statusText()).toBe('Couldn’t turn off overage.') const off = screen.getByRole('button', { name: 'Turn off overage' }) + expect(off.closest('div')?.contains(screen.getByRole('status'))).toBe(true) + const field = screen.getByLabelText('Monthly spend cap') + expect(field.value).toBe('40') + expect(field.closest('form')?.contains(screen.getByRole('status'))).toBe(false) expect(off.getAttribute('aria-disabled')).toBeNull() fireEvent.click(off) expect(pending).toHaveLength(2) }) - it('a failed Turn on ends in Couldn’t save next to a working Turn on', () => { + it('a failed Turn on says so next to a working Turn on', () => { const { pending } = renderCard() fireEvent.click(turnOnButton()) settleLast(pending, false) - expect(statusText()).toBe('Couldn’t save') + expect(statusText()).toBe('Couldn’t turn on overage.') expect(screen.queryByLabelText('Monthly spend cap')).toBeNull() fireEvent.click(turnOnButton()) expect(pending).toHaveLength(2) @@ -147,6 +151,20 @@ describe('overage save state', () => { expect(statusText()).toBe('Saved') }) + it('a failed cap change says Couldn’t save by the field and rolls the value back', () => { + const { pending } = renderCard({ + ...sampleTeam, + overage: { ...sampleTeam.overage, optedIn: true, active: true, spendCapCents: 4000 } + }) + const field = screen.getByLabelText('Monthly spend cap') + fireEvent.change(field, { target: { value: '60' } }) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + settleLast(pending, false) + expect(statusText()).toBe('Couldn’t save') + expect(field.closest('form')?.contains(screen.getByRole('status'))).toBe(true) + expect(field.value).toBe('40') + }) + it('never shows the check with Saving…', () => { const { pending } = renderCard() fireEvent.click(turnOnButton()) diff --git a/src/renderer/src/components/billing/PlanBillingUsage.tsx b/src/renderer/src/components/billing/PlanBillingUsage.tsx index bcd1ce8bc78..acffe8a9ff0 100644 --- a/src/renderer/src/components/billing/PlanBillingUsage.tsx +++ b/src/renderer/src/components/billing/PlanBillingUsage.tsx @@ -17,7 +17,7 @@ import { } from './plan-billing-usage-copy' import { formatCents, formatRateCents } from './plans/plans-pricing' import { capTooHighCopy } from './plan-billing-copy' -import { OverageSaveStatus, useOverageSaveState } from './overage-save-state' +import { OverageSaveStatus, useOverageSaveState, type OverageAction } from './overage-save-state' import { ORCA_BILLING_OVERAGE_SECTION_ID } from '../new-workspace/orca-cloud-run-target-access' /** "Resets and renews Dec 2" when both land the same day (monthly); separate parts otherwise. */ @@ -143,7 +143,7 @@ export function OverageControls({ setSeededFrom(savedDollars) setCapDollars(savedDollars) } - const { status, begin, clear } = useOverageSaveState({ + const { status, action, begin, clear } = useOverageSaveState({ overage, busy, failures, @@ -211,8 +211,9 @@ export function OverageControls({ ) } - const save = (enabled: boolean, cents: number | null): void => { - begin({ optedIn: enabled, capCents: enabled ? cents : null }) + const save = (action: OverageAction, cents: number | null): void => { + const enabled = action !== 'off' + begin({ action, optedIn: enabled, capCents: enabled ? cents : null }) onSetOverage( !enabled ? { enabled: false } @@ -222,7 +223,9 @@ export function OverageControls({ ) } const blocked = busy || saving || paymentFailed - const statusLine = + const statusLine = + // Turn off's status sits by its link, so it doesn't read as the cap failing (design-squad). + const offStatus = action === 'off' const blockedReason = paymentFailed ? t( 'auto.components.billing.overagePaymentFailed', @@ -252,7 +255,7 @@ export function OverageControls({ setCapDollars(String(Math.round(defaultCapCents / 100))) } focusOnSwitch.current = true - save(true, defaultCapCents) + save('on', defaultCapCents) } // N-09: off is one status line and one action. No cap field and no "$ 0" (which reads as "on"). @@ -306,7 +309,7 @@ export function OverageControls({ onSubmit={(event) => { event.preventDefault() if (changed && !blocked) { - save(true, parsedCap * 100) + save('cap', parsedCap * 100) // Save turns into the status in place; keep focus in the card on the field. capInputRef.current?.focus() } @@ -352,7 +355,9 @@ export function OverageControls({ )}
    {saving || !changed ? ( - statusLine + offStatus ? null : ( + statusLine + ) ) : ( - +
    + + + + {offStatus ? statusLine : null} +
    {alertsNote}
    ) diff --git a/src/renderer/src/components/billing/checkout/CheckoutPanel.tsx b/src/renderer/src/components/billing/checkout/CheckoutPanel.tsx index 8ab5a50efe0..83870d2cb2a 100644 --- a/src/renderer/src/components/billing/checkout/CheckoutPanel.tsx +++ b/src/renderer/src/components/billing/checkout/CheckoutPanel.tsx @@ -217,7 +217,7 @@ export function CheckoutPanel(props: CheckoutPanelProps): React.JSX.Element { : t('auto.components.billing.checkout.openFailed', 'Checkout didn’t open') body = openFailureCopy(phase.result, t) ?? - t('auto.components.billing.openFailed', 'Could not reach billing. Try again.') + t('auto.components.billing.openFailed', 'Couldn’t reach billing. Try again.') break } diff --git a/src/renderer/src/components/billing/overage-save-state.tsx b/src/renderer/src/components/billing/overage-save-state.tsx index a6d5baf16de..d2042757224 100644 --- a/src/renderer/src/components/billing/overage-save-state.tsx +++ b/src/renderer/src/components/billing/overage-save-state.tsx @@ -11,8 +11,11 @@ import type { OrcaBillingEntitlement } from '../../../../shared/orca-billing' */ export type OverageSaveState = 'idle' | 'saving' | 'saved' | 'error' +/** Which control made the change: its status shows next to that control (design-squad). */ +export type OverageAction = 'on' | 'off' | 'cap' + /** What a pending change asks for; a null cap means billing's default (or, when off, none). */ -export type OverageTarget = { optedIn: boolean; capCents: number | null } +export type OverageTarget = { action: OverageAction; optedIn: boolean; capCents: number | null } export function overageMatches( overage: OrcaBillingEntitlement['overage'], @@ -40,12 +43,15 @@ export function useOverageSaveState({ onFailure: () => void }): { status: OverageSaveState + /** The control behind the last change; null before the first one. */ + action: OverageAction | null begin: (target: OverageTarget) => void /** Back to no status, e.g. once the field is edited again (review M13). */ clear: () => void } { const [saveState, setSaveState] = useState('idle') const [target, setTarget] = useState(null) + const [action, setAction] = useState(null) // Whether the parent has reported the request in flight since this change began. const [sawBusy, setSawBusy] = useState(false) const [seenFailures, setSeenFailures] = useState(failures) @@ -73,8 +79,10 @@ export function useOverageSaveState({ } return { status, + action, begin: (next) => { setSaveState('saving') + setAction(next.action) setSawBusy(false) setTarget(next) }, @@ -88,9 +96,11 @@ export function useOverageSaveState({ /** One status for both views: a spinner while saving; the check only ever goes with "Saved". */ export function OverageSaveStatus({ - status + status, + action }: { status: OverageSaveState + action: OverageAction | null }): React.JSX.Element | null { const t = useTranslate() if (status === 'idle') { @@ -115,6 +125,10 @@ export function OverageSaveStatus({ {t('auto.components.billing.capSaved', 'Saved')} + ) : action === 'off' ? ( + t('components.billing.overage.turnOffFailed', 'Couldn’t turn off overage.') + ) : action === 'on' ? ( + t('components.billing.overage.turnOnFailed', 'Couldn’t turn on overage.') ) : ( t('auto.components.billing.capNotSaved', 'Couldn’t save') )} diff --git a/src/renderer/src/components/billing/plan-billing-copy.ts b/src/renderer/src/components/billing/plan-billing-copy.ts index c2e0ffc16f6..2388b5e9616 100644 --- a/src/renderer/src/components/billing/plan-billing-copy.ts +++ b/src/renderer/src/components/billing/plan-billing-copy.ts @@ -141,7 +141,7 @@ export function openFailureCopy(result: OrcaBillingOpenResult, t: Translate): st case 'unconfigured': return t('auto.components.billing.openUnconfigured', 'Billing is unavailable in this build.') case 'failed': - return t('auto.components.billing.openFailed', 'Could not reach billing. Try again.') + return t('auto.components.billing.openFailed', 'Couldn’t reach billing. Try again.') } } @@ -181,6 +181,6 @@ export function overageFailureCopy( case 'unconfigured': return t('auto.components.billing.openUnconfigured', 'Billing is unavailable in this build.') case 'failed': - return t('auto.components.billing.openFailed', 'Could not reach billing. Try again.') + return t('auto.components.billing.openFailed', 'Couldn’t reach billing. Try again.') } } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index f32bf3161a8..35dd76b8a26 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -21054,7 +21054,7 @@ "openAlreadySubscribed": "This account already has a plan. Use Manage billing to change it.", "openNoAccount": "There is no billing account yet. Choose a plan first.", "openUnconfigured": "Billing is unavailable in this build.", - "openFailed": "Could not reach billing. Try again.", + "openFailed": "Couldn’t reach billing. Try again.", "endsOn": "Plan ends {{value0}}", "renewsOn": "Renews {{value0}}", "continueInBrowser": "Continue in your browser. This updates when you come back.", @@ -24361,7 +24361,9 @@ "offStatus2": "Overage is off. Running sandboxes go to sleep and new ones can’t start when your team runs out of sandbox hours.", "turnOff": "Turn off overage", "memberOn5": "Overage is on, up to your {{cap}} spend cap.", - "memberOff5": "Off until an owner or admin turns it on." + "memberOff5": "Off until an owner or admin turns it on.", + "turnOffFailed": "Couldn’t turn off overage.", + "turnOnFailed": "Couldn’t turn on overage." }, "overageUsed": "{{value0}} of {{value1}} used", "capZero2": "Set a spend cap above $0, or turn overage off." From 4ea9f1288058230af1c57ad799e25e2771ef3a78 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 22:32:45 -0800 Subject: [PATCH 33/50] fix(recipes): render passEnv as code in the narrowed recipe-env headline Design-squad sign-off on agentHostPanel.recipeEnvNarrowed: same text, with passEnv in a code span so it reads as the orca.yaml key. Co-Authored-By: Claude --- .../RecipeEnvForwardingWarning.test.tsx | 25 +++++++++++++++++++ .../RecipeEnvForwardingWarning.tsx | 23 +++++++++++------ src/renderer/src/i18n/locales/en.json | 2 +- 3 files changed, 42 insertions(+), 8 deletions(-) create mode 100644 src/renderer/src/components/agent-host-panel/RecipeEnvForwardingWarning.test.tsx diff --git a/src/renderer/src/components/agent-host-panel/RecipeEnvForwardingWarning.test.tsx b/src/renderer/src/components/agent-host-panel/RecipeEnvForwardingWarning.test.tsx new file mode 100644 index 00000000000..00718ef0fae --- /dev/null +++ b/src/renderer/src/components/agent-host-panel/RecipeEnvForwardingWarning.test.tsx @@ -0,0 +1,25 @@ +// @vitest-environment happy-dom +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, it } from 'vitest' +import { RecipeEnvForwardingWarning } from './RecipeEnvForwardingWarning' + +afterEach(cleanup) + +const summary = { count: 2, keyLike: ['AWS_SECRET_ACCESS_KEY'], other: ['PATH'] } + +it('says the recipe gets only what its passEnv allows, with passEnv as code', () => { + render() + const code = screen.getByText('passEnv') + expect(code.tagName).toBe('CODE') + expect(code.parentElement?.textContent).toBe( + "This recipe's create script gets only the variables its passEnv allows." + ) + expect(screen.queryByText(/this computer's environment/)).toBeNull() +}) + +it("keeps the whole-environment headline when passEnv didn't apply", () => { + render() + expect( + screen.getByText("This recipe's create script gets this computer's environment.") + ).toBeTruthy() +}) diff --git a/src/renderer/src/components/agent-host-panel/RecipeEnvForwardingWarning.tsx b/src/renderer/src/components/agent-host-panel/RecipeEnvForwardingWarning.tsx index abb228f99de..6e4b48150c6 100644 --- a/src/renderer/src/components/agent-host-panel/RecipeEnvForwardingWarning.tsx +++ b/src/renderer/src/components/agent-host-panel/RecipeEnvForwardingWarning.tsx @@ -1,6 +1,8 @@ import { TriangleAlert } from 'lucide-react' import type React from 'react' import { useEffect, useId, useState } from 'react' +import { Trans } from 'react-i18next' +import { i18n } from '@/i18n/i18n' import { useTranslate } from '@/i18n/use-translate' import { Button } from '../ui/button' import { Skeleton } from '../ui/skeleton' @@ -71,15 +73,22 @@ export function RecipeEnvForwardingWarning({

    - {summary?.narrowed - ? t( + {summary?.narrowed ? ( + // passEnv is an orca.yaml key, so it reads as code rather than a word. + passEnv allows." )} + components={{ code: }} + /> + ) : ( + t( + 'agentHostPanel.recipeEnv', + "This recipe's create script gets this computer's environment." + ) + )}

    {summary === null ? ( diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index e74f6cabd73..52299f52065 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -28468,7 +28468,7 @@ "deviceStep": "Enter this code on auth.openai.com. The sign-in is saved on {{host}}.", "codePlaceholder": "Paste the code" }, - "recipeEnvNarrowed": "This recipe's create script gets only the variables its passEnv allows." + "recipeEnvNarrowed": "This recipe's create script gets only the variables its passEnv allows." }, "cloudSecretsAsk": { "scopeLabel": "Scope for {{name}}", From 8514964961fe54eadd11630efb38661c0455430c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 12 Nov 2026 22:34:43 -0800 Subject: [PATCH 34/50] fix(cloud): PAIRED-WAKE-TYPE-THROUGH typing during a paired wake is held and delivered to the woken terminal, like owned (#617) The paired surface's waking cover now lets input through to the pane (pointer-events-none) while it wakes, shows the held-input count with Clear, and toasts what was delivered, using the same useWakeInput / useWakeInputNotices / wake-input-buffer as the owned surface. A failed wake covers the pane again. --- .../PairedCloudWorkspaceSurface.test.tsx | 8 +++++++ .../PairedCloudWorkspaceSurface.tsx | 24 ++++++++++++++++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/orca-cloud/PairedCloudWorkspaceSurface.test.tsx b/src/renderer/src/components/orca-cloud/PairedCloudWorkspaceSurface.test.tsx index c4ec26e7780..8ba8dbf77f3 100644 --- a/src/renderer/src/components/orca-cloud/PairedCloudWorkspaceSurface.test.tsx +++ b/src/renderer/src/components/orca-cloud/PairedCloudWorkspaceSurface.test.tsx @@ -124,6 +124,10 @@ describe('the paired cloud workspace surface', () => { const { container } = render() await flush() expect(container.querySelector('[data-cloud-cover="waking"]')).not.toBeNull() + // PAIRED-WAKE-TYPE-THROUGH: while it wakes the pane underneath stays typeable, like owned. + expect(container.querySelector('[data-cloud-cover="waking"]')?.className).toContain( + 'pointer-events-none' + ) act(() => seedPaired('sleeping', { wake: { @@ -143,6 +147,10 @@ describe('the paired cloud workspace surface', () => { /Try again/.test(b.textContent ?? '') ) expect(retry).toBeDefined() + // A failed wake covers the pane again: nothing typed there would reach a terminal. + expect(container.querySelector('[data-cloud-cover="waking"]')?.className).not.toContain( + 'pointer-events-none' + ) fireEvent.click(retry!) expect(wakePaired).toHaveBeenCalledWith(ID) }) diff --git a/src/renderer/src/components/orca-cloud/PairedCloudWorkspaceSurface.tsx b/src/renderer/src/components/orca-cloud/PairedCloudWorkspaceSurface.tsx index cc903cee8ad..f4831d6675d 100644 --- a/src/renderer/src/components/orca-cloud/PairedCloudWorkspaceSurface.tsx +++ b/src/renderer/src/components/orca-cloud/PairedCloudWorkspaceSurface.tsx @@ -27,6 +27,8 @@ import { toRuntimeExecutionHostId } from '../../../../shared/execution-host' import { coveredPaneHadFocus, useInertSiblings } from './cloud-cover-focus' import { CloudWakingOverlay } from './CloudWakingOverlay' import { useWakeOverlay } from './use-wake-overlay' +import { useWakeInput, useWakeInputNotices } from './use-wake-input' +import { clearWakeInput } from '@/components/terminal-pane/input/wake-input-buffer' import { useConfirmedRestart } from './use-cloud-panel-actions' import { useCloudWorkspaceView } from './use-cloud-workspace-view' import { useSleepCopyProgress } from './offline/CloudSleepCopyState' @@ -114,10 +116,15 @@ export function PairedCloudWorkspaceSurface({ const view = useCloudWorkspaceView(workspaceId, { pairedRows: true }) const name = useAppStore((state) => findWorktreeById(state, workspaceId)?.displayName ?? null) const now = useNow(1_000, view.wake !== undefined) + // PAIRED-WAKE-TYPE-THROUGH: typing is held while it wakes and delivered to the woken terminal, + // exactly as for an owned workspace (the store begins and ends the hold on wake progress). + const wakeInput = useWakeInput(workspaceId) + useWakeInputNotices(workspaceId) const overlay = useWakeOverlay( view.wake, Math.max(now, view.wake?.at ?? 0), - t('orcaCloudWorkspace.overlay.stalled', 'Orca Cloud stopped responding while waking.') + t('orcaCloudWorkspace.overlay.stalled', 'Orca Cloud stopped responding while waking.'), + wakeInput.pending > 0 ) const copySaveEntry = useSleepCopySave(workspaceId) const wakeInFlight = Boolean( @@ -141,10 +148,12 @@ export function PairedCloudWorkspaceSurface({ const slowNow = useNow(30_000, layer === 'asleep') const restart = useConfirmedRestart(workspaceId) + // While it wakes the pane stays typeable under the overlay (a failed wake covers it again). + const typeThrough = layer === 'waking' && overlay.wake?.phase !== 'failed' const coverRef = useRef(null) const headingRef = useRef(null) const [takeFocus, setTakeFocus] = useState(false) - useInertSiblings(coverRef, layer !== null && !overlay.leaving) + useInertSiblings(coverRef, layer !== null && !typeThrough && !overlay.leaving) useLayoutEffect(() => { setTakeFocus(layer !== null && coveredPaneHadFocus(coverRef.current)) }, [layer]) @@ -175,7 +184,13 @@ export function PairedCloudWorkspaceSurface({ {layer ? (
    @@ -185,6 +200,9 @@ export function PairedCloudWorkspaceSurface({ workspaceName={name} leaving={overlay.leaving} autoFocus={takeFocus} + typeThrough={typeThrough} + pendingInput={wakeInput.pending} + onClearPending={() => clearWakeInput(workspaceId)} onRetry={() => void wakePairedCloudWorkspace(workspaceId)} onRestart={restart} onDismiss={() => dismissCloudWake(workspaceId)} From 0a90be9ac83075c0af39e58e06d04c8dea00e08e Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 22:40:25 -0800 Subject: [PATCH 35/50] test(billing): billing-link expects the contracted Couldn't reach billing copy Co-Authored-By: Claude --- src/renderer/src/components/billing/billing-link.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/components/billing/billing-link.test.ts b/src/renderer/src/components/billing/billing-link.test.ts index 8bca573d724..f17900f1118 100644 --- a/src/renderer/src/components/billing/billing-link.test.ts +++ b/src/renderer/src/components/billing/billing-link.test.ts @@ -51,6 +51,6 @@ describe('billing "Copy link"', () => { } }) await expect(billingPortalLink()).resolves.toBeNull() - expect(toastError).toHaveBeenCalledWith('Could not reach billing. Try again.') + expect(toastError).toHaveBeenCalledWith('Couldn’t reach billing. Try again.') }) }) From ebacf7c882b16ef62cc91f606f30a50f40c48ce2 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 22:47:05 -0800 Subject: [PATCH 36/50] fix(cloud): premerge lint: type guards instead of assertions, MFA member fields in their own file, runtime catalog, link-muted button --- .../hosted-sandbox-config.test.ts | 2 +- .../hosted-sandbox/hosted-sandbox-config.ts | 9 +++-- .../orca-profile-org-members-handlers.ts | 17 +++++----- .../cloud-mfa/profile-cloud-mfa-service.ts | 10 ++++-- .../profile-cloud-team-mfa-service.ts | 33 +++++++++++-------- .../profile-cloud-org-members-client.ts | 2 +- .../team-mfa/TeamMfaBlockedScreen.tsx | 8 +---- .../src/components/team-mfa/team-mfa.test.tsx | 4 +-- .../src/i18n/en-runtime-required.json | 12 +++++++ src/shared/orca-account-mfa.ts | 14 ++++++++ src/shared/orca-profiles.ts | 14 ++------ 11 files changed, 75 insertions(+), 50 deletions(-) diff --git a/src/main/hosted-sandbox/hosted-sandbox-config.test.ts b/src/main/hosted-sandbox/hosted-sandbox-config.test.ts index 143960b1ab1..c22aa14be3d 100644 --- a/src/main/hosted-sandbox/hosted-sandbox-config.test.ts +++ b/src/main/hosted-sandbox/hosted-sandbox-config.test.ts @@ -126,7 +126,7 @@ describe('isMfaRequiredRefusal', () => { { status: 403 } ) expect(await isMfaRequiredRefusal(response)).toBe(true) - expect(((await response.json()) as { orgId: string }).orgId).toBe('org_1') + expect(await response.json()).toMatchObject({ orgId: 'org_1' }) }) it('is false for other refusals and for bodies that are not JSON', async () => { diff --git a/src/main/hosted-sandbox/hosted-sandbox-config.ts b/src/main/hosted-sandbox/hosted-sandbox-config.ts index 973ab6596e5..6558709f8af 100644 --- a/src/main/hosted-sandbox/hosted-sandbox-config.ts +++ b/src/main/hosted-sandbox/hosted-sandbox-config.ts @@ -28,8 +28,13 @@ export const MFA_REQUIRED_REASON = 'mfa_required' /** Reads a copy of the body, so the caller can still read the response. */ export async function isMfaRequiredRefusal(response: Response): Promise { try { - const body = (await response.clone().json()) as { error?: unknown } | null - return body?.error === MFA_REQUIRED_REASON + const body: unknown = await response.clone().json() + return ( + typeof body === 'object' && + body !== null && + 'error' in body && + body.error === MFA_REQUIRED_REASON + ) } catch { return false } diff --git a/src/main/ipc/profiles/orca-profile-org-members-handlers.ts b/src/main/ipc/profiles/orca-profile-org-members-handlers.ts index 455bdc47600..889b38d0d94 100644 --- a/src/main/ipc/profiles/orca-profile-org-members-handlers.ts +++ b/src/main/ipc/profiles/orca-profile-org-members-handlers.ts @@ -160,9 +160,8 @@ export function registerOrcaProfileOrgMemberHandlers(): void { createOrcaProfileOrg(getProfileUserDataPath(), { name: orgCreateNameFromUnknown(rawArgs) }) ) - ipcMain.handle( - 'orcaProfiles:accountMfa', - async (): Promise => getOrcaProfileAccountMfa(getProfileUserDataPath()) + ipcMain.handle('orcaProfiles:accountMfa', async (): Promise => + getOrcaProfileAccountMfa(getProfileUserDataPath()) ) ipcMain.handle( @@ -175,15 +174,15 @@ export function registerOrcaProfileOrgMemberHandlers(): void { 'orcaProfiles:teamMfaPolicySet', async (_event, rawArgs: unknown): Promise => { const { orgId, record } = orgMembersScopedArgs(rawArgs) - const enforce = record.enforce // Only the fixed choices cross to the server; anything else is refused here. - if (enforce !== 'off' && !ORCA_TEAM_MFA_GRACE_CHOICES.some((choice) => choice === enforce)) { + const enforce: OrcaTeamMfaGrace | 'off' | undefined = + record.enforce === 'off' + ? 'off' + : ORCA_TEAM_MFA_GRACE_CHOICES.find((choice) => choice === record.enforce) + if (enforce === undefined) { return { status: 'failed', error: 'invalid_enforce' } } - return setOrcaTeamMfaPolicy(getProfileUserDataPath(), { - orgId, - enforce: enforce as OrcaTeamMfaGrace | 'off' - }) + return setOrcaTeamMfaPolicy(getProfileUserDataPath(), { orgId, enforce }) } ) diff --git a/src/main/orca-profiles/cloud-mfa/profile-cloud-mfa-service.ts b/src/main/orca-profiles/cloud-mfa/profile-cloud-mfa-service.ts index a901526a3cb..f4349260791 100644 --- a/src/main/orca-profiles/cloud-mfa/profile-cloud-mfa-service.ts +++ b/src/main/orca-profiles/cloud-mfa/profile-cloud-mfa-service.ts @@ -15,7 +15,8 @@ import { ensureActiveOrcaProfile } from '../profile-index-store' * a compromised or misconfigured answer cannot send the person to another site to "set up MFA". */ export function normalizeAccountMfa(value: unknown, apiBaseUrl: string): OrcaAccountMfa { - const record = value && typeof value === 'object' ? (value as Record) : {} + const record: Record = + typeof value === 'object' && value !== null ? { ...value } : {} const mfa = record.mfa === 'on' || record.mfa === 'off' ? record.mfa : null let setupUrl: URL | null = null try { @@ -46,10 +47,13 @@ async function fetchAccountMfa( if (!response.ok) { throw new OrcaCloudRequestError(response.status, await extractErrorCode(response)) } - return normalizeAccountMfa((await response.json()) as unknown, config.apiBaseUrl) + const body: unknown = await response.json() + return normalizeAccountMfa(body, config.apiBaseUrl) } -export async function getOrcaProfileAccountMfa(userDataPath: string): Promise { +export async function getOrcaProfileAccountMfa( + userDataPath: string +): Promise { const active = ensureActiveOrcaProfile(userDataPath) // Local dev auth has no server and no factor to enrol. if (isOrcaCloudDevAuthEnabled()) { diff --git a/src/main/orca-profiles/cloud-mfa/profile-cloud-team-mfa-service.ts b/src/main/orca-profiles/cloud-mfa/profile-cloud-team-mfa-service.ts index ae7f8101976..88cb059de74 100644 --- a/src/main/orca-profiles/cloud-mfa/profile-cloud-team-mfa-service.ts +++ b/src/main/orca-profiles/cloud-mfa/profile-cloud-team-mfa-service.ts @@ -25,6 +25,10 @@ import { } from '../cloud-org/profile-cloud-org-members-service' import { ensureActiveOrcaProfile } from '../profile-index-store' +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + const SELF_STATES: readonly OrcaTeamMfaSelfState[] = ['on', 'off', 'pending', 'blocked', 'unknown'] function finiteNumber(value: unknown): number | null { @@ -47,27 +51,25 @@ export function sameOriginUrl(value: unknown, apiBaseUrl: string): string | null } function normalizeDeadline(value: unknown): { enforceAt: number; setAt: number } | null { - const record = value && typeof value === 'object' ? (value as Record) : null + const record = isRecord(value) ? value : null const enforceAt = finiteNumber(record?.enforceAt) const setAt = finiteNumber(record?.setAt) return enforceAt !== null && setAt !== null ? { enforceAt, setAt } : null } export function normalizeTeamMfaPolicy(value: unknown, apiBaseUrl: string): OrcaTeamMfaPolicy { - const record = value && typeof value === 'object' ? (value as Record) : null + const record = isRecord(value) ? value : null const setupUrl = sameOriginUrl(record?.setupUrl, apiBaseUrl) if (!record || !setupUrl) { throw new Error('invalid_orca_team_mfa_policy') } - const selfRecord = - record.self && typeof record.self === 'object' ? (record.self as Record) : {} + const selfRecord = isRecord(record.self) ? record.self : {} // An unrecognized state is "unknown", never "blocked": only the server's word blocks anyone. const self = SELF_STATES.find((state) => state === selfRecord.mfa) ?? 'unknown' const pendingMembers = finiteNumber(record.pendingMembers) - const inactive = - record.inactivePolicy && typeof record.inactivePolicy === 'object' - ? finiteNumber((record.inactivePolicy as Record).enforceAt) - : null + const inactive = isRecord(record.inactivePolicy) + ? finiteNumber(record.inactivePolicy.enforceAt) + : null return { policy: normalizeDeadline(record.policy), self, @@ -86,10 +88,10 @@ export function normalizeTeamMfaPolicy(value: unknown, apiBaseUrl: string): Orca } export function normalizePendingMembers(value: unknown): OrcaTeamMfaPendingMember[] { - const record = value && typeof value === 'object' ? (value as Record) : null + const record = isRecord(value) ? value : null const members = Array.isArray(record?.members) ? record.members : [] return members.flatMap((entry): OrcaTeamMfaPendingMember[] => { - const member = entry && typeof entry === 'object' ? (entry as Record) : null + const member = isRecord(entry) ? entry : null const email = typeof member?.email === 'string' ? member.email.trim() : '' if (!member || !email) { return [] @@ -111,7 +113,8 @@ async function request(url: string, init: RequestInit): Promise { if (!response.ok) { throw new OrcaCloudRequestError(response.status, await extractErrorCode(response)) } - return (await response.json()) as unknown + const body: unknown = await response.json() + return body } type Unavailable = Extract< @@ -203,7 +206,8 @@ export async function setOrcaTeamMfaPolicy( args.enforce === 'off' ? { ...requestInit('GET', token), method: 'DELETE' } : { ...requestInit('POST', token, { enforce: args.enforce }), method: 'PUT' } - const body = (await request(url('/mfa-policy'), init)) as Record | null + const answer = await request(url('/mfa-policy'), init) + const body = isRecord(answer) ? answer : null return { policy: normalizeDeadline(body?.policy), pendingMembers: finiteNumber(body?.pendingMembers) ?? 0 @@ -254,10 +258,11 @@ export async function remindOrcaTeamMfa( const result = await call( userDataPath, async (_apiBaseUrl, token, url) => { - const body = (await request( + const answer = await request( url('/mfa-policy/remind'), requestInit('POST', token, args.userId ? { userId: args.userId } : {}) - )) as Record | null + ) + const body = isRecord(answer) ? answer : null const delivery: 'log' | 'test-inbox' | 'unknown' = body?.delivery === 'log' || body?.delivery === 'test-inbox' ? body.delivery : 'unknown' return { recipients: finiteNumber(body?.recipients) ?? 0, delivery } diff --git a/src/main/orca-profiles/cloud-org/profile-cloud-org-members-client.ts b/src/main/orca-profiles/cloud-org/profile-cloud-org-members-client.ts index 26ad8e230d3..90e14c3e393 100644 --- a/src/main/orca-profiles/cloud-org/profile-cloud-org-members-client.ts +++ b/src/main/orca-profiles/cloud-org/profile-cloud-org-members-client.ts @@ -1,6 +1,6 @@ +import type { OrcaOrgMemberMfa } from '../../../shared/orca-account-mfa' import type { OrcaOrgMember, - OrcaOrgMemberMfa, OrcaOrgMembersRoster, OrcaOrgPendingInvite, OrcaOrgRole diff --git a/src/renderer/src/components/team-mfa/TeamMfaBlockedScreen.tsx b/src/renderer/src/components/team-mfa/TeamMfaBlockedScreen.tsx index bb18047e2ec..f89665070ec 100644 --- a/src/renderer/src/components/team-mfa/TeamMfaBlockedScreen.tsx +++ b/src/renderer/src/components/team-mfa/TeamMfaBlockedScreen.tsx @@ -84,13 +84,7 @@ export function TeamMfaBlockedScreen({ : null}

    {onUsePersonal ? ( - ) : null} diff --git a/src/renderer/src/components/team-mfa/team-mfa.test.tsx b/src/renderer/src/components/team-mfa/team-mfa.test.tsx index 18999d7b3b2..34bc8f81f65 100644 --- a/src/renderer/src/components/team-mfa/team-mfa.test.tsx +++ b/src/renderer/src/components/team-mfa/team-mfa.test.tsx @@ -212,8 +212,8 @@ describe('Require MFA row', () => { describe('the server refusal in cloud workspace errors', () => { it('says to set up MFA, never to sign in again', () => { - for (const describe of [describeError, describeInlineError]) { - const message = describe('unauthorized', 'mfa_required', translate) + for (const explain of [describeError, describeInlineError]) { + const message = explain('unauthorized', 'mfa_required', translate) expect(message).toContain('requires two-factor authentication') expect(message).not.toMatch(/sign in/i) } diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index a987535364d..83d3ecc7cc7 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -1374,6 +1374,18 @@ "unavailableCount_one": "{{count}} unavailable", "unavailableCount_other": "{{count}} unavailable" }, + "teamMfa": { + "daysLeft_one": "{{count}} day left", + "daysLeft_other": "{{count}} days left", + "nowWarning_one": "{{count}} member doesn’t have it yet and will lose access to this team right away.", + "nowWarning_other": "{{count}} members don’t have it yet and will lose access to this team right away.", + "pendingCount_one": "{{count}} member still needs it", + "pendingCount_other": "{{count}} members still need it", + "reminded_one": "Reminder sent to {{count}} member.", + "reminded_other": "Reminder sent to {{count}} members.", + "soonTitle_one": "{{value0}} requires MFA in {{count}} day", + "soonTitle_other": "{{value0}} requires MFA in {{count}} days" + }, "teammates": { "daySandboxHours_one": "{{hours}} sandbox hour", "daySandboxHours_other": "{{hours}} sandbox hours" diff --git a/src/shared/orca-account-mfa.ts b/src/shared/orca-account-mfa.ts index 12596bc0cda..2274d9784eb 100644 --- a/src/shared/orca-account-mfa.ts +++ b/src/shared/orca-account-mfa.ts @@ -11,3 +11,17 @@ export type OrcaAccountMfaResult = /** `unsupported`: this Orca Cloud deployment (or local dev auth) has no MFA status. */ | { status: 'unconfigured' | 'reconnect-required' | 'unsupported' } | { status: 'failed'; error: string } + +/** + * A team member's two-factor state as owners and admins see it. Absent for everyone else, and + * for a member the provider said nothing about. 'pending': a team policy requires it by the + * member's `mfaDeadline` and they have not set it up. + */ +export type OrcaOrgMemberMfa = 'on' | 'off' | 'pending' + +/** The two-factor fields of a team member (OrcaOrgMember in orca-profiles.ts). */ +export type OrcaOrgMemberMfaFields = { + mfa?: OrcaOrgMemberMfa + /** Epoch ms the member must have MFA by; only with `mfa: 'pending'`. */ + mfaDeadline?: number +} diff --git a/src/shared/orca-profiles.ts b/src/shared/orca-profiles.ts index 4ce8ec0bdc8..1f8d3daf904 100644 --- a/src/shared/orca-profiles.ts +++ b/src/shared/orca-profiles.ts @@ -1,3 +1,4 @@ +import type { OrcaOrgMemberMfaFields } from './orca-account-mfa' import './constants' import { ORCA_BROWSER_PARTITION } from './browser/browser-session-partition' import type { ExecutionHostId } from './execution-host' @@ -250,7 +251,8 @@ export type RefreshCurrentOrcaProfileAuthResult = // Mirror the server’s fixed role enum so UI options cannot drift from accepted API values. export type OrcaOrgRole = 'owner' | 'admin' | 'member' -export type OrcaOrgMember = { +// Two-factor state (owners and admins only) lives in OrcaOrgMemberMfaFields. +export type OrcaOrgMember = OrcaOrgMemberMfaFields & { // Provisioned teammates without sign-in have no userId; disable mutations requiring that key. userId: string | null email: string @@ -258,18 +260,8 @@ export type OrcaOrgMember = { /** Profile picture from the identity provider (https only); avatars fall back to initials. */ pictureUrl?: string role: OrcaOrgRole - /** - * Whether the member has two-factor authentication. The server sends it to owners and admins - * only, so it is absent for everyone else, and for a member the provider said nothing about. - * 'pending': a team policy requires it by `mfaDeadline` and the member has not set it up. - */ - mfa?: OrcaOrgMemberMfa - /** Epoch ms the member must have MFA by; only with `mfa: 'pending'`. */ - mfaDeadline?: number } -export type OrcaOrgMemberMfa = 'on' | 'off' | 'pending' - export type OrcaOrgPendingInvite = { email: string role: OrcaOrgRole From b6fdb18033e34f3be6a10d0b02a18fdd48ebd433 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 22:49:42 -0800 Subject: [PATCH 37/50] test(recipes): type the forwarded-env handler fake instead of casting its result Co-Authored-By: Claude --- src/main/ipc/ephemeral-vm-forwarded-env.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/ipc/ephemeral-vm-forwarded-env.test.ts b/src/main/ipc/ephemeral-vm-forwarded-env.test.ts index 6a59860b2c3..e0fd791fd14 100644 --- a/src/main/ipc/ephemeral-vm-forwarded-env.test.ts +++ b/src/main/ipc/ephemeral-vm-forwarded-env.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest' -const handlers = new Map unknown>() +// The only channel registered here returns a summary. +const handlers = new Map RecipeForwardedEnvSummary>() const { listRuntimesMock, recipeContextMock } = vi.hoisted(() => ({ listRuntimesMock: vi.fn(), recipeContextMock: vi.fn() @@ -31,7 +32,7 @@ function summarize(args?: unknown): RecipeForwardedEnvSummary { if (!handler) { throw new Error('handler not registered') } - return handler({}, args) as RecipeForwardedEnvSummary + return handler({}, args) } beforeEach(() => { From 8c27b84e761e6d29f9d02d0356ea2d37f74df65f Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 22:09:22 -0800 Subject: [PATCH 38/50] fix(cloud): mint sandbox tokens for the shown team, verify the org claim, x-orca-org + re-mint once (ORG-TOKEN-STALE b) - Mint body {orgId} (null for personal); fall back to {} on 400 from older auth. - Decode the minted token's org claim; never cache a wrong-team token, re-mint once, then fail with "Couldn't switch to {team}. Sign out and back in." - selectCloudOrg refreshes the session before publishing activeOrgId; refresh errors surface; the switch bumps a token epoch that drops cached sandbox tokens. - The token epoch also bumps on any membership/role change in a saved session and on member role change/removal. - Control-plane fetch wrapper (all desktop clients) sends x-orca-org: ; on 409 org_mismatch or 401/403 stale_token it invalidates, re-mints once and retries once. Co-Authored-By: Claude --- src/main/cloud-api/cloud-api-client.ts | 17 +- src/main/cloud-api/cloud-api-service.ts | 1 + src/main/cloud-secrets/cloud-secrets-ipc.ts | 3 +- .../cloud-workspaces-service.ts | 2 +- .../hosted-environment-service.ts | 2 +- .../hosted-sandbox/hosted-sandbox-config.ts | 195 +++++++---- .../hosted-sandbox-device-enrollment.ts | 2 +- .../hosted-sandbox/hosted-sandbox-install.ts | 3 +- .../hosted-sandbox-org-fetch.ts | 71 ++++ .../hosted-sandbox-org-token.test.ts | 307 ++++++++++++++++++ .../hosted-sandbox/hosted-sandbox-recipe.ts | 3 +- .../hosted-sandbox-share-ipc.ts | 3 +- .../hosted-sandbox-token-org.ts | 47 +++ .../orca-cloud-agents-ipc.ts | 3 +- .../profile-cloud-org-members-service.ts | 11 +- .../profile-cloud-org-selection.test.ts | 87 +++++ .../cloud-org/profile-cloud-org-selection.ts | 35 +- .../profile-cloud-session-store.ts | 4 + src/main/orca-profiles/cloud-token-epoch.ts | 42 +++ 19 files changed, 750 insertions(+), 88 deletions(-) create mode 100644 src/main/hosted-sandbox/hosted-sandbox-org-fetch.ts create mode 100644 src/main/hosted-sandbox/hosted-sandbox-org-token.test.ts create mode 100644 src/main/hosted-sandbox/hosted-sandbox-token-org.ts create mode 100644 src/main/orca-profiles/cloud-org/profile-cloud-org-selection.test.ts create mode 100644 src/main/orca-profiles/cloud-token-epoch.ts diff --git a/src/main/cloud-api/cloud-api-client.ts b/src/main/cloud-api/cloud-api-client.ts index 272b025d3b6..ff382485367 100644 --- a/src/main/cloud-api/cloud-api-client.ts +++ b/src/main/cloud-api/cloud-api-client.ts @@ -107,6 +107,13 @@ export function classifyCloudApiResponse( return { code: 'server', detail: error ?? `HTTP ${status}` } } +/** The "Couldn't switch to {team}" message, when the token could not be minted for the team. */ +function orgSwitchDetail(error: unknown): string | undefined { + return error instanceof Error && (error as { reason?: unknown }).reason === 'org_mismatch' + ? error.message + : undefined +} + export function createCloudApiClient(deps: CloudApiClientDeps) { const fetchImpl = deps.fetchImpl ?? fetch const base = `${deps.controlPlaneUrl.replace(/\/+$/, '')}/` @@ -121,7 +128,10 @@ export function createCloudApiClient(deps: CloudApiClientDeps) { try { token = await deps.getAccessToken() } catch (error) { - throw new CloudApiError(deps.classifyTokenError?.(error) ?? 'signed-out') + throw new CloudApiError( + deps.classifyTokenError?.(error) ?? 'signed-out', + orgSwitchDetail(error) + ) } const url = new URL(path.replace(/^\//, ''), base) for (const [name, value] of Object.entries(options.query ?? {})) { @@ -142,8 +152,9 @@ export function createCloudApiClient(deps: CloudApiClientDeps) { redirect: 'error', signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }) - } catch { - throw new CloudApiError('network') + } catch (error) { + // The control-plane fetch throws when a re-minted token still names another team. + throw new CloudApiError('network', orgSwitchDetail(error)) } if (!response.ok) { const body: unknown = await response.json().catch(() => null) diff --git a/src/main/cloud-api/cloud-api-service.ts b/src/main/cloud-api/cloud-api-service.ts index a90aca89939..bd2fcdb5bea 100644 --- a/src/main/cloud-api/cloud-api-service.ts +++ b/src/main/cloud-api/cloud-api-service.ts @@ -49,6 +49,7 @@ function resolveClient(): { client: CloudApiClient; controlPlaneUrl: string } | client: createCloudApiClient({ controlPlaneUrl: config.controlPlaneUrl, getAccessToken: () => config.getAccessToken(), + fetchImpl: config.fetch, classifyTokenError }) } diff --git a/src/main/cloud-secrets/cloud-secrets-ipc.ts b/src/main/cloud-secrets/cloud-secrets-ipc.ts index 3a75ba41dad..411cb5e4c61 100644 --- a/src/main/cloud-secrets/cloud-secrets-ipc.ts +++ b/src/main/cloud-secrets/cloud-secrets-ipc.ts @@ -102,8 +102,7 @@ export function registerCloudSecretsHandlers(userDataPath: string): void { ? createCloudSecretsClient({ baseUrl: config.controlPlaneUrl, getAccessToken: config.getAccessToken, - // Admin-only writes need a token under a minute old; a stale one is re-minted once. - invalidateAccessToken: config.invalidateAccessToken + fetch: config.fetch }) : null // Agent API keys saved while signed in are written to personal secrets through the same client. diff --git a/src/main/cloud-workspaces/cloud-workspaces-service.ts b/src/main/cloud-workspaces/cloud-workspaces-service.ts index 6ee16e8539b..9050dd334a3 100644 --- a/src/main/cloud-workspaces/cloud-workspaces-service.ts +++ b/src/main/cloud-workspaces/cloud-workspaces-service.ts @@ -83,7 +83,7 @@ export function createCloudWorkspacesService(deps: { const client: CloudWorkspacesClient = createCloudWorkspacesClient({ baseUrl: deps.config.controlPlaneUrl, getAccessToken: deps.config.getAccessToken, - fetch: deps.fetch + fetch: deps.fetch ?? deps.config.fetch }) let active: { sandboxId: string diff --git a/src/main/hosted-sandbox/environments/hosted-environment-service.ts b/src/main/hosted-sandbox/environments/hosted-environment-service.ts index 233a4d82fbb..0d15ea8fe51 100644 --- a/src/main/hosted-sandbox/environments/hosted-environment-service.ts +++ b/src/main/hosted-sandbox/environments/hosted-environment-service.ts @@ -32,7 +32,7 @@ export function createHostedEnvironmentService( ? createHostedEnvironmentClient({ baseUrl: config.controlPlaneUrl, getAccessToken: config.getAccessToken, - fetch: fetchImpl + fetch: fetchImpl ?? config.fetch }) : null } diff --git a/src/main/hosted-sandbox/hosted-sandbox-config.ts b/src/main/hosted-sandbox/hosted-sandbox-config.ts index a85ed77e2b7..b945c1bfb57 100644 --- a/src/main/hosted-sandbox/hosted-sandbox-config.ts +++ b/src/main/hosted-sandbox/hosted-sandbox-config.ts @@ -13,6 +13,9 @@ import { type ActiveOrcaProfileState } from '../orca-profiles/profile-index-store' import { getOrcaProfileIndexGeneration } from '../orca-profiles/profile-index-generation' +import { getOrcaCloudTokenEpoch } from '../orca-profiles/cloud-token-epoch' +import { createOrgCheckedFetch, ORCA_ORG_HEADER_PERSONAL } from './hosted-sandbox-org-fetch' +import { isNotAMember, orgSwitchError, readTokenOrg } from './hosted-sandbox-token-org' import { EnvironmentProviderError } from '../environment-providers/environment-provider-driver' export type HostedSandboxConfig = { @@ -20,6 +23,11 @@ export type HostedSandboxConfig = { getAccessToken: (signal?: AbortSignal) => Promise /** Drops the cached token so the next call mints one (the control plane said it predates a role change). */ invalidateAccessToken?: () => void + /** + * fetch for control-plane calls: adds `x-orca-org` (the team the desktop shows, or `user`), and + * on a 409 org_mismatch or a stale_token refusal re-mints once and retries. + */ + fetch?: typeof fetch } const SandboxTokenBody = z.object({ @@ -45,8 +53,12 @@ type CachedSandboxToken = { identity: string /** Profile index generation the identity was last confirmed at. */ generation: number + /** Team-switch / membership / role epoch the token was minted under; any change drops it. */ + epoch: number } +type MintedSandboxToken = { token: string; expiresAt: number } + /** Sandbox tokens are minted per call by Orca Cloud auth; cached until a minute before expiry. */ export function getHostedSandboxConfig( userDataPath: string, @@ -62,69 +74,109 @@ export function getHostedSandboxConfig( ? env.ORCA_CLOUD_SANDBOX_DEV_KEY_FILE?.trim() : undefined let cached: CachedSandboxToken | null = null + const explicitOrg: ExplicitOrgSupport = { unsupportedUntil: 0 } let inflight: { identity: string - promise: Promise<{ token: string; expiresAt: number }> + epoch: number + promise: Promise } | null = null const fresh = (entry: CachedSandboxToken | null): entry is CachedSandboxToken => - entry !== null && entry.expiresAt - Date.now() > 60_000 + entry !== null && + entry.expiresAt - Date.now() > 60_000 && + entry.epoch === getOrcaCloudTokenEpoch() const mint = (active: ActiveOrcaProfileState) => devKeyFile ? mintDevSandboxToken(devKeyFile, active.profile.cloud, env) - : mintSandboxToken(userDataPath, active, env, packaged, fetchImpl) - return { - controlPlaneUrl, - getAccessToken: async () => { - // Why: sign-in, sign-out and team or profile switches all rewrite the profile index. A token - // minted for the previous identity would create sandboxes the new one can't see or delete. - if (fresh(cached) && cached.generation === getOrcaProfileIndexGeneration()) { + : mintSandboxToken(userDataPath, active, env, packaged, fetchImpl, explicitOrg) + // Concurrent callers for the same identity and epoch share one mint. + const sharedMint = (active: ActiveOrcaProfileState, identity: string, epoch: number) => { + if (inflight?.identity === identity && inflight.epoch === epoch) { + return inflight.promise + } + const entry = { identity, epoch, promise: mint(active) } + inflight = entry + void entry.promise + .catch(() => undefined) + .finally(() => { + if (inflight === entry) { + inflight = null + } + }) + return entry.promise + } + const getAccessToken = async (): Promise => { + // Why: sign-in, sign-out and team or profile switches all rewrite the profile index. A token + // minted for the previous identity would create sandboxes the new one can't see or delete. + if (fresh(cached) && cached.generation === getOrcaProfileIndexGeneration()) { + return cached.token + } + let orgMismatches = 0 + for (let attempt = 0; ; attempt++) { + const generation = getOrcaProfileIndexGeneration() + const epoch = getOrcaCloudTokenEpoch() + const active = ensureActiveOrcaProfile(userDataPath) + const identity = sandboxIdentity(active) + if (fresh(cached) && cached.identity === identity) { + cached = { ...cached, generation } return cached.token } - for (let attempt = 0; ; attempt++) { - const generation = getOrcaProfileIndexGeneration() - const active = ensureActiveOrcaProfile(userDataPath) - const identity = sandboxIdentity(active) - if (fresh(cached) && cached.identity === identity) { - cached = { ...cached, generation } - return cached.token - } - // Concurrent callers for the same identity share one mint. - let shared = inflight?.identity === identity ? inflight : null - if (!shared) { - const entry = { identity, promise: mint(active) } - shared = entry - inflight = entry - void entry.promise - .catch(() => undefined) - .finally(() => { - if (inflight === entry) { - inflight = null - } - }) - } - const minted = await shared.promise - // An identity change while minting must not hand the old identity's token to the caller. - if ( - generation !== getOrcaProfileIndexGeneration() && - sandboxIdentity(ensureActiveOrcaProfile(userDataPath)) !== identity - ) { - if (attempt >= 2) { - throw new EnvironmentProviderError( - 'unavailable', - 'Your Orca account changed while signing in to Orca Cloud. Try again.' - ) - } - continue - } - cached = { ...minted, identity, generation } - return minted.token - } - }, - invalidateAccessToken: () => { - // A fresh mint on the next call; one already in flight is fresh anyway. + // Only one identity is ever cached: a switch drops every other team's token. cached = null + const pending = sharedMint(active, identity, epoch) + const minted = await pending + // An identity, team or role change while minting must not hand the old token to the caller. + const identityMoved = + generation !== getOrcaProfileIndexGeneration() && + sandboxIdentity(ensureActiveOrcaProfile(userDataPath)) !== identity + if (identityMoved || epoch !== getOrcaCloudTokenEpoch()) { + if (attempt >= 2) { + throw new EnvironmentProviderError( + 'unavailable', + 'Your Orca account changed while signing in to Orca Cloud. Try again.' + ) + } + continue + } + // Why: auth may still pick the team from session state (the old team mid-switch). Never + // cache or use a token for any team other than the one this identity asked for. + const requestedOrg = active.profile.cloud?.activeOrgId ?? null + const tokenOrg = readTokenOrg(minted.token) + if (tokenOrg !== requestedOrg) { + console.warn( + `[orca-cloud] sandbox token org mismatch: requested=${requestedOrg ?? 'user'} got=${tokenOrg ?? 'user'}` + ) + orgMismatches++ + // The re-mint must ask auth again, not share the refused answer. + if (inflight?.promise === pending) { + inflight = null + } + if (orgMismatches >= 2) { + throw orgSwitchError(active) + } + continue + } + cached = { ...minted, identity, generation, epoch } + return minted.token } } + const invalidateAccessToken = (): void => { + // A fresh mint on the next call; one already in flight is fresh anyway. + cached = null + } + return { + controlPlaneUrl, + getAccessToken, + invalidateAccessToken, + fetch: createOrgCheckedFetch({ + fetchImpl, + orgHeader: () => + ensureActiveOrcaProfile(userDataPath).profile.cloud?.activeOrgId ?? + ORCA_ORG_HEADER_PERSONAL, + getAccessToken, + invalidateAccessToken, + orgSwitchError: () => orgSwitchError(ensureActiveOrcaProfile(userDataPath)) + }) + } } function sandboxIdentity(active: ActiveOrcaProfileState): string { @@ -132,17 +184,23 @@ function sandboxIdentity(active: ActiveOrcaProfileState): string { return JSON.stringify([active.profile.id, cloud?.userId ?? null, cloud?.activeOrgId ?? null]) } +/** Auth older than the explicit-org mint body answers 400; skip the body for a while after one. */ +type ExplicitOrgSupport = { unsupportedUntil: number } +const EXPLICIT_ORG_RETRY_MS = 10 * 60_000 + async function mintSandboxToken( userDataPath: string, active: ActiveOrcaProfileState, env: NodeJS.ProcessEnv, packaged: boolean, - fetchImpl: typeof fetch + fetchImpl: typeof fetch, + explicitOrg: ExplicitOrgSupport ): Promise<{ token: string; expiresAt: number }> { const auth = getOrcaCloudAuthConfig(env, packaged) if (!auth.configured || !active.profile.cloud) { throw new EnvironmentProviderError('unauthorized', 'Sign in to Orca to use Orca Cloud.') } + const requestedOrg = active.profile.cloud.activeOrgId ?? null const endpoint = cleanCloudServiceUrl(env.ORCA_CLOUD_SANDBOX_TOKEN_URL, !packaged) ?? new URL('/v1/desktop/auth/sandbox-token', `${auth.config.apiBaseUrl}/`).toString() @@ -151,16 +209,29 @@ async function mintSandboxToken( active, userDataPath, async (session) => { - const response = await fetchImpl(endpoint, { - method: 'POST', - headers: { - 'content-type': 'application/json', - authorization: `Bearer ${session.accessToken}` - }, - body: '{}', - redirect: 'error', - signal: AbortSignal.timeout(15_000) - }) + const post = (body: string) => + fetchImpl(endpoint, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${session.accessToken}` + }, + body, + redirect: 'error', + signal: AbortSignal.timeout(15_000) + }) + // Ask for the team explicitly; auth must not pick it from session state mid-switch. + const explicit = Date.now() >= explicitOrg.unsupportedUntil + let response = await post(explicit ? JSON.stringify({ orgId: requestedOrg }) : '{}') + if (explicit && response.status === 400) { + // Auth older than the explicit-org contract rejects any body. The caller's org-claim check + // still refuses a token for the wrong team. + explicitOrg.unsupportedUntil = Date.now() + EXPLICIT_ORG_RETRY_MS + response = await post('{}') + } + if (response.status === 403 && requestedOrg && (await isNotAMember(response))) { + throw orgSwitchError(active) + } if (response.status === 401 || response.status === 403) { throw new OrcaCloudRequestError(response.status) } diff --git a/src/main/hosted-sandbox/hosted-sandbox-device-enrollment.ts b/src/main/hosted-sandbox/hosted-sandbox-device-enrollment.ts index 1129d5748e6..15b5a0bb38f 100644 --- a/src/main/hosted-sandbox/hosted-sandbox-device-enrollment.ts +++ b/src/main/hosted-sandbox/hosted-sandbox-device-enrollment.ts @@ -25,7 +25,7 @@ async function isStaleToken(response: Response): Promise { export function createHostedSandboxDeviceEnroller( config: HostedSandboxConfig, - fetchImpl: typeof fetch = fetch + fetchImpl: typeof fetch = config.fetch ?? fetch ): OrcaCloudDeviceEnroller { async function token(): Promise { try { diff --git a/src/main/hosted-sandbox/hosted-sandbox-install.ts b/src/main/hosted-sandbox/hosted-sandbox-install.ts index 3b84e6c6d75..54425f61413 100644 --- a/src/main/hosted-sandbox/hosted-sandbox-install.ts +++ b/src/main/hosted-sandbox/hosted-sandbox-install.ts @@ -136,7 +136,8 @@ export function installHostedSandbox( installDevTokenClaimsProbe(() => config.getAccessToken(), app.isPackaged) const mcpSync = installCloudMcpSync(userDataPath, { baseUrl: config.controlPlaneUrl, - getAccessToken: config.getAccessToken + getAccessToken: config.getAccessToken, + fetch: config.fetch }) const agentConfigSync = installCloudAgentConfigSync(userDataPath) // Dev rehearsals shorten idle sleep; packaged builds always use the provider's default. diff --git a/src/main/hosted-sandbox/hosted-sandbox-org-fetch.ts b/src/main/hosted-sandbox/hosted-sandbox-org-fetch.ts new file mode 100644 index 00000000000..a67264b62e0 --- /dev/null +++ b/src/main/hosted-sandbox/hosted-sandbox-org-fetch.ts @@ -0,0 +1,71 @@ +// fetch for Orca Cloud control-plane calls that names the team the desktop is acting for, so the +// Worker can refuse a token minted for another team (409 org_mismatch) instead of acting on it. +import type { EnvironmentProviderError } from '../environment-providers/environment-provider-driver' + +/** Header the control plane checks against the token's `org` claim. */ +export const ORCA_ORG_HEADER = 'x-orca-org' +/** `x-orca-org` value for a personal account: the token must carry no org. */ +export const ORCA_ORG_HEADER_PERSONAL = 'user' +export const ORG_MISMATCH = 'org_mismatch' +const STALE_TOKEN = 'stale_token' + +type Refusal = 'org_mismatch' | 'stale_token' | null + +async function refusalOf(response: Response): Promise { + if (response.status !== 409 && response.status !== 401 && response.status !== 403) { + return null + } + const body: unknown = await response + .clone() + .json() + .catch(() => null) + if (typeof body !== 'object' || body === null) { + return null + } + const { error, reason } = body as { error?: unknown; reason?: unknown } + if (response.status === 409) { + return error === ORG_MISMATCH || reason === ORG_MISMATCH ? 'org_mismatch' : null + } + // Token minted before this user's role or membership changed (generic 401 means signed out). + return error === STALE_TOKEN || reason === STALE_TOKEN ? 'stale_token' : null +} + +export function createOrgCheckedFetch(deps: { + fetchImpl: typeof fetch + /** The active team id, or `user` for a personal account; read per request. */ + orgHeader: () => string + getAccessToken: (signal?: AbortSignal) => Promise + invalidateAccessToken: () => void + orgSwitchError: () => EnvironmentProviderError +}): typeof fetch { + const send = ( + input: Parameters[0], + init: RequestInit | undefined, + token?: string + ): Promise => { + const headers = new Headers(init?.headers) + headers.set(ORCA_ORG_HEADER, deps.orgHeader()) + if (token !== undefined) { + headers.set('authorization', `Bearer ${token}`) + } + return deps.fetchImpl(input, { ...init, headers }) + } + return async (input, init) => { + const response = await send(input, init) + const refusal = await refusalOf(response) + if (!refusal) { + return response + } + // The token predates a team switch or role change: drop it, mint once more, ask again. + console.warn(`[orca-cloud] control plane refused token (${refusal}); re-minting once`) + await response.body?.cancel().catch(() => undefined) + deps.invalidateAccessToken() + const token = await deps.getAccessToken(init?.signal ?? undefined) + const retried = await send(input, init, token) + if ((await refusalOf(retried)) === 'org_mismatch') { + await retried.body?.cancel().catch(() => undefined) + throw deps.orgSwitchError() + } + return retried + } +} diff --git a/src/main/hosted-sandbox/hosted-sandbox-org-token.test.ts b/src/main/hosted-sandbox/hosted-sandbox-org-token.test.ts new file mode 100644 index 00000000000..0adf59fdbdd --- /dev/null +++ b/src/main/hosted-sandbox/hosted-sandbox-org-token.test.ts @@ -0,0 +1,307 @@ +// ORG-TOKEN-STALE: sandbox tokens are minted for the team the desktop shows, verified against the +// token's org claim, dropped on team/role changes, and re-minted once when the control plane refuses. +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { installFakeAppEnvironment } from '../../../config/scripts/test-runtime/environment/vitest-host-ports-setup' +import { removeTreeSync } from '../../shared/windows-transient-lock-removal' +import type { OrcaProfileCloudSummary } from '../../shared/orca-profiles' +import { isEnvironmentProviderError } from '../environment-providers/environment-provider-driver' +import { linkOrcaProfileToCloud } from '../orca-profiles/cloud-auth/profile-cloud-index' +import { noteOrcaCloudMemberships } from '../orca-profiles/cloud-token-epoch' +import { ensureActiveOrcaProfile } from '../orca-profiles/profile-index-store' +import { getHostedSandboxConfig } from './hosted-sandbox-config' + +vi.mock('../orca-profiles/cloud-session/profile-cloud-session-refresh', () => ({ + runWithFreshOrcaCloudSession: async ( + _config: unknown, + _active: unknown, + _userDataPath: string, + operation: (session: { accessToken: string }) => Promise + ) => ({ status: 'ok', value: await operation({ accessToken: 'session-access' }) }) +})) + +const state = { dir: '' } +installFakeAppEnvironment({ getPath: () => state.dir }) + +const MINT_URL = 'http://127.0.0.1:9/v1/desktop/auth/sandbox-token' +const CONTROL_PLANE = 'https://sandbox.cloud.test' + +/** Unsigned token-shaped test value; only the claims matter to the desktop. */ +function fakeToken(org: string | null, serial: number): string { + const claims = { sub: 'user-a', ...(org ? { org } : {}), purpose: 'sandbox', serial } + return `h.${Buffer.from(JSON.stringify(claims)).toString('base64url')}.s` +} + +function tokenOrg(authorization: string | null): string | null { + const payload = authorization?.replace(/^Bearer /, '').split('.')[1] ?? '' + const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as { org?: string } + return claims.org ?? null +} + +function cloud(activeOrgId?: string, activeOrgName?: string): OrcaProfileCloudSummary { + return { + cloudProfileId: 'cp-user-a', + userId: 'user-a', + email: 'user-a@example.test', + ...(activeOrgId ? { activeOrgId } : {}), + ...(activeOrgName ? { activeOrgName } : {}), + linkedAt: 1 + } +} + +type MintCall = { body: string } + +/** + * Fake auth + control plane. `issue` decides the org claim of each minted token from the requested + * org; the control plane answers via `controlPlane`. + */ +function harness(options: { + issue?: (requested: string | null | undefined, call: number) => string | null + mintStatus?: (body: string, call: number) => number + controlPlane?: (request: { headers: Headers; call: number }) => Response +}) { + const mints: MintCall[] = [] + const controlCalls: Headers[] = [] + let gate: Promise | null = null + const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input) + if (url === MINT_URL) { + const body = String(init?.body ?? '') + mints.push({ body }) + const call = mints.length + const status = options.mintStatus?.(body, call) ?? 200 + if (status !== 200) { + return Response.json({ error: 'invalid_request' }, { status }) + } + const requested = (JSON.parse(body) as { orgId?: string | null }).orgId + // Auth decides the team when asked, even if its answer arrives after a switch. + const org = options.issue ? options.issue(requested, call) : (requested ?? null) + const pending = gate + if (pending) { + await pending + } + return Response.json({ sandboxToken: fakeToken(org, call), expiresAt: Date.now() + 300_000 }) + } + const headers = new Headers(init?.headers) + controlCalls.push(headers) + return ( + options.controlPlane?.({ headers, call: controlCalls.length }) ?? Response.json({ ok: true }) + ) + }) as typeof fetch + const config = getHostedSandboxConfig( + state.dir, + { + ORCA_CLOUD_SANDBOX_URL: CONTROL_PLANE, + ORCA_CLOUD_API_URL: 'http://127.0.0.1:9', + ORCA_CLOUD_CLIENT_ID: 'test-client' + }, + false, + fetchImpl + ) + if (!config) { + throw new Error('expected a hosted sandbox config') + } + return { + config, + mints, + controlCalls, + /** Holds every mint response until the returned release() is called. */ + holdMints: () => { + let release!: () => void + gate = new Promise((resolve) => { + release = () => { + gate = null + resolve() + } + }) + return release + } + } +} + +function link(activeOrgId?: string, activeOrgName?: string): string { + const profileId = ensureActiveOrcaProfile(state.dir).profile.id + linkOrcaProfileToCloud(profileId, cloud(activeOrgId, activeOrgName), state.dir) + return profileId +} + +describe('sandbox token team checks', () => { + beforeEach(() => { + state.dir = mkdtempSync(join(tmpdir(), 'orca-org-token-test-')) + installFakeAppEnvironment({ getPath: () => state.dir }) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + }) + + afterEach(() => { + vi.restoreAllMocks() + removeTreeSync(state.dir) + }) + + it('mints with the active team named explicitly, and null for a personal account', async () => { + const h = harness({}) + link('org-1') + expect(tokenOrg(`Bearer ${await h.config.getAccessToken()}`)).toBe('org-1') + link() + expect(tokenOrg(`Bearer ${await h.config.getAccessToken()}`)).toBeNull() + expect(h.mints.map((m) => JSON.parse(m.body))).toEqual([{ orgId: 'org-1' }, { orgId: null }]) + }) + + it('rejects a token for the wrong team, re-mints once, and caches only the right one', async () => { + // Auth's membership mirror lacks the team the first time: it mints a personal token. + const h = harness({ issue: (requested, call) => (call === 1 ? null : (requested ?? null)) }) + link('org-1') + const token = await h.config.getAccessToken() + expect(tokenOrg(`Bearer ${token}`)).toBe('org-1') + expect(await h.config.getAccessToken()).toBe(token) + expect(h.mints).toHaveLength(2) + }) + + it('never uses a wrong-team token: two mismatches surface a clear error', async () => { + const h = harness({ issue: () => 'org-old' }) + link('org-new', 'Acme') + const error = await h.config.getAccessToken().catch((caught: unknown) => caught) + expect(isEnvironmentProviderError(error, 'conflict')).toBe(true) + expect((error as Error).message).toBe("Couldn't switch to Acme. Sign out and back in.") + // Nothing was cached: the next call asks auth again. + await h.config.getAccessToken().catch(() => undefined) + expect(h.mints).toHaveLength(4) + }) + + it('falls back to an empty body when auth predates the explicit-org body, and remembers', async () => { + const h = harness({ mintStatus: (body) => (body === '{}' ? 200 : 400) }) + link() + expect(tokenOrg(`Bearer ${await h.config.getAccessToken()}`)).toBeNull() + h.config.invalidateAccessToken?.() + await h.config.getAccessToken() + expect(h.mints.map((m) => m.body)).toEqual(['{"orgId":null}', '{}', '{}']) + }) + + it('with the fallback, a team token missing its org claim is still refused', async () => { + const h = harness({ + mintStatus: (body) => (body === '{}' ? 200 : 400), + // Old auth picks from its mirror, which lacks the team: personal token. + issue: () => null + }) + link('org-1', 'Acme') + const error = await h.config.getAccessToken().catch((caught: unknown) => caught) + expect(isEnvironmentProviderError(error, 'conflict')).toBe(true) + }) + + it('switch race: a mint in flight for the old team is never cached for the new one', async () => { + // Auth that ignores the body and answers with whichever team its session held when asked. + let sessionOrg = 'org-old' + const h = harness({ issue: () => sessionOrg }) + link('org-old') + const release = h.holdMints() + const pending = h.config.getAccessToken() + // The switch commits while the old mint is in flight. + sessionOrg = 'org-new' + link('org-new') + release() + const token = await pending + expect(tokenOrg(`Bearer ${token}`)).toBe('org-new') + expect(tokenOrg(`Bearer ${await h.config.getAccessToken()}`)).toBe('org-new') + }) + + it('a role change drops the cached token', async () => { + const h = harness({}) + const profileId = link('org-1') + const key = `${profileId}:role-test` + noteOrcaCloudMemberships(key, [{ orgId: 'org-1', name: 'Acme', role: 'owner' }]) + const first = await h.config.getAccessToken() + expect(await h.config.getAccessToken()).toBe(first) + noteOrcaCloudMemberships(key, [{ orgId: 'org-1', name: 'Acme', role: 'member' }]) + const second = await h.config.getAccessToken() + expect(second).not.toBe(first) + // Same roles again: no further re-mint. + noteOrcaCloudMemberships(key, [{ orgId: 'org-1', name: 'Acme', role: 'member' }]) + expect(await h.config.getAccessToken()).toBe(second) + }) +}) + +describe('control-plane fetch', () => { + beforeEach(() => { + state.dir = mkdtempSync(join(tmpdir(), 'orca-org-fetch-test-')) + installFakeAppEnvironment({ getPath: () => state.dir }) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + }) + + afterEach(() => { + vi.restoreAllMocks() + removeTreeSync(state.dir) + }) + + async function call(h: ReturnType): Promise { + const token = await h.config.getAccessToken() + const controlPlaneFetch = h.config.fetch + if (!controlPlaneFetch) { + throw new Error('expected the control-plane fetch') + } + return controlPlaneFetch(new URL('/v1/sandboxes', CONTROL_PLANE), { + method: 'POST', + headers: { authorization: `Bearer ${token}` }, + body: '{"a":1}' + }) + } + + it('names the active team, or user for a personal account', async () => { + const h = harness({}) + link('org-1') + await call(h) + link() + await call(h) + expect(h.controlCalls.map((headers) => headers.get('x-orca-org'))).toEqual(['org-1', 'user']) + }) + + it('409 org_mismatch drops the token, re-mints once, and retries with the new one', async () => { + const h = harness({ + controlPlane: ({ call }) => + call === 1 + ? Response.json({ error: 'org_mismatch', reason: 'org_mismatch' }, { status: 409 }) + : Response.json({ ok: true }) + }) + link('org-1') + const response = await call(h) + expect(response.status).toBe(200) + expect(h.mints).toHaveLength(2) + const [first, retry] = h.controlCalls + expect(retry!.get('authorization')).not.toBe(first!.get('authorization')) + expect(tokenOrg(retry!.get('authorization'))).toBe('org-1') + // The retry uses the fresh token, which is now the cached one. + expect(`Bearer ${await h.config.getAccessToken()}`).toBe(retry!.get('authorization')) + }) + + it('401 stale_token re-mints once and retries instead of signing out', async () => { + const h = harness({ + controlPlane: ({ call }) => + call === 1 + ? Response.json({ error: 'unauthorized', reason: 'stale_token' }, { status: 401 }) + : Response.json({ ok: true }) + }) + link('org-1') + expect((await call(h)).status).toBe(200) + expect(h.mints).toHaveLength(2) + }) + + it('a second org_mismatch surfaces the clear error rather than looping', async () => { + const h = harness({ + controlPlane: () => Response.json({ error: 'org_mismatch' }, { status: 409 }) + }) + link('org-1', 'Acme') + const error = await call(h).catch((caught: unknown) => caught) + expect(isEnvironmentProviderError(error, 'conflict')).toBe(true) + expect((error as Error).message).toBe("Couldn't switch to Acme. Sign out and back in.") + expect(h.controlCalls).toHaveLength(2) + }) + + it('other refusals pass through untouched', async () => { + const h = harness({ + controlPlane: () => Response.json({ error: 'unauthorized' }, { status: 401 }) + }) + link('org-1') + expect((await call(h)).status).toBe(401) + expect(h.controlCalls).toHaveLength(1) + }) +}) diff --git a/src/main/hosted-sandbox/hosted-sandbox-recipe.ts b/src/main/hosted-sandbox/hosted-sandbox-recipe.ts index 68a40383a04..3c941bdcc16 100644 --- a/src/main/hosted-sandbox/hosted-sandbox-recipe.ts +++ b/src/main/hosted-sandbox/hosted-sandbox-recipe.ts @@ -49,7 +49,8 @@ const LifecyclePayload = z.looseObject({ recipeResult: EphemeralVmRecipeResultSc export function createHostedSandboxRecipeClient(config: HostedSandboxConfig) { return createHostedSandboxControlPlaneClient({ baseUrl: config.controlPlaneUrl, - getAccessToken: config.getAccessToken + getAccessToken: config.getAccessToken, + fetch: config.fetch }) } diff --git a/src/main/hosted-sandbox/hosted-sandbox-share-ipc.ts b/src/main/hosted-sandbox/hosted-sandbox-share-ipc.ts index 0a90bfc423a..97f3df6ea5b 100644 --- a/src/main/hosted-sandbox/hosted-sandbox-share-ipc.ts +++ b/src/main/hosted-sandbox/hosted-sandbox-share-ipc.ts @@ -124,7 +124,8 @@ export function registerCloudShareHandlers( ? createHostedSandboxShareClient( createHostedSandboxRequest({ baseUrl: config.controlPlaneUrl, - getAccessToken: config.getAccessToken + getAccessToken: config.getAccessToken, + fetch: config.fetch }) ) : null diff --git a/src/main/hosted-sandbox/hosted-sandbox-token-org.ts b/src/main/hosted-sandbox/hosted-sandbox-token-org.ts new file mode 100644 index 00000000000..4d0a7b550b4 --- /dev/null +++ b/src/main/hosted-sandbox/hosted-sandbox-token-org.ts @@ -0,0 +1,47 @@ +// Which team a minted sandbox token speaks for, and the error when it can't be made to speak for +// the team the desktop shows. +import { EnvironmentProviderError } from '../environment-providers/environment-provider-driver' +import type { ActiveOrcaProfileState } from '../orca-profiles/profile-index-store' +import { ORG_MISMATCH } from './hosted-sandbox-org-fetch' + +/** The `org` claim of a sandbox token (null: personal). Decodes only; the control plane verifies. */ +export function readTokenOrg(token: string): string | null { + let payload: unknown = null + try { + payload = JSON.parse(Buffer.from(token.split('.')[1] ?? '', 'base64url').toString('utf8')) + } catch { + // Reported below without echoing the token. + } + if (typeof payload !== 'object' || payload === null) { + throw new EnvironmentProviderError( + 'invalid_response', + 'Orca Cloud auth sent an invalid sandbox token.' + ) + } + const org = (payload as { org?: unknown }).org + return typeof org === 'string' && org ? org : null +} + +export function orgSwitchError(active: ActiveOrcaProfileState): EnvironmentProviderError { + const cloud = active.profile.cloud + const team = cloud?.activeOrgId + ? cloud.activeOrgName?.trim() || 'your team' + : 'your personal account' + return new EnvironmentProviderError( + 'conflict', + `Couldn't switch to ${team}. Sign out and back in.`, + ORG_MISMATCH + ) +} + +export async function isNotAMember(response: Response): Promise { + const body: unknown = await response + .clone() + .json() + .catch(() => null) + return ( + typeof body === 'object' && + body !== null && + (body as { error?: unknown }).error === 'not_a_member' + ) +} diff --git a/src/main/orca-cloud-agents/orca-cloud-agents-ipc.ts b/src/main/orca-cloud-agents/orca-cloud-agents-ipc.ts index 547abdad8d5..a7c0b0e58c1 100644 --- a/src/main/orca-cloud-agents/orca-cloud-agents-ipc.ts +++ b/src/main/orca-cloud-agents/orca-cloud-agents-ipc.ts @@ -105,7 +105,8 @@ function serviceFor(userDataPath: string): OrcaCloudAgentsService { client: config ? createAgentCredentialsClient({ baseUrl: config.controlPlaneUrl, - getAccessToken: config.getAccessToken + getAccessToken: config.getAccessToken, + fetch: config.fetch }) : null, spawnSetupToken: spawnClaudeSetupTokenPty, diff --git a/src/main/orca-profiles/cloud-org/profile-cloud-org-members-service.ts b/src/main/orca-profiles/cloud-org/profile-cloud-org-members-service.ts index f886639940b..7f4e8d16c55 100644 --- a/src/main/orca-profiles/cloud-org/profile-cloud-org-members-service.ts +++ b/src/main/orca-profiles/cloud-org/profile-cloud-org-members-service.ts @@ -21,6 +21,7 @@ import { import type { OrcaCloudSession } from '../cloud-session/profile-cloud-session-store' import { OrcaCloudRequestError } from '../cloud-auth/profile-cloud-client' import { runWithFreshOrcaCloudSession } from '../cloud-session/profile-cloud-session-refresh' +import { bumpOrcaCloudTokenEpoch } from '../cloud-token-epoch' import { changeOrcaCloudOrgMemberRole, createOrcaCloudOrg, @@ -200,11 +201,14 @@ export async function changeOrcaProfileOrgMemberRole( if (!configState.configured) { return { status: 'unconfigured' } } - return mapMutationResult( + const result = mapMutationResult( await runOrgMemberCall(configState.config, active, userDataPath, (session) => changeOrcaCloudOrgMemberRole(configState.config, session, args) ) ) + // Why: the member may be this user; a cached sandbox token would keep the old role. + bumpOrcaCloudTokenEpoch() + return result } export async function removeOrcaProfileOrgMember( @@ -219,11 +223,14 @@ export async function removeOrcaProfileOrgMember( if (!configState.configured) { return { status: 'unconfigured' } } - return mapMutationResult( + const result = mapMutationResult( await runOrgMemberCall(configState.config, active, userDataPath, (session) => removeOrcaCloudOrgMember(configState.config, session, args) ) ) + // Why: the member may be this user; a cached sandbox token would keep the old role. + bumpOrcaCloudTokenEpoch() + return result } /** Revoke then re-send so the provider emails a fresh link; the role is kept. */ diff --git a/src/main/orca-profiles/cloud-org/profile-cloud-org-selection.test.ts b/src/main/orca-profiles/cloud-org/profile-cloud-org-selection.test.ts new file mode 100644 index 00000000000..cdd69b1a448 --- /dev/null +++ b/src/main/orca-profiles/cloud-org/profile-cloud-org-selection.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { OrcaCloudAuthConfig } from '../cloud-auth/profile-cloud-auth-config' +import type { ActiveOrcaProfileState } from '../profile-index-store' +import { getOrcaCloudTokenEpoch } from '../cloud-token-epoch' + +const mocks = vi.hoisted(() => ({ + calls: [] as string[], + refresh: vi.fn(), + commit: vi.fn() +})) + +vi.mock('../cloud-auth/profile-cloud-client', () => ({ + OrcaCloudRequestError: class extends Error { + statusCode = 0 + }, + selectOrcaCloudOrg: async (_config: unknown, _session: unknown, orgId: string | null) => { + mocks.calls.push('select') + return { + cloud: { userId: 'user-a', cloudProfileId: 'cp-a', ...(orgId ? { activeOrgId: orgId } : {}) }, + capabilities: { flags: {}, refreshedAt: 1 } + } + } +})) + +vi.mock('../cloud-session/profile-cloud-session-refresh', () => ({ + forceRefreshOrcaCloudSession: (...args: unknown[]) => { + mocks.calls.push('refresh') + return mocks.refresh(...args) + } +})) + +vi.mock('../cloud-session/profile-cloud-session-owner', () => ({ + beginCloudSessionRelink: () => ({ generation: 0, linkVersion: 1 }), + readCloudSession: () => ({ status: 'found', session: { accessToken: 'a', refreshToken: 'r' } }), + commitCloudSessionRelink: (...args: unknown[]) => { + mocks.calls.push('commit') + return mocks.commit(...args) + } +})) + +const { selectCloudOrg, ORG_SWITCH_REFRESH_FAILED } = await import('./profile-cloud-org-selection') + +const active = { + profile: { + id: 'p1', + cloud: { userId: 'user-a', cloudProfileId: 'cp-a', activeOrgId: 'org-old' } + } +} as unknown as ActiveOrcaProfileState + +function select() { + return selectCloudOrg({ + config: {} as OrcaCloudAuthConfig, + active, + userDataPath: '/tmp/unused', + orgId: 'org-new' + }) +} + +describe('selectCloudOrg', () => { + beforeEach(() => { + mocks.calls.length = 0 + mocks.refresh.mockReset() + mocks.commit.mockReset() + }) + + it('publishes the new team only after the session refreshed for it, then drops sandbox tokens', async () => { + mocks.refresh.mockResolvedValue({ status: 'found', session: {} }) + mocks.commit.mockReturnValue({ activeProfileId: 'p1', profiles: [] }) + const epoch = getOrcaCloudTokenEpoch() + await expect(select()).resolves.toEqual({ activeProfileId: 'p1', profiles: [] }) + expect(mocks.calls).toEqual(['select', 'refresh', 'commit']) + expect(getOrcaCloudTokenEpoch()).toBeGreaterThan(epoch) + }) + + it('a failed refresh fails the switch loudly and leaves the old team in place', async () => { + mocks.refresh.mockRejectedValue(new Error('network down')) + await expect(select()).rejects.toThrow('network down') + expect(mocks.calls).toEqual(['select', 'refresh']) + expect(mocks.commit).not.toHaveBeenCalled() + }) + + it('a refresh that needs reconnecting fails the switch with a readable message', async () => { + mocks.refresh.mockResolvedValue({ status: 'reconnect-required' }) + await expect(select()).rejects.toThrow(ORG_SWITCH_REFRESH_FAILED) + expect(mocks.commit).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/orca-profiles/cloud-org/profile-cloud-org-selection.ts b/src/main/orca-profiles/cloud-org/profile-cloud-org-selection.ts index 1061b0ad38b..40b704b9d1a 100644 --- a/src/main/orca-profiles/cloud-org/profile-cloud-org-selection.ts +++ b/src/main/orca-profiles/cloud-org/profile-cloud-org-selection.ts @@ -3,12 +3,16 @@ import { OrcaCloudRequestError, selectOrcaCloudOrg } from '../cloud-auth/profile import { forceRefreshOrcaCloudSession } from '../cloud-session/profile-cloud-session-refresh' import type { ActiveOrcaProfileState } from '../profile-index-store' import type { OrcaProfileListState } from '../../../shared/orca-profiles' +import { bumpOrcaCloudTokenEpoch } from '../cloud-token-epoch' import { beginCloudSessionRelink, commitCloudSessionRelink, readCloudSession } from '../cloud-session/profile-cloud-session-owner' +export const ORG_SWITCH_REFRESH_FAILED = + "Couldn't refresh your Orca sign-in for the new team. Try again, or sign out and back in." + export async function selectCloudOrg(input: { config: OrcaCloudAuthConfig active: ActiveOrcaProfileState @@ -48,21 +52,28 @@ export async function selectCloudOrg(input: { ) { throw new Error('orca_cloud_org_selection_identity_mismatch') } + // Why: /auth/org updates the stored selection but not the access token, and auth (sandbox + // tokens included) reads the team from it. Publishing the new activeOrgId before the token + // follows lets a mint in between cache an old-team token under the new team. So refresh first, + // and commit only once the session speaks for the new team; a failed refresh fails the switch. + const current = readCloudSession(profileId, input.userDataPath) + if (current.status !== 'found') { + throw new Error(ORG_SWITCH_REFRESH_FAILED) + } + const refreshed = await forceRefreshOrcaCloudSession( + input.config, + input.active, + input.userDataPath, + current.session + ) + if (refreshed.status !== 'found') { + throw new Error(ORG_SWITCH_REFRESH_FAILED) + } const list = commitCloudSessionRelink(profileId, input.userDataPath, ticket, selected) if (!list) { throw new Error('stale_cloud_session_mutation') } - // /auth/org updates the stored selection but not the access token, and some services read the - // team from the token. Refresh now so they follow the switch (best effort; the next refresh - // would pick it up anyway). - const current = readCloudSession(profileId, input.userDataPath) - if (current.status === 'found') { - await forceRefreshOrcaCloudSession( - input.config, - input.active, - input.userDataPath, - current.session - ).catch(() => undefined) - } + // Drop every cached sandbox token minted for the previous team. + bumpOrcaCloudTokenEpoch() return list } diff --git a/src/main/orca-profiles/cloud-session/profile-cloud-session-store.ts b/src/main/orca-profiles/cloud-session/profile-cloud-session-store.ts index 00772a2cccd..6dc7264d656 100644 --- a/src/main/orca-profiles/cloud-session/profile-cloud-session-store.ts +++ b/src/main/orca-profiles/cloud-session/profile-cloud-session-store.ts @@ -9,6 +9,7 @@ import type { } from '../../../shared/orca-profiles' import { getOrcaProfileDirectory } from '../profile-storage-paths' import { allowsPlaintextOrcaCloudSession } from '../cloud-auth/profile-cloud-auth-config' +import { forgetOrcaCloudMemberships, noteOrcaCloudMemberships } from '../cloud-token-epoch' export type OrcaCloudSession = { accessToken: string @@ -156,6 +157,8 @@ export function saveOrcaCloudSession( options: { spentOnFailure?: boolean } = {} ): OrcaCloudSessionPersistence { const key = orcaCloudSessionKey(profileId, userDataPath) + // Why: a role or membership change must drop cached sandbox tokens minted under the old role. + noteOrcaCloudMemberships(key, session.organizations) try { const persistence = writeOrcaCloudSession(profileId, userDataPath, session) unsavedRotations.delete(key) @@ -271,6 +274,7 @@ export function clearOrcaCloudSession(profileId: string, userDataPath: string): const key = orcaCloudSessionKey(profileId, userDataPath) unsavedRotations.delete(key) memorySessions.delete(key) + forgetOrcaCloudMemberships(key) rmSync(getOrcaCloudSessionPath(profileId, userDataPath), { force: true }) } diff --git a/src/main/orca-profiles/cloud-token-epoch.ts b/src/main/orca-profiles/cloud-token-epoch.ts new file mode 100644 index 00000000000..bfc9d10daa2 --- /dev/null +++ b/src/main/orca-profiles/cloud-token-epoch.ts @@ -0,0 +1,42 @@ +// Bumped whenever a cached Orca Cloud sandbox token may no longer speak for the right team or role: +// a team switch commit, or any change to the signed-in user's memberships or roles. Token caches +// compare epochs so a role change (owner -> member -> owner) never reuses a token minted before it. +import type { OrcaCloudOrgSummary } from '../../shared/orca-profiles' + +let epoch = 0 +const membershipSignatures = new Map() + +export function getOrcaCloudTokenEpoch(): number { + return epoch +} + +export function bumpOrcaCloudTokenEpoch(): void { + epoch += 1 +} + +function membershipSignature(organizations: readonly OrcaCloudOrgSummary[] | undefined): string { + return (organizations ?? []) + .map((org) => `${org.orgId}:${org.role ?? ''}`) + .sort() + .join('|') +} + +/** Record a session's memberships; bumps the epoch when they differ from the last ones seen. */ +export function noteOrcaCloudMemberships( + sessionKey: string, + organizations: readonly OrcaCloudOrgSummary[] | undefined +): void { + const next = membershipSignature(organizations) + const previous = membershipSignatures.get(sessionKey) + membershipSignatures.set(sessionKey, next) + // First sighting counts as a change: a token minted at startup predates what we now know. + if (previous !== next) { + bumpOrcaCloudTokenEpoch() + } +} + +export function forgetOrcaCloudMemberships(sessionKey: string): void { + if (membershipSignatures.delete(sessionKey)) { + bumpOrcaCloudTokenEpoch() + } +} From f23b6f1f6c91a4ea437484b3d6040a380eb8a971 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 22:23:21 -0800 Subject: [PATCH 39/50] fix(cloud): drop type assertions in ORG-TOKEN-STALE code and tests --- src/main/cloud-api/cloud-api-client.ts | 2 +- .../hosted-sandbox-org-fetch.ts | 3 +- .../hosted-sandbox-org-token.test.ts | 18 +++++---- .../hosted-sandbox-token-org.ts | 6 +-- .../profile-cloud-org-selection.test.ts | 38 +++++++++++++++---- 5 files changed, 45 insertions(+), 22 deletions(-) diff --git a/src/main/cloud-api/cloud-api-client.ts b/src/main/cloud-api/cloud-api-client.ts index ff382485367..c67998eea86 100644 --- a/src/main/cloud-api/cloud-api-client.ts +++ b/src/main/cloud-api/cloud-api-client.ts @@ -109,7 +109,7 @@ export function classifyCloudApiResponse( /** The "Couldn't switch to {team}" message, when the token could not be minted for the team. */ function orgSwitchDetail(error: unknown): string | undefined { - return error instanceof Error && (error as { reason?: unknown }).reason === 'org_mismatch' + return error instanceof Error && 'reason' in error && error.reason === 'org_mismatch' ? error.message : undefined } diff --git a/src/main/hosted-sandbox/hosted-sandbox-org-fetch.ts b/src/main/hosted-sandbox/hosted-sandbox-org-fetch.ts index a67264b62e0..795a9b5f1c9 100644 --- a/src/main/hosted-sandbox/hosted-sandbox-org-fetch.ts +++ b/src/main/hosted-sandbox/hosted-sandbox-org-fetch.ts @@ -22,7 +22,8 @@ async function refusalOf(response: Response): Promise { if (typeof body !== 'object' || body === null) { return null } - const { error, reason } = body as { error?: unknown; reason?: unknown } + const error = 'error' in body ? body.error : undefined + const reason = 'reason' in body ? body.reason : undefined if (response.status === 409) { return error === ORG_MISMATCH || reason === ORG_MISMATCH ? 'org_mismatch' : null } diff --git a/src/main/hosted-sandbox/hosted-sandbox-org-token.test.ts b/src/main/hosted-sandbox/hosted-sandbox-org-token.test.ts index 0adf59fdbdd..f0bd00a5c0f 100644 --- a/src/main/hosted-sandbox/hosted-sandbox-org-token.test.ts +++ b/src/main/hosted-sandbox/hosted-sandbox-org-token.test.ts @@ -12,6 +12,7 @@ import { linkOrcaProfileToCloud } from '../orca-profiles/cloud-auth/profile-clou import { noteOrcaCloudMemberships } from '../orca-profiles/cloud-token-epoch' import { ensureActiveOrcaProfile } from '../orca-profiles/profile-index-store' import { getHostedSandboxConfig } from './hosted-sandbox-config' +import { readTokenOrg } from './hosted-sandbox-token-org' vi.mock('../orca-profiles/cloud-session/profile-cloud-session-refresh', () => ({ runWithFreshOrcaCloudSession: async ( @@ -35,9 +36,7 @@ function fakeToken(org: string | null, serial: number): string { } function tokenOrg(authorization: string | null): string | null { - const payload = authorization?.replace(/^Bearer /, '').split('.')[1] ?? '' - const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as { org?: string } - return claims.org ?? null + return readTokenOrg(authorization?.replace(/^Bearer /, '') ?? '') } function cloud(activeOrgId?: string, activeOrgName?: string): OrcaProfileCloudSummary { @@ -65,7 +64,7 @@ function harness(options: { const mints: MintCall[] = [] const controlCalls: Headers[] = [] let gate: Promise | null = null - const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => { + const fetchImpl: typeof fetch = async (input, init) => { const url = String(input) if (url === MINT_URL) { const body = String(init?.body ?? '') @@ -75,7 +74,10 @@ function harness(options: { if (status !== 200) { return Response.json({ error: 'invalid_request' }, { status }) } - const requested = (JSON.parse(body) as { orgId?: string | null }).orgId + const parsed: unknown = JSON.parse(body) + const raw = + typeof parsed === 'object' && parsed && 'orgId' in parsed ? parsed.orgId : undefined + const requested = typeof raw === 'string' || raw === null ? raw : undefined // Auth decides the team when asked, even if its answer arrives after a switch. const org = options.issue ? options.issue(requested, call) : (requested ?? null) const pending = gate @@ -89,7 +91,7 @@ function harness(options: { return ( options.controlPlane?.({ headers, call: controlCalls.length }) ?? Response.json({ ok: true }) ) - }) as typeof fetch + } const config = getHostedSandboxConfig( state.dir, { @@ -163,7 +165,7 @@ describe('sandbox token team checks', () => { link('org-new', 'Acme') const error = await h.config.getAccessToken().catch((caught: unknown) => caught) expect(isEnvironmentProviderError(error, 'conflict')).toBe(true) - expect((error as Error).message).toBe("Couldn't switch to Acme. Sign out and back in.") + expect(error).toHaveProperty('message', "Couldn't switch to Acme. Sign out and back in.") // Nothing was cached: the next call asks auth again. await h.config.getAccessToken().catch(() => undefined) expect(h.mints).toHaveLength(4) @@ -292,7 +294,7 @@ describe('control-plane fetch', () => { link('org-1', 'Acme') const error = await call(h).catch((caught: unknown) => caught) expect(isEnvironmentProviderError(error, 'conflict')).toBe(true) - expect((error as Error).message).toBe("Couldn't switch to Acme. Sign out and back in.") + expect(error).toHaveProperty('message', "Couldn't switch to Acme. Sign out and back in.") expect(h.controlCalls).toHaveLength(2) }) diff --git a/src/main/hosted-sandbox/hosted-sandbox-token-org.ts b/src/main/hosted-sandbox/hosted-sandbox-token-org.ts index 4d0a7b550b4..7a28fa51ada 100644 --- a/src/main/hosted-sandbox/hosted-sandbox-token-org.ts +++ b/src/main/hosted-sandbox/hosted-sandbox-token-org.ts @@ -18,7 +18,7 @@ export function readTokenOrg(token: string): string | null { 'Orca Cloud auth sent an invalid sandbox token.' ) } - const org = (payload as { org?: unknown }).org + const org = 'org' in payload ? payload.org : undefined return typeof org === 'string' && org ? org : null } @@ -40,8 +40,6 @@ export async function isNotAMember(response: Response): Promise { .json() .catch(() => null) return ( - typeof body === 'object' && - body !== null && - (body as { error?: unknown }).error === 'not_a_member' + typeof body === 'object' && body !== null && 'error' in body && body.error === 'not_a_member' ) } diff --git a/src/main/orca-profiles/cloud-org/profile-cloud-org-selection.test.ts b/src/main/orca-profiles/cloud-org/profile-cloud-org-selection.test.ts index cdd69b1a448..1727d8c355b 100644 --- a/src/main/orca-profiles/cloud-org/profile-cloud-org-selection.test.ts +++ b/src/main/orca-profiles/cloud-org/profile-cloud-org-selection.test.ts @@ -1,10 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { OrcaCloudAuthConfig } from '../cloud-auth/profile-cloud-auth-config' +import { getOrcaCloudAuthConfig } from '../cloud-auth/profile-cloud-auth-config' import type { ActiveOrcaProfileState } from '../profile-index-store' import { getOrcaCloudTokenEpoch } from '../cloud-token-epoch' const mocks = vi.hoisted(() => ({ - calls: [] as string[], + calls: new Array(), refresh: vi.fn(), commit: vi.fn() })) @@ -40,16 +40,38 @@ vi.mock('../cloud-session/profile-cloud-session-owner', () => ({ const { selectCloudOrg, ORG_SWITCH_REFRESH_FAILED } = await import('./profile-cloud-org-selection') -const active = { - profile: { - id: 'p1', - cloud: { userId: 'user-a', cloudProfileId: 'cp-a', activeOrgId: 'org-old' } +const profile = { + id: 'p1', + name: 'Test', + avatar: { kind: 'initials', initials: 'T', color: 'neutral' }, + kind: 'cloud-linked', + createdAt: 1, + updatedAt: 1, + lastOpenedAt: 1, + cloud: { + userId: 'user-a', + cloudProfileId: 'cp-a', + email: 'a@example.test', + activeOrgId: 'org-old', + linkedAt: 1 } -} as unknown as ActiveOrcaProfileState +} satisfies ActiveOrcaProfileState['profile'] +const active: ActiveOrcaProfileState = { + index: { schemaVersion: 1, activeProfileId: 'p1', profiles: [profile] }, + profile, + dataFile: '/tmp/unused/data', + stateDatabaseFile: '/tmp/unused/state', + profileDirectory: '/tmp/unused' +} + +const authConfig = getOrcaCloudAuthConfig( + { ORCA_CLOUD_API_URL: 'http://127.0.0.1:9', ORCA_CLOUD_CLIENT_ID: 'test-client' }, + false +) function select() { return selectCloudOrg({ - config: {} as OrcaCloudAuthConfig, + config: authConfig.configured ? authConfig.config : expect.fail('auth config'), active, userDataPath: '/tmp/unused', orgId: 'org-new' From 0174367e1dfa6a034522b3b75a704379509ea5a2 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 22:44:29 -0800 Subject: [PATCH 40/50] test(cloud): fixed token expiry instead of the wall clock --- src/main/hosted-sandbox/hosted-sandbox-org-token.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/hosted-sandbox/hosted-sandbox-org-token.test.ts b/src/main/hosted-sandbox/hosted-sandbox-org-token.test.ts index f0bd00a5c0f..38c78024ca2 100644 --- a/src/main/hosted-sandbox/hosted-sandbox-org-token.test.ts +++ b/src/main/hosted-sandbox/hosted-sandbox-org-token.test.ts @@ -28,6 +28,8 @@ installFakeAppEnvironment({ getPath: () => state.dir }) const MINT_URL = 'http://127.0.0.1:9/v1/desktop/auth/sandbox-token' const CONTROL_PLANE = 'https://sandbox.cloud.test' +// Fixed expiry (2100-01-01) so minted tokens stay fresh without reading the wall clock. +const FAR_FUTURE_MS = 4_102_444_800_000 /** Unsigned token-shaped test value; only the claims matter to the desktop. */ function fakeToken(org: string | null, serial: number): string { @@ -84,7 +86,7 @@ function harness(options: { if (pending) { await pending } - return Response.json({ sandboxToken: fakeToken(org, call), expiresAt: Date.now() + 300_000 }) + return Response.json({ sandboxToken: fakeToken(org, call), expiresAt: FAR_FUTURE_MS }) } const headers = new Headers(init?.headers) controlCalls.push(headers) From aea8274e8f047e1cf779b82e0c3e921bd2dc820f Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 22:51:31 -0800 Subject: [PATCH 41/50] =?UTF-8?q?refactor(cloud):=20one=20stale=5Ftoken=20?= =?UTF-8?q?re-mint=20path=20=E2=80=94=20drop=20the=20request-level=20retry?= =?UTF-8?q?=20in=20favour=20of=20the=20control-plane=20fetch=20(after=20#5?= =?UTF-8?q?98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cloud-secrets-client.test.ts | 28 ++++++++++----- .../hosted-sandbox/hosted-sandbox-request.ts | 34 +------------------ 2 files changed, 21 insertions(+), 41 deletions(-) diff --git a/src/main/cloud-secrets/cloud-secrets-client.test.ts b/src/main/cloud-secrets/cloud-secrets-client.test.ts index 54c68298f37..eb0724fe430 100644 --- a/src/main/cloud-secrets/cloud-secrets-client.test.ts +++ b/src/main/cloud-secrets/cloud-secrets-client.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from 'vitest' +import { EnvironmentProviderError } from '../environment-providers/environment-provider-driver' +import { createOrgCheckedFetch } from '../hosted-sandbox/hosted-sandbox-org-fetch' import { cloudSecretsFailureCode, createCloudSecretsClient } from './cloud-secrets-client' type Seen = { method: string; url: string; body: string | null; authorization: string | null } @@ -35,20 +37,28 @@ const VIEW = { describe('createCloudSecretsClient', () => { it('re-mints once on 401 stale_token (admin write window), and not for other 401s', async () => { + // The single re-mint path is the control-plane fetch (hosted-sandbox-org-fetch), as wired by + // cloud-secrets-ipc; the request layer itself never re-mints. const tokens = ['old', 'new'] let invalidated = 0 const seen: string[] = [] let answers: Response[] = [] + const getAccessToken = async (): Promise => tokens[Math.min(invalidated, 1)] ?? 'new' const client = createCloudSecretsClient({ baseUrl: 'https://control.test', - getAccessToken: async () => tokens[Math.min(invalidated, 1)]!, - invalidateAccessToken: () => { - invalidated++ - }, - fetch: async (_input, init) => { - seen.push(new Headers(init?.headers).get('authorization') ?? '') - return answers.shift()! - } + getAccessToken, + fetch: createOrgCheckedFetch({ + fetchImpl: async (_input, init) => { + seen.push(new Headers(init?.headers).get('authorization') ?? '') + return answers.shift() ?? Response.json({}, { status: 500 }) + }, + orgHeader: () => 'org-1', + getAccessToken, + invalidateAccessToken: () => { + invalidated++ + }, + orgSwitchError: () => new EnvironmentProviderError('conflict', 'switch failed') + }) }) answers = [ Response.json({ error: 'unauthorized', reason: 'stale_token' }, { status: 401 }), @@ -58,12 +68,14 @@ describe('createCloudSecretsClient', () => { expect(seen).toEqual(['Bearer old', 'Bearer new']) expect(invalidated).toBe(1) + // Still stale after the re-mint: exactly one re-mint, then the refusal surfaces. answers = [ Response.json({ error: 'unauthorized', reason: 'stale_token' }, { status: 401 }), Response.json({ error: 'unauthorized', reason: 'stale_token' }, { status: 401 }) ] await expect(client.remove({ scope: 'org' }, 'DATABASE_URL')).rejects.toThrow() expect(invalidated).toBe(2) + expect(seen).toHaveLength(4) answers = [Response.json({ error: 'unauthorized' }, { status: 401 })] await expect(client.remove({ scope: 'org' }, 'DATABASE_URL')).rejects.toThrow() diff --git a/src/main/hosted-sandbox/hosted-sandbox-request.ts b/src/main/hosted-sandbox/hosted-sandbox-request.ts index 57bf1f0cb8e..7ec3d4c0223 100644 --- a/src/main/hosted-sandbox/hosted-sandbox-request.ts +++ b/src/main/hosted-sandbox/hosted-sandbox-request.ts @@ -12,11 +12,6 @@ export type HostedSandboxControlPlaneClientOptions = { baseUrl: string /** Mints an Orca Cloud token with aud=orca-sandbox; never a provider credential. */ getAccessToken: (signal?: AbortSignal) => Promise - /** - * Drops the cached token. When set, a 401 `stale_token` (the token predates a role change, or is too - * old for an admin-only write) is retried once with a freshly minted token. - */ - invalidateAccessToken?: () => void fetch?: typeof fetch /** Per-call ceilings; create/resume/rollover include a VM boot and orcad start. */ timeouts?: Partial> @@ -46,7 +41,7 @@ export function createHostedSandboxRequest( const doFetch = options.fetch ?? fetch const timeouts = { ...DEFAULT_TIMEOUTS, ...options.timeouts } - const attempt = async function attempt( + return async function call( method: 'GET' | 'POST' | 'PUT' | 'DELETE', path: string, schema: z.ZodType, @@ -103,33 +98,6 @@ export function createHostedSandboxRequest( } return parsed.data } - - return async function call( - method: 'GET' | 'POST' | 'PUT' | 'DELETE', - path: string, - schema: z.ZodType, - init: Parameters[3] - ): Promise { - try { - return await attempt(method, path, schema, init) - } catch (error) { - if (!options.invalidateAccessToken || !isStaleToken(error)) { - throw error - } - // The control plane refused this token as older than a role change or the write window; the - // request did nothing, so one retry with a newly minted token is safe. - options.invalidateAccessToken() - return attempt(method, path, schema, init) - } - } -} - -function isStaleToken(error: unknown): boolean { - return ( - error instanceof HostedSandboxRefusalError && - error.httpStatus === 401 && - error.reason === 'stale_token' - ) } function parseBaseUrl(value: string): URL { From d5e74c7822af26f57106ec47cbb81227e81e8e82 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 23:01:00 -0800 Subject: [PATCH 42/50] feat(cloud): AUDIT-AUTH-EVENTS billing changes reach the team audit log; per-sender event families; wording for auth and billing events --- cloud/apps/billing/src/audit-push.ts | 122 +++++++++++++ cloud/apps/billing/src/index.ts | 2 +- cloud/apps/billing/src/overage.ts | 26 ++- cloud/apps/billing/src/webhook.ts | 12 ++ cloud/apps/billing/test/audit-push.test.ts | 172 ++++++++++++++++++ .../sandbox-control/src/api/audit-ingest.ts | 20 +- cloud/apps/sandbox-control/src/index.ts | 9 +- .../sandbox-control/test/audit-log.test.ts | 44 ++++- .../EnterpriseSettingsView.test.tsx | 48 ++++- .../enterprise-audit-account-copy.ts | 93 ++++++++++ .../enterprise/enterprise-audit-copy.ts | 52 +----- src/renderer/src/i18n/locales/en.json | 13 +- 12 files changed, 548 insertions(+), 65 deletions(-) create mode 100644 cloud/apps/billing/src/audit-push.ts create mode 100644 cloud/apps/billing/test/audit-push.test.ts create mode 100644 src/renderer/src/components/settings/enterprise/enterprise-audit-account-copy.ts diff --git a/cloud/apps/billing/src/audit-push.ts b/cloud/apps/billing/src/audit-push.ts new file mode 100644 index 00000000000..22208297b7d --- /dev/null +++ b/cloud/apps/billing/src/audit-push.ts @@ -0,0 +1,122 @@ +// Billing changes for the team's audit log, which the sandbox control plane owns +// (POST /v1/internal/audit-events, CONTROL_PLANE_TOKEN: that bearer may only write `billing.*`). +// Best effort and bounded: billing's own state is already written, so a failed push loses a log +// line and nothing else. Ids are a stable hash, so the control plane drops a repeat. +import type { Env } from './env' +import type { SubscriptionRow } from './store' + +const PUSH_TIMEOUT_MS = 5_000 + +export type BillingAuditAction = + | 'billing.plan_started' + | 'billing.plan_changed' + | 'billing.plan_canceled' + | 'billing.cancel_scheduled' + | 'billing.cancel_reverted' + | 'billing.seats_changed' + | 'billing.overage_enabled' + | 'billing.overage_disabled' + | 'billing.spend_cap_changed' + +export type BillingAuditEvent = { + action: BillingAuditAction + at: number + /** The person who made the change in the desktop; absent for changes Stripe reports. */ + actorUserId?: string + // Flat and small. No price ids, customer ids or card details: plan, interval, seats, cents. + detail?: Record +} + +type PushEnv = Pick + +/** Only teams have an audit log; a personal account's billing changes are its own. */ +export function hasTeamAuditLog(accountId: string): boolean { + return accountId.startsWith('org:') || accountId.startsWith('dev:org:') +} + +async function eventId(accountId: string, event: BillingAuditEvent): Promise { + const text = `${accountId}|${event.action}|${event.at}|${event.actorUserId ?? ''}|${JSON.stringify(event.detail ?? {})}` + const bytes = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))) + return `billing_${Array.from(bytes.slice(0, 20), (byte) => byte.toString(16).padStart(2, '0')).join('')}` +} + +export async function pushBillingAudit( + env: PushEnv, + accountId: string, + events: readonly BillingAuditEvent[], + fetchImpl: typeof fetch = fetch +): Promise { + const token = (env.CONTROL_PLANE_TOKEN ?? '').split(',')[0]?.trim() ?? '' + if (!env.SANDBOX_CONTROL_URL || !token || events.length === 0 || !hasTeamAuditLog(accountId)) { + return false + } + // Over the service binding when there is one (same-account workers.dev fetches fail with 1042). + const send: typeof fetch = env.SANDBOX_CONTROL ? (input, init) => env.SANDBOX_CONTROL!.fetch(input, init) : fetchImpl + try { + const response = await send(new URL('/v1/internal/audit-events', env.SANDBOX_CONTROL_URL), { + method: 'POST', + headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, + body: JSON.stringify({ + accountId, + events: await Promise.all( + events.slice(0, 50).map(async (event) => ({ + eventId: await eventId(accountId, event), + action: event.action, + at: event.at, + ...(event.actorUserId ? { actorUserId: event.actorUserId } : {}), + target: { type: 'plan' }, + ...(event.detail ? { detail: event.detail } : {}) + })) + ) + }), + redirect: 'manual', + signal: AbortSignal.timeout(PUSH_TIMEOUT_MS) + }) + await response.body?.cancel().catch(() => undefined) + if (!response.ok) { + console.error(JSON.stringify({ error: 'billing_audit_push', status: response.status })) + return false + } + return true + } catch (error) { + console.error(JSON.stringify({ error: 'billing_audit_push', message: error instanceof Error ? error.name : 'unknown' })) + return false + } +} + +type SubscriptionTerms = Pick + +const ENDED = new Set(['canceled', 'incomplete_expired']) +/** Not a plan yet: Checkout made the subscription but the first payment has not gone through. */ +const NOT_STARTED = new Set(['incomplete']) + +/** + * What changed between the stored subscription and the one Stripe now reports, as audit events. + * A replay of the same Stripe state finds no difference, so it records nothing twice. + */ +export function subscriptionAuditEvents(before: SubscriptionTerms | null, after: SubscriptionTerms, at: number): BillingAuditEvent[] { + const terms = { plan: after.plan, interval: after.billing_interval, seats: after.seats } + const wasLive = before !== null && !ENDED.has(before.status) && !NOT_STARTED.has(before.status) + const isLive = !ENDED.has(after.status) && !NOT_STARTED.has(after.status) + if (!wasLive) { + return isLive ? [{ action: 'billing.plan_started', at, detail: terms }] : [] + } + if (!isLive) { + return ENDED.has(after.status) ? [{ action: 'billing.plan_canceled', at, detail: { plan: before!.plan } }] : [] + } + const events: BillingAuditEvent[] = [] + if (before!.plan !== after.plan || before!.billing_interval !== after.billing_interval) { + events.push({ + action: 'billing.plan_changed', + at, + detail: { plan: after.plan, interval: after.billing_interval, previousPlan: before!.plan, previousInterval: before!.billing_interval } + }) + } + if (before!.seats !== after.seats) { + events.push({ action: 'billing.seats_changed', at, detail: { seats: after.seats, previousSeats: before!.seats } }) + } + if (before!.cancel_at_period_end !== after.cancel_at_period_end) { + events.push({ action: after.cancel_at_period_end === 1 ? 'billing.cancel_scheduled' : 'billing.cancel_reverted', at }) + } + return events +} diff --git a/cloud/apps/billing/src/index.ts b/cloud/apps/billing/src/index.ts index 24cfc27063e..419c7a6265e 100644 --- a/cloud/apps/billing/src/index.ts +++ b/cloud/apps/billing/src/index.ts @@ -454,7 +454,7 @@ export async function handleRequest(request: Request, env: Env, ctx: ExecutionCo if (!body.success) { return json({ error: 'invalid_request' }, 400) } - const outcome = await updateOverage(env, stripe, caller, body.data, now) + const outcome = await updateOverage(env, stripe, caller, body.data, now, deps.fetch) if (!outcome.ok) { return json({ error: outcome.error }, outcome.status) } diff --git a/cloud/apps/billing/src/overage.ts b/cloud/apps/billing/src/overage.ts index f0ae0fb604f..7007edce43c 100644 --- a/cloud/apps/billing/src/overage.ts +++ b/cloud/apps/billing/src/overage.ts @@ -5,6 +5,7 @@ // metering immediately. The item is removed once it has nothing left to invoice (no meter report // this Stripe period): at once if none was sent, otherwise at a sync after the period rolls over, // so usage already reported is still invoiced and no $0 overage line is left on later invoices. +import { pushBillingAudit, type BillingAuditEvent } from './audit-push' import { z } from 'zod' import type { BillingCaller } from './auth' import { OPEN_STATUSES } from './checkout' @@ -36,7 +37,8 @@ export async function updateOverage( stripe: StripeClient, caller: BillingCaller, request: z.infer, - now: number + now: number, + fetchImpl: typeof fetch = fetch ): Promise { if (!caller.canManage) { return { ok: false, status: 403, error: 'billing_admin_required' } @@ -56,11 +58,25 @@ export async function updateOverage( } } // An omitted cap keeps the current one; null restores the plan default. - const spendCapCents = - request.spendCapCents === undefined - ? (await accountSettings(env.BILLING_DB, caller.accountId)).spend_cap_cents - : request.spendCapCents + const previous = await accountSettings(env.BILLING_DB, caller.accountId) + const spendCapCents = request.spendCapCents === undefined ? previous.spend_cap_cents : request.spendCapCents await setOverageSettings(env.BILLING_DB, caller.accountId, { optIn: request.enabled, spendCapCents }, now) + // The team's audit log: who turned overage on or off, or changed the cap. Only real changes. + const wasEnabled = previous.overage_opt_in === 1 + const audit: BillingAuditEvent[] = [] + if (wasEnabled !== request.enabled) { + audit.push({ action: request.enabled ? 'billing.overage_enabled' : 'billing.overage_disabled', at: now, actorUserId: caller.userId }) + } + if (previous.spend_cap_cents !== spendCapCents) { + audit.push({ + action: 'billing.spend_cap_changed', + at: now, + actorUserId: caller.userId, + // null is the plan's default cap. + detail: { spendCapCents, previousSpendCapCents: previous.spend_cap_cents } + }) + } + await pushBillingAudit(env, caller.accountId, audit, fetchImpl) if (!request.enabled) { // Best effort: the opt-out already stops metering; a failed removal is retried by the next sync. await retireOverageItem(env, stripe, subscription, now).catch((error: unknown) => { diff --git a/cloud/apps/billing/src/webhook.ts b/cloud/apps/billing/src/webhook.ts index 0a29a52b881..fce2ee98437 100644 --- a/cloud/apps/billing/src/webhook.ts +++ b/cloud/apps/billing/src/webhook.ts @@ -15,6 +15,7 @@ import { } from './store' import { entitlementsMode } from './entitlements-mode' import { loadCatalog } from './catalog' +import { pushBillingAudit, subscriptionAuditEvents } from './audit-push' import { projectSubscription } from './grants' import { catalogIfApplied, isOveragePrice, recordSubscriptionPrices, seatPriceMatch, syncCatalogPrices, type PriceMatch } from './catalog-sync' import type { StripePrice } from './stripe' @@ -160,6 +161,16 @@ export async function syncSubscription(env: Env, stripe: StripeClient, subscript seat_price_id: seatPrice?.id ?? null }) await projectGrants(env, subscription.id, now) + // The team's audit log: plan started, changed or canceled, seats, a scheduled cancellation. + await pushBillingAudit( + env, + accountId, + subscriptionAuditEvents( + known, + { status: subscription.status, plan: match.plan, billing_interval: match.interval, seats, cancel_at_period_end: subscription.cancel_at_period_end ? 1 : 0 }, + now + ) + ) if (overageItemId) { // An opted-out account's overage item goes once the period it was used in has been invoiced. const stored = await currentSubscription(db, accountId) @@ -290,6 +301,7 @@ export async function endCustomerSubscriptions(env: Env, customerId: string, now async function markCanceled(env: Env, row: SubscriptionRow, now: number): Promise { await upsertSubscription(env.BILLING_DB, { ...row, status: 'canceled', past_due_since: null, member_count: null, updated_at: now }) await projectGrants(env, row.stripe_subscription_id, now) + await pushBillingAudit(env, row.account_id, subscriptionAuditEvents(row, { ...row, status: 'canceled' }, now)) } /** diff --git a/cloud/apps/billing/test/audit-push.test.ts b/cloud/apps/billing/test/audit-push.test.ts new file mode 100644 index 00000000000..39c6b5d8204 --- /dev/null +++ b/cloud/apps/billing/test/audit-push.test.ts @@ -0,0 +1,172 @@ +// AUDIT-AUTH-EVENTS (billing half): plan, seat, cancellation, overage and spend-cap changes +// reach the team's audit log on the sandbox control plane, with who acted when a person did. +import { describe, expect, it } from 'vitest' +import { hasTeamAuditLog, pushBillingAudit, subscriptionAuditEvents } from '../src/audit-push' +import { createHarness } from './harness' +import { checkoutCompleted, stripeEvent } from './stripe-fake' + +const CONTROL = 'https://control.test' +const HOUR = 3600 +const PERIOD_START = Math.floor(Date.now() / 1000) - 5 * 24 * HOUR +const PERIOD_END = PERIOD_START + 30 * 24 * HOUR + +type Pushed = { accountId: string; eventId: string; action: string; at: number; actorUserId?: string; target: { type: string }; detail?: Record } + +/** The control plane over billing's service binding: every audit push, recorded. */ +function controlPlane(options: { status?: number } = {}) { + const requests: Array<{ url: string; authorization: string | null; body: { accountId: string; events: Array> } }> = [] + const binding = { + fetch: async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input instanceof Request ? input.url : input.toString() + requests.push({ url, authorization: new Headers(init?.headers).get('authorization'), body: JSON.parse(String(init?.body)) }) + return Response.json({ recorded: 1, duplicates: 0 }, { status: options.status ?? 200 }) + } + } + const events = (): Pushed[] => requests.flatMap((request) => request.body.events.map((event) => ({ ...event, accountId: request.body.accountId }))) + return { requests, binding, events } +} + +async function team(options: { status?: number } = {}) { + const control = controlPlane(options) + const h = await createHarness({ SANDBOX_CONTROL_URL: CONTROL }) + // SAFETY: the Worker only calls fetch on the binding. + h.env.SANDBOX_CONTROL = control.binding as unknown as Fetcher + const token = await h.desktopToken({ sub: 'u1', org: 'o1', orgRole: 'owner', email: 'a@example.com' }) + const auth = { authorization: `Bearer ${token}` } + const accountId = 'org:o1' + await h.call('POST', '/v1/billing/checkout', { body: { plan: 'team', seats: 2 }, headers: auth }) + const customer = [...h.stripe.customers.keys()][0]! + const subscription = h.stripe.putSubscription({ customer, accountId, basePrice: 'price_team_month', quantity: 2, periodStart: PERIOD_START, periodEnd: PERIOD_END }) + await h.webhook(checkoutCompleted({ customer, subscription: subscription.id, accountId })) + return { h, auth, control, subscription, accountId } +} + +describe('subscription changes', () => { + it('records the plan starting, seats changing, a cancellation scheduled and reverted, and the plan ending', async () => { + const { h, control, subscription } = await team() + expect(control.events()).toEqual([ + { + accountId: 'org:o1', + eventId: expect.stringMatching(/^billing_[0-9a-f]{40}$/), + action: 'billing.plan_started', + at: expect.any(Number), + target: { type: 'plan' }, + detail: { plan: 'team', interval: 'month', seats: 2 } + } + ]) + // Billing's own bearer, to the one route it may write. + expect(control.requests[0]).toMatchObject({ url: `${CONTROL}/v1/internal/audit-events`, authorization: `Bearer ${h.env.CONTROL_PLANE_TOKEN.split(',')[0]}` }) + + subscription.items.data[0]!.quantity = 5 + await h.webhook(stripeEvent('customer.subscription.updated', subscription)) + subscription.cancel_at_period_end = true + await h.webhook(stripeEvent('customer.subscription.updated', subscription)) + subscription.cancel_at_period_end = false + await h.webhook(stripeEvent('customer.subscription.updated', subscription)) + subscription.status = 'canceled' + await h.webhook(stripeEvent('customer.subscription.deleted', subscription)) + expect(control.events().map((event) => [event.action, event.detail ?? null])).toEqual([ + ['billing.plan_started', { plan: 'team', interval: 'month', seats: 2 }], + ['billing.seats_changed', { seats: 5, previousSeats: 2 }], + ['billing.cancel_scheduled', null], + ['billing.cancel_reverted', null], + ['billing.plan_canceled', { plan: 'team' }] + ]) + // Stripe reported these: no person is named. + expect(control.events().every((event) => event.actorUserId === undefined)).toBe(true) + }) + + it('a replayed or unchanged Stripe event records nothing more', async () => { + const { h, control, subscription } = await team() + await h.webhook(stripeEvent('customer.subscription.updated', subscription)) + await h.webhook(stripeEvent('customer.subscription.updated', subscription)) + await h.webhook(stripeEvent('invoice.paid', { id: 'in_1', customer: subscription.customer, subscription: subscription.id })) + expect(control.events().map((event) => event.action)).toEqual(['billing.plan_started']) + }) + + it('works out what changed from the stored row alone', () => { + const live = { status: 'active', plan: 'team', billing_interval: 'month', seats: 2, cancel_at_period_end: 0 } as const + const actions = (before: Parameters[0], after: Parameters[1]) => + subscriptionAuditEvents(before, after, 1).map((event) => event.action) + expect(actions(null, live)).toEqual(['billing.plan_started']) + // Checkout made it but the first payment has not gone through: not a plan yet. + expect(actions(null, { ...live, status: 'incomplete' })).toEqual([]) + expect(actions({ ...live, status: 'incomplete' }, live)).toEqual(['billing.plan_started']) + expect(actions({ ...live, status: 'incomplete' }, { ...live, status: 'incomplete_expired' })).toEqual([]) + // A failed payment is not a plan change; it keeps its own notices. + expect(actions(live, { ...live, status: 'past_due' })).toEqual([]) + expect(actions(live, { ...live, billing_interval: 'year', seats: 3 })).toEqual(['billing.plan_changed', 'billing.seats_changed']) + expect(subscriptionAuditEvents(live, { ...live, billing_interval: 'year' }, 1)[0]!.detail).toEqual({ plan: 'team', interval: 'year', previousPlan: 'team', previousInterval: 'month' }) + expect(actions(live, { ...live, status: 'canceled' })).toEqual(['billing.plan_canceled']) + expect(actions({ ...live, status: 'canceled' }, { ...live, status: 'canceled' })).toEqual([]) + // A team that comes back after canceling starts a plan again. + expect(actions({ ...live, status: 'canceled' }, live)).toEqual(['billing.plan_started']) + }) +}) + +describe('overage and the spend cap', () => { + it('records who turned overage on or off and who changed the cap, and only real changes', async () => { + const { h, auth, control } = await team() + const overage = (body: unknown) => h.call('POST', '/v1/billing/overage', { body, headers: auth }) + const mine = () => control.events().filter((event) => event.action !== 'billing.plan_started' && event.action !== 'billing.seats_changed') + expect((await overage({ enabled: true, spendCapCents: 4000 })).status).toBe(200) + expect(mine().map((event) => [event.action, event.actorUserId, event.detail ?? null])).toEqual([ + ['billing.overage_enabled', 'u1', null], + ['billing.spend_cap_changed', 'u1', { spendCapCents: 4000, previousSpendCapCents: null }] + ]) + // The same settings again: nothing changed, nothing recorded. + expect((await overage({ enabled: true, spendCapCents: 4000 })).status).toBe(200) + expect((await overage({ enabled: true })).status).toBe(200) + expect(mine()).toHaveLength(2) + expect((await overage({ enabled: true, spendCapCents: 9000 })).status).toBe(200) + expect((await overage({ enabled: false })).status).toBe(200) + expect(mine().slice(2).map((event) => [event.action, event.detail ?? null])).toEqual([ + ['billing.spend_cap_changed', { spendCapCents: 9000, previousSpendCapCents: 4000 }], + ['billing.overage_disabled', null] + ]) + }) + + it('records nothing for a change that was refused', async () => { + const { h, control } = await team() + const member = await h.desktopToken({ sub: 'u2', org: 'o1', orgRole: 'member', email: 'b@example.com' }) + const refused = await h.call('POST', '/v1/billing/overage', { body: { enabled: true }, headers: { authorization: `Bearer ${member}` } }) + expect(refused.status).toBe(403) + expect(control.events().filter((event) => event.action.startsWith('billing.overage'))).toEqual([]) + }) +}) + +describe('the push itself', () => { + it('a control plane that refuses or is down never fails the billing change', async () => { + const { h, auth, control } = await team({ status: 500 }) + const changed = await h.call('POST', '/v1/billing/overage', { body: { enabled: true }, headers: auth }) + expect(changed.status).toBe(200) + expect(changed.body.overage.optedIn).toBe(true) + expect(control.requests.length).toBeGreaterThan(0) + }) + + it('a personal account has no team log: nothing is sent', async () => { + const control = controlPlane() + // SAFETY: the push only calls fetch on the binding. + const env = { CONTROL_PLANE_TOKEN: 'c'.repeat(40), SANDBOX_CONTROL_URL: CONTROL, SANDBOX_CONTROL: control.binding as unknown as Fetcher } + expect(await pushBillingAudit(env, 'user:u1', [{ action: 'billing.plan_started', at: 1 }])).toBe(false) + expect(control.requests).toEqual([]) + expect(await pushBillingAudit(env, 'org:o1', [{ action: 'billing.plan_started', at: 1 }])).toBe(true) + expect(control.requests).toHaveLength(1) + expect([hasTeamAuditLog('org:o1'), hasTeamAuditLog('dev:org:x'), hasTeamAuditLog('user:u1'), hasTeamAuditLog('dev:alice')]).toEqual([true, true, false, false]) + }) + + it('sends nothing when the control plane is not configured, and never a price, customer or card', async () => { + expect(await pushBillingAudit({ CONTROL_PLANE_TOKEN: 'c'.repeat(40) }, 'org:o1', [{ action: 'billing.plan_started', at: 1 }])).toBe(false) + const { h, auth, control, subscription } = await team() + await h.call('POST', '/v1/billing/overage', { body: { enabled: true, spendCapCents: 5000 }, headers: auth }) + const sent = JSON.stringify(control.requests.map((request) => request.body)) + expect(sent).not.toMatch(/price_|cus_|sub_test|si_test|card/) + expect(sent).not.toContain(subscription.id) + // Keys the control plane refuses as credential-like are never used. + for (const event of control.events()) { + for (const key of Object.keys(event.detail ?? {})) { + expect(key).not.toMatch(/secret|token|password|passphrase|hint|value|ciphertext|credential|private|code/i) + } + } + }) +}) diff --git a/cloud/apps/sandbox-control/src/api/audit-ingest.ts b/cloud/apps/sandbox-control/src/api/audit-ingest.ts index 2b967dfbc13..5986be07cac 100644 --- a/cloud/apps/sandbox-control/src/api/audit-ingest.ts +++ b/cloud/apps/sandbox-control/src/api/audit-ingest.ts @@ -4,14 +4,20 @@ // POST /v1/internal/audit-events {accountId, events: [{eventId, action, at?, actorUserId?, target?, detail?}]} // -> {recorded, duplicates} // -// Auth's token (MEMBER_REMOVED_TOKEN) or the operator token. The route only appends: it can't -// read, change or delete a row. `eventId` makes a retry a no-op. Actions are limited to the +// Auth's token (MEMBER_REMOVED_TOKEN) writes member, team, sso, scim and mfa events; billing's +// (CONTROL_PLANE_TOKEN) writes billing events and nothing else; the operator token writes any. +// The route only appends: it can't read, change or delete a row. `eventId` makes a retry a no-op. Actions are limited to the // families those services own, so a pushed event can never pass for an API key or secret change. // Member removal and role changes are not sent here: they have their own pushes, which also do // the cleanup, and record the event themselves (actorUserId on those bodies). import { z } from 'zod' const EVENT_FAMILIES = ['member', 'team', 'sso', 'scim', 'mfa', 'billing'] as const +export type AuditEventFamily = (typeof EVENT_FAMILIES)[number] +/** What each sender may write, so one service's bearer can't forge another's events. */ +export const AUTH_EVENT_FAMILIES: readonly AuditEventFamily[] = ['member', 'team', 'sso', 'scim', 'mfa'] +export const BILLING_EVENT_FAMILIES: readonly AuditEventFamily[] = ['billing'] +export const ALL_EVENT_FAMILIES: readonly AuditEventFamily[] = EVENT_FAMILIES const ACTION = new RegExp(`^(${EVENT_FAMILIES.join('|')})\\.[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)?$`) /** Recorded by this service from the member pushes; a second copy from outside would double them. */ const OWNED_HERE = new Set(['member.removed', 'member.left', 'member.role_changed', 'member.role_synced']) @@ -53,9 +59,17 @@ export type AuditIngestBody = z.infer export class AuditIngestError extends Error {} /** Appends the events; one that was already recorded (same eventId) is skipped. */ -export async function ingestAuditEvents(db: D1Database, body: AuditIngestBody, now: number): Promise<{ recorded: number; duplicates: number }> { +export async function ingestAuditEvents( + db: D1Database, + body: AuditIngestBody, + now: number, + families: readonly AuditEventFamily[] = ALL_EVENT_FAMILIES +): Promise<{ recorded: number; duplicates: number }> { const statements: D1PreparedStatement[] = [] for (const event of body.events) { + if (!(families as readonly string[]).includes(event.action.split('.')[0]!)) { + throw new AuditIngestError(`action_not_allowed_for_sender:${event.action}`) + } if (OWNED_HERE.has(event.action)) { throw new AuditIngestError(`action_recorded_by_control_plane:${event.action}`) } diff --git a/cloud/apps/sandbox-control/src/index.ts b/cloud/apps/sandbox-control/src/index.ts index 2a6ed4b9ce2..b2c38b2cc8a 100644 --- a/cloud/apps/sandbox-control/src/index.ts +++ b/cloud/apps/sandbox-control/src/index.ts @@ -157,7 +157,7 @@ import { forgetShareLink, handleShareGateway } from './share-gateway' import { handleShareRoute, revokeSandboxShares, sharePathMode, shareSecret, shareSecrets } from './share-links' import { SshKeyRequestSchema, updateSshKey } from './ssh-access' import { audit, auditSession } from './api/audit' -import { AuditIngestError, AuditIngestSchema, ingestAuditEvents } from './api/audit-ingest' +import { ALL_EVENT_FAMILIES, AUTH_EVENT_FAMILIES, AuditIngestError, AuditIngestSchema, BILLING_EVENT_FAMILIES, ingestAuditEvents } from './api/audit-ingest' import { getSandbox, getSandboxByProviderName, listAccountSandboxes, markUserSlept, type SandboxRow } from './store' import { createVercelSandboxApi } from './vercel-sandbox-api' import { advanceAllBuilds, tickBuild, unfinishedBuilds, type BuildDeps } from './environment-builds' @@ -1261,7 +1261,9 @@ async function routeRequest(request: Request, env: Env, deps: Deps): Promise { expect((await s.h.call('GET', '/v1/internal/status', { bearer: AUTH_TOKEN })).status).toBe(404) }) + it("billing's bearer writes billing events and nothing else; auth's writes none of billing's", async () => { + const s = await setup() + const plan = { eventId: 'billing_0000000001', action: 'billing.seats_changed', target: { type: 'plan' }, detail: { seats: 5, previousSeats: 2 } } + const overage = { eventId: 'billing_0000000002', action: 'billing.overage_enabled', actorUserId: 'olga', target: { type: 'plan' } } + await s.h.call('GET', '/v1/me', { bearer: s.owner }) + expect((await s.internal('/v1/internal/audit-events', { accountId: 'org:acme', events: [plan, overage] }, BILLING_TOKEN)).body).toEqual({ recorded: 2, duplicates: 0 }) + const events: Event[] = (await s.log(s.owner, 'category=billing')).body.events + expect(events.map((event) => [event.action, event.actor.kind, event.actor.name, event.target?.type, event.detail])).toEqual( + expect.arrayContaining([ + ['billing.seats_changed', 'system', null, 'plan', { seats: 5, previousSeats: 2 }], + ['billing.overage_enabled', 'person', 'Olga Owner', 'plan', null] + ]) + ) + expect(events).toHaveLength(2) + // One sender cannot write another's events. + const forged = await s.internal('/v1/internal/audit-events', { accountId: 'org:acme', events: [invite] }, BILLING_TOKEN) + expect([forged.status, forged.body.reason]).toEqual([400, 'action_not_allowed_for_sender:member.invited']) + const crossed = await s.internal('/v1/internal/audit-events', { accountId: 'org:acme', events: [{ ...plan, eventId: 'billing_0000000003' }] }) + expect([crossed.status, crossed.body.reason]).toEqual([400, 'action_not_allowed_for_sender:billing.seats_changed']) + // The operator may write either. + expect((await s.internal('/v1/internal/audit-events', { accountId: 'org:acme', events: [{ ...plan, eventId: 'billing_0000000004' }, invite] }, OPERATOR)).status).toBe(200) + // Billing's bearer opens nothing else under /v1/internal. + expect((await s.h.call('GET', '/v1/internal/status', { bearer: BILLING_TOKEN })).status).toBe(404) + expect((await s.internal('/v1/internal/org-members/removed', { accountId: 'org:acme', userId: 'bob' }, BILLING_TOKEN)).status).toBe(404) + expect((await s.internal('/v1/internal/members/role-changed', { accountId: 'org:acme', userId: 'bob', role: 'owner' }, BILLING_TOKEN)).status).toBe(404) + }) + + it('accepts the team and join events auth sends', async () => { + const s = await setup() + const events = [ + { eventId: 'member_0000000001', action: 'member.joined', actorUserId: 'erin', target: { type: 'member', id: 'erin' }, detail: { source: 'sign-in', role: 'member' } }, + { eventId: 'member_0000000002', action: 'member.invite_revoked', actorUserId: 'olga', target: { type: 'invite', label: 'x@acme.dev' } }, + { eventId: 'team_0000000001', action: 'team.renamed', actorUserId: 'olga', target: { type: 'team', label: 'Acme Labs' }, detail: { via: 'cloud-setup', previousName: 'Acme' } }, + { eventId: 'team_0000000002', action: 'team.deleted', actorUserId: 'olga', target: { type: 'team', label: 'Acme Labs' }, detail: { members: 3 } } + ] + expect((await s.internal('/v1/internal/audit-events', { accountId: 'org:acme', events })).body).toEqual({ recorded: 4, duplicates: 0 }) + const members: Event[] = (await s.log(s.owner, 'category=members')).body.events + expect(members.map((event) => event.action).sort()).toEqual(['member.invite_revoked', 'member.joined', 'team.deleted', 'team.renamed']) + expect(members.find((event) => event.action === 'team.renamed')).toMatchObject({ target: { type: 'team', label: 'Acme Labs' }, detail: { previousName: 'Acme' } }) + }) + it('refuses anything that could pass for another kind of change, hold a credential, or post-date the log', async () => { const s = await setup() const post = (event: Record, accountId = 'org:acme') => s.internal('/v1/internal/audit-events', { accountId, events: [{ ...invite, ...event }] }) diff --git a/src/renderer/src/components/settings/enterprise/EnterpriseSettingsView.test.tsx b/src/renderer/src/components/settings/enterprise/EnterpriseSettingsView.test.tsx index 842a904c61e..b840a90d946 100644 --- a/src/renderer/src/components/settings/enterprise/EnterpriseSettingsView.test.tsx +++ b/src/renderer/src/components/settings/enterprise/EnterpriseSettingsView.test.tsx @@ -348,11 +348,57 @@ describe('copy', () => { 'routine.run', 'routine.run.start', 'routine.run.finish', - 'routine.rotate_webhook' + 'routine.rotate_webhook', + // Sent by auth and billing (AUDIT-AUTH-EVENTS). + 'member.invited', + 'member.invite_revoked', + 'member.joined', + 'team.created', + 'team.renamed', + 'team.deleted', + 'billing.plan_started', + 'billing.plan_changed', + 'billing.plan_canceled', + 'billing.cancel_scheduled', + 'billing.cancel_reverted', + 'billing.seats_changed', + 'billing.overage_enabled', + 'billing.overage_disabled', + 'billing.spend_cap_changed' ] expect(recorded.filter((action) => !isKnownAuditAction(action))).toEqual([]) }) + it('words the events auth and billing send', () => { + const base: CloudAuditEvent = { + id: 'a', + at: FIXTURE_NOW, + category: 'members', + action: 'member.invited', + actor: { kind: 'person', userId: 'u_ada', name: 'Ada Lovelace', via: null }, + target: { type: 'invite', id: null, label: 'new@acme.dev' }, + detail: { role: 'member' } + } + const say = (action: string, target: CloudAuditEvent['target'] = null): string => + auditEventSentence({ ...base, action, target }) + expect(auditEventSentence(base)).toBe('Invited new@acme.dev') + expect(say('member.invite_revoked', base.target)).toBe('Canceled the invite for new@acme.dev') + expect(say('member.joined', { type: 'member', id: 'u_x', label: 'Bo' })).toBe('Joined the team') + expect(say('team.renamed', { type: 'team', id: null, label: 'Acme Labs' })).toBe( + 'Renamed the team to “Acme Labs”' + ) + expect(say('team.deleted', { type: 'team', id: null, label: 'Acme Labs' })).toBe( + 'Deleted the team' + ) + expect(say('billing.plan_started', { type: 'plan', id: null })).toBe('Plan started') + expect(say('billing.cancel_scheduled', { type: 'plan', id: null })).toBe( + 'Plan set to end at the next renewal' + ) + expect(say('billing.spend_cap_changed', { type: 'plan', id: null })).toBe( + 'Changed the overage spend cap' + ) + }) + it('falls back to the plain sentence when the target has no name', () => { const event: CloudAuditEvent = { id: 'a', diff --git a/src/renderer/src/components/settings/enterprise/enterprise-audit-account-copy.ts b/src/renderer/src/components/settings/enterprise/enterprise-audit-account-copy.ts new file mode 100644 index 00000000000..dced67300bb --- /dev/null +++ b/src/renderer/src/components/settings/enterprise/enterprise-audit-account-copy.ts @@ -0,0 +1,93 @@ +// Wording for the audit events the auth service and billing send: members and invites, the team +// itself, and the plan. (Workspace, key, secret and routine wording is in enterprise-audit-copy.) +import type { Translate } from '@/i18n/use-translate' + +type Sentence = (t: Translate) => string +/** `named` takes the thing's name ({{name}}): a key, a secret, a teammate, a workspace. */ +export type ActionCopy = { plain: Sentence; named?: (t: Translate, name: string) => string } + +export const ACCOUNT_AUDIT_ACTIONS: Record = { + 'member.invited': { + plain: (t) => t('settings.enterprise.audit.action.memberInvite', 'Invited a teammate'), + named: (t, name) => + t('settings.enterprise.audit.action.memberInviteNamed', 'Invited {{name}}', { name }) + }, + 'member.invite_revoked': { + plain: (t) => t('settings.enterprise.audit.action.inviteRevoke', 'Canceled an invite'), + named: (t, name) => + t('settings.enterprise.audit.action.inviteRevokeNamed', 'Canceled the invite for {{name}}', { + name + }) + }, + 'member.removed': { + plain: (t) => t('settings.enterprise.audit.action.memberRemove', 'Removed a teammate'), + named: (t, name) => + t('settings.enterprise.audit.action.memberRemoveNamed', 'Removed {{name}} from the team', { + name + }) + }, + 'member.left': { + plain: (t) => t('settings.enterprise.audit.action.memberLeft', 'Left the team') + }, + 'member.role_changed': { + plain: (t) => t('settings.enterprise.audit.action.roleChange', 'Changed a teammate’s role'), + named: (t, name) => + t('settings.enterprise.audit.action.roleChangeNamed', 'Changed the role of {{name}}', { + name + }) + }, + 'member.role_synced': { + plain: (t) => + t( + 'settings.enterprise.audit.action.roleSync', + 'Updated a teammate’s API keys to match their role' + ) + }, + 'team.created': { + plain: (t) => t('settings.enterprise.audit.action.teamCreate', 'Created the team') + }, + 'team.renamed': { + plain: (t) => t('settings.enterprise.audit.action.teamRename', 'Renamed the team'), + named: (t, name) => + t('settings.enterprise.audit.action.teamRenameNamed', 'Renamed the team to “{{name}}”', { + name + }) + }, + 'member.joined': { + plain: (t) => t('settings.enterprise.audit.action.memberJoined', 'Joined the team') + }, + 'team.deleted': { + plain: (t) => t('settings.enterprise.audit.action.teamDelete', 'Deleted the team') + }, + // Billing. Stripe reports plan and seat changes, so those read without a subject (the person + // column says Orca); overage and the spend cap are changed by a person in Plan & billing. + 'billing.plan_started': { + plain: (t) => t('settings.enterprise.audit.action.planStarted', 'Plan started') + }, + 'billing.plan_changed': { + plain: (t) => t('settings.enterprise.audit.action.planChanged', 'Plan changed') + }, + 'billing.plan_canceled': { + plain: (t) => t('settings.enterprise.audit.action.planCanceled', 'Plan ended') + }, + 'billing.cancel_scheduled': { + plain: (t) => + t('settings.enterprise.audit.action.cancelScheduled', 'Plan set to end at the next renewal') + }, + 'billing.cancel_reverted': { + plain: (t) => t('settings.enterprise.audit.action.cancelReverted', 'Plan set to renew again') + }, + 'billing.seats_changed': { + plain: (t) => t('settings.enterprise.audit.action.seatsChanged', 'Seats changed') + }, + 'billing.overage_enabled': { + plain: (t) => t('settings.enterprise.audit.action.overageOn', 'Turned on overage') + }, + 'billing.overage_disabled': { + plain: (t) => t('settings.enterprise.audit.action.overageOff', 'Turned off overage') + }, + 'billing.spend_cap_changed': { + plain: (t) => + t('settings.enterprise.audit.action.spendCapChanged', 'Changed the overage spend cap') + } +} diff --git a/src/renderer/src/components/settings/enterprise/enterprise-audit-copy.ts b/src/renderer/src/components/settings/enterprise/enterprise-audit-copy.ts index c5915d61a87..d7ac2af733b 100644 --- a/src/renderer/src/components/settings/enterprise/enterprise-audit-copy.ts +++ b/src/renderer/src/components/settings/enterprise/enterprise-audit-copy.ts @@ -7,6 +7,7 @@ import type { CloudAuditCategory, CloudAuditEvent } from '../../../../../shared/cloud-api/cloud-api-types' +import { ACCOUNT_AUDIT_ACTIONS, type ActionCopy } from './enterprise-audit-account-copy' export function auditCategoryLabel(category: CloudAuditCategory, t: Translate = translate): string { switch (category) { @@ -33,10 +34,6 @@ export function auditCategoryLabel(category: CloudAuditCategory, t: Translate = } } -type Sentence = (t: Translate) => string -/** `named` takes the thing's name ({{name}}): a key, a secret, a teammate, a workspace. */ -type ActionCopy = { plain: Sentence; named?: (t: Translate, name: string) => string } - const ACTIONS: Record = { 'api_key.create': { plain: (t) => t('auto.components.settings.cloudApi.audit.action.keyCreate', 'Created API key'), @@ -214,52 +211,7 @@ const ACTIONS: Record = { named: (t, name) => t('settings.enterprise.audit.action.secretDeleteNamed', 'Deleted secret {{name}}', { name }) }, - 'member.invited': { - plain: (t) => t('settings.enterprise.audit.action.memberInvite', 'Invited a teammate'), - named: (t, name) => - t('settings.enterprise.audit.action.memberInviteNamed', 'Invited {{name}}', { name }) - }, - 'member.invite_revoked': { - plain: (t) => t('settings.enterprise.audit.action.inviteRevoke', 'Canceled an invite'), - named: (t, name) => - t('settings.enterprise.audit.action.inviteRevokeNamed', 'Canceled the invite for {{name}}', { - name - }) - }, - 'member.removed': { - plain: (t) => t('settings.enterprise.audit.action.memberRemove', 'Removed a teammate'), - named: (t, name) => - t('settings.enterprise.audit.action.memberRemoveNamed', 'Removed {{name}} from the team', { - name - }) - }, - 'member.left': { - plain: (t) => t('settings.enterprise.audit.action.memberLeft', 'Left the team') - }, - 'member.role_changed': { - plain: (t) => t('settings.enterprise.audit.action.roleChange', 'Changed a teammate’s role'), - named: (t, name) => - t('settings.enterprise.audit.action.roleChangeNamed', 'Changed the role of {{name}}', { - name - }) - }, - 'member.role_synced': { - plain: (t) => - t( - 'settings.enterprise.audit.action.roleSync', - 'Updated a teammate’s API keys to match their role' - ) - }, - 'team.created': { - plain: (t) => t('settings.enterprise.audit.action.teamCreate', 'Created the team') - }, - 'team.renamed': { - plain: (t) => t('settings.enterprise.audit.action.teamRename', 'Renamed the team'), - named: (t, name) => - t('settings.enterprise.audit.action.teamRenameNamed', 'Renamed the team to “{{name}}”', { - name - }) - }, + ...ACCOUNT_AUDIT_ACTIONS, 'routine.create': { plain: (t) => t('settings.enterprise.audit.action.routineCreate', 'Created a routine') }, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index febbf19d401..da1f4e328ae 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1575,7 +1575,18 @@ "routineRun": "Ran a routine", "routineRunStart": "A routine run started", "routineRunFinish": "A routine run finished", - "routineWebhook": "Replaced a routine’s webhook URL" + "routineWebhook": "Replaced a routine’s webhook URL", + "memberJoined": "Joined the team", + "teamDelete": "Deleted the team", + "planStarted": "Plan started", + "planChanged": "Plan changed", + "planCanceled": "Plan ended", + "cancelScheduled": "Plan set to end at the next renewal", + "cancelReverted": "Plan set to renew again", + "seatsChanged": "Seats changed", + "overageOn": "Turned on overage", + "overageOff": "Turned off overage", + "spendCapChanged": "Changed the overage spend cap" } }, "sso": { From bb8861807cd2b6eb87e20db54a5a88bf5e831e9a Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 23:04:38 -0800 Subject: [PATCH 43/50] fix(cloud): AUDIT-AUTH-EVENTS design review: billing rows say the plan, seats, date and cap --- cloud/apps/billing/src/audit-push.ts | 11 ++- cloud/apps/billing/src/webhook.ts | 2 +- cloud/apps/billing/test/audit-push.test.ts | 2 +- .../enterprise/EnterpriseAuditLog.tsx | 2 +- .../EnterpriseSettingsView.test.tsx | 50 +++++++++- .../enterprise-audit-account-copy.ts | 98 +++++++++++++++++-- .../enterprise/enterprise-audit-copy.ts | 10 +- src/renderer/src/i18n/locales/en.json | 11 ++- 8 files changed, 167 insertions(+), 19 deletions(-) diff --git a/cloud/apps/billing/src/audit-push.ts b/cloud/apps/billing/src/audit-push.ts index 22208297b7d..7ef81401cad 100644 --- a/cloud/apps/billing/src/audit-push.ts +++ b/cloud/apps/billing/src/audit-push.ts @@ -84,7 +84,10 @@ export async function pushBillingAudit( } } -type SubscriptionTerms = Pick +type SubscriptionTerms = Pick & { + /** When the current period ends (ms): the day a scheduled cancellation takes effect. */ + current_period_end?: number +} const ENDED = new Set(['canceled', 'incomplete_expired']) /** Not a plan yet: Checkout made the subscription but the first payment has not gone through. */ @@ -116,7 +119,11 @@ export function subscriptionAuditEvents(before: SubscriptionTerms | null, after: events.push({ action: 'billing.seats_changed', at, detail: { seats: after.seats, previousSeats: before!.seats } }) } if (before!.cancel_at_period_end !== after.cancel_at_period_end) { - events.push({ action: after.cancel_at_period_end === 1 ? 'billing.cancel_scheduled' : 'billing.cancel_reverted', at }) + events.push( + after.cancel_at_period_end === 1 + ? { action: 'billing.cancel_scheduled', at, ...(after.current_period_end ? { detail: { endsAt: after.current_period_end } } : {}) } + : { action: 'billing.cancel_reverted', at } + ) } return events } diff --git a/cloud/apps/billing/src/webhook.ts b/cloud/apps/billing/src/webhook.ts index fce2ee98437..887e2cda185 100644 --- a/cloud/apps/billing/src/webhook.ts +++ b/cloud/apps/billing/src/webhook.ts @@ -167,7 +167,7 @@ export async function syncSubscription(env: Env, stripe: StripeClient, subscript accountId, subscriptionAuditEvents( known, - { status: subscription.status, plan: match.plan, billing_interval: match.interval, seats, cancel_at_period_end: subscription.cancel_at_period_end ? 1 : 0 }, + { status: subscription.status, plan: match.plan, billing_interval: match.interval, seats, cancel_at_period_end: subscription.cancel_at_period_end ? 1 : 0, current_period_end: periodEnd * 1000 }, now ) ) diff --git a/cloud/apps/billing/test/audit-push.test.ts b/cloud/apps/billing/test/audit-push.test.ts index 39c6b5d8204..d5f7829407a 100644 --- a/cloud/apps/billing/test/audit-push.test.ts +++ b/cloud/apps/billing/test/audit-push.test.ts @@ -68,7 +68,7 @@ describe('subscription changes', () => { expect(control.events().map((event) => [event.action, event.detail ?? null])).toEqual([ ['billing.plan_started', { plan: 'team', interval: 'month', seats: 2 }], ['billing.seats_changed', { seats: 5, previousSeats: 2 }], - ['billing.cancel_scheduled', null], + ['billing.cancel_scheduled', { endsAt: PERIOD_END * 1000 }], ['billing.cancel_reverted', null], ['billing.plan_canceled', { plan: 'team' }] ]) diff --git a/src/renderer/src/components/settings/enterprise/EnterpriseAuditLog.tsx b/src/renderer/src/components/settings/enterprise/EnterpriseAuditLog.tsx index a93d6afb4df..e3eca575956 100644 --- a/src/renderer/src/components/settings/enterprise/EnterpriseAuditLog.tsx +++ b/src/renderer/src/components/settings/enterprise/EnterpriseAuditLog.tsx @@ -347,7 +347,7 @@ export function EnterpriseAuditLog(props: EnterpriseAuditLogProps): React.JSX.El className="flex items-center gap-3 py-2 text-xs" > - {auditEventSentence(event, t)} + {auditEventSentence(event, t, locale)} {auditCategoryLabel(event.category, t)} diff --git a/src/renderer/src/components/settings/enterprise/EnterpriseSettingsView.test.tsx b/src/renderer/src/components/settings/enterprise/EnterpriseSettingsView.test.tsx index b840a90d946..bf72ac848ff 100644 --- a/src/renderer/src/components/settings/enterprise/EnterpriseSettingsView.test.tsx +++ b/src/renderer/src/components/settings/enterprise/EnterpriseSettingsView.test.tsx @@ -390,13 +390,53 @@ describe('copy', () => { expect(say('team.deleted', { type: 'team', id: null, label: 'Acme Labs' })).toBe( 'Deleted the team' ) - expect(say('billing.plan_started', { type: 'plan', id: null })).toBe('Plan started') - expect(say('billing.cancel_scheduled', { type: 'plan', id: null })).toBe( - 'Plan set to end at the next renewal' + }) + + it('puts what billing recorded into the sentence, and falls back when it recorded nothing', () => { + const say = (action: string, detail: Record | null): string => + auditEventSentence( + { + id: 'b', + at: FIXTURE_NOW, + category: 'billing', + action, + actor: { kind: 'system', userId: null, name: null, via: null }, + target: { type: 'plan', id: null }, + detail + }, + undefined, + 'en-US' + ) + expect(say('billing.plan_started', { plan: 'team', interval: 'month', seats: 2 })).toBe( + 'Plan started (Team, monthly)' ) - expect(say('billing.spend_cap_changed', { type: 'plan', id: null })).toBe( - 'Changed the overage spend cap' + expect(say('billing.plan_started', null)).toBe('Plan started') + expect(say('billing.plan_changed', { plan: 'team', interval: 'year' })).toBe( + 'Plan changed to Team, annual' ) + expect(say('billing.plan_changed', { plan: 'team' })).toBe('Plan changed') + expect(say('billing.seats_changed', { seats: 5, previousSeats: 3 })).toBe( + 'Seats changed from 3 to 5' + ) + expect(say('billing.seats_changed', { seats: 5 })).toBe('Seats changed') + expect(say('billing.plan_canceled', { plan: 'team' })).toBe('Plan ended') + expect(say('billing.cancel_scheduled', { endsAt: Date.UTC(2026, 11, 2, 12) })).toBe( + 'Plan set to end on Dec 2, 2026' + ) + expect(say('billing.cancel_scheduled', null)).toBe('Plan set to end at the next renewal') + expect(say('billing.cancel_reverted', null)).toBe('Plan set to renew again') + expect( + say('billing.spend_cap_changed', { spendCapCents: 5000, previousSpendCapCents: null }) + ).toBe('Changed the monthly spend cap to $50') + expect(say('billing.spend_cap_changed', { spendCapCents: 1250 })).toBe( + 'Changed the monthly spend cap to $12.50' + ) + // Back to the plan's default cap: no amount to show. + expect(say('billing.spend_cap_changed', { previousSpendCapCents: 5000 })).toBe( + 'Changed the monthly spend cap' + ) + expect(say('billing.overage_enabled', null)).toBe('Turned on overage') + expect(say('billing.overage_disabled', null)).toBe('Turned off overage') }) it('falls back to the plain sentence when the target has no name', () => { diff --git a/src/renderer/src/components/settings/enterprise/enterprise-audit-account-copy.ts b/src/renderer/src/components/settings/enterprise/enterprise-audit-account-copy.ts index dced67300bb..f5cd6efaa55 100644 --- a/src/renderer/src/components/settings/enterprise/enterprise-audit-account-copy.ts +++ b/src/renderer/src/components/settings/enterprise/enterprise-audit-account-copy.ts @@ -3,8 +3,34 @@ import type { Translate } from '@/i18n/use-translate' type Sentence = (t: Translate) => string -/** `named` takes the thing's name ({{name}}): a key, a secret, a teammate, a workspace. */ -export type ActionCopy = { plain: Sentence; named?: (t: Translate, name: string) => string } +type Detail = Record +/** + * `named` takes the thing's name ({{name}}): a key, a secret, a teammate, a workspace. + * `detailed` builds the sentence from what the event recorded (a plan, seat counts, an amount); + * it returns null when the event does not carry enough, and the plain sentence is used. + */ +export type ActionCopy = { + plain: Sentence + named?: (t: Translate, name: string) => string + detailed?: (t: Translate, detail: Detail, locale: string | undefined) => string | null +} + +/** Billing sends plan ids ("team"); the log shows them as names ("Team"). */ +function planName(detail: Detail, key = 'plan'): string | null { + const id = detail[key] + return typeof id === 'string' && id.length > 0 ? id.charAt(0).toUpperCase() + id.slice(1) : null +} + +function intervalWord(detail: Detail, t: Translate): string | null { + return detail.interval === 'month' + ? t('settings.enterprise.audit.interval.month', 'monthly') + : detail.interval === 'year' + ? t('settings.enterprise.audit.interval.year', 'annual') + : null +} + +const count = (value: unknown): number | null => + typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null export const ACCOUNT_AUDIT_ACTIONS: Record = { 'member.invited': { @@ -62,23 +88,64 @@ export const ACCOUNT_AUDIT_ACTIONS: Record = { // Billing. Stripe reports plan and seat changes, so those read without a subject (the person // column says Orca); overage and the spend cap are changed by a person in Plan & billing. 'billing.plan_started': { - plain: (t) => t('settings.enterprise.audit.action.planStarted', 'Plan started') + plain: (t) => t('settings.enterprise.audit.action.planStarted', 'Plan started'), + detailed: (t, detail) => { + const plan = planName(detail) + const interval = intervalWord(detail, t) + return plan && interval + ? t( + 'settings.enterprise.audit.action.planStartedDetail', + 'Plan started ({{plan}}, {{interval}})', + { plan, interval } + ) + : null + } }, 'billing.plan_changed': { - plain: (t) => t('settings.enterprise.audit.action.planChanged', 'Plan changed') + plain: (t) => t('settings.enterprise.audit.action.planChanged', 'Plan changed'), + detailed: (t, detail) => { + const plan = planName(detail) + const interval = intervalWord(detail, t) + return plan && interval + ? t( + 'settings.enterprise.audit.action.planChangedDetail', + 'Plan changed to {{plan}}, {{interval}}', + { plan, interval } + ) + : null + } }, 'billing.plan_canceled': { plain: (t) => t('settings.enterprise.audit.action.planCanceled', 'Plan ended') }, 'billing.cancel_scheduled': { plain: (t) => - t('settings.enterprise.audit.action.cancelScheduled', 'Plan set to end at the next renewal') + t('settings.enterprise.audit.action.cancelScheduled', 'Plan set to end at the next renewal'), + detailed: (t, detail, locale) => { + const endsAt = count(detail.endsAt) + return endsAt + ? t('settings.enterprise.audit.action.cancelScheduledOn', 'Plan set to end on {{date}}', { + date: new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(endsAt) + }) + : null + } }, 'billing.cancel_reverted': { plain: (t) => t('settings.enterprise.audit.action.cancelReverted', 'Plan set to renew again') }, 'billing.seats_changed': { - plain: (t) => t('settings.enterprise.audit.action.seatsChanged', 'Seats changed') + plain: (t) => t('settings.enterprise.audit.action.seatsChanged', 'Seats changed'), + detailed: (t, detail) => { + const from = count(detail.previousSeats) + const to = count(detail.seats) + return from !== null && to !== null + ? t( + 'settings.enterprise.audit.action.seatsChangedDetail', + 'Seats changed from {{from}} to {{to}}', + { from: String(from), to: String(to) } + ) + : null + } }, 'billing.overage_enabled': { plain: (t) => t('settings.enterprise.audit.action.overageOn', 'Turned on overage') @@ -88,6 +155,23 @@ export const ACCOUNT_AUDIT_ACTIONS: Record = { }, 'billing.spend_cap_changed': { plain: (t) => - t('settings.enterprise.audit.action.spendCapChanged', 'Changed the overage spend cap') + t('settings.enterprise.audit.action.spendCapChanged2', 'Changed the monthly spend cap'), + detailed: (t, detail, locale) => { + // No amount (null) means the plan's default cap: say only that it changed. + const cents = count(detail.spendCapCents) + return cents === null + ? null + : t( + 'settings.enterprise.audit.action.spendCapChangedTo', + 'Changed the monthly spend cap to {{amount}}', + { + amount: new Intl.NumberFormat(locale, { + style: 'currency', + currency: 'USD', + minimumFractionDigits: cents % 100 === 0 ? 0 : 2 + }).format(cents / 100) + } + ) + } } } diff --git a/src/renderer/src/components/settings/enterprise/enterprise-audit-copy.ts b/src/renderer/src/components/settings/enterprise/enterprise-audit-copy.ts index d7ac2af733b..cf31667beda 100644 --- a/src/renderer/src/components/settings/enterprise/enterprise-audit-copy.ts +++ b/src/renderer/src/components/settings/enterprise/enterprise-audit-copy.ts @@ -248,11 +248,19 @@ function targetName(event: CloudAuditEvent): string | null { } /** "Created API key “CI deploy”"; an unknown action reads as its machine name. */ -export function auditEventSentence(event: CloudAuditEvent, t: Translate = translate): string { +export function auditEventSentence( + event: CloudAuditEvent, + t: Translate = translate, + locale?: string +): string { const copy = ACTIONS[event.action] if (!copy) { return event.action } + const detailed = event.detail ? copy.detailed?.(t, event.detail, locale) : null + if (detailed) { + return detailed + } const name = targetName(event) return name && copy.named ? copy.named(t, name) : copy.plain(t) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index da1f4e328ae..7bcf70342bd 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1586,7 +1586,16 @@ "seatsChanged": "Seats changed", "overageOn": "Turned on overage", "overageOff": "Turned off overage", - "spendCapChanged": "Changed the overage spend cap" + "planStartedDetail": "Plan started ({{plan}}, {{interval}})", + "planChangedDetail": "Plan changed to {{plan}}, {{interval}}", + "cancelScheduledOn": "Plan set to end on {{date}}", + "seatsChangedDetail": "Seats changed from {{from}} to {{to}}", + "spendCapChanged2": "Changed the monthly spend cap", + "spendCapChangedTo": "Changed the monthly spend cap to {{amount}}" + }, + "interval": { + "month": "monthly", + "year": "annual" } }, "sso": { From 7533e82242bc1fb4760275cfbdb173dd0b5dda10 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 21:45:55 -0800 Subject: [PATCH 44/50] fix(team): TEAM-DIALOG-2; drop the header Plan & billing button, invite line is only the seat rule --- .../billing/team/TeamInviteComposer.test.tsx | 32 +++++++ .../billing/team/TeamInviteComposer.tsx | 30 +----- .../billing/team/TeamPageView.fixture.tsx | 23 +---- .../billing/team/TeamPageView.test.tsx | 63 ++++++++++++ .../components/billing/team/TeamPageView.tsx | 20 +--- .../team/team-invite-impact-copy.test.ts | 96 ------------------- .../billing/team/team-invite-impact-copy.ts | 86 ----------------- .../billing/team/team-seat-math.test.ts | 77 +-------------- .../components/billing/team/team-seat-math.ts | 81 +--------------- .../src/i18n/en-runtime-required.json | 8 -- src/renderer/src/i18n/locales/en.json | 16 +--- 11 files changed, 106 insertions(+), 426 deletions(-) create mode 100644 src/renderer/src/components/billing/team/TeamInviteComposer.test.tsx create mode 100644 src/renderer/src/components/billing/team/TeamPageView.test.tsx delete mode 100644 src/renderer/src/components/billing/team/team-invite-impact-copy.test.ts delete mode 100644 src/renderer/src/components/billing/team/team-invite-impact-copy.ts diff --git a/src/renderer/src/components/billing/team/TeamInviteComposer.test.tsx b/src/renderer/src/components/billing/team/TeamInviteComposer.test.tsx new file mode 100644 index 00000000000..3eb9fe09a3b --- /dev/null +++ b/src/renderer/src/components/billing/team/TeamInviteComposer.test.tsx @@ -0,0 +1,32 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, expect, it } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import { TeamInviteComposer } from './TeamInviteComposer' + +afterEach(cleanup) + +const RULE = 'Each invite uses a seat once accepted.' + +it('says only the seat rule, with no seat counts or prices, however many are entered', () => { + render( ({ failed: [] })} />, { + wrapper: TooltipProvider + }) + const input = screen.getByRole('textbox') + const hint = document.getElementById(input.getAttribute('aria-describedby')!)! + expect(hint.textContent).toBe(RULE) + + fireEvent.change(input, { target: { value: 'jordan@acme.dev, kim@acme.dev ' } }) + expect(hint.textContent).toBe(RULE) + expect(hint.textContent).not.toMatch(/open seat|\$|prorated/) +}) + +it('still flags invalid entries after the rule', () => { + render( ({ failed: [] })} />, { + wrapper: TooltipProvider + }) + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: 'jordan@acme.dev, not-an-email ' } }) + const hint = document.getElementById(input.getAttribute('aria-describedby')!)! + expect(hint.textContent).toBe(`${RULE} Skipping 1 invalid entry.`) +}) diff --git a/src/renderer/src/components/billing/team/TeamInviteComposer.tsx b/src/renderer/src/components/billing/team/TeamInviteComposer.tsx index 566ea08c500..4c1429b5d25 100644 --- a/src/renderer/src/components/billing/team/TeamInviteComposer.tsx +++ b/src/renderer/src/components/billing/team/TeamInviteComposer.tsx @@ -10,9 +10,8 @@ import { } from '@/components/ui/select' import { LoadingSpinner } from '@/components/LoadingSpinner' import { SettingsDisabledControlTooltip } from '@/components/settings/form-controls/SettingsDisabledControlTooltip' -import { useIntlLocale, useTranslate } from '@/i18n/use-translate' +import { useTranslate } from '@/i18n/use-translate' import { cn } from '@/lib/class-names' -import type { OrcaBillingEntitlement } from '../../../../../shared/orca-billing' import type { OrcaOrgRole } from '../../../../../shared/orca-profiles' import { MAX_INVITES_PER_BATCH, @@ -22,17 +21,11 @@ import { } from './parse-invite-emails' import { roleDescription } from './team-copy' import { assignableRoles, roleLabel } from './team-roles' -import { inviteSeatImpactLine } from './team-invite-impact-copy' -import { inviteSeatImpact } from './team-seat-math' export type InviteBatchOutcome = { failed: { email: string; message: string }[] } export type TeamInviteComposerProps = { viewerRole: OrcaOrgRole - entitlement: OrcaBillingEntitlement | null - members: number - pendingInvites: number - now: number /** Sends every invite; resolves with the ones that failed so they stay in the field. */ onInvite: (emails: string[], role: OrcaOrgRole) => Promise inputRef?: React.Ref @@ -41,7 +34,6 @@ export type TeamInviteComposerProps = { /** Multi-email invite field: paste a list, review the chips, pick a role, send. */ export function TeamInviteComposer(props: TeamInviteComposerProps): React.JSX.Element { const t = useTranslate() - const locale = useIntlLocale() const id = useId() const [draft, setDraft] = useState('') const [candidates, setCandidates] = useState([]) @@ -65,19 +57,10 @@ export function TeamInviteComposer(props: TeamInviteComposerProps): React.JSX.El const pendingDraft = draft.trim() ? mergeInviteCandidates(candidates, splitInviteText(draft)) : candidates - // The button and the seat line both count what Send would send, including a typed, - // unseparated address, so the seat impact always matches the invites about to go out. + // The button counts what Send would send, including a typed, unseparated address. const valid = pendingDraft.filter((candidate) => candidate.valid) const committedValid = candidates.filter((candidate) => candidate.valid) const invalidCount = candidates.length - committedValid.length - const paymentFailed = props.entitlement?.payment.pastDueSince != null - const impact = inviteSeatImpact({ - entitlement: props.entitlement, - members: props.members, - pendingInvites: props.pendingInvites, - newInvites: valid.length, - now: props.now - }) const send = async (): Promise => { const all = commit(draft) @@ -271,13 +254,8 @@ export function TeamInviteComposer(props: TeamInviteComposerProps): React.JSX.El

    - {inviteSeatImpactLine(impact, valid.length, locale, t)} - {paymentFailed && valid.length > 0 - ? ` ${t( - 'auto.components.billing.team.invitePaymentFailed2', - 'New seats are charged once the failed payment is fixed.' - )}` - : null} + {/* Only the rule: seat counts and prices live in Plan & billing (Neil, TEAM-DIALOG-2). */} + {t('auto.components.billing.team.inviteSeatRule', 'Each invite uses a seat once accepted.')} {invalidCount > 0 ? ` ${t( 'auto.components.billing.team.skipInvalid', diff --git a/src/renderer/src/components/billing/team/TeamPageView.fixture.tsx b/src/renderer/src/components/billing/team/TeamPageView.fixture.tsx index 67784fa9137..1eb77834e17 100644 --- a/src/renderer/src/components/billing/team/TeamPageView.fixture.tsx +++ b/src/renderer/src/components/billing/team/TeamPageView.fixture.tsx @@ -244,34 +244,13 @@ async function typeInvites(text: string): Promise { await new Promise((resolve) => setTimeout(resolve, 100)) } -/** Will add seats: 4 paid, 4 members and 2 pending, so both new invites add a seat. */ +/** Pasted list: the chips, one invalid entry, and the seat rule (no counts or prices). */ export const invitePasted = defineFixture({ width, render: () => , interact: () => typeInvites('jordan@acme.dev, kim@acme.dev, not-an-email ') }) -/** Seats free: 8 paid, 6 taken (members plus pending), so one invite adds nothing. */ -export const inviteSeatsFree = defineFixture({ - width, - render: () => , - interact: () => typeInvites('jordan@acme.dev ') -}) - -/** Billing unknown (disabled in this build, or it failed): the rule without counts or prices. */ -export const inviteBillingUnknown = defineFixture({ - width, - render: () => , - interact: () => typeInvites('jordan@acme.dev, kim@acme.dev ') -}) - -/** Billing sent no price: seats are still counted, no price is shown. */ -export const inviteNoPrice = defineFixture({ - width, - render: () => , - interact: () => typeInvites('jordan@acme.dev, kim@acme.dev ') -}) - /** Short roster so the Enterprise row sits inside the frame. */ export const ssoRow = defineFixture({ width, diff --git a/src/renderer/src/components/billing/team/TeamPageView.test.tsx b/src/renderer/src/components/billing/team/TeamPageView.test.tsx new file mode 100644 index 00000000000..5ae89ebf7cf --- /dev/null +++ b/src/renderer/src/components/billing/team/TeamPageView.test.tsx @@ -0,0 +1,63 @@ +// @vitest-environment happy-dom +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, it } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import type { OrcaOrgMembersRoster } from '../../../../../shared/orca-profiles' +import { sampleTeam } from '../plan-billing-fixtures' +import { TeamPageView, type TeamPageViewProps } from './TeamPageView' + +afterEach(cleanup) + +const roster: OrcaOrgMembersRoster = { + viewerRole: 'owner', + canManageMembers: true, + members: [ + { userId: 'u-neil', email: 'neil@acme.dev', displayName: 'Neil Parker', role: 'owner' }, + { userId: 'u-ada', email: 'ada@acme.dev', displayName: 'Ada Lovelace', role: 'admin' } + ], + pendingInvites: [{ email: 'sam@acme.dev', role: 'member', createdAt: 0 }] +} + +const noop = (): void => {} +const props: TeamPageViewProps = { + org: { orgId: 'org-acme', name: 'Acme Engineering' }, + viewerUserId: 'u-neil', + roster: { status: 'ok', roster }, + billing: { status: 'ok', entitlement: { ...sampleTeam, canManageBilling: true } }, + arrival: null, + rowStates: {}, + refreshing: false, + billingBusy: false, + now: 0, + create: { + organizations: [], + creating: false, + error: null, + unavailable: false, + switchingOrgId: null + }, + onCreateTeam: noop, + onSwitchOrg: noop, + onRefresh: noop, + onInvite: async () => ({ failed: [] }), + onChangeRole: noop, + onMakeOwner: noop, + onRemove: noop, + onResend: noop, + onRevoke: noop, + onLeave: noop, + onSeePlans: noop, + onManageBilling: noop, + onCreateWorkspace: noop, + onDismissArrival: noop +} + +it('keeps the header to the name and counts: no role sentence, no Plan & billing button', () => { + render(, { wrapper: TooltipProvider }) + const header = document.querySelector('header')! + expect(header.textContent).toContain('2 members · 1 invited') + expect(header.textContent).not.toMatch(/You’re an|Plan & billing/) + expect(header.querySelector('button')).toBeNull() + // Seat counts and prices live only in Plan & billing. + expect(screen.queryByText(/open seat/)).toBeNull() +}) diff --git a/src/renderer/src/components/billing/team/TeamPageView.tsx b/src/renderer/src/components/billing/team/TeamPageView.tsx index 427c63bb752..ccf26d7b5a2 100644 --- a/src/renderer/src/components/billing/team/TeamPageView.tsx +++ b/src/renderer/src/components/billing/team/TeamPageView.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef } from 'react' -import { CreditCard, RefreshCw } from 'lucide-react' +import { RefreshCw } from 'lucide-react' import { Button } from '@/components/ui/button' import { InlineError } from '@/components/ui/inline-error' import { Skeleton } from '@/components/ui/skeleton' @@ -141,20 +141,6 @@ export function TeamPageView(props: TeamPageViewProps): React.JSX.Element { : t('auto.components.billing.team.loadFailedTitle', 'Couldn’t load team')}

    - {!inline && entitlement?.canManageBilling ? ( - // Seats, prices and invoices live in Plan & billing; one quiet way there from here. - // mr-6 clears the dialog's close button, which sits in this corner. - - ) : null}
    {props.arrival?.kind === 'checkout-success' && entitlement ? ( @@ -213,10 +199,6 @@ export function TeamPageView(props: TeamPageViewProps): React.JSX.Element { {roster?.canManageMembers ? ( diff --git a/src/renderer/src/components/billing/team/team-invite-impact-copy.test.ts b/src/renderer/src/components/billing/team/team-invite-impact-copy.test.ts deleted file mode 100644 index e642e810f4c..00000000000 --- a/src/renderer/src/components/billing/team/team-invite-impact-copy.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { OrcaBillingEntitlement } from '../../../../../shared/orca-billing' -import type { Translate } from '@/i18n/use-translate' -import { sampleTeam } from '../plan-billing-fixtures' -import { formatCents } from '../plans/plans-pricing' -import { inviteSeatImpactLine } from './team-invite-impact-copy' -import { inviteSeatImpact } from './team-seat-math' - -const DAY = 86_400_000 - -// Mirrors i18next's `_one` plural pick so both forms are checked. -const t: Translate = (_key, fallback, options) => { - const one = options?.count === 1 ? options.defaultValue_one : undefined - const text = typeof one === 'string' ? one : fallback - return text.replace(/\{\{(\w+)\}\}/g, (_, name: string) => String(options?.[name] ?? '')) -} - -function team(overrides: Partial = {}): OrcaBillingEntitlement { - return { - ...sampleTeam, - status: 'active', - interval: 'month', - seats: 3, - seatChange: null, - cancelAtPeriodEnd: false, - period: { start: 0, end: 30 * DAY }, - ...overrides - } -} - -function line( - entitlement: OrcaBillingEntitlement | null, - counts: { members: number; pendingInvites: number; newInvites: number } -): string { - const impact = inviteSeatImpact({ entitlement, ...counts, now: 0 }) - return inviteSeatImpactLine(impact, counts.newInvites, 'en-US', t) -} - -const monthly = sampleTeam.offers?.team.monthlyPriceCentsPerSeat ?? 0 - -describe('invite seat impact line', () => { - it('says how many seats are open before anything is typed', () => { - expect(line(team({ seats: 5 }), { members: 2, pendingInvites: 1, newInvites: 0 })).toBe( - 'Each invite uses a seat once accepted. You have 2 open seats.' - ) - expect(line(team({ seats: 3 }), { members: 3, pendingInvites: 0, newInvites: 0 })).toBe( - 'Each invite uses a seat once accepted. You have no open seats.' - ) - }) - - it('adds nothing while the invites fit in open seats', () => { - expect(line(team({ seats: 4 }), { members: 3, pendingInvites: 0, newInvites: 1 })).toBe( - 'Each invite uses a seat once accepted. You have 1 open seat, so no seats will be added.' - ) - }) - - it('prices the added seats from billing, per period, prorated', () => { - const total = formatCents(2 * monthly, 'en-US') - expect(line(team({ seats: 3 }), { members: 3, pendingInvites: 0, newInvites: 2 })).toBe( - `Each invite uses a seat once accepted. You have no open seats, so 2 more seats (${total}/mo, prorated) will be added.` - ) - }) - - it('counts open seats first, then pluralizes the added ones apart', () => { - const one = formatCents(monthly, 'en-US') - expect(line(team({ seats: 4 }), { members: 3, pendingInvites: 0, newInvites: 2 })).toBe( - `Each invite uses a seat once accepted. You have 1 open seat, so 1 more seat (${one}/mo, prorated) will be added.` - ) - }) - - it('names the yearly price on annual plans', () => { - expect( - line(team({ seats: 1, interval: 'year' }), { members: 1, pendingInvites: 0, newInvites: 1 }) - ).toMatch(/\/yr, prorated\) will be added\.$/) - }) - - it('never shows a price billing did not send', () => { - expect( - line(team({ seats: 3, offers: null }), { members: 3, pendingInvites: 0, newInvites: 2 }) - ).toBe( - 'Each invite uses a seat once accepted. You have no open seats, so 2 more seats will be added, prorated.' - ) - }) - - it('keeps to the rule when billing is off or unreachable', () => { - expect(line(null, { members: 3, pendingInvites: 0, newInvites: 2 })).toBe( - 'Each invite uses a seat once accepted.' - ) - }) - - it('says invites are free before the team has a plan', () => { - expect( - line(team({ plan: null, status: 'none' }), { members: 1, pendingInvites: 0, newInvites: 1 }) - ).toBe('Invites are free. Seats are billed once your team picks a plan.') - }) -}) diff --git a/src/renderer/src/components/billing/team/team-invite-impact-copy.ts b/src/renderer/src/components/billing/team/team-invite-impact-copy.ts deleted file mode 100644 index c51d6e50e79..00000000000 --- a/src/renderer/src/components/billing/team/team-invite-impact-copy.ts +++ /dev/null @@ -1,86 +0,0 @@ -// The one line under "Invite teammates" that says what the invites do to seats. Billing's own -// numbers only (ENTITLEMENTS-C): no price is shown when billing didn't send one. -import type { Translate } from '@/i18n/use-translate' -import { formatCents } from '../plans/plans-pricing' -import type { InviteSeatImpact } from './team-seat-math' - -/** "You have N open seats", its own key so the seat count and the added count pluralize apart. */ -function openSeatsClause(open: number, t: Translate): string { - return open === 0 - ? t('auto.components.billing.team.openSeatsNone', 'You have no open seats') - : t('auto.components.billing.team.openSeatsClause', 'You have {{count}} open seats', { - count: open, - defaultValue_one: 'You have {{count}} open seat' - }) -} - -export function inviteSeatImpactLine( - impact: InviteSeatImpact, - invites: number, - locale: string, - t: Translate -): string { - switch (impact.kind) { - case 'unknown': - return t( - 'auto.components.billing.team.inviteSeatUnknown', - 'Each invite uses a seat once accepted.' - ) - case 'no-plan': - return t( - 'auto.components.billing.team.inviteNoPlan', - 'Invites are free. Seats are billed once your team picks a plan.' - ) - case 'covered': - return invites === 0 - ? t( - 'auto.components.billing.team.inviteSeatOpen', - 'Each invite uses a seat once accepted. {{openSeats}}.', - { - openSeats: openSeatsClause(impact.freeSeats, t) - } - ) - : t( - 'auto.components.billing.team.inviteSeatCovered', - 'Each invite uses a seat once accepted. {{openSeats}}, so no seats will be added.', - { openSeats: openSeatsClause(impact.freeSeats, t) } - ) - case 'adds-seats-unpriced': - return t( - 'auto.components.billing.team.inviteSeatAddsUnpriced', - 'Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seats will be added, prorated.', - { - openSeats: openSeatsClause(impact.freeSeats, t), - count: impact.addedSeats, - defaultValue_one: - 'Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seat will be added, prorated.' - } - ) - case 'adds-seats': { - const price = formatCents(impact.addedSeats * impact.perSeatPeriodCents, locale) - return impact.interval === 'year' - ? t( - 'auto.components.billing.team.inviteSeatAddsYear', - 'Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seats ({{price}}/yr, prorated) will be added.', - { - openSeats: openSeatsClause(impact.freeSeats, t), - count: impact.addedSeats, - price, - defaultValue_one: - 'Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seat ({{price}}/yr, prorated) will be added.' - } - ) - : t( - 'auto.components.billing.team.inviteSeatAddsMonth', - 'Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seats ({{price}}/mo, prorated) will be added.', - { - openSeats: openSeatsClause(impact.freeSeats, t), - count: impact.addedSeats, - price, - defaultValue_one: - 'Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seat ({{price}}/mo, prorated) will be added.' - } - ) - } - } -} diff --git a/src/renderer/src/components/billing/team/team-seat-math.test.ts b/src/renderer/src/components/billing/team/team-seat-math.test.ts index 94e18f86817..6c6919b83d0 100644 --- a/src/renderer/src/components/billing/team/team-seat-math.test.ts +++ b/src/renderer/src/components/billing/team/team-seat-math.test.ts @@ -1,17 +1,9 @@ import { describe, expect, it } from 'vitest' import type { OrcaBillingEntitlement } from '../../../../../shared/orca-billing' import { sampleTeam as activeTeamEntitlement } from '../plan-billing-fixtures' -import { inviteSeatImpact, nextInvoiceEstimate, proratedSeatCents } from './team-seat-math' +import { nextInvoiceEstimate } from './team-seat-math' const DAY = 86_400_000 -const noPlanEntitlement: OrcaBillingEntitlement = { - ...activeTeamEntitlement, - plan: null, - planLabel: null, - status: 'none', - interval: null, - period: null -} function team(overrides: Partial = {}): OrcaBillingEntitlement { return { @@ -25,73 +17,6 @@ function team(overrides: Partial = {}): OrcaBillingEntit } } -describe('proratedSeatCents', () => { - it('charges the remaining share of the period', () => { - const pricing = { interval: 'month' as const, perSeatPeriodCents: 5000 } - expect(proratedSeatCents(pricing, { start: 0, end: 30 * DAY }, 15 * DAY)).toBe(2500) - expect(proratedSeatCents(pricing, { start: 0, end: 30 * DAY }, 31 * DAY)).toBe(0) - expect(proratedSeatCents(pricing, { start: 0, end: 30 * DAY }, -DAY)).toBe(5000) - }) -}) - -describe('inviteSeatImpact', () => { - it('reports no plan when unsubscribed', () => { - expect( - inviteSeatImpact({ - entitlement: noPlanEntitlement, - members: 1, - pendingInvites: 0, - newInvites: 2, - now: 0 - }) - ).toEqual({ kind: 'no-plan' }) - }) - - it('uses seats already paid this period first (peak seats)', () => { - // 3 paid, 1 member: two free seats, so two invites cost nothing. - expect( - inviteSeatImpact({ - entitlement: team(), - members: 1, - pendingInvites: 0, - newInvites: 2, - now: 0 - }) - ).toEqual({ kind: 'covered', freeSeats: 2 }) - }) - - it('counts pending invites against free seats before new ones', () => { - const impact = inviteSeatImpact({ - entitlement: team(), - members: 2, - pendingInvites: 1, - newInvites: 2, - now: 15 * DAY - }) - expect(impact).toMatchObject({ - kind: 'adds-seats', - addedSeats: 2, - coveredSeats: 0, - proratedCentsEach: 2500 - }) - }) -}) - -describe('inviteSeatImpact without prices', () => { - it('still says seats are added (never "covered") when pricing is unknown', () => { - const unpriced = team({ offers: null }) - expect( - inviteSeatImpact({ - entitlement: unpriced, - members: 3, - pendingInvites: 0, - newInvites: 2, - now: 0 - }) - ).toEqual({ kind: 'adds-seats-unpriced', addedSeats: 2, freeSeats: 0 }) - }) -}) - describe('nextInvoiceEstimate', () => { it('bills the renewal seat count, not the period peak', () => { const estimate = nextInvoiceEstimate( diff --git a/src/renderer/src/components/billing/team/team-seat-math.ts b/src/renderer/src/components/billing/team/team-seat-math.ts index 928ac36cc41..c4465884cd7 100644 --- a/src/renderer/src/components/billing/team/team-seat-math.ts +++ b/src/renderer/src/components/billing/team/team-seat-math.ts @@ -1,7 +1,6 @@ -// Seat money shown on the Team page. Display estimates only: Stripe computes the real invoice. -// Mirrors the billing design: seats billed = the period's peak membership; a member who joins -// above the paid peak is charged now, prorated; removals only lower seats at renewal; pending -// invites cost nothing until accepted. +// Seat money shown in Plan & billing. Display estimates only: Stripe computes the real invoice. +// Mirrors the billing design: seats billed = the period's peak membership; removals only lower +// seats at renewal. import type { OrcaBillingEntitlement, OrcaBillingInterval @@ -33,80 +32,6 @@ export function isActivePlan(entitlement: OrcaBillingEntitlement | null): boolea ) } -/** One seat for the rest of the current period, rounded to the cent like Stripe's proration. */ -export function proratedSeatCents( - pricing: SeatPricing, - period: { start: number; end: number }, - now: number -): number { - const length = period.end - period.start - if (length <= 0) { - return pricing.perSeatPeriodCents - } - const remaining = Math.min(Math.max(period.end - now, 0), length) - return Math.round((pricing.perSeatPeriodCents * remaining) / length) -} - -export type InviteSeatImpact = - /** Billing didn't answer (disabled in this build, signed out or failed): no seat facts to show. */ - | { kind: 'unknown' } - | { kind: 'no-plan' } - /** Every new invite fits in seats already paid this period. */ - | { kind: 'covered'; freeSeats: number } - /** Seats will be added but the price or period isn't known (older billing service). */ - | { kind: 'adds-seats-unpriced'; addedSeats: number; freeSeats: number } - | { - kind: 'adds-seats' - /** Paid seats not taken by a member or a pending invite. */ - freeSeats: number - /** How many of the new invites would add a seat when accepted. */ - addedSeats: number - /** Seats already paid but unused that the first acceptances take for free. */ - coveredSeats: number - proratedCentsEach: number - perSeatPeriodCents: number - interval: OrcaBillingInterval - } - -/** - * What inviting `newInvites` people costs if they all accept now. Pending invites are assumed to - * accept first (conservative: never promises a free seat that an earlier invite will take). - */ -export function inviteSeatImpact(args: { - entitlement: OrcaBillingEntitlement | null - members: number - pendingInvites: number - newInvites: number - now: number -}): InviteSeatImpact { - const { entitlement } = args - if (!entitlement) { - return { kind: 'unknown' } - } - if (!isActivePlan(entitlement)) { - return { kind: 'no-plan' } - } - const pricing = seatPricing(entitlement) - const paidSeats = Math.max(entitlement.seats, 1) - const freeSeats = Math.max(0, paidSeats - args.members - args.pendingInvites) - const addedSeats = Math.max(0, args.newInvites - freeSeats) - if (addedSeats === 0) { - return { kind: 'covered', freeSeats } - } - if (!pricing || !entitlement.period) { - return { kind: 'adds-seats-unpriced', addedSeats, freeSeats } - } - return { - kind: 'adds-seats', - freeSeats, - addedSeats, - coveredSeats: Math.min(freeSeats, args.newInvites), - proratedCentsEach: proratedSeatCents(pricing, entitlement.period, args.now), - perSeatPeriodCents: pricing.perSeatPeriodCents, - interval: pricing.interval - } -} - export type NextInvoiceEstimate = { seats: number amountCents: number diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index c8cdcaf698d..689c39c5ed9 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -242,18 +242,10 @@ "blockedWorkspaces_one": "{{count}} cloud workspace still exists. Delete it first.", "blockedWorkspaces_other": "{{count}} cloud workspaces still exist. Delete them first." }, - "inviteSeatAddsMonth_one": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seat ({{price}}/mo, prorated) will be added.", - "inviteSeatAddsMonth_other": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seats ({{price}}/mo, prorated) will be added.", - "inviteSeatAddsUnpriced_one": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seat will be added, prorated.", - "inviteSeatAddsUnpriced_other": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seats will be added, prorated.", - "inviteSeatAddsYear_one": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seat ({{price}}/yr, prorated) will be added.", - "inviteSeatAddsYear_other": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seats ({{price}}/yr, prorated) will be added.", "invitedCount_one": "{{count}} invited", "invitedCount_other": "{{count}} invited", "memberCount_one": "{{count}} member", "memberCount_other": "{{count}} members", - "openSeatsClause_one": "You have {{count}} open seat", - "openSeatsClause_other": "You have {{count}} open seats", "seatCount_one": "{{count}} seat", "seatCount_other": "{{count}} seats", "sendInvites_one": "Send {{count}} invite", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 8ef21b2d308..4133e13111b 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -21254,7 +21254,6 @@ "dismiss": "Dismiss", "enterEmail": "Enter a teammate’s email", "inviteLabel": "Invite teammates", - "inviteNoPlan": "Invites are free. Seats are billed once your team picks a plan.", "invitePlaceholder2": "alex@company.com, sam@company.com", "inviteSentBody": "They join, and take a seat, when they accept.", "inviteSentMany": "{{value0}} invites sent", @@ -21310,7 +21309,6 @@ "createdNotSwitched": "{{value0}} was created, but Orca couldn’t switch to it.", "invitedCount_one": "{{count}} invited", "invitedCount_other": "{{count}} invited", - "invitePaymentFailed2": "New seats are charged once the failed payment is fixed.", "memberCount_one": "{{count}} member", "memberCount_other": "{{count}} members", "seatCount_one": "{{count}} seat", @@ -21351,22 +21349,10 @@ "deletedToast2": "{{value0}} was deleted" }, "leaveLastOwnerOrDelete": "You’re the only owner. Make someone else an owner first, or delete the team.", - "planBillingLink": "Plan & billing", "perSeat": "Per seat", "perSeatMonthValue": "{{value0}}/mo", "seatRulesShort": "New members above your paid seats are charged now, prorated. Removed seats stay paid until renewal.", - "openSeatsNone": "You have no open seats", - "openSeatsClause_one": "You have {{count}} open seat", - "openSeatsClause_other": "You have {{count}} open seats", - "inviteSeatUnknown": "Each invite uses a seat once accepted.", - "inviteSeatOpen": "Each invite uses a seat once accepted. {{openSeats}}.", - "inviteSeatCovered": "Each invite uses a seat once accepted. {{openSeats}}, so no seats will be added.", - "inviteSeatAddsUnpriced_one": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seat will be added, prorated.", - "inviteSeatAddsUnpriced_other": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seats will be added, prorated.", - "inviteSeatAddsYear_one": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seat ({{price}}/yr, prorated) will be added.", - "inviteSeatAddsYear_other": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seats ({{price}}/yr, prorated) will be added.", - "inviteSeatAddsMonth_one": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seat ({{price}}/mo, prorated) will be added.", - "inviteSeatAddsMonth_other": "Each invite uses a seat once accepted. {{openSeats}}, so {{count}} more seats ({{price}}/mo, prorated) will be added." + "inviteSeatRule": "Each invite uses a seat once accepted." }, "manageBilling": "Manage billing", "comparePlans": "Compare plans", From e7e7f1f723352ec1478b5bd54c7e506d3bbfee48 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 12 Nov 2026 23:42:28 -0800 Subject: [PATCH 45/50] fix(cloud): PAIRED-ARCHIVE-STALL a paired workspace's host-side Sleep is bounded, so the control-plane suspend and Archive still run (#636) n40b: a paired Archive never reached the server; its Sleep's host-side step (terminals over the paired connection) hung, so neither the control-plane suspend nor the archive ran. With a paired target in the batch, that step now times out after 30s: paired targets get no outcome (their sandbox is still slept by the control plane, and an Archive waiting on this Sleep proceeds), other targets report the timeout as a failure. Batches without a paired target are unchanged. --- .../sleep-worktree-flow-paired-copy.test.ts | 40 ++++++++++- .../sleep/sleep-worktree-flow.ts | 67 ++++++++++++++++--- 2 files changed, 94 insertions(+), 13 deletions(-) diff --git a/src/renderer/src/components/sidebar/workspace-actions/sleep/sleep-worktree-flow-paired-copy.test.ts b/src/renderer/src/components/sidebar/workspace-actions/sleep/sleep-worktree-flow-paired-copy.test.ts index 2c25f2fb9e1..a99ec607ded 100644 --- a/src/renderer/src/components/sidebar/workspace-actions/sleep/sleep-worktree-flow-paired-copy.test.ts +++ b/src/renderer/src/components/sidebar/workspace-actions/sleep/sleep-worktree-flow-paired-copy.test.ts @@ -9,7 +9,8 @@ const mocks = vi.hoisted(() => ({ vi.mock('sonner', () => ({ toast: { warning: vi.fn(), error: vi.fn() } })) vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } })) vi.mock('@/lib/orca-cloud/paired-cloud-sleep', () => ({ - sleepPairedCloudSandboxes: mocks.sleepPairedCloudSandboxes + sleepPairedCloudSandboxes: mocks.sleepPairedCloudSandboxes, + isPairedCloudSleepTarget: (id: string) => id === 'repo::/workspace/orca-sandbox-paired' })) vi.mock('@/lib/orca-cloud/cloud-sleep-failure-toast', () => ({ toastCloudSleepFailure: () => true @@ -23,7 +24,7 @@ vi.mock('./workspace-sleep-target', () => ({ import { seedCloudWorkspaceSnapshot } from '@/lib/orca-cloud/orca-cloud-workspace-store' import { resetWorktreeSleepViewForTests, sleepCopySave } from '@/lib/worktree-sleep-view' -import { runSleepWorktrees } from './sleep-worktree-flow' +import { PAIRED_HOST_SLEEP_TIMEOUT_MS, runSleepWorktrees } from './sleep-worktree-flow' const T0 = 1_800_000_000_000 const PAIRED = 'repo::/workspace/orca-sandbox-paired' @@ -66,3 +67,38 @@ describe('runSleepWorktrees on a paired cloud workspace', () => { expect(sleepCopySave(PAIRED)).toBeNull() }) }) + +describe('a paired host-side Sleep that never finishes (PAIRED-ARCHIVE-STALL, n40b)', () => { + function hangHostSleep(): void { + window.api.worktrees.sleep = () => new Promise(() => undefined) + } + + it('is left behind after the timeout: the control plane still sleeps the paired sandbox', async () => { + hangHostSleep() + const done = runSleepWorktrees([PAIRED]) + await vi.advanceTimersByTimeAsync(PAIRED_HOST_SLEEP_TIMEOUT_MS) + const results = await done + expect(mocks.sleepPairedCloudSandboxes).toHaveBeenCalledWith([PAIRED]) + // Nothing reported as not slept, so an Archive waiting on this Sleep goes ahead. + expect(results.every((result) => result.completed)).toBe(true) + }) + + it('a non-paired workspace in the same batch reports the timeout as a failure', async () => { + hangHostSleep() + const done = runSleepWorktrees([PAIRED, LOCAL]) + await vi.advanceTimersByTimeAsync(PAIRED_HOST_SLEEP_TIMEOUT_MS) + await done + // The local row was not slept: it is not handed to the paired sleep. + expect(mocks.sleepPairedCloudSandboxes).toHaveBeenCalledWith([PAIRED]) + }) + + it('without a paired target nothing is bounded: an owned or local Sleep waits as before', async () => { + let finished = false + hangHostSleep() + void runSleepWorktrees([LOCAL]).then(() => { + finished = true + }) + await vi.advanceTimersByTimeAsync(PAIRED_HOST_SLEEP_TIMEOUT_MS * 2) + expect(finished).toBe(false) + }) +}) diff --git a/src/renderer/src/components/sidebar/workspace-actions/sleep/sleep-worktree-flow.ts b/src/renderer/src/components/sidebar/workspace-actions/sleep/sleep-worktree-flow.ts index 513d96da0ac..66d38cfb72a 100644 --- a/src/renderer/src/components/sidebar/workspace-actions/sleep/sleep-worktree-flow.ts +++ b/src/renderer/src/components/sidebar/workspace-actions/sleep/sleep-worktree-flow.ts @@ -1,6 +1,9 @@ import { toast } from 'sonner' import { toastCloudSleepFailure } from '@/lib/orca-cloud/cloud-sleep-failure-toast' -import { sleepPairedCloudSandboxes } from '@/lib/orca-cloud/paired-cloud-sleep' +import { + isPairedCloudSleepTarget, + sleepPairedCloudSandboxes +} from '@/lib/orca-cloud/paired-cloud-sleep' import { isPairedCloudWorkspace } from '@/lib/orca-cloud/paired-cloud-wake' import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' @@ -185,6 +188,45 @@ export type WorktreeSleepOutcome = { } /** The sleep without the toast, for callers that report failures themselves. */ +/** PAIRED-ARCHIVE-STALL: how long a PAIRED workspace's host-side Sleep may take before it is left behind. */ +export const PAIRED_HOST_SLEEP_TIMEOUT_MS = 30_000 + +/** + * A paired workspace's host-side Sleep (its terminals, over the paired connection) can hang when that + * connection is slow or reconnecting; then neither the control-plane suspend (what stops billing) nor + * an Archive waiting on this Sleep ever ran (n40b). With a paired target in the batch, the host step + * is bounded: past the timeout a paired target gets no outcome (its sandbox is still put to sleep by + * the control plane next), and any other target reports the timeout as a failure. + */ +async function boundPairedHostSleep( + worktreeIds: readonly string[], + call: Promise +): Promise { + if (!worktreeIds.some((id) => isPairedCloudSleepTarget(id))) { + return call + } + let timer: ReturnType | undefined + const timedOut = new Promise((resolve) => { + timer = setTimeout(() => resolve(null), PAIRED_HOST_SLEEP_TIMEOUT_MS) + }) + const outcome = await Promise.race([call, timedOut]) + clearTimeout(timer) + if (outcome) { + return outcome + } + console.error( + '[sleep-worktree] host-side sleep timed out; paired sandboxes sleep via the control plane' + ) + return worktreeIds + .filter((id) => !isPairedCloudSleepTarget(id)) + .map((worktreeId) => ({ + worktreeId, + failure: 'The host did not finish putting this workspace to sleep in time.', + chatFailed: false, + receipts: [] + })) +} + export async function sleepWorktreesReportingFailures( requestedWorktreeIds: readonly string[], // Why: only the user's Sleep hides its chats; a project move pauses through this core too. @@ -216,16 +258,19 @@ export async function sleepWorktreesReportingFailures( }) const outcomes = targets.length > 0 - ? await window.api.worktrees - .sleep({ targets, markChatsAsleep: options.markChatsAsleep === true }) - .catch((error: unknown): WorkspaceSleepOutcome[] => - targets.map(({ worktreeId }) => ({ - worktreeId, - failure: error instanceof Error ? error.message : String(error), - chatFailed: false, - receipts: [] - })) - ) + ? await boundPairedHostSleep( + targets.map(({ worktreeId }) => worktreeId), + window.api.worktrees + .sleep({ targets, markChatsAsleep: options.markChatsAsleep === true }) + .catch((error: unknown): WorkspaceSleepOutcome[] => + targets.map(({ worktreeId }) => ({ + worktreeId, + failure: error instanceof Error ? error.message : String(error), + chatFailed: false, + receipts: [] + })) + ) + ) : [] const results: WorkspaceSleepResult[] = [] const vmStillRunningIds: string[] = [] From bd8e7002cbf706ccb16e92c59bc9b645352bee25 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 21:31:26 -0800 Subject: [PATCH 46/50] fix(cloud-create): GitHub-only creates get a sidebar row and a real Cancel (GH-CREATE-NO-ROW, GH-CREATE-CANCEL) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NO-ROW: a create with no project here yet (GitHub-only Orca Cloud) now has its pending row atop the list when grouped by project too (it got a blank 'repo:' section before). While it opens, openCloudWorkspace reports the server it pairs to (onHost); the sidebar hides that server's rows until the hand-off removes the pending row, both in one store update, so the workspace never shows twice. CANCEL: createSandboxWithProgress (owned and GitHub-only creates) deletes what a cancel leaves: cancelled in flight, it replays the create by its Idempotency-Key and deletes the sandbox the control plane hands back (reclaimCancelledCreate, retrying while the first is still in progress); cancelled just as it answered, it deletes it. A cancelled clone already deleted itself. The GitHub runner deletes the sandbox when Cancel lands before or during Open workspace (which also drops this computer's pairing), and cancel's 'Deleting the sandbox…' waits for it. Copy link moves to cloud-workspace-share-link.ts (re-exported) to keep open-cloud-workspace.ts within max-lines. --- .../cloud-create-from-github.ts | 5 - .../hosted-sandbox-create-reclaim.test.ts | 105 ++++++++++++++++ .../hosted-sandbox-create-reclaim.ts | 64 ++++++++++ .../hosted-sandbox/hosted-sandbox-driver.ts | 38 ++++-- .../cloud-workspace-share-link.ts | 68 ++++++++++ .../cloud-workspaces/open-cloud-workspace.ts | 105 +++++----------- .../grouping/projectless-pending-row.test.ts | 51 ++++++++ .../rows/PendingWorktreeRowGitHub.fixture.tsx | 62 ++++++++++ ...cloud-workspace-from-github.cancel.test.ts | 117 ++++++++++++++++++ .../start-cloud-workspace-from-github.ts | 41 +++++- .../use-sidebar-hidden-checkouts.test.ts | 23 ++++ .../use-sidebar-hidden-checkouts.ts | 16 ++- .../creation/pending-worktree-creation.ts | 2 + .../create/pending-worktree-creation.ts | 8 +- .../worktrees/worktree-slice-contract.ts | 1 + src/shared/sidebar-list/sidebar-row-plan.ts | 6 +- src/shared/sidebar-list/worktree-grouping.ts | 3 +- 17 files changed, 608 insertions(+), 107 deletions(-) create mode 100644 src/main/hosted-sandbox/hosted-sandbox-create-reclaim.test.ts create mode 100644 src/main/hosted-sandbox/hosted-sandbox-create-reclaim.ts create mode 100644 src/renderer/src/components/cloud-workspaces/cloud-workspace-share-link.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/grouping/projectless-pending-row.test.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/rows/PendingWorktreeRowGitHub.fixture.tsx create mode 100644 src/renderer/src/lib/orca-cloud/start-cloud-workspace-from-github.cancel.test.ts diff --git a/src/main/hosted-sandbox/cloud-create-from-github.ts b/src/main/hosted-sandbox/cloud-create-from-github.ts index 1809caa686c..23dca09b989 100644 --- a/src/main/hosted-sandbox/cloud-create-from-github.ts +++ b/src/main/hosted-sandbox/cloud-create-from-github.ts @@ -73,11 +73,6 @@ export async function createCloudWorkspaceFromGitHub( onProgress: (progress) => io.onLog?.(hostedProgressLine(progress)) } ) - if (io.signal?.aborted) { - // Cancelled just as it answered: nothing will open it, so it must not keep running (and billing). - await client.destroy(ready.sandbox.id).catch(() => undefined) - return { ok: false, code: 'failed', message: 'Cancelled.' } - } return { ok: true, value: { sandboxId: ready.sandbox.id } } } catch (error) { const result = hostedCreateFailure(error) diff --git a/src/main/hosted-sandbox/hosted-sandbox-create-reclaim.test.ts b/src/main/hosted-sandbox/hosted-sandbox-create-reclaim.test.ts new file mode 100644 index 00000000000..b5686f8a2ee --- /dev/null +++ b/src/main/hosted-sandbox/hosted-sandbox-create-reclaim.test.ts @@ -0,0 +1,105 @@ +// GH-CREATE-CANCEL: a create cancelled in flight never leaves a sandbox running, for the owned +// create and the GitHub-only create alike (both use createSandboxWithProgress). +import { describe, expect, it, vi } from 'vitest' +import { EnvironmentProviderError } from '../environment-providers/environment-provider-driver' +import { HostedEntitlementDeniedError } from './hosted-environment-provider' +import { reclaimCancelledCreate } from './hosted-sandbox-create-reclaim' +import { createSandboxWithProgress } from './hosted-sandbox-driver' + +const READY = { + sandbox: { + id: 'sbx_1', + workspaceId: 'web-1', + status: 'running' as const, + machineSize: 'standard' as const, + region: 'iad1', + session: null, + createdAt: 1 + }, + connection: { type: 'orca-server' as const, pairingCode: 'code', projectRoot: '/work' } +} +const BODY = { workspaceId: 'web-1', machineSize: 'standard' as const } +const quiet = { waitMs: 0, log: () => {} } + +function client() { + return { + create: vi.fn(async () => READY), + bootPlan: vi.fn(async () => 'clone' as const), + cloneStatus: vi.fn(async () => ({ state: 'done' as const })), + destroy: vi.fn(async (id: string) => ({ id, status: 'destroyed' as const })) + } +} + +describe('reclaimCancelledCreate', () => { + it('replays the create by its key and deletes the sandbox it gets back', async () => { + const api = client() + expect(await reclaimCancelledCreate(api, BODY, 'create:k1', quiet)).toBe(true) + expect(api.create).toHaveBeenCalledWith(BODY, undefined, 'create:k1') + expect(api.destroy).toHaveBeenCalledWith('sbx_1') + }) + + it('waits while the first create is still in progress (409), then deletes', async () => { + const api = client() + api.create + .mockRejectedValueOnce(new EnvironmentProviderError('conflict', 'idempotency_in_progress')) + .mockRejectedValueOnce(new EnvironmentProviderError('unavailable', 'try later')) + expect(await reclaimCancelledCreate(api, BODY, 'create:k1', quiet)).toBe(true) + expect(api.create).toHaveBeenCalledTimes(3) + expect(api.destroy).toHaveBeenCalledWith('sbx_1') + }) + + it('a refused replay means the cancelled create made nothing either', async () => { + const api = client() + api.create.mockRejectedValueOnce(new HostedEntitlementDeniedError('denied', 'no_subscription')) + expect(await reclaimCancelledCreate(api, BODY, 'create:k1', quiet)).toBe(true) + expect(api.destroy).not.toHaveBeenCalled() + }) + + it('gives up (false) when it can never tell', async () => { + const api = client() + api.create.mockRejectedValue(new EnvironmentProviderError('unavailable', 'down')) + expect(await reclaimCancelledCreate(api, BODY, 'create:k1', { ...quiet, attempts: 3 })).toBe( + false + ) + }) +}) + +describe('createSandboxWithProgress cancel', () => { + const spec = { workspaceId: 'web-1', machineSize: 'standard', idempotencyKey: 'create:k1' } + + it('cancelled while the create was in flight: reclaims by key, then reports the cancel', async () => { + const api = client() + const controller = new AbortController() + api.create.mockImplementationOnce(async () => { + controller.abort() + throw new EnvironmentProviderError('aborted', 'Cancelled.') + }) + await expect( + createSandboxWithProgress(api, spec, { signal: controller.signal }) + ).rejects.toMatchObject({ code: 'aborted' }) + // The replay (no signal, same key) found the sandbox and deleted it. + expect(api.create).toHaveBeenLastCalledWith(expect.anything(), undefined, 'create:k1') + expect(api.destroy).toHaveBeenCalledWith('sbx_1') + }) + + it('cancelled just as the create answered: deletes the sandbox and reports the cancel', async () => { + const api = client() + const controller = new AbortController() + api.create.mockImplementationOnce(async () => { + controller.abort() + return READY + }) + await expect( + createSandboxWithProgress(api, spec, { signal: controller.signal }) + ).rejects.toMatchObject({ code: 'aborted' }) + expect(api.destroy).toHaveBeenCalledWith('sbx_1') + expect(api.create).toHaveBeenCalledTimes(1) + }) + + it('not cancelled: no replay, no delete', async () => { + const api = client() + await expect(createSandboxWithProgress(api, spec, {})).resolves.toBe(READY) + expect(api.create).toHaveBeenCalledTimes(1) + expect(api.destroy).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/hosted-sandbox/hosted-sandbox-create-reclaim.ts b/src/main/hosted-sandbox/hosted-sandbox-create-reclaim.ts new file mode 100644 index 00000000000..343360ff400 --- /dev/null +++ b/src/main/hosted-sandbox/hosted-sandbox-create-reclaim.ts @@ -0,0 +1,64 @@ +// A create cancelled while its request was in flight may still have made a sandbox on the control +// plane, and a sandbox bills (GH-CREATE-CANCEL). The control plane keys creates by Idempotency-Key +// and body: sending the same create again hands back the sandbox the first one made (or makes one, if +// the first never arrived), and that sandbox is deleted. Shared by the owned create (the recipe) and +// the GitHub-only create, which both go through createSandboxWithProgress. +import { isEnvironmentProviderError } from '../environment-providers/environment-provider-driver' +import type { HostedSandboxControlPlaneClient } from './hosted-sandbox-control-plane-client' + +type CreateBody = Parameters[0] + +const RECLAIM_ATTEMPTS = 8 +const RECLAIM_WAIT_MS = 5_000 + +/** The first create may still be running (409 in progress) or the service briefly unreachable. */ +function worthRetrying(error: unknown): boolean { + return ( + isEnvironmentProviderError(error, 'conflict') || + isEnvironmentProviderError(error, 'unavailable') || + isEnvironmentProviderError(error, 'timeout') || + !isEnvironmentProviderError(error) + ) +} + +/** + * Deletes whatever the cancelled create made. True once it is gone (or the control plane refused + * the create, so nothing was made); false when it still couldn't tell after every attempt. + */ +export async function reclaimCancelledCreate( + client: Pick, + body: CreateBody, + idempotencyKey: string, + { + attempts = RECLAIM_ATTEMPTS, + waitMs = RECLAIM_WAIT_MS, + log = (message: string) => console.warn(message) + }: { attempts?: number; waitMs?: number; log?: (message: string) => void } = {} +): Promise { + for (let attempt = 1; attempt <= attempts; attempt += 1) { + let sandboxId: string + try { + // No signal: this request is the cleanup and must not be cancelled with the create. + sandboxId = (await client.create(body, undefined, idempotencyKey)).sandbox.id + } catch (error) { + if (!worthRetrying(error)) { + // Refused (billing, sign-in, bad request): the cancelled create made nothing either. + return true + } + log( + `[orca-cloud] cancelled create: replay ${attempt}/${attempts} failed (${isEnvironmentProviderError(error) ? error.code : 'error'})` + ) + await new Promise((resolve) => setTimeout(resolve, waitMs)) + continue + } + try { + await client.destroy(sandboxId) + log(`[orca-cloud] cancelled create: deleted sandbox ${sandboxId}`) + return true + } catch { + await new Promise((resolve) => setTimeout(resolve, waitMs)) + } + } + log('[orca-cloud] cancelled create: could not confirm the sandbox was deleted') + return false +} diff --git a/src/main/hosted-sandbox/hosted-sandbox-driver.ts b/src/main/hosted-sandbox/hosted-sandbox-driver.ts index cb0ccc76f5c..088f92f5b89 100644 --- a/src/main/hosted-sandbox/hosted-sandbox-driver.ts +++ b/src/main/hosted-sandbox/hosted-sandbox-driver.ts @@ -25,6 +25,7 @@ import type { } from './hosted-environment-provider' import type { HostedSandboxControlPlaneClient } from './hosted-sandbox-control-plane-client' import { takeSuspendReason } from './idle-sleep-requests' +import { reclaimCancelledCreate } from './hosted-sandbox-create-reclaim' export const HOSTED_SANDBOX_DRIVER_ID = 'orca-cloud' @@ -144,21 +145,34 @@ export async function createSandboxWithProgress( ) .catch(() => undefined) } - const ready = await client.create( - { - workspaceId: spec.workspaceId, - machineSize, - // Named rows: Home shows the workspace's name, never an untitled sandbox (PC-8). - ...(spec.workspaceName?.trim() ? { title: spec.workspaceName.trim().slice(0, 120) } : {}), - ...(spec.region ? { region: spec.region } : {}), - ...(spec.repo ? { repo: spec.repo } : {}) - }, - ctx?.signal, - spec.idempotencyKey - ) + const body = { + workspaceId: spec.workspaceId, + machineSize, + // Named rows: Home shows the workspace's name, never an untitled sandbox (PC-8). + ...(spec.workspaceName?.trim() ? { title: spec.workspaceName.trim().slice(0, 120) } : {}), + ...(spec.region ? { region: spec.region } : {}), + ...(spec.repo ? { repo: spec.repo } : {}) + } + let ready: HostedSandboxReadyResponse + try { + ready = await client.create(body, ctx?.signal, spec.idempotencyKey) + } catch (error) { + // GH-CREATE-CANCEL: cancelled in flight, the control plane may still have made it: find it by + // its key and delete it before reporting the cancel. + if (ctx?.signal?.aborted && spec.idempotencyKey) { + await reclaimCancelledCreate(client, body, spec.idempotencyKey) + } + throw error + } if (ready.clone) { + // A cancelled, failed or stalled clone deletes the sandbox itself. await waitForClone(client, ready.sandbox.id, ctx, { mode: ready.clone.mode ?? 'clone' }) } + if (ctx?.signal?.aborted) { + // Cancelled just as it answered: nothing will use it, so it must not keep running (and billing). + await client.destroy(ready.sandbox.id).catch(() => undefined) + throw new EnvironmentProviderError('aborted', 'Cancelled.') + } ctx?.onProgress?.({ phase: 'connecting' }) return ready } diff --git a/src/renderer/src/components/cloud-workspaces/cloud-workspace-share-link.ts b/src/renderer/src/components/cloud-workspaces/cloud-workspace-share-link.ts new file mode 100644 index 00000000000..d64be4157e1 --- /dev/null +++ b/src/renderer/src/components/cloud-workspaces/cloud-workspace-share-link.ts @@ -0,0 +1,68 @@ +// A cloud workspace's share link: the org slug + W-number link when known, else the sandbox-id form. +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { cloudWorkspaceShareLink } from '../../../../shared/cloud-workspaces/cloud-workspaces-wire' +import { cloudWorkspaceIdentity, rememberCloudWorkspaces } from './cloud-workspace-identity' + +// How long Copy link waits on the control plane before settling for the sandbox-id link. +const LINK_LOOKUP_MS = 3_000 + +async function lookUpLink(id: string): Promise { + let timer: ReturnType | undefined + try { + const fetched = await Promise.race([ + window.api.cloudWorkspaces.get(id), + new Promise((resolve) => { + timer = setTimeout(() => resolve(null), LINK_LOOKUP_MS) + }) + ]) + if (!fetched?.ok) { + return null + } + rememberCloudWorkspaces([fetched.value]) + return fetched.value.link ?? null + } catch { + return null + } finally { + clearTimeout(timer) + } +} + +/** + * The org slug + W-number link when the control plane gave one, else the sandbox id form, which + * opens for anyone with access. Never rejects: a device that didn't create the workspace (paired, + * offline, signed out) still gets a working link. + */ +export async function shareLinkFor(id: string, chatId?: string | null): Promise { + const link = cloudWorkspaceIdentity(id)?.link ?? (await lookUpLink(id)) + return cloudWorkspaceShareLink({ id, ...(link ? { link } : {}) }, chatId) +} + +export async function copyCloudWorkspaceLink( + id: string, + chatId?: string | null, + options: { quiet?: boolean } = {} +): Promise { + const link = await shareLinkFor(id, chatId) + try { + // Why: main-process clipboard, like every other renderer copy. navigator.clipboard needs a + // focused document with live user activation; after the link lookup (or from the menu or a + // shortcut) Electron rejects it with "Document is not focused". + await window.api.ui.writeClipboardText(link) + } catch (error) { + console.warn('[cloud-workspaces] copy link failed', error) + toast.error( + translate('auto.components.cloudWorkspaces.copyFailed', "Couldn't copy the link. Try again.") + ) + return false + } + if (options.quiet) { + return true + } + toast.success( + chatId + ? translate('auto.components.cloudWorkspaces.chatLinkCopied', 'Chat link copied') + : translate('auto.components.cloudWorkspaces.workspaceLinkCopied', 'Workspace link copied') + ) + return true +} diff --git a/src/renderer/src/components/cloud-workspaces/open-cloud-workspace.ts b/src/renderer/src/components/cloud-workspaces/open-cloud-workspace.ts index e2b376a817e..0ae44d82588 100644 --- a/src/renderer/src/components/cloud-workspaces/open-cloud-workspace.ts +++ b/src/renderer/src/components/cloud-workspaces/open-cloud-workspace.ts @@ -11,8 +11,6 @@ import { activateAndRevealWorktree } from '@/lib/workspace/activation/worktree-a import { refreshRuntimeProjectWorktreesAndLineage } from '@/hooks/runtime-project-refresh-scheduler' import { RuntimeRpcCallError, unwrapRuntimeRpcResult } from '@/runtime/rpc/calls/runtime-rpc-result' import type { CloudWorkspace } from '../../../../shared/cloud-workspaces/cloud-workspaces-wire' -import { cloudWorkspaceShareLink } from '../../../../shared/cloud-workspaces/cloud-workspaces-wire' -import { cloudWorkspaceIdentity, rememberCloudWorkspaces } from './cloud-workspace-identity' import { getWorktreeExecutionHostId, toRuntimeExecutionHostId @@ -66,7 +64,20 @@ function isPairingRejected(error: unknown): boolean { return error instanceof Error && /\b(unauthori[sz]ed|device_revoked)\b/i.test(error.message) } -async function connectAndReveal(environmentId: string, name: string): Promise { +export type OpenCloudWorkspaceOptions = { + /** + * The server this open connects to, before any of its rows load. A GitHub-only create's sheet + * hides that server's rows until it hands off to them, so its pending row never doubles up. + */ + onHost?: (hostId: string) => void +} + +async function connectAndReveal( + environmentId: string, + name: string, + options: OpenCloudWorkspaceOptions = {} +): Promise { + options.onHost?.(toRuntimeExecutionHostId(environmentId)) const environments = await window.api.runtimeEnvironments.list() useAppStore.getState().setRuntimeEnvironments(environments) const environment = environments.find((e) => e.id === environmentId) @@ -147,7 +158,11 @@ async function wakeIfAsleep(workspace: CloudWorkspace): Promise { +async function pairAndReveal( + workspace: CloudWorkspace, + name: string, + options: OpenCloudWorkspaceOptions +): Promise { const api = window.api.cloudWorkspaces const binding = (await api.environmentBindings())[workspace.id] if (binding) { @@ -161,7 +176,7 @@ async function pairAndReveal(workspace: CloudWorkspace, name: string): Promise undefined) - return await connectAndReveal(added.environment.id, name) + return await connectAndReveal(added.environment.id, name, options) } -async function openOnce(workspace: CloudWorkspace): Promise { +async function openOnce( + workspace: CloudWorkspace, + options: OpenCloudWorkspaceOptions +): Promise { const name = cloudWorkspaceTitle(workspace) try { if (workspace.access === 'owner') { @@ -244,7 +262,7 @@ async function openOnce(workspace: CloudWorkspace): Promise { ) } } - return await pairAndReveal(workspace, name) + return await pairAndReveal(workspace, name, options) } catch (error) { // Raw transport/RPC messages are not user copy; keep them for diagnostics only. console.warn('[cloud-workspaces] open failed', error) @@ -262,75 +280,18 @@ async function openOnce(workspace: CloudWorkspace): Promise { // instead of minting parallel grants. const opening = new Map>() -export function openCloudWorkspace(workspace: CloudWorkspace): Promise { +export function openCloudWorkspace( + workspace: CloudWorkspace, + options: OpenCloudWorkspaceOptions = {} +): Promise { const existing = opening.get(workspace.id) if (existing) { return existing } - const attempt = openOnce(workspace).finally(() => opening.delete(workspace.id)) + const attempt = openOnce(workspace, options).finally(() => opening.delete(workspace.id)) opening.set(workspace.id, attempt) return attempt } -// How long Copy link waits on the control plane before settling for the sandbox-id link. -const LINK_LOOKUP_MS = 3_000 - -async function lookUpLink(id: string): Promise { - let timer: ReturnType | undefined - try { - const fetched = await Promise.race([ - window.api.cloudWorkspaces.get(id), - new Promise((resolve) => { - timer = setTimeout(() => resolve(null), LINK_LOOKUP_MS) - }) - ]) - if (!fetched?.ok) { - return null - } - rememberCloudWorkspaces([fetched.value]) - return fetched.value.link ?? null - } catch { - return null - } finally { - clearTimeout(timer) - } -} - -/** - * The org slug + W-number link when the control plane gave one, else the sandbox id form, which - * opens for anyone with access. Never rejects: a device that didn't create the workspace (paired, - * offline, signed out) still gets a working link. - */ -export async function shareLinkFor(id: string, chatId?: string | null): Promise { - const link = cloudWorkspaceIdentity(id)?.link ?? (await lookUpLink(id)) - return cloudWorkspaceShareLink({ id, ...(link ? { link } : {}) }, chatId) -} - -export async function copyCloudWorkspaceLink( - id: string, - chatId?: string | null, - options: { quiet?: boolean } = {} -): Promise { - const link = await shareLinkFor(id, chatId) - try { - // Why: main-process clipboard, like every other renderer copy. navigator.clipboard needs a - // focused document with live user activation; after the link lookup (or from the menu or a - // shortcut) Electron rejects it with "Document is not focused". - await window.api.ui.writeClipboardText(link) - } catch (error) { - console.warn('[cloud-workspaces] copy link failed', error) - toast.error( - translate('auto.components.cloudWorkspaces.copyFailed', "Couldn't copy the link. Try again.") - ) - return false - } - if (options.quiet) { - return true - } - toast.success( - chatId - ? translate('auto.components.cloudWorkspaces.chatLinkCopied', 'Chat link copied') - : translate('auto.components.cloudWorkspaces.workspaceLinkCopied', 'Workspace link copied') - ) - return true -} +// Copy link lives beside it; re-exported so callers keep one import. +export { copyCloudWorkspaceLink, shareLinkFor } from './cloud-workspace-share-link' diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/projectless-pending-row.test.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/projectless-pending-row.test.ts new file mode 100644 index 00000000000..b2bab1da297 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/projectless-pending-row.test.ts @@ -0,0 +1,51 @@ +// GH-CREATE-NO-ROW: a create with no project here yet (a GitHub-only Orca Cloud create) has a +// pending row atop the list when grouped by project, as in every other grouping; a create with a +// project still sits atop its own project. +import { describe, expect, it } from 'vitest' +import { repo, worktree } from './worktree-list-groups-test-fixtures' +import { buildRows } from './build-rows.test-fixture' + +function rows(groupBy: 'repo' | 'none', pending: { creationId: string; repoId: string }[]) { + return buildRows( + groupBy, + [{ ...worktree, lastActivityAt: 1 }], + new Map([[repo.id, repo]]), + null, + new Set(), + undefined, + undefined, + 'manual', + {}, + undefined, + false, + undefined, + [], + new Set(), + new Map(), + new Map(), + pending + ) +} + +describe('pending row for a create with no project yet', () => { + it('by project: sits atop the list, before the first project header', () => { + const list = rows('repo', [{ creationId: 'gh-1', repoId: '' }]) + expect(list[0]).toMatchObject({ type: 'pending-creation', creationId: 'gh-1' }) + expect(list.filter((row) => row.type === 'pending-creation')).toHaveLength(1) + // No blank project header for it (it used to get one, keyed "repo:"). + expect(list.filter((row) => row.type === 'header')).toHaveLength(1) + }) + + it('by project: a create with a project still sits atop that project only', () => { + const list = rows('repo', [{ creationId: 'local-1', repoId: repo.id }]) + const header = list.findIndex((row) => row.type === 'header') + expect(header).toBeGreaterThanOrEqual(0) + expect(list.findIndex((row) => row.type === 'pending-creation')).toBeGreaterThan(header) + expect(list.filter((row) => row.type === 'pending-creation')).toHaveLength(1) + }) + + it('ungrouped: unchanged, every pending row on top', () => { + const list = rows('none', [{ creationId: 'gh-1', repoId: '' }]) + expect(list[0]).toMatchObject({ type: 'pending-creation', creationId: 'gh-1' }) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/PendingWorktreeRowGitHub.fixture.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/PendingWorktreeRowGitHub.fixture.tsx new file mode 100644 index 00000000000..38ef8a2cc50 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/rows/PendingWorktreeRowGitHub.fixture.tsx @@ -0,0 +1,62 @@ +// GH-CREATE-NO-ROW: a GitHub-only Orca Cloud create has a sidebar row while it builds, atop the list +// (it has no project here yet), with the cloud glyph from the start; the paired workspace's rows +// replace it on hand-off. +import { getDefaultSettings } from '../../../../../../shared/constants' +import { defineFixture } from '../../../../component-fixtures/component-fixture' +import { githubCreateRequest } from '@/lib/orca-cloud/start-cloud-workspace-from-github' +import type { PendingWorktreeCreation } from '@/lib/workspace/creation/pending-worktree-creation' +import { PendingWorktreeRow } from './PendingWorktreeRow' + +const T0 = 1_700_000_000_000 + +function creation( + creationId: string, + patch: Partial = {} +): PendingWorktreeCreation { + return { + creationId, + phase: 'provisioning-vm', + status: 'creating', + startedAt: T0, + indeterminate: false, + loaderVisible: true, + provisioningLog: '[orca-cloud] provisioning\n[orca-cloud] plan-clone', + provisioningLogStamps: [T0, T0 + 400], + request: githubCreateRequest({ + repoUrl: 'https://github.com/nwparker/orca-sandbox-push-test', + fullName: 'nwparker/orca-sandbox-push-test', + prompt: 'Add a push test', + agent: 'claude', + composerRepoId: 'repo' + }), + ...patch + } +} + +const rows = { + building: creation('building'), + opening: creation('opening', { phase: 'creating' }), + failed: creation('failed', { + status: 'error', + error: 'Orca Cloud workspaces need a plan with cloud hours.', + errorCode: 'cloudPlanRequired', + provisioningLog: '[orca-cloud] provisioning\nOrca Cloud: included_hours_exhausted' + }) +} + +/** The sidebar's top while three GitHub-only creates are in flight: building, opening, refused. */ +export const githubOnlyPendingRows = defineFixture({ + width: 320, + store: { + settings: getDefaultSettings('/home/fixture'), + pendingWorktreeCreations: rows, + activePendingCreationId: 'building' + }, + render: () => ( +
    + {Object.keys(rows).map((creationId) => ( + + ))} +
    + ) +}) diff --git a/src/renderer/src/lib/orca-cloud/start-cloud-workspace-from-github.cancel.test.ts b/src/renderer/src/lib/orca-cloud/start-cloud-workspace-from-github.cancel.test.ts new file mode 100644 index 00000000000..216791fa2c3 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/start-cloud-workspace-from-github.cancel.test.ts @@ -0,0 +1,117 @@ +// @vitest-environment happy-dom +// GH-CREATE-CANCEL and GH-CREATE-NO-ROW for a GitHub-only Orca Cloud create: Cancel at any step +// leaves no workspace (the sandbox is deleted, which drops the pairing too), and a create that +// opens hands its pending row off to the paired server's rows, which stay hidden until then. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' + +type OpenOptions = { onHost?: (hostId: string) => void } +type OpenOutcome = { ok: true; worktreeId: string | null; hostId?: string } + +const mocks = vi.hoisted(() => ({ + open: vi.fn<(workspace: unknown, options?: OpenOptions) => Promise>(), + launch: vi.fn() +})) + +vi.mock('@/components/cloud-workspaces/open-cloud-workspace', () => ({ + openCloudWorkspace: mocks.open +})) +vi.mock('@/lib/agent-session/launch/new-tab/launch-agent-in-new-tab', () => ({ + launchAgentInNewTabWithOutcome: mocks.launch +})) + +import { startCloudWorkspaceFromGitHub } from './start-cloud-workspace-from-github' +import { resetGitHubCreateKeysForTest } from './github-cloud-create-key' + +const createFromGitHub = vi.fn<(args: unknown) => Promise>() +const destroy = vi.fn(async () => ({ ok: true, value: undefined })) + +beforeEach(() => { + resetGitHubCreateKeysForTest() + useAppStore.setState({ pendingWorktreeCreations: {}, activePendingCreationId: null }) + createFromGitHub.mockResolvedValue({ ok: true, value: { sandboxId: 'sbx_1' } }) + mocks.open.mockResolvedValue({ ok: true, worktreeId: null }) + Object.assign(window, { + api: { + ...window.api, + orcaCloudWorkspace: { createFromGitHub }, + cloudWorkspaces: { + get: vi.fn(async (id: string) => ({ ok: true, value: { id } })), + destroy, + localWorkspaces: vi.fn(async () => []), + environmentBindings: vi.fn(async () => ({})) + }, + ephemeralVm: { + onProvisionEvent: () => () => {}, + cancelProvision: vi.fn(async () => ({ cancelled: true })), + // Cancel's cleanup looks for this create's runtime records; a GitHub-only create has none. + listRuntimes: vi.fn(async () => []) + } + } + }) +}) + +afterEach(() => { + vi.clearAllMocks() +}) + +const REQUEST = { + repoUrl: 'https://github.com/acme/web', + fullName: 'acme/web', + prompt: '', + agent: null, + composerRepoId: 'repo-1' +} + +const creationId = (): string => + Object.keys(useAppStore.getState().pendingWorktreeCreations)[0] ?? '' +const cancel = (): void => useAppStore.getState().removePendingWorktreeCreation(creationId()) + +describe('Cancel leaves no workspace (GH-CREATE-CANCEL)', () => { + it('cancelled after the sandbox started, before it opened: deleted, never opened', async () => { + createFromGitHub.mockImplementationOnce(async () => { + cancel() + return { ok: true, value: { sandboxId: 'sbx_1' } } + }) + expect(await startCloudWorkspaceFromGitHub(REQUEST)).toBe(false) + expect(mocks.open).not.toHaveBeenCalled() + expect(destroy).toHaveBeenCalledWith('sbx_1') + }) + + it('cancelled while it opened: the sandbox is deleted (its pairing and row go with it)', async () => { + mocks.open.mockImplementationOnce(async (_workspace, options) => { + options?.onHost?.('runtime:p') + cancel() + return { ok: true, worktreeId: 'w1', hostId: 'runtime:p' } + }) + expect(await startCloudWorkspaceFromGitHub(REQUEST)).toBe(false) + expect(destroy).toHaveBeenCalledWith('sbx_1') + expect(useAppStore.getState().pendingWorktreeCreations).toEqual({}) + }) + + it('cancelled while the create was in flight: main reclaims it; nothing opens here', async () => { + createFromGitHub.mockImplementationOnce(async () => { + cancel() + return { ok: false, code: 'failed', message: 'Cancelled.' } + }) + expect(await startCloudWorkspaceFromGitHub(REQUEST)).toBe(false) + expect(window.api.ephemeralVm.cancelProvision).toHaveBeenCalled() + expect(mocks.open).not.toHaveBeenCalled() + }) +}) + +describe('pending row hand-off (GH-CREATE-NO-ROW)', () => { + it('records the paired server while opening, and the hand-off drops the pending row', async () => { + let pairingHostIdDuringOpen: string | undefined + mocks.open.mockImplementationOnce(async (_workspace, options) => { + options?.onHost?.('runtime:p') + pairingHostIdDuringOpen = + useAppStore.getState().pendingWorktreeCreations[creationId()]?.pairingHostId + return { ok: true, worktreeId: null } + }) + expect(await startCloudWorkspaceFromGitHub(REQUEST)).toBe(true) + expect(pairingHostIdDuringOpen).toBe('runtime:p') + expect(useAppStore.getState().pendingWorktreeCreations).toEqual({}) + expect(destroy).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/orca-cloud/start-cloud-workspace-from-github.ts b/src/renderer/src/lib/orca-cloud/start-cloud-workspace-from-github.ts index 982b05ebc14..dd3fdd7000e 100644 --- a/src/renderer/src/lib/orca-cloud/start-cloud-workspace-from-github.ts +++ b/src/renderer/src/lib/orca-cloud/start-cloud-workspace-from-github.ts @@ -9,6 +9,7 @@ import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' import { openCloudWorkspace } from '@/components/cloud-workspaces/open-cloud-workspace' +import { refreshCloudWorktreeBindings } from '@/components/cloud-workspaces/cloud-workspace-identity' import { createNonSecureContextUuid } from '../../../../shared/non-secure-context-uuid' import { isTuiAgent } from '../../../../shared/agent-launch/catalog/tui-agent-config' import { ORCA_CLOUD_RECIPE_ID } from '../../../../shared/hosted-sandbox/orca-cloud-recipe-id' @@ -87,7 +88,12 @@ export function startCloudWorkspaceFromGitHub(request: StartFromGitHubRequest): }) store.setActiveView('terminal') store.setSidebarOpen(true) - return runGitHubCloudCreate(creationId) + return runTracked(creationId) +} + +/** Cancel's "Deleting the sandbox…" waits for every step that may still own a sandbox. */ +function runTracked(creationId: string): Promise { + return trackOrcaCloudCreateStep(creationId, runGitHubCloudCreate(creationId)) } /** The sheet's Retry (and the plans page's retry after checkout) for a GitHub-only create. */ @@ -114,7 +120,7 @@ export function retryGitHubCloudCreate(creationId: string): void { }) store.setActivePendingWorktreeCreation(creationId) store.setActiveView('terminal') - void runGitHubCloudCreate(creationId) + void runTracked(creationId) } function current(creationId: string) { @@ -186,13 +192,27 @@ async function runGitHubCloudCreate(creationId: string): Promise { return false } if (!current(creationId)) { - // Cancelled while it started: main stopped the create and deleted what it had made. - startedSandboxes.delete(creationId) + // GH-CREATE-CANCEL: cancelled after the sandbox started but before it opened. + await discardSandbox(creationId, sandboxId) return false } useAppStore.getState().updatePendingWorktreeCreation(creationId, { phase: 'creating' }) const workspace = await window.api.cloudWorkspaces.get(sandboxId).catch(() => null) - const opened = workspace?.ok ? await openCloudWorkspace(workspace.value) : null + const opened = workspace?.ok + ? await openCloudWorkspace(workspace.value, { + // GH-CREATE-NO-ROW: that server's rows wait behind the pending row until the hand-off. + onHost: (hostId) => + useAppStore + .getState() + .updatePendingWorktreeCreation(creationId, { pairingHostId: hostId }) + }) + : null + if (!current(creationId)) { + // GH-CREATE-CANCEL: cancelled while it opened. Opening can't be stopped half way, so the + // sandbox is deleted, which also drops this computer's pairing and its row. + await discardSandbox(creationId, sandboxId) + return false + } if (!opened?.ok) { // The sandbox exists from here on: Retry opens it and never creates it again. return fail( @@ -219,3 +239,14 @@ async function runGitHubCloudCreate(creationId: string): Promise { }) return true } + +/** A cancelled create's sandbox: deleted, so nothing keeps running (or billing) behind the cancel. */ +async function discardSandbox(creationId: string, sandboxId: string): Promise { + startedSandboxes.delete(creationId) + forgetGitHubCreateKey(creationId) + const result = await window.api.cloudWorkspaces.destroy(sandboxId).catch(() => null) + if (!result?.ok) { + console.warn('[orca-cloud] cancelled GitHub create: could not delete sandbox', sandboxId) + } + void refreshCloudWorktreeBindings().catch(() => undefined) +} diff --git a/src/renderer/src/lib/orca-cloud/use-sidebar-hidden-checkouts.test.ts b/src/renderer/src/lib/orca-cloud/use-sidebar-hidden-checkouts.test.ts index 3e296c962c2..39cc60b4eff 100644 --- a/src/renderer/src/lib/orca-cloud/use-sidebar-hidden-checkouts.test.ts +++ b/src/renderer/src/lib/orca-cloud/use-sidebar-hidden-checkouts.test.ts @@ -125,3 +125,26 @@ describe('paired workspaces scoped by account (PAIRED-ACCOUNT-FILTER)', () => { expect(hostsFor('team-two', paired()).hiddenHostIds.has('runtime:gh')).toBe(false) }) }) + +describe('GH-CREATE-NO-ROW hand-off', () => { + it('hides the server a GitHub-only create is pairing to until its pending row hands off', () => { + const pairing = (pairingHostId?: string) => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the selector reads only these fields. + ({ + runtimeEnvironments: [{ id: 'p', name: 'web (cloud)', orcaCloudPaired: true }], + orcaProfileAuthStatus: { cloud: { userId: 'u1' } }, + pendingWorktreeCreations: { + gh: { + creationId: 'gh', + request: { githubSource: { fullName: 'acme/web' } }, + ...(pairingHostId ? { pairingHostId } : {}) + } + } + }) as unknown as Pick + const hidden = (state: Pick) => [ + ...parseOrcaCloudHostScopeKey(selectOrcaCloudHostScopeKey(state)).hiddenHostIds + ] + expect(hidden(pairing())).toEqual([]) + expect(hidden(pairing('runtime:p'))).toEqual(['runtime:p']) + }) +}) diff --git a/src/renderer/src/lib/orca-cloud/use-sidebar-hidden-checkouts.ts b/src/renderer/src/lib/orca-cloud/use-sidebar-hidden-checkouts.ts index ac77b801dd3..538b4601197 100644 --- a/src/renderer/src/lib/orca-cloud/use-sidebar-hidden-checkouts.ts +++ b/src/renderer/src/lib/orca-cloud/use-sidebar-hidden-checkouts.ts @@ -13,7 +13,12 @@ import { isOrcaCloudRuntimeEnvironment } from '../../../../shared/remote-runtime import type { Repo } from '../../../../shared/repo-types' type CloudScopeState = Pick & - Partial> + Partial< + Pick< + AppState, + 'orcaProfileAuthStatus' | 'cloudAccountIdByEnvironmentId' | 'pendingWorktreeCreations' + > + > /** * Every Orca Cloud server this desktop runs, `+host` when the active team owns it and `-host` @@ -30,7 +35,12 @@ type CloudScopeState = Pick & export function selectOrcaCloudHostScopeKey(state: CloudScopeState): string { const active = orcaCloudAccountId(state.orcaProfileAuthStatus?.cloud) const owners = state.cloudAccountIdByEnvironmentId ?? {} - return (state.runtimeEnvironments ?? []) + // GH-CREATE-NO-ROW: a GitHub-only create's pending row stands for its server's rows until the + // hand-off removes it; both change in one store update, so the row never shows twice. + const handingOff = Object.values(state.pendingWorktreeCreations ?? {}).flatMap((entry) => + entry.request.githubSource && entry.pairingHostId ? [`-${entry.pairingHostId}`] : [] + ) + const scoped = (state.runtimeEnvironments ?? []) .filter((environment) => isOrcaCloudRuntimeEnvironment(environment)) .flatMap((environment) => { const hostId = toRuntimeExecutionHostId(environment.id) @@ -41,7 +51,7 @@ export function selectOrcaCloudHostScopeKey(state: CloudScopeState): string { const inTeam = active !== null && owners[environment.id] === active return [`${inTeam ? '+' : '-'}${hostId}`] }) - .join('\n') + return [...scoped, ...handingOff].join('\n') } export function parseOrcaCloudHostScopeKey(key: string): { diff --git a/src/renderer/src/lib/workspace/creation/pending-worktree-creation.ts b/src/renderer/src/lib/workspace/creation/pending-worktree-creation.ts index 6b8a9f043b4..d7b4b35f343 100644 --- a/src/renderer/src/lib/workspace/creation/pending-worktree-creation.ts +++ b/src/renderer/src/lib/workspace/creation/pending-worktree-creation.ts @@ -195,6 +195,8 @@ export type PendingWorktreeCreation = { resumedAt?: number /** VM creates: the provisioned VM's runtime environment, known once it answers. */ vmEnvironmentId?: string + /** GitHub-only Orca Cloud create: the server it is pairing to; its rows wait for the hand-off. */ + pairingHostId?: string // Local git checkout meter; absent when git emits none or creation runs over SSH/runtime. checkoutProgress?: WorktreeCheckoutProgress /** Phases entered so far with their start times, so the chat checklist can time each step. */ diff --git a/src/renderer/src/store/slices/worktrees/create/pending-worktree-creation.ts b/src/renderer/src/store/slices/worktrees/create/pending-worktree-creation.ts index 177eb7bf2f6..3ac855a0cf8 100644 --- a/src/renderer/src/store/slices/worktrees/create/pending-worktree-creation.ts +++ b/src/renderer/src/store/slices/worktrees/create/pending-worktree-creation.ts @@ -149,13 +149,7 @@ export function createRemovePendingWorktreeCreation( .catch(() => undefined) } const cleanup = cleanUpCancelledVm(creationId, removedEntry.request, get) - // A GitHub-only create past its start is opening a sandbox that stays; nothing is deleted. - const opening = Boolean(removedEntry.request.githubSource) && removedEntry.phase === 'creating' - if ( - removedEntry.status === 'creating' && - isOrcaCloudCreateRequest(removedEntry.request) && - !opening - ) { + if (removedEntry.status === 'creating' && isOrcaCloudCreateRequest(removedEntry.request)) { beginOrcaCloudCancelCleanup(creationId, cleanup ? [cleanup] : [], { runtimeId: removedEntry.request.ephemeralVmRuntimeId, createStartedAt: removedEntry.startedAt, diff --git a/src/renderer/src/store/slices/worktrees/worktree-slice-contract.ts b/src/renderer/src/store/slices/worktrees/worktree-slice-contract.ts index f55349c25be..8fc823291c3 100644 --- a/src/renderer/src/store/slices/worktrees/worktree-slice-contract.ts +++ b/src/renderer/src/store/slices/worktrees/worktree-slice-contract.ts @@ -186,6 +186,7 @@ export type WorktreeSlice = { pausedMs?: number resumedAt?: number vmEnvironmentId?: string + pairingHostId?: string checkoutProgress?: PendingWorktreeCreation['checkoutProgress'] worktreeId?: string heldChatMessage?: string diff --git a/src/shared/sidebar-list/sidebar-row-plan.ts b/src/shared/sidebar-list/sidebar-row-plan.ts index 68dbcda8e62..195a5d30651 100644 --- a/src/shared/sidebar-list/sidebar-row-plan.ts +++ b/src/shared/sidebar-list/sidebar-row-plan.ts @@ -212,8 +212,10 @@ export function planSidebarRows(inputs: SidebarRowPlanInputs) { const naturalWorktrees = orderedGroups.flatMap(([, group]) => group.items) const render = (collapsedGroups: Set): Row[] => { const result: Row[] = [] - if (groupBy !== 'repo') { - for (const creation of pendingCreations) { + // By project, a create sits atop its own project; one with no project here yet (a GitHub-only + // Orca Cloud create, GH-CREATE-NO-ROW) sits atop the list, as in every other grouping. + for (const creation of pendingCreations) { + if (groupBy !== 'repo' || !repoMap.has(creation.repoId)) { result.push(buildPendingCreationRow(creation, repoMap)) } } diff --git a/src/shared/sidebar-list/worktree-grouping.ts b/src/shared/sidebar-list/worktree-grouping.ts index ad1d8dd639b..387aa316f96 100644 --- a/src/shared/sidebar-list/worktree-grouping.ts +++ b/src/shared/sidebar-list/worktree-grouping.ts @@ -71,7 +71,8 @@ export function buildOrderedGroups(args: { ([repoId, candidate]) => [repoId, candidate.repo] as const ) ], - pendingRepoIds: pendingByRepo.keys(), + // A create with no project here yet sits atop the list instead (GH-CREATE-NO-ROW). + pendingRepoIds: [...pendingByRepo.keys()].filter((repoId) => repoMap.has(repoId)), repoOrder, projectOrderBy }) From 52b838fb4252d05b7eaef059b90df5e89fe3f51b Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 21:34:27 -0800 Subject: [PATCH 47/50] fix(sidebar): a cloud create's billing refusal reads in muted text (design-squad #623) --- .../rows/PendingWorktreeRow.refusal.test.tsx | 60 +++++++++++++++++++ .../worktree-list/rows/PendingWorktreeRow.tsx | 11 +++- 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/components/sidebar/worktree-list/rows/PendingWorktreeRow.refusal.test.tsx diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/PendingWorktreeRow.refusal.test.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/PendingWorktreeRow.refusal.test.tsx new file mode 100644 index 00000000000..438da0aac4a --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/rows/PendingWorktreeRow.refusal.test.tsx @@ -0,0 +1,60 @@ +// @vitest-environment happy-dom +// A cloud create's billing refusal is a precondition, not an error: its sidebar status reads in +// muted text (the glyph carries the state); a real failure stays red. +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { useAppStore } from '@/store' +import { githubCreateRequest } from '@/lib/orca-cloud/start-cloud-workspace-from-github' +import type { PendingWorktreeCreation } from '@/lib/workspace/creation/pending-worktree-creation' +import { PendingWorktreeRow } from './PendingWorktreeRow' + +afterEach(cleanup) + +function failed( + errorCode: string | undefined, + log: string, + error: string +): PendingWorktreeCreation { + return { + creationId: 'c', + phase: 'provisioning-vm', + status: 'error', + startedAt: 1, + indeterminate: false, + loaderVisible: true, + error, + ...(errorCode ? { errorCode } : {}), + provisioningLog: log, + request: githubCreateRequest({ + repoUrl: 'https://github.com/acme/web', + fullName: 'acme/web', + prompt: 'Fix login', + agent: null + }) + } +} + +function statusLine(entry: PendingWorktreeCreation): HTMLElement { + useAppStore.setState({ pendingWorktreeCreations: { c: entry }, repos: [] }) + render() + return screen.getByText('Fix login').nextElementSibling as HTMLElement +} + +describe('pending row status colour', () => { + it('billing refusal: muted', () => { + const line = statusLine( + failed( + 'cloudPlanRequired', + '[orca-cloud] provisioning\nOrca Cloud: included_hours_exhausted', + 'Orca Cloud workspaces need a plan with cloud hours.' + ) + ) + expect(line.className).toContain('text-muted-foreground') + expect(line.className).not.toContain('text-destructive') + }) + + it('a real failure: red', () => { + const line = statusLine(failed(undefined, '[orca-cloud] provisioning', 'fetch failed')) + expect(line.className).toContain('text-destructive') + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/PendingWorktreeRow.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/PendingWorktreeRow.tsx index ccc90ad092d..7dff544c420 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/PendingWorktreeRow.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/rows/PendingWorktreeRow.tsx @@ -10,6 +10,10 @@ import { import { translate } from '@/i18n/i18n' import { useTranslate, type Translate } from '@/i18n/use-translate' import { vmBuildFailureSummary } from '@/components/worktree-creation/vm-build-failure-toast' +import { + classifyVmBuildFailure, + isBillingFailure +} from '@/components/worktree-creation/vm-build-failure' import type { Repo } from '../../../../../../shared/repo-types' function statusLabel( @@ -67,6 +71,11 @@ export function PendingWorktreeRow({ const isError = entry.status === 'error' const isOrcaCloud = entry.request.ephemeralVmRecipe?.provider === 'orca-cloud' + // A billing refusal is a precondition, not an error: muted text; the glyph carries the state. + const refused = + isError && + isOrcaCloud && + isBillingFailure(classifyVmBuildFailure({ entry, error: entry.error ?? null, cloud: true })) return (
    {statusLabel(entry, repos, t)} From 849cf33d1c325f1b3f619d83628bb0a114d2e4d9 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 21:45:15 -0800 Subject: [PATCH 48/50] test(sidebar): type guard instead of a cast in the refusal colour test --- .../worktree-list/rows/PendingWorktreeRow.refusal.test.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/PendingWorktreeRow.refusal.test.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/PendingWorktreeRow.refusal.test.tsx index 438da0aac4a..7765689b53e 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/PendingWorktreeRow.refusal.test.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/rows/PendingWorktreeRow.refusal.test.tsx @@ -37,7 +37,11 @@ function failed( function statusLine(entry: PendingWorktreeCreation): HTMLElement { useAppStore.setState({ pendingWorktreeCreations: { c: entry }, repos: [] }) render() - return screen.getByText('Fix login').nextElementSibling as HTMLElement + const line = screen.getByText('Fix login').nextElementSibling + if (!(line instanceof HTMLElement)) { + throw new Error('no status line') + } + return line } describe('pending row status colour', () => { From df55fc478656e02c9e1b66f60dde000a07b68973 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 12 Nov 2026 23:45:34 -0800 Subject: [PATCH 49/50] feat(cloud): COPY-LINK-FIRST-CLICK Copy link on the first Sign in and Continue in browser (#616) * feat(cloud): COPY-LINK-FIRST-CLICK Copy link on the first Sign in and setup's first Continue in browser starts the same flow without opening the browser * feat(cloud): COPY-LINK-FIRST-CLICK loopback sign-in links say they open in any browser on this computer (menu line and copied toast) * fix(cloud): COPY-LINK-FIRST-CLICK Copy link stays on sign-in only; the choose-plan Continue in browser has no copy link --- src/main/billing/cloud-setup-service.test.ts | 21 +++- src/main/billing/cloud-setup-service.ts | 17 ++- .../profiles/orca-profile-billing-handlers.ts | 3 +- src/main/ipc/profiles/orca-profiles.ts | 6 +- .../cloud-auth/profile-cloud-pkce.ts | 40 ++++++- ...file-cloud-service-connect-overlap.test.ts | 3 +- ...loud-service-sign-out-connect-race.test.ts | 3 +- .../cloud-auth/profile-cloud-service.ts | 9 +- .../orca-profiles/profile-cloud-pkce.test.ts | 38 +++++++ ...ofile-cloud-service-sign-in-cancel.test.ts | 8 +- src/preload/api/orca-profile-api.ts | 3 +- src/preload/api/orca-profiles-bridge.ts | 2 +- .../src/components/OrcaSignInProgress.tsx | 19 +++- .../OpenExternalSplitButton.fixture.tsx | 39 +++++++ .../OpenExternalSplitButton.test.tsx | 22 ++++ .../open-external/OpenExternalSplitButton.tsx | 17 ++- .../open-external/copy-external-link.ts | 11 +- .../CloudSetupBrowserButton.tsx | 41 ++++++- .../CloudSetupPage.fixture.tsx | 1 + .../orca-cloud-setup/CloudSetupPage.tsx | 11 +- .../orca-cloud-setup/use-cloud-setup-flow.ts | 104 +++++++++++------- .../src/components/orca-sign-in-link.test.ts | 50 +++++++++ .../src/components/orca-sign-in-link.ts | 58 ++++++++++ src/renderer/src/i18n/locales/en.json | 3 +- .../orca-cloud/cloud-setup-session-seam.ts | 4 +- .../src/lib/orca-cloud/cloud-setup-session.ts | 13 ++- .../orca-cloud/install-cloud-setup-session.ts | 3 +- .../orca-profiles-auth-actions.ts | 9 +- src/shared/orca-cloud-setup.ts | 13 ++- 29 files changed, 481 insertions(+), 90 deletions(-) create mode 100644 src/renderer/src/components/open-external/OpenExternalSplitButton.fixture.tsx create mode 100644 src/renderer/src/components/orca-sign-in-link.test.ts create mode 100644 src/renderer/src/components/orca-sign-in-link.ts diff --git a/src/main/billing/cloud-setup-service.test.ts b/src/main/billing/cloud-setup-service.test.ts index 1a919d1c058..020c25fbbf8 100644 --- a/src/main/billing/cloud-setup-service.test.ts +++ b/src/main/billing/cloud-setup-service.test.ts @@ -93,18 +93,23 @@ describe('cloud setup service', () => { }) it('names the Mac by its Computer Name, apostrophe included, and falls back to the hostname', async () => { - const named = (name: string, final = true) => async () => ({ name, final }) + const named = + (name: string, final = true) => + async () => ({ name, final }) expect( await cloudSetupDeviceLabel({ platform: 'darwin', detect: named("m4air's MacBook Air") }) ).toBe("m4air's MacBook Air") // scutil timed out or failed: the cleaned hostname, as before. expect( - await cloudSetupDeviceLabel({ platform: 'darwin', detect: named('m4airs-MacBook-Air', false) }) + await cloudSetupDeviceLabel({ + platform: 'darwin', + detect: named('m4airs-MacBook-Air', false) + }) ).toBe(setupDeviceLabel()) // Off macOS the hostname is the name. - expect( - await cloudSetupDeviceLabel({ platform: 'linux', detect: named('ignored') }) - ).toBe(setupDeviceLabel()) + expect(await cloudSetupDeviceLabel({ platform: 'linux', detect: named('ignored') })).toBe( + setupDeviceLabel() + ) }) it('validates start args from the renderer', () => { @@ -116,5 +121,11 @@ describe('cloud setup service', () => { cloudSetupStartArgsFromUnknown({ needs: ['team'], repo: 'https://gitlab.com/a/b' }) ).toThrow() expect(() => cloudSetupStartArgsFromUnknown({ needs: ['bogus'] })).toThrow() + // COPY-LINK-FIRST-CLICK: Copy link starts setup without the browser. + expect(cloudSetupStartArgsFromUnknown({ needs: ['plan'], openBrowser: false })).toEqual({ + needs: ['plan'], + openBrowser: false + }) + expect(() => cloudSetupStartArgsFromUnknown({ needs: ['plan'], openBrowser: 'no' })).toThrow() }) }) diff --git a/src/main/billing/cloud-setup-service.ts b/src/main/billing/cloud-setup-service.ts index 49623e44cb8..665b98ca51f 100644 --- a/src/main/billing/cloud-setup-service.ts +++ b/src/main/billing/cloud-setup-service.ts @@ -153,8 +153,18 @@ export async function startCloudSetup( try { const signedIn = await create() if (signedIn.status === 'ok') { + const opened = { + status: 'opened' as const, + sessionId: signedIn.value.id, + expiresAt: signedIn.value.expiresAt + } + // Copy link: the same session, its link returned instead of opened. It expires on the + // server at `expiresAt`, like a session whose opened tab was never used. + if (args.openBrowser === false) { + return { ...opened, url: signedIn.value.url } + } await openInBrowser(signedIn.value.url) - return { status: 'opened', sessionId: signedIn.value.id, expiresAt: signedIn.value.expiresAt } + return opened } if (signedIn.status === 'unconfigured') { return { status: 'unconfigured' } @@ -180,7 +190,10 @@ export async function startCloudSetup( outcome.error = error } return outcome.created?.url ?? null - } + }, + // Copy link while signed out: the sign-in (which then carries on into setup in the same + // tab) starts without the browser; its link is the sign-in progress link. + openBrowser: args.openBrowser !== false }) if (connected.status === 'cancelled' || connected.status === 'timed-out') { return { status: 'cancelled' } diff --git a/src/main/ipc/profiles/orca-profile-billing-handlers.ts b/src/main/ipc/profiles/orca-profile-billing-handlers.ts index 7b277f07486..fc04de3678a 100644 --- a/src/main/ipc/profiles/orca-profile-billing-handlers.ts +++ b/src/main/ipc/profiles/orca-profile-billing-handlers.ts @@ -111,7 +111,8 @@ const CloudSetupStart = z .object({ repo: z.string().url().startsWith('https://github.com/').max(300).optional(), needs: z.array(z.enum(CLOUD_SETUP_NEEDS)).max(3), - chooseTeam: z.boolean().optional() + chooseTeam: z.boolean().optional(), + openBrowser: z.boolean().optional() }) .strict() diff --git a/src/main/ipc/profiles/orca-profiles.ts b/src/main/ipc/profiles/orca-profiles.ts index 21a64c3e42c..379f26b30c2 100644 --- a/src/main/ipc/profiles/orca-profiles.ts +++ b/src/main/ipc/profiles/orca-profiles.ts @@ -45,6 +45,7 @@ import { selectCurrentOrcaProfileOrg, signOutCurrentOrcaProfile } from '../../orca-profiles/cloud-auth/profile-cloud-service' +import { connectCurrentArgsFromUnknown } from '../../orca-profiles/cloud-auth/profile-cloud-pkce' import { refreshCurrentOrcaProfileAuth } from '../../orca-profiles/cloud-auth/profile-cloud-capability-refresh' import { registerOrcaProfileOrgMemberHandlers } from './orca-profile-org-members-handlers' import { registerOrcaProfileBillingHandlers } from './orca-profile-billing-handlers' @@ -258,8 +259,9 @@ export function registerOrcaProfileHandlers( ipcMain.handle( 'orcaProfiles:connectCurrent', - async (): Promise => { - const result = await connectCurrentOrcaProfile(getProfileUserDataPath()) + async (_event, args?: unknown): Promise => { + const connect = connectCurrentArgsFromUnknown(args) + const result = await connectCurrentOrcaProfile(getProfileUserDataPath(), connect) if (result.status === 'connected') { options.onAuthMutation?.() } diff --git a/src/main/orca-profiles/cloud-auth/profile-cloud-pkce.ts b/src/main/orca-profiles/cloud-auth/profile-cloud-pkce.ts index 4ecb4fd6807..4ff0a828517 100644 --- a/src/main/orca-profiles/cloud-auth/profile-cloud-pkce.ts +++ b/src/main/orca-profiles/cloud-auth/profile-cloud-pkce.ts @@ -68,6 +68,12 @@ export function beginOrcaCloudPkceFlow( authorizeParams?: Record /** Keep the browser's callback request open until the caller calls finishBrowser. */ holdBrowser?: boolean + /** + * COPY-LINK-FIRST-CLICK: false starts the same flow without opening the browser (the person + * copied the link to paste into a private window). The listener and its timeout are unchanged, + * so an unused link ends like an unanswered click. + */ + openBrowser?: boolean } = {} ): Promise { const codeVerifier = createCodeVerifier() @@ -209,9 +215,12 @@ export function beginOrcaCloudPkceFlow( const devUrlFile = app.isPackaged ? '' : (process.env.ORCA_CLOUD_DEV_OPEN_URL_FILE?.trim() ?? '') - const open = devUrlFile - ? appendFile(devUrlFile, `${authorizeUrl.toString()}\n`, { mode: 0o600 }) - : shell.openExternal(authorizeUrl.toString()) + const open = + options.openBrowser === false + ? Promise.resolve() + : devUrlFile + ? appendFile(devUrlFile, `${authorizeUrl.toString()}\n`, { mode: 0o600 }) + : shell.openExternal(authorizeUrl.toString()) void open.catch((error) => { rejectFlow( error instanceof Error ? error : new Error('orca_cloud_auth_browser_open_failed') @@ -220,3 +229,28 @@ export function beginOrcaCloudPkceFlow( }) }) } + +/** COPY-LINK-FIRST-CLICK: `openBrowser: false` starts sign-in without opening the browser. */ +export type ConnectCurrentArgs = { openBrowser?: boolean } + +/** The renderer's connect args: only an explicit `openBrowser: false` suppresses the browser. */ +export function connectCurrentArgsFromUnknown(args: unknown): ConnectCurrentArgs { + const openBrowser = + typeof args === 'object' && args !== null + ? Object.getOwnPropertyDescriptor(args, 'openBrowser')?.value + : undefined + return openBrowser === false ? { openBrowser: false } : {} +} + +/** + * The PKCE options a sign-in passes: cloud setup's parameters and held tab, or a plain sign-in that + * may skip the browser (COPY-LINK-FIRST-CLICK; setup asks for that on its own `connect` args). + */ +export function pkceSignInOptions( + setup: ({ authorizeParams?: Record } & ConnectCurrentArgs) | undefined +): { openBrowser: boolean; authorizeParams?: Record; holdBrowser?: boolean } { + return { + openBrowser: setup?.openBrowser !== false, + ...(setup?.authorizeParams ? { authorizeParams: setup.authorizeParams, holdBrowser: true } : {}) + } +} diff --git a/src/main/orca-profiles/cloud-auth/profile-cloud-service-connect-overlap.test.ts b/src/main/orca-profiles/cloud-auth/profile-cloud-service-connect-overlap.test.ts index cb4dea3d017..973e7db0046 100644 --- a/src/main/orca-profiles/cloud-auth/profile-cloud-service-connect-overlap.test.ts +++ b/src/main/orca-profiles/cloud-auth/profile-cloud-service-connect-overlap.test.ts @@ -34,7 +34,8 @@ vi.mock('electron', () => ({ })) vi.mock('./profile-cloud-pkce', () => ({ - beginOrcaCloudPkceFlow: beginOrcaCloudPkceFlowMock + beginOrcaCloudPkceFlow: beginOrcaCloudPkceFlowMock, + pkceSignInOptions: () => ({}) })) vi.mock('./profile-cloud-client', () => ({ diff --git a/src/main/orca-profiles/cloud-auth/profile-cloud-service-sign-out-connect-race.test.ts b/src/main/orca-profiles/cloud-auth/profile-cloud-service-sign-out-connect-race.test.ts index d88825a2b2c..e00cf7dfb04 100644 --- a/src/main/orca-profiles/cloud-auth/profile-cloud-service-sign-out-connect-race.test.ts +++ b/src/main/orca-profiles/cloud-auth/profile-cloud-service-sign-out-connect-race.test.ts @@ -32,7 +32,8 @@ vi.mock('electron', () => ({ })) vi.mock('./profile-cloud-pkce', () => ({ - beginOrcaCloudPkceFlow: beginOrcaCloudPkceFlowMock + beginOrcaCloudPkceFlow: beginOrcaCloudPkceFlowMock, + pkceSignInOptions: () => ({}) })) vi.mock('./profile-cloud-client', () => ({ diff --git a/src/main/orca-profiles/cloud-auth/profile-cloud-service.ts b/src/main/orca-profiles/cloud-auth/profile-cloud-service.ts index ceb8e47cd6d..25d8631353c 100644 --- a/src/main/orca-profiles/cloud-auth/profile-cloud-service.ts +++ b/src/main/orca-profiles/cloud-auth/profile-cloud-service.ts @@ -25,7 +25,7 @@ import { exchangeOrcaCloudAuthCode, revokeOrcaCloudSession } from './profile-cloud-client' -import { beginOrcaCloudPkceFlow } from './profile-cloud-pkce' +import { beginOrcaCloudPkceFlow, pkceSignInOptions } from './profile-cloud-pkce' import { createCloudLinkedOrcaProfileRecord, linkOrcaProfileToCloud, @@ -65,7 +65,8 @@ export function getCurrentOrcaProfileAuthStatus(userDataPath: string): OrcaProfi export async function connectCurrentOrcaProfile( userDataPath: string, - setup?: OrcaCloudSetupSignIn + /** Cloud setup's sign-in, or (COPY-LINK-FIRST-CLICK) a plain one that may skip the browser. */ + setup?: OrcaCloudSetupSignIn | { openBrowser?: boolean } ): Promise { const active = ensureActiveOrcaProfile(userDataPath) if (isOrcaCloudDevAuthEnabled()) { @@ -101,7 +102,7 @@ export async function connectCurrentOrcaProfile( const code = await beginOrcaCloudPkceFlow(configState.config, active.profile.id, { signal: attemptAbort.signal, onAuthorizeUrl: (link) => setOrcaCloudSignInLink(attempt, link), - ...(setup ? { authorizeParams: setup.authorizeParams, holdBrowser: true } : {}) + ...pkceSignInOptions(setup) }) finishBrowser = code.finishBrowser // The callback listener has closed, so its browser link is dead. @@ -123,7 +124,7 @@ export async function connectCurrentOrcaProfile( signInCloudSession(active.profile.id, userDataPath, exchange) const list = linkOrcaProfileToCloud(active.profile.id, exchange.cloud, userDataPath) linkedCloudConnectAttempt = attempt - if (setup && finishBrowser) { + if (setup && 'afterSignIn' in setup && finishBrowser) { const next = await setup.afterSignIn().catch(() => null) finishBrowser(next) finishBrowser = undefined diff --git a/src/main/orca-profiles/profile-cloud-pkce.test.ts b/src/main/orca-profiles/profile-cloud-pkce.test.ts index dcaa4dc4d8b..ae709d9c067 100644 --- a/src/main/orca-profiles/profile-cloud-pkce.test.ts +++ b/src/main/orca-profiles/profile-cloud-pkce.test.ts @@ -5,9 +5,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => ({ app: { isPackaged: false }, shell: { openExternal: vi.fn() } })) +import { shell } from 'electron' import type { OrcaCloudAuthConfig } from './cloud-auth/profile-cloud-auth-config' import { beginOrcaCloudPkceFlow, + connectCurrentArgsFromUnknown, + pkceSignInOptions, ORCA_CLOUD_SIGN_IN_TIMEOUT_MS, orcaCloudSignInTimeoutMs } from './cloud-auth/profile-cloud-pkce' @@ -128,6 +131,26 @@ describe('PKCE loopback cancel and timeout', () => { expect(onAuthorizeUrl).not.toHaveBeenCalled() }) + it('COPY-LINK-FIRST-CLICK: openBrowser false publishes the link with the listener up, opening nothing', async () => { + delete process.env.ORCA_CLOUD_DEV_OPEN_URL_FILE + vi.mocked(shell.openExternal).mockClear() + const abort = new AbortController() + let authorize = '' + const flow = beginOrcaCloudPkceFlow(config, 'profile-1', { + signal: abort.signal, + openBrowser: false, + onAuthorizeUrl: (url) => (authorize = url) + }) + flow.catch(() => undefined) + await vi.waitFor(() => expect(authorize).not.toBe('')) + expect(shell.openExternal).not.toHaveBeenCalled() + // The same loopback listener a click would have: a pasted link completes against it. + const redirect = new URL(new URL(authorize).searchParams.get('redirect_uri')!) + expect((await fetch(new URL('/auth/callback?state=wrong', redirect))).status).toBe(400) + abort.abort() + await expect(flow).rejects.toThrow('orca_cloud_auth_cancelled') + }) + it('times out a sign-in the browser never finishes', async () => { process.env.ORCA_CLOUD_SIGN_IN_TIMEOUT_MS = '1000' const { flow, redirect } = await start() @@ -144,3 +167,18 @@ describe('PKCE loopback cancel and timeout', () => { expect(orcaCloudSignInTimeoutMs({ ORCA_CLOUD_SIGN_IN_TIMEOUT_MS: 'x' }, false)).toBe(300_000) }) }) + +describe('COPY-LINK-FIRST-CLICK options', () => { + it('only an explicit openBrowser false (from the renderer) skips the browser', () => { + expect(connectCurrentArgsFromUnknown({ openBrowser: false })).toEqual({ openBrowser: false }) + expect(connectCurrentArgsFromUnknown({ openBrowser: 'false' })).toEqual({}) + expect(connectCurrentArgsFromUnknown(undefined)).toEqual({}) + expect(pkceSignInOptions(undefined)).toEqual({ openBrowser: true }) + expect(pkceSignInOptions({ openBrowser: false })).toEqual({ openBrowser: false }) + expect(pkceSignInOptions({ authorizeParams: { setup: '1' }, openBrowser: false })).toEqual({ + openBrowser: false, + authorizeParams: { setup: '1' }, + holdBrowser: true + }) + }) +}) diff --git a/src/main/orca-profiles/profile-cloud-service-sign-in-cancel.test.ts b/src/main/orca-profiles/profile-cloud-service-sign-in-cancel.test.ts index e121a03bd3a..0c0e5443390 100644 --- a/src/main/orca-profiles/profile-cloud-service-sign-in-cancel.test.ts +++ b/src/main/orca-profiles/profile-cloud-service-sign-in-cancel.test.ts @@ -24,7 +24,8 @@ vi.mock('electron', () => ({ })) vi.mock('./cloud-auth/profile-cloud-pkce', () => ({ - beginOrcaCloudPkceFlow: beginOrcaCloudPkceFlowMock + beginOrcaCloudPkceFlow: beginOrcaCloudPkceFlowMock, + pkceSignInOptions: () => ({}) })) vi.mock('./cloud-auth/profile-cloud-client', () => ({ @@ -34,7 +35,10 @@ vi.mock('./cloud-auth/profile-cloud-client', () => ({ selectOrcaCloudOrg: vi.fn() })) -import { connectCurrentOrcaProfile, getCurrentOrcaProfileAuthStatus } from './cloud-auth/profile-cloud-service' +import { + connectCurrentOrcaProfile, + getCurrentOrcaProfileAuthStatus +} from './cloud-auth/profile-cloud-service' import { cancelOrcaCloudSignIn, getOrcaCloudSignInProgress, diff --git a/src/preload/api/orca-profile-api.ts b/src/preload/api/orca-profile-api.ts index 71572197248..53ea1982c6b 100644 --- a/src/preload/api/orca-profile-api.ts +++ b/src/preload/api/orca-profile-api.ts @@ -74,7 +74,8 @@ export type OrcaProfileApi = { findProjectProfiles: ( args: FindOrcaProfileProjectsByPathArgs ) => Promise - connectCurrent: () => Promise + /** COPY-LINK-FIRST-CLICK: `openBrowser: false` starts sign-in without opening the browser. */ + connectCurrent: (args?: { openBrowser?: boolean }) => Promise signInProgress: () => Promise onSignInProgressChanged: ( /** `end` says why a sign-in stopped when the UI should tell the user (it timed out). */ diff --git a/src/preload/api/orca-profiles-bridge.ts b/src/preload/api/orca-profiles-bridge.ts index 22837449f8d..c3c9ecb6598 100644 --- a/src/preload/api/orca-profiles-bridge.ts +++ b/src/preload/api/orca-profiles-bridge.ts @@ -67,7 +67,7 @@ export const orcaProfilesApi = { ) }, findProjectProfiles: (args) => ipcRenderer.invoke('orcaProfiles:findProjectProfiles', args), - connectCurrent: () => ipcRenderer.invoke('orcaProfiles:connectCurrent'), + connectCurrent: (args) => ipcRenderer.invoke('orcaProfiles:connectCurrent', args), signInProgress: () => ipcRenderer.invoke('orcaProfiles:signInProgress'), cancelSignIn: () => ipcRenderer.invoke('orcaProfiles:cancelSignIn'), refreshAuth: () => ipcRenderer.invoke('orcaProfiles:refreshAuth'), diff --git a/src/renderer/src/components/OrcaSignInProgress.tsx b/src/renderer/src/components/OrcaSignInProgress.tsx index a7e857ff597..280c7b5b4ae 100644 --- a/src/renderer/src/components/OrcaSignInProgress.tsx +++ b/src/renderer/src/components/OrcaSignInProgress.tsx @@ -4,6 +4,7 @@ import { toast } from 'sonner' import { registerSignInNotice } from '@/lib/orca-cloud/sign-in-notice-visibility' import { Button } from './ui/button' import { OpenExternalSplitButton } from './open-external/OpenExternalSplitButton' +import { mintFirstClickLink, signInLinkHint } from './orca-sign-in-link' import { translate } from '@/i18n/i18n' import { useTranslate } from '@/i18n/use-translate' import { cn } from '@/lib/class-names' @@ -12,7 +13,9 @@ import { useAppStore } from '../store/index' async function copySignInLink(link: string): Promise { try { await window.api.ui.writeClipboardText(link) - toast.success(translate('auto.components.OrcaSignInProgress.copied', 'Sign-in link copied')) + toast.success(translate('auto.components.OrcaSignInProgress.copied', 'Sign-in link copied'), { + description: signInLinkHint() + }) } catch (error) { console.warn('Could not copy the Orca sign-in link:', error) toast.error( @@ -49,6 +52,7 @@ export function OrcaSignInButton({ groupClassName={props.className} className="flex-1" link={link} + copyHint={signInLinkHint()} onClick={onClick} > {t('auto.components.OrcaSignInProgress.reopenBrowser', 'Reopen browser')} @@ -56,10 +60,19 @@ export function OrcaSignInButton({ ) } + // COPY-LINK-FIRST-CLICK: Copy link starts the same sign-in without opening the browser and + // copies its link (the button then reads "Reopen browser" over that pending sign-in). return ( - + ) } diff --git a/src/renderer/src/components/open-external/OpenExternalSplitButton.fixture.tsx b/src/renderer/src/components/open-external/OpenExternalSplitButton.fixture.tsx new file mode 100644 index 00000000000..2a939b4ced0 --- /dev/null +++ b/src/renderer/src/components/open-external/OpenExternalSplitButton.fixture.tsx @@ -0,0 +1,39 @@ +// The chevron's "Copy link" menu: a loopback sign-in link says where it works (muted second line); +// a link that works anywhere (billing, GitHub, help) doesn't. +import { ExternalLink } from 'lucide-react' +import { defineFixture } from '../../component-fixtures/component-fixture' +import { signInLinkHint } from '../orca-sign-in-link' +import { OpenExternalSplitButton } from './OpenExternalSplitButton' + +function menuOpen(label: string, link: string, hint?: string) { + return defineFixture({ + width: 360, + render: () => ( +
    + undefined} + {...(hint ? { copyHint: hint } : {})} + > + {label} + + +
    + ), + interact: async (frame) => { + frame + .querySelector('[data-open-external-chevron]') + ?.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0 })) + await new Promise((resolve) => setTimeout(resolve, 200)) + } + }) +} + +/** Orca sign-in (loopback): "Copy link" with "Open it in any browser on this computer." under it. */ +export const signInLinkMenu = menuOpen( + 'Sign in to Orca', + 'http://127.0.0.1:5555/auth?state=s', + signInLinkHint() +) +/** A link that works anywhere: just "Copy link". */ +export const plainLinkMenu = menuOpen('Open billing portal', 'https://billing.example/portal') diff --git a/src/renderer/src/components/open-external/OpenExternalSplitButton.test.tsx b/src/renderer/src/components/open-external/OpenExternalSplitButton.test.tsx index e1672521d1a..5464302fc61 100644 --- a/src/renderer/src/components/open-external/OpenExternalSplitButton.test.tsx +++ b/src/renderer/src/components/open-external/OpenExternalSplitButton.test.tsx @@ -56,6 +56,28 @@ describe('OpenExternalSplitButton', () => { ) }) + it('a loopback sign-in link says where it works, in the menu and once copied', async () => { + render( + + Sign in + + ) + const user = userEvent.setup() + await user.click(screen.getByRole('button', { name: 'More link options' })) + const item = await screen.findByRole('menuitem', { name: /Copy link/ }) + expect(item.textContent).toContain('Open it in any browser on this computer.') + await user.click(item) + await waitFor(() => + expect(toast.success).toHaveBeenCalledWith('Link copied', { + description: 'Open it in any browser on this computer.' + }) + ) + }) + it('mints a lazy URL the same way on Copy link, then copies it without opening', async () => { const onOpen = vi.fn() const mint = vi.fn(async () => 'https://billing.stripe.com/p/session/test_123') diff --git a/src/renderer/src/components/open-external/OpenExternalSplitButton.tsx b/src/renderer/src/components/open-external/OpenExternalSplitButton.tsx index 88075711253..507deadf84b 100644 --- a/src/renderer/src/components/open-external/OpenExternalSplitButton.tsx +++ b/src/renderer/src/components/open-external/OpenExternalSplitButton.tsx @@ -29,12 +29,18 @@ export type OpenExternalSplitButtonProps = Omit) => void /** The wrapper (place it in the parent's layout); `className` styles the main button. */ groupClassName?: string + /** + * Where the copied link works, when that's limited: a muted second line under "Copy link" and + * the copied toast's description. Only loopback sign-in links set it (`SIGN_IN_LINK_HINT`). + */ + copyHint?: string } export function OpenExternalSplitButton({ link, onClick, groupClassName, + copyHint, className, variant, size, @@ -61,7 +67,7 @@ export function OpenExternalSplitButton({ return } setState('copying') - const result = await copyExternalLink(link) + const result = await copyExternalLink(link, copyHint) if (!mounted.current) { return } @@ -138,7 +144,14 @@ export function OpenExternalSplitButton({ void copy()}> - {copyLabel} + {copyHint ? ( + + {copyLabel} + {copyHint} + + ) : ( + copyLabel + )} diff --git a/src/renderer/src/components/open-external/copy-external-link.ts b/src/renderer/src/components/open-external/copy-external-link.ts index 5316dd6e32b..3df47a48df8 100644 --- a/src/renderer/src/components/open-external/copy-external-link.ts +++ b/src/renderer/src/components/open-external/copy-external-link.ts @@ -21,7 +21,9 @@ export function requireLink(url: string | null): string { export type CopyExternalLinkResult = 'copied' | 'no-link' | 'failed' export async function copyExternalLink( - source: ExternalLinkSource + source: ExternalLinkSource, + /** A limit worth saying once it's copied (only loopback sign-in links: "on this computer"). */ + hint?: string ): Promise { let url: string | null try { @@ -43,6 +45,11 @@ export async function copyExternalLink( toast.error(translate('openExternal.copyLinkFailed', 'Couldn’t copy the link.')) return 'failed' } - toast.success(translate('openExternal.linkCopied', 'Link copied')) + const copied = translate('openExternal.linkCopied', 'Link copied') + if (hint) { + toast.success(copied, { description: hint }) + } else { + toast.success(copied) + } return 'copied' } diff --git a/src/renderer/src/components/orca-cloud-setup/CloudSetupBrowserButton.tsx b/src/renderer/src/components/orca-cloud-setup/CloudSetupBrowserButton.tsx index 9c6c4706d8e..e73be4ecfbc 100644 --- a/src/renderer/src/components/orca-cloud-setup/CloudSetupBrowserButton.tsx +++ b/src/renderer/src/components/orca-cloud-setup/CloudSetupBrowserButton.tsx @@ -4,23 +4,60 @@ import type React from 'react' import { ExternalLink, LoaderCircle } from 'lucide-react' import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton' import type { ExternalLinkSource } from '@/components/open-external/copy-external-link' +import { signInLinkHint } from '../orca-sign-in-link' +import type { CloudSetupFlow } from './use-cloud-setup-flow' export function CloudSetupBrowserButton({ label, busy, onClick, - link + link, + copyHint }: { label: string busy: boolean onClick: () => void link?: ExternalLinkSource | null + /** A loopback sign-in link's "on this computer" line (signed out, the trip starts with sign-in). */ + copyHint?: string }): React.JSX.Element { return ( - + {busy ? : null} {label} ) } + +/** + * A step's browser button on the setup page. Signed out, the first "Continue in browser" copies the + * sign-in's loopback link, which only completes on this computer, so it says so. + */ +export function CloudSetupFlowBrowserButton({ + flow, + label, + onClick, + link +}: { + flow: Pick + label: string + onClick: () => void + link?: ExternalLinkSource | null +}): React.JSX.Element { + const signIn = flow.action === 'sign-in' && link === flow.continueLink + return ( + + ) +} diff --git a/src/renderer/src/components/orca-cloud-setup/CloudSetupPage.fixture.tsx b/src/renderer/src/components/orca-cloud-setup/CloudSetupPage.fixture.tsx index 590ef8896b3..394f284c0d5 100644 --- a/src/renderer/src/components/orca-cloud-setup/CloudSetupPage.fixture.tsx +++ b/src/renderer/src/components/orca-cloud-setup/CloudSetupPage.fixture.tsx @@ -29,6 +29,7 @@ function flow(rows: CloudSetupStep[], teamSwitch = false): CloudSetupFlow { canManageBilling: true, teamAction: teamSwitch ? switchTeamAction : null, continueInBrowser: noop, + continueLink: async () => 'https://auth.example/setup/abc', startTrial: noop, fixBilling: noop, installGitHub: noop, diff --git a/src/renderer/src/components/orca-cloud-setup/CloudSetupPage.tsx b/src/renderer/src/components/orca-cloud-setup/CloudSetupPage.tsx index 8712c75a814..b74022a6539 100644 --- a/src/renderer/src/components/orca-cloud-setup/CloudSetupPage.tsx +++ b/src/renderer/src/components/orca-cloud-setup/CloudSetupPage.tsx @@ -7,7 +7,7 @@ import { ArrowLeft, Check, ExternalLink, LoaderCircle, X } from 'lucide-react' import { Button } from '@/components/ui/button' import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton' import type { ExternalLinkSource } from '@/components/open-external/copy-external-link' -import { CloudSetupBrowserButton } from './CloudSetupBrowserButton' +import { CloudSetupFlowBrowserButton } from './CloudSetupBrowserButton' import { Skeleton } from '@/components/ui/skeleton' import { cn } from '@/lib/class-names' import { useTranslate } from '@/i18n/use-translate' @@ -176,9 +176,7 @@ function StepActions({ label: string, onClick: () => void, link?: ExternalLinkSource | null - ): React.JSX.Element => ( - - ) + ): React.JSX.Element => const inBrowserAction = flow.action === 'sign-in' || flow.action === 'choose-plan' || @@ -195,12 +193,13 @@ function StepActions({

    {t('cloudSetupPage.signIn.didNotFinish', 'Sign-in didn’t finish. Try again.')}

    - {browser(continueLabel, flow.continueInBrowser)} + {browser(continueLabel, flow.continueInBrowser, flow.continueLink)}
    ) : ( - browser(continueLabel, flow.continueInBrowser) + browser(continueLabel, flow.continueInBrowser, flow.continueLink) ) case 'choose-plan': + // Copy link is for sign-in only: a plan is chosen in the browser that opens. return browser(continueLabel, flow.continueInBrowser) case 'fix-billing': // A failed payment is fixed in the billing portal (browser); hours and the cap in Settings. diff --git a/src/renderer/src/components/orca-cloud-setup/use-cloud-setup-flow.ts b/src/renderer/src/components/orca-cloud-setup/use-cloud-setup-flow.ts index cfc3dd381d0..63b24df5ada 100644 --- a/src/renderer/src/components/orca-cloud-setup/use-cloud-setup-flow.ts +++ b/src/renderer/src/components/orca-cloud-setup/use-cloud-setup-flow.ts @@ -2,6 +2,7 @@ // rows from billing, the GitHub repository check and the agent accounts (useCloudStartSteps), the // one-trip setup session (#255) with its deep-link return (#257), B2's team switch, and the // tracker's error parsing (#260). Only the UI moved: from a card in the dialog to this page. +import { mintFirstClickLink } from '@/components/orca-sign-in-link' import { usePlanCatalog } from '@/components/billing/plans/use-plan-catalog' import { cloudContactUrl } from './cloud-contact-url' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -95,6 +96,8 @@ export type CloudSetupFlow = CloudSetupPageModel & { canManageBilling: boolean teamAction: React.ReactNode | null continueInBrowser: () => void + /** "Copy link" on the first "Continue in browser": the same trip, started without the browser. */ + continueLink: ExternalLinkSource startTrial: () => void fixBilling: () => void installGitHub: () => void @@ -190,51 +193,67 @@ export function useCloudSetupFlow( const signingIn = busy && startedSignIn && signInPending // One browser trip for everything still open from here (sign-in, team, trial or Checkout, GitHub). - const continueInBrowser = useCallback(() => { - const needs = sessionNeedsFrom(stepsRef.current) - if (needs.length > 0 && needs.every((need) => need === 'github')) { - markInBrowser() - void openInstall() - return - } - const start = getCloudSetupStarter() - if (!start) { - markInBrowser() - fallback() - return - } - // Each click is a new run. An older run that ends late (its sign-in was replaced or cancelled) - // must not touch the page or open anything. - const run = ++runRef.current - const isCurrent = (): boolean => run === runRef.current - setBusy(true) - setBrowserStep(null) - setStartedSignIn(cloudSetupPageModel(stepsRef.current).current?.id === 'account') - void start({ repoId: request.repoId, needs }) - .then((opened) => { - if (!isCurrent() || (opened && 'cancelled' in opened)) { - // Cancelled, timed out or replaced: back to the step's own button; nothing reopens. + // COPY-LINK-FIRST-CLICK: `openBrowser: false` makes the same trip without the browser and hands + // its link to `onUrl` (signed in: the session's; signed out: the sign-in publishes its own). + const runBrowserTrip = useCallback( + (copy?: { onUrl: (url: string | null) => void }) => { + const needs = sessionNeedsFrom(stepsRef.current) + if (needs.length > 0 && needs.every((need) => need === 'github')) { + if (copy) { + copy.onUrl(null) return } markInBrowser() - if (opened) { - setSessionId(opened.sessionId) - } else { - fallback() - } - }) - .catch(() => { - if (isCurrent()) { - fallback() - } - }) - .finally(() => { - if (isCurrent()) { - setBusy(false) - setStartedSignIn(false) - } - }) - }, [fallback, markInBrowser, openInstall, request.repoId]) + void openInstall() + return + } + const start = getCloudSetupStarter() + if (!start) { + copy?.onUrl(null) + markInBrowser() + fallback() + return + } + // Each click is a new run. An older run that ends late (its sign-in was replaced or cancelled) + // must not touch the page or open anything. + const run = ++runRef.current + const isCurrent = (): boolean => run === runRef.current + setBusy(true) + setBrowserStep(null) + setStartedSignIn(cloudSetupPageModel(stepsRef.current).current?.id === 'account') + void start({ repoId: request.repoId, needs, ...(copy ? { openBrowser: false } : {}) }) + .then((opened) => { + if (!isCurrent() || (opened && 'cancelled' in opened)) { + // Cancelled, timed out or replaced: back to the step's own button; nothing reopens. + return + } + markInBrowser() + if (opened) { + copy?.onUrl(opened.url ?? null) + setSessionId(opened.sessionId) + } else { + fallback() + } + }) + .catch(() => { + if (isCurrent()) { + fallback() + } + }) + .finally(() => { + if (isCurrent()) { + setBusy(false) + setStartedSignIn(false) + } + }) + }, + [fallback, markInBrowser, openInstall, request.repoId] + ) + const continueInBrowser = useCallback(() => runBrowserTrip(), [runBrowserTrip]) + const continueLink = useCallback( + () => mintFirstClickLink((deliver) => runBrowserTrip({ onUrl: deliver })), + [runBrowserTrip] + ) const cancelSignIn = useCallback(() => { void window.api.orcaProfiles.cancelSignIn().catch(() => {}) @@ -292,6 +311,7 @@ export function useCloudSetupFlow( canManageBilling: access.state === 'no-plan' ? access.canManageBilling : true, teamAction: switchTeam?.action ?? null, continueInBrowser, + continueLink, startTrial, fixBilling, installGitHub, diff --git a/src/renderer/src/components/orca-sign-in-link.test.ts b/src/renderer/src/components/orca-sign-in-link.test.ts new file mode 100644 index 00000000000..01c6520146f --- /dev/null +++ b/src/renderer/src/components/orca-sign-in-link.test.ts @@ -0,0 +1,50 @@ +// COPY-LINK-FIRST-CLICK: the first "Sign in" / "Continue in browser" copies the link of the same +// trip a click would make, started without the browser. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { mintFirstClickLink } from './orca-sign-in-link' + +const progress = (link: string | null) => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the minter reads only the link. + ({ link }) as never + +beforeEach(() => { + useAppStore.setState({ orcaProfileSignInProgress: null }) +}) +afterEach(() => { + vi.useRealTimers() +}) + +describe("the first click's Copy link", () => { + it('a sign-in already out: copies its link and starts nothing', async () => { + useAppStore.setState({ orcaProfileSignInProgress: progress('https://auth/a?state=1') }) + const start = vi.fn() + await expect(mintFirstClickLink(start)).resolves.toBe('https://auth/a?state=1') + expect(start).not.toHaveBeenCalled() + }) + + it('starts the sign-in without the browser and copies the link main publishes', async () => { + const start = vi.fn(() => { + setTimeout(() => + useAppStore.setState({ orcaProfileSignInProgress: progress('https://auth/b') }) + ) + }) + await expect(mintFirstClickLink(start)).resolves.toBe('https://auth/b') + expect(start).toHaveBeenCalledTimes(1) + }) + + it('signed in, setup hands its session link back directly', async () => { + await expect(mintFirstClickLink((deliver) => deliver('https://setup/s'))).resolves.toBe( + 'https://setup/s' + ) + }) + + it('no link (no setup, or nothing published in time): the copy fails rather than copying nothing', async () => { + await expect(mintFirstClickLink((deliver) => deliver(null))).rejects.toThrow() + vi.useFakeTimers() + const waiting = mintFirstClickLink(() => undefined, 1_000) + const settled = expect(waiting).rejects.toThrow() + await vi.advanceTimersByTimeAsync(1_000) + await settled + }) +}) diff --git a/src/renderer/src/components/orca-sign-in-link.ts b/src/renderer/src/components/orca-sign-in-link.ts new file mode 100644 index 00000000000..a010630b2b9 --- /dev/null +++ b/src/renderer/src/components/orca-sign-in-link.ts @@ -0,0 +1,58 @@ +// COPY-LINK-FIRST-CLICK: the link of a browser trip that hasn't started yet (Orca's first "Sign in", +// the setup page's first "Continue in browser"). Copying starts the same flow a click would, without +// the browser, and copies its link: the sign-in's loopback link once main publishes it, or the +// setup session's link when the person is already signed in. A private window on this computer +// then completes it. An unused link ends the way an unanswered click does: the sign-in's own +// timeout closes main's listener (Cancel too), and a setup session expires on the server. +import { useAppStore } from '@/store' +import { translate } from '@/i18n/i18n' +import { requireLink } from './open-external/copy-external-link' + +/** How long to wait for the flow to have a link (main's listener up, or the session created). */ +export const FIRST_CLICK_LINK_WAIT_MS = 10_000 + +/** + * The pending sign-in's link if one is out; otherwise starts the flow (`start`, which may also hand a + * link back directly through `deliver`) and resolves the first link either way. + */ +export function mintFirstClickLink( + start: (deliver: (url: string | null) => void) => void, + waitMs: number = FIRST_CLICK_LINK_WAIT_MS +): Promise { + const current = useAppStore.getState().orcaProfileSignInProgress?.link ?? null + if (current) { + return Promise.resolve(current) + } + return new Promise((resolve, reject) => { + let settled = false + const finish = (link: string | null): void => { + if (settled) { + return + } + settled = true + unsubscribe() + clearTimeout(timer) + try { + resolve(requireLink(link)) + } catch (error) { + reject(error) + } + } + const unsubscribe = useAppStore.subscribe((state) => { + const link = state.orcaProfileSignInProgress?.link ?? null + if (link) { + finish(link) + } + }) + const timer = setTimeout(() => finish(null), waitMs) + start(finish) + }) +} + +/** + * Where a copied Orca sign-in link works: its callback is this computer's loopback listener, so a + * private window or another browser profile here, never another machine. Only these links say so. + */ +export function signInLinkHint(): string { + return translate('openExternal.signInLinkHint', 'Open it in any browser on this computer.') +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 8ef21b2d308..9ec9f59fe84 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -29488,6 +29488,7 @@ "copyLink": "Copy link", "moreOptions": "More link options", "copyLinkFailed": "Couldn’t copy the link.", - "linkCopied": "Link copied" + "linkCopied": "Link copied", + "signInLinkHint": "Open it in any browser on this computer." } } diff --git a/src/renderer/src/lib/orca-cloud/cloud-setup-session-seam.ts b/src/renderer/src/lib/orca-cloud/cloud-setup-session-seam.ts index d0ae500d0a3..466ebc0128e 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-setup-session-seam.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-setup-session-seam.ts @@ -41,6 +41,8 @@ export type CloudSetupStartRequest = { repoId: string /** The rows still open when the user clicked (account, team, plan, github). */ needs: CloudSetupStep['id'][] + /** COPY-LINK-FIRST-CLICK: false starts the same trip without opening the browser. */ + openBrowser?: boolean } /** * Null when the session couldn't open; the composer then falls back to today's actions. @@ -48,7 +50,7 @@ export type CloudSetupStartRequest = { */ type SessionStarter = ( request: CloudSetupStartRequest -) => Promise<{ sessionId: string } | { cancelled: true } | null> +) => Promise<{ sessionId: string; url?: string } | { cancelled: true } | null> let source: SessionSource | null = null let starter: SessionStarter | null = null diff --git a/src/renderer/src/lib/orca-cloud/cloud-setup-session.ts b/src/renderer/src/lib/orca-cloud/cloud-setup-session.ts index 51199abb3fd..f5f7de5ce2f 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-setup-session.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-setup-session.ts @@ -176,20 +176,25 @@ export function stopWatchingCloudSetupSession(sessionId: string): void { * What "Set up and start" got back: the session it opened, `cancelled` when the user cancelled the * sign-in (or it timed out, or a newer one replaced it), or null when setup couldn't open. */ -export type CloudSetupSessionStart = { sessionId: string } | { cancelled: true } | null +export type CloudSetupSessionStart = + | { sessionId: string; url?: string } + | { cancelled: true } + | null /** "Set up and start": opens the one browser tab and starts polling. */ export async function startCloudSetupSession(request: { repo?: string needs: CloudSetupNeed[] chooseTeam?: boolean + openBrowser?: boolean }): Promise { let result: CloudSetupStartResult try { result = await window.api.orcaProfiles.cloudSetupStart({ ...(request.repo ? { repo: request.repo } : {}), needs: request.needs, - ...(request.chooseTeam ? { chooseTeam: true } : {}) + ...(request.chooseTeam ? { chooseTeam: true } : {}), + ...(request.openBrowser === false ? { openBrowser: false } : {}) }) } catch { return null @@ -202,7 +207,9 @@ export async function startCloudSetupSession(request: { return null } watchCloudSetupSession(result.sessionId) - return { sessionId: result.sessionId } + return result.url + ? { sessionId: result.sessionId, url: result.url } + : { sessionId: result.sessionId } } /** The setup session still open in this window, for the plans dialog to join (T2 Compare plans). */ diff --git a/src/renderer/src/lib/orca-cloud/install-cloud-setup-session.ts b/src/renderer/src/lib/orca-cloud/install-cloud-setup-session.ts index 90612da475f..f9618da72e8 100644 --- a/src/renderer/src/lib/orca-cloud/install-cloud-setup-session.ts +++ b/src/renderer/src/lib/orca-cloud/install-cloud-setup-session.ts @@ -76,7 +76,8 @@ export function installCloudSetupSession(): () => void { const repoUrl = githubRepoUrlFor(repo?.gitRemoteIdentity?.remoteUrl) return startCloudSetupSession({ ...(repoUrl ? { repo: repoUrl } : {}), - needs: sessionNeedsFor(request.needs, repoUrl) + needs: sessionNeedsFor(request.needs, repoUrl), + ...(request.openBrowser === false ? { openBrowser: false } : {}) }) }) const unsubscribe = subscribeCloudSetupSessions(() => notifyCloudSetupSessionChanged()) diff --git a/src/renderer/src/store/slices/orca-profiles/orca-profiles-auth-actions.ts b/src/renderer/src/store/slices/orca-profiles/orca-profiles-auth-actions.ts index cfa2bea7af3..b98cdb03def 100644 --- a/src/renderer/src/store/slices/orca-profiles/orca-profiles-auth-actions.ts +++ b/src/renderer/src/store/slices/orca-profiles/orca-profiles-auth-actions.ts @@ -16,7 +16,10 @@ export type OrcaProfilesAuthActions = { orgId?: string name?: string }) => Promise - connectCurrentOrcaProfile: () => Promise + /** COPY-LINK-FIRST-CLICK: `openBrowser: false` starts sign-in without opening the browser. */ + connectCurrentOrcaProfile: (args?: { + openBrowser?: boolean + }) => Promise refreshCurrentOrcaProfileAuth: () => Promise signOutCurrentOrcaProfile: () => Promise selectOrcaProfileOrg: (orgId: string | null) => Promise @@ -74,11 +77,11 @@ export const createOrcaProfilesAuthActions: StateCreator< } }, - connectCurrentOrcaProfile: async () => { + connectCurrentOrcaProfile: async (args) => { const attempt = ++nextConnectAttempt try { // Pending PKCE must not block retry; newer linked waits supersede older callbacks. - const result = await window.api.orcaProfiles.connectCurrent() + const result = await window.api.orcaProfiles.connectCurrent(args) if (attempt < appliedConnectAttempt) { return result } diff --git a/src/shared/orca-cloud-setup.ts b/src/shared/orca-cloud-setup.ts index a1477682041..b11788c7a13 100644 --- a/src/shared/orca-cloud-setup.ts +++ b/src/shared/orca-cloud-setup.ts @@ -13,10 +13,21 @@ export type CloudSetupStartArgs = { needs: CloudSetupNeed[] /** Ask which team even when the profile already has an active one. */ chooseTeam?: boolean + /** + * COPY-LINK-FIRST-CLICK: false starts the same setup without opening the browser; a signed-in + * start then returns the session's link (`url`) to copy instead. + */ + openBrowser?: boolean } export type CloudSetupStartResult = - | { status: 'opened'; sessionId: string; expiresAt: number } + | { + status: 'opened' + sessionId: string + expiresAt: number + /** Only for `openBrowser: false`: the link the browser would have opened. */ + url?: string + } | { status: 'unconfigured' } | { status: 'signed-out' } | { status: 'cancelled' } From 18a79bcf4bb2dc4f57ebf15beb50b7eb38215f24 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 13 Nov 2026 00:12:10 -0800 Subject: [PATCH 50/50] fix(cloud): MFA toast uses a plain button; toasts no longer take a Copy link source --- src/renderer/src/components/team-mfa/TeamMfa.fixture.tsx | 2 +- src/renderer/src/components/team-mfa/TeamMfaAppBanner.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/team-mfa/TeamMfa.fixture.tsx b/src/renderer/src/components/team-mfa/TeamMfa.fixture.tsx index fd4509e29fe..ee234a16b22 100644 --- a/src/renderer/src/components/team-mfa/TeamMfa.fixture.tsx +++ b/src/renderer/src/components/team-mfa/TeamMfa.fixture.tsx @@ -77,7 +77,7 @@ export const toast = frame( { key: 'mfa', text: 'Acme Engineering requires MFA from December 2, 2026.', - action: { label: 'Set up MFA', primary: true, link: setupUrl, onClick: noop } + action: { label: 'Set up MFA', primary: true, onClick: noop } } ] }} diff --git a/src/renderer/src/components/team-mfa/TeamMfaAppBanner.tsx b/src/renderer/src/components/team-mfa/TeamMfaAppBanner.tsx index f2d02d290be..4d2a862bb06 100644 --- a/src/renderer/src/components/team-mfa/TeamMfaAppBanner.tsx +++ b/src/renderer/src/components/team-mfa/TeamMfaAppBanner.tsx @@ -42,8 +42,8 @@ export function TeamMfaSidebarBanner(): React.JSX.Element | null { action: { label: t('auto.components.teamMfa.setUp', 'Set up MFA'), primary: true, - // Opens the account page in the browser, so the chevron offers Copy link. - link: toast.setupUrl, + // A plain button: toasts no longer carry a Copy link chevron (AUTH-ONLY-COPY-LINK). + // The sidebar card and Settings, Account keep the split button. onClick: () => { void window.api.shell.openUrl(toast.setupUrl) hideCloudToast(id)