From 34b09159859486ac4e03f51bb91ccf12faa87876 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:35:19 -0700 Subject: [PATCH 1/9] Add per-agent Settings key management Co-authored-by: forge Agent-Model: codex/gpt-6-sol/medium --- .../service/runtimeTokenRevoke.test.js | 30 ++- .../registry.runtimeTokenOwnership.test.js | 3 +- backend/routes/registry/agent-tokens.ts | 50 ++++- frontend/src/i18n/locales/en.json | 28 +++ frontend/src/i18n/locales/zh-CN.json | 28 +++ frontend/src/v2/V2App.tsx | 5 + .../v2/__tests__/V2AgentSettingsPage.test.tsx | 59 ++++++ .../v2/__tests__/v2-layout-invariants.test.ts | 7 + frontend/src/v2/agents/V2AgentProfile.tsx | 17 +- .../src/v2/agents/V2AgentSettingsPage.tsx | 195 ++++++++++++++++++ frontend/src/v2/agents/v2-agent-settings.css | 69 +++++++ 11 files changed, 482 insertions(+), 9 deletions(-) create mode 100644 frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx create mode 100644 frontend/src/v2/agents/V2AgentSettingsPage.tsx create mode 100644 frontend/src/v2/agents/v2-agent-settings.css diff --git a/backend/__tests__/service/runtimeTokenRevoke.test.js b/backend/__tests__/service/runtimeTokenRevoke.test.js index 09b68edadc..4a26ff9c46 100644 --- a/backend/__tests__/service/runtimeTokenRevoke.test.js +++ b/backend/__tests__/service/runtimeTokenRevoke.test.js @@ -21,7 +21,8 @@ process.env.NODE_ENV = 'test'; // The signed-in human. A jest.mock factory may only reach `mock`-prefixed // bindings, read at call time. -const mockOwner = { id: new mongoose.Types.ObjectId().toString() }; +const primaryOwnerId = new mongoose.Types.ObjectId().toString(); +const mockOwner = { id: primaryOwnerId }; jest.mock('../../middleware/auth', () => { const auth = (req, _res, next) => { @@ -85,6 +86,7 @@ describe('revoking a runtime token kills it in every store', () => { beforeAll(async () => { await setupMongoDb(); }); afterAll(async () => { await closeMongoDb(); }); beforeEach(async () => { + mockOwner.id = primaryOwnerId; await clearMongoDb(); jest.spyOn(console, 'warn').mockImplementation(() => {}); }); @@ -111,6 +113,26 @@ describe('revoking a runtime token kills it in every store', () => { expect((await probe(token)).status).toBe(401); expect(await storesHolding(tokenHash)).toEqual({ users: 0, installations: 0 }); + const history = await request(app).get(`/api/registry/pods/${workspace._id}/agents/${AGENT}/runtime-tokens`); + expect(history.status).toBe(200); + expect(history.body.tokens).toEqual([expect.objectContaining({ + label: 'laptop', revokedAt: expect.any(String), + })]); + expect(JSON.stringify(history.body)).not.toContain(token); + }); + + test('a pod member who did not install the identity cannot list key metadata', async () => { + const { workspace } = await seed(); + const issued = await request(app) + .post(`/api/registry/pods/${workspace._id}/agents/${AGENT}/runtime-tokens`) + .send({ label: 'private laptop' }); + expect(issued.status).toBe(200); + const otherId = new mongoose.Types.ObjectId().toString(); + await Pod.updateOne({ _id: workspace._id }, { $push: { members: otherId } }); + mockOwner.id = otherId; + const listed = await request(app).get(`/api/registry/pods/${workspace._id}/agents/${AGENT}/runtime-tokens`); + expect(listed.status).toBe(403); + expect(JSON.stringify(listed.body)).not.toContain('private laptop'); }); test('a copy on a sibling installation dies too, and so does a ledger row', async () => { @@ -179,6 +201,12 @@ describe('revoking a runtime token kills it in every store', () => { expect((await probe(first.token)).status).toBe(401); expect((await probe(second)).status).toBe(200); + const history = await request(app).get(`/api/registry/pods/${workspace._id}/agents/${AGENT}/runtime-tokens`); + expect(history.status).toBe(200); + expect(history.body.tokens).toEqual(expect.arrayContaining([ + expect.objectContaining({ label: 'laptop', revokedAt: expect.any(String) }), + expect.objectContaining({ label: 'desktop', revokedAt: null }), + ])); }); test('pod uninstall keeps a shared token until the last installation, then revokes it and preserves identity', async () => { diff --git a/backend/__tests__/unit/routes/registry.runtimeTokenOwnership.test.js b/backend/__tests__/unit/routes/registry.runtimeTokenOwnership.test.js index 561145879f..d4a48edccb 100644 --- a/backend/__tests__/unit/routes/registry.runtimeTokenOwnership.test.js +++ b/backend/__tests__/unit/routes/registry.runtimeTokenOwnership.test.js @@ -41,6 +41,7 @@ jest.mock('../../../models/AgentRegistry', () => ({ jest.mock('../../../models/Pod', () => ({ findById: jest.fn() })); jest.mock('../../../models/User', () => ({ findById: jest.fn(), findOne: jest.fn() })); +jest.mock('../../../models/AgentCredential', () => ({ updateOne: jest.fn().mockResolvedValue({}) })); jest.mock('../../../services/agentIdentityService', () => ({ resolveAgentType: jest.fn((name) => String(name).toLowerCase()), @@ -101,7 +102,7 @@ beforeEach(() => { mockIsAdmin.mockResolvedValue(false); // The caller is a member of the pod in every case: membership is not the question. Pod.findById.mockReturnValue({ lean: () => Promise.resolve({ _id: 'pod-a', createdBy: 'user-a', members: ['user-a'] }) }); - User.findOne.mockResolvedValue({ agentRuntimeTokens: [{ _id: 'tok-1', tokenHash: 'hash-1' }], save: jest.fn() }); + User.findOne.mockResolvedValue({ _id: 'bot-1', agentRuntimeTokens: [{ _id: 'tok-1', tokenHash: 'hash-1' }], save: jest.fn() }); }); const mint = (body = {}) => request(app).post('/api/registry/pods/pod-a/agents/quire/runtime-tokens').send(body); diff --git a/backend/routes/registry/agent-tokens.ts b/backend/routes/registry/agent-tokens.ts index 9f97d59c25..e67f30d2a6 100644 --- a/backend/routes/registry/agent-tokens.ts +++ b/backend/routes/registry/agent-tokens.ts @@ -34,6 +34,7 @@ const express = require('express'); const auth = require('../../middleware/auth'); const Pod = require('../../models/Pod'); const User = require('../../models/User'); +const AgentCredential = require('../../models/AgentCredential'); const AgentIdentityService = require('../../services/agentIdentityService'); const { getUserId, @@ -67,6 +68,21 @@ const credentialRefusal = async (userId: unknown, agentName: string, instanceId: return { code: check.failure, error: runtimeCredentialRefusal[check.failure] }; }; +// Legacy embedded keys predate the credential ledger. Record their metadata +// before a replacement or revoke removes the only embedded copy, so Settings +// can still show the revoked state after the authenticating copies are pulled. +const recordLegacyKeys = async (agentUser: any, ownerUserId: unknown, records: any[]) => { + await Promise.all(records.filter((record) => record?.tokenHash).map((record) => AgentCredential.updateOne( + { tokenHash: record.tokenHash }, + { $setOnInsert: { + kind: 'runtime', ownerUserId, agentUserId: agentUser._id, + label: record.label || 'Runtime token', status: 'active', + createdAt: record.createdAt || new Date(), lastUsedAt: record.lastUsedAt || null, + } }, + { upsert: true }, + ))); +}; + /** * GET /api/registry/pods/:podId/agents/:name/runtime-tokens * List runtime tokens for an installed agent @@ -96,7 +112,7 @@ agentTokensRouter.get('/pods/:podId/agents/:name/runtime-tokens', tokenRouteLimi return memberId && memberId === userId.toString(); }); - if (!membership && !isCreator) { + if (!membership && !isCreator && !(await isGlobalAdminUser(userId))) { return res.status(403).json({ error: 'Access denied' }); } @@ -104,12 +120,35 @@ agentTokensRouter.get('/pods/:podId/agents/:name/runtime-tokens', tokenRouteLimi return res.status(404).json({ error: 'Agent not installed in this pod' }); } + const refusal = await credentialRefusal(userId, installation.agentName || name, instanceId); + if (refusal) return res.status(403).json(refusal); + const resolvedType = AgentIdentityService.resolveAgentType(name); const agentUsername = AgentIdentityService.buildAgentUsername(resolvedType, instanceId); const agentUser = await User.findOne({ username: agentUsername, isBot: true }) .select('agentRuntimeTokens') .lean(); - const tokens = serializeRuntimeTokens(agentUser?.agentRuntimeTokens || []); + const embedded = agentUser?.agentRuntimeTokens || []; + const credentials = agentUser?._id + ? await AgentCredential.find({ agentUserId: agentUser._id, kind: 'runtime' }) + .select('tokenHash label status createdAt lastUsedAt revokedAt').lean() + : []; + const credentialByHash = new Map(credentials.map((row: any) => [row.tokenHash, row])); + const tokens = serializeRuntimeTokens(embedded).map((token: any, index: number) => { + const credential: any = credentialByHash.get(embedded[index]?.tokenHash); + return { ...token, lastUsedAt: credential?.lastUsedAt || token.lastUsedAt || null, revokedAt: null }; + }); + const activeHashes = new Set(embedded.map((row: any) => row.tokenHash)); + for (const credential of credentials) { + if (credential.status !== 'revoked' || activeHashes.has(credential.tokenHash)) continue; + tokens.push({ + id: String(credential._id), + label: credential.label || 'Runtime token', + createdAt: credential.createdAt, + lastUsedAt: credential.lastUsedAt || null, + revokedAt: credential.revokedAt || null, + }); + } return res.json({ tokens }); } catch (error) { @@ -143,7 +182,7 @@ agentTokensRouter.post('/pods/:podId/agents/:name/runtime-tokens', tokenRouteLim return memberId && memberId === userId.toString(); }); - if (!membership && !isCreator) { + if (!membership && !isCreator && !(await isGlobalAdminUser(userId))) { return res.status(403).json({ error: 'Access denied' }); } @@ -178,6 +217,7 @@ agentTokensRouter.post('/pods/:podId/agents/:name/runtime-tokens', tokenRouteLim // User token and legacy installation copies, so revoke every hash before // issuing the replacement. if (force) { + await recordLegacyKeys(agentUser, installation.installedBy || userId, agentUser.agentRuntimeTokens || []); await revokeRuntimeTokensForAgent({ agentUser, agentName: installation.agentName || name, @@ -189,6 +229,7 @@ agentTokensRouter.post('/pods/:podId/agents/:name/runtime-tokens', tokenRouteLim agentUser, label || `Provisioned ${normalizedInstanceId}`, installation, + { ownerUserId: userId }, ); return res.json(issued); } catch (error) { @@ -229,7 +270,7 @@ agentTokensRouter.delete('/pods/:podId/agents/:name/runtime-tokens/:tokenId', to return memberId && memberId === userId.toString(); }); - if (!membership && !isCreator) { + if (!membership && !isCreator && !(await isGlobalAdminUser(userId))) { return res.status(403).json({ error: 'Access denied' }); } @@ -257,6 +298,7 @@ agentTokensRouter.delete('/pods/:podId/agents/:name/runtime-tokens/:tokenId', to return res.status(404).json({ error: 'Runtime token not found' }); } + await recordLegacyKeys(agentUser, installation.installedBy || userId, [record]); await revokeRuntimeTokenEverywhere(record.tokenHash); return res.json({ success: true }); } catch (error) { diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 55d6fe9070..a7ac172192 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2254,6 +2254,34 @@ "listSeparator": ", ", "sentenceSeparator": " " }, + "agentSettings": { + "loading": "Loading agent settings…", + "unavailable": "Agent settings unavailable.", + "yourTeam": "Your team", + "tabs": "Agent tabs", + "profile": "Profile", + "settings": "Settings", + "key": "Key", + "regenerate": "Regenerate key", + "copyNow": "Copy this key now. It will not be shown again.", + "done": "Done", + "empty": "No runtime keys yet.", + "runtimeKey": "Runtime key", + "active": "Active", + "revoked": "Revoked", + "created": "Created {{date}}", + "lastUsed": "Last used {{date}}", + "revokedAt": "Revoked {{date}}", + "never": "Never", + "unknown": "Unknown", + "revokeWarning": "Revoking this key disconnects the running agent.", + "revokeKey": "Revoke key", + "keepKey": "Keep key", + "revoke": "Revoke", + "updateError": "Could not update the key. Try again.", + "noPod": "This agent has no active pod.", + "missingReplacement": "The replacement key was not returned." + }, "time": { "age": { "justNow": "just now", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 61066a8d16..146815ec7a 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2245,6 +2245,34 @@ "listSeparator": "、", "sentenceSeparator": "" }, + "agentSettings": { + "loading": "正在加载智能体设置…", + "unavailable": "无法打开智能体设置。", + "yourTeam": "你的团队", + "tabs": "智能体标签页", + "profile": "资料", + "settings": "设置", + "key": "密钥", + "regenerate": "重新生成密钥", + "copyNow": "请立即复制此密钥。之后不会再次显示。", + "done": "完成", + "empty": "暂无运行时密钥。", + "runtimeKey": "运行时密钥", + "active": "有效", + "revoked": "已撤销", + "created": "创建于 {{date}}", + "lastUsed": "上次使用 {{date}}", + "revokedAt": "撤销于 {{date}}", + "never": "从未", + "unknown": "未知", + "revokeWarning": "撤销此密钥会断开正在运行的智能体。", + "revokeKey": "撤销密钥", + "keepKey": "保留密钥", + "revoke": "撤销", + "updateError": "无法更新密钥,请重试。", + "noPod": "此智能体没有活跃的 Pod。", + "missingReplacement": "未返回新密钥。" + }, "time": { "age": { "justNow": "刚刚", diff --git a/frontend/src/v2/V2App.tsx b/frontend/src/v2/V2App.tsx index 4e6b8090ad..e924f2b19a 100644 --- a/frontend/src/v2/V2App.tsx +++ b/frontend/src/v2/V2App.tsx @@ -33,6 +33,7 @@ const V2InviteRedeem = React.lazy(() => import('./components/V2InviteRedeem')); const V2CommunityRedirect = React.lazy(() => import('./components/V2CommunityRedirect')); const V2SettingsPage = React.lazy(() => import('./components/V2SettingsPage')); const V2AgentProfile = React.lazy(() => import('./agents/V2AgentProfile')); +const V2AgentSettingsPage = React.lazy(() => import('./agents/V2AgentSettingsPage')); const UserProfile = React.lazy(() => import('../components/UserProfile')); const AgentsHub = React.lazy(() => import('../components/agents/AgentsHub')); const V2AgentBYO = React.lazy(() => import('./components/V2AgentBYO')); @@ -282,6 +283,10 @@ const V2App: React.FC = () => { path="agents/byo" element={} /> + , false, false)} + /> } diff --git a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx new file mode 100644 index 0000000000..e5ef4f030d --- /dev/null +++ b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx @@ -0,0 +1,59 @@ +// @ts-nocheck +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import V2AgentSettingsPage from '../agents/V2AgentSettingsPage'; + +jest.mock('axios', () => { + const mock = { + get: jest.fn(), post: 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 renderPage = () => render( + + } /> + , +); + +beforeEach(() => { + jest.clearAllMocks(); + axios.get.mockImplementation((url) => { + if (url.startsWith('/api/agent-memory/')) return Promise.resolve({ data: { + displayName: 'Maya', pods: [{ id: 'pod-1', name: 'Workspace' }], + } }); + return Promise.resolve({ data: { tokens: [{ + id: 'tok-1', label: 'Laptop', createdAt: '2026-10-01T00:00:00Z', lastUsedAt: null, revokedAt: null, + }] } }); + }); +}); + +test('regeneration waits for the explicit running-key replacement confirmation', async () => { + axios.post.mockResolvedValue({ data: { token: 'cm_agent_once' } }); + renderPage(); + await screen.findByText('Laptop'); + fireEvent.click(screen.getByRole('button', { name: 'Regenerate key' })); + expect(axios.post).not.toHaveBeenCalled(); + expect(screen.getByTestId('agent-key-replace-confirmation')).toHaveTextContent('Replacing it will disconnect'); + fireEvent.click(screen.getByRole('button', { name: /keep the current key/i })); + expect(axios.post).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: 'Regenerate key' })); + fireEvent.click(screen.getByRole('button', { name: /replace the running agent/i })); + await waitFor(() => expect(axios.post).toHaveBeenCalledWith( + '/api/registry/pods/pod-1/agents/maya-claude/runtime-tokens', + { instanceId: 'default', label: 'Agent Settings replacement', force: true }, + )); + expect(await screen.findByTestId('agent-one-time-key')).toHaveTextContent('cm_agent_once'); +}); + +test('a denied owner probe renders no key controls or metadata', async () => { + axios.get.mockRejectedValue({ response: { status: 403, data: { error: 'Forbidden' } } }); + renderPage(); + expect(await screen.findByRole('alert')).toHaveTextContent('Forbidden'); + expect(screen.queryByRole('button', { name: 'Regenerate key' })).not.toBeInTheDocument(); + expect(screen.queryByText('Laptop')).not.toBeInTheDocument(); +}); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 29f6fb34a3..5c3f259423 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -150,6 +150,7 @@ describe('v2 layout invariants (CSS rule presence)', () => { const v2 = read('../v2.css'); const tokens = read('../../../design-system/tokens.css'); const aprofile = read('../agents/v2-agent-profile.css'); + const agentSettings = read('../agents/v2-agent-settings.css'); const landing = read('../landing/v2-landing.css'); const podChat = read('../components/V2Thread.tsx'); const podsSidebar = read('../components/V2PodsSidebar.tsx'); @@ -178,6 +179,12 @@ describe('v2 layout invariants (CSS rule presence)', () => { const appsManagement = read('../../components/AppsManagement.tsx'); const podModel = read('../../../../backend/models/Pod.ts'); + test('agent Settings key metadata wraps within the page at phone width', () => { + expect(ruleBody(agentSettings, '.v2-agent-settings')).toContain('width: min(100%, 960px)'); + expect(ruleBody(agentSettings, '.v2-agent-settings__key-meta')).toContain('flex-wrap: wrap'); + expect(mediaBlockContaining(agentSettings, '.v2-agent-settings__key-meta')).toContain('display: grid'); + }); + test('BYO hints do not add paragraph margins to flex gaps', () => { expect(ruleBody(v2, '.v2-byo__hint')).toContain('margin: 0'); }); diff --git a/frontend/src/v2/agents/V2AgentProfile.tsx b/frontend/src/v2/agents/V2AgentProfile.tsx index ad8f003a34..47bb79126c 100644 --- a/frontend/src/v2/agents/V2AgentProfile.tsx +++ b/frontend/src/v2/agents/V2AgentProfile.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Link, useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import axios from 'axios'; @@ -7,6 +7,7 @@ import V2Avatar from '../components/V2Avatar'; import { presetCharacterOptions } from '../../utils/avatarUtils'; import '../v2.css'; import './v2-agent-profile.css'; +import './v2-agent-settings.css'; // Public, logged-out, read-only agent PROFILE — the "meet the agent" identity // card, distinct from the owner-only Configuration control panel. Sits OUTSIDE @@ -117,6 +118,7 @@ const V2AgentProfile: React.FC = () => { const [data, setData] = useState(null); const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading'); const [memIndex, setMemIndex] = useState(null); + const memoryRequestId = useRef(0); const [openingRoom, setOpeningRoom] = useState(false); const [roomError, setRoomError] = useState(null); @@ -138,6 +140,8 @@ const V2AgentProfile: React.FC = () => { // is an admin), pull the private memory index. Public visitors 401/403 here and // just see the count. Token passed explicitly since getClient() is token-less. const fetchMemoryIndex = useCallback(async () => { + const requestId = ++memoryRequestId.current; + setMemIndex(null); const token = typeof window !== 'undefined' ? window.localStorage.getItem('token') : null; if (!token || !agentName) { setMemIndex(null); return; } try { @@ -146,9 +150,9 @@ const V2AgentProfile: React.FC = () => { `/api/agent-memory/${encodeURIComponent(agentName)}/${encodeURIComponent(id)}`, { headers: { Authorization: `Bearer ${token}` } }, ); - setMemIndex(res.data); + if (requestId === memoryRequestId.current) setMemIndex(res.data); } catch { - setMemIndex(null); + if (requestId === memoryRequestId.current) setMemIndex(null); } }, [agentName, instanceId]); @@ -377,6 +381,13 @@ const V2AgentProfile: React.FC = () => { {roomError &&
{roomError}
} + {memIndex?.viewerRole && ( + + )} +
{/* Pods — owner/admin see ALL pods + the agent's last message there; diff --git a/frontend/src/v2/agents/V2AgentSettingsPage.tsx b/frontend/src/v2/agents/V2AgentSettingsPage.tsx new file mode 100644 index 0000000000..0f2ef5df54 --- /dev/null +++ b/frontend/src/v2/agents/V2AgentSettingsPage.tsx @@ -0,0 +1,195 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { Link, useParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import axios from '../../utils/axiosConfig'; +import V2PageHead from '../components/V2PageHead'; +import './v2-agent-settings.css'; + +interface AgentAccess { + displayName: string; + pods: Array<{ id: string; name: string }>; +} + +interface RuntimeKey { + id: string; + label: string; + createdAt?: string; + lastUsedAt?: string | null; + revokedAt?: string | null; +} + +const dateLabel = (value: string | null | undefined, t: (key: string) => string): string => { + if (!value) return t('agentSettings.never'); + const date = new Date(value); + return Number.isNaN(date.getTime()) ? t('agentSettings.unknown') : date.toLocaleString(); +}; + +const errorLabel = (error: unknown, fallback: string): string => ( + (error as { response?: { data?: { error?: string } } })?.response?.data?.error + || (error instanceof Error && error.message) + || fallback +); + +const V2AgentSettingsPage: React.FC = () => { + const { t } = useTranslation(); + const { agentName = '', instanceId = '' } = useParams<{ agentName: string; instanceId: string }>(); + const [access, setAccess] = useState(null); + const [keys, setKeys] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [pendingAction, setPendingAction] = useState<'replace' | string | null>(null); + const [busy, setBusy] = useState(false); + const [oneTimeKey, setOneTimeKey] = useState(null); + + const keyUrl = useCallback((podId: string) => ( + `/api/registry/pods/${encodeURIComponent(podId)}/agents/${encodeURIComponent(agentName)}/runtime-tokens` + ), [agentName]); + + const loadKeys = useCallback(async (podId: string) => { + const response = await axios.get<{ tokens: RuntimeKey[] }>(keyUrl(podId), { + params: { instanceId }, + }); + setKeys(response.data.tokens || []); + }, [instanceId, keyUrl]); + + useEffect(() => { + let active = true; + setLoading(true); + setAccess(null); + setKeys([]); + setError(null); + setOneTimeKey(null); + setPendingAction(null); + const load = async () => { + try { + // This owner/admin endpoint gates the page and supplies an installed + // pod for the existing pod-scoped token routes. Public profile data is + // deliberately insufficient to render the Settings tab. + const response = await axios.get( + `/api/agent-memory/${encodeURIComponent(agentName)}/${encodeURIComponent(instanceId)}`, + ); + if (!active) return; + const next = response.data; + if (!next.pods?.length) throw new Error(t('agentSettings.noPod')); + await loadKeys(next.pods[0].id); + if (active) setAccess(next); + } catch (requestError) { + if (active) setError(errorLabel(requestError, t('agentSettings.unavailable'))); + } finally { + if (active) setLoading(false); + } + }; + void load(); + return () => { active = false; }; + }, [agentName, instanceId, loadKeys, t]); + + const replaceKey = async () => { + if (!access || pendingAction !== 'replace' || busy) return; + setBusy(true); + setError(null); + try { + const response = await axios.post<{ token?: string }>(keyUrl(access.pods[0].id), { + instanceId, + label: 'Agent Settings replacement', + force: true, + }); + if (!response.data.token) throw new Error(t('agentSettings.missingReplacement')); + setOneTimeKey(response.data.token); + setPendingAction(null); + await loadKeys(access.pods[0].id); + } catch (requestError) { + setError(errorLabel(requestError, t('agentSettings.updateError'))); + } finally { + setBusy(false); + } + }; + + const revokeKey = async (tokenId: string) => { + if (!access || pendingAction !== tokenId || busy) return; + setBusy(true); + setError(null); + try { + await axios.delete(`${keyUrl(access.pods[0].id)}/${encodeURIComponent(tokenId)}`, { + params: { instanceId }, + }); + setPendingAction(null); + setOneTimeKey(null); + await loadKeys(access.pods[0].id); + } catch (requestError) { + setError(errorLabel(requestError, t('agentSettings.updateError'))); + } finally { + setBusy(false); + } + }; + + if (loading) return
{t('agentSettings.loading')}
; + if (!access) return
{error || t('agentSettings.unavailable')}
; + + return ( +
+ {t('agentSettings.yourTeam')}} /> + +
+
+

{t('agentSettings.key')}

+ +
+ {error &&

{error}

} + {pendingAction === 'replace' && ( +
+

{t('agentByo.rekey.warning', { name: access.displayName || agentName })}

+
+ + +
+
+ )} + {oneTimeKey && ( +
+ {t('agentSettings.copyNow')} + {oneTimeKey} + +
+ )} + {keys.length === 0 ?

{t('agentSettings.empty')}

: ( +
    + {keys.map((key) => ( +
  • +
    + {key.label || t('agentSettings.runtimeKey')} + {key.revokedAt ? t('agentSettings.revoked') : t('agentSettings.active')} +
    +
    + {t('agentSettings.created', { date: dateLabel(key.createdAt, t) })} + {t('agentSettings.lastUsed', { date: dateLabel(key.lastUsedAt, t) })} + {key.revokedAt && {t('agentSettings.revokedAt', { date: dateLabel(key.revokedAt, t) })}} +
    + {!key.revokedAt && pendingAction === key.id && ( +
    +

    {t('agentSettings.revokeWarning')}

    +
    + + +
    +
    + )} + {!key.revokedAt && pendingAction !== key.id && ( + + )} +
  • + ))} +
+ )} +
+
+ ); +}; + +export default V2AgentSettingsPage; diff --git a/frontend/src/v2/agents/v2-agent-settings.css b/frontend/src/v2/agents/v2-agent-settings.css new file mode 100644 index 0000000000..84e88d9497 --- /dev/null +++ b/frontend/src/v2/agents/v2-agent-settings.css @@ -0,0 +1,69 @@ +.v2-agent-settings { + width: min(100%, 960px); + margin: 0 auto; + padding: 24px; + color: var(--v2-text-primary); +} + +.v2-agent-settings__state { padding: 32px; color: var(--v2-text-secondary); } + +.v2-agent-settings__tabs { + display: flex; + gap: 24px; + margin-top: 16px; + margin-bottom: 20px; + border-bottom: 1px solid var(--v2-border-soft); +} +.v2-agent-settings__tabs > * { + padding: 10px 2px; + color: var(--v2-text-secondary); + text-decoration: none; + font-size: 14px; +} +.v2-agent-settings__tabs [aria-current="page"] { + color: var(--v2-text-primary); + border-bottom: 2px solid var(--v2-accent); +} + +.v2-agent-settings__section { padding: 28px 0; } +.v2-agent-settings__section-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; } +.v2-agent-settings__section h2 { margin: 0; font-family: var(--v2-font-display); font-size: 20px; } +.v2-agent-settings button { + min-height: 34px; + padding: 6px 12px; + border: 1px solid var(--v2-border); + border-radius: var(--v2-radius); + background: var(--v2-surface); + color: var(--v2-text-primary); + font: inherit; + font-size: 13px; + font-weight: 600; + cursor: pointer; +} +.v2-agent-settings button:hover { background: var(--v2-surface-hover); } +.v2-agent-settings button:focus-visible { box-shadow: var(--v2-focus-ring); outline: 1px solid var(--v2-accent); } +.v2-agent-settings button:disabled { opacity: .55; cursor: default; } +.v2-agent-settings__section-head button, +.v2-agent-settings__actions button:first-child { background: var(--v2-ink); color: var(--v2-on-ink); border-color: var(--v2-ink); } +.v2-agent-settings__section-head button:hover, +.v2-agent-settings__actions button:first-child:hover { background: var(--v2-ink-hover); } +.v2-agent-settings__error { color: var(--v2-danger); } +.v2-agent-settings__empty { margin-top: 24px; color: var(--v2-text-secondary); } +.v2-agent-settings__keys { list-style: none; padding: 0; margin: 20px 0 0; } +.v2-agent-settings__key { position: relative; padding: 14px 0; border-top: 1px solid var(--v2-border-soft); } +.v2-agent-settings__key-main { display: flex; gap: 12px; align-items: baseline; font-size: 14px; } +.v2-agent-settings__key-main span { color: var(--v2-text-tertiary); font-size: 12px; } +.v2-agent-settings__key-meta { display: flex; gap: 16px; flex-wrap: wrap; margin-top: 6px; color: var(--v2-text-secondary); font-family: var(--v2-font-mono); font-size: 11px; } +.v2-agent-settings__quiet { margin-top: 12px; } +.v2-agent-settings__confirm, +.v2-agent-settings__reveal { margin: 20px 0; padding: 16px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); } +.v2-agent-settings__confirm p { margin: 0 0 12px; font-size: 14px; } +.v2-agent-settings__actions { display: flex; gap: 8px; flex-wrap: wrap; } +.v2-agent-settings__reveal { display: grid; gap: 12px; font-size: 13px; } +.v2-agent-settings__reveal code { display: block; overflow-wrap: anywhere; padding: 10px; background: var(--v2-surface-tint); font-family: var(--v2-font-mono); } +.v2-agent-settings__reveal button { justify-self: start; } + +@media (max-width: 760px) { + .v2-agent-settings { padding: 16px; } + .v2-agent-settings__key-meta { display: grid; gap: 4px; } +} From 1f9e4e3eb147612b7589cd9116c620368178654c Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:48:09 -0700 Subject: [PATCH 2/9] Use agent credential ledger for settings keys Co-authored-by: forge Agent-Model: codex/gpt-6-sol/medium --- .../service/runtimeTokenRevoke.test.js | 55 +++++++++++++++--- .../routes/registry.runtime-tokens.test.js | 2 + backend/routes/credentials.ts | 58 ++++++++++++++++++- backend/routes/registry/agent-tokens.ts | 22 +------ .../v2/__tests__/V2AgentSettingsPage.test.tsx | 18 +++++- .../src/v2/agents/V2AgentSettingsPage.tsx | 37 ++++++------ 6 files changed, 141 insertions(+), 51 deletions(-) diff --git a/backend/__tests__/service/runtimeTokenRevoke.test.js b/backend/__tests__/service/runtimeTokenRevoke.test.js index 4a26ff9c46..8442f8333f 100644 --- a/backend/__tests__/service/runtimeTokenRevoke.test.js +++ b/backend/__tests__/service/runtimeTokenRevoke.test.js @@ -47,6 +47,7 @@ const agentRuntimeAuth = require('../../middleware/agentRuntimeAuth'); const app = express(); app.use(express.json()); app.use('/api/registry', require('../../routes/registry/agent-tokens')); +app.use('/api/credentials', require('../../routes/credentials')); app.use('/api/registry', require('../../routes/registry/admin')); app.use('/api/registry', require('../../routes/registry/pod-agents')); // A limiter far above anything this suite sends: the probe is a test route, @@ -113,10 +114,10 @@ describe('revoking a runtime token kills it in every store', () => { expect((await probe(token)).status).toBe(401); expect(await storesHolding(tokenHash)).toEqual({ users: 0, installations: 0 }); - const history = await request(app).get(`/api/registry/pods/${workspace._id}/agents/${AGENT}/runtime-tokens`); + const history = await request(app).get(`/api/credentials?agentName=${AGENT}&instanceId=default`); expect(history.status).toBe(200); - expect(history.body.tokens).toEqual([expect.objectContaining({ - label: 'laptop', revokedAt: expect.any(String), + expect(history.body).toEqual([expect.objectContaining({ + label: 'laptop', status: 'revoked', revokedAt: expect.any(String), })]); expect(JSON.stringify(history.body)).not.toContain(token); }); @@ -128,11 +129,51 @@ describe('revoking a runtime token kills it in every store', () => { .send({ label: 'private laptop' }); expect(issued.status).toBe(200); const otherId = new mongoose.Types.ObjectId().toString(); + const credential = await AgentCredential.findOne({ label: 'private laptop' }).lean(); await Pod.updateOne({ _id: workspace._id }, { $push: { members: otherId } }); mockOwner.id = otherId; const listed = await request(app).get(`/api/registry/pods/${workspace._id}/agents/${AGENT}/runtime-tokens`); expect(listed.status).toBe(403); expect(JSON.stringify(listed.body)).not.toContain('private laptop'); + const credentials = await request(app).get(`/api/credentials?agentName=${AGENT}&instanceId=default`); + expect(credentials.status).toBe(403); + expect(JSON.stringify(credentials.body)).not.toContain('private laptop'); + const revoke = await request(app).delete(`/api/credentials/${credential._id}?agentName=${AGENT}&instanceId=default`); + expect(revoke.status).toBe(403); + expect((await AgentCredential.findById(credential._id).lean()).status).toBe('active'); + }); + + test('the Settings credential revoke stops the agent token and keeps its history', async () => { + const { workspace } = await seed(); + const issued = await request(app) + .post(`/api/registry/pods/${workspace._id}/agents/${AGENT}/runtime-tokens`) + .send({ label: 'Settings key' }); + expect((await probe(issued.body.token)).status).toBe(200); + const listed = await request(app).get(`/api/credentials?agentName=${AGENT}&instanceId=default`); + expect(listed.status).toBe(200); + expect(listed.body).toEqual([expect.objectContaining({ label: 'Settings key', status: 'active' })]); + const revoked = await request(app).delete(`/api/credentials/${listed.body[0]._id}?agentName=${AGENT}&instanceId=default`); + expect(revoked.status).toBe(200); + expect((await probe(issued.body.token)).status).toBe(401); + const after = await request(app).get(`/api/credentials?agentName=${AGENT}&instanceId=default`); + expect(after.body).toEqual([expect.objectContaining({ label: 'Settings key', status: 'revoked' })]); + }); + + test('an existing embedded key is imported into Settings before it is revoked', async () => { + const { workspaceInstall } = await seed(); + const agentUser = await AgentIdentityService.getOrCreateAgentUser(AGENT, { instanceId: 'default' }); + const issued = await issueRuntimeTokenForAgent(agentUser, 'Old CLI key', workspaceInstall); + expect(await AgentCredential.countDocuments({ agentUserId: agentUser._id })).toBe(0); + const listed = await request(app).get(`/api/credentials?agentName=${AGENT}&instanceId=default`); + expect(listed.status).toBe(200); + expect(listed.body).toEqual([expect.objectContaining({ label: 'Old CLI key', status: 'active' })]); + const revoked = await request(app).delete(`/api/credentials/${listed.body[0]._id}?agentName=${AGENT}&instanceId=default`); + expect(revoked.status).toBe(200); + expect((await probe(issued.token)).status).toBe(401); + expect((await request(app).get(`/api/credentials?agentName=${AGENT}&instanceId=default`)).body) + .toEqual([expect.objectContaining({ label: 'Old CLI key', status: 'revoked' })]); + expect((await User.findById(agentUser._id).lean()).agentRuntimeTokens).toHaveLength(1); + expect((await AgentInstallation.findById(workspaceInstall._id).lean()).runtimeTokens).toHaveLength(1); }); test('a copy on a sibling installation dies too, and so does a ledger row', async () => { @@ -201,11 +242,11 @@ describe('revoking a runtime token kills it in every store', () => { expect((await probe(first.token)).status).toBe(401); expect((await probe(second)).status).toBe(200); - const history = await request(app).get(`/api/registry/pods/${workspace._id}/agents/${AGENT}/runtime-tokens`); + const history = await request(app).get(`/api/credentials?agentName=${AGENT}&instanceId=default`); expect(history.status).toBe(200); - expect(history.body.tokens).toEqual(expect.arrayContaining([ - expect.objectContaining({ label: 'laptop', revokedAt: expect.any(String) }), - expect.objectContaining({ label: 'desktop', revokedAt: null }), + expect(history.body).toEqual(expect.arrayContaining([ + expect.objectContaining({ label: 'laptop', status: 'revoked', revokedAt: expect.any(String) }), + expect.objectContaining({ label: 'desktop', status: 'active', revokedAt: null }), ])); }); diff --git a/backend/__tests__/unit/routes/registry.runtime-tokens.test.js b/backend/__tests__/unit/routes/registry.runtime-tokens.test.js index 6e27e63be1..d0eb351077 100644 --- a/backend/__tests__/unit/routes/registry.runtime-tokens.test.js +++ b/backend/__tests__/unit/routes/registry.runtime-tokens.test.js @@ -26,6 +26,8 @@ jest.mock('../../../models/AgentRegistry', () => ({ })); jest.mock('../../../models/AgentCredential', () => ({ updateMany: jest.fn().mockResolvedValue({ modifiedCount: 0 }), + updateOne: jest.fn().mockResolvedValue({ upsertedCount: 1 }), + create: jest.fn().mockResolvedValue({}), })); jest.mock('../../../models/User', () => ({ findOne: jest.fn(), diff --git a/backend/routes/credentials.ts b/backend/routes/credentials.ts index 61483e2287..1cb74f5055 100644 --- a/backend/routes/credentials.ts +++ b/backend/routes/credentials.ts @@ -9,6 +9,8 @@ import rateLimit from 'express-rate-limit'; import { cloudflareIpRateLimitKeyGenerator } from '../middleware/ipRateLimit'; import { createHash } from 'crypto'; import AgentCredential from '../models/AgentCredential'; +import { AgentInstallation } from '../models/AgentRegistry'; +import { ownsRuntimeIdentity } from '../services/runtimeIdentityOwnership'; // eslint-disable-next-line @typescript-eslint/no-require-imports const auth = require('../middleware/auth'); @@ -40,7 +42,42 @@ type AuthReq = express.Request & { user?: { id?: string } }; router.get('/', credentialRateLimit, auth, async (req: AuthReq, res: express.Response) => { try { - const rows = await AgentCredential.find({ ownerUserId: req.user?.id }) + const agentName = String(req.query.agentName || '').trim().toLowerCase(); + const instanceId = String(req.query.instanceId || '').trim(); + let filter: Record = { ownerUserId: req.user?.id }; + if (agentName || instanceId) { + if (!agentName || !instanceId) return res.status(400).json({ message: 'Agent name and instance id are required' }); + const caller = await User.findById(req.user?.id).select('role').lean(); + if (caller?.role !== 'admin') { + const ownership = await ownsRuntimeIdentity(req.user?.id, agentName, instanceId); + if (!ownership.owned) return res.status(403).json({ message: 'Access denied' }); + } + const agent = await User.findOne({ + isBot: true, + 'botMetadata.agentName': agentName, + 'botMetadata.instanceId': instanceId, + }).select('_id agentRuntimeTokens').lean(); + if (!agent) return res.status(404).json({ message: 'Agent not found' }); + // Existing CLI keys were minted before the credential ledger became + // authoritative. Import their metadata once so list/revoke can use the + // same credential id and a later revoke remains visible in Settings. + const installation = await AgentInstallation.findOne({ + agentName, instanceId, status: 'active', + }).select('installedBy').lean(); + const ownerUserId = installation?.installedBy || req.user?.id; + await Promise.all((agent.agentRuntimeTokens || []).filter((token: { tokenHash?: string }) => token.tokenHash) + .map((token: { tokenHash: string; label?: string; createdAt?: Date; lastUsedAt?: Date }) => AgentCredential.updateOne( + { tokenHash: token.tokenHash }, + { $setOnInsert: { + kind: 'runtime', ownerUserId, agentUserId: agent._id, + label: token.label || 'Runtime token', status: 'active', + createdAt: token.createdAt || new Date(), lastUsedAt: token.lastUsedAt || null, + } }, + { upsert: true }, + ))); + filter = { agentUserId: agent._id, kind: 'runtime' }; + } + const rows = await AgentCredential.find(filter) .select('-tokenHash') .sort({ createdAt: -1 }) .lean(); @@ -58,8 +95,25 @@ router.delete('/:id', credentialRateLimit, auth, async (req: AuthReq, res: expre const credential = await AgentCredential.findById(id); if (!credential) return res.status(404).json({ message: 'Credential not found' }); const caller = await User.findById(req.user?.id).select('role').lean(); + const agentName = String(req.query.agentName || '').trim().toLowerCase(); + const instanceId = String(req.query.instanceId || '').trim(); + if (agentName || instanceId) { + if (!agentName || !instanceId) return res.status(400).json({ message: 'Agent name and instance id are required' }); + const agent = await User.findOne({ + isBot: true, + 'botMetadata.agentName': agentName, + 'botMetadata.instanceId': instanceId, + }).select('_id').lean(); + if (!agent || String(credential.agentUserId) !== String(agent._id) || credential.kind !== 'runtime') { + return res.status(404).json({ message: 'Credential not found for this agent' }); + } + if (caller?.role !== 'admin') { + const ownership = await ownsRuntimeIdentity(req.user?.id, agentName, instanceId); + if (!ownership.owned) return res.status(403).json({ message: 'Access denied' }); + } + } const isOwner = String(credential.ownerUserId) === String(req.user?.id); - if (!isOwner && caller?.role !== 'admin') { + if (!agentName && !isOwner && caller?.role !== 'admin') { return res.status(403).json({ message: 'Access denied' }); } const revoked = await AgentCredential.revokeCascade(credential._id as Types.ObjectId); diff --git a/backend/routes/registry/agent-tokens.ts b/backend/routes/registry/agent-tokens.ts index e67f30d2a6..7a6e7ad296 100644 --- a/backend/routes/registry/agent-tokens.ts +++ b/backend/routes/registry/agent-tokens.ts @@ -128,27 +128,7 @@ agentTokensRouter.get('/pods/:podId/agents/:name/runtime-tokens', tokenRouteLimi const agentUser = await User.findOne({ username: agentUsername, isBot: true }) .select('agentRuntimeTokens') .lean(); - const embedded = agentUser?.agentRuntimeTokens || []; - const credentials = agentUser?._id - ? await AgentCredential.find({ agentUserId: agentUser._id, kind: 'runtime' }) - .select('tokenHash label status createdAt lastUsedAt revokedAt').lean() - : []; - const credentialByHash = new Map(credentials.map((row: any) => [row.tokenHash, row])); - const tokens = serializeRuntimeTokens(embedded).map((token: any, index: number) => { - const credential: any = credentialByHash.get(embedded[index]?.tokenHash); - return { ...token, lastUsedAt: credential?.lastUsedAt || token.lastUsedAt || null, revokedAt: null }; - }); - const activeHashes = new Set(embedded.map((row: any) => row.tokenHash)); - for (const credential of credentials) { - if (credential.status !== 'revoked' || activeHashes.has(credential.tokenHash)) continue; - tokens.push({ - id: String(credential._id), - label: credential.label || 'Runtime token', - createdAt: credential.createdAt, - lastUsedAt: credential.lastUsedAt || null, - revokedAt: credential.revokedAt || null, - }); - } + const tokens = serializeRuntimeTokens(agentUser?.agentRuntimeTokens || []); return res.json({ tokens }); } catch (error) { diff --git a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx index e5ef4f030d..21316c706c 100644 --- a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx @@ -26,9 +26,9 @@ beforeEach(() => { if (url.startsWith('/api/agent-memory/')) return Promise.resolve({ data: { displayName: 'Maya', pods: [{ id: 'pod-1', name: 'Workspace' }], } }); - return Promise.resolve({ data: { tokens: [{ - id: 'tok-1', label: 'Laptop', createdAt: '2026-10-01T00:00:00Z', lastUsedAt: null, revokedAt: null, - }] } }); + return Promise.resolve({ data: [{ + _id: 'tok-1', label: 'Laptop', createdAt: '2026-10-01T00:00:00Z', lastUsedAt: null, revokedAt: null, status: 'active', + }] }); }); }); @@ -57,3 +57,15 @@ test('a denied owner probe renders no key controls or metadata', async () => { expect(screen.queryByRole('button', { name: 'Regenerate key' })).not.toBeInTheDocument(); expect(screen.queryByText('Laptop')).not.toBeInTheDocument(); }); + +test('revocation uses the credential id only after confirmation', async () => { + axios.delete.mockResolvedValue({ data: { revoked: 1 } }); + renderPage(); + await screen.findByText('Laptop'); + fireEvent.click(screen.getByRole('button', { name: 'Revoke' })); + expect(axios.delete).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: 'Revoke key' })); + await waitFor(() => expect(axios.delete).toHaveBeenCalledWith('/api/credentials/tok-1', { + params: { agentName: 'maya-claude', instanceId: 'default' }, + })); +}); diff --git a/frontend/src/v2/agents/V2AgentSettingsPage.tsx b/frontend/src/v2/agents/V2AgentSettingsPage.tsx index 0f2ef5df54..7d797ba6af 100644 --- a/frontend/src/v2/agents/V2AgentSettingsPage.tsx +++ b/frontend/src/v2/agents/V2AgentSettingsPage.tsx @@ -11,11 +11,12 @@ interface AgentAccess { } interface RuntimeKey { - id: string; + _id: string; label: string; createdAt?: string; lastUsedAt?: string | null; revokedAt?: string | null; + status: 'active' | 'revoked'; } const dateLabel = (value: string | null | undefined, t: (key: string) => string): string => { @@ -45,12 +46,12 @@ const V2AgentSettingsPage: React.FC = () => { `/api/registry/pods/${encodeURIComponent(podId)}/agents/${encodeURIComponent(agentName)}/runtime-tokens` ), [agentName]); - const loadKeys = useCallback(async (podId: string) => { - const response = await axios.get<{ tokens: RuntimeKey[] }>(keyUrl(podId), { - params: { instanceId }, + const loadKeys = useCallback(async () => { + const response = await axios.get('/api/credentials', { + params: { agentName, instanceId }, }); - setKeys(response.data.tokens || []); - }, [instanceId, keyUrl]); + setKeys(response.data || []); + }, [agentName, instanceId]); useEffect(() => { let active = true; @@ -71,7 +72,7 @@ const V2AgentSettingsPage: React.FC = () => { if (!active) return; const next = response.data; if (!next.pods?.length) throw new Error(t('agentSettings.noPod')); - await loadKeys(next.pods[0].id); + await loadKeys(); if (active) setAccess(next); } catch (requestError) { if (active) setError(errorLabel(requestError, t('agentSettings.unavailable'))); @@ -96,7 +97,7 @@ const V2AgentSettingsPage: React.FC = () => { if (!response.data.token) throw new Error(t('agentSettings.missingReplacement')); setOneTimeKey(response.data.token); setPendingAction(null); - await loadKeys(access.pods[0].id); + await loadKeys(); } catch (requestError) { setError(errorLabel(requestError, t('agentSettings.updateError'))); } finally { @@ -109,12 +110,12 @@ const V2AgentSettingsPage: React.FC = () => { setBusy(true); setError(null); try { - await axios.delete(`${keyUrl(access.pods[0].id)}/${encodeURIComponent(tokenId)}`, { - params: { instanceId }, + await axios.delete(`/api/credentials/${encodeURIComponent(tokenId)}`, { + params: { agentName, instanceId }, }); setPendingAction(null); setOneTimeKey(null); - await loadKeys(access.pods[0].id); + await loadKeys(); } catch (requestError) { setError(errorLabel(requestError, t('agentSettings.updateError'))); } finally { @@ -159,27 +160,27 @@ const V2AgentSettingsPage: React.FC = () => { {keys.length === 0 ?

{t('agentSettings.empty')}

: (
    {keys.map((key) => ( -
  • +
  • {key.label || t('agentSettings.runtimeKey')} - {key.revokedAt ? t('agentSettings.revoked') : t('agentSettings.active')} + {key.status === 'revoked' ? t('agentSettings.revoked') : t('agentSettings.active')}
    {t('agentSettings.created', { date: dateLabel(key.createdAt, t) })} {t('agentSettings.lastUsed', { date: dateLabel(key.lastUsedAt, t) })} - {key.revokedAt && {t('agentSettings.revokedAt', { date: dateLabel(key.revokedAt, t) })}} + {key.status === 'revoked' && {t('agentSettings.revokedAt', { date: dateLabel(key.revokedAt, t) })}}
    - {!key.revokedAt && pendingAction === key.id && ( + {key.status === 'active' && pendingAction === key._id && (

    {t('agentSettings.revokeWarning')}

    - +
    )} - {!key.revokedAt && pendingAction !== key.id && ( - )} From 4a8f85ed44a37ae90af7813f7d076f2ee791826c Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:51:42 -0700 Subject: [PATCH 3/9] Share agent settings read and write access Co-authored-by: forge Agent-Model: codex/gpt-6-sol/medium --- frontend/src/i18n/locales/en.json | 1 + frontend/src/i18n/locales/zh-CN.json | 1 + .../v2/__tests__/V2AgentSettingsPage.test.tsx | 58 ++++++++++++ .../v2/__tests__/useAgentSettings.test.tsx | 45 +++++++++ .../src/v2/agents/V2AgentSettingsPage.tsx | 75 ++++++++------- frontend/src/v2/agents/useAgentSettings.ts | 92 +++++++++++++++++++ 6 files changed, 233 insertions(+), 39 deletions(-) create mode 100644 frontend/src/v2/__tests__/useAgentSettings.test.tsx create mode 100644 frontend/src/v2/agents/useAgentSettings.ts diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index a7ac172192..177f46d8c4 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2263,6 +2263,7 @@ "settings": "Settings", "key": "Key", "regenerate": "Regenerate key", + "reconnectToRegenerate": "Reconnect to regenerate key", "copyNow": "Copy this key now. It will not be shown again.", "done": "Done", "empty": "No runtime keys yet.", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 146815ec7a..e213a7c361 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2254,6 +2254,7 @@ "settings": "设置", "key": "密钥", "regenerate": "重新生成密钥", + "reconnectToRegenerate": "重新连接并生成密钥", "copyNow": "请立即复制此密钥。之后不会再次显示。", "done": "完成", "empty": "暂无运行时密钥。", diff --git a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx index 21316c706c..af27aea585 100644 --- a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx @@ -26,12 +26,32 @@ beforeEach(() => { 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: 'webhook' }, + } } }); return Promise.resolve({ data: [{ _id: 'tok-1', label: 'Laptop', createdAt: '2026-10-01T00:00:00Z', lastUsedAt: null, revokedAt: null, status: 'active', }] }); }); }); +test('wrapper key replacement goes through reconnect instead of stranding its local key', async () => { + 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' }, + } } }); + return Promise.resolve({ data: [] }); + }); + renderPage(); + const reconnect = await screen.findByRole('link', { name: 'Reconnect to regenerate key' }); + expect(reconnect).toHaveAttribute('href', '/v2/agents/byo?pod=pod-1&name=maya-claude&reconnect=1&mode=machine'); + expect(screen.queryByRole('button', { name: 'Regenerate key' })).not.toBeInTheDocument(); + expect(axios.post).not.toHaveBeenCalled(); +}); + test('regeneration waits for the explicit running-key replacement confirmation', async () => { axios.post.mockResolvedValue({ data: { token: 'cm_agent_once' } }); renderPage(); @@ -58,6 +78,22 @@ test('a denied owner probe renders no key controls or metadata', async () => { expect(screen.queryByText('Laptop')).not.toBeInTheDocument(); }); +test('a partial installation owner cannot see identity-wide key controls', async () => { + 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: 'webhook' }, + } } }); + return Promise.reject({ response: { status: 403, data: { message: 'Access denied' } } }); + }); + renderPage(); + expect(await screen.findByRole('alert')).toHaveTextContent('Agent settings unavailable'); + expect(screen.queryByRole('button', { name: 'Regenerate key' })).not.toBeInTheDocument(); + expect(screen.queryByText('Laptop')).not.toBeInTheDocument(); +}); + test('revocation uses the credential id only after confirmation', async () => { axios.delete.mockResolvedValue({ data: { revoked: 1 } }); renderPage(); @@ -69,3 +105,25 @@ test('revocation uses the credential id only after confirmation', async () => { params: { agentName: 'maya-claude', instanceId: 'default' }, })); }); + +test('selects a pod the owner can read when the first identity pod is inaccessible', async () => { + axios.get.mockImplementation((url) => { + if (url.startsWith('/api/agent-memory/')) return Promise.resolve({ data: { + displayName: 'Maya', pods: [{ id: 'foreign', name: 'Other pod' }, { id: 'owned', name: 'My pod' }], + } }); + if (url.includes('/pods/foreign/')) return Promise.reject({ response: { status: 403 } }); + if (url.includes('/pods/owned/')) return Promise.resolve({ data: { agent: { + name: 'maya-claude', instanceId: 'default', displayName: 'Maya', runtime: { runtimeType: 'webhook' }, + } } }); + return Promise.resolve({ data: [] }); + }); + axios.post.mockResolvedValue({ data: { token: 'cm_agent_once' } }); + renderPage(); + await screen.findByRole('button', { name: 'Regenerate key' }); + fireEvent.click(screen.getByRole('button', { name: 'Regenerate key' })); + fireEvent.click(screen.getByRole('button', { name: /replace the running agent/i })); + await waitFor(() => expect(axios.post).toHaveBeenCalledWith( + '/api/registry/pods/owned/agents/maya-claude/runtime-tokens', + expect.objectContaining({ force: true }), + )); +}); diff --git a/frontend/src/v2/__tests__/useAgentSettings.test.tsx b/frontend/src/v2/__tests__/useAgentSettings.test.tsx new file mode 100644 index 0000000000..d43ced33df --- /dev/null +++ b/frontend/src/v2/__tests__/useAgentSettings.test.tsx @@ -0,0 +1,45 @@ +// @ts-nocheck +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { useAgentSettings } from '../agents/useAgentSettings'; + +jest.mock('axios', () => { + const mock = { + get: jest.fn(), patch: 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 Probe = () => { + const { installation, podId, update } = useAgentSettings('maya-claude', 'default'); + return
    + {installation?.displayName || 'loading'} + + {podId} +
    ; +}; + +test('reads one accessible installation and patches it with the instance id', async () => { + axios.get.mockImplementation((url) => { + if (url.startsWith('/api/agent-memory/')) return Promise.resolve({ data: { + displayName: 'Maya', pods: [{ id: 'pod-1', name: 'Workspace' }], + } }); + return Promise.resolve({ data: { agent: { + name: 'maya-claude', instanceId: 'default', displayName: 'Maya', config: {}, + } } }); + }); + axios.patch.mockResolvedValue({ data: { agent: {} } }); + render(); + await screen.findByText('Maya'); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(axios.patch).toHaveBeenCalledWith( + '/api/registry/pods/pod-1/agents/maya-claude', + { displayName: 'Maya Updated', instanceId: 'default' }, + )); + expect(screen.getByTestId('pod')).toHaveTextContent('pod-1'); +}); diff --git a/frontend/src/v2/agents/V2AgentSettingsPage.tsx b/frontend/src/v2/agents/V2AgentSettingsPage.tsx index 7d797ba6af..110ef2d60b 100644 --- a/frontend/src/v2/agents/V2AgentSettingsPage.tsx +++ b/frontend/src/v2/agents/V2AgentSettingsPage.tsx @@ -3,13 +3,9 @@ import { Link, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import axios from '../../utils/axiosConfig'; import V2PageHead from '../components/V2PageHead'; +import { useAgentSettings } from './useAgentSettings'; import './v2-agent-settings.css'; -interface AgentAccess { - displayName: string; - pods: Array<{ id: string; name: string }>; -} - interface RuntimeKey { _id: string; label: string; @@ -34,62 +30,52 @@ const errorLabel = (error: unknown, fallback: string): string => ( const V2AgentSettingsPage: React.FC = () => { const { t } = useTranslation(); const { agentName = '', instanceId = '' } = useParams<{ agentName: string; instanceId: string }>(); - const [access, setAccess] = useState(null); + const { access, installation, podId, loading, error: loadError } = useAgentSettings(agentName, instanceId); const [keys, setKeys] = useState([]); - const [loading, setLoading] = useState(true); + const [keysReadyFor, setKeysReadyFor] = useState(null); const [error, setError] = useState(null); const [pendingAction, setPendingAction] = useState<'replace' | string | null>(null); const [busy, setBusy] = useState(false); const [oneTimeKey, setOneTimeKey] = useState(null); + const identityKey = `${agentName}:${instanceId}:${podId || ''}`; const keyUrl = useCallback((podId: string) => ( `/api/registry/pods/${encodeURIComponent(podId)}/agents/${encodeURIComponent(agentName)}/runtime-tokens` ), [agentName]); - const loadKeys = useCallback(async () => { + const fetchKeys = useCallback(async () => { const response = await axios.get('/api/credentials', { params: { agentName, instanceId }, }); - setKeys(response.data || []); + return response.data || []; }, [agentName, instanceId]); useEffect(() => { let active = true; - setLoading(true); - setAccess(null); setKeys([]); + setKeysReadyFor(null); setError(null); setOneTimeKey(null); setPendingAction(null); - const load = async () => { - try { - // This owner/admin endpoint gates the page and supplies an installed - // pod for the existing pod-scoped token routes. Public profile data is - // deliberately insufficient to render the Settings tab. - const response = await axios.get( - `/api/agent-memory/${encodeURIComponent(agentName)}/${encodeURIComponent(instanceId)}`, - ); - if (!active) return; - const next = response.data; - if (!next.pods?.length) throw new Error(t('agentSettings.noPod')); - await loadKeys(); - if (active) setAccess(next); - } catch (requestError) { + if (access && podId) { + void fetchKeys().then((rows) => { + if (active) { + setKeys(rows); + setKeysReadyFor(identityKey); + } + }).catch((requestError) => { if (active) setError(errorLabel(requestError, t('agentSettings.unavailable'))); - } finally { - if (active) setLoading(false); - } - }; - void load(); + }); + } return () => { active = false; }; - }, [agentName, instanceId, loadKeys, t]); + }, [access, podId, fetchKeys, identityKey, t]); const replaceKey = async () => { - if (!access || pendingAction !== 'replace' || busy) return; + if (!access || !podId || pendingAction !== 'replace' || busy || installation?.runtime?.runtimeType === 'wrapper') return; setBusy(true); setError(null); try { - const response = await axios.post<{ token?: string }>(keyUrl(access.pods[0].id), { + const response = await axios.post<{ token?: string }>(keyUrl(podId), { instanceId, label: 'Agent Settings replacement', force: true, @@ -97,7 +83,7 @@ const V2AgentSettingsPage: React.FC = () => { if (!response.data.token) throw new Error(t('agentSettings.missingReplacement')); setOneTimeKey(response.data.token); setPendingAction(null); - await loadKeys(); + setKeys(await fetchKeys()); } catch (requestError) { setError(errorLabel(requestError, t('agentSettings.updateError'))); } finally { @@ -115,7 +101,7 @@ const V2AgentSettingsPage: React.FC = () => { }); setPendingAction(null); setOneTimeKey(null); - await loadKeys(); + setKeys(await fetchKeys()); } catch (requestError) { setError(errorLabel(requestError, t('agentSettings.updateError'))); } finally { @@ -124,7 +110,12 @@ const V2AgentSettingsPage: React.FC = () => { }; if (loading) return
    {t('agentSettings.loading')}
    ; - if (!access) return
    {error || t('agentSettings.unavailable')}
    ; + if (!access || !podId) return
    + {error || errorLabel(loadError, t('agentSettings.unavailable'))} +
    ; + if (keysReadyFor !== identityKey) return
    + {error || t('agentSettings.loading')} +
    ; return (
    @@ -136,9 +127,15 @@ const V2AgentSettingsPage: React.FC = () => {

    {t('agentSettings.key')}

    - + {installation?.runtime?.runtimeType === 'wrapper' ? ( + + {t('agentSettings.reconnectToRegenerate')} + + ) : ( + + )}
    {error &&

    {error}

    } {pendingAction === 'replace' && ( diff --git a/frontend/src/v2/agents/useAgentSettings.ts b/frontend/src/v2/agents/useAgentSettings.ts new file mode 100644 index 0000000000..6f54a57198 --- /dev/null +++ b/frontend/src/v2/agents/useAgentSettings.ts @@ -0,0 +1,92 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import axios from '../../utils/axiosConfig'; + +export interface AgentSettingsInstallation { + name: string; + instanceId: string; + displayName: string; + installedBy?: string; + runtime?: { runtimeType?: string }; + config?: Record | null; + [field: string]: unknown; +} + +export interface AgentSettingsPatch { + config?: Record; + displayName?: string; + instructions?: string; + persona?: string; + modelPreferences?: unknown; + toolPolicy?: unknown; + contextPolicy?: unknown; + [field: string]: unknown; +} + +interface AgentAccess { + displayName: string; + pods: Array<{ id: string; name: string }>; +} + +/** The generic PATCH writes across this identity's accessible installations. */ +export const useAgentSettings = (agentName: string, instanceId: string) => { + const [access, setAccess] = useState(null); + const [installation, setInstallation] = useState(null); + const [podId, setPodId] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const requestId = useRef(0); + + const installationUrl = useCallback((id: string) => ( + `/api/registry/pods/${encodeURIComponent(id)}/agents/${encodeURIComponent(agentName)}` + ), [agentName]); + + const refresh = useCallback(async () => { + const currentRequest = ++requestId.current; + const accessResponse = await axios.get( + `/api/agent-memory/${encodeURIComponent(agentName)}/${encodeURIComponent(instanceId)}`, + ); + const nextAccess = accessResponse.data; + // Memory lists every pod for the identity. A viewer can be a member of + // only some of them, so find a readable installation before writing. + for (const pod of nextAccess.pods || []) { + try { + const response = await axios.get<{ agent: AgentSettingsInstallation }>(installationUrl(pod.id), { + params: { instanceId }, + }); + if (currentRequest === requestId.current) { + setAccess(nextAccess); + setInstallation(response.data.agent); + setPodId(pod.id); + setError(null); + } + return response.data.agent; + } catch (candidateError) { + if ((candidateError as { response?: { status?: number } })?.response?.status !== 403) throw candidateError; + } + } + throw new Error('No accessible installation'); + }, [agentName, instanceId, installationUrl]); + + useEffect(() => { + let active = true; + setLoading(true); + setAccess(null); + setInstallation(null); + setPodId(null); + void refresh().catch((requestError) => { + if (active) setError(requestError); + }).finally(() => { + if (active) setLoading(false); + }); + return () => { active = false; requestId.current += 1; }; + }, [refresh]); + + const update = useCallback(async (patch: AgentSettingsPatch) => { + if (!podId) throw new Error('No accessible installation'); + const response = await axios.patch(installationUrl(podId), { ...patch, instanceId }); + await refresh(); + return response.data; + }, [instanceId, installationUrl, podId, refresh]); + + return { access, installation, podId, loading, error, refresh, update }; +}; From 94f54b1081a3affe84e69601853d0b2455ccaa64 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:54:20 -0700 Subject: [PATCH 4/9] Cover installation keys and admin settings access Co-authored-by: forge Agent-Model: codex/gpt-6-sol/medium --- .../service/runtimeTokenRevoke.test.js | 31 +++++++++++++++++++ ...gistry.agent-config-installer-gate.test.js | 17 +++++++++- backend/routes/credentials.ts | 19 ++++++++---- backend/routes/registry/agent-config.ts | 7 ++--- backend/routes/registry/agent-tokens.ts | 23 +++++++++++--- backend/routes/registry/pod-agents.ts | 2 +- .../v2/__tests__/useAgentSettings.test.tsx | 11 ++++++- frontend/src/v2/agents/useAgentSettings.ts | 17 +++++++++- 8 files changed, 109 insertions(+), 18 deletions(-) diff --git a/backend/__tests__/service/runtimeTokenRevoke.test.js b/backend/__tests__/service/runtimeTokenRevoke.test.js index 8442f8333f..c0c7e7707d 100644 --- a/backend/__tests__/service/runtimeTokenRevoke.test.js +++ b/backend/__tests__/service/runtimeTokenRevoke.test.js @@ -143,6 +143,23 @@ describe('revoking a runtime token kills it in every store', () => { expect((await AgentCredential.findById(credential._id).lean()).status).toBe('active'); }); + test('an instance admin can read a key-bearing installation without joining its pod', async () => { + const { workspace } = await seed(); + const issued = await request(app) + .post(`/api/registry/pods/${workspace._id}/agents/${AGENT}/runtime-tokens`) + .send({ label: 'owner key' }); + expect(issued.status).toBe(200); + const adminId = new mongoose.Types.ObjectId(); + await User.create({ _id: adminId, username: 'settings-admin', email: 'settings-admin@example.test', + role: 'admin', authProviders: [{ provider: 'github', providerId: 'settings-admin' }] }); + mockOwner.id = String(adminId); + const installation = await request(app) + .get(`/api/registry/pods/${workspace._id}/agents/${AGENT}?instanceId=default`); + expect(installation.status).toBe(200); + const keys = await request(app).get(`/api/credentials?agentName=${AGENT}&instanceId=default`); + expect(keys.status).toBe(200); + }); + test('the Settings credential revoke stops the agent token and keeps its history', async () => { const { workspace } = await seed(); const issued = await request(app) @@ -176,6 +193,20 @@ describe('revoking a runtime token kills it in every store', () => { expect((await AgentInstallation.findById(workspaceInstall._id).lean()).runtimeTokens).toHaveLength(1); }); + test('an installation-only legacy key remains visible for owner revocation', async () => { + const { workspaceInstall } = await seed(); + const agentUser = await AgentIdentityService.getOrCreateAgentUser(AGENT, { instanceId: 'default' }); + const issued = await issueRuntimeTokenForAgent(agentUser, 'Installation key', workspaceInstall); + await User.updateOne({ _id: agentUser._id }, { $set: { agentRuntimeTokens: [] } }); + expect((await probe(issued.token)).status).toBe(200); + const listed = await request(app).get(`/api/credentials?agentName=${AGENT}&instanceId=default`); + expect(listed.status).toBe(200); + expect(listed.body).toEqual([expect.objectContaining({ label: 'Installation key', status: 'active' })]); + const revoked = await request(app).delete(`/api/credentials/${listed.body[0]._id}?agentName=${AGENT}&instanceId=default`); + expect(revoked.status).toBe(200); + expect((await probe(issued.token)).status).toBe(401); + }); + test('a copy on a sibling installation dies too, and so does a ledger row', async () => { const { workspace, roomInstall } = await seed(); const agentUser = await AgentIdentityService.getOrCreateAgentUser(AGENT, { instanceId: 'default' }); diff --git a/backend/__tests__/unit/routes/registry.agent-config-installer-gate.test.js b/backend/__tests__/unit/routes/registry.agent-config-installer-gate.test.js index d5c4021206..9a33032f2d 100644 --- a/backend/__tests__/unit/routes/registry.agent-config-installer-gate.test.js +++ b/backend/__tests__/unit/routes/registry.agent-config-installer-gate.test.js @@ -273,7 +273,22 @@ describe('agent config PATCH — installer gate', () => { expect(primary.save).toHaveBeenCalledTimes(1); }); - it('still refuses a non-member before any of that (membership is a precondition)', async () => { + it('lets an instance admin manage an installation without joining its pod', async () => { + const primary = installation(); + setCallerRole('admin'); + setPod({ members: [] }); + AgentInstallation.findOne.mockResolvedValue(primary); + AgentInstallation.find.mockResolvedValue([primary]); + + const res = await request(app) + .patch('/api/registry/pods/pod-1/agents/openclaw') + .send({ instanceId: 'curator', config: { heartbeat: { enabled: false } } }); + + expect(res.status).toBe(200); + expect(primary.save).toHaveBeenCalledTimes(1); + }); + + it('still refuses a non-admin non-member before any of that', async () => { const primary = installation(); Pod.findById.mockReturnValue({ lean: jest.fn().mockResolvedValue({ _id: 'pod-1', createdBy: 'someone-else', members: [] }), diff --git a/backend/routes/credentials.ts b/backend/routes/credentials.ts index 1cb74f5055..784a5d8283 100644 --- a/backend/routes/credentials.ts +++ b/backend/routes/credentials.ts @@ -61,12 +61,19 @@ router.get('/', credentialRateLimit, auth, async (req: AuthReq, res: express.Res // Existing CLI keys were minted before the credential ledger became // authoritative. Import their metadata once so list/revoke can use the // same credential id and a later revoke remains visible in Settings. - const installation = await AgentInstallation.findOne({ - agentName, instanceId, status: 'active', - }).select('installedBy').lean(); - const ownerUserId = installation?.installedBy || req.user?.id; - await Promise.all((agent.agentRuntimeTokens || []).filter((token: { tokenHash?: string }) => token.tokenHash) - .map((token: { tokenHash: string; label?: string; createdAt?: Date; lastUsedAt?: Date }) => AgentCredential.updateOne( + const installations = await AgentInstallation.find({ agentName, instanceId }) + .select('installedBy runtimeTokens').lean(); + const ownerUserId = installations[0]?.installedBy || req.user?.id; + type EmbeddedToken = { tokenHash?: string; label?: string; createdAt?: Date; lastUsedAt?: Date }; + const embedded: EmbeddedToken[] = [...(agent.agentRuntimeTokens || []), + ...installations.flatMap((entry: { runtimeTokens?: EmbeddedToken[] }) => entry.runtimeTokens || [])]; + const seen = new Set(); + await Promise.all(embedded.filter((token) => { + if (!token.tokenHash || seen.has(token.tokenHash)) return false; + seen.add(token.tokenHash); + return true; + }) + .map((token) => AgentCredential.updateOne( { tokenHash: token.tokenHash }, { $setOnInsert: { kind: 'runtime', ownerUserId, agentUserId: agent._id, diff --git a/backend/routes/registry/agent-config.ts b/backend/routes/registry/agent-config.ts index 649a7dae09..4252b72e59 100644 --- a/backend/routes/registry/agent-config.ts +++ b/backend/routes/registry/agent-config.ts @@ -90,7 +90,8 @@ agentConfigRouter.patch('/pods/:podId/agents/:name', auth, async (req: any, res: return memberId && memberId === userId.toString(); }); - if (!membership && !isCreator) { + const isInstanceAdmin = req.user?.role === 'admin' || await isGlobalAdminUser(userId); + if (!membership && !isCreator && !isInstanceAdmin) { return res.status(403).json({ error: 'Access denied' }); } @@ -104,7 +105,7 @@ agentConfigRouter.patch('/pods/:podId/agents/:name', auth, async (req: any, res: return res.status(404).json({ error: 'Agent not installed in this pod' }); } - // Pod membership is a precondition, not an authority. Changing an installed + // For a non-admin, pod membership is a precondition, not an authority. Changing an installed // agent is the installer's or an instance admin's call, because `config` // reaches the owner's machine: agentBinding projects `config.environment` // to the owner's daemon as the seat's declared spec, so a member who can @@ -118,8 +119,6 @@ agentConfigRouter.patch('/pods/:podId/agents/:name', auth, async (req: any, res: // have installed, and deriving it only on the non-installer path would // scope the same admin's PATCH differently depending on how they // authenticated. - const isInstanceAdmin = req.user?.role === 'admin' - || await isGlobalAdminUser(userId); if (!isInstaller && !isInstanceAdmin) { return res.status(403).json({ error: 'Only the agent installer or an instance admin can change an installed agent', diff --git a/backend/routes/registry/agent-tokens.ts b/backend/routes/registry/agent-tokens.ts index 7a6e7ad296..4887aa41ee 100644 --- a/backend/routes/registry/agent-tokens.ts +++ b/backend/routes/registry/agent-tokens.ts @@ -71,8 +71,20 @@ const credentialRefusal = async (userId: unknown, agentName: string, instanceId: // Legacy embedded keys predate the credential ledger. Record their metadata // before a replacement or revoke removes the only embedded copy, so Settings // can still show the revoked state after the authenticating copies are pulled. -const recordLegacyKeys = async (agentUser: any, ownerUserId: unknown, records: any[]) => { - await Promise.all(records.filter((record) => record?.tokenHash).map((record) => AgentCredential.updateOne( +const recordLegacyKeys = async ( + agentUser: any, ownerUserId: unknown, agentName: string, instanceId: string, +) => { + const { AgentInstallation } = require('../../models/AgentRegistry'); + const installations = await AgentInstallation.find({ agentName, instanceId }) + .collation({ locale: 'en', strength: 2 }).select('runtimeTokens').lean(); + const records = [...(agentUser.agentRuntimeTokens || []), + ...(installations || []).flatMap((entry: any) => entry.runtimeTokens || [])]; + const seen = new Set(); + await Promise.all(records.filter((record) => { + if (!record?.tokenHash || seen.has(record.tokenHash)) return false; + seen.add(record.tokenHash); + return true; + }).map((record) => AgentCredential.updateOne( { tokenHash: record.tokenHash }, { $setOnInsert: { kind: 'runtime', ownerUserId, agentUserId: agentUser._id, @@ -197,7 +209,10 @@ agentTokensRouter.post('/pods/:podId/agents/:name/runtime-tokens', tokenRouteLim // User token and legacy installation copies, so revoke every hash before // issuing the replacement. if (force) { - await recordLegacyKeys(agentUser, installation.installedBy || userId, agentUser.agentRuntimeTokens || []); + await recordLegacyKeys( + agentUser, installation.installedBy || userId, + installation.agentName || name, normalizedInstanceId, + ); await revokeRuntimeTokensForAgent({ agentUser, agentName: installation.agentName || name, @@ -278,7 +293,7 @@ agentTokensRouter.delete('/pods/:podId/agents/:name/runtime-tokens/:tokenId', to return res.status(404).json({ error: 'Runtime token not found' }); } - await recordLegacyKeys(agentUser, installation.installedBy || userId, [record]); + await recordLegacyKeys(agentUser, installation.installedBy || userId, installation.agentName || name, instanceId); await revokeRuntimeTokenEverywhere(record.tokenHash); return res.json({ success: true }); } catch (error) { diff --git a/backend/routes/registry/pod-agents.ts b/backend/routes/registry/pod-agents.ts index 239d6223ea..d0735dbdd3 100644 --- a/backend/routes/registry/pod-agents.ts +++ b/backend/routes/registry/pod-agents.ts @@ -347,7 +347,7 @@ podAgentsRouter.get('/pods/:podId/agents/:name', auth, async (req: any, res: any const memberId = m.userId?.toString?.() || m.toString?.(); return memberId && memberId === userId.toString(); }); - if (!membership && !isCreator) { + if (!membership && !isCreator && !(await isGlobalAdminUser(userId))) { return res.status(403).json({ error: 'Access denied' }); } if (!installation || installation.status === 'uninstalled') { diff --git a/frontend/src/v2/__tests__/useAgentSettings.test.tsx b/frontend/src/v2/__tests__/useAgentSettings.test.tsx index d43ced33df..bfeaec3f8f 100644 --- a/frontend/src/v2/__tests__/useAgentSettings.test.tsx +++ b/frontend/src/v2/__tests__/useAgentSettings.test.tsx @@ -14,12 +14,15 @@ jest.mock('axios', () => { const axios = jest.requireMock('axios').default; const Probe = () => { - const { installation, podId, update } = useAgentSettings('maya-claude', 'default'); + const { installation, podId, update, updatePod } = useAgentSettings('maya-claude', 'default'); return
    {installation?.displayName || 'loading'} + {podId}
    ; }; @@ -42,4 +45,10 @@ test('reads one accessible installation and patches it with the instance id', as { displayName: 'Maya Updated', instanceId: 'default' }, )); expect(screen.getByTestId('pod')).toHaveTextContent('pod-1'); + fireEvent.click(screen.getByRole('button', { name: 'Save pod' })); + await waitFor(() => expect(axios.patch).toHaveBeenCalledWith( + '/api/registry/agents/maya-claude/manage-settings/pods/pod-2', + { wakeOnMessage: true, boardWake: false, heartbeatMinutes: 60 }, + { params: { instanceId: 'default' } }, + )); }); diff --git a/frontend/src/v2/agents/useAgentSettings.ts b/frontend/src/v2/agents/useAgentSettings.ts index 6f54a57198..8905b65aa2 100644 --- a/frontend/src/v2/agents/useAgentSettings.ts +++ b/frontend/src/v2/agents/useAgentSettings.ts @@ -22,6 +22,12 @@ export interface AgentSettingsPatch { [field: string]: unknown; } +export interface AgentPodWakePatch { + wakeOnMessage: boolean; + boardWake: boolean; + heartbeatMinutes: number; +} + interface AgentAccess { displayName: string; pods: Array<{ id: string; name: string }>; @@ -88,5 +94,14 @@ export const useAgentSettings = (agentName: string, instanceId: string) => { return response.data; }, [instanceId, installationUrl, podId, refresh]); - return { access, installation, podId, loading, error, refresh, update }; + const updatePod = useCallback(async (targetPodId: string, patch: AgentPodWakePatch) => { + const response = await axios.patch( + `/api/registry/agents/${encodeURIComponent(agentName)}/manage-settings/pods/${encodeURIComponent(targetPodId)}`, + patch, + { params: { instanceId } }, + ); + return response.data; + }, [agentName, instanceId]); + + return { access, installation, podId, loading, error, refresh, update, updatePod }; }; From ac4915e20df5b60e6783d6b72409f695d929035b Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:55:57 -0700 Subject: [PATCH 5/9] Confirm wrapper reconnect and protect nondefault instances Co-authored-by: forge Agent-Model: codex/gpt-6-sol/medium --- frontend/src/i18n/locales/en.json | 1 + frontend/src/i18n/locales/zh-CN.json | 1 + .../v2/__tests__/V2AgentSettingsPage.test.tsx | 27 ++++++++++++++++--- .../src/v2/agents/V2AgentSettingsPage.tsx | 12 +++++---- 4 files changed, 32 insertions(+), 9 deletions(-) diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 177f46d8c4..f535adb231 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2264,6 +2264,7 @@ "key": "Key", "regenerate": "Regenerate key", "reconnectToRegenerate": "Reconnect to regenerate key", + "reconnectNonDefault": "Reconnect this agent from its Control section to regenerate its key.", "copyNow": "Copy this key now. It will not be shown again.", "done": "Done", "empty": "No runtime keys yet.", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index e213a7c361..0a87a8ca65 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2255,6 +2255,7 @@ "key": "密钥", "regenerate": "重新生成密钥", "reconnectToRegenerate": "重新连接并生成密钥", + "reconnectNonDefault": "请在此智能体的控制区重新连接以生成密钥。", "copyNow": "请立即复制此密钥。之后不会再次显示。", "done": "完成", "empty": "暂无运行时密钥。", diff --git a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx index af27aea585..8ceb1e5b94 100644 --- a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx @@ -14,8 +14,8 @@ jest.mock('axios', () => { }); const axios = jest.requireMock('axios').default; -const renderPage = () => render( - +const renderPage = (path = '/v2/agents/maya-claude/default') => render( + } /> , ); @@ -46,12 +46,31 @@ test('wrapper key replacement goes through reconnect instead of stranding its lo return Promise.resolve({ data: [] }); }); renderPage(); - const reconnect = await screen.findByRole('link', { name: 'Reconnect to regenerate key' }); + const regenerate = await screen.findByRole('button', { name: 'Regenerate key' }); + expect(screen.queryByRole('link', { name: 'Reconnect to regenerate key' })).not.toBeInTheDocument(); + fireEvent.click(regenerate); + expect(screen.getByTestId('agent-key-replace-confirmation')).toHaveTextContent('Replacing it will disconnect'); + const reconnect = screen.getByRole('link', { name: 'Reconnect to regenerate key' }); expect(reconnect).toHaveAttribute('href', '/v2/agents/byo?pod=pod-1&name=maya-claude&reconnect=1&mode=machine'); - expect(screen.queryByRole('button', { name: 'Regenerate key' })).not.toBeInTheDocument(); expect(axios.post).not.toHaveBeenCalled(); }); +test('nondefault wrapper instances never link to a default-instance reconnect', async () => { + 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: 'second', displayName: 'Maya', runtime: { runtimeType: 'wrapper' }, + } } }); + return Promise.resolve({ data: [] }); + }); + renderPage('/v2/agents/maya-claude/second'); + await screen.findByText('Reconnect this agent from its Control section to regenerate its key.'); + expect(screen.queryByRole('link', { name: 'Reconnect to regenerate key' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Regenerate key' })).not.toBeInTheDocument(); +}); + test('regeneration waits for the explicit running-key replacement confirmation', async () => { axios.post.mockResolvedValue({ data: { token: 'cm_agent_once' } }); renderPage(); diff --git a/frontend/src/v2/agents/V2AgentSettingsPage.tsx b/frontend/src/v2/agents/V2AgentSettingsPage.tsx index 110ef2d60b..41950ca149 100644 --- a/frontend/src/v2/agents/V2AgentSettingsPage.tsx +++ b/frontend/src/v2/agents/V2AgentSettingsPage.tsx @@ -127,10 +127,8 @@ const V2AgentSettingsPage: React.FC = () => {

    {t('agentSettings.key')}

    - {installation?.runtime?.runtimeType === 'wrapper' ? ( - - {t('agentSettings.reconnectToRegenerate')} - + {installation?.runtime?.runtimeType === 'wrapper' && instanceId !== 'default' ? ( + {t('agentSettings.reconnectNonDefault')} ) : ( + {installation?.runtime?.runtimeType === 'wrapper' ? ( + + {t('agentSettings.reconnectToRegenerate')} + + ) : }
From 94511c774488510f723d1c0ce930bf47b787b3f8 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:57:48 -0700 Subject: [PATCH 6/9] Expose safe environment controls for agent settings Co-authored-by: forge Agent-Model: codex/gpt-6-sol/medium --- ...gistry.agent-config-installer-gate.test.js | 20 +++++++++++++++++++ .../registry.description-and-userid.test.js | 9 +++++++++ backend/routes/registry/agent-config.ts | 9 +++++++++ backend/routes/registry/helpers.ts | 10 ++++++++++ frontend/src/i18n/locales/en.json | 3 +-- frontend/src/i18n/locales/zh-CN.json | 3 +-- .../v2/__tests__/V2AgentSettingsPage.test.tsx | 11 ++++------ .../src/v2/agents/V2AgentSettingsPage.tsx | 10 +++------- 8 files changed, 57 insertions(+), 18 deletions(-) diff --git a/backend/__tests__/unit/routes/registry.agent-config-installer-gate.test.js b/backend/__tests__/unit/routes/registry.agent-config-installer-gate.test.js index 9a33032f2d..44039bb2a6 100644 --- a/backend/__tests__/unit/routes/registry.agent-config-installer-gate.test.js +++ b/backend/__tests__/unit/routes/registry.agent-config-installer-gate.test.js @@ -288,6 +288,26 @@ describe('agent config PATCH — installer gate', () => { expect(primary.save).toHaveBeenCalledTimes(1); }); + it('changes environment model controls without deleting hidden MCP or workspace declarations', async () => { + const existingEnvironment = { + model: 'old-model', workspace: { path: './seat' }, + mcp: [{ name: 'trusted', command: ['true'] }], + }; + const primary = installation({ installedBy: MEMBER, config: new Map([['environment', existingEnvironment]]) }); + setPod(); + AgentInstallation.findOne.mockResolvedValue(primary); + AgentInstallation.find.mockResolvedValue([primary]); + + const res = await request(app) + .patch('/api/registry/pods/pod-1/agents/openclaw') + .send({ instanceId: 'curator', config: { environment: { model: 'new-model', effort: 'high' } } }); + + expect(res.status).toBe(200); + expect(primary.config.get('environment')).toEqual({ + ...existingEnvironment, model: 'new-model', effort: 'high', + }); + }); + it('still refuses a non-admin non-member before any of that', async () => { const primary = installation(); Pod.findById.mockReturnValue({ diff --git a/backend/__tests__/unit/routes/registry.description-and-userid.test.js b/backend/__tests__/unit/routes/registry.description-and-userid.test.js index 120c13ef2a..9bd8208835 100644 --- a/backend/__tests__/unit/routes/registry.description-and-userid.test.js +++ b/backend/__tests__/unit/routes/registry.description-and-userid.test.js @@ -49,4 +49,13 @@ describe('agent listing description + principal id', () => { .toBe(resolveAgentDisplayLabel(user, user.username)); } }); + + test('settings projects effective environment model controls without secrets', () => { + const payload = buildAgentInstallationPayload({ ...install, config: new Map([['environment', { + model: 'openai/gpt-5', effort: 'high', adapter: 'codex', + provider: { apiKey: 'secret' }, mcp: [{ env: { TOKEN: 'secret' } }], + }]]) }); + expect(payload.config.environment).toEqual({ model: 'openai/gpt-5', effort: 'high', adapter: 'codex' }); + expect(JSON.stringify(payload)).not.toContain('secret'); + }); }); diff --git a/backend/routes/registry/agent-config.ts b/backend/routes/registry/agent-config.ts index 4252b72e59..91027cfcbe 100644 --- a/backend/routes/registry/agent-config.ts +++ b/backend/routes/registry/agent-config.ts @@ -169,6 +169,15 @@ agentConfigRouter.patch('/pods/:podId/agents/:name', auth, async (req: any, res: if (config) { const existingConfig = normalizeConfigMap(targetInstallation.config) || {}; const nextConfig = { ...existingConfig, ...config }; + // The Settings model editor receives only these safe fields, never + // the full ADR-008 declaration. Preserve MCP, workspace and sandbox + // values when it changes a model/effort/adapter control. + const environmentControls = config.environment; + if (environmentControls && typeof environmentControls === 'object' + && !Array.isArray(environmentControls) + && Object.keys(environmentControls).every((key) => ['model', 'effort', 'adapter'].includes(key))) { + nextConfig.environment = { ...(existingConfig.environment || {}), ...environmentControls }; + } if (config.runtime && typeof config.runtime === 'object') { nextConfig.runtime = runtimeKeepingType(config.runtime, existingConfig, { mayChangeType: isInstanceAdmin }); } diff --git a/backend/routes/registry/helpers.ts b/backend/routes/registry/helpers.ts index 67a4f5a1fe..f1dcf10d17 100644 --- a/backend/routes/registry/helpers.ts +++ b/backend/routes/registry/helpers.ts @@ -401,6 +401,15 @@ const buildAgentInstallationPayload = (installation: any, { normalizedConfig?.runtime || installation.config?.runtime || null, installation.agentName, ); + // Settings may edit these model controls, and the daemon prefers an explicit + // ADR-008 environment over the runtime overlay. Expose only these benign + // scalar fields; never project environment secrets or tool declarations. + const environmentSettings = normalizedConfig?.environment; + const editableEnvironment = environmentSettings && typeof environmentSettings === 'object' + ? Object.fromEntries(['model', 'effort', 'adapter'] + .filter((key) => typeof environmentSettings[key] === 'string') + .map((key) => [key, environmentSettings[key]])) + : {}; // Chat attributes this seat through the User principal. Use that same // resolver for the listing; scoped names are only a fallback for installs // that have no User row yet. @@ -464,6 +473,7 @@ const buildAgentInstallationPayload = (installation: any, { errorRouting: normalizedConfig.errorRouting || null, heartbeatChecklist: normalizedConfig.heartbeatChecklist || '', skillSync: normalizedConfig.skillSync || null, + environment: editableEnvironment, } : null, profile: profile ? { diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index f535adb231..f823ac08b7 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2263,8 +2263,7 @@ "settings": "Settings", "key": "Key", "regenerate": "Regenerate key", - "reconnectToRegenerate": "Reconnect to regenerate key", - "reconnectNonDefault": "Reconnect this agent from its Control section to regenerate its key.", + "wrapperReconnect": "Regenerate this key from the agent's Control section.", "copyNow": "Copy this key now. It will not be shown again.", "done": "Done", "empty": "No runtime keys yet.", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 0a87a8ca65..55a5fc2bbf 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2254,8 +2254,7 @@ "settings": "设置", "key": "密钥", "regenerate": "重新生成密钥", - "reconnectToRegenerate": "重新连接并生成密钥", - "reconnectNonDefault": "请在此智能体的控制区重新连接以生成密钥。", + "wrapperReconnect": "请在此智能体的控制区重新生成密钥。", "copyNow": "请立即复制此密钥。之后不会再次显示。", "done": "完成", "empty": "暂无运行时密钥。", diff --git a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx index 8ceb1e5b94..84c80a6e20 100644 --- a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx @@ -35,7 +35,7 @@ beforeEach(() => { }); }); -test('wrapper key replacement goes through reconnect instead of stranding its local key', async () => { +test('wrapper key replacement cannot bypass the daemon reconnect flow', async () => { axios.get.mockImplementation((url) => { if (url.startsWith('/api/agent-memory/')) return Promise.resolve({ data: { displayName: 'Maya', pods: [{ id: 'pod-1', name: 'Workspace' }], @@ -46,12 +46,9 @@ test('wrapper key replacement goes through reconnect instead of stranding its lo return Promise.resolve({ data: [] }); }); renderPage(); - const regenerate = await screen.findByRole('button', { name: 'Regenerate key' }); + await screen.findByText("Regenerate this key from the agent's Control section."); + expect(screen.queryByRole('button', { name: 'Regenerate key' })).not.toBeInTheDocument(); expect(screen.queryByRole('link', { name: 'Reconnect to regenerate key' })).not.toBeInTheDocument(); - fireEvent.click(regenerate); - expect(screen.getByTestId('agent-key-replace-confirmation')).toHaveTextContent('Replacing it will disconnect'); - const reconnect = screen.getByRole('link', { name: 'Reconnect to regenerate key' }); - expect(reconnect).toHaveAttribute('href', '/v2/agents/byo?pod=pod-1&name=maya-claude&reconnect=1&mode=machine'); expect(axios.post).not.toHaveBeenCalled(); }); @@ -66,7 +63,7 @@ test('nondefault wrapper instances never link to a default-instance reconnect', return Promise.resolve({ data: [] }); }); renderPage('/v2/agents/maya-claude/second'); - await screen.findByText('Reconnect this agent from its Control section to regenerate its key.'); + await screen.findByText("Regenerate this key from the agent's Control section."); expect(screen.queryByRole('link', { name: 'Reconnect to regenerate key' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Regenerate key' })).not.toBeInTheDocument(); }); diff --git a/frontend/src/v2/agents/V2AgentSettingsPage.tsx b/frontend/src/v2/agents/V2AgentSettingsPage.tsx index 41950ca149..a4c807437f 100644 --- a/frontend/src/v2/agents/V2AgentSettingsPage.tsx +++ b/frontend/src/v2/agents/V2AgentSettingsPage.tsx @@ -127,8 +127,8 @@ const V2AgentSettingsPage: React.FC = () => {

{t('agentSettings.key')}

- {installation?.runtime?.runtimeType === 'wrapper' && instanceId !== 'default' ? ( - {t('agentSettings.reconnectNonDefault')} + {installation?.runtime?.runtimeType === 'wrapper' ? ( + {t('agentSettings.wrapperReconnect')} ) : ( } +
From 091c536bd10a813c4211b95780719699fe6a90a5 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:07:14 -0700 Subject: [PATCH 7/9] Fix agent settings button styling and access test expectations Co-authored-by: forge Agent-Model: codex/gpt-6-sol/medium --- .../registry.runtimeTokenOwnership.test.js | 3 +++ backend/__tests__/utils/leakMatrixSuites.js | 4 ++-- frontend/src/i18n/locales/en.json | 2 +- frontend/src/i18n/locales/zh-CN.json | 2 +- .../v2/__tests__/V2AgentSettingsPage.test.tsx | 4 ++-- .../v2/__tests__/v2-layout-invariants.test.ts | 7 +++++++ frontend/src/v2/agents/v2-agent-settings.css | 16 ++++++++-------- 7 files changed, 24 insertions(+), 14 deletions(-) diff --git a/backend/__tests__/unit/routes/registry.runtimeTokenOwnership.test.js b/backend/__tests__/unit/routes/registry.runtimeTokenOwnership.test.js index d4a48edccb..35b27702f7 100644 --- a/backend/__tests__/unit/routes/registry.runtimeTokenOwnership.test.js +++ b/backend/__tests__/unit/routes/registry.runtimeTokenOwnership.test.js @@ -26,6 +26,9 @@ const matches = (doc, filter) => Object.entries(filter).every(([key, want]) => { }); jest.mock('../../../models/AgentRegistry', () => ({ AgentInstallation: { + find: jest.fn(() => ({ + collation: () => ({ select: () => ({ lean: () => Promise.resolve([]) }) }), + })), findOne: jest.fn((filter) => { const hit = mockInstalls.find((doc) => matches(doc, filter)) || null; const chain = { diff --git a/backend/__tests__/utils/leakMatrixSuites.js b/backend/__tests__/utils/leakMatrixSuites.js index 4605e5fcf7..9ef0890cdc 100644 --- a/backend/__tests__/utils/leakMatrixSuites.js +++ b/backend/__tests__/utils/leakMatrixSuites.js @@ -541,10 +541,10 @@ const registry = { }, cases: [ ...matrix('GET', '/api/registry/pods/:podId/agents/:name', (ctx) => `/api/registry/pods/${ctx.podId}/agents/${REG_AGENT}`, - { owner: 200, member: 200, stranger: 403, admin: 403 }), + { owner: 200, member: 200, stranger: 403, admin: 200 }), ...matrix('GET', '/api/registry/pods/:podId/agents/:name/runtime-tokens', (ctx) => `/api/registry/pods/${ctx.podId}/agents/${REG_AGENT}/runtime-tokens`, - { owner: 200, member: 200, stranger: 403, admin: 403 }), + { owner: 200, member: 403, stranger: 403, admin: 200 }), ...matrix('GET', '/api/registry/pods/:podId/agents/:name/user-token', (ctx) => `/api/registry/pods/${ctx.podId}/agents/${REG_AGENT}/user-token`, { owner: 200, member: 200, stranger: 403, admin: 403 }), diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index f823ac08b7..8bdad2dcd8 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2263,7 +2263,7 @@ "settings": "Settings", "key": "Key", "regenerate": "Regenerate key", - "wrapperReconnect": "Regenerate this key from the agent's Control section.", + "wrapperReconnect": "Key replacement is unavailable here for locally connected agents.", "copyNow": "Copy this key now. It will not be shown again.", "done": "Done", "empty": "No runtime keys yet.", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 55a5fc2bbf..d8fd38c82b 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2254,7 +2254,7 @@ "settings": "设置", "key": "密钥", "regenerate": "重新生成密钥", - "wrapperReconnect": "请在此智能体的控制区重新生成密钥。", + "wrapperReconnect": "此处暂不支持更换本地连接的智能体密钥。", "copyNow": "请立即复制此密钥。之后不会再次显示。", "done": "完成", "empty": "暂无运行时密钥。", diff --git a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx index 84c80a6e20..33ebb51731 100644 --- a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx @@ -46,7 +46,7 @@ test('wrapper key replacement cannot bypass the daemon reconnect flow', async () return Promise.resolve({ data: [] }); }); renderPage(); - await screen.findByText("Regenerate this key from the agent's Control section."); + await screen.findByText('Key replacement is unavailable here for locally connected agents.'); expect(screen.queryByRole('button', { name: 'Regenerate key' })).not.toBeInTheDocument(); expect(screen.queryByRole('link', { name: 'Reconnect to regenerate key' })).not.toBeInTheDocument(); expect(axios.post).not.toHaveBeenCalled(); @@ -63,7 +63,7 @@ test('nondefault wrapper instances never link to a default-instance reconnect', return Promise.resolve({ data: [] }); }); renderPage('/v2/agents/maya-claude/second'); - await screen.findByText("Regenerate this key from the agent's Control section."); + await screen.findByText('Key replacement is unavailable here for locally connected agents.'); expect(screen.queryByRole('link', { name: 'Reconnect to regenerate key' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Regenerate key' })).not.toBeInTheDocument(); }); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 5c3f259423..f7521f2a46 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -185,6 +185,13 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(mediaBlockContaining(agentSettings, '.v2-agent-settings__key-meta')).toContain('display: grid'); }); + test('agent Settings buttons retain their chrome over the v2 button reset', () => { + const button = ruleBody(agentSettings, '.v2-root .v2-agent-settings button:not(.MuiButtonBase-root)'); + expect(button).toContain('border: 1px solid var(--v2-border)'); + expect(button).toContain('background: var(--v2-surface)'); + expect(ruleBody(agentSettings, '.v2-root .v2-agent-settings .v2-agent-settings__section-head button')).toContain('background: var(--v2-ink)'); + }); + test('BYO hints do not add paragraph margins to flex gaps', () => { expect(ruleBody(v2, '.v2-byo__hint')).toContain('margin: 0'); }); diff --git a/frontend/src/v2/agents/v2-agent-settings.css b/frontend/src/v2/agents/v2-agent-settings.css index 84e88d9497..42b702a7e0 100644 --- a/frontend/src/v2/agents/v2-agent-settings.css +++ b/frontend/src/v2/agents/v2-agent-settings.css @@ -28,7 +28,7 @@ .v2-agent-settings__section { padding: 28px 0; } .v2-agent-settings__section-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; } .v2-agent-settings__section h2 { margin: 0; font-family: var(--v2-font-display); font-size: 20px; } -.v2-agent-settings button { +.v2-root .v2-agent-settings button:not(.MuiButtonBase-root) { min-height: 34px; padding: 6px 12px; border: 1px solid var(--v2-border); @@ -40,13 +40,13 @@ font-weight: 600; cursor: pointer; } -.v2-agent-settings button:hover { background: var(--v2-surface-hover); } -.v2-agent-settings button:focus-visible { box-shadow: var(--v2-focus-ring); outline: 1px solid var(--v2-accent); } -.v2-agent-settings button:disabled { opacity: .55; cursor: default; } -.v2-agent-settings__section-head button, -.v2-agent-settings__actions button:first-child { background: var(--v2-ink); color: var(--v2-on-ink); border-color: var(--v2-ink); } -.v2-agent-settings__section-head button:hover, -.v2-agent-settings__actions button:first-child:hover { background: var(--v2-ink-hover); } +.v2-root .v2-agent-settings button:hover { background: var(--v2-surface-hover); } +.v2-root .v2-agent-settings button:focus-visible { box-shadow: var(--v2-focus-ring); outline: 1px solid var(--v2-accent); } +.v2-root .v2-agent-settings button:disabled { opacity: .55; cursor: default; } +.v2-root .v2-agent-settings .v2-agent-settings__section-head button { background: var(--v2-ink); color: var(--v2-on-ink); border-color: var(--v2-ink); } +.v2-root .v2-agent-settings .v2-agent-settings__actions button:first-child { background: var(--v2-ink); color: var(--v2-on-ink); border-color: var(--v2-ink); } +.v2-root .v2-agent-settings .v2-agent-settings__section-head button:hover, +.v2-root .v2-agent-settings .v2-agent-settings__actions button:first-child:hover { background: var(--v2-ink-hover); } .v2-agent-settings__error { color: var(--v2-danger); } .v2-agent-settings__empty { margin-top: 24px; color: var(--v2-text-secondary); } .v2-agent-settings__keys { list-style: none; padding: 0; margin: 20px 0 0; } From 454bb0e46af4cc35584df057f949cc4f0a5e9e10 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:11:49 -0700 Subject: [PATCH 8/9] Expect safe environment projection in registry list test Co-authored-by: forge Agent-Model: codex/gpt-6-sol/medium --- .../__tests__/unit/routes/registry.list-agents-config.test.js | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/__tests__/unit/routes/registry.list-agents-config.test.js b/backend/__tests__/unit/routes/registry.list-agents-config.test.js index 92e86bcaeb..67bd9bcf23 100644 --- a/backend/__tests__/unit/routes/registry.list-agents-config.test.js +++ b/backend/__tests__/unit/routes/registry.list-agents-config.test.js @@ -152,6 +152,7 @@ describe('registry list pod agents config payload', () => { expect(res.body.agents[0].config).toEqual({ presetId: null, customizations: null, + environment: {}, heartbeat: { enabled: true, everyMinutes: 60 }, autonomy: { autoJoinAgentOwnedPods: true }, errorRouting: { ownerDm: true }, From cdadea40a130b28ac8015ea35deebe7234966484 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:17:30 -0700 Subject: [PATCH 9/9] Keep in-flight key actions scoped to current agent page Co-authored-by: forge Agent-Model: codex/gpt-6-sol/medium --- .../v2/__tests__/V2AgentSettingsPage.test.tsx | 39 ++++++++++++++++++- .../src/v2/agents/V2AgentSettingsPage.tsx | 23 +++++++---- 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx index 33ebb51731..0c4a774001 100644 --- a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx @@ -1,7 +1,7 @@ // @ts-nocheck import React from 'react'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, Route, Routes, useNavigate } from 'react-router-dom'; import V2AgentSettingsPage from '../agents/V2AgentSettingsPage'; jest.mock('axios', () => { @@ -86,6 +86,41 @@ test('regeneration waits for the explicit running-key replacement confirmation', expect(await screen.findByTestId('agent-one-time-key')).toHaveTextContent('cm_agent_once'); }); +test('an in-flight replacement never reveals its key on another agent page', async () => { + let finishPost; + axios.post.mockImplementation(() => new Promise((resolve) => { finishPost = resolve; })); + axios.get.mockImplementation((url, options) => { + const other = url.includes('/other/') || options?.params?.agentName === 'other'; + if (url.startsWith('/api/agent-memory/')) return Promise.resolve({ data: { + displayName: other ? 'Other agent' : 'Maya', pods: [{ id: 'pod-1', name: 'Workspace' }], + } }); + if (url.startsWith('/api/registry/pods/')) return Promise.resolve({ data: { agent: { + name: other ? 'other' : 'maya-claude', instanceId: 'default', + displayName: other ? 'Other agent' : 'Maya', runtime: { runtimeType: 'webhook' }, + } } }); + return Promise.resolve({ data: other ? [] : [{ _id: 'tok-1', label: 'Laptop', status: 'active' }] }); + }); + const NextAgent = () => { + const navigate = useNavigate(); + return ; + }; + render( + + + } /> + , + ); + await screen.findByText('Laptop'); + fireEvent.click(screen.getByRole('button', { name: 'Regenerate key' })); + fireEvent.click(screen.getByRole('button', { name: /replace the running agent/i })); + await waitFor(() => expect(axios.post).toHaveBeenCalledTimes(1)); + fireEvent.click(screen.getByRole('button', { name: 'Next agent' })); + await screen.findByText('Other agent'); + await act(async () => { finishPost({ data: { token: 'cm_agent_old_identity' } }); }); + expect(screen.queryByTestId('agent-one-time-key')).not.toBeInTheDocument(); + expect(screen.queryByText('cm_agent_old_identity')).not.toBeInTheDocument(); +}); + test('a denied owner probe renders no key controls or metadata', async () => { axios.get.mockRejectedValue({ response: { status: 403, data: { error: 'Forbidden' } } }); renderPage(); diff --git a/frontend/src/v2/agents/V2AgentSettingsPage.tsx b/frontend/src/v2/agents/V2AgentSettingsPage.tsx index a4c807437f..5c54408f5b 100644 --- a/frontend/src/v2/agents/V2AgentSettingsPage.tsx +++ b/frontend/src/v2/agents/V2AgentSettingsPage.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Link, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import axios from '../../utils/axiosConfig'; @@ -38,6 +38,8 @@ const V2AgentSettingsPage: React.FC = () => { const [busy, setBusy] = useState(false); const [oneTimeKey, setOneTimeKey] = useState(null); const identityKey = `${agentName}:${instanceId}:${podId || ''}`; + const currentIdentity = useRef(identityKey); + currentIdentity.current = identityKey; const keyUrl = useCallback((podId: string) => ( `/api/registry/pods/${encodeURIComponent(podId)}/agents/${encodeURIComponent(agentName)}/runtime-tokens` @@ -57,6 +59,7 @@ const V2AgentSettingsPage: React.FC = () => { setError(null); setOneTimeKey(null); setPendingAction(null); + setBusy(false); if (access && podId) { void fetchKeys().then((rows) => { if (active) { @@ -72,6 +75,7 @@ const V2AgentSettingsPage: React.FC = () => { const replaceKey = async () => { if (!access || !podId || pendingAction !== 'replace' || busy || installation?.runtime?.runtimeType === 'wrapper') return; + const requestIdentity = identityKey; setBusy(true); setError(null); try { @@ -80,32 +84,37 @@ const V2AgentSettingsPage: React.FC = () => { label: 'Agent Settings replacement', force: true, }); + if (currentIdentity.current !== requestIdentity) return; if (!response.data.token) throw new Error(t('agentSettings.missingReplacement')); setOneTimeKey(response.data.token); setPendingAction(null); - setKeys(await fetchKeys()); + const rows = await fetchKeys(); + if (currentIdentity.current === requestIdentity) setKeys(rows); } catch (requestError) { - setError(errorLabel(requestError, t('agentSettings.updateError'))); + if (currentIdentity.current === requestIdentity) setError(errorLabel(requestError, t('agentSettings.updateError'))); } finally { - setBusy(false); + if (currentIdentity.current === requestIdentity) setBusy(false); } }; const revokeKey = async (tokenId: string) => { if (!access || pendingAction !== tokenId || busy) return; + const requestIdentity = identityKey; setBusy(true); setError(null); try { await axios.delete(`/api/credentials/${encodeURIComponent(tokenId)}`, { params: { agentName, instanceId }, }); + if (currentIdentity.current !== requestIdentity) return; setPendingAction(null); setOneTimeKey(null); - setKeys(await fetchKeys()); + const rows = await fetchKeys(); + if (currentIdentity.current === requestIdentity) setKeys(rows); } catch (requestError) { - setError(errorLabel(requestError, t('agentSettings.updateError'))); + if (currentIdentity.current === requestIdentity) setError(errorLabel(requestError, t('agentSettings.updateError'))); } finally { - setBusy(false); + if (currentIdentity.current === requestIdentity) setBusy(false); } };