diff --git a/.gitignore b/.gitignore index d200b9908..d3abf7326 100644 --- a/.gitignore +++ b/.gitignore @@ -88,6 +88,7 @@ dmypy.json # Claude Code .claude/settings.local.json +.worktrees/ # mkdocs /site diff --git a/components/frontend/src/app/projects/[name]/keys/page.tsx b/components/frontend/src/app/projects/[name]/keys/page.tsx index c23618873..13294c9b0 100644 --- a/components/frontend/src/app/projects/[name]/keys/page.tsx +++ b/components/frontend/src/app/projects/[name]/keys/page.tsx @@ -10,6 +10,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { Input } from '@/components/ui/input'; import { Badge } from '@/components/ui/badge'; import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { ErrorMessage } from '@/components/error-message'; @@ -21,6 +22,17 @@ import { toast } from 'sonner'; import type { CreateKeyRequest } from '@/services/api/keys'; import { ROLE_DEFINITIONS } from '@/lib/role-colors'; +const EXPIRATION_OPTIONS = [ + { value: '86400', label: '1 day' }, + { value: '604800', label: '7 days' }, + { value: '2592000', label: '30 days' }, + { value: '7776000', label: '90 days' }, + { value: '31536000', label: '1 year' }, + { value: 'none', label: 'No expiration' }, +] as const; + +const DEFAULT_EXPIRATION = '7776000'; // 90 days + export default function ProjectKeysPage() { const params = useParams(); const projectName = params?.name as string; @@ -35,6 +47,7 @@ export default function ProjectKeysPage() { const [newKeyName, setNewKeyName] = useState(''); const [newKeyDesc, setNewKeyDesc] = useState(''); const [newKeyRole, setNewKeyRole] = useState<'view' | 'edit' | 'admin'>('edit'); + const [newKeyExpiration, setNewKeyExpiration] = useState(DEFAULT_EXPIRATION); const [oneTimeKey, setOneTimeKey] = useState(null); const [oneTimeKeyName, setOneTimeKeyName] = useState(''); const [showDeleteDialog, setShowDeleteDialog] = useState(false); @@ -47,6 +60,7 @@ export default function ProjectKeysPage() { name: newKeyName.trim(), description: newKeyDesc.trim() || undefined, role: newKeyRole, + expirationSeconds: newKeyExpiration !== 'none' ? Number(newKeyExpiration) : undefined, }; createKeyMutation.mutate( @@ -58,6 +72,7 @@ export default function ProjectKeysPage() { setOneTimeKeyName(data.name); setNewKeyName(''); setNewKeyDesc(''); + setNewKeyExpiration(DEFAULT_EXPIRATION); setShowCreate(false); }, onError: (error) => { @@ -65,7 +80,7 @@ export default function ProjectKeysPage() { }, } ); - }, [newKeyName, newKeyDesc, newKeyRole, projectName, createKeyMutation]); + }, [newKeyName, newKeyDesc, newKeyRole, newKeyExpiration, projectName, createKeyMutation]); const openDeleteDialog = useCallback((keyId: string, keyName: string) => { setKeyToDelete({ id: keyId, name: keyName }); @@ -286,6 +301,24 @@ export default function ProjectKeysPage() { })} +
+ + +

+ How long the token remains valid. Choose "No expiration" for long-lived service keys. +

