diff --git a/backend/__tests__/service/runtimeTokenRevoke.test.js b/backend/__tests__/service/runtimeTokenRevoke.test.js index 09b68edadc..c0c7e7707d 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) => { @@ -46,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, @@ -85,6 +87,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 +114,97 @@ 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/credentials?agentName=${AGENT}&instanceId=default`); + expect(history.status).toBe(200); + expect(history.body).toEqual([expect.objectContaining({ + label: 'laptop', status: 'revoked', 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(); + 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('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) + .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('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 () => { @@ -179,6 +273,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/credentials?agentName=${AGENT}&instanceId=default`); + expect(history.status).toBe(200); + expect(history.body).toEqual(expect.arrayContaining([ + expect.objectContaining({ label: 'laptop', status: 'revoked', revokedAt: expect.any(String) }), + expect.objectContaining({ label: 'desktop', status: 'active', 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.agent-config-installer-gate.test.js b/backend/__tests__/unit/routes/registry.agent-config-installer-gate.test.js index d5c4021206..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 @@ -273,7 +273,42 @@ 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('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({ lean: jest.fn().mockResolvedValue({ _id: 'pod-1', createdBy: 'someone-else', members: [] }), 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/__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 }, 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/__tests__/unit/routes/registry.runtimeTokenOwnership.test.js b/backend/__tests__/unit/routes/registry.runtimeTokenOwnership.test.js index 561145879f..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 = { @@ -41,6 +44,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 +105,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/__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/backend/routes/credentials.ts b/backend/routes/credentials.ts index 61483e2287..784a5d8283 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,49 @@ 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 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, + 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 +102,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-config.ts b/backend/routes/registry/agent-config.ts index 649a7dae09..91027cfcbe 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', @@ -170,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/agent-tokens.ts b/backend/routes/registry/agent-tokens.ts index 9f97d59c25..4887aa41ee 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,33 @@ 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, 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, + 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 +124,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,6 +132,9 @@ 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 }) @@ -143,7 +174,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 +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, + installation.agentName || name, normalizedInstanceId, + ); await revokeRuntimeTokensForAgent({ agentUser, agentName: installation.agentName || name, @@ -189,6 +224,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 +265,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 +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, installation.agentName || name, instanceId); await revokeRuntimeTokenEverywhere(record.tokenHash); return res.json({ success: true }); } catch (error) { 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/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/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 55d6fe9070..8bdad2dcd8 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2254,6 +2254,35 @@ "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", + "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.", + "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..d8fd38c82b 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2245,6 +2245,35 @@ "listSeparator": "、", "sentenceSeparator": "" }, + "agentSettings": { + "loading": "正在加载智能体设置…", + "unavailable": "无法打开智能体设置。", + "yourTeam": "你的团队", + "tabs": "智能体标签页", + "profile": "资料", + "settings": "设置", + "key": "密钥", + "regenerate": "重新生成密钥", + "wrapperReconnect": "此处暂不支持更换本地连接的智能体密钥。", + "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..0c4a774001 --- /dev/null +++ b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx @@ -0,0 +1,180 @@ +// @ts-nocheck +import React from 'react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, Route, Routes, useNavigate } from 'react-router-dom'; +import V2AgentSettingsPage from '../agents/V2AgentSettingsPage'; + +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 = (path = '/v2/agents/maya-claude/default') => 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' }], + } }); + 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 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' }], + } }); + 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(); + 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(); +}); + +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('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(); +}); + +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('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(); + expect(await screen.findByRole('alert')).toHaveTextContent('Forbidden'); + expect(screen.queryByRole('button', { name: 'Regenerate key' })).not.toBeInTheDocument(); + 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(); + 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' }, + })); +}); + +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..bfeaec3f8f --- /dev/null +++ b/frontend/src/v2/__tests__/useAgentSettings.test.tsx @@ -0,0 +1,54 @@ +// @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, updatePod } = 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'); + 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/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 29f6fb34a3..f7521f2a46 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,19 @@ 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('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/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..5c54408f5b --- /dev/null +++ b/frontend/src/v2/agents/V2AgentSettingsPage.tsx @@ -0,0 +1,200 @@ +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'; +import V2PageHead from '../components/V2PageHead'; +import { useAgentSettings } from './useAgentSettings'; +import './v2-agent-settings.css'; + +interface RuntimeKey { + _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 => { + 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, installation, podId, loading, error: loadError } = useAgentSettings(agentName, instanceId); + const [keys, setKeys] = useState([]); + 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 currentIdentity = useRef(identityKey); + currentIdentity.current = identityKey; + + const keyUrl = useCallback((podId: string) => ( + `/api/registry/pods/${encodeURIComponent(podId)}/agents/${encodeURIComponent(agentName)}/runtime-tokens` + ), [agentName]); + + const fetchKeys = useCallback(async () => { + const response = await axios.get('/api/credentials', { + params: { agentName, instanceId }, + }); + return response.data || []; + }, [agentName, instanceId]); + + useEffect(() => { + let active = true; + setKeys([]); + setKeysReadyFor(null); + setError(null); + setOneTimeKey(null); + setPendingAction(null); + setBusy(false); + if (access && podId) { + void fetchKeys().then((rows) => { + if (active) { + setKeys(rows); + setKeysReadyFor(identityKey); + } + }).catch((requestError) => { + if (active) setError(errorLabel(requestError, t('agentSettings.unavailable'))); + }); + } + return () => { active = false; }; + }, [access, podId, fetchKeys, identityKey, t]); + + const replaceKey = async () => { + if (!access || !podId || pendingAction !== 'replace' || busy || installation?.runtime?.runtimeType === 'wrapper') return; + const requestIdentity = identityKey; + setBusy(true); + setError(null); + try { + const response = await axios.post<{ token?: string }>(keyUrl(podId), { + instanceId, + 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); + const rows = await fetchKeys(); + if (currentIdentity.current === requestIdentity) setKeys(rows); + } catch (requestError) { + if (currentIdentity.current === requestIdentity) setError(errorLabel(requestError, t('agentSettings.updateError'))); + } finally { + 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); + const rows = await fetchKeys(); + if (currentIdentity.current === requestIdentity) setKeys(rows); + } catch (requestError) { + if (currentIdentity.current === requestIdentity) setError(errorLabel(requestError, t('agentSettings.updateError'))); + } finally { + if (currentIdentity.current === requestIdentity) setBusy(false); + } + }; + + if (loading) return
{t('agentSettings.loading')}
; + if (!access || !podId) return
+ {error || errorLabel(loadError, t('agentSettings.unavailable'))} +
; + if (keysReadyFor !== identityKey) return
+ {error || t('agentSettings.loading')} +
; + + return ( +
+ {t('agentSettings.yourTeam')}} /> + +
+
+

{t('agentSettings.key')}

+ {installation?.runtime?.runtimeType === 'wrapper' ? ( + {t('agentSettings.wrapperReconnect')} + ) : ( + + )} +
+ {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.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.status === 'revoked' && {t('agentSettings.revokedAt', { date: dateLabel(key.revokedAt, t) })}} +
    + {key.status === 'active' && pendingAction === key._id && ( +
    +

    {t('agentSettings.revokeWarning')}

    +
    + + +
    +
    + )} + {key.status === 'active' && pendingAction !== key._id && ( + + )} +
  • + ))} +
+ )} +
+
+ ); +}; + +export default V2AgentSettingsPage; diff --git a/frontend/src/v2/agents/useAgentSettings.ts b/frontend/src/v2/agents/useAgentSettings.ts new file mode 100644 index 0000000000..8905b65aa2 --- /dev/null +++ b/frontend/src/v2/agents/useAgentSettings.ts @@ -0,0 +1,107 @@ +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; +} + +export interface AgentPodWakePatch { + wakeOnMessage: boolean; + boardWake: boolean; + heartbeatMinutes: number; +} + +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]); + + 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 }; +}; 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..42b702a7e0 --- /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-root .v2-agent-settings button:not(.MuiButtonBase-root) { + 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-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; } +.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; } +}