From 8868275a462ff337804011be89b50c9e5291ac7d Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:54:56 -0700 Subject: [PATCH 1/7] feat: add per-agent runtime and connector grants section Co-authored-by: nib Agent-Model: codex/gpt-6-sol/medium --- .../__tests__/unit/routes/grants.read.test.js | 14 +- .../routes/registry.manage-grants.test.js | 89 ++++++++ .../registry.patch-shared-settings.test.js | 40 ++++ backend/routes/grants.ts | 5 +- backend/routes/registry.ts | 2 + backend/routes/registry/agent-config.ts | 16 ++ backend/routes/registry/manage-grants.ts | 99 +++++++++ .../__tests__/V2AgentRuntimeGrants.test.tsx | 87 ++++++++ .../v2/components/V2AgentRuntimeGrants.css | 15 ++ .../v2/components/V2AgentRuntimeGrants.tsx | 191 ++++++++++++++++++ 10 files changed, 556 insertions(+), 2 deletions(-) create mode 100644 backend/__tests__/unit/routes/registry.manage-grants.test.js create mode 100644 backend/routes/registry/manage-grants.ts create mode 100644 frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx create mode 100644 frontend/src/v2/components/V2AgentRuntimeGrants.css create mode 100644 frontend/src/v2/components/V2AgentRuntimeGrants.tsx 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..848b2245f7 --- /dev/null +++ b/backend/__tests__/unit/routes/registry.manage-grants.test.js @@ -0,0 +1,89 @@ +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', config: {} }, { installedBy: 'someone-else', config: {} }, + ] }); + const res = await request(app).get(URL).set('x-test-user', 'owner'); + expect(res.status).toBe(403); + expect(RoomGrant.find).not.toHaveBeenCalled(); +}); + +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([]); +}); diff --git a/backend/__tests__/unit/routes/registry.patch-shared-settings.test.js b/backend/__tests__/unit/routes/registry.patch-shared-settings.test.js index 22a7799108..2b3ccf442b 100644 --- a/backend/__tests__/unit/routes/registry.patch-shared-settings.test.js +++ b/backend/__tests__/unit/routes/registry.patch-shared-settings.test.js @@ -180,4 +180,44 @@ describe('registry shared agent settings', () => { }), ); }); + + it('updates each daemon environment model without replacing its declarations', async () => { + const primaryInstall = { + agentName: 'claude-code', podId: 'pod-1', instanceId: 'default', + status: 'active', installedBy: 'user-1', + config: new Map(Object.entries({ + runtime: { runtimeType: 'claude-code', host: 'byo', model: 'old' }, + environment: { version: 1, model: 'old', workspace: { path: '/one' } }, + })), + save: jest.fn().mockResolvedValue(true), + }; + const secondaryInstall = { + ...primaryInstall, podId: 'pod-2', + config: new Map(Object.entries({ + runtime: { runtimeType: 'claude-code', host: 'byo', model: 'old' }, + environment: { version: 1, model: 'old', mcp: [{ name: 'commonly', transport: 'stdio', command: ['npx', '@commonlyai/mcp'] }] }, + })), + save: jest.fn().mockResolvedValue(true), + }; + Pod.findById.mockReturnValue({ lean: async () => ({ + _id: 'pod-1', createdBy: 'user-1', members: ['user-1'], + }) }); + Pod.find.mockReturnValue({ select: () => ({ lean: async () => [ + { _id: 'pod-1', createdBy: 'user-1', members: ['user-1'] }, + { _id: 'pod-2', createdBy: 'user-1', members: ['user-1'] }, + ] }) }); + AgentInstallation.findOne.mockResolvedValue(primaryInstall); + AgentInstallation.find.mockResolvedValue([primaryInstall, secondaryInstall]); + + const response = await request(app).patch('/api/registry/pods/pod-1/agents/claude-code') + .send({ instanceId: 'default', config: { runtime: { model: 'new', effort: 'high' } } }); + + expect(response.status).toBe(200); + expect(primaryInstall.config.get('runtime')).toMatchObject({ runtimeType: 'claude-code', model: 'new', effort: 'high' }); + expect(primaryInstall.config.get('environment')).toEqual({ version: 1, model: 'new', effort: 'high', workspace: { path: '/one' } }); + expect(secondaryInstall.config.get('environment')).toEqual({ + version: 1, model: 'new', effort: 'high', + mcp: [{ name: 'commonly', transport: 'stdio', command: ['npx', '@commonlyai/mcp'] }], + }); + }); }); diff --git a/backend/routes/grants.ts b/backend/routes/grants.ts index dbf3790c20..7958dd1c93 100644 --- a/backend/routes/grants.ts +++ b/backend/routes/grants.ts @@ -466,7 +466,10 @@ 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) { + const isOwner = Boolean(connection) && connectionOwnerId(connection) === userId; + const isAdmin = req.user?.role === 'admin' + || (Boolean(userId) && (await User.findById(userId).select('role').lean())?.role === 'admin'); + if (!connection || (!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/agent-config.ts b/backend/routes/registry/agent-config.ts index 91027cfcbe..a13ccefdec 100644 --- a/backend/routes/registry/agent-config.ts +++ b/backend/routes/registry/agent-config.ts @@ -180,6 +180,22 @@ agentConfigRouter.patch('/pods/:podId/agents/:name', auth, async (req: any, res: } if (config.runtime && typeof config.runtime === 'object') { nextConfig.runtime = runtimeKeepingType(config.runtime, existingConfig, { mayChangeType: isInstanceAdmin }); + // The daemon prefers an ADR-008 environment's model and effort over + // the legacy runtime overlay. A settings edit must update both + // controls on each row without replacing that row's workspace, + // sandbox, skills or MCP declarations. + if (config.environment === undefined && existingConfig.environment + && typeof existingConfig.environment === 'object' + && !Array.isArray(existingConfig.environment)) { + const preferences = Object.fromEntries( + ['model', 'effort'] + .filter((key) => Object.prototype.hasOwnProperty.call(config.runtime, key)) + .map((key) => [key, config.runtime[key]]), + ); + if (Object.keys(preferences).length) { + nextConfig.environment = { ...existingConfig.environment, ...preferences }; + } + } } if (nextConfig.runtime && typeof nextConfig.runtime === 'object') { const runtimeConfig = { ...nextConfig.runtime }; diff --git a/backend/routes/registry/manage-grants.ts b/backend/routes/registry/manage-grants.ts new file mode 100644 index 0000000000..f9c2cca85c --- /dev/null +++ b/backend/routes/registry/manage-grants.ts @@ -0,0 +1,99 @@ +import express from 'express'; +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(); + +/** The manage page reads one identity, not every grant in a pod. */ +router.get('/agents/:name/instances/:instanceId/grants', 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: 'active', + }).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').lean() + : null; + const connection = byId || await Integration.findOne({ installationId: connectionId }) + .select('type createdBy config.entryId').lean(); + return [connectionId, connection] as const; + })); + const connectionById = new Map(connections); + const granterIds = Array.from(new Set(connections + .map(([, connection]) => String(connection?.createdBy || '')) + .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 || ''); + 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/v2/__tests__/V2AgentRuntimeGrants.test.tsx b/frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx new file mode 100644 index 0000000000..5433c51009 --- /dev/null +++ b/frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx @@ -0,0 +1,87 @@ +// @ts-nocheck +import React from 'react'; +import { fireEvent, render, screen, waitFor } 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 }); +}); + +test('shows this agent grant and requires a second click to revoke through the existing route', async () => { + renderSection(); + expect(await screen.findByText('On my 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('Change model on this computer'); + 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('Change model on this computer')).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('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('Connector grants')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Revoke' })).toBeNull(); +}); diff --git a/frontend/src/v2/components/V2AgentRuntimeGrants.css b/frontend/src/v2/components/V2AgentRuntimeGrants.css new file mode 100644 index 0000000000..62670ae1a0 --- /dev/null +++ b/frontend/src/v2/components/V2AgentRuntimeGrants.css @@ -0,0 +1,15 @@ +.v2-agent-runtime-grants { display: grid; gap: 28px; } +.v2-agent-runtime-grants section { min-width: 0; } +.v2-agent-runtime-grants h2 { margin: 0 0 12px; font-size: 18px; } +.v2-agent-runtime-grants__facts { margin: 0; display: grid; gap: 8px; } +.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-muted, #667085); } +.v2-agent-runtime-grants__facts dd { margin: 0; } +.v2-agent-runtime-grants__edit { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 16px; } +.v2-agent-runtime-grants__edit input { min-width: 180px; max-width: 280px; } +.v2-agent-runtime-grants__list { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; } +.v2-agent-runtime-grants__list li { border: 1px solid var(--v2-border, #d0d5dd); border-radius: 8px; padding: 12px; } +.v2-agent-runtime-grants__list li > div:first-child { display: flex; flex-wrap: wrap; gap: 8px; } +.v2-agent-runtime-grants__list li p { margin: 6px 0; } +.v2-agent-runtime-grants__actions { display: flex; gap: 8px; } +@media (max-width: 500px) { .v2-agent-runtime-grants__facts > div { grid-template-columns: 1fr; gap: 2px; } } diff --git a/frontend/src/v2/components/V2AgentRuntimeGrants.tsx b/frontend/src/v2/components/V2AgentRuntimeGrants.tsx new file mode 100644 index 0000000000..af3ea90c97 --- /dev/null +++ b/frontend/src/v2/components/V2AgentRuntimeGrants.tsx @@ -0,0 +1,191 @@ +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 mode = type === 'wrapper' ? 'daemon' + : type === 'hosted' || type === 'internal' || runtime.host === 'cloud' ? 'hosted' + : type === 'webhook' || runtime.host === 'byo' || WALKED_ADAPTERS[type] ? 'cli' : 'unknown'; + const adapterKey = String(runtime.adapter || (mode === 'cli' ? type : '')).toLowerCase(); + 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 [error, setError] = 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); + setError(''); + try { + const next = await api.get( + `/api/registry/agents/${encodeURIComponent(name)}/instances/${encodeURIComponent(instanceId)}/grants`, + ); + setGrants(next.grants || []); + } catch { + setError(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 my 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 runtime.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 saveRuntime = async () => { + if (!onSaveRuntime || !mayEditRuntime) return; + setBusy(true); + setError(''); + try { + await onSaveRuntime({ + ...(modelDraft.trim() && modelDraft.trim() !== (runtime.model || '') ? { model: modelDraft.trim() } : {}), + ...(effortDraft.trim() && effortDraft.trim() !== (runtime.effort || '') ? { effort: effortDraft.trim() } : {}), + }); + } catch { + setError(t('agentManage.runtime.saveError', { defaultValue: 'Could not save the model.' })); + } finally { + setBusy(false); + } + }; + + const revoke = async (grantId: string) => { + setBusy(true); + setError(''); + try { + await api.post(`/api/grants/${encodeURIComponent(grantId)}/revoke`); + setConfirmGrantId(null); + await load(); + } catch { + setError(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)} /> + + setEffortDraft(event.target.value)} /> + +
+ )} +
+
+

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