+
- - ) : null, -})); - -describe('KeysSection', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('renders key table with mock data', () => { - render(); - expect(screen.getByText('ci-key')).toBeDefined(); - expect(screen.getByText('admin-key')).toBeDefined(); - expect(screen.getByText('CI pipeline key')).toBeDefined(); - expect(screen.getByText('Access Keys (2)')).toBeDefined(); - }); - - it('shows role badges', () => { - render(); - expect(screen.getByText('Edit')).toBeDefined(); - expect(screen.getByText('Admin')).toBeDefined(); - }); - - it('opens Create Key dialog', () => { - render(); - fireEvent.click(screen.getByText('Create Key')); - expect(screen.getByText('Create Access Key')).toBeDefined(); - expect(screen.getByLabelText('Name *')).toBeDefined(); - }); - - it('submits create key form', async () => { - render(); - fireEvent.click(screen.getByText('Create Key')); - - const nameInput = screen.getByLabelText('Name *'); - fireEvent.change(nameInput, { target: { value: 'new-key' } }); - - const descInput = screen.getByLabelText('Description'); - fireEvent.change(descInput, { target: { value: 'New key description' } }); - - // The "Create Key" button inside the dialog - const createButtons = screen.getAllByText('Create Key'); - const dialogCreateBtn = createButtons[createButtons.length - 1]; - fireEvent.click(dialogCreateBtn); - - expect(mockCreateMutate).toHaveBeenCalledWith( - expect.objectContaining({ - projectName: 'test-project', - data: expect.objectContaining({ - name: 'new-key', - description: 'New key description', - role: 'edit', - }), - }), - expect.any(Object) - ); - }); - - it('opens delete confirmation for a key', () => { - render(); - -// // Click the first delete button (trash icon buttons in the table) -// screen.getAllByRole('button').filter((btn) => { -// return btn.querySelector('svg'); -// }); - // Find buttons within table rows (the trash buttons) - const trashButtons = screen.getAllByRole('row').slice(1).map((row) => { - return row.querySelector('button'); - }).filter(Boolean); - - if (trashButtons[0]) { - fireEvent.click(trashButtons[0]); - } - - expect(screen.getByTestId('delete-dialog')).toBeDefined(); - expect(screen.getByText('Delete Access Key')).toBeDefined(); - }); - - it('calls delete mutation on confirm', async () => { - render(); - - // Open delete dialog for first key - const rows = screen.getAllByRole('row').slice(1); - const firstRowBtn = rows[0]?.querySelector('button'); - if (firstRowBtn) fireEvent.click(firstRowBtn); - - fireEvent.click(screen.getByText('Confirm Delete')); - expect(mockDeleteMutate).toHaveBeenCalledWith( - expect.objectContaining({ - projectName: 'test-project', - keyId: 'key-1', - }), - expect.any(Object) - ); - }); -}); diff --git a/components/frontend/src/components/workspace-sections/index.ts b/components/frontend/src/components/workspace-sections/index.ts index 9ab5afd1e..9c37b821b 100644 --- a/components/frontend/src/components/workspace-sections/index.ts +++ b/components/frontend/src/components/workspace-sections/index.ts @@ -2,4 +2,3 @@ export { SessionsSection } from './sessions-section'; export { SharingSection } from './sharing-section'; export { SettingsSection } from './settings-section'; export { FeatureFlagsSection } from './feature-flags-section'; -export { KeysSection } from './keys-section'; diff --git a/components/frontend/src/components/workspace-sections/keys-section.tsx b/components/frontend/src/components/workspace-sections/keys-section.tsx deleted file mode 100644 index 27de4b1d1..000000000 --- a/components/frontend/src/components/workspace-sections/keys-section.tsx +++ /dev/null @@ -1,378 +0,0 @@ -'use client'; - -import { useCallback, useState } from 'react'; -import { formatDistanceToNow } from 'date-fns'; -import { Copy, KeyRound, Loader2, Plus, RefreshCw, Trash2 } from 'lucide-react'; - -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Input } from '@/components/ui/input'; -import { Badge } from '@/components/ui/badge'; -import { Label } from '@/components/ui/label'; -import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { ErrorMessage } from '@/components/error-message'; -import { EmptyState } from '@/components/empty-state'; -import { DestructiveConfirmationDialog } from '@/components/confirmation-dialog'; - -import { useKeys, useCreateKey, useDeleteKey } from '@/services/queries'; -import { toast } from 'sonner'; -import type { CreateKeyRequest } from '@/services/api/keys'; -import { ROLE_DEFINITIONS } from '@/lib/role-colors'; - -type KeysSectionProps = { - projectName: string; -}; - -const EXPIRATION_OPTIONS = [ - { value: '86400', label: '1 day' }, - { value: '604800', label: '7 days' }, - { value: '2592000', label: '30 days' }, - { value: '7776000', label: '90 days' }, - { value: '31536000', label: '1 year' }, - { value: 'none', label: 'No expiration' }, -] as const; - -export function KeysSection({ projectName }: KeysSectionProps) { - const { data: keys = [], isLoading, error, refetch } = useKeys(projectName); - const createKeyMutation = useCreateKey(); - const deleteKeyMutation = useDeleteKey(); - - const [showCreate, setShowCreate] = useState(false); - const [newKeyName, setNewKeyName] = useState(''); - const [newKeyDesc, setNewKeyDesc] = useState(''); - const [newKeyRole, setNewKeyRole] = useState<'view' | 'edit' | 'admin'>('edit'); - const [newKeyExpiration, setNewKeyExpiration] = useState('7776000'); // default 90 days - const [oneTimeKey, setOneTimeKey] = useState(null); - const [oneTimeKeyName, setOneTimeKeyName] = useState(''); - const [showDeleteDialog, setShowDeleteDialog] = useState(false); - const [keyToDelete, setKeyToDelete] = useState<{ id: string; name: string } | null>(null); - - const handleCreate = useCallback(() => { - if (!newKeyName.trim()) return; - - const request: CreateKeyRequest = { - name: newKeyName.trim(), - description: newKeyDesc.trim() || undefined, - role: newKeyRole, - expirationSeconds: newKeyExpiration !== 'none' ? Number(newKeyExpiration) : undefined, - }; - - createKeyMutation.mutate( - { projectName, data: request }, - { - onSuccess: (data) => { - toast.success(`Access key "${data.name}" created successfully`); - setOneTimeKey(data.key); - setOneTimeKeyName(data.name); - setNewKeyName(''); - setNewKeyDesc(''); - setNewKeyExpiration('7776000'); - setShowCreate(false); - }, - onError: (error) => { - toast.error(error instanceof Error ? error.message : 'Failed to create key'); - }, - } - ); - }, [newKeyName, newKeyDesc, newKeyRole, newKeyExpiration, projectName, createKeyMutation]); - - const openDeleteDialog = useCallback((keyId: string, keyName: string) => { - setKeyToDelete({ id: keyId, name: keyName }); - setShowDeleteDialog(true); - }, []); - - const confirmDelete = useCallback(() => { - if (!keyToDelete) return; - deleteKeyMutation.mutate( - { projectName, keyId: keyToDelete.id }, - { - onSuccess: () => { - toast.success(`Access key "${keyToDelete.name}" deleted successfully`); - setShowDeleteDialog(false); - setKeyToDelete(null); - }, - } - ); - }, [keyToDelete, projectName, deleteKeyMutation]); - - const copy = async (text: string) => { - try { - await navigator.clipboard.writeText(text); - } catch {} - }; - - if (isLoading && keys.length === 0) { - return ( -
- - Loading access keys... -
- ); - } - - return ( -
- {/* Header */} -
-
-

- - Access Keys -

-

- Create and manage API keys for non-user access -

-
-
- - -
-
- - {/* Error state */} - {error && refetch()} />} - {createKeyMutation.isError && ( - - )} - {deleteKeyMutation.isError && ( - - )} - - - - - - Access Keys ({keys.length}) - - API keys scoped to this workspace - - - {keys.length > 0 ? ( - - - - Name - Description - Created - Last Used - Role - Actions - - - - {keys.map((k) => { - const isDeletingThis = deleteKeyMutation.isPending && deleteKeyMutation.variables?.keyId === k.id; - return ( - - {k.name} - - {k.description || ( - No description - )} - - - {k.createdAt ? ( - formatDistanceToNow(new Date(k.createdAt), { addSuffix: true }) - ) : ( - Unknown - )} - - - {k.lastUsedAt ? ( - formatDistanceToNow(new Date(k.lastUsedAt), { addSuffix: true }) - ) : ( - Never - )} - - - {k.role ? ( - (() => { - const role = k.role as keyof typeof ROLE_DEFINITIONS; - const cfg = ROLE_DEFINITIONS[role]; - const Icon = cfg.icon; - return ( - - - {cfg.label} - - ); - })() - ) : ( - - )} - - - - - - ); - })} - -
- ) : ( - setShowCreate(true), - }} - /> - )} -
-
- - {/* Create Key Dialog */} - - - - Create Access Key - Provide a name and configure the key - -
-
- - setNewKeyName(e.target.value)} - placeholder="my-ci-key" - maxLength={64} - /> -
-
- - setNewKeyDesc(e.target.value)} - placeholder="Used by CI pipelines" - maxLength={200} - /> -
-
- -
- {(['view', 'edit', 'admin'] as const).map((roleKey) => { - const cfg = ROLE_DEFINITIONS[roleKey]; - const Icon = cfg.icon; - const id = `key-role-${roleKey}`; - return ( -
- setNewKeyRole(roleKey)} - disabled={createKeyMutation.isPending} - /> - -
- ); - })} -
-
-
- - -

- How long the token remains valid. Choose "No expiration" for long-lived service keys. -

-
-
- - - - -
-
- - {/* One-time Key Viewer */} - !open && setOneTimeKey(null)}> - - - Copy Your New Access Key - - This is the only time the full key will be shown. Store it securely. Key name: {oneTimeKeyName} - - -
- {oneTimeKey || ''} - -
- - - -
-
- - {/* Delete confirmation dialog */} - -
- ); -} diff --git a/components/runners/ambient-runner/tests/test_shared_session_credentials.py b/components/runners/ambient-runner/tests/test_shared_session_credentials.py index 460f7d67f..de73d363e 100644 --- a/components/runners/ambient-runner/tests/test_shared_session_credentials.py +++ b/components/runners/ambient-runner/tests/test_shared_session_credentials.py @@ -181,7 +181,11 @@ async def test_populate_writes_github_token_file(self): async def _creds(ctx, ctype): if ctype == "github": - return {"token": "gh-mid-run-token", "userName": "user", "email": "u@example.com"} + return { + "token": "gh-mid-run-token", + "userName": "user", + "email": "u@example.com", + } return {} mock_fetch.side_effect = _creds @@ -204,7 +208,11 @@ async def test_populate_writes_gitlab_token_file(self): async def _creds(ctx, ctype): if ctype == "gitlab": - return {"token": "gl-mid-run-token", "userName": "user", "email": "u@example.com"} + return { + "token": "gl-mid-run-token", + "userName": "user", + "email": "u@example.com", + } return {} mock_fetch.side_effect = _creds @@ -224,8 +232,12 @@ def test_clear_removes_token_files(self): _GITLAB_TOKEN_FILE.write_text("old-gl-token") try: clear_runtime_credentials() - assert not _GITHUB_TOKEN_FILE.exists(), "GitHub token file should be removed" - assert not _GITLAB_TOKEN_FILE.exists(), "GitLab token file should be removed" + assert not _GITHUB_TOKEN_FILE.exists(), ( + "GitHub token file should be removed" + ) + assert not _GITLAB_TOKEN_FILE.exists(), ( + "GitLab token file should be removed" + ) finally: self._cleanup() @@ -249,10 +261,16 @@ async def test_second_populate_overwrites_token_file(self): async def _creds(ctx, ctype): if ctype == "github": call_num[0] += 1 - return {"token": f"gh-token-{call_num[0]}", "userName": "u", "email": "u@e.com"} + return { + "token": f"gh-token-{call_num[0]}", + "userName": "u", + "email": "u@e.com", + } return {} - with patch("ambient_runner.platform.auth._fetch_credential", side_effect=_creds): + with patch( + "ambient_runner.platform.auth._fetch_credential", side_effect=_creds + ): ctx = _make_context() await populate_runtime_credentials(ctx) assert _GITHUB_TOKEN_FILE.read_text() == "gh-token-1" @@ -300,9 +318,15 @@ async def test_sends_current_user_header_when_set(self): result = await _fetch_credential(ctx, "github") assert result.get("token") == "gh-token-for-userB" - assert _CredentialHandler.captured_headers.get("X-Runner-Current-User") == "userB@example.com" + assert ( + _CredentialHandler.captured_headers.get("X-Runner-Current-User") + == "userB@example.com" + ) # Should use caller token, not BOT_TOKEN - assert "Bearer userB-oauth-token" in _CredentialHandler.captured_headers.get("Authorization", "") + assert ( + "Bearer userB-oauth-token" + in _CredentialHandler.captured_headers.get("Authorization", "") + ) finally: server.server_close() thread.join(timeout=2) @@ -370,7 +394,11 @@ async def test_credentials_populated_then_cleared(self): responses = { "/github": {"token": "gh-tok"}, "/google": {}, - "/jira": {"apiToken": "jira-tok", "url": "https://jira.example.com", "email": "j@example.com"}, + "/jira": { + "apiToken": "jira-tok", + "url": "https://jira.example.com", + "email": "j@example.com", + }, "/gitlab": {"token": "gl-tok"}, } @@ -393,7 +421,9 @@ def log_message(self, format, *args): server = HTTPServer(("127.0.0.1", 0), MultiHandler) port = server.server_address[1] - thread = Thread(target=lambda: [server.handle_request() for _ in range(4)], daemon=True) + thread = Thread( + target=lambda: [server.handle_request() for _ in range(4)], daemon=True + ) thread.start() try: @@ -428,7 +458,15 @@ def log_message(self, format, *args): server.server_close() thread.join(timeout=2) # Cleanup any leaked env vars - for key in ["GITHUB_TOKEN", "GITLAB_TOKEN", "JIRA_API_TOKEN", "JIRA_URL", "JIRA_EMAIL", "GIT_USER_NAME", "GIT_USER_EMAIL"]: + for key in [ + "GITHUB_TOKEN", + "GITLAB_TOKEN", + "JIRA_API_TOKEN", + "JIRA_URL", + "JIRA_EMAIL", + "GIT_USER_NAME", + "GIT_USER_EMAIL", + ]: os.environ.pop(key, None) @@ -439,7 +477,9 @@ def log_message(self, format, *args): class TestFetchCredentialAuthFailures: @pytest.mark.asyncio - async def test_raises_permission_error_on_401_without_caller_token(self, monkeypatch): + async def test_raises_permission_error_on_401_without_caller_token( + self, monkeypatch + ): """_fetch_credential raises PermissionError when backend returns 401 with BOT_TOKEN.""" monkeypatch.setenv("BACKEND_API_URL", "http://backend.svc.cluster.local/api") monkeypatch.setenv("PROJECT_NAME", "test-project") @@ -448,13 +488,23 @@ async def test_raises_permission_error_on_401_without_caller_token(self, monkeyp ctx = _make_context(session_id="sess-1") # No caller token — uses BOT_TOKEN directly - err = HTTPError("http://backend.svc.cluster.local/api/...", 401, "Unauthorized", {}, BytesIO(b"")) + err = HTTPError( + "http://backend.svc.cluster.local/api/...", + 401, + "Unauthorized", + {}, + BytesIO(b""), + ) with patch("urllib.request.urlopen", side_effect=err): - with pytest.raises(PermissionError, match="authentication failed with HTTP 401"): + with pytest.raises( + PermissionError, match="authentication failed with HTTP 401" + ): await _fetch_credential(ctx, "github") @pytest.mark.asyncio - async def test_raises_permission_error_on_403_without_caller_token(self, monkeypatch): + async def test_raises_permission_error_on_403_without_caller_token( + self, monkeypatch + ): """_fetch_credential raises PermissionError when backend returns 403 with BOT_TOKEN.""" monkeypatch.setenv("BACKEND_API_URL", "http://backend.svc.cluster.local/api") monkeypatch.setenv("PROJECT_NAME", "test-project") @@ -462,13 +512,23 @@ async def test_raises_permission_error_on_403_without_caller_token(self, monkeyp ctx = _make_context(session_id="sess-1") - err = HTTPError("http://backend.svc.cluster.local/api/...", 403, "Forbidden", {}, BytesIO(b"")) + err = HTTPError( + "http://backend.svc.cluster.local/api/...", + 403, + "Forbidden", + {}, + BytesIO(b""), + ) with patch("urllib.request.urlopen", side_effect=err): - with pytest.raises(PermissionError, match="authentication failed with HTTP 403"): + with pytest.raises( + PermissionError, match="authentication failed with HTTP 403" + ): await _fetch_credential(ctx, "google") @pytest.mark.asyncio - async def test_raises_permission_error_when_caller_and_bot_both_fail(self, monkeypatch): + async def test_raises_permission_error_when_caller_and_bot_both_fail( + self, monkeypatch + ): """_fetch_credential raises PermissionError when caller token 401s and BOT_TOKEN also fails.""" monkeypatch.setenv("BACKEND_API_URL", "http://backend.svc.cluster.local/api") monkeypatch.setenv("PROJECT_NAME", "test-project") @@ -481,7 +541,10 @@ async def test_raises_permission_error_when_caller_and_bot_both_fail(self, monke fallback_err = HTTPError("http://...", 403, "Forbidden", {}, BytesIO(b"")) with patch("urllib.request.urlopen", side_effect=[caller_err, fallback_err]): - with pytest.raises(PermissionError, match="caller token expired and BOT_TOKEN fallback also failed"): + with pytest.raises( + PermissionError, + match="caller token expired and BOT_TOKEN fallback also failed", + ): await _fetch_credential(ctx, "github") @pytest.mark.asyncio @@ -499,7 +562,9 @@ async def test_does_not_raise_on_non_auth_http_errors(self, monkeypatch): assert result == {} @pytest.mark.asyncio - async def test_caller_token_fallback_succeeds_when_bot_token_works(self, monkeypatch): + async def test_caller_token_fallback_succeeds_when_bot_token_works( + self, monkeypatch + ): """_fetch_credential returns data when caller token 401s but BOT_TOKEN fallback succeeds.""" monkeypatch.setenv("BACKEND_API_URL", "http://backend.svc.cluster.local/api") monkeypatch.setenv("PROJECT_NAME", "test-project") @@ -511,7 +576,9 @@ async def test_caller_token_fallback_succeeds_when_bot_token_works(self, monkeyp caller_err = HTTPError("http://...", 401, "Unauthorized", {}, BytesIO(b"")) mock_response = MagicMock() - mock_response.read.return_value = json.dumps({"token": "gh-tok-via-bot"}).encode() + mock_response.read.return_value = json.dumps( + {"token": "gh-tok-via-bot"} + ).encode() mock_response.__enter__ = lambda s: s mock_response.__exit__ = MagicMock(return_value=False) @@ -540,8 +607,13 @@ async def _fail_github(context, cred_type): raise PermissionError("github authentication failed with HTTP 401") return {} - with patch("ambient_runner.platform.auth._fetch_credential", side_effect=_fail_github): - with pytest.raises(PermissionError, match="Credential refresh failed due to authentication errors"): + with patch( + "ambient_runner.platform.auth._fetch_credential", side_effect=_fail_github + ): + with pytest.raises( + PermissionError, + match="Credential refresh failed due to authentication errors", + ): await populate_runtime_credentials(ctx) @pytest.mark.asyncio @@ -555,7 +627,9 @@ async def test_raises_when_multiple_providers_fail(self, monkeypatch): async def _fail_all(context, cred_type): raise PermissionError(f"{cred_type} authentication failed with HTTP 401") - with patch("ambient_runner.platform.auth._fetch_credential", side_effect=_fail_all): + with patch( + "ambient_runner.platform.auth._fetch_credential", side_effect=_fail_all + ): with pytest.raises(PermissionError) as exc_info: await populate_runtime_credentials(ctx) @@ -583,10 +657,13 @@ async def test_succeeds_when_all_credentials_empty_no_auth_error(self, monkeypat class TestRefreshCredentialsTool: def _make_tool_decorator(self): """Create a mock sdk_tool decorator that preserves the function.""" + def mock_tool(name, description, schema): def decorator(func): return func + return decorator + return mock_tool @pytest.mark.asyncio @@ -595,7 +672,9 @@ async def test_returns_is_error_on_auth_failure(self): from ambient_runner.bridges.claude.tools import create_refresh_credentials_tool mock_context = MagicMock() - tool_fn = create_refresh_credentials_tool(mock_context, self._make_tool_decorator()) + tool_fn = create_refresh_credentials_tool( + mock_context, self._make_tool_decorator() + ) with patch( "ambient_runner.platform.auth.populate_runtime_credentials", @@ -613,14 +692,19 @@ async def test_returns_success_on_successful_refresh(self): from ambient_runner.bridges.claude.tools import create_refresh_credentials_tool mock_context = MagicMock() - tool_fn = create_refresh_credentials_tool(mock_context, self._make_tool_decorator()) - - with patch( - "ambient_runner.platform.auth.populate_runtime_credentials", - new_callable=AsyncMock, - ), patch( - "ambient_runner.platform.utils.get_active_integrations", - return_value=["github", "jira"], + tool_fn = create_refresh_credentials_tool( + mock_context, self._make_tool_decorator() + ) + + with ( + patch( + "ambient_runner.platform.auth.populate_runtime_credentials", + new_callable=AsyncMock, + ), + patch( + "ambient_runner.platform.utils.get_active_integrations", + return_value=["github", "jira"], + ), ): result = await tool_fn({})