From 7ce2885149eb2d07f3630692b9283f21ab1299e1 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 20:32:53 -0800 Subject: [PATCH 1/4] feat(cloud): JA-16 one Orca Cloud settings group led by Agent accounts; Orca account is identity + Sign out --- .../accounts/orca/OrcaAccountSettingsPane.tsx | 83 ++++++++++--------- .../orca-cloud/OrcaCloudSettings.fixture.tsx | 11 --- .../orca-cloud/OrcaCloudSettingsLinks.tsx | 54 ------------ ...tings-setup-workflow-section-renderers.tsx | 5 +- ...settings-navigation-capability-sections.ts | 4 +- ...ngs-navigation-orca-cloud-sections.test.ts | 50 ++++++++++- ...settings-navigation-orca-cloud-sections.ts | 22 +++++ .../hooks/useSettingsNavigationMetadata.ts | 24 ++++-- .../src/i18n/en-runtime-required.json | 3 + src/renderer/src/i18n/locales/en.json | 2 +- 10 files changed, 134 insertions(+), 124 deletions(-) delete mode 100644 src/renderer/src/components/settings/orca-cloud/OrcaCloudSettingsLinks.tsx diff --git a/src/renderer/src/components/settings/accounts/orca/OrcaAccountSettingsPane.tsx b/src/renderer/src/components/settings/accounts/orca/OrcaAccountSettingsPane.tsx index f730b1ed4c3..3ed751b6d8c 100644 --- a/src/renderer/src/components/settings/accounts/orca/OrcaAccountSettingsPane.tsx +++ b/src/renderer/src/components/settings/accounts/orca/OrcaAccountSettingsPane.tsx @@ -10,7 +10,6 @@ import { useTranslate, type Translate } from '@/i18n/use-translate' import { useAppStore } from '@/store/index' import { OrcaProfileSignOutConfirmDialog } from '../../../orca-profiles/OrcaProfileSignOutConfirmDialog' import { OrcaSignInButton, OrcaSignInWaitNotice } from '../../../OrcaSignInProgress' -import { OrcaCloudSettingsLinks } from '../../orca-cloud/OrcaCloudSettingsLinks' import { useOrcaCloudApiAvailable } from '@/hooks/useOrcaCloudApiAvailability' function accountStatusCopy( @@ -68,7 +67,6 @@ export function OrcaAccountSettingsPane(): React.JSX.Element { const t = useTranslate() const authStatus = useAppStore((state) => state.orcaProfileAuthStatus) const signOut = useAppStore((state) => state.signOutCurrentOrcaProfile) - const openSettingsTarget = useAppStore((state) => state.openSettingsTarget) const cloudAvailable = useOrcaCloudApiAvailable() const [signOutOpen, setSignOutOpen] = useState(false) const [signingOut, setSigningOut] = useState(false) @@ -133,51 +131,54 @@ export function OrcaAccountSettingsPane(): React.JSX.Element { {connected ? null : } - {connected && cloudAvailable ? ( - openSettingsTarget({ pane, repoId: null })} /> - ) : null} - -
- -
-
+ {/* JA-16: with Orca Cloud the page is identity + Sign out only; plan, team and agents have + their own entries in the Orca Cloud group right beside it. */} + {cloudAvailable ? null : ( +
+ +
+
+ + +
+ {/* Put the third benefit below at full width so it cannot squeeze the existing pair. */} -
- {/* Put the third benefit below at full width so it cannot squeeze the existing pair. */} -
-
+ )}
{} @@ -197,16 +196,6 @@ export const sessionExpired = defineFixture({ ) }) -/** Settings › Orca account, signed in: the Orca Cloud panes are one click away. */ -export const accountLinks = defineFixture({ - width: 720, - render: () => ( -
- -
- ) -}) - /** Settings › Agent API keys header: desktop keys are not for Orca Cloud; link to Cloud secrets. */ export const agentApiKeysHeader = defineFixture({ width: 784, diff --git a/src/renderer/src/components/settings/orca-cloud/OrcaCloudSettingsLinks.tsx b/src/renderer/src/components/settings/orca-cloud/OrcaCloudSettingsLinks.tsx deleted file mode 100644 index d5b204c71f6..00000000000 --- a/src/renderer/src/components/settings/orca-cloud/OrcaCloudSettingsLinks.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { Boxes, ChevronRight, CreditCard, LockKeyhole, Plug, Users } from 'lucide-react' -import type { LucideIcon } from 'lucide-react' -import { SettingsGroupHeader } from '@/components/settings/section-layout/SettingsGroup' -import { useTranslate } from '@/i18n/use-translate' -import type { SettingsNavTarget } from '@/lib/settings-navigation-types' - -type OrcaCloudLink = { pane: SettingsNavTarget; icon: LucideIcon; label: string } - -/** Settings › Orca account: the Orca Cloud panes live in their own group; this points at them. */ -export function OrcaCloudSettingsLinks({ - onOpen -}: { - onOpen: (pane: SettingsNavTarget) => void -}): React.JSX.Element { - const t = useTranslate() - const links: OrcaCloudLink[] = [ - { - pane: 'cloud-billing', - icon: CreditCard, - label: t('auto.components.billing.title', 'Plan & billing') - }, - { pane: 'cloud-team', icon: Users, label: t('auto.components.billing.team.title', 'Team') }, - { pane: 'cloud-secrets', icon: LockKeyhole, label: t('cloudSecrets.title', 'Cloud secrets') }, - { - pane: 'cloud-environments', - icon: Boxes, - label: t('settings.orcaCloud.environments.title', 'Cloud environments') - }, - { pane: 'cloud-mcp', icon: Plug, label: t('cloudMcpSync.title', 'MCP servers') } - ] - return ( - - ) -} diff --git a/src/renderer/src/components/settings/page/settings-setup-workflow-section-renderers.tsx b/src/renderer/src/components/settings/page/settings-setup-workflow-section-renderers.tsx index 2a10abea1fc..753b0547c30 100644 --- a/src/renderer/src/components/settings/page/settings-setup-workflow-section-renderers.tsx +++ b/src/renderer/src/components/settings/page/settings-setup-workflow-section-renderers.tsx @@ -24,10 +24,7 @@ export function renderOrcaAccountSettingsSection( title={t('auto.components.settings.orcaAccount.title', 'Orca account')} description={ model.orcaCloudApiAvailable - ? t( - 'settings.orcaAccount.descriptionWithCloud', - 'Sign-in, plan, team and Orca Cloud settings.' - ) + ? t('settings.orcaAccount.descriptionIdentity', 'The Orca account you’re signed in with.') : t( 'auto.components.settings.orcaAccount.description', 'Share work instantly and reach your desktop from Orca Mobile wherever you are.' diff --git a/src/renderer/src/hooks/settings-navigation-capability-sections.ts b/src/renderer/src/hooks/settings-navigation-capability-sections.ts index a8f1a273ebc..a15e16d6c54 100644 --- a/src/renderer/src/hooks/settings-navigation-capability-sections.ts +++ b/src/renderer/src/hooks/settings-navigation-capability-sections.ts @@ -170,8 +170,8 @@ export function buildSetupSettingsSections( title: t('auto.components.settings.orcaAccount.title', 'Orca account'), description: isOrcaCloudApiAvailable ? t( - 'settings.orcaAccount.descriptionWithCloud', - 'Sign-in, plan, team and Orca Cloud settings.' + 'settings.orcaAccount.descriptionIdentity', + 'The Orca account you’re signed in with.' ) : t( 'auto.components.settings.orcaAccount.description', diff --git a/src/renderer/src/hooks/settings-navigation-orca-cloud-sections.test.ts b/src/renderer/src/hooks/settings-navigation-orca-cloud-sections.test.ts index de57a415f0f..15b35d93f37 100644 --- a/src/renderer/src/hooks/settings-navigation-orca-cloud-sections.test.ts +++ b/src/renderer/src/hooks/settings-navigation-orca-cloud-sections.test.ts @@ -1,7 +1,12 @@ +import { Bot } from 'lucide-react' +import type { SettingsNavSection } from '@/lib/settings-navigation-types' import { describe, expect, it } from 'vitest' import { buildSetupSettingsSections } from './settings-navigation-capability-sections' import type { SettingsNavigationBuildOptions } from './settings-navigation-build-options' -import { buildOrcaCloudSettingsSections } from './settings-navigation-orca-cloud-sections' +import { + buildOrcaCloudSettingsSections, + gatherOrcaCloudSettingsGroup +} from './settings-navigation-orca-cloud-sections' function options(patch: Partial): SettingsNavigationBuildOptions { return { @@ -45,7 +50,7 @@ describe('Orca Cloud settings group', () => { const setup = buildSetupSettingsSections(options({})) expect(setup.some((section) => section.id.startsWith('cloud-'))).toBe(false) expect(setup.find((section) => section.id === 'orca-account')?.description).toBe( - 'Sign-in, plan, team and Orca Cloud settings.' + 'The Orca account you’re signed in with.' ) const local = buildSetupSettingsSections(options({ isOrcaCloudApiAvailable: false })) expect(local.find((section) => section.id === 'orca-account')?.description).not.toMatch( @@ -53,3 +58,44 @@ describe('Orca Cloud settings group', () => { ) }) }) + +describe('one Orca Cloud group (JA-16)', () => { + const nav = (id: string, group: SettingsNavSection['group']): SettingsNavSection => ({ + id, + title: id, + description: '', + icon: Bot, + searchEntries: [], + group + }) + + const all = [ + nav('agents', 'capabilities'), + nav('accounts', 'capabilities'), + nav('orca-account', 'setup'), + nav('general', 'setup'), + nav('cloud-billing', 'orca-cloud'), + nav('cloud-team', 'orca-cloud'), + nav('automations', 'workflows') + ] + + it('leads the Orca Cloud group with Agent accounts and the Orca account', () => { + const out = gatherOrcaCloudSettingsGroup(all, options({})) + expect(out.map((section) => `${section.group}:${section.id}`)).toEqual([ + 'capabilities:agents', + 'setup:general', + 'orca-cloud:accounts', + 'orca-cloud:orca-account', + 'orca-cloud:cloud-billing', + 'orca-cloud:cloud-team', + 'workflows:automations' + ]) + }) + + it('leaves the upstream grouping alone without Orca Cloud', () => { + expect(gatherOrcaCloudSettingsGroup(all, options({ isOrcaCloudApiAvailable: false }))).toEqual( + all + ) + expect(gatherOrcaCloudSettingsGroup(all, options({ isWebClient: true }))).toEqual(all) + }) +}) 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 8384c60f7d1..a23d3bd819c 100644 --- a/src/renderer/src/hooks/settings-navigation-orca-cloud-sections.ts +++ b/src/renderer/src/hooks/settings-navigation-orca-cloud-sections.ts @@ -104,3 +104,25 @@ export function buildOrcaCloudSettingsSections( } ] } + +/** + * JA-16: in Orca Cloud builds every cloud entry sits in one group, led by Agent accounts and the + * Orca account, instead of being split across AI capabilities, Set up and Orca Cloud. + */ +export function gatherOrcaCloudSettingsGroup( + sections: readonly SettingsNavSection[], + { isWebClient, isOrcaCloudApiAvailable }: SettingsNavigationBuildOptions +): SettingsNavSection[] { + if (isWebClient || !isOrcaCloudApiAvailable) { + return [...sections] + } + const leadIds = ['accounts', 'orca-account'] + const leads = leadIds + .map((id) => sections.find((section) => section.id === id)) + .filter((section): section is SettingsNavSection => section !== undefined) + .map((section) => ({ ...section, group: 'orca-cloud' as const })) + const rest = sections.filter((section) => !leadIds.includes(section.id)) + const firstCloud = rest.findIndex((section) => section.group === 'orca-cloud') + const at = firstCloud === -1 ? rest.length : firstCloud + return [...rest.slice(0, at), ...leads, ...rest.slice(at)] +} diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts index 5f8a00fc898..8aa97b29d71 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts @@ -31,7 +31,10 @@ import { buildSetupSettingsSections } from './settings-navigation-capability-sections' import type { SettingsNavigationBuildOptions } from './settings-navigation-build-options' -import { buildOrcaCloudSettingsSections } from './settings-navigation-orca-cloud-sections' +import { + buildOrcaCloudSettingsSections, + gatherOrcaCloudSettingsGroup +} from './settings-navigation-orca-cloud-sections' import { buildInterfaceSettingsSections } from './settings-navigation-interface-sections' import { buildRemoteSettingsSections } from './settings-navigation-remote-sections' import { buildWorkflowSettingsSections } from './settings-navigation-workflow-sections' @@ -110,14 +113,17 @@ export function buildSettingsNavigationMetadata( } // Match SETTINGS_NAV_GROUPS order so Settings and Cmd+J keep siblings together. - return [ - ...buildCapabilitySettingsSections(options, t), - ...buildSetupSettingsSections(options, t), - ...buildOrcaCloudSettingsSections(options, t), - ...buildWorkflowSettingsSections(options, terminalPaneSearchEntries, t), - ...buildInterfaceSettingsSections(options, t), - ...buildRemoteSettingsSections(options, runtimeEnvironmentsSearchEntry, reposById, t) - ] + return gatherOrcaCloudSettingsGroup( + [ + ...buildCapabilitySettingsSections(options, t), + ...buildSetupSettingsSections(options, t), + ...buildOrcaCloudSettingsSections(options, t), + ...buildWorkflowSettingsSections(options, terminalPaneSearchEntries, t), + ...buildInterfaceSettingsSections(options, t), + ...buildRemoteSettingsSections(options, runtimeEnvironmentsSearchEntry, reposById, t) + ], + options + ) } export function useSettingsNavigationMetadata(): SettingsNavSection[] { diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 7208c28db92..4c295f6dceb 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2478,6 +2478,9 @@ "sendingNotConfigured": "sending diagnostics is not configured for this build" } }, + "groups": { + "orcaCloud": "Orca Cloud" + }, "mcp": { "serverCount_one": "{{count}} server", "serverCount_other": "{{count}} servers" diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 37135bf63c2..5b7d5b55ccc 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1397,7 +1397,7 @@ "orcaCloud": "Orca Cloud" }, "orcaAccount": { - "descriptionWithCloud": "Sign-in, plan, team and Orca Cloud settings." + "descriptionIdentity": "The Orca account you’re signed in with." }, "agentAccounts": { "searchTitle": "Agent accounts", From 69567b9ecf3f1f603fb4f20efe68946ab4f70044 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 21:02:55 -0800 Subject: [PATCH 2/4] fix(cloud-routines): routine creator shows a display name, never a raw id (AG-3) --- .../settings/cloud-api/CloudRoutineRow.tsx | 31 ++++++++++++------- .../settings/cloud-api/CloudRoutinesPane.tsx | 16 ++++++++++ .../settings/cloud-api/CloudRoutinesView.tsx | 3 ++ .../cloud-api/cloud-api-format.test.ts | 20 ++++++++++++ src/renderer/src/i18n/locales/en.json | 3 +- 5 files changed, 61 insertions(+), 12 deletions(-) diff --git a/src/renderer/src/components/settings/cloud-api/CloudRoutineRow.tsx b/src/renderer/src/components/settings/cloud-api/CloudRoutineRow.tsx index aad30e0e498..b65d06314be 100644 --- a/src/renderer/src/components/settings/cloud-api/CloudRoutineRow.tsx +++ b/src/renderer/src/components/settings/cloud-api/CloudRoutineRow.tsx @@ -21,14 +21,13 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { Switch } from '@/components/ui/switch' -import { useIntlLocale, useTranslate } from '@/i18n/use-translate' +import { useIntlLocale, useTranslate, type Translate } from '@/i18n/use-translate' import { cn } from '@/lib/utils' import type { CloudApiProject, Routine, RoutineRun } from '../../../../../shared/cloud-api/cloud-api-types' -import { PersonSentence } from './PersonName' import { RoutineRunHistory, type LoadRoutineRuns } from './RoutineRunHistory' import { describeRoutineTrigger, @@ -62,7 +61,8 @@ export function RoutineRow({ onLatestRun, onOpenWorkspace, fixedNow, - viewerUserId + viewerUserId, + memberNames }: { routine: Routine projects: readonly CloudApiProject[] @@ -79,6 +79,7 @@ export function RoutineRow({ onOpenWorkspace?: (workspaceId: string) => void fixedNow?: number viewerUserId: string | null + memberNames?: ReadonlyMap }): React.JSX.Element { const t = useTranslate() const locale = useIntlLocale() @@ -164,14 +165,9 @@ export function RoutineRow({ {viewerUserId && routine.createdBy !== viewerUserId ? ( {' · '} - ' - )} - name={routine.createdByName} - id={routine.createdBy} - /> + {t('auto.components.settings.cloudRoutines.createdByName', 'by {{name}}', { + name: creatorName(routine, memberNames, t) + })} ) : null}

@@ -321,3 +317,16 @@ export function RoutineRow({ ) } + +/** Display name only, never a raw id: the server's name, then the team roster, then "Former member". */ +export function creatorName( + routine: Pick, + memberNames: ReadonlyMap | undefined, + t: Translate +): string { + return ( + routine.createdByName?.trim() || + memberNames?.get(routine.createdBy)?.trim() || + t('auto.components.settings.cloudRoutines.formerMember', 'Former member') + ) +} diff --git a/src/renderer/src/components/settings/cloud-api/CloudRoutinesPane.tsx b/src/renderer/src/components/settings/cloud-api/CloudRoutinesPane.tsx index 722154f9d97..57daf01a789 100644 --- a/src/renderer/src/components/settings/cloud-api/CloudRoutinesPane.tsx +++ b/src/renderer/src/components/settings/cloud-api/CloudRoutinesPane.tsx @@ -16,6 +16,7 @@ import { localTimeZone } from './cloud-api-format' import { toCloudLoadState, type CloudApiFailure, type CloudLoadState } from './cloud-load-state' import { useOrcaCloudApiAvailability } from '@/hooks/useOrcaCloudApiAvailability' import { useRequestGeneration } from './use-request-generation' +import { useOrgRoster } from '../../billing/team/use-org-roster' const RUNS_PAGE_SIZE = 20 @@ -48,6 +49,20 @@ export function CloudRoutinesPane(): React.JSX.Element | null { const [installations, setInstallations] = useState(null) const [now, setNow] = useState(() => Date.now()) const timezone = useMemo(() => localTimeZone(), []) + // Creators the server can't name come from the team roster, the same directory Home uses. + const orgId = useAppStore((s) => s.orcaProfileAuthStatus?.cloud?.activeOrgId ?? null) + const roster = useOrgRoster(orgId) + const memberNames = useMemo(() => { + const names = new Map() + if (roster.result?.status === 'ok') { + for (const m of roster.result.roster.members) { + if (m.userId && (m.displayName || m.email)) { + names.set(m.userId, m.displayName || m.email) + } + } + } + return names + }, [roster.result]) const word = useCallback( (failure: CloudApiFailure) => cloudApiErrorCopy(failure.error, t, failure), [t] @@ -126,6 +141,7 @@ export function CloudRoutinesPane(): React.JSX.Element | null { onRefresh={() => void load()} refreshing={refreshing} viewerUserId={viewerUserId} + memberNames={memberNames} onOpenWorkspace={(sandboxId) => void openRunWorkspace(sandboxId)} onLatestRun={(routine, run) => { // Keep the row's pill in step with the newest run its open history just fetched. diff --git a/src/renderer/src/components/settings/cloud-api/CloudRoutinesView.tsx b/src/renderer/src/components/settings/cloud-api/CloudRoutinesView.tsx index 2e6b0ca1102..a9f3248b36b 100644 --- a/src/renderer/src/components/settings/cloud-api/CloudRoutinesView.tsx +++ b/src/renderer/src/components/settings/cloud-api/CloudRoutinesView.tsx @@ -48,6 +48,8 @@ export type CloudRoutinesViewProps = { fixtureNow?: number /** Routines someone else created name their creator; null while unknown. */ viewerUserId?: string | null + /** Team roster names by user id (the same directory Home uses), for creators the server can't name. */ + memberNames?: ReadonlyMap /** A refresh is running while the current list stays on screen. */ refreshing?: boolean /** Fixtures: start with the editor, history or delete confirmation open. */ @@ -183,6 +185,7 @@ export function CloudRoutinesView(props: CloudRoutinesViewProps): React.JSX.Elem onOpenWorkspace={props.onOpenWorkspace} fixedNow={props.fixtureNow} viewerUserId={props.viewerUserId ?? null} + memberNames={props.memberNames} onRunNow={async () => { const failure = await props.onRunNow(routine) if (!failure) { diff --git a/src/renderer/src/components/settings/cloud-api/cloud-api-format.test.ts b/src/renderer/src/components/settings/cloud-api/cloud-api-format.test.ts index 86ef124bba5..3e472aa736c 100644 --- a/src/renderer/src/components/settings/cloud-api/cloud-api-format.test.ts +++ b/src/renderer/src/components/settings/cloud-api/cloud-api-format.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest' import { CLOUD_API_SCOPES } from '../../../../../shared/cloud-api/cloud-api-types' +import { creatorName } from './CloudRoutineRow' +import { translate } from '@/i18n/i18n' import { cloudApiErrorCopy, describeRoutineRunError, @@ -137,3 +139,21 @@ describe('run failure tone', () => { ]) }) }) + +describe('routine creator', () => { + it('shows a display name only, never a raw id', () => { + const roster = new Map([['u_lin', 'Lin Chen']]) + expect( + creatorName({ createdBy: 'u_grace', createdByName: 'Grace Hopper' }, roster, translate) + ).toBe('Grace Hopper') + expect(creatorName({ createdBy: 'u_lin', createdByName: null }, roster, translate)).toBe( + 'Lin Chen' + ) + expect(creatorName({ createdBy: 'u_7f3k', createdByName: ' ' }, roster, translate)).toBe( + 'Former member' + ) + expect(creatorName({ createdBy: 'u_7f3k', createdByName: null }, undefined, translate)).toBe( + 'Former member' + ) + }) +}) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index b8388163803..51a5356e373 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -14602,7 +14602,8 @@ }, "cloudRoutines": { "pausedSr": "(paused)", - "createdByPerson": "by ", + "createdByName": "by {{name}}", + "formerMember": "Former member", "lastRun": "Last run:", "neverRun": "Never run", "nextRun": "Next run {{time}}", From 39ffbdd86213d7c9e54907245b38d884d7b5ec7d Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 21:08:10 -0800 Subject: [PATCH 3/4] feat(cloud): MEMBER_REMOVED_TOKEN, a bearer valid only for /v1/internal/org-members/removed Auth pushes PropelAuth offboarding and Orca-side member removals here (stablyai/orca-cloud#739); it should not need the full operator token for that. The operator token is still accepted. --- cloud/apps/sandbox-control/src/env.ts | 6 ++++++ cloud/apps/sandbox-control/src/index.ts | 7 ++++++- .../sandbox-control/test/team-presence.test.ts | 15 +++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/cloud/apps/sandbox-control/src/env.ts b/cloud/apps/sandbox-control/src/env.ts index 08ec6d476de..c0c9c9faa5a 100644 --- a/cloud/apps/sandbox-control/src/env.ts +++ b/cloud/apps/sandbox-control/src/env.ts @@ -132,6 +132,12 @@ export type Env = { /** Operator routes (/v1/internal/*): kill switch, drain, bindings. Secret, >= 32 chars; unset = no routes. */ OPERATOR_TOKEN?: string + /** + * Auth's bearer for POST /v1/internal/org-members/removed only (PropelAuth offboarding and + * Orca-side removals). Secret, >= 32 chars; lets auth push removals without holding the + * operator token. The operator token is still accepted on that route. + */ + MEMBER_REMOVED_TOKEN?: string /** `true` refuses every create/resume/rollover (static kill switch; the D1 flag is the runtime one). */ SANDBOX_CREATES_DISABLED?: string /** Warm pool size (pool.ts); unset or 0 = off. */ diff --git a/cloud/apps/sandbox-control/src/index.ts b/cloud/apps/sandbox-control/src/index.ts index 5edaae7c49a..8107dfbf757 100644 --- a/cloud/apps/sandbox-control/src/index.ts +++ b/cloud/apps/sandbox-control/src/index.ts @@ -1146,7 +1146,12 @@ export async function handleRequest(request: Request, env: Env, deps: Deps = {}) return await internalInstallUrl(env, deps, url) } if (url.pathname.startsWith('/v1/internal/')) { - if (!(await isOperator(request, env.OPERATOR_TOKEN))) { + // Auth's member-removed push holds a token good for that one route, not the operator token. + const memberRemovedOnly = + url.pathname === '/v1/internal/org-members/removed' && + request.method === 'POST' && + (await isOperator(request, env.MEMBER_REMOVED_TOKEN)) + if (!memberRemovedOnly && !(await isOperator(request, env.OPERATOR_TOKEN))) { return fail('not_found', 404) } const life = await lifecycleDeps(env, deps) diff --git a/cloud/apps/sandbox-control/test/team-presence.test.ts b/cloud/apps/sandbox-control/test/team-presence.test.ts index c8e0c256ddd..8a47fe8e2f7 100644 --- a/cloud/apps/sandbox-control/test/team-presence.test.ts +++ b/cloud/apps/sandbox-control/test/team-presence.test.ts @@ -180,3 +180,18 @@ describe('pokes', () => { expect((await t.req(solo, 'GET', '/v1/team/pokes')).body).toEqual({ enabled: false, pokes: [] }) }) }) + +describe('member-removed token', () => { + it('authorizes only POST /v1/internal/org-members/removed, never other operator routes', async () => { + const MR = 'm'.repeat(40) + const body = { accountId: 'org:acme', userId: 'bob' } + const h = await createHarness({ OPERATOR_TOKEN: 'o'.repeat(40), MEMBER_REMOVED_TOKEN: MR }) + expect((await h.call('POST', '/v1/internal/org-members/removed', { bearer: MR, body })).status).toBe(200) + expect((await h.call('GET', '/v1/internal/status', { bearer: MR })).status).toBe(404) + expect((await h.call('POST', '/v1/internal/members/removed', { bearer: MR, body })).status).toBe(404) + // The operator token still works on the route; a short token is never accepted. + expect((await h.call('POST', '/v1/internal/org-members/removed', { bearer: 'o'.repeat(40), body })).status).toBe(200) + const weak = await createHarness({ OPERATOR_TOKEN: 'o'.repeat(40), MEMBER_REMOVED_TOKEN: 'short' }) + expect((await weak.call('POST', '/v1/internal/org-members/removed', { bearer: 'short', body })).status).toBe(404) + }) +}) From 0335884b8aae55f65d437efa11a9cdf5c5b5fe9d Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 21:10:13 -0800 Subject: [PATCH 4/4] fix(cloud): JA-16 keep Agent accounts in AI capabilities; Orca account leads the Orca Cloud group --- .../hooks/settings-navigation-orca-cloud-sections.test.ts | 4 ++-- .../src/hooks/settings-navigation-orca-cloud-sections.ts | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/hooks/settings-navigation-orca-cloud-sections.test.ts b/src/renderer/src/hooks/settings-navigation-orca-cloud-sections.test.ts index 15b35d93f37..6a649081a17 100644 --- a/src/renderer/src/hooks/settings-navigation-orca-cloud-sections.test.ts +++ b/src/renderer/src/hooks/settings-navigation-orca-cloud-sections.test.ts @@ -79,12 +79,12 @@ describe('one Orca Cloud group (JA-16)', () => { nav('automations', 'workflows') ] - it('leads the Orca Cloud group with Agent accounts and the Orca account', () => { + it('leads the Orca Cloud group with the Orca account; Agent accounts stays in AI capabilities', () => { const out = gatherOrcaCloudSettingsGroup(all, options({})) expect(out.map((section) => `${section.group}:${section.id}`)).toEqual([ 'capabilities:agents', + 'capabilities:accounts', 'setup:general', - 'orca-cloud:accounts', 'orca-cloud:orca-account', 'orca-cloud:cloud-billing', 'orca-cloud:cloud-team', 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 a23d3bd819c..0e3c2b78680 100644 --- a/src/renderer/src/hooks/settings-navigation-orca-cloud-sections.ts +++ b/src/renderer/src/hooks/settings-navigation-orca-cloud-sections.ts @@ -106,8 +106,9 @@ export function buildOrcaCloudSettingsSections( } /** - * JA-16: in Orca Cloud builds every cloud entry sits in one group, led by Agent accounts and the - * Orca account, instead of being split across AI capabilities, Set up and Orca Cloud. + * JA-16: in Orca Cloud builds the Orca account leads the one Orca Cloud group instead of sitting in + * Set up. Agent accounts stays in AI capabilities: it covers this computer and hosts as much as + * Orca Cloud, and its Orca Cloud part is deep-linked from every cloud entry point. */ export function gatherOrcaCloudSettingsGroup( sections: readonly SettingsNavSection[], @@ -116,7 +117,7 @@ export function gatherOrcaCloudSettingsGroup( if (isWebClient || !isOrcaCloudApiAvailable) { return [...sections] } - const leadIds = ['accounts', 'orca-account'] + const leadIds = ['orca-account'] const leads = leadIds .map((id) => sections.find((section) => section.id === id)) .filter((section): section is SettingsNavSection => section !== undefined)