+ {loading &&

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

} + {!loading && 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 ? ( +
    + + +
    + ) : ( + + ))} +
  • + ); + })} +
+
+ {error &&

{error}

} +
+ ); +}; + +export default V2AgentRuntimeGrants; From 9d6791dc5999ef814c9162e640a5706435fca801 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:01:43 -0700 Subject: [PATCH 2/7] style: fit runtime grants into agent settings layout Co-authored-by: nib Agent-Model: codex/gpt-6-sol/medium --- .../__tests__/V2AgentRuntimeGrants.test.tsx | 1 + .../v2/__tests__/v2-layout-invariants.test.ts | 9 +++++ .../v2/components/V2AgentRuntimeGrants.css | 35 ++++++++++++------- .../v2/components/V2AgentRuntimeGrants.tsx | 23 +++++++----- 4 files changed, 46 insertions(+), 22 deletions(-) diff --git a/frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx b/frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx index 5433c51009..e875c24833 100644 --- a/frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx @@ -45,6 +45,7 @@ test('runtime labels only recognize walked adapters and distinguish daemon from 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 () => { 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/components/V2AgentRuntimeGrants.css b/frontend/src/v2/components/V2AgentRuntimeGrants.css index 62670ae1a0..04d1d3bc4a 100644 --- a/frontend/src/v2/components/V2AgentRuntimeGrants.css +++ b/frontend/src/v2/components/V2AgentRuntimeGrants.css @@ -1,15 +1,24 @@ -.v2-agent-runtime-grants { display: grid; gap: 28px; } +.v2-agent-runtime-grants { + display: grid; +} + .v2-agent-runtime-grants section { min-width: 0; } -.v2-agent-runtime-grants h2 { margin: 0 0 12px; font-size: 18px; } -.v2-agent-runtime-grants__facts { margin: 0; display: grid; gap: 8px; } +.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-muted, #667085); } -.v2-agent-runtime-grants__facts dd { margin: 0; } -.v2-agent-runtime-grants__edit { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 16px; } -.v2-agent-runtime-grants__edit input { min-width: 180px; max-width: 280px; } -.v2-agent-runtime-grants__list { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; } -.v2-agent-runtime-grants__list li { border: 1px solid var(--v2-border, #d0d5dd); border-radius: 8px; padding: 12px; } -.v2-agent-runtime-grants__list li > div:first-child { display: flex; flex-wrap: wrap; gap: 8px; } -.v2-agent-runtime-grants__list li p { margin: 6px 0; } -.v2-agent-runtime-grants__actions { display: flex; gap: 8px; } -@media (max-width: 500px) { .v2-agent-runtime-grants__facts > div { grid-template-columns: 1fr; gap: 2px; } } +.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 index af3ea90c97..a0871511f6 100644 --- a/frontend/src/v2/components/V2AgentRuntimeGrants.tsx +++ b/frontend/src/v2/components/V2AgentRuntimeGrants.tsx @@ -46,10 +46,10 @@ const WALKED_ADAPTERS: Record = { 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 === 'internal' || runtime.host === 'cloud' ? 'hosted' - : type === 'webhook' || runtime.host === 'byo' || WALKED_ADAPTERS[type] ? 'cli' : 'unknown'; - const adapterKey = String(runtime.adapter || (mode === 'cli' ? type : '')).toLowerCase(); + : 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 }; }; @@ -102,6 +102,8 @@ const V2AgentRuntimeGrants: React.FC = ({ // The daemon reads runtime.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 = Boolean(effortDraft.trim() && effortDraft.trim() !== (runtime.effort || '')); const saveRuntime = async () => { if (!onSaveRuntime || !mayEditRuntime) return; @@ -109,8 +111,8 @@ const V2AgentRuntimeGrants: React.FC = ({ setError(''); try { await onSaveRuntime({ - ...(modelDraft.trim() && modelDraft.trim() !== (runtime.model || '') ? { model: modelDraft.trim() } : {}), - ...(effortDraft.trim() && effortDraft.trim() !== (runtime.effort || '') ? { effort: effortDraft.trim() } : {}), + ...(modelChanged ? { model: modelDraft.trim() } : {}), + ...(effortChanged ? { effort: effortDraft.trim() } : {}), }); } catch { setError(t('agentManage.runtime.saveError', { defaultValue: 'Could not save the model.' })); @@ -135,7 +137,7 @@ const V2AgentRuntimeGrants: React.FC = ({ return (
-
+

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

{t('agentManage.runtime.how', { defaultValue: 'How it runs' })}
{modeLabel}
@@ -148,14 +150,17 @@ const V2AgentRuntimeGrants: React.FC = ({ setModelDraft(event.target.value)} /> - setEffortDraft(event.target.value)} /> -
)} -
+

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

{loading &&

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

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

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

} From ba876d57493b8a5b2965c402eee234b1f38d4a23 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:05:16 -0700 Subject: [PATCH 3/7] refactor: use shared environment settings path for agent model Co-authored-by: nib Agent-Model: codex/gpt-6-sol/medium --- .../registry.patch-shared-settings.test.js | 40 ------------------- backend/routes/registry/agent-config.ts | 16 -------- 2 files changed, 56 deletions(-) diff --git a/backend/__tests__/unit/routes/registry.patch-shared-settings.test.js b/backend/__tests__/unit/routes/registry.patch-shared-settings.test.js index 2b3ccf442b..22a7799108 100644 --- a/backend/__tests__/unit/routes/registry.patch-shared-settings.test.js +++ b/backend/__tests__/unit/routes/registry.patch-shared-settings.test.js @@ -180,44 +180,4 @@ describe('registry shared agent settings', () => { }), ); }); - - it('updates each daemon environment model without replacing its declarations', async () => { - const primaryInstall = { - agentName: 'claude-code', podId: 'pod-1', instanceId: 'default', - status: 'active', installedBy: 'user-1', - config: new Map(Object.entries({ - runtime: { runtimeType: 'claude-code', host: 'byo', model: 'old' }, - environment: { version: 1, model: 'old', workspace: { path: '/one' } }, - })), - save: jest.fn().mockResolvedValue(true), - }; - const secondaryInstall = { - ...primaryInstall, podId: 'pod-2', - config: new Map(Object.entries({ - runtime: { runtimeType: 'claude-code', host: 'byo', model: 'old' }, - environment: { version: 1, model: 'old', mcp: [{ name: 'commonly', transport: 'stdio', command: ['npx', '@commonlyai/mcp'] }] }, - })), - save: jest.fn().mockResolvedValue(true), - }; - Pod.findById.mockReturnValue({ lean: async () => ({ - _id: 'pod-1', createdBy: 'user-1', members: ['user-1'], - }) }); - Pod.find.mockReturnValue({ select: () => ({ lean: async () => [ - { _id: 'pod-1', createdBy: 'user-1', members: ['user-1'] }, - { _id: 'pod-2', createdBy: 'user-1', members: ['user-1'] }, - ] }) }); - AgentInstallation.findOne.mockResolvedValue(primaryInstall); - AgentInstallation.find.mockResolvedValue([primaryInstall, secondaryInstall]); - - const response = await request(app).patch('/api/registry/pods/pod-1/agents/claude-code') - .send({ instanceId: 'default', config: { runtime: { model: 'new', effort: 'high' } } }); - - expect(response.status).toBe(200); - expect(primaryInstall.config.get('runtime')).toMatchObject({ runtimeType: 'claude-code', model: 'new', effort: 'high' }); - expect(primaryInstall.config.get('environment')).toEqual({ version: 1, model: 'new', effort: 'high', workspace: { path: '/one' } }); - expect(secondaryInstall.config.get('environment')).toEqual({ - version: 1, model: 'new', effort: 'high', - mcp: [{ name: 'commonly', transport: 'stdio', command: ['npx', '@commonlyai/mcp'] }], - }); - }); }); diff --git a/backend/routes/registry/agent-config.ts b/backend/routes/registry/agent-config.ts index a13ccefdec..91027cfcbe 100644 --- a/backend/routes/registry/agent-config.ts +++ b/backend/routes/registry/agent-config.ts @@ -180,22 +180,6 @@ agentConfigRouter.patch('/pods/:podId/agents/:name', auth, async (req: any, res: } if (config.runtime && typeof config.runtime === 'object') { nextConfig.runtime = runtimeKeepingType(config.runtime, existingConfig, { mayChangeType: isInstanceAdmin }); - // The daemon prefers an ADR-008 environment's model and effort over - // the legacy runtime overlay. A settings edit must update both - // controls on each row without replacing that row's workspace, - // sandbox, skills or MCP declarations. - if (config.environment === undefined && existingConfig.environment - && typeof existingConfig.environment === 'object' - && !Array.isArray(existingConfig.environment)) { - const preferences = Object.fromEntries( - ['model', 'effort'] - .filter((key) => Object.prototype.hasOwnProperty.call(config.runtime, key)) - .map((key) => [key, config.runtime[key]]), - ); - if (Object.keys(preferences).length) { - nextConfig.environment = { ...existingConfig.environment, ...preferences }; - } - } } if (nextConfig.runtime && typeof nextConfig.runtime === 'object') { const runtimeConfig = { ...nextConfig.runtime }; From 1c9abb6892547c97e2dd750b29d3db22d5e101b5 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:39:32 -0700 Subject: [PATCH 4/7] feat: mount agent runtime and grants in settings Co-authored-by: nib Agent-Model: codex/gpt-6-sol/medium --- .../routes/registry.manage-grants.test.js | 9 ++++ backend/routes/registry/manage-grants.ts | 8 +-- frontend/src/i18n/locales/en.json | 35 +++++++++++++ frontend/src/i18n/locales/zh-CN.json | 35 +++++++++++++ .../v2/__tests__/V2AgentSettingsPage.test.tsx | 50 ++++++++++++++++--- .../src/v2/agents/V2AgentSettingsPage.tsx | 22 +++++++- .../v2/components/V2AgentRuntimeGrants.tsx | 6 +-- 7 files changed, 149 insertions(+), 16 deletions(-) diff --git a/backend/__tests__/unit/routes/registry.manage-grants.test.js b/backend/__tests__/unit/routes/registry.manage-grants.test.js index 848b2245f7..bd63bc906e 100644 --- a/backend/__tests__/unit/routes/registry.manage-grants.test.js +++ b/backend/__tests__/unit/routes/registry.manage-grants.test.js @@ -87,3 +87,12 @@ test('a grant for a pod the agent left does not appear as current access', async 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/registry/manage-grants.ts b/backend/routes/registry/manage-grants.ts index f9c2cca85c..9984bc9e3b 100644 --- a/backend/routes/registry/manage-grants.ts +++ b/backend/routes/registry/manage-grants.ts @@ -54,15 +54,15 @@ router.get('/agents/:name/instances/:instanceId/grants', auth, async (req: any, 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').lean() + ? 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').lean(); + .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 || '')) + .map(([, connection]) => String(connection?.createdBy || connection?.config?.linkedUserId || '')) .filter(Boolean))); const granters = granterIds.length ? await User.find({ _id: { $in: granterIds } }).select('username displayName').lean() @@ -72,7 +72,7 @@ router.get('/agents/:name/instances/:instanceId/grants', auth, async (req: any, return res.json({ grants: visibleGrants.map((grant) => { const connection = connectionById.get(grant.connectionId); - const granterId = String(connection?.createdBy || ''); + const granterId = String(connection?.createdBy || connection?.config?.linkedUserId || ''); const granter: any = granterById.get(granterId); return { grantId: grant.grantId, diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 1cf9c8351f..42d80f041d 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 my computer via the daemon", + "cli": "Single CLI", + "unknown": "Runtime details unavailable", + "adapter": "Adapter", + "model": "Model", + "modelUnknown": "Set by the runtime", + "effort": "Effort", + "changeModel": "Change model on this computer", + "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..0ffa1fe5fb 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__/V2AgentSettingsPage.test.tsx b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx index 0c4a774001..1946cf2209 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,27 @@ 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('Change model on this computer'); + 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/agents/V2AgentSettingsPage.tsx b/frontend/src/v2/agents/V2AgentSettingsPage.tsx index b0d6c50224..2c4290f41d 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')}} /> @@ -145,6 +156,15 @@ const V2AgentSettingsPage: React.FC = () => { {t('agentSettings.profile')} {t('agentSettings.settings')} + { + await update({ config: { environment: changes } }); + }} + />

{t('agentSettings.key')}

diff --git a/frontend/src/v2/components/V2AgentRuntimeGrants.tsx b/frontend/src/v2/components/V2AgentRuntimeGrants.tsx index a0871511f6..9e20026c46 100644 --- a/frontend/src/v2/components/V2AgentRuntimeGrants.tsx +++ b/frontend/src/v2/components/V2AgentRuntimeGrants.tsx @@ -99,8 +99,8 @@ const V2AgentRuntimeGrants: React.FC = ({ unknown: t('agentManage.runtime.unknown', { defaultValue: 'Runtime details unavailable' }), }[details.mode]; const model = runtime.model || preferredModel || null; - // The daemon reads runtime.model on its next assignment and restarts the - // child when it changes. A single CLI chooses its own model locally. + // 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 = Boolean(effortDraft.trim() && effortDraft.trim() !== (runtime.effort || '')); @@ -163,7 +163,7 @@ const V2AgentRuntimeGrants: React.FC = ({

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

{loading &&

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

} - {!loading && grants.length === 0 &&

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

} + {!loading && !error && 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(); From 5c331b7f2e7d56066e4c9ead21ea83e0fc2adaa6 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:42:17 -0700 Subject: [PATCH 5/7] fix: bound grants reads and normalize instance ids linearly Co-authored-by: nib Agent-Model: codex/gpt-6-sol/medium --- backend/routes/registry/manage-grants.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/backend/routes/registry/manage-grants.ts b/backend/routes/registry/manage-grants.ts index 9984bc9e3b..553e3241f0 100644 --- a/backend/routes/registry/manage-grants.ts +++ b/backend/routes/registry/manage-grants.ts @@ -1,4 +1,7 @@ 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'; @@ -14,9 +17,21 @@ const { } = 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', auth, async (req: any, res: any) => { +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' }); From de3dfa0de97fdd183e9adf453fab0bcf8e50c12e Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:46:33 -0700 Subject: [PATCH 6/7] fix: deny revoke before admin lookup when connection is gone Co-authored-by: nib Agent-Model: codex/gpt-6-sol/medium --- backend/routes/grants.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/backend/routes/grants.ts b/backend/routes/grants.ts index 7958dd1c93..cb06f4a97a 100644 --- a/backend/routes/grants.ts +++ b/backend/routes/grants.ts @@ -466,10 +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); - const isOwner = Boolean(connection) && connectionOwnerId(connection) === userId; - const isAdmin = req.user?.role === 'admin' - || (Boolean(userId) && (await User.findById(userId).select('role').lean())?.role === 'admin'); - if (!connection || (!isOwner && !isAdmin)) { + 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); From 324d35df75d422e3892ad84d51edf17963e4e6b2 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:58:20 -0700 Subject: [PATCH 7/7] fix: align agent runtime grants with settings gates Co-authored-by: nib Agent-Model: codex/gpt-6-sol/medium --- .../routes/registry.manage-grants.test.js | 15 +++++++- backend/routes/registry/manage-grants.ts | 2 +- frontend/src/i18n/locales/en.json | 4 +- frontend/src/i18n/locales/zh-CN.json | 4 +- .../__tests__/V2AgentRuntimeGrants.test.tsx | 37 ++++++++++++++++--- .../v2/__tests__/V2AgentSettingsPage.test.tsx | 6 ++- .../src/v2/agents/V2AgentSettingsPage.tsx | 18 ++++----- .../v2/components/V2AgentRuntimeGrants.tsx | 29 +++++++++------ 8 files changed, 82 insertions(+), 33 deletions(-) diff --git a/backend/__tests__/unit/routes/registry.manage-grants.test.js b/backend/__tests__/unit/routes/registry.manage-grants.test.js index bd63bc906e..ad88b99694 100644 --- a/backend/__tests__/unit/routes/registry.manage-grants.test.js +++ b/backend/__tests__/unit/routes/registry.manage-grants.test.js @@ -72,11 +72,24 @@ test('only the installer or an instance admin can read an agent grants', async ( test('a second installer cannot inspect a shared identity through their row', async () => { AgentInstallation.find.mockReturnValue({ lean: async () => [ - { installedBy: 'owner', config: {} }, { installedBy: 'someone-else', config: {} }, + { 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 () => { diff --git a/backend/routes/registry/manage-grants.ts b/backend/routes/registry/manage-grants.ts index 553e3241f0..23bea9fd11 100644 --- a/backend/routes/registry/manage-grants.ts +++ b/backend/routes/registry/manage-grants.ts @@ -38,7 +38,7 @@ router.get('/agents/:name/instances/:instanceId/grants', grantManageLimit, auth, const agentName = String(req.params.name || '').toLowerCase(); const instanceId = normalizeInstanceId(req.params.instanceId); const installations = await AgentInstallation.find({ - agentName, instanceId, status: 'active', + agentName, instanceId, status: { $ne: 'uninstalled' }, }).lean(); if (!installations.length) return res.status(404).json({ error: 'Agent not installed' }); diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 42d80f041d..d9be75cbae 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2259,14 +2259,14 @@ "title": "Runtime", "how": "How it runs", "hosted": "Hosted", - "daemon": "On my computer via the daemon", + "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": "Change model on this computer", + "changeModel": "Model", "changeEffort": "Effort", "effortUnset": "Choose effort", "save": "Save runtime", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 0ffa1fe5fb..5435a68dd4 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2250,14 +2250,14 @@ "title": "运行环境", "how": "运行方式", "hosted": "托管运行", - "daemon": "通过守护进程在我的电脑上运行", + "daemon": "通过守护进程在电脑上运行", "cli": "单个 CLI", "unknown": "暂无运行环境详情", "adapter": "适配器", "model": "模型", "modelUnknown": "由运行环境决定", "effort": "推理强度", - "changeModel": "更改这台电脑上的模型", + "changeModel": "模型", "changeEffort": "推理强度", "effortUnset": "选择推理强度", "save": "保存运行设置", diff --git a/frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx b/frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx index e875c24833..b6e44cf7a9 100644 --- a/frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx @@ -1,6 +1,6 @@ // @ts-nocheck import React from 'react'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import V2AgentRuntimeGrants, { describeRuntime } from '../components/V2AgentRuntimeGrants'; import { AuthContext } from '../../context/AuthContext'; @@ -50,7 +50,7 @@ test('runtime labels only recognize walked adapters and distinguish daemon from test('shows this agent grant and requires a second click to revoke through the existing route', async () => { renderSection(); - expect(await screen.findByText('On my computer via the daemon')).toBeInTheDocument(); + 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' })); @@ -61,7 +61,7 @@ test('shows this agent grant and requires a second click to revoke through the e 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('Change model on this computer'); + 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' })); @@ -69,7 +69,7 @@ test('model changes delegate to the shared settings hook; a single CLI has no ed rerender(); expect(await screen.findByText('Single CLI')).toBeInTheDocument(); - expect(screen.queryByLabelText('Change model on this computer')).toBeNull(); + expect(screen.queryByLabelText('Model')).toBeNull(); }); test('daemon effort changes are passed to the shared settings callback', async () => { @@ -80,9 +80,36 @@ test('daemon effort changes are passed to the shared settings callback', async ( 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('Connector grants')).toBeInTheDocument(); + 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 1946cf2209..171d78d9b3 100644 --- a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx @@ -204,7 +204,11 @@ test('runtime editor writes environment controls through the shared settings hoo return Promise.resolve({ data: [] }); }); renderPage(); - const model = await screen.findByLabelText('Change model on this computer'); + 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( diff --git a/frontend/src/v2/agents/V2AgentSettingsPage.tsx b/frontend/src/v2/agents/V2AgentSettingsPage.tsx index 2c4290f41d..cbaa6feed0 100644 --- a/frontend/src/v2/agents/V2AgentSettingsPage.tsx +++ b/frontend/src/v2/agents/V2AgentSettingsPage.tsx @@ -156,15 +156,6 @@ const V2AgentSettingsPage: React.FC = () => { {t('agentSettings.profile')} {t('agentSettings.settings')} - { - await update({ config: { environment: changes } }); - }} - />

    {t('agentSettings.key')}

    @@ -226,6 +217,15 @@ const V2AgentSettingsPage: React.FC = () => { )}
    + { + await update({ config: { environment: changes } }); + }} + />
); }; diff --git a/frontend/src/v2/components/V2AgentRuntimeGrants.tsx b/frontend/src/v2/components/V2AgentRuntimeGrants.tsx index 9e20026c46..4bf289789c 100644 --- a/frontend/src/v2/components/V2AgentRuntimeGrants.tsx +++ b/frontend/src/v2/components/V2AgentRuntimeGrants.tsx @@ -66,7 +66,8 @@ const V2AgentRuntimeGrants: React.FC = ({ const api = useV2Api(); const [grants, setGrants] = useState([]); const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); + 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 || ''); @@ -74,14 +75,14 @@ const V2AgentRuntimeGrants: React.FC = ({ const load = useCallback(async () => { setLoading(true); - setError(''); + setGrantsError(''); try { const next = await api.get( `/api/registry/agents/${encodeURIComponent(name)}/instances/${encodeURIComponent(instanceId)}/grants`, ); setGrants(next.grants || []); } catch { - setError(t('agentManage.grants.loadError', { defaultValue: 'Could not load connector grants.' })); + setGrantsError(t('agentManage.grants.loadError', { defaultValue: 'Could not load connector grants.' })); } finally { setLoading(false); } @@ -94,7 +95,7 @@ const V2AgentRuntimeGrants: React.FC = ({ const details = useMemo(() => describeRuntime(runtime), [runtime]); const modeLabel = { hosted: t('agentManage.runtime.hosted', { defaultValue: 'Hosted' }), - daemon: t('agentManage.runtime.daemon', { defaultValue: 'On my computer via the daemon' }), + 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]; @@ -103,19 +104,19 @@ const V2AgentRuntimeGrants: React.FC = ({ // 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 = Boolean(effortDraft.trim() && effortDraft.trim() !== (runtime.effort || '')); + const effortChanged = effortDraft.trim() !== (runtime.effort || ''); const saveRuntime = async () => { if (!onSaveRuntime || !mayEditRuntime) return; setBusy(true); - setError(''); + setRuntimeError(''); try { await onSaveRuntime({ ...(modelChanged ? { model: modelDraft.trim() } : {}), ...(effortChanged ? { effort: effortDraft.trim() } : {}), }); } catch { - setError(t('agentManage.runtime.saveError', { defaultValue: 'Could not save the model.' })); + setRuntimeError(t('agentManage.runtime.saveError', { defaultValue: 'Could not save the model.' })); } finally { setBusy(false); } @@ -123,13 +124,16 @@ const V2AgentRuntimeGrants: React.FC = ({ const revoke = async (grantId: string) => { setBusy(true); - setError(''); + 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 { - setError(t('agentManage.grants.revokeError', { defaultValue: 'Could not revoke the grant.' })); + setGrantsError(t('agentManage.grants.revokeError', { defaultValue: 'Could not revoke the grant.' })); } finally { setBusy(false); } @@ -147,7 +151,7 @@ const V2AgentRuntimeGrants: React.FC = ({ {mayEditRuntime && (
- + setModelDraft(event.target.value)} />