diff --git a/backend/__tests__/unit/routes/grants.read.test.js b/backend/__tests__/unit/routes/grants.read.test.js index 5e692d3837..61887cbd46 100644 --- a/backend/__tests__/unit/routes/grants.read.test.js +++ b/backend/__tests__/unit/routes/grants.read.test.js @@ -13,7 +13,7 @@ const mongoose = require('mongoose'); jest.mock('../../../middleware/auth', () => (req, res, next) => { const id = req.get('x-test-user'); if (!id) return res.status(401).json({ error: 'unauthorized' }); - req.user = { id }; + req.user = { id, role: req.get('x-test-role') || 'user' }; req.userId = id; return next(); }); @@ -267,6 +267,18 @@ describe('GET /api/grants/:grantId', () => { }); describe('POST /api/grants/:grantId/revoke', () => { + test('refuses a non-owner member and lets an instance admin revoke', async () => { + const row = await RoomGrant.create(grant()); + const denied = await request(app).post(`/api/grants/${row.grantId}/revoke`).set('x-test-user', MEMBER); + expect(denied.status).toBe(403); + expect((await RoomGrant.findOne({ grantId: row.grantId })).revokedAt).toBeNull(); + + const admin = await request(app).post(`/api/grants/${row.grantId}/revoke`) + .set('x-test-user', STRANGER).set('x-test-role', 'admin'); + expect(admin.status).toBe(200); + expect((await RoomGrant.findOne({ grantId: row.grantId })).revokedBy).toBe(STRANGER); + }); + test('records the authenticated caller as revokedBy', async () => { const row = await RoomGrant.create(grant()); const res = await request(app).post(`/api/grants/${row.grantId}/revoke`).set('x-test-user', OWNER); diff --git a/backend/__tests__/unit/routes/registry.manage-grants.test.js b/backend/__tests__/unit/routes/registry.manage-grants.test.js new file mode 100644 index 0000000000..ad88b99694 --- /dev/null +++ b/backend/__tests__/unit/routes/registry.manage-grants.test.js @@ -0,0 +1,111 @@ +const express = require('express'); +const request = require('supertest'); + +jest.mock('../../../middleware/auth', () => (req, res, next) => { + const id = req.get('x-test-user'); + if (!id) return res.status(401).json({ error: 'Unauthorized' }); + req.userId = id; + req.user = { id, role: req.get('x-test-role') || 'user' }; + return next(); +}); +jest.mock('../../../models/AgentRegistry', () => ({ AgentInstallation: { find: jest.fn() } })); +jest.mock('../../../models/RoomGrant', () => ({ find: jest.fn() })); +jest.mock('../../../models/Integration', () => ({ findById: jest.fn(), findOne: jest.fn() })); +jest.mock('../../../models/Pod', () => ({ find: jest.fn() })); +jest.mock('../../../models/User', () => ({ findOne: jest.fn(), find: jest.fn() })); +jest.mock('../../../services/agentIdentityService', () => ({ buildAgentUsername: () => 'agent-nib' })); +jest.mock('../../../routes/registry/helpers', () => ({ + getUserId: (req) => req.userId, + isGlobalAdminUser: async () => false, + normalizeInstanceId: (id) => id || 'default', +})); + +const { AgentInstallation } = require('../../../models/AgentRegistry'); +const RoomGrant = require('../../../models/RoomGrant'); +const Integration = require('../../../models/Integration'); +const Pod = require('../../../models/Pod'); +const User = require('../../../models/User'); + +const app = express(); +app.use('/api/registry', require('../../../routes/registry/manage-grants')); +const URL = '/api/registry/agents/nib/instances/default/grants'; + +beforeEach(() => { + jest.clearAllMocks(); + AgentInstallation.find.mockReturnValue({ lean: async () => [{ + agentName: 'nib', instanceId: 'default', installedBy: 'owner', podId: 'pod-1', + }] }); + User.findOne.mockReturnValue({ select: () => ({ lean: async () => ({ _id: 'agent-1' }) }) }); + RoomGrant.find.mockReturnValue({ sort: () => ({ lean: async () => [{ + grantId: 'grant-1', connectionId: 'connection-1', target: { kind: 'pod', id: 'pod-1' }, + audience: ['agent-1'], tools: ['linear.list_issues'], createdAt: new Date('2026-10-01'), + expiresAt: new Date('2026-11-01'), revokedAt: null, + }] }) }); + Pod.find.mockReturnValue({ select: () => ({ lean: async () => [{ + _id: 'pod-1', name: 'Work', members: ['agent-1'], + }] }) }); + Integration.findById.mockReturnValue({ select: () => ({ lean: async () => null }) }); + Integration.findOne.mockReturnValue({ select: () => ({ lean: async () => ({ + type: 'hosted-mcp', config: { entryId: 'linear' }, createdBy: 'owner', + }) }) }); + User.find.mockReturnValue({ select: () => ({ lean: async () => [{ + _id: 'owner', displayName: 'Owner', username: 'owner', + }] }) }); +}); + +test('only the installer or an instance admin can read an agent grants', async () => { + const denied = await request(app).get(URL).set('x-test-user', 'member'); + expect(denied.status).toBe(403); + expect(RoomGrant.find).not.toHaveBeenCalled(); + + const owner = await request(app).get(URL).set('x-test-user', 'owner'); + expect(owner.status).toBe(200); + expect(owner.body.grants).toMatchObject([{ + grantId: 'grant-1', connector: 'linear', podName: 'Work', grantedBy: 'Owner', + canRevoke: true, + }]); + + const admin = await request(app).get(URL).set('x-test-user', 'admin').set('x-test-role', 'admin'); + expect(admin.status).toBe(200); + expect(admin.body.grants[0].canRevoke).toBe(true); +}); + +test('a second installer cannot inspect a shared identity through their row', async () => { + AgentInstallation.find.mockReturnValue({ lean: async () => [ + { installedBy: 'owner', status: 'active', config: {} }, + { installedBy: 'someone-else', status: 'paused', config: {} }, + ] }); + const res = await request(app).get(URL).set('x-test-user', 'owner'); + expect(res.status).toBe(403); + expect(RoomGrant.find).not.toHaveBeenCalled(); + expect(AgentInstallation.find).toHaveBeenCalledWith({ + agentName: 'nib', instanceId: 'default', status: { $ne: 'uninstalled' }, + }); +}); + +test('a paused-only installer can read their agent grants', async () => { + AgentInstallation.find.mockReturnValue({ lean: async () => [ + { installedBy: 'owner', status: 'paused', config: {} }, + ] }); + const res = await request(app).get(URL).set('x-test-user', 'owner'); + expect(res.status).toBe(200); + expect(res.body.grants).toHaveLength(1); +}); + +test('a grant for a pod the agent left does not appear as current access', async () => { + Pod.find.mockReturnValue({ select: () => ({ lean: async () => [{ + _id: 'pod-1', name: 'Work', members: ['someone-else'], + }] }) }); + const res = await request(app).get(URL).set('x-test-user', 'owner'); + expect(res.status).toBe(200); + expect(res.body.grants).toEqual([]); +}); + +test('linked user can revoke when the connection has no createdBy', async () => { + Integration.findOne.mockReturnValue({ select: () => ({ lean: async () => ({ + type: 'hosted-mcp', config: { entryId: 'linear', linkedUserId: 'owner' }, + }) }) }); + const res = await request(app).get(URL).set('x-test-user', 'owner'); + expect(res.status).toBe(200); + expect(res.body.grants[0]).toMatchObject({ grantedBy: 'Owner', canRevoke: true }); +}); diff --git a/backend/routes/grants.ts b/backend/routes/grants.ts index dbf3790c20..cb06f4a97a 100644 --- a/backend/routes/grants.ts +++ b/backend/routes/grants.ts @@ -466,7 +466,11 @@ const revokeHandler = async (req: AuthenticatedRequest, res: express.Response): if (!grant) return res.status(404).json({ error: 'grant_not_found' }); const userId = callerId(req); const connection = await findConnection(grant.connectionId); - if (!connection || connectionOwnerId(connection) !== userId) { + if (!connection) return res.status(403).json({ error: 'access_denied' }); + const isOwner = connectionOwnerId(connection) === userId; + const isAdmin = !isOwner && (req.user?.role === 'admin' + || (Boolean(userId) && (await User.findById(userId).select('role').lean())?.role === 'admin')); + if (!isOwner && !isAdmin) { return res.status(403).json({ error: 'access_denied' }); } const revoked = await revokeGrant(grant.grantId, userId); diff --git a/backend/routes/registry.ts b/backend/routes/registry.ts index df9bb3b352..7c2e10d211 100644 --- a/backend/routes/registry.ts +++ b/backend/routes/registry.ts @@ -13,6 +13,8 @@ const router: ReturnType = express.Router(); // eslint-disable-next-line global-require router.use(require('./registry/runtime')); // eslint-disable-next-line global-require +router.use(require('./registry/manage-grants')); +// eslint-disable-next-line global-require router.use(require('./registry/templates')); // eslint-disable-next-line global-require router.use(require('./registry/catalog')); diff --git a/backend/routes/registry/manage-grants.ts b/backend/routes/registry/manage-grants.ts new file mode 100644 index 0000000000..23bea9fd11 --- /dev/null +++ b/backend/routes/registry/manage-grants.ts @@ -0,0 +1,114 @@ +import express from 'express'; +import rateLimit from 'express-rate-limit'; +import { createHash } from 'crypto'; +import { cloudflareIpRateLimitKeyGenerator } from '../../middleware/ipRateLimit'; +import { AgentInstallation } from '../../models/AgentRegistry'; +import RoomGrant from '../../models/RoomGrant'; +import Integration from '../../models/Integration'; +import Pod from '../../models/Pod'; +import User from '../../models/User'; +import AgentIdentityService from '../../services/agentIdentityService'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const auth = require('../../middleware/auth'); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { + getUserId, isGlobalAdminUser, normalizeInstanceId, +} = require('./helpers'); + +const router = express.Router(); +const grantManageLimit = rateLimit({ + windowMs: 60_000, + max: 60, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: (req: express.Request): string => { + const bearer = req.get('Authorization'); + if (bearer) return `grant-manage:${createHash('sha256').update(bearer).digest('hex').slice(0, 16)}`; + return cloudflareIpRateLimitKeyGenerator(req as never); + }, + handler: (_req, res) => res.status(429).json({ code: 'rate_limited' }), +}); + +/** The manage page reads one identity, not every grant in a pod. */ +router.get('/agents/:name/instances/:instanceId/grants', grantManageLimit, auth, async (req: any, res: any) => { + try { + const userId = String(getUserId(req) || ''); + if (!userId) return res.status(401).json({ error: 'Unauthorized' }); + const agentName = String(req.params.name || '').toLowerCase(); + const instanceId = normalizeInstanceId(req.params.instanceId); + const installations = await AgentInstallation.find({ + agentName, instanceId, status: { $ne: 'uninstalled' }, + }).lean(); + if (!installations.length) return res.status(404).json({ error: 'Agent not installed' }); + + const isAdmin = req.user?.role === 'admin' || await isGlobalAdminUser(userId); + // A shared identity reaches every installation. One installer must not + // inspect another installer's grants through the shared agent principal. + if (!isAdmin && !installations.every((row: any) => String(row.installedBy || '') === userId)) { + return res.status(403).json({ error: 'Only the agent installer or an instance admin can manage this agent' }); + } + + const username = AgentIdentityService.buildAgentUsername(agentName, instanceId); + const agentUser = await User.findOne({ username, isBot: true }).select('_id').lean(); + if (!agentUser) return res.json({ grants: [] }); + + const agentId = String(agentUser._id); + const grants = await RoomGrant.find({ audience: agentId }).sort({ createdAt: -1 }).lean(); + const podIds = grants.filter((row) => row.target.kind === 'pod').map((row) => row.target.id); + const pods = podIds.length + ? await Pod.find({ _id: { $in: podIds } }).select('name members').lean() + : []; + const podById = new Map(pods.map((pod: any) => [String(pod._id), pod])); + const visibleGrants = grants.filter((row) => { + if (row.target.kind === 'seat') return row.target.id === agentId; + const pod: any = podById.get(row.target.id); + return Boolean(pod?.members?.some((member: any) => String(member?.userId || member) === agentId)); + }); + + const connections = await Promise.all(Array.from(new Set(visibleGrants.map((row) => row.connectionId))) + .map(async (connectionId) => { + const byId = /^[a-f\d]{24}$/i.test(connectionId) + ? await Integration.findById(connectionId).select('type createdBy config.entryId config.linkedUserId').lean() + : null; + const connection = byId || await Integration.findOne({ installationId: connectionId }) + .select('type createdBy config.entryId config.linkedUserId').lean(); + return [connectionId, connection] as const; + })); + const connectionById = new Map(connections); + const granterIds = Array.from(new Set(connections + .map(([, connection]) => String(connection?.createdBy || connection?.config?.linkedUserId || '')) + .filter(Boolean))); + const granters = granterIds.length + ? await User.find({ _id: { $in: granterIds } }).select('username displayName').lean() + : []; + const granterById = new Map(granters.map((user: any) => [String(user._id), user])); + + return res.json({ + grants: visibleGrants.map((grant) => { + const connection = connectionById.get(grant.connectionId); + const granterId = String(connection?.createdBy || connection?.config?.linkedUserId || ''); + const granter: any = granterById.get(granterId); + return { + grantId: grant.grantId, + tools: grant.tools, + connector: connection?.type === 'github-app' ? 'GitHub' : connection?.config?.entryId || null, + podId: grant.target.kind === 'pod' ? grant.target.id : null, + podName: grant.target.kind === 'pod' + ? (podById.get(grant.target.id) as any)?.name || null : null, + grantedBy: granter?.displayName || granter?.username || null, + createdAt: grant.createdAt, + expiresAt: grant.expiresAt, + revokedAt: grant.revokedAt || null, + canRevoke: Boolean(connection) && (isAdmin || granterId === userId), + }; + }), + }); + } catch (error) { + console.error('Error loading agent grants:', error); + return res.status(500).json({ error: 'Failed to load agent grants' }); + } +}); + +module.exports = router; +export {}; diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 1cf9c8351f..d9be75cbae 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2254,6 +2254,41 @@ "listSeparator": ", ", "sentenceSeparator": " " }, + "agentManage": { + "runtime": { + "title": "Runtime", + "how": "How it runs", + "hosted": "Hosted", + "daemon": "On a computer, via the daemon", + "cli": "Single CLI", + "unknown": "Runtime details unavailable", + "adapter": "Adapter", + "model": "Model", + "modelUnknown": "Set by the runtime", + "effort": "Effort", + "changeModel": "Model", + "changeEffort": "Effort", + "effortUnset": "Choose effort", + "save": "Save runtime", + "saveError": "Could not save the model." + }, + "grants": { + "title": "Connector grants", + "loading": "Loading grants…", + "empty": "This agent has no connector grants.", + "tool": "Tool", + "direct": "Direct to this agent", + "by": "Granted by", + "unknown": "unknown", + "ended": "Ended", + "ends": "Ends", + "revoke": "Revoke", + "confirmRevoke": "Yes, revoke it", + "cancel": "Cancel", + "loadError": "Could not load connector grants.", + "revokeError": "Could not revoke the grant." + } + }, "agentSettings": { "wakeTitle": "Wake", "mentionsAlwaysWake": "Mentions always wake this agent.", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index f3b824e790..5435a68dd4 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2245,6 +2245,41 @@ "listSeparator": "、", "sentenceSeparator": "" }, + "agentManage": { + "runtime": { + "title": "运行环境", + "how": "运行方式", + "hosted": "托管运行", + "daemon": "通过守护进程在电脑上运行", + "cli": "单个 CLI", + "unknown": "暂无运行环境详情", + "adapter": "适配器", + "model": "模型", + "modelUnknown": "由运行环境决定", + "effort": "推理强度", + "changeModel": "模型", + "changeEffort": "推理强度", + "effortUnset": "选择推理强度", + "save": "保存运行设置", + "saveError": "无法保存模型。" + }, + "grants": { + "title": "连接器授权", + "loading": "正在加载授权…", + "empty": "此智能体没有连接器授权。", + "tool": "工具", + "direct": "直接授权给此智能体", + "by": "授权人", + "unknown": "未知", + "ended": "已结束", + "ends": "结束于", + "revoke": "撤销", + "confirmRevoke": "确认撤销", + "cancel": "取消", + "loadError": "无法加载连接器授权。", + "revokeError": "无法撤销授权。" + } + }, "agentSettings": { "wakeTitle": "唤醒", "mentionsAlwaysWake": "提及始终会唤醒此智能体。", diff --git a/frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx b/frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx new file mode 100644 index 0000000000..b6e44cf7a9 --- /dev/null +++ b/frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx @@ -0,0 +1,115 @@ +// @ts-nocheck +import React from 'react'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import V2AgentRuntimeGrants, { describeRuntime } from '../components/V2AgentRuntimeGrants'; +import { AuthContext } from '../../context/AuthContext'; + +jest.mock('axios', () => { + const mock = { + get: jest.fn(), post: jest.fn(), + defaults: { baseURL: '', headers: { common: {} } }, + interceptors: { request: { use: jest.fn(), eject: jest.fn() }, response: { use: jest.fn(), eject: jest.fn() } }, + }; + return { __esModule: true, default: mock, ...mock }; +}); +const axios = jest.requireMock('axios').default; +const saveRuntime = jest.fn(); +const authValue = { + currentUser: { _id: 'owner', username: 'owner' }, user: { _id: 'owner', username: 'owner' }, token: 'user-jwt', + loading: false, error: null, isAuthenticated: true, + register: jest.fn(), login: jest.fn(), logout: jest.fn(), updateProfile: jest.fn(), +}; +const response = { + grants: [{ + grantId: 'grant-1', connector: 'linear', tools: ['linear.list_issues'], podName: 'Work', + grantedBy: 'Owner', createdAt: '2026-10-01T00:00:00Z', expiresAt: '2099-11-01T00:00:00Z', + revokedAt: null, canRevoke: true, + }], +}; + +const renderSection = (runtime = { runtimeType: 'wrapper', adapter: 'claude', model: 'sonnet' }) => render( + + + , +); + +beforeEach(() => { + jest.clearAllMocks(); + axios.get.mockResolvedValue({ data: response }); + axios.post.mockResolvedValue({ data: { revoked: 1 } }); + saveRuntime.mockResolvedValue(undefined); +}); + +test('runtime labels only recognize walked adapters and distinguish daemon from a single CLI', () => { + expect(describeRuntime({ runtimeType: 'wrapper', adapter: 'claude' })).toEqual({ mode: 'daemon', adapter: 'Claude Code' }); + expect(describeRuntime({ runtimeType: 'webhook', host: 'byo', adapter: 'dsh' })).toEqual({ mode: 'cli', adapter: 'DeepSeek Harness' }); + expect(describeRuntime({ runtimeType: 'wrapper', adapter: 'unwalked' })).toEqual({ mode: 'daemon', adapter: null }); + expect(describeRuntime({ runtimeType: 'hosted' })).toEqual({ mode: 'hosted', adapter: null }); + expect(describeRuntime({ runtimeType: 'webhook', host: 'byo' })).toEqual({ mode: 'unknown', adapter: null }); +}); + +test('shows this agent grant and requires a second click to revoke through the existing route', async () => { + renderSection(); + expect(await screen.findByText('On a computer, via the daemon')).toBeInTheDocument(); + expect(screen.getByText('Claude Code')).toBeInTheDocument(); + expect(await screen.findByText(/Work · Granted by Owner/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Revoke' })); + expect(axios.post).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: 'Yes, revoke it' })); + await waitFor(() => expect(axios.post).toHaveBeenCalledWith('/api/grants/grant-1/revoke', undefined, expect.anything())); +}); + +test('model changes delegate to the shared settings hook; a single CLI has no edit control', async () => { + const { rerender } = renderSection(); + const input = await screen.findByLabelText('Model'); + fireEvent.change(input, { target: { value: 'opus' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save runtime' })); + await waitFor(() => expect(saveRuntime).toHaveBeenCalledWith({ model: 'opus' })); + expect(axios.post).not.toHaveBeenCalled(); + + rerender(); + expect(await screen.findByText('Single CLI')).toBeInTheDocument(); + expect(screen.queryByLabelText('Model')).toBeNull(); +}); + +test('daemon effort changes are passed to the shared settings callback', async () => { + renderSection({ runtimeType: 'wrapper', adapter: 'codex', model: 'gpt-6-sol', effort: 'medium' }); + const input = screen.getByLabelText('Effort'); + fireEvent.change(input, { target: { value: 'high' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save runtime' })); + await waitFor(() => expect(saveRuntime).toHaveBeenCalledWith({ effort: 'high' })); +}); + +test('the unset effort option clears a saved effort', async () => { + renderSection({ runtimeType: 'wrapper', adapter: 'codex', model: 'gpt-6-sol', effort: 'medium' }); + fireEvent.change(screen.getByLabelText('Effort'), { target: { value: '' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save runtime' })); + await waitFor(() => expect(saveRuntime).toHaveBeenCalledWith({ effort: '' })); +}); + +test('a failed runtime save reports next to the editor', async () => { + saveRuntime.mockRejectedValue(new Error('unavailable')); + renderSection(); + fireEvent.change(screen.getByLabelText('Model'), { target: { value: 'opus' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save runtime' })); + const runtimeSection = screen.getByRole('heading', { name: 'Runtime' }).closest('section'); + expect(await within(runtimeSection).findByRole('alert')).toHaveTextContent('Could not save the model.'); +}); + +test('a successful revoke stays ended if the grant reload fails', async () => { + renderSection(); + await screen.findByText(/Work · Granted by Owner/); + axios.get.mockRejectedValue(new Error('reload failed')); + fireEvent.click(screen.getByRole('button', { name: 'Revoke' })); + fireEvent.click(screen.getByRole('button', { name: 'Yes, revoke it' })); + const grantsSection = screen.getByRole('heading', { name: 'Connector grants' }).closest('section'); + expect(await within(grantsSection).findByRole('alert')).toHaveTextContent('Could not load connector grants.'); + expect(screen.queryByRole('button', { name: 'Revoke' })).toBeNull(); +}); + +test('does not offer revoke when the server says this viewer cannot revoke', async () => { + axios.get.mockResolvedValue({ data: { ...response, grants: [{ ...response.grants[0], canRevoke: false }] } }); + renderSection(); + expect(await screen.findByText(/Work · Granted by Owner/)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Revoke' })).toBeNull(); +}); diff --git a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx index 0c4a774001..171d78d9b3 100644 --- a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx @@ -3,21 +3,29 @@ import React from 'react'; import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { MemoryRouter, Route, Routes, useNavigate } from 'react-router-dom'; import V2AgentSettingsPage from '../agents/V2AgentSettingsPage'; +import { AuthContext } from '../../context/AuthContext'; jest.mock('axios', () => { const mock = { - get: jest.fn(), post: jest.fn(), delete: jest.fn(), + get: jest.fn(), post: jest.fn(), patch: jest.fn(), delete: jest.fn(), defaults: { baseURL: '', headers: { common: {} } }, interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } }, }; return { __esModule: true, default: mock, ...mock }; }); const axios = jest.requireMock('axios').default; +const authValue = { + currentUser: { _id: 'owner', username: 'owner' }, user: { _id: 'owner', username: 'owner' }, + token: 'user-jwt', loading: false, error: null, isAuthenticated: true, + register: jest.fn(), login: jest.fn(), signInWithToken: jest.fn(), logout: jest.fn(), updateProfile: jest.fn(), +}; const renderPage = (path = '/v2/agents/maya-claude/default') => render( - - } /> - , + + + } /> + + , ); beforeEach(() => { @@ -105,10 +113,12 @@ test('an in-flight replacement never reveals its key on another agent page', asy return ; }; render( - - - } /> - , + + + + } /> + + , ); await screen.findByText('Laptop'); fireEvent.click(screen.getByRole('button', { name: 'Regenerate key' })); @@ -178,3 +188,31 @@ test('selects a pod the owner can read when the first identity pod is inaccessib expect.objectContaining({ force: true }), )); }); + +test('runtime editor writes environment controls through the shared settings hook', async () => { + axios.patch.mockResolvedValue({ data: { updated: true } }); + axios.get.mockImplementation((url) => { + if (url.startsWith('/api/agent-memory/')) return Promise.resolve({ data: { + displayName: 'Maya', pods: [{ id: 'pod-1', name: 'Workspace' }], + } }); + if (url.startsWith('/api/registry/pods/')) return Promise.resolve({ data: { agent: { + name: 'maya-claude', instanceId: 'default', displayName: 'Maya', + runtime: { runtimeType: 'wrapper' }, + config: { environment: { adapter: 'claude', model: 'old', effort: 'medium' } }, + } } }); + if (url.includes('/grants')) return Promise.resolve({ data: { grants: [] } }); + return Promise.resolve({ data: [] }); + }); + renderPage(); + const model = await screen.findByLabelText('Model'); + const sections = Array.from(document.querySelectorAll('.v2-agent-settings h2')).map((node) => node.textContent); + expect(sections.indexOf('Key')).toBeLessThan(sections.indexOf('Runtime')); + expect(sections.indexOf('Wake')).toBeLessThan(sections.indexOf('Runtime')); + expect(sections.indexOf('Runtime')).toBeLessThan(sections.indexOf('Connector grants')); + fireEvent.change(model, { target: { value: 'new-model' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save runtime' })); + await waitFor(() => expect(axios.patch).toHaveBeenCalledWith( + '/api/registry/pods/pod-1/agents/maya-claude', + { config: { environment: { model: 'new-model' } }, instanceId: 'default' }, + )); +}); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index fcb3b54174..9cae0aabb6 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -4692,3 +4692,12 @@ describe('agent Wake and Pods controls', () => { expect(remove).toContain('border: 1px solid var(--v2-border)'); }); }); + +describe('agent runtime and grants fit the Settings column', () => { + const css = read('../components/V2AgentRuntimeGrants.css'); + test('facts stack on phones and long grant details wrap', () => { + const phone = blockContaining(css, '@media (max-width: 760px)', '.v2-agent-runtime-grants__facts > div'); + expect(phone).toContain('grid-template-columns: 1fr'); + expect(ruleBody(css, '.v2-agent-runtime-grants__list li p')).toContain('overflow-wrap: anywhere'); + }); +}); diff --git a/frontend/src/v2/agents/V2AgentSettingsPage.tsx b/frontend/src/v2/agents/V2AgentSettingsPage.tsx index b0d6c50224..cbaa6feed0 100644 --- a/frontend/src/v2/agents/V2AgentSettingsPage.tsx +++ b/frontend/src/v2/agents/V2AgentSettingsPage.tsx @@ -3,6 +3,7 @@ import { Link, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import axios from '../../utils/axiosConfig'; import V2PageHead from '../components/V2PageHead'; +import V2AgentRuntimeGrants, { Runtime } from '../components/V2AgentRuntimeGrants'; import { useAgentSettings } from './useAgentSettings'; import V2AgentWakePods from './V2AgentWakePods'; import './v2-agent-settings.css'; @@ -31,7 +32,7 @@ const errorLabel = (error: unknown, fallback: string): string => ( const V2AgentSettingsPage: React.FC = () => { const { t } = useTranslation(); const { agentName = '', instanceId = '' } = useParams<{ agentName: string; instanceId: string }>(); - const { access, installation, podId, loading, error: loadError, refresh, updatePod } = useAgentSettings(agentName, instanceId); + const { access, installation, podId, loading, error: loadError, refresh, update, updatePod } = useAgentSettings(agentName, instanceId); const [removedLast, setRemovedLast] = useState(false); const [keys, setKeys] = useState([]); const [keysReadyFor, setKeysReadyFor] = useState(null); @@ -138,6 +139,16 @@ const V2AgentSettingsPage: React.FC = () => { {error || t('agentSettings.loading')} ; + const environment = installation?.config?.environment; + const controls = environment && typeof environment === 'object' && !Array.isArray(environment) + ? environment as Record : {}; + const runtime: Runtime = { + ...(installation?.runtime || {}), + ...(typeof controls.adapter === 'string' ? { adapter: controls.adapter } : {}), + ...(typeof controls.model === 'string' ? { model: controls.model } : {}), + ...(typeof controls.effort === 'string' ? { effort: controls.effort } : {}), + }; + return (
{t('agentSettings.yourTeam')}} /> @@ -206,6 +217,15 @@ const V2AgentSettingsPage: React.FC = () => { )} + { + await update({ config: { environment: changes } }); + }} + />
); }; diff --git a/frontend/src/v2/components/V2AgentRuntimeGrants.css b/frontend/src/v2/components/V2AgentRuntimeGrants.css new file mode 100644 index 0000000000..04d1d3bc4a --- /dev/null +++ b/frontend/src/v2/components/V2AgentRuntimeGrants.css @@ -0,0 +1,24 @@ +.v2-agent-runtime-grants { + display: grid; +} + +.v2-agent-runtime-grants section { min-width: 0; } +.v2-agent-runtime-grants__facts { margin: 16px 0 0; display: grid; gap: 12px; } +.v2-agent-runtime-grants__facts > div { display: grid; grid-template-columns: minmax(110px, 1fr) 2fr; gap: 12px; } +.v2-agent-runtime-grants__facts dt { color: var(--v2-text-secondary); } +.v2-agent-runtime-grants__facts dd { margin: 0; overflow-wrap: anywhere; } +.v2-agent-runtime-grants__edit { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 20px; } +.v2-agent-runtime-grants__edit input, .v2-agent-runtime-grants__edit select { min-width: 120px; max-width: 280px; padding: 7px 10px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: var(--v2-surface); color: var(--v2-text-primary); font: inherit; } +.v2-agent-runtime-grants__edit input:focus-visible, .v2-agent-runtime-grants__edit select:focus-visible { outline: 1px solid var(--v2-accent); box-shadow: var(--v2-focus-ring); } +.v2-agent-runtime-grants__list { list-style: none; margin: 16px 0 0; padding: 0; display: grid; } +.v2-agent-runtime-grants__list li { border-top: 1px solid var(--v2-border-soft); padding: 14px 0; min-width: 0; } +.v2-agent-runtime-grants__list li > div:first-child { display: flex; flex-wrap: wrap; gap: 8px; overflow-wrap: anywhere; } +.v2-agent-runtime-grants__list li p { margin: 6px 0; color: var(--v2-text-secondary); font-size: 13px; overflow-wrap: anywhere; } +.v2-agent-runtime-grants__actions { display: flex; flex-wrap: wrap; gap: 8px; } +.v2-agent-runtime-grants [role="alert"] { color: var(--v2-danger); } + +@media (max-width: 760px) { + .v2-agent-runtime-grants__facts > div { grid-template-columns: 1fr; gap: 2px; } + .v2-agent-runtime-grants__edit { align-items: stretch; } + .v2-agent-runtime-grants__edit input, .v2-agent-runtime-grants__edit select { width: 100%; max-width: 100%; } +} diff --git a/frontend/src/v2/components/V2AgentRuntimeGrants.tsx b/frontend/src/v2/components/V2AgentRuntimeGrants.tsx new file mode 100644 index 0000000000..4bf289789c --- /dev/null +++ b/frontend/src/v2/components/V2AgentRuntimeGrants.tsx @@ -0,0 +1,201 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useV2Api } from '../hooks/useV2Api'; +import './V2AgentRuntimeGrants.css'; + +export interface Runtime { + runtimeType?: string; + host?: 'cloud' | 'byo'; + adapter?: string; + model?: string; + effort?: string; +} + +interface AgentGrant { + grantId: string; + tools: string[]; + connector: string | null; + podName: string | null; + grantedBy: string | null; + createdAt: string; + expiresAt: string; + revokedAt: string | null; + canRevoke: boolean; +} + +interface AgentGrantsResponse { + grants: AgentGrant[]; +} + +interface Props { + name: string; + instanceId: string; + runtime: Runtime; + preferredModel?: string | null; + onSaveRuntime?: (update: { model?: string; effort?: string }) => Promise; +} + +const WALKED_ADAPTERS: Record = { + claude: 'Claude Code', + 'claude-code': 'Claude Code', + codex: 'Codex', + opencode: 'OpenCode', + hermes: 'Hermes', + dsh: 'DeepSeek Harness', +}; + +export const describeRuntime = (runtime: Runtime): { mode: 'hosted' | 'daemon' | 'cli' | 'unknown'; adapter: string | null } => { + const type = String(runtime.runtimeType || '').toLowerCase(); + const adapterKey = String(runtime.adapter || type).toLowerCase(); + const mode = type === 'wrapper' ? 'daemon' + : type === 'hosted' || type === 'native' || type === 'internal' || runtime.host === 'cloud' ? 'hosted' + : runtime.host === 'byo' && WALKED_ADAPTERS[adapterKey] ? 'cli' : 'unknown'; + return { mode, adapter: WALKED_ADAPTERS[adapterKey] || null }; +}; + +const dateLabel = (value: string | null): string => { + if (!value) return ''; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? '' : date.toLocaleDateString(); +}; + +const V2AgentRuntimeGrants: React.FC = ({ + name, instanceId, runtime, preferredModel = null, onSaveRuntime, +}) => { + const { t } = useTranslation(); + const api = useV2Api(); + const [grants, setGrants] = useState([]); + const [loading, setLoading] = useState(true); + const [runtimeError, setRuntimeError] = useState(''); + const [grantsError, setGrantsError] = useState(''); + const [busy, setBusy] = useState(false); + const [modelDraft, setModelDraft] = useState(runtime.model || ''); + const [effortDraft, setEffortDraft] = useState(runtime.effort || ''); + const [confirmGrantId, setConfirmGrantId] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setGrantsError(''); + try { + const next = await api.get( + `/api/registry/agents/${encodeURIComponent(name)}/instances/${encodeURIComponent(instanceId)}/grants`, + ); + setGrants(next.grants || []); + } catch { + setGrantsError(t('agentManage.grants.loadError', { defaultValue: 'Could not load connector grants.' })); + } finally { + setLoading(false); + } + }, [api, name, instanceId, t]); + + useEffect(() => { void load(); }, [load]); + useEffect(() => setModelDraft(runtime.model || ''), [runtime.model]); + useEffect(() => setEffortDraft(runtime.effort || ''), [runtime.effort]); + + const details = useMemo(() => describeRuntime(runtime), [runtime]); + const modeLabel = { + hosted: t('agentManage.runtime.hosted', { defaultValue: 'Hosted' }), + daemon: t('agentManage.runtime.daemon', { defaultValue: 'On a computer, via the daemon' }), + cli: t('agentManage.runtime.cli', { defaultValue: 'Single CLI' }), + unknown: t('agentManage.runtime.unknown', { defaultValue: 'Runtime details unavailable' }), + }[details.mode]; + const model = runtime.model || preferredModel || null; + // The daemon reads the environment model on its next assignment and restarts + // the child when it changes. A single CLI chooses its own model locally. + const mayEditRuntime = Boolean(onSaveRuntime && details.mode === 'daemon' && details.adapter); + const modelChanged = Boolean(modelDraft.trim() && modelDraft.trim() !== (runtime.model || '')); + const effortChanged = effortDraft.trim() !== (runtime.effort || ''); + + const saveRuntime = async () => { + if (!onSaveRuntime || !mayEditRuntime) return; + setBusy(true); + setRuntimeError(''); + try { + await onSaveRuntime({ + ...(modelChanged ? { model: modelDraft.trim() } : {}), + ...(effortChanged ? { effort: effortDraft.trim() } : {}), + }); + } catch { + setRuntimeError(t('agentManage.runtime.saveError', { defaultValue: 'Could not save the model.' })); + } finally { + setBusy(false); + } + }; + + const revoke = async (grantId: string) => { + setBusy(true); + setGrantsError(''); + try { + await api.post(`/api/grants/${encodeURIComponent(grantId)}/revoke`); + setConfirmGrantId(null); + setGrants((current) => current.map((grant) => ( + grant.grantId === grantId ? { ...grant, revokedAt: new Date().toISOString() } : grant + ))); + await load(); + } catch { + setGrantsError(t('agentManage.grants.revokeError', { defaultValue: 'Could not revoke the grant.' })); + } finally { + setBusy(false); + } + }; + + return ( +
+
+

{t('agentManage.runtime.title', { defaultValue: 'Runtime' })}

+
+
{t('agentManage.runtime.how', { defaultValue: 'How it runs' })}
{modeLabel}
+ {details.adapter &&
{t('agentManage.runtime.adapter', { defaultValue: 'Adapter' })}
{details.adapter}
} +
{t('agentManage.runtime.model', { defaultValue: 'Model' })}
{model || t('agentManage.runtime.modelUnknown', { defaultValue: 'Set by the runtime' })}
+ {runtime.effort &&
{t('agentManage.runtime.effort', { defaultValue: 'Effort' })}
{runtime.effort}
} +
+ {mayEditRuntime && ( +
+ + setModelDraft(event.target.value)} /> + + + +
+ )} + {runtimeError &&

{runtimeError}

} +
+
+

{t('agentManage.grants.title', { defaultValue: 'Connector grants' })}

+ {grantsError &&

{grantsError}

} + {loading &&

{t('agentManage.grants.loading', { defaultValue: 'Loading grants…' })}

} + {!loading && !grantsError && grants.length === 0 &&

{t('agentManage.grants.empty', { defaultValue: 'This agent has no connector grants.' })}

} +
    + {grants.map((grant) => { + const ended = Boolean(grant.revokedAt) || new Date(grant.expiresAt).getTime() <= Date.now(); + return ( +
  • +
    + {grant.connector || grant.tools[0]?.split('.')[0] || t('agentManage.grants.tool', { defaultValue: 'Tool' })} + {grant.tools.join(', ')} +
    +

    {grant.podName || t('agentManage.grants.direct', { defaultValue: 'Direct to this agent' })} · {t('agentManage.grants.by', { defaultValue: 'Granted by' })} {grant.grantedBy || t('agentManage.grants.unknown', { defaultValue: 'unknown' })} · {dateLabel(grant.createdAt)}

    +

    {ended ? t('agentManage.grants.ended', { defaultValue: 'Ended' }) : t('agentManage.grants.ends', { defaultValue: 'Ends' })} {dateLabel(grant.revokedAt || grant.expiresAt)}

    + {!ended && grant.canRevoke && (confirmGrantId === grant.grantId ? ( +
    + + +
    + ) : ( + + ))} +
  • + ); + })} +
+
+
+ ); +}; + +export default V2AgentRuntimeGrants;