Add provider host scope controls

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-06-12 23:05:51 -07:00
co-authored by Orca
parent dcad5f497d
commit 90a89bd649
11 changed files with 268 additions and 59 deletions
@@ -84,9 +84,9 @@ Done means: scheduled or manual automations behave predictably when a project ex
## 6. Settings, Accounts, And Host Ownership
- [~] Clarify which settings are client-owned versus host-owned.
- [~] Add host/account selectors to provider settings where credentials may differ by host.
- [~] Make provider rate limits, diagnostics, and “current account” views show their host/source context.
- [x] Clarify which settings are client-owned versus host-owned.
- [x] Add host/account selectors to provider settings where credentials may differ by host.
- [x] Make provider rate limits, diagnostics, and “current account” views show their host/source context.
- [x] Audit source-control AI, agent defaults, command defaults, and model selections for whether they should be client-global or host-scoped.
- [x] Preserve existing Windows/WSL-style setting patterns where they already solve host-specific settings.
- [x] Ensure remote server version/capability information is visible enough to explain unavailable controls.
@@ -258,3 +258,4 @@ Done means: reviewers and future agents can understand the vision, implementatio
- [x] 2026-06-12: Extracted and tested external automation source availability copy so local missing tooling, disconnected SSH sources, connecting SSH sources, and connected SSH hosts with missing remote tooling get distinct states; verified with focused automation availability tests, targeted oxlint, and full `pnpm run typecheck`.
- [x] 2026-06-12: Marked repo-id/path compatibility adapters complete after hardening the renderer project-host selector to merge repo-derived compatibility rows when hydrated project/setup arrays are empty or partial, alongside existing shared projection, persistence backfill/sync, automation context migration, and pending worktree context coverage. Verified with selector/projection tests, persistence tests, targeted oxlint, full `pnpm run typecheck`, and `git diff --check`.
- [x] 2026-06-12: Marked the Host copy audit complete after replacing remaining visible stale SSH/project wording in paired-web project creation errors, Settings SSH search entries, AI Vault SSH-host workspace copy, and SSH shutdown errors. Verified with a stale-copy scan showing only comments/tests/technical internals, focused SSH IPC tests, targeted oxlint, full `pnpm run typecheck`, and `git diff --check`.
- [x] 2026-06-12: Completed Settings ownership/account rows by adding a shared provider Host scope control with a Change Host action that opens Settings > Active Server from GitHub/GitLab/Linear/Jira account surfaces and GitHub/GitLab API budget panels. Existing ownership diagnostics still distinguish desktop-client versus remote-server credentials, and rate-limit panels show the budget Host scope. Verified with settings ownership, RuntimeEnvironmentsPane, provider account, source-control card, task-tracker card, Jira card, and rate-limit panel tests, targeted oxlint, full `pnpm run typecheck`, and `git diff --check`.
@@ -6,6 +6,7 @@ import { useAppStore } from '@/store'
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import type { GetRateLimitResult, GitHubRateLimitSnapshot } from '../../../../shared/types'
import { getProviderRateLimitScope } from '@/components/settings/provider-account-scope'
import { ProviderHostScopeControl } from '@/components/settings/ProviderHostScopeControl'
import { translate } from '@/i18n/i18n'
const REFRESH_INTERVAL_MS = 60_000
@@ -190,16 +191,14 @@ export function GitHubRateLimitPanel({ className }: { className?: string }): Rea
'Orca uses REST, Search, and GraphQL through the GitHub CLI.'
)}
</p>
<p className="text-xs text-muted-foreground">
<span className="font-medium text-foreground">
{translate(
'auto.components.github.github.rate.limit.display.budget_scope',
'Budget scope: {{value0}}',
{ value0: budgetScope.label }
)}
</span>{' '}
{budgetScope.description}
</p>
<ProviderHostScopeControl
labelPrefix={translate(
'auto.components.github.github.rate.limit.display.budget_scope_prefix',
'Budget scope'
)}
scope={budgetScope}
className="text-xs"
/>
</div>
<button
type="button"
@@ -7,6 +7,7 @@ import { useAppStore } from '@/store'
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import type { GetGitLabRateLimitResult, GitLabRateLimitSnapshot } from '../../../../shared/types'
import { getProviderRateLimitScope } from '@/components/settings/provider-account-scope'
import { ProviderHostScopeControl } from '@/components/settings/ProviderHostScopeControl'
import { translate } from '@/i18n/i18n'
const REFRESH_INTERVAL_MS = 60_000
@@ -167,16 +168,14 @@ export function GitLabRateLimitPanel({ className }: { className?: string }): Rea
'Orca uses REST through the GitLab CLI.'
)}
</p>
<p className="text-xs text-muted-foreground">
<span className="font-medium text-foreground">
{translate(
'auto.components.gitlab.gitlab.rate.limit.display.budget_scope',
'Budget scope: {{value0}}',
{ value0: budgetScope.label }
)}
</span>{' '}
{budgetScope.description}
</p>
<ProviderHostScopeControl
labelPrefix={translate(
'auto.components.gitlab.gitlab.rate.limit.display.budget_scope_prefix',
'Budget scope'
)}
scope={budgetScope}
className="text-xs"
/>
</div>
<Button
type="button"
@@ -0,0 +1,49 @@
import { ServerCog } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useAppStore } from '@/store'
import type { ProviderAccountScope, ProviderRateLimitScope } from './provider-account-scope'
import { translate } from '@/i18n/i18n'
type ProviderHostScopeControlProps = {
labelPrefix: string
scope: ProviderAccountScope | ProviderRateLimitScope
className?: string
}
export function ProviderHostScopeControl({
labelPrefix,
scope,
className
}: ProviderHostScopeControlProps): React.JSX.Element {
const openSettingsPage = useAppStore((state) => state.openSettingsPage)
const openSettingsTarget = useAppStore((state) => state.openSettingsTarget)
const openActiveServer = (): void => {
openSettingsPage()
openSettingsTarget({ pane: 'servers', repoId: null })
}
return (
<div className={className}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<span className="font-medium text-foreground">
{translate(
'auto.components.settings.ProviderHostScopeControl.scope_label',
'{{value0}}: {{value1}}',
{ value0: labelPrefix, value1: scope.label }
)}
</span>
<div className="mt-0.5 text-muted-foreground">{scope.description}</div>
</div>
<Button type="button" variant="ghost" size="sm" onClick={openActiveServer}>
<ServerCog className="size-3.5" />
{translate(
'auto.components.settings.ProviderHostScopeControl.change_host',
'Change Host'
)}
</Button>
</div>
</div>
)
}
@@ -10,6 +10,8 @@ import {
type StoreState = {
settings: { activeRuntimeEnvironmentId: string | null }
openSettingsPage: () => void
openSettingsTarget: (target: { pane: string; repoId: string | null }) => void
}
const mocks = vi.hoisted(() => ({
@@ -74,7 +76,13 @@ describe('CLI source-control integration card account scope', () => {
})
it('shows local-client ownership for connected GitHub CLI credentials', async () => {
mocks.store.current = { settings: { activeRuntimeEnvironmentId: null } }
const openSettingsPage = vi.fn()
const openSettingsTarget = vi.fn()
mocks.store.current = {
settings: { activeRuntimeEnvironmentId: null },
openSettingsPage,
openSettingsTarget
}
const rendered = await renderCard(<GitHubIntegrationCard />)
@@ -84,10 +92,22 @@ describe('CLI source-control integration card account scope', () => {
expect(rendered.textContent).toContain(
'Credentials and account checks for this provider are owned by this desktop client. Choose a remote Host from Settings > Active Server to edit server-owned credentials.'
)
await act(async () => {
Array.from(rendered.querySelectorAll('button'))
.find((button) => button.textContent === 'Change Host')
?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(openSettingsPage).toHaveBeenCalledTimes(1)
expect(openSettingsTarget).toHaveBeenCalledWith({ pane: 'servers', repoId: null })
})
it('shows remote-server ownership for GitLab CLI credential checks', async () => {
mocks.store.current = { settings: { activeRuntimeEnvironmentId: 'runtime-1' } }
mocks.store.current = {
settings: { activeRuntimeEnvironmentId: 'runtime-1' },
openSettingsPage: vi.fn(),
openSettingsTarget: vi.fn()
}
mocks.preflight.statuses.glabStatus = 'not-authenticated'
const rendered = await renderCard(<GitLabIntegrationCard />)
@@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button'
import { useAppStore } from '@/store'
import { IntegrationCardDetails, IntegrationCardShell } from './integration-card-shell'
import { getProviderAccountScope } from './provider-account-scope'
import { ProviderHostScopeControl } from './ProviderHostScopeControl'
import { usePreflightCardStatuses } from './source-control-preflight-card-status'
import { translate } from '@/i18n/i18n'
@@ -16,16 +17,14 @@ function ProviderAccountScopeDetails({
return (
<IntegrationCardDetails>
<div className="text-xs">
<span className="font-medium text-foreground">
{translate(
'auto.components.settings.cli.source.control.integration.cards.account_scope',
'Account scope: {{value0}}',
{ value0: accountScope.label }
)}
</span>
<div className="mt-0.5 text-muted-foreground">{accountScope.description}</div>
</div>
<ProviderHostScopeControl
labelPrefix={translate(
'auto.components.settings.cli.source.control.integration.cards.account_scope_prefix',
'Account scope'
)}
scope={accountScope}
className="text-xs"
/>
{children}
</IntegrationCardDetails>
)
@@ -0,0 +1,115 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context'
import { JiraIntegrationCard } from './jira-integration-card'
type StoreState = {
jiraStatus: {
connected: boolean
sites?: { id: string; displayName: string; siteUrl: string; email?: string }[]
}
jiraStatusChecked: boolean
jiraStatusContextKey: string | null
checkJiraConnection: () => Promise<void>
disconnectJira: (siteId?: string) => Promise<void>
testJiraConnection: (siteId: string) => Promise<{ ok: boolean; error?: string }>
settings: { activeRuntimeEnvironmentId: string | null }
openSettingsPage: () => void
openSettingsTarget: (target: { pane: string; repoId: string | null }) => void
}
const mocks = vi.hoisted(() => ({
store: { current: null as StoreState | null }
}))
vi.mock('@/store', () => ({
useAppStore: (selector: (state: StoreState) => unknown) => {
if (!mocks.store.current) {
throw new Error('Store state was not installed')
}
return selector(mocks.store.current)
}
}))
vi.mock('@/components/jira-connect-dialog', () => ({
JiraConnectDialog: ({ onConnected }: { onConnected?: () => void }) => (
<button type="button" data-testid="simulate-jira-connected" onClick={onConnected}>
Simulate Jira connected
</button>
)
}))
let root: Root | null = null
let container: HTMLDivElement | null = null
function installStore(settings: StoreState['settings']): StoreState {
const state: StoreState = {
jiraStatus: {
connected: true,
sites: [
{
id: 'site-1',
displayName: 'Acme Jira',
siteUrl: 'https://acme.atlassian.net',
email: 'jira@example.test'
}
]
},
jiraStatusChecked: true,
jiraStatusContextKey: getProviderRuntimeContextKey(settings),
checkJiraConnection: vi.fn(async () => {}),
disconnectJira: vi.fn(async () => {}),
testJiraConnection: vi.fn(async () => ({ ok: true })),
settings,
openSettingsPage: vi.fn(),
openSettingsTarget: vi.fn()
}
mocks.store.current = state
return state
}
async function renderCard(): Promise<HTMLDivElement> {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
await act(async () => {
root?.render(<JiraIntegrationCard />)
})
return container
}
describe('JiraIntegrationCard account scope', () => {
afterEach(async () => {
if (root) {
await act(async () => {
root?.unmount()
})
}
root = null
container?.remove()
container = null
mocks.store.current = null
})
it('shows remote-server account ownership and opens Active Server host selection', async () => {
const state = installStore({ activeRuntimeEnvironmentId: 'runtime-1' })
const rendered = await renderCard()
expect(rendered.textContent).toContain('Account scope: Remote server: runtime-1')
expect(rendered.textContent).toContain('Acme Jira')
expect(rendered.textContent).toContain('https://acme.atlassian.net · jira@example.test')
await act(async () => {
Array.from(rendered.querySelectorAll('button'))
.find((button) => button.textContent === 'Change Host')
?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(state.openSettingsPage).toHaveBeenCalledTimes(1)
expect(state.openSettingsTarget).toHaveBeenCalledWith({ pane: 'servers', repoId: null })
})
})
@@ -11,6 +11,7 @@ import {
import { useAppStore } from '@/store'
import { IntegrationCardDetails, IntegrationCardShell } from './integration-card-shell'
import { getProviderAccountScope } from './provider-account-scope'
import { ProviderHostScopeControl } from './ProviderHostScopeControl'
import { translate } from '@/i18n/i18n'
type VerificationResult = { state: 'ok' | 'error'; error?: string }
@@ -108,16 +109,14 @@ export function JiraIntegrationCard(): React.JSX.Element {
) : null
}
>
<div className="mt-3 rounded-md border border-border/40 bg-background/50 px-3 py-2 text-xs">
<div className="font-medium text-foreground">
{translate(
'auto.components.settings.task.tracker.integration.cards.account_scope',
'Account scope: {{value0}}',
{ value0: accountScope.label }
)}
</div>
<div className="mt-0.5 text-muted-foreground">{accountScope.description}</div>
</div>
<ProviderHostScopeControl
labelPrefix={translate(
'auto.components.settings.task.tracker.integration.cards.account_scope_prefix',
'Account scope'
)}
scope={accountScope}
className="mt-3 rounded-md border border-border/40 bg-background/50 px-3 py-2 text-xs"
/>
{connected && sites.length > 0 ? (
<div className="mt-3 space-y-2">
{sites.map((site) => {
@@ -5,10 +5,18 @@ import { GitLabRateLimitPanel } from '@/components/gitlab/gitlab-rate-limit-disp
type StoreState = {
settings: { activeRuntimeEnvironmentId: string | null }
openSettingsPage: () => void
openSettingsTarget: (target: { pane: string; repoId: string | null }) => void
}
const mocks = vi.hoisted(() => ({
store: { current: { settings: { activeRuntimeEnvironmentId: null } } as StoreState }
store: {
current: {
settings: { activeRuntimeEnvironmentId: null },
openSettingsPage: vi.fn(),
openSettingsTarget: vi.fn()
} as StoreState
}
}))
vi.mock('@/store', () => ({
@@ -17,7 +25,11 @@ vi.mock('@/store', () => ({
describe('provider rate-limit panels account scope', () => {
it('shows the local host scope for GitHub API budget', () => {
mocks.store.current = { settings: { activeRuntimeEnvironmentId: null } }
mocks.store.current = {
settings: { activeRuntimeEnvironmentId: null },
openSettingsPage: vi.fn(),
openSettingsTarget: vi.fn()
}
const markup = renderToStaticMarkup(<GitHubRateLimitPanel />)
@@ -25,10 +37,15 @@ describe('provider rate-limit panels account scope', () => {
expect(markup).toContain(
'GitHub API budget is fetched from the CLI on this desktop client. Choose a remote Host from Settings &gt; Active Server to view server-owned budgets.'
)
expect(markup).toContain('Change Host')
})
it('shows the remote server scope for GitLab API budget', () => {
mocks.store.current = { settings: { activeRuntimeEnvironmentId: 'runtime-1' } }
mocks.store.current = {
settings: { activeRuntimeEnvironmentId: 'runtime-1' },
openSettingsPage: vi.fn(),
openSettingsTarget: vi.fn()
}
const markup = renderToStaticMarkup(<GitLabRateLimitPanel />)
@@ -18,6 +18,8 @@ type StoreState = {
checkLinearConnection: (force?: boolean) => Promise<void>
testLinearConnection: (workspaceId: string) => Promise<{ ok: boolean; error?: string }>
settings: { activeRuntimeEnvironmentId: string | null }
openSettingsPage: () => void
openSettingsTarget: (target: { pane: string; repoId: string | null }) => void
}
const mocks = vi.hoisted(() => ({
@@ -68,7 +70,9 @@ function installStore(
disconnectLinearWorkspace: vi.fn(async () => {}),
checkLinearConnection: vi.fn(async () => {}),
testLinearConnection: vi.fn(async () => ({ ok: true })),
settings
settings,
openSettingsPage: vi.fn(),
openSettingsTarget: vi.fn()
}
mocks.store.current = state
return state
@@ -106,6 +110,7 @@ describe('LinearIntegrationCard account scope', () => {
expect(rendered.textContent).toContain(
'Credentials and account checks for this provider are owned by this desktop client. Choose a remote Host from Settings > Active Server to edit server-owned credentials.'
)
expect(rendered.textContent).toContain('Change Host')
expect(rendered.textContent).toContain('Add access with a Personal API key')
await act(async () => {
@@ -126,6 +131,13 @@ describe('LinearIntegrationCard account scope', () => {
expect(rendered.textContent).toContain(
'Credentials and account checks for this provider are owned by this remote server. Choose a different Host from Settings > Active Server to edit another account scope.'
)
await act(async () => {
Array.from(rendered.querySelectorAll('button'))
.find((button) => button.textContent === 'Change Host')
?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(state.openSettingsPage).toHaveBeenCalledTimes(1)
expect(state.openSettingsTarget).toHaveBeenCalledWith({ pane: 'servers', repoId: null })
expect(rendered.textContent).toContain('Acme')
expect(rendered.textContent).toContain('Acme workspace · linear@example.test')
@@ -8,6 +8,7 @@ import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context'
import { useAppStore } from '@/store'
import { IntegrationCardDetails, IntegrationCardShell } from './integration-card-shell'
import { getProviderAccountScope } from './provider-account-scope'
import { ProviderHostScopeControl } from './ProviderHostScopeControl'
import { translate } from '@/i18n/i18n'
type VerificationResult = { state: 'ok' | 'error'; error?: string }
@@ -214,16 +215,14 @@ export function LinearIntegrationCard(): React.JSX.Element {
function ProviderAccountScopeRow({ scope }: { scope: ReturnType<typeof getProviderAccountScope> }) {
return (
<div className="mt-3 rounded-md border border-border/40 bg-background/50 px-3 py-2 text-xs">
<div className="font-medium text-foreground">
{translate(
'auto.components.settings.task.tracker.integration.cards.account_scope',
'Account scope: {{value0}}',
{ value0: scope.label }
)}
</div>
<div className="mt-0.5 text-muted-foreground">{scope.description}</div>
</div>
<ProviderHostScopeControl
labelPrefix={translate(
'auto.components.settings.task.tracker.integration.cards.account_scope_prefix',
'Account scope'
)}
scope={scope}
className="mt-3 rounded-md border border-border/40 bg-background/50 px-3 py-2 text-xs"
/>
)
}