diff --git a/backend/__tests__/unit/middleware/agentRuntimeAuth.test.js b/backend/__tests__/unit/middleware/agentRuntimeAuth.test.js index f0598a3fe6..02535a5bf9 100644 --- a/backend/__tests__/unit/middleware/agentRuntimeAuth.test.js +++ b/backend/__tests__/unit/middleware/agentRuntimeAuth.test.js @@ -20,6 +20,22 @@ const mockInstallationFind = jest.fn(); const mockInstallationUpdateOne = jest.fn(); const mockCompleteStarterTask = jest.fn(); const mockMarkByoConnectionAttempt = jest.fn(); +const mockNarrowedPods = jest.fn(); +const mockEnsembleFindById = jest.fn(); +const mockMessageFindById = jest.fn(); +const mockCredentialFindOne = jest.fn(); +const mockCredentialFindById = jest.fn(); + +jest.mock('../../../models/AgentEnsembleState', () => ({ + findById: (...args) => mockEnsembleFindById(...args), +})); +jest.mock('../../../models/pg/Message', () => ({ + findById: (...args) => mockMessageFindById(...args), +})); + +jest.mock('../../../services/nativeOutsiderGrantPolicy', () => ({ + podsWithOutsiderWritableGrants: (...args) => mockNarrowedPods(...args), +})); jest.mock('../../../services/starterTaskService', () => ({ completeConnectAgentStarterTask: (...args) => mockCompleteStarterTask(...args), @@ -39,8 +55,8 @@ jest.mock('../../../models/User', () => { // legacy fallback; the credential paths have their own suite // (agentCredential.substrate.test.js on mongodb-memory-server). jest.mock('../../../models/AgentCredential', () => ({ - findOne: jest.fn().mockResolvedValue(null), - findById: jest.fn().mockResolvedValue(null), + findOne: (...args) => mockCredentialFindOne(...args), + findById: (...args) => mockCredentialFindById(...args), updateOne: jest.fn().mockResolvedValue({}), })); @@ -72,6 +88,12 @@ const buildRes = () => { }; beforeEach(() => { + mockNarrowedPods.mockReset().mockResolvedValue(new Set()); + mockEnsembleFindById.mockReset(); + mockMessageFindById.mockReset(); + mockCredentialFindOne.mockReset().mockResolvedValue(null); + mockCredentialFindById.mockReset(); + require('../../../models/AgentCredential').updateOne.mockClear(); mockSecret.hash.mockReset(); mockSecret.hash.mockReturnValue('hashed-token'); mockUserFindOne.mockReset(); @@ -85,7 +107,342 @@ beforeEach(() => { mockMarkByoConnectionAttempt.mockReset(); }); +describe('TASK-213 runtime token narrowing', () => { + const setupLegacy = () => { + mockUserFindOne.mockResolvedValue(null); + const installation = { + _id: 'install-a', agentName: 'codex', instanceId: 'rivet', + podId: { toString: () => 'pod-a' }, runtimeTokens: [{ tokenHash: 'hashed-token', lastUsedAt: new Date() }], + }; + mockInstallationFindOne.mockResolvedValue(installation); + mockInstallationFind.mockResolvedValue([installation]); + mockInstallationUpdateOne.mockResolvedValue({}); + return installation; + }; + + test('a refused first legacy call does not consume first-use completion; an allowed call completes it once', async () => { + const installation = setupLegacy(); + installation.installedBy = 'human-a'; + installation.runtimeTokens[0].lastUsedAt = undefined; + mockInstallationUpdateOne.mockImplementation(async () => { + installation.runtimeTokens[0].lastUsedAt = new Date(); + }); + mockNarrowedPods.mockResolvedValue(new Set(['pod-a'])); + const req = buildReq('cm_agent_test'); + Object.assign(req, { method: 'POST', baseUrl: '/api/agents/runtime', + route: { path: '/agent-dm' }, params: {} }); + const res = buildRes(); + const next = jest.fn(); + + await agentRuntimeAuth(req, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(mockInstallationUpdateOne).not.toHaveBeenCalled(); + expect(mockMarkByoConnectionAttempt).not.toHaveBeenCalled(); + expect(mockCompleteStarterTask).not.toHaveBeenCalled(); + + req.route.path = '/pods/:podId/messages'; + req.params.podId = 'pod-a'; + await agentRuntimeAuth(req, res, next); + await agentRuntimeAuth(req, res, next); + expect(next).toHaveBeenCalledTimes(2); + expect(mockMarkByoConnectionAttempt).toHaveBeenCalledTimes(1); + expect(mockCompleteStarterTask).toHaveBeenCalledTimes(1); + }); + + test('a refused first User-row call leaves its starter completion for an allowed call', async () => { + const bot = { + _id: 'bot-a', isBot: true, + botMetadata: { agentName: 'codex', instanceId: 'rivet' }, + agentRuntimeTokens: [{ tokenHash: 'hashed-token' }], + }; + mockUserFindOne.mockResolvedValue(bot); + mockUserUpdateOne.mockImplementation(async () => { + bot.agentRuntimeTokens[0].lastUsedAt = new Date(); + }); + mockInstallationFind.mockReturnValue({ lean: async () => [{ + podId: { toString: () => 'pod-a' }, installedBy: 'human-a', status: 'active', + }] }); + mockNarrowedPods.mockResolvedValue(new Set(['pod-a'])); + const req = buildReq('cm_agent_test'); + Object.assign(req, { method: 'POST', baseUrl: '/api/agents/runtime', + route: { path: '/agent-dm' }, params: {} }); + const res = buildRes(); + const next = jest.fn(); + + await agentRuntimeAuth(req, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(mockUserUpdateOne).not.toHaveBeenCalled(); + expect(mockMarkByoConnectionAttempt).not.toHaveBeenCalled(); + expect(mockCompleteStarterTask).not.toHaveBeenCalled(); + + req.route.path = '/pods/:podId/messages'; + req.params.podId = 'pod-a'; + await agentRuntimeAuth(req, res, next); + await agentRuntimeAuth(req, res, next); + expect(next).toHaveBeenCalledTimes(2); + expect(mockMarkByoConnectionAttempt).toHaveBeenCalledTimes(1); + expect(mockCompleteStarterTask).toHaveBeenCalledTimes(1); + }); + + test('allows a write into the one grant pod and refuses another pod', async () => { + setupLegacy(); + mockNarrowedPods.mockResolvedValue(new Set(['pod-a'])); + const req = buildReq('cm_agent_test'); + Object.assign(req, { method: 'POST', baseUrl: '/api/agents/runtime', + route: { path: '/pods/:podId/messages' }, params: { podId: 'pod-b' } }); + const res = buildRes(); + const next = jest.fn(); + await agentRuntimeAuth(req, res, next); + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'outsider_grant_runtime_narrowed' })); + req.params.podId = 'pod-a'; + await agentRuntimeAuth(req, res, next); + expect(next).toHaveBeenCalledTimes(1); + }); + + test('unknown GET fails closed, but listed read passes', async () => { + setupLegacy(); + mockNarrowedPods.mockResolvedValue(new Set(['pod-a'])); + const req = buildReq('cm_agent_test'); + Object.assign(req, { method: 'GET', baseUrl: '/api/agents/runtime', + route: { path: '/future-read' }, params: {} }); + const res = buildRes(); + const next = jest.fn(); + await agentRuntimeAuth(req, res, next); + expect(next).not.toHaveBeenCalled(); + req.route.path = '/installations'; + await agentRuntimeAuth(req, res, next); + expect(next).toHaveBeenCalledTimes(1); + }); + + test('a post without a pod and an outward DM are refused', async () => { + setupLegacy(); + mockNarrowedPods.mockResolvedValue(new Set(['pod-a'])); + const req = buildReq('cm_agent_test'); + Object.assign(req, { method: 'POST', baseUrl: '/api/agents/runtime', + route: { path: '/posts' }, params: {}, body: { content: 'x' } }); + const res = buildRes(); + const next = jest.fn(); + await agentRuntimeAuth(req, res, next); + req.route.path = '/agent-dm'; + await agentRuntimeAuth(req, res, next); + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledTimes(2); + expect(res.status).toHaveBeenCalledWith(403); + }); + + test('ensemble path in P cannot write into an ensemble stored in Q', async () => { + setupLegacy(); + mockNarrowedPods.mockResolvedValue(new Set(['pod-a'])); + mockEnsembleFindById.mockReturnValue({ select: () => ({ lean: async () => ({ podId: 'pod-b' }) }) }); + const req = buildReq('cm_agent_test'); + Object.assign(req, { method: 'POST', baseUrl: '/api/pods', + route: { path: '/:podId/ensemble/response' }, params: { podId: 'pod-a' }, + body: { ensembleId: 'ensemble-b', content: 'cross-pod' } }); + const res = buildRes(); + const next = jest.fn(); + await agentRuntimeAuth(req, res, next); + expect(mockEnsembleFindById).toHaveBeenCalledWith('ensemble-b'); + expect(res.status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + }); + + test('failed route pod lookup refuses a write even when one grant pod is known', async () => { + setupLegacy(); + mockNarrowedPods.mockResolvedValue(new Set(['pod-a'])); + mockEnsembleFindById.mockReturnValue({ select: () => ({ lean: async () => { + throw new Error('ensemble DB unavailable'); + } }) }); + const req = buildReq('cm_agent_test'); + Object.assign(req, { method: 'POST', baseUrl: '/api/pods', + route: { path: '/:podId/ensemble/response' }, params: { podId: 'pod-a' }, + body: { ensembleId: 'ensemble-a', content: 'write' } }); + const res = buildRes(); + const next = jest.fn(); + const log = jest.spyOn(console, 'error').mockImplementation(() => {}); + try { + await agentRuntimeAuth(req, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + code: 'outsider_grant_runtime_narrowed', routeClass: 'unknown', + })); + expect(next).not.toHaveBeenCalled(); + } finally { log.mockRestore(); } + }); + + test('failed grant census narrows the agent', async () => { + setupLegacy(); + mockNarrowedPods.mockRejectedValue(new Error('grant DB unavailable')); + const req = buildReq('cm_agent_test'); + Object.assign(req, { method: 'POST', baseUrl: '/api/agents/runtime', + route: { path: '/agent-dm' }, params: {} }); + const res = buildRes(); + const next = jest.fn(); + const log = jest.spyOn(console, 'error').mockImplementation(() => {}); + try { + await agentRuntimeAuth(req, res, next); + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + } finally { log.mockRestore(); } + }); + + test('two grant pods refuse even a contained write, while revocation lifts the gate', async () => { + setupLegacy(); + mockInstallationFind.mockResolvedValue([ + { podId: { toString: () => 'pod-a' } }, + { podId: { toString: () => 'pod-b' } }, + ]); + mockNarrowedPods.mockResolvedValueOnce(new Set(['pod-a', 'pod-b'])) + .mockResolvedValueOnce(new Set()); + const req = buildReq('cm_agent_test'); + Object.assign(req, { method: 'POST', baseUrl: '/api/agents/runtime', + route: { path: '/pods/:podId/messages' }, params: { podId: 'pod-a' } }); + const res = buildRes(); + const next = jest.fn(); + await agentRuntimeAuth(req, res, next); + expect(next).not.toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + code: 'outsider_grant_runtime_narrowed', podIds: ['pod-a', 'pod-b'], + })); + await agentRuntimeAuth(req, res, next); + expect(next).toHaveBeenCalledTimes(1); + }); + + test('two grant pods still permit a private-only memory update', async () => { + setupLegacy(); + mockInstallationFind.mockResolvedValue([ + { podId: { toString: () => 'pod-a' } }, + { podId: { toString: () => 'pod-b' } }, + ]); + mockNarrowedPods.mockResolvedValue(new Set(['pod-a', 'pod-b'])); + const req = buildReq('cm_agent_test'); + Object.assign(req, { method: 'PUT', baseUrl: '/api/agents/runtime', + route: { path: '/memory' }, params: {}, + body: { sections: { long_term: { visibility: 'private', content: 'x' } } } }); + const res = buildRes(); + const next = jest.fn(); + await agentRuntimeAuth(req, res, next); + expect(next).toHaveBeenCalledTimes(1); + expect(res.status).not.toHaveBeenCalled(); + }); + + test('two grant pods refuse private memory metadata visible to other agents', async () => { + setupLegacy(); + mockInstallationFind.mockResolvedValue([ + { podId: { toString: () => 'pod-a' } }, + { podId: { toString: () => 'pod-b' } }, + ]); + mockNarrowedPods.mockResolvedValue(new Set(['pod-a', 'pod-b'])); + for (const [method, routePath, mode] of [ + ['PUT', '/memory', undefined], + ['POST', '/memory/sync', 'patch'], + ]) { + const req = buildReq('cm_agent_test'); + Object.assign(req, { method, baseUrl: '/api/agents/runtime', + route: { path: routePath }, params: {}, + body: { sections: { long_term: { visibility: 'private', content: 'x' } }, + sourceRuntime: 'caller-controlled', ...(mode ? { mode } : {}) } }); + const res = buildRes(); + const next = jest.fn(); + await agentRuntimeAuth(req, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + } + }); + + test('claim release uses the stored message pod despite a forged body pod', async () => { + setupLegacy(); + mockNarrowedPods.mockResolvedValue(new Set(['pod-a'])); + const req = buildReq('cm_agent_test'); + Object.assign(req, { method: 'DELETE', baseUrl: '/api/agents/runtime', + route: { path: '/messages/:messageId/claim' }, + params: { messageId: 'message-b' }, body: { podId: 'pod-a' } }); + const res = buildRes(); + const next = jest.fn(); + mockMessageFindById.mockResolvedValue({ pod_id: 'pod-b' }); + await agentRuntimeAuth(req, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + mockMessageFindById.mockResolvedValueOnce({ pod_id: 'pod-a' }); + await agentRuntimeAuth(req, res, next); + expect(next).toHaveBeenCalledTimes(1); + }); + + test.each(['legacy', 'User-row'])('refused %s credential calls do not mark the credential used', async (shape) => { + setupLegacy(); + if (shape === 'User-row') { + mockUserFindOne.mockResolvedValue({ + _id: 'bot-a', isBot: true, + botMetadata: { agentName: 'codex', instanceId: 'rivet' }, + agentRuntimeTokens: [{ tokenHash: 'hashed-token', lastUsedAt: new Date() }], + }); + mockInstallationFind.mockReturnValue({ lean: async () => [{ + podId: { toString: () => 'pod-a' }, status: 'active', + }] }); + } + mockCredentialFindOne.mockResolvedValue({ _id: 'credential-a', kind: 'runtime', status: 'active' }); + mockNarrowedPods.mockResolvedValue(new Set(['pod-a'])); + const req = buildReq('cm_agent_test'); + Object.assign(req, { method: 'POST', baseUrl: '/api/agents/runtime', + route: { path: '/agent-dm' }, params: {} }); + const res = buildRes(); + const next = jest.fn(); + const credentialUpdate = require('../../../models/AgentCredential').updateOne; + await agentRuntimeAuth(req, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(credentialUpdate).not.toHaveBeenCalled(); + req.route.path = '/pods/:podId/messages'; + req.params.podId = 'pod-a'; + await agentRuntimeAuth(req, res, next); + expect(next).toHaveBeenCalledTimes(1); + expect(credentialUpdate).toHaveBeenCalledWith( + { _id: 'credential-a' }, { $set: { lastUsedAt: expect.any(Date) } }, + ); + }); + + test('spawn maintenance and event acknowledgement remain available across two grant pods', async () => { + setupLegacy(); + mockInstallationFind.mockResolvedValue([ + { podId: { toString: () => 'pod-a' } }, + { podId: { toString: () => 'pod-b' } }, + ]); + mockNarrowedPods.mockResolvedValue(new Set(['pod-a', 'pod-b'])); + const req = buildReq('cm_agent_test'); + Object.assign(req, { method: 'POST', baseUrl: '/api/agents/runtime/spawn-credentials', + route: { path: '/' }, params: {} }); + const res = buildRes(); + const next = jest.fn(); + await agentRuntimeAuth(req, res, next); + expect(next).toHaveBeenCalledTimes(1); + req.baseUrl = '/api/agents/runtime'; + req.route.path = '/events/:id/ack'; + await agentRuntimeAuth(req, res, next); + expect(next).toHaveBeenCalledTimes(2); + expect(res.status).not.toHaveBeenCalled(); + }); +}); + describe('agentRuntimeAuth path 2 (install-bound token) — #66 fix', () => { + test('a default-instance legacy token enumerates older installations with no stored instanceId', async () => { + mockUserFindOne.mockResolvedValue(null); + const matched = { _id: 'install-a', agentName: 'scout', instanceId: 'default', + podId: { toString: () => 'pod-a' }, runtimeTokens: [{ tokenHash: 'hashed-token', lastUsedAt: new Date() }] }; + const older = { _id: 'install-b', agentName: 'scout', + podId: { toString: () => 'pod-b' }, runtimeTokens: [] }; + mockInstallationFindOne.mockResolvedValue(matched); + mockInstallationFind.mockResolvedValue([matched, older]); + mockInstallationUpdateOne.mockResolvedValue({}); + const req = buildReq('cm_agent_test'); + const next = jest.fn(); + await agentRuntimeAuth(req, buildRes(), next); + expect(mockInstallationFind).toHaveBeenCalledWith({ + agentName: 'scout', $or: [{ instanceId: 'default' }, { instanceId: null }], status: 'active', + }); + expect(req.agentAuthorizedPodIds).toEqual(['pod-a', 'pod-b']); + expect(next).toHaveBeenCalled(); + }); + test('surfaces ALL active installations for the same (agentName, instanceId), not just the matched one', async () => { mockUserFindOne.mockResolvedValue(null); const matchedInstall = { @@ -159,6 +516,72 @@ describe('agentRuntimeAuth path 1 (User-row token) — first-use starter hook (# agentRuntimeTokens: [{ tokenHash: 'hashed-token', ...(lastUsedAt ? { lastUsedAt } : {}) }], }); + test('default-instance User token includes legacy installation pods in its grant census', async () => { + mockUserFindOne.mockResolvedValue(buildAgentUser(new Date('2026-08-12T00:00:00Z'))); + mockInstallationFind.mockReturnValue({ lean: async () => [ + { podId: { toString: () => 'pod-a' }, instanceId: 'default' }, + { podId: { toString: () => 'pod-b' } }, + ] }); + const next = jest.fn(); + await agentRuntimeAuth(buildReq('cm_agent_test'), buildRes(), next); + expect(mockInstallationFind).toHaveBeenCalledWith({ + agentName: 'my-byo-agent', $or: [{ instanceId: 'default' }, { instanceId: null }], status: 'active', + }); + expect(mockNarrowedPods).toHaveBeenCalledWith(['pod-a', 'pod-b']); + expect(next).toHaveBeenCalled(); + }); + + const narrowedRoute = () => { + const req = buildReq('cm_agent_test'); + Object.assign(req, { method: 'POST', baseUrl: '/api/agents/runtime', + route: { path: '/agent-dm' }, params: {} }); + mockInstallationFind.mockReturnValue({ + lean: async () => [{ podId: { toString: () => 'pod-workspace' }, status: 'active' }], + }); + mockNarrowedPods.mockResolvedValue(new Set(['pod-workspace'])); + return req; + }; + + test('refuses an outward call for an embedded User agent token', async () => { + const bot = buildAgentUser(new Date('2026-08-12T00:00:00Z')); + mockUserFindOne.mockResolvedValue(bot); + const req = narrowedRoute(); + const res = buildRes(); + const next = jest.fn(); + + await agentRuntimeAuth(req, res, next); + + expect(req.agentUser).toBe(bot); + expect(req.agentCredential).toBeNull(); + expect(mockInstallationFindOne).not.toHaveBeenCalled(); + expect(mockNarrowedPods).toHaveBeenCalledWith(['pod-workspace']); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'outsider_grant_runtime_narrowed' })); + expect(next).not.toHaveBeenCalled(); + }); + + test('refuses an outward call for a spawn-scoped AgentCredential row', async () => { + const bot = { ...buildAgentUser(null), agentRuntimeTokens: [] }; + const child = { _id: 'credential-child', kind: 'runtime', status: 'active', + parentId: 'credential-parent', scopes: ['spawn'], agentUserId: bot._id }; + mockCredentialFindOne.mockResolvedValue(child); + mockCredentialFindById.mockReturnValue({ select: () => ({ lean: async () => ({ status: 'active' }) }) }); + mockUserFindOne.mockResolvedValueOnce(null).mockResolvedValueOnce(bot); + const req = narrowedRoute(); + const res = buildRes(); + const next = jest.fn(); + + await agentRuntimeAuth(req, res, next); + + expect(req.agentUser).toBe(bot); + expect(req.agentCredential).toBe(child); + expect(mockInstallationFindOne).not.toHaveBeenCalled(); + expect(mockNarrowedPods).toHaveBeenCalledWith(['pod-workspace']); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'outsider_grant_runtime_narrowed' })); + expect(next).not.toHaveBeenCalled(); + }); + test('fires once, with the installation podIds and the display label', async () => { mockUserFindOne.mockResolvedValue(buildAgentUser(null)); mockInstallationFind.mockReturnValue({ diff --git a/backend/__tests__/unit/routes/agentsRuntime.claims.test.js b/backend/__tests__/unit/routes/agentsRuntime.claims.test.js index 66f5b5e3c4..fcd55adcc5 100644 --- a/backend/__tests__/unit/routes/agentsRuntime.claims.test.js +++ b/backend/__tests__/unit/routes/agentsRuntime.claims.test.js @@ -140,6 +140,19 @@ describe('claim routes', () => { })); }); + test('an unavailable comment lookup returns 500 without minting a claim', async () => { + const log = jest.spyOn(console, 'error').mockImplementation(() => {}); + mockMessageExists.mockRejectedValue(new Error('MongoServerSelectionError')); + const res = await request(app) + .post('/api/agents/runtime/messages/507f1f77bcf86cd799439011/claim') + .send({ podId: 'p1' }); + expect(res.status).toBe(500); + expect(res.body).toEqual({ error: 'claim_failed' }); + expect(mockClaim).not.toHaveBeenCalled(); + expect(mockTypingStart).not.toHaveBeenCalled(); + log.mockRestore(); + }); + test('missing podId → 400', async () => { const res = await request(app).post('/api/agents/runtime/messages/52907/claim').send({}); expect(res.status).toBe(400); 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 cc548dba91..c01562ae8b 100644 --- a/backend/__tests__/unit/routes/registry.list-agents-config.test.js +++ b/backend/__tests__/unit/routes/registry.list-agents-config.test.js @@ -51,6 +51,10 @@ jest.mock('../../../services/dmService', () => ({ canViewPod: jest.fn().mockResolvedValue(true), })); +jest.mock('../../../services/nativeOutsiderGrantPolicy', () => ({ + agentsWithMultipleOutsiderGrantPods: jest.fn(), +})); + // AgentIdentityService surface used by helpers.ts. Two import shapes are // consumed: (a) the named export `buildAgentUsername` (used by the route // handler to compose user lookup keys) and (b) `.default` (used by @@ -81,6 +85,7 @@ const AgentProfile = require('../../../models/AgentProfile'); const AgentTemplate = require('../../../models/AgentTemplate'); const User = require('../../../models/User'); const { AgentRegistry, AgentInstallation } = require('../../../models/AgentRegistry'); +const { agentsWithMultipleOutsiderGrantPods } = require('../../../services/nativeOutsiderGrantPolicy'); const registryRoutes = require('../../../routes/registry'); const app = express(); @@ -90,9 +95,11 @@ app.use('/api/registry', registryRoutes); describe('registry list pod agents config payload', () => { beforeEach(() => { jest.clearAllMocks(); + agentsWithMultipleOutsiderGrantPods.mockResolvedValue(new Set()); }); it('returns persisted config fields used by agent settings UI', async () => { + agentsWithMultipleOutsiderGrantPods.mockResolvedValue(new Set(['openclaw:x-curator'])); Pod.findById.mockReturnValue({ lean: jest.fn().mockResolvedValue({ _id: 'pod-1', @@ -151,6 +158,11 @@ describe('registry list pod agents config payload', () => { expect(res.status).toBe(200); expect(res.body.agents).toHaveLength(1); + expect(agentsWithMultipleOutsiderGrantPods).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ agentName: 'openclaw', instanceId: 'x-curator' })]), + ); + expect(res.body.agents[0].runtimeNarrowedByMultipleGrantPods).toBe(true); + expect(res.body.agents[0].runtimeNarrowingUnavailable).toBe(false); expect(res.body.agents[0].config).toEqual({ presetId: null, customizations: null, @@ -174,6 +186,17 @@ describe('registry list pod agents config payload', () => { skillEnvKeys: ['github'], })); expect(res.body.agents[0].runtime).not.toHaveProperty('webhookSecret'); + + agentsWithMultipleOutsiderGrantPods.mockRejectedValueOnce(new Error('grant DB unavailable')); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const unavailable = await request(app).get('/api/registry/pods/pod-1/agents'); + expect(unavailable.status).toBe(200); + expect(unavailable.body.agents[0]).toEqual(expect.objectContaining({ + runtimeNarrowedByMultipleGrantPods: false, + runtimeNarrowingUnavailable: true, + })); + } finally { warn.mockRestore(); } }); it('uses the chat principal label when scoped labels differ', async () => { diff --git a/backend/__tests__/unit/services/agentCredential.substrate.test.js b/backend/__tests__/unit/services/agentCredential.substrate.test.js index e4d051ad8d..f1ffcf7818 100644 --- a/backend/__tests__/unit/services/agentCredential.substrate.test.js +++ b/backend/__tests__/unit/services/agentCredential.substrate.test.js @@ -53,8 +53,8 @@ const makeRes = () => { return res; }; -const runAuth = async (rawToken) => { - const req = { header: (h) => (h === 'Authorization' ? `Bearer ${rawToken}` : undefined) }; +const runAuth = async (rawToken, route) => { + const req = { header: (h) => (h === 'Authorization' ? `Bearer ${rawToken}` : undefined), ...route }; const res = makeRes(); let nexted = false; await agentRuntimeAuth(req, res, () => { nexted = true; }); @@ -208,6 +208,49 @@ describe('per-spawn child credentials (TASK-094)', () => { expect(req.agentAuthorizedPodIds).toContain(String(podId)); }); + it('a minted child and its seat keep own-state calls while two grant pods refuse outward and pod writes', async () => { + const { raw, bot, seat } = await makeSeat(); + const { AgentInstallation } = require('../../../models/AgentRegistry'); + const installation = await AgentInstallation.findOne({ agentName: 'testbot', instanceId: 'default' }); + const otherPodId = new mongoose.Types.ObjectId(); + await AgentInstallation.create({ + agentName: 'testbot', instanceId: 'default', podId: otherPodId, + version: '1.0.0', installedBy: bot._id, + }); + const grantPolicy = require('../../../services/nativeOutsiderGrantPolicy'); + const spy = jest.spyOn(grantPolicy, 'podsWithOutsiderWritableGrants') + .mockResolvedValue(new Set([String(installation.podId), String(otherPodId)])); + const outward = { method: 'POST', baseUrl: '/api/agents/runtime', + route: { path: '/agent-dm' }, params: {} }; + const podWrite = { method: 'POST', baseUrl: '/api/agents/runtime', + route: { path: '/pods/:podId/messages' }, params: { podId: String(installation.podId) } }; + const ownAck = { method: 'POST', baseUrl: '/api/agents/runtime', + route: { path: '/events/:id/ack' }, params: { id: 'event-1' } }; + try { + const express = require('express'); + const request = require('supertest'); + const app = express(); + app.use(express.json()); + app.use('/api/agents/runtime/spawn-credentials', require('../../../routes/spawnCredentials')); + const minted = await request(app).post('/api/agents/runtime/spawn-credentials') + .set('Authorization', `Bearer ${raw}`).send({ spawnId: 'spawn-narrowed' }); + expect(minted.status).toBe(201); + const child = await AgentCredential.findById(minted.body.credentialId); + expect(child.scopes).toEqual(['spawn']); + expect(String(child.parentId)).toBe(String(seat._id)); + for (const token of [raw, minted.body.token]) { + expect((await runAuth(token, ownAck)).nexted).toBe(true); + for (const route of [outward, podWrite]) { + const { res, nexted } = await runAuth(token, route); + expect(nexted).toBe(false); + expect(res.statusCode).toBe(403); + expect(res.body.code).toBe('outsider_grant_runtime_narrowed'); + } + } + expect(spy).toHaveBeenCalledTimes(7); + } finally { spy.mockRestore(); } + }); + it('the negative control: a credential row with an agentUserId but no spawn scope is NOT an authentication path', async () => { const owner = new mongoose.Types.ObjectId(); const bot = await makeBot(`cm_agent_${'z'.repeat(32)}`); diff --git a/backend/__tests__/unit/services/messageClaimService.test.js b/backend/__tests__/unit/services/messageClaimService.test.js index 2c5c3fc444..244a0ac956 100644 --- a/backend/__tests__/unit/services/messageClaimService.test.js +++ b/backend/__tests__/unit/services/messageClaimService.test.js @@ -675,7 +675,7 @@ describe('messageExists', () => { await expect(MessageClaimService.messageExists('507f1f77bcf86cd799439011', 'p1')).resolves.toBe(true); // The id is asked of the comment's own store, by the path the comment // actually lives under — `comments._id`, a subdocument of Post. - expect(Post.exists).toHaveBeenCalledWith({ 'comments._id': '507f1f77bcf86cd799439011' }); + expect(Post.exists).toHaveBeenCalledWith({ podId: 'p1', 'comments._id': '507f1f77bcf86cd799439011' }); // ...and NOT of Postgres, which cannot see a comment at all. expect(pool.query).not.toHaveBeenCalled(); // Pod is still required first — the arm does not bypass the scope check @@ -701,23 +701,27 @@ describe('messageExists', () => { await expect(MessageClaimService.messageExists('123456789012345678901234', 'p1')).resolves.toBe(false); // The store was ASKED: this false is an answer about the comment, not a // shape refusal that never reached it. - expect(Post.exists).toHaveBeenCalledWith({ 'comments._id': '123456789012345678901234' }); + expect(Post.exists).toHaveBeenCalledWith({ podId: 'p1', 'comments._id': '123456789012345678901234' }); expect(pool.query).not.toHaveBeenCalled(); }); - test('a store that cannot answer passes the id through — refusing would unguard every woken seat', async () => { - // The direction is the design: enforcement.js:414 turns the route's non-2xx - // into {failOpen: true}, so a refusal for a real comment wake stops the - // race deduping and every woken seat proceeds. An unconfirmed id must - // therefore degrade to the pre-TASK-122 behaviour (dedupe holds, a phantom - // is possible), not to a refusal. + test('a real comment in another pod cannot be claimed under this pod', async () => { + Post.exists.mockImplementation(async (filter) => + (filter.podId === 'pod-b' ? { _id: 'post-b' } : null)); + const commentId = '507f1f77bcf86cd799439011'; + await expect(MessageClaimService.messageExists(commentId, 'pod-a')).resolves.toBe(false); + await expect(MessageClaimService.messageExists(commentId, 'pod-b')).resolves.toBe(true); + expect(Post.exists).toHaveBeenCalledWith({ podId: 'pod-a', 'comments._id': commentId }); + expect(Post.exists).toHaveBeenCalledWith({ podId: 'pod-b', 'comments._id': commentId }); + }); + + test('a store failure stops an unconfirmed comment claim before SQL mutation', async () => { const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); Post.exists.mockRejectedValue(new Error('MongoServerSelectionError')); - await expect(MessageClaimService.messageExists('507f1f77bcf86cd799439011', 'p1')).resolves.toBe(true); + await expect(MessageClaimService.messageExists('507f1f77bcf86cd799439011', 'p1')) + .rejects.toThrow('MongoServerSelectionError'); expect(Post.exists).toHaveBeenCalledTimes(1); - // ...and it SAYS so, on one line, rather than reverting the hardening in - // silence. A stored check that quietly stops checking is the failure mode - // this whole row is about. + expect(pool.query).not.toHaveBeenCalled(); expect(warn).toHaveBeenCalledTimes(1); expect(String(warn.mock.calls[0][0])).toMatch(/comment existence check failed.*MongoServerSelectionError/); warn.mockRestore(); @@ -743,7 +747,7 @@ describe('messageExists', () => { // costing a real comment wake its dedupe (wren 71962 — order matters). await expect(MessageClaimService.messageExists('123456789012345678901234', 'p1')).resolves.toBe(true); // Answered by the comment store, never by the range test above it. - expect(Post.exists).toHaveBeenCalledWith({ 'comments._id': '123456789012345678901234' }); + expect(Post.exists).toHaveBeenCalledWith({ podId: 'p1', 'comments._id': '123456789012345678901234' }); expect(pool.query).not.toHaveBeenCalled(); }); }); diff --git a/backend/__tests__/unit/services/nativeOutsiderGrantPolicy.test.js b/backend/__tests__/unit/services/nativeOutsiderGrantPolicy.test.js index e46ceb98ca..10690ffa70 100644 --- a/backend/__tests__/unit/services/nativeOutsiderGrantPolicy.test.js +++ b/backend/__tests__/unit/services/nativeOutsiderGrantPolicy.test.js @@ -1,11 +1,12 @@ /* eslint-disable global-require, import/no-unresolved, import/extensions */ -const mockPodFindById = jest.fn(); +const mockPodFind = jest.fn(); const mockGrantFind = jest.fn(); const mockConnectionFind = jest.fn(); +const mockInstallationFind = jest.fn(); jest.mock('../../../models/Pod', () => ({ __esModule: true, - default: { findById: (...args) => mockPodFindById(...args) }, + default: { find: (...args) => mockPodFind(...args) }, })); jest.mock('../../../models/RoomGrant', () => ({ __esModule: true, @@ -15,8 +16,11 @@ jest.mock('../../../models/Integration', () => ({ __esModule: true, default: { find: (...args) => mockConnectionFind(...args) }, })); +jest.mock('../../../models/AgentRegistry', () => ({ + AgentInstallation: { find: (...args) => mockInstallationFind(...args) }, +})); -const { podHasOutsiderWritableGrant } = require('../../../services/nativeOutsiderGrantPolicy'); +const { podHasOutsiderWritableGrant, podsWithOutsiderWritableGrants, agentsWithMultipleOutsiderGrantPods } = require('../../../services/nativeOutsiderGrantPolicy'); const POD = '507f1f77bcf86cd799439011'; const SEAT = '507f1f77bcf86cd799439013'; @@ -32,9 +36,10 @@ const grant = (overrides = {}) => ({ beforeEach(() => { jest.clearAllMocks(); - mockPodFindById.mockReturnValue(chain({ members: [SEAT] })); + mockPodFind.mockReturnValue(chain([{ _id: POD, members: [SEAT] }])); mockGrantFind.mockReturnValue(chain([grant()])); - mockConnectionFind.mockReturnValue(chain([{ _id: CONNECTION }])); + mockConnectionFind.mockReturnValue(chain([{ _id: CONNECTION, type: 'hosted-mcp' }])); + mockInstallationFind.mockReturnValue(chain([])); }); test('a live pod grant with a current audience member narrows every native agent in the pod', async () => { @@ -43,7 +48,7 @@ test('a live pod grant with a current audience member narrows every native agent revokedAt: null, expiresAt: { $gt: expect.any(Date) }, $or: expect.arrayContaining([ - { 'target.kind': 'pod', 'target.id': POD }, + { 'target.kind': 'pod', 'target.id': { $in: [POD] } }, { 'target.kind': 'seat', 'target.id': { $in: [SEAT] } }, ]), })); @@ -55,12 +60,65 @@ test('a live pod grant with a current audience member narrows every native agent test('a GitHub App grant may name an installation id rather than a Mongo id', async () => { mockGrantFind.mockReturnValue(chain([grant({ connectionId: 'github-installation-123' })])); + mockConnectionFind.mockReturnValue(chain([{ _id: CONNECTION, type: 'github-app', installationId: 'github-installation-123' }])); expect(await podHasOutsiderWritableGrant(POD)).toBe(true); expect(mockConnectionFind).toHaveBeenCalledWith(expect.objectContaining({ $or: [{ type: 'github-app', installationId: { $in: ['github-installation-123'] } }], })); }); +test('batch census checks multiple installation pods with one query per collection', async () => { + mockPodFind.mockReturnValue(chain([{ _id: POD, members: [SEAT] }, { _id: OTHER, members: [SEAT] }])); + mockGrantFind.mockReturnValue(chain([grant({ target: { kind: 'pod', id: OTHER } })])); + expect(await podsWithOutsiderWritableGrants([POD, OTHER])).toEqual(new Set([OTHER])); + expect(mockPodFind).toHaveBeenCalledTimes(1); + expect(mockGrantFind).toHaveBeenCalledTimes(1); + expect(mockConnectionFind).toHaveBeenCalledTimes(1); +}); + +test('roster marker uses every installation pod, including pods outside the viewer roster', async () => { + mockInstallationFind.mockReturnValue(chain([ + { agentName: 'scout', instanceId: 'default', podId: POD }, + { agentName: 'scout', instanceId: 'default', podId: OTHER }, + ])); + mockPodFind.mockReturnValue(chain([{ _id: POD, members: [SEAT] }, { _id: OTHER, members: [SEAT] }])); + mockGrantFind.mockReturnValue(chain([grant(), grant({ target: { kind: 'pod', id: OTHER } })])); + expect(await agentsWithMultipleOutsiderGrantPods([{ agentName: 'scout', instanceId: 'default' }])) + .toEqual(new Set(['scout:default'])); + expect(mockInstallationFind).toHaveBeenCalledTimes(1); + expect(mockPodFind).toHaveBeenCalledTimes(1); + mockGrantFind.mockReturnValue(chain([grant()])); + expect(await agentsWithMultipleOutsiderGrantPods([{ agentName: 'scout' }])).toEqual(new Set()); +}); + +test('default-instance roster census includes an older row without instanceId', async () => { + mockInstallationFind.mockReturnValue(chain([ + { agentName: 'scout', instanceId: 'default', podId: POD }, + { agentName: 'scout', podId: OTHER }, + ])); + mockPodFind.mockReturnValue(chain([{ _id: POD, members: [SEAT] }, { _id: OTHER, members: [SEAT] }])); + mockGrantFind.mockReturnValue(chain([grant(), grant({ target: { kind: 'pod', id: OTHER } })])); + expect(await agentsWithMultipleOutsiderGrantPods([{ agentName: 'scout' }])) + .toEqual(new Set(['scout:default'])); + expect(mockInstallationFind).toHaveBeenCalledWith({ + status: 'active', + $or: [{ agentName: 'scout', $or: [{ instanceId: 'default' }, { instanceId: null }] }], + }); +}); + +test('roster marker propagates a failed grant census for the unavailable state', async () => { + mockInstallationFind.mockReturnValue(chain([ + { agentName: 'scout', instanceId: 'default', podId: POD }, + { agentName: 'scout', instanceId: 'default', podId: OTHER }, + ])); + mockPodFind.mockReturnValue(chain([{ _id: POD, members: [SEAT] }, { _id: OTHER, members: [SEAT] }])); + mockGrantFind.mockReturnValue({ select: () => ({ lean: async () => { + throw new Error('grant DB unavailable'); + } }) }); + await expect(agentsWithMultipleOutsiderGrantPods([{ agentName: 'scout', instanceId: 'default' }])) + .rejects.toThrow('grant DB unavailable'); +}); + test('a seat grant is in scope only when its seat is a current member and in its audience', async () => { mockGrantFind.mockReturnValue(chain([grant({ target: { kind: 'seat', id: SEAT } })])); expect(await podHasOutsiderWritableGrant(POD)).toBe(true); diff --git a/backend/__tests__/unit/services/runtimeOutsiderGrantRoutes.test.js b/backend/__tests__/unit/services/runtimeOutsiderGrantRoutes.test.js new file mode 100644 index 0000000000..7e3d9dfdd7 --- /dev/null +++ b/backend/__tests__/unit/services/runtimeOutsiderGrantRoutes.test.js @@ -0,0 +1,134 @@ +const fs = require('fs'); +const path = require('path'); +const ts = require('typescript'); + +jest.mock('../../../models/Post', () => ({ findById: jest.fn() })); +jest.mock('../../../models/pg/Message', () => ({ findById: jest.fn() })); +jest.mock('../../../models/AgentAsk', () => ({ findOne: jest.fn() })); +const mockEnsembleFindById = jest.fn(); +jest.mock('../../../models/AgentEnsembleState', () => ({ findById: (...args) => mockEnsembleFindById(...args) })); + +const { runtimeOutsiderGrantRouteKeys, classifyRuntimeOutsiderGrantRoute } = + require('../../../services/runtimeOutsiderGrantRoutes'); + +const routesDir = path.resolve(__dirname, '../../../routes'); +const fileBases = { + agentsRuntime: '/api/agents/runtime', + agentHooks: '/api/agents/runtime', + spawnCredentials: '/api/agents/runtime/spawn-credentials', + agentEnsemble: '/api/pods', + messages: '/api/messages', + artifacts: '/api/artifacts', + uploads: '/api/uploads', + github: '/api/github', + tasksApi: '/api/v1/tasks', + grants: '/api/grants', + mcpGrants: '/api/mcp/grants', +}; +const wrappers = { + agentsRuntime: ['agentRuntimeAuth', 'dualAuth'], + agentHooks: ['agentRuntimeAuth'], spawnCredentials: ['agentRuntimeAuth'], + agentEnsemble: ['agentRuntimeAuth'], messages: ['dualAuth'], + artifacts: ['dualAuth'], uploads: ['maybeAgentRuntimeAuth'], + github: ['anyAuth'], tasksApi: ['auth'], + grants: ['agentRuntimeAuth', 'dualAuth'], mcpGrants: ['agentRuntimeAuth'], +}; + +test('every runtime-token acceptor is in the route table', () => { + const actual = []; + // Discover source files by the middleware they USE, not by a token-prefix + // grep. A new file fails here until its mount and routes are classified. + const sourceFiles = (dir) => fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const file = path.join(dir, entry.name); + return entry.isDirectory() ? sourceFiles(file) : (entry.name.endsWith('.ts') ? [file] : []); + }); + const acceptorFiles = sourceFiles(routesDir).filter((file) => + /require\(['"][^'"]*middleware\/agentRuntimeAuth['"]\)/.test(fs.readFileSync(file, 'utf8'))); + expect(acceptorFiles.map((file) => path.basename(file, '.ts')).sort()) + .toEqual(Object.keys(fileBases).sort()); + for (const [name, base] of Object.entries(fileBases)) { + const source = fs.readFileSync(path.join(routesDir, `${name}.ts`), 'utf8'); + const tree = ts.createSourceFile(`${name}.ts`, source, ts.ScriptTarget.Latest, true); + const walk = (node) => { + if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) + && node.expression.expression.getText(tree) === 'router' + && ['get', 'post', 'put', 'patch', 'delete'].includes(node.expression.name.text) + && ts.isStringLiteral(node.arguments[0])) { + const middleware = node.arguments.slice(1).filter(ts.isIdentifier).map((arg) => arg.text); + if (middleware.some((entry) => wrappers[name].includes(entry))) { + actual.push(`${base} ${node.expression.name.text.toUpperCase()} ${node.arguments[0].text}`); + } + } + ts.forEachChild(node, walk); + }; + walk(tree); + } + expect([...new Set(actual)].sort()).toEqual([...runtimeOutsiderGrantRouteKeys].sort()); +}); + +test('private memory passes, shared memory is outward', async () => { + const req = { + baseUrl: '/api/agents/runtime', method: 'PUT', route: { path: '/memory' }, + body: { sections: { long_term: { visibility: 'private', content: 'x' } } }, + }; + expect((await classifyRuntimeOutsiderGrantRoute(req)).routeClass).toBe('read'); + req.body.sections.long_term.visibility = 'public'; + expect((await classifyRuntimeOutsiderGrantRoute(req)).routeClass).toBe('outward'); +}); + +test('private memory with sourceRuntime is outward for PUT and patch sync', async () => { + for (const [method, routePath, mode] of [ + ['PUT', '/memory', undefined], + ['POST', '/memory/sync', 'patch'], + ]) { + const req = { + baseUrl: '/api/agents/runtime', method, route: { path: routePath }, + body: { + sections: { long_term: { visibility: 'private', content: 'x' } }, + sourceRuntime: 'caller-controlled', + ...(mode ? { mode } : {}), + }, + }; + expect((await classifyRuntimeOutsiderGrantRoute(req)).routeClass).toBe('outward'); + delete req.body.sourceRuntime; + expect((await classifyRuntimeOutsiderGrantRoute(req)).routeClass).toBe('read'); + } +}); + +test('claim release uses the stored message pod, not a forged body pod', async () => { + const Message = require('../../../models/pg/Message'); + Message.findById.mockResolvedValue({ pod_id: 'pod-b' }); + const req = { + baseUrl: '/api/agents/runtime', method: 'DELETE', + route: { path: '/messages/:messageId/claim' }, + params: { messageId: 'message-b' }, body: { podId: 'pod-a' }, + }; + expect(await classifyRuntimeOutsiderGrantRoute(req)) + .toEqual({ routeClass: 'pod_write', podId: 'pod-b' }); + expect(Message.findById).toHaveBeenCalledWith('message-b'); + Message.findById.mockResolvedValueOnce({ pod_id: 'pod-a' }); + expect(await classifyRuntimeOutsiderGrantRoute(req)) + .toEqual({ routeClass: 'pod_write', podId: 'pod-a' }); +}); + +test('unscoped posts resolve no pod and broker calls retain their own gate', async () => { + const classify = (baseUrl, method, routePath, body = {}) => + classifyRuntimeOutsiderGrantRoute({ baseUrl, method, route: { path: routePath }, body, params: {} }); + expect((await classify('/api/agents/runtime', 'POST', '/posts')).routeClass).toBe('outward'); + expect((await classify('/api/agents/runtime', 'POST', '/posts')).podId).toBeNull(); + expect((await classify('/api/github', 'POST', '/issues')).routeClass).toBe('outward'); + expect((await classify('/api/grants', 'POST', '/:grantId/attenuate')).routeClass).toBe('grant'); + expect((await classify('/api/mcp/grants', 'POST', '/:grantId')).routeClass).toBe('grant'); + expect((await classify('/api/agents/runtime/spawn-credentials', 'POST', '/')).routeClass).toBe('own_state'); + expect((await classify('/api/agents/runtime', 'POST', '/events/:id/ack')).routeClass).toBe('own_state'); +}); + +test('ensemble response resolves the record pod rather than the ignored path pod', async () => { + mockEnsembleFindById.mockReturnValue({ select: () => ({ lean: async () => ({ podId: 'pod-b' }) }) }); + const route = await classifyRuntimeOutsiderGrantRoute({ + baseUrl: '/api/pods', method: 'POST', route: { path: '/:podId/ensemble/response' }, + params: { podId: 'pod-a' }, body: { ensembleId: 'ensemble-1' }, + }); + expect(mockEnsembleFindById).toHaveBeenCalledWith('ensemble-1'); + expect(route).toEqual({ routeClass: 'pod_write', podId: 'pod-b' }); +}); diff --git a/backend/middleware/agentRuntimeAuth.ts b/backend/middleware/agentRuntimeAuth.ts index 72759e67f5..61e4bd3045 100644 --- a/backend/middleware/agentRuntimeAuth.ts +++ b/backend/middleware/agentRuntimeAuth.ts @@ -10,6 +10,9 @@ import AgentCredential from '../models/AgentCredential'; import { markByoConnectionAttempt } from '../services/onboardingMarks'; import { isSpawnCredential } from '../services/spawnCredentialService'; import { machineHasLiveAuthorization } from '../services/machineService'; +import { podsWithOutsiderWritableGrants } from '../services/nativeOutsiderGrantPolicy'; +import { installationIdentityFilter } from '../services/agentInstallationIdentity'; +import { classifyRuntimeOutsiderGrantRoute } from '../services/runtimeOutsiderGrantRoutes'; // eslint-disable-next-line global-require const { hash } = require('../utils/secret') as { hash: (value: string) => string }; @@ -75,6 +78,45 @@ const extractToken = (req: Request): string | undefined => { return req.header('x-commonly-agent-token'); }; +const checkOutsiderGrantRuntimeNarrowing = async ( + req: Request, res: Response, installationPodIds: string[], +): Promise => { + const uniquePodIds = [...new Set(installationPodIds.filter(Boolean))]; + if (!uniquePodIds.length) return true; + let narrowedPods: Set; + try { + narrowedPods = await podsWithOutsiderWritableGrants(uniquePodIds); + } catch (error) { + // A failed grant read must never turn into an unrestricted token. + console.error('Outsider grant runtime census failed:', error); + narrowedPods = new Set(uniquePodIds); + } + if (!narrowedPods.size) return true; + let route: Awaited>; + try { + route = await classifyRuntimeOutsiderGrantRoute(req); + } catch (error) { + console.error('Outsider grant runtime route resolution failed:', error); + route = { routeClass: 'unknown', podId: null }; + } + const allowed = route.routeClass === 'read' + || route.routeClass === 'own_state' + || route.routeClass === 'grant' + || (route.routeClass === 'pod_write' && narrowedPods.size === 1 + && route.podId === [...narrowedPods][0]); + if (allowed) return true; + const action = req.method.toUpperCase() === 'GET' ? route.routeClass : 'write'; + res.status(403).json({ + code: 'outsider_grant_runtime_narrowed', + message: narrowedPods.size > 1 + ? `Runtime ${action} (${route.routeClass}) refused: outsider-writable grants reach installation pods ${[...narrowedPods].join(', ')}; writes cannot be confined to one pod` + : `Runtime ${route.routeClass} refused while an outsider-writable grant reaches pod ${[...narrowedPods][0]}`, + podIds: [...narrowedPods], + routeClass: route.routeClass, + }); + return false; +}; + export default async function agentRuntimeAuth(req: Request, res: Response, next: NextFunction): Promise { try { const token = extractToken(req); @@ -120,14 +162,18 @@ export default async function agentRuntimeAuth(req: Request, res: Response, next )) { return res.status(401).json({ message: 'Machine access revoked' }); } + } + + const markCredentialUsed = async () => { + if (!credential) return; try { - // Settings reads this ledger row. A successful authenticated request - // must be visible to the next credential read, including first use. + // Rejected narrowed calls are not successful token use. Settings reads + // this ledger row after an allowed call, including its first use. await AgentCredential.updateOne({ _id: credential._id }, { $set: { lastUsedAt: new Date() } }); } catch (err) { console.warn('Failed to update credential usage:', (err as Error).message); } - } + }; // TASK-094: expose the row so routes can key on the credential (the mint // route needs the caller's row) rather than hashing the bearer again. @@ -155,20 +201,10 @@ export default async function agentRuntimeAuth(req: Request, res: Response, next // once per seat. const isFirstTokenUse = Boolean(tokenRecord) && !tokenRecord?.lastUsedAt; - try { - await User.updateOne( - { _id: agentUser._id, 'agentRuntimeTokens.tokenHash': tokenHash }, - { $set: { 'agentRuntimeTokens.$.lastUsedAt': new Date() } }, - ); - } catch (err: unknown) { - console.warn('Failed to update agent token usage on User:', (err as Error).message); - } - const { agentName, instanceId } = resolveTokenAgentIdentity(agentUser); const installations = await AgentInstallation.find({ - agentName, - instanceId, + ...installationIdentityFilter(agentName, instanceId), status: 'active', }).lean(); // Old uninstall paths left User-row tokens behind. Even before a data @@ -192,6 +228,19 @@ export default async function agentRuntimeAuth(req: Request, res: Response, next req.agentAuthorizedPodIds = authorizedPodIds; req.agentInstallation = (installations[0] as never) || null; + if (!await checkOutsiderGrantRuntimeNarrowing(req, res, installationPodIds)) return undefined; + + await markCredentialUsed(); + + try { + await User.updateOne( + { _id: agentUser._id, 'agentRuntimeTokens.tokenHash': tokenHash }, + { $set: { 'agentRuntimeTokens.$.lastUsedAt': new Date() } }, + ); + } catch (err: unknown) { + console.warn('Failed to update agent token usage on User:', (err as Error).message); + } + if (isFirstTokenUse) { for (const installationRow of installations) { markByoConnectionAttempt(installationRow.installedBy, installationRow); @@ -220,15 +269,6 @@ export default async function agentRuntimeAuth(req: Request, res: Response, next .find((t: { tokenHash?: string }) => t.tokenHash === tokenHash); const isFirstLegacyTokenUse = !legacyTokenRecord?.lastUsedAt; - try { - await AgentInstallation.updateOne( - { _id: installation._id, 'runtimeTokens.tokenHash': tokenHash }, - { $set: { 'runtimeTokens.$.lastUsedAt': new Date() } }, - ); - } catch (err: unknown) { - console.warn('Failed to update agent token usage:', (err as Error).message); - } - // Task #66: a token may be bound to one AgentInstallation row, but the // agent identity (agentName + instanceId) can have multiple active // installations across pods. Surface ALL of them so the /events @@ -240,27 +280,22 @@ export default async function agentRuntimeAuth(req: Request, res: Response, next // Codex Hub install couldn't see events from a new pod she'd been // freshly installed into. const allActiveInstallations = await AgentInstallation.find({ - agentName: installation.agentName, - instanceId: installation.instanceId || 'default', + ...installationIdentityFilter(installation.agentName, installation.instanceId || 'default'), status: 'active', }); + // The matched credential is itself an active installation. Preserve its + // pod even if a legacy instanceId shape makes the identity-wide query + // miss it; otherwise this token would escape the grant census. + const identityInstallations = allActiveInstallations.some((inst: { _id?: unknown }) => + String(inst._id) === String(installation._id)) + ? allActiveInstallations : [installation, ...allActiveInstallations]; req.agentInstallation = installation as never; - req.agentInstallations = allActiveInstallations as never[]; - req.agentAuthorizedPodIds = allActiveInstallations + req.agentInstallations = identityInstallations as never[]; + req.agentAuthorizedPodIds = identityInstallations .map((inst: { podId?: { toString: () => string } }) => inst?.podId?.toString()) .filter(Boolean) as string[]; - if (isFirstLegacyTokenUse) { - markByoConnectionAttempt(installation.installedBy, installation); - // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports - const { completeConnectAgentStarterTask } = require('../services/starterTaskService'); - void completeConnectAgentStarterTask({ - podIds: req.agentAuthorizedPodIds, - agentLabel: installation.displayName || installation.agentName, - }); - } - // Also resolve and attach the bot User so downstream routes that derive // `req.agentUser._id` (file-upload uploaderId, message authorship) work // for agents authenticated via the legacy installation-token path. The @@ -302,6 +337,30 @@ export default async function agentRuntimeAuth(req: Request, res: Response, next console.warn('[agentRuntimeAuth] failed to resolve bot user for legacy token path:', (err as Error).message); } + if (!await checkOutsiderGrantRuntimeNarrowing( + req, res, identityInstallations.map((inst: { podId?: unknown }) => String(inst.podId || '')), + )) return undefined; + + await markCredentialUsed(); + + try { + await AgentInstallation.updateOne( + { _id: installation._id, 'runtimeTokens.tokenHash': tokenHash }, + { $set: { 'runtimeTokens.$.lastUsedAt': new Date() } }, + ); + } catch (err: unknown) { + console.warn('Failed to update agent token usage:', (err as Error).message); + } + + if (isFirstLegacyTokenUse) { + markByoConnectionAttempt(installation.installedBy, installation); + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports + const { completeConnectAgentStarterTask } = require('../services/starterTaskService'); + void completeConnectAgentStarterTask({ + podIds: req.agentAuthorizedPodIds, + agentLabel: installation.displayName || installation.agentName, + }); + } return next(); } catch (error) { console.error('Agent auth error:', error); diff --git a/backend/routes/registry/pod-agents.ts b/backend/routes/registry/pod-agents.ts index 3cc4612ebb..7ec903a552 100644 --- a/backend/routes/registry/pod-agents.ts +++ b/backend/routes/registry/pod-agents.ts @@ -13,6 +13,7 @@ const { hostedRunsNative } = require('../../services/hostedEngine'); const Pod = require('../../models/Pod'); const User = require('../../models/User'); const AgentIdentityService = require('../../services/agentIdentityService'); +const { agentsWithMultipleOutsiderGrantPods } = require('../../services/nativeOutsiderGrantPolicy'); const { revokeRuntimeTokensAfterLastUninstall } = require('./tokens'); const { getUserId, @@ -175,6 +176,16 @@ podAgentsRouter.get('/pods/:podId/agents', auth, async (req: any, res: any) => { } const installations = await AgentInstallation.getInstalledAgents(podId); + let multiGrantPodAgents = new Set(); + let runtimeNarrowingUnavailable = false; + try { + multiGrantPodAgents = await agentsWithMultipleOutsiderGrantPods(installations); + } catch (narrowingErr) { + // The roster is advisory. The runtime middleware independently fails + // closed; the Tools page must not claim the seat can post on a bad read. + console.warn('[pod-agents] runtime narrowing lookup failed:', (narrowingErr as Error).message); + runtimeNarrowingUnavailable = true; + } // One derivation for proof-of-life (heartbeats ∪ runs ∪ token use) — // shared with the native runtime's commonly_agent_status tool via @@ -348,11 +359,15 @@ podAgentsRouter.get('/pods/:podId/agents', auth, async (req: any, res: any) => { user, lastMessage: (user && lastMessageByUserId.get(String(user._id))) || null, }); + const narrowing = { + runtimeNarrowedByMultipleGrantPods: multiGrantPodAgents.has(instanceKey), + runtimeNarrowingUnavailable, + }; const runtimeType = payload?.runtime?.runtimeType; // A provisioned in-process seat needs no external connection. Its // runtime is available by construction, without a credential ledger. if (runtimeType === 'native' || hostedRunsNative(i)) { - return { ...payload, connection: { state: 'available', lastSeenAt: null } }; + return { ...payload, ...narrowing, connection: { state: 'available', lastSeenAt: null } }; } // Legacy local-cli installs are normalized to their harness name // (codex/claude-code) in the public payload. Host, not that label, @@ -361,10 +376,11 @@ podAgentsRouter.get('/pods/:podId/agents', auth, async (req: any, res: any) => { || runtimeType === 'wrapper' || (runtimeType === 'webhook' && !payload?.runtime?.host); if (!connectionAvailable || !user || !hasRuntimeConnection - || payload?.runtime?.webhookUrl) return payload; + || payload?.runtime?.webhookUrl) return { ...payload, ...narrowing }; const machineId = user.botMetadata?.machineId; return { ...payload, + ...narrowing, connection: deriveAgentConnection({ runtimeType, credentials: credentialByAgent.get(String(user._id)) || [], diff --git a/backend/services/agentInstallationIdentity.ts b/backend/services/agentInstallationIdentity.ts new file mode 100644 index 0000000000..b22c2bf069 --- /dev/null +++ b/backend/services/agentInstallationIdentity.ts @@ -0,0 +1,9 @@ +/** An installation created before instance ids were stored belongs to the + * default instance. Share this predicate between runtime auth and the roster + * so the same pod set is used for both grant checks. */ +export const installationIdentityFilter = (agentName: string, instanceId: string) => ({ + agentName, + ...(instanceId === 'default' + ? { $or: [{ instanceId: 'default' }, { instanceId: null }] } + : { instanceId }), +}); diff --git a/backend/services/messageClaimService.ts b/backend/services/messageClaimService.ts index 3389b5499a..f139efb9ea 100644 --- a/backend/services/messageClaimService.ts +++ b/backend/services/messageClaimService.ts @@ -123,7 +123,7 @@ const MESSAGE_ID_MAX = 2147483647; * accept digits: * * chat id digits, 1..2147483647 verified against Postgres below - * comment id 24 hex, canonical case verified against Mongo (existence only) + * comment id 24 hex, canonical case verified against Mongo in its pod * anything else refused, no query * * A post-thread comment enters the mention and wake path as a Mongo ObjectId: @@ -137,27 +137,11 @@ const MESSAGE_ID_MAX = 2147483647; * regression this arm exists to prevent (connector-ops 71952, vera 71956, * wren's ruling 71961). * - * THE POD CANNOT BE CHECKED HERE; THE ID NOW CAN (TASK-122). Until TASK-122 - * this arm passed the id through unverified, so a fabricated 24-hex id minted a - * permanent, un-renewable, un-completable phantom lease — permanent because the - * prune collects terminal states only. It is now checked against the comment's - * own store, which is `Post.comments[]`, not `messages`. - * - * What the check is NOT is pod-scoped, and that is a property of the wake path - * rather than a shortcut: the pod for a comment wake cannot be verified here by - * construction, because the enqueue resolves it through `resolveMentionPod`, - * which may return the request's pod or a fallback — so refusing on a pod - * mismatch could refuse a wake that is real. Existence is what this arm can - * buy; the pod half stays the route's installation check. - * - * A STORE FAILURE PASSES THE ID THROUGH rather than refusing it, and the - * direction carries the arm's own reasoning: a refusal for a 24-hex id does not - * fail closed — the CLI's claim path (`cli/src/lib/enforcement.js:414`) turns - * the route's non-2xx into `{failOpen: true}` — so an outage that refused would - * unguard every woken seat and stop the race deduping. An unconfirmed id - * therefore degrades to the - * pre-TASK-122 behaviour (dedupe holds, a phantom is possible) and says so on - * one line, rather than reverting the hardening in silence. + * TASK-122 added the comment-id existence check to prevent permanent phantom + * leases. TASK-213 also scopes that check to the stored Post.podId. The route's + * installation check alone cannot establish ownership of this comment. A + * store failure propagates before SQL mutation: the client may fail open on + * a non-2xx claim response, but a forged cross-pod lease must not be saved. * * The lookup is a collection scan: no index covers `comments._id` (models/Post * declares none), so a MISS — the fabricated-id case this arm exists to refuse — @@ -222,21 +206,18 @@ function commentStore() { * nothing here needs to construct one; the caller has already proven the 24-hex * canonical shape, so the cast cannot fail on any input this predicate admits. * - * The catch returns TRUE, not false, and that is the whole design: see - * MONGO_OBJECT_ID. A store that cannot answer must not unguard the fleet, and - * refusing an unconfirmed comment id is exactly what would do that. + * An unavailable comment store cannot confirm pod ownership. Propagate the + * error so the route stops before mutating the SQL claim table; otherwise a + * caller can create a lease for another pod's comment while Mongo is down. */ -async function commentExists(objectId: string): Promise { +async function commentExists(objectId: string, podId: string): Promise { try { - const found = await commentStore().exists({ 'comments._id': objectId }); + const found = await commentStore().exists({ podId, 'comments._id': objectId }); return Boolean(found); } catch (err) { const message = err instanceof Error ? err.message : String(err); - console.warn( - `[claims] comment existence check failed (${message}) — passing ${objectId} through, ` - + 'so the wake race keeps deduping', - ); - return true; + console.warn(`[claims] comment existence check failed (${message}) for ${objectId} in ${podId}`); + throw err; } } @@ -292,9 +273,9 @@ class MessageClaimService { const pod = String(podId ?? ''); if (!pod) return false; // Comment namespace: it exists in Mongo, not in Postgres, so it is the - // other store that answers it — and since TASK-122 it does, existence-only - // (commentExists names the direction a store failure takes, and why). - if (MONGO_OBJECT_ID.test(id)) return commentExists(id); + // other store that answers it. Its failure stops claim mutation because + // pod ownership remains unconfirmed. + if (MONGO_OBJECT_ID.test(id)) return commentExists(id, pod); if (!isChatMessageId(id)) return false; const found = await pool.query( 'SELECT 1 FROM messages WHERE id = $1 AND pod_id = $2 LIMIT 1', diff --git a/backend/services/nativeOutsiderGrantPolicy.ts b/backend/services/nativeOutsiderGrantPolicy.ts index aaceafbb61..6c10926820 100644 --- a/backend/services/nativeOutsiderGrantPolicy.ts +++ b/backend/services/nativeOutsiderGrantPolicy.ts @@ -6,7 +6,9 @@ import Integration from '../models/Integration'; import Pod from '../models/Pod'; import RoomGrant from '../models/RoomGrant'; +import { AgentInstallation } from '../models/AgentRegistry'; import { GRANT_BROKER_ID } from './installable/toolInstallables'; +import { installationIdentityFilter } from './agentInstallationIdentity'; interface CandidateGrant { connectionId?: unknown; @@ -16,36 +18,46 @@ interface CandidateGrant { const objectId = (value: unknown): string | null => { const id = String(value || ''); - return /^[a-f\d]{24}$/i.test(id) ? id : null; + return /^[a-f\d]{24}$/i.test(id) ? id.toLowerCase() : null; }; -export const podHasOutsiderWritableGrant = async (podId: string): Promise => { - const id = objectId(podId); - if (!id) return false; - const pod = await Pod.findById(id).select('members').lean() as { members?: unknown[] } | null; - if (!pod) return false; - const members = new Set((pod.members || []).map(String)); - if (!members.size) return false; +export const podHasOutsiderWritableGrant = async (podId: string): Promise => + (await podsWithOutsiderWritableGrants([podId])).has(objectId(podId) || ''); + +/** Read all of an identity's installation pods in one pass, without a cache. */ +export const podsWithOutsiderWritableGrants = async (podIds: string[]): Promise> => { + const ids = [...new Set(podIds.map(objectId).filter((id): id is string => Boolean(id)))]; + if (!ids.length) return new Set(); + const pods = await Pod.find({ _id: { $in: ids } }).select('_id members').lean() as + Array<{ _id: unknown; members?: unknown[] }>; + const memberSets = new Map(pods.map((pod) => [String(pod._id), new Set((pod.members || []).map(String))])); + const allMembers = [...new Set([...memberSets.values()].flatMap((members) => [...members]))]; + if (!allMembers.length) return new Set(); const grants = await RoomGrant.find({ brokerId: GRANT_BROKER_ID, revokedAt: null, expiresAt: { $gt: new Date() }, $or: [ - { 'target.kind': 'pod', 'target.id': id }, - { 'target.kind': 'seat', 'target.id': { $in: [...members] } }, + { 'target.kind': 'pod', 'target.id': { $in: ids } }, + { 'target.kind': 'seat', 'target.id': { $in: allMembers } }, ], }).select('connectionId target audience').lean() as CandidateGrant[]; - const connectionIds = [...new Set(grants.filter((grant) => { + const candidates = grants.flatMap((grant) => { const targetId = String(grant.target?.id || ''); const audience = new Set((grant.audience || []).map(String)); if (grant.target?.kind === 'pod') { - return targetId === id && [...members].some((member) => audience.has(member)); + const members = memberSets.get(targetId); + return members && [...members].some((member) => audience.has(member)) + ? [{ podId: targetId, connectionId: String(grant.connectionId || '').trim() }] : []; } - return grant.target?.kind === 'seat' && members.has(targetId) && audience.has(targetId); - }).map((grant) => String(grant.connectionId || '').trim()).filter(Boolean))]; - if (!connectionIds.length) return false; + if (grant.target?.kind !== 'seat' || !audience.has(targetId)) return []; + return [...memberSets].filter(([, members]) => members.has(targetId)) + .map(([podId]) => ({ podId, connectionId: String(grant.connectionId || '').trim() })); + }).filter((candidate) => candidate.connectionId); + const connectionIds = [...new Set(candidates.map((candidate) => candidate.connectionId))]; + if (!connectionIds.length) return new Set(); const mongoIds = connectionIds.filter((value) => objectId(value)); // Every current hosted-MCP entry and the GitHub App return text that an @@ -59,6 +71,39 @@ export const podHasOutsiderWritableGrant = async (podId: string): Promise 0; + }).select('_id type installationId').lean() as Array<{ _id: unknown; type: string; installationId?: unknown }>; + const matched = new Set(connections.flatMap((connection) => + [String(connection._id), ...(connection.type === 'github-app' ? [String(connection.installationId || '')] : [])])); + return new Set(candidates.filter((candidate) => matched.has(candidate.connectionId)) + .map((candidate) => candidate.podId)); +}; + +/** Human roster projection of the same identity-wide boundary enforced by + * agentRuntimeAuth. Return only a boolean; other pod ids remain private. */ +export const agentsWithMultipleOutsiderGrantPods = async ( + identities: Array<{ agentName: string; instanceId?: string }>, +): Promise> => { + const unique = [...new Map(identities.map((identity) => { + const instanceId = identity.instanceId || 'default'; + return [`${identity.agentName}:${instanceId}`, installationIdentityFilter(identity.agentName, instanceId)]; + })).values()]; + if (!unique.length) return new Set(); + const installations = await AgentInstallation.find({ + status: 'active', $or: unique, + }).select('agentName instanceId podId').lean() as Array<{ + agentName: string; instanceId?: string; podId?: unknown; + }>; + const podIds = [...new Set(installations.map((row) => objectId(row.podId)).filter((id): id is string => Boolean(id)))]; + // The human roster reports an unavailable status on census failure. Runtime + // authentication applies its own fail-closed policy to the same failure. + const narrowed = await podsWithOutsiderWritableGrants(podIds); + const counts = new Map>(); + for (const row of installations) { + const podId = objectId(row.podId); + if (!podId || !narrowed.has(podId)) continue; + const key = `${row.agentName}:${row.instanceId || 'default'}`; + if (!counts.has(key)) counts.set(key, new Set()); + counts.get(key)!.add(podId); + } + return new Set([...counts].filter(([, pods]) => pods.size >= 2).map(([key]) => key)); }; diff --git a/backend/services/runtimeOutsiderGrantRoutes.ts b/backend/services/runtimeOutsiderGrantRoutes.ts new file mode 100644 index 0000000000..e5c946a0eb --- /dev/null +++ b/backend/services/runtimeOutsiderGrantRoutes.ts @@ -0,0 +1,114 @@ +/** Runtime-token route policy for TASK-213 A. Keep this list exhaustive: an + * unlisted acceptor fails closed while an outsider-writable grant is live. */ +import type { Request } from 'express'; +import Post from '../models/Post'; +import AgentAsk from '../models/AgentAsk'; +import AgentEnsembleState from '../models/AgentEnsembleState'; +import Message from '../models/pg/Message'; + +type RouteClass = 'read' | 'pod_write' | 'grant' | 'own_state' | 'outward'; +type PodResolver = (req: Request) => Promise; +interface RouteRule { routeClass: RouteClass; resolvePod?: PodResolver } +const podParam: PodResolver = async (req) => String(req.params.podId || '') || null; +const bodyPod: PodResolver = async (req) => String(req.body?.podId || '') || null; +const postPod: PodResolver = async (req) => { + const post = await Post.findById(req.params.threadId).select('podId').lean(); + return post?.podId ? String(post.podId) : null; +}; +const messagePod: PodResolver = async (req) => { + const message = await Message.findById(String(req.params.messageId || '')); + return message?.pod_id ? String(message.pod_id) : null; +}; +const askPod: PodResolver = async (req) => { + const ask = await AgentAsk.findOne({ requestId: req.params.requestId }).select('podId').lean(); + return ask?.podId ? String(ask.podId) : null; +}; +const ensemblePod: PodResolver = async (req) => { + const state = await AgentEnsembleState.findById(req.body?.ensembleId).select('podId').lean(); + return state?.podId ? String(state.podId) : null; +}; +const privateMemory = (req: Request): boolean => { + const { content, sections, mode, sourceRuntime } = req.body || {}; + // Legacy content mirrors into long_term, and a full sync can retain or + // restore shared sections. sourceRuntime is visible to other agents through + // the shared-memory read even when every supplied section is private. + if (content !== undefined || sourceRuntime !== undefined || mode === 'full' + || !sections || typeof sections !== 'object' + || Array.isArray(sections)) return false; + return Object.values(sections).every((section) => { + if (Array.isArray(section)) return section.every((entry) => entry?.visibility === 'private'); + return section && typeof section === 'object' && (section as { visibility?: unknown }).visibility === 'private'; + }); +}; + +const rules: Record = {}; +const add = (base: string, routeClass: RouteClass, methodsAndPaths: string[], resolvePod?: PodResolver) => { + methodsAndPaths.forEach((entry) => { rules[`${base} ${entry}`] = { routeClass, resolvePod }; }); +}; +const runtime = '/api/agents/runtime'; +add(runtime, 'read', [ + 'GET /installations', 'GET /events', 'GET /pods/:podId/context', 'GET /pods/:podId/messages', + 'GET /pods/:podId/files', 'GET /pods/:podId/files/:fileName/content', 'GET /pods/:podId/posts', + 'GET /memory', 'GET /pods', 'GET /pods/:podId/integrations', + 'GET /pods/:podId/skills', 'GET /pods/:podId/send-freshness', + 'GET /pods/:podId/integrations/:integrationId/messages', 'GET /pods/:podId/social-policy', + 'GET /memory/shared/:agentName/:instanceId?', +]); +add(runtime, 'own_state', ['POST /events/:id/ack', 'POST /pods/:podId/skill-load']); +add(runtime, 'pod_write', [ + 'POST /pods/:podId/typing', 'POST /pods/:podId/messages', 'POST /pods/:podId/propose-action', + 'POST /pods/:podId/summaries', 'POST /pods/:podId/ask', 'POST /pods/:podId/uploads', + 'POST /pods/:podId/hooks', +], podParam); +add(runtime, 'pod_write', ['POST /messages/:messageId/claim', 'POST /posts', 'POST /decisions'], bodyPod); +// Release looks up the message by ID; its handler ignores a caller-supplied podId. +add(runtime, 'pod_write', ['DELETE /messages/:messageId/claim'], messagePod); +add(runtime, 'pod_write', ['POST /threads/:threadId/comments'], postPod); +add(runtime, 'pod_write', ['POST /asks/:requestId/respond'], askPod); +add(runtime, 'outward', [ + 'POST /room', 'POST /agent-dm', 'POST /pods', 'POST /pods/:podId/self-install', + 'POST /pods/:podId/integrations/:integrationId/publish', +]); +add(runtime, 'pod_write', ['PUT /memory', 'POST /memory/sync']); // private-only special case below +const spawn = `${runtime}/spawn-credentials`; +// Credential maintenance reaches only the authenticated seat. A minted child +// resolves the same agent identity in agentRuntimeAuth and inherits this check. +add(spawn, 'own_state', ['GET /policy', 'POST /', 'POST /:id/renew', 'POST /revoke-orphans', 'DELETE /:id']); +// The handler ignores :podId and writes to the ensemble named in the body. +add('/api/pods', 'pod_write', ['POST /:podId/ensemble/response'], ensemblePod); +add('/api/messages', 'read', ['GET /threads/state']); +add('/api/messages', 'pod_write', [ + 'POST /:messageId/reactions', 'DELETE /:messageId/reactions/:emoji', + 'POST /:messageId/follow', 'DELETE /:messageId/follow', 'PUT /:messageId/collapsed', +], messagePod); +add('/api/artifacts', 'read', ['GET /']); +add('/api/uploads', 'read', ['GET /:fileName', 'GET /:fileName/preview-pptx-html']); +add('/api/github', 'read', ['GET /issues']); +add('/api/github', 'outward', ['POST /issues', 'POST /issues/:number/comment', 'POST /issues/:number/close']); +add('/api/v1/tasks', 'read', ['GET /:podId']); +add('/api/v1/tasks', 'pod_write', [ + 'POST /:podId', 'POST /:podId/:taskId/claim', 'POST /:podId/:taskId/complete', + 'POST /:podId/:taskId/updates', 'PATCH /:podId/:taskId', +], podParam); +add('/api/grants', 'grant', ['POST /:grantId/attenuate', 'GET /:grantId/calls', 'GET /:grantId']); +add('/api/mcp/grants', 'grant', ['POST /:grantId']); + +export const runtimeOutsiderGrantRouteKeys = Object.keys(rules); + +export const classifyRuntimeOutsiderGrantRoute = async (req: Request): Promise<{ + routeClass: RouteClass | 'unknown'; podId: string | null; +}> => { + const key = `${req.baseUrl} ${req.method.toUpperCase()} ${req.route?.path || ''}`; + const rule = rules[key]; + if (!rule) return { routeClass: 'unknown', podId: null }; + if ((req.route?.path === '/memory' || req.route?.path === '/memory/sync') + && rule.routeClass === 'pod_write') { + return { routeClass: privateMemory(req) ? 'read' : 'outward', podId: null }; + } + const podId = rule.resolvePod ? await rule.resolvePod(req) : null; + if (!podId && req.baseUrl === runtime + && (req.route?.path === '/posts' || req.route?.path === '/threads/:threadId/comments')) { + return { routeClass: 'outward', podId: null }; + } + return { routeClass: rule.routeClass, podId }; +}; diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 799d695987..37942a69a3 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2275,6 +2275,8 @@ "notEnabled": "not enabled on this instance · ask your operator", "notGranted": "not granted", "nothingMatches": "Nothing matches.", + "multiGrantPodPostingBlocked": "{{agents}} cannot post in any pod while connectors reach more than one of their pods. End a grant until only one pod has connector access.", + "runtimePostingStatusUnavailable": "Could not check whether connector grants limit agent posting. Try again shortly.", "unknownConnector": "Unknown connector", "unknownWritePolicy": "write policy unavailable", "outcomeFailed": "failed", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 49aeeea657..4bf6b76a94 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2266,6 +2266,8 @@ "notEnabled": "此实例尚未启用 · 联系实例管理员", "notGranted": "未授权", "nothingMatches": "没有匹配项。", + "multiGrantPodPostingBlocked": "连接器授权覆盖{{agents}}所在的多个 Pod,因此它们暂时无法在任何 Pod 发帖。请结束授权,直到只有一个 Pod 仍可使用连接器。", + "runtimePostingStatusUnavailable": "暂时无法检查连接器授权是否限制智能体发帖。请稍后重试。", "unknownConnector": "未知连接器", "unknownWritePolicy": "写入策略不可用", "outcomeFailed": "失败", diff --git a/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx b/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx index 6d75cf05da..5ba1585a9a 100644 --- a/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx +++ b/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx @@ -122,6 +122,39 @@ const renderTools = (props = {}) => render( beforeEach(() => { jest.clearAllMocks(); mockApi([githubEntry]); }); +test('explains why a seat reached by grants in multiple pods cannot post', async () => { + const baseGet = axios.get.getMockImplementation(); + axios.get.mockImplementation((url) => { + if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: [{ + name: 'scout', displayName: 'Scout', userId: 'a1', runtimeNarrowedByMultipleGrantPods: true, + }] } }); + return baseGet(url); + }); + renderTools(); + expect(await screen.findByText(/Scout cannot post in any pod while connectors reach more than one of their pods/)).toBeInTheDocument(); +}); + +test('does not imply agent posting is available when the roster read fails', async () => { + const baseGet = axios.get.getMockImplementation(); + axios.get.mockImplementation((url) => ( + url === '/api/registry/pods/p1/agents' ? Promise.reject(new Error('roster unavailable')) : baseGet(url) + )); + renderTools(); + expect(await screen.findByText(/Could not check whether connector grants limit agent posting/)).toBeInTheDocument(); +}); + +test('shows unavailable status sent by the roster when its grant census fails', async () => { + const baseGet = axios.get.getMockImplementation(); + axios.get.mockImplementation((url) => { + if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: [{ + name: 'scout', displayName: 'Scout', userId: 'a1', runtimeNarrowingUnavailable: true, + }] } }); + return baseGet(url); + }); + renderTools(); + expect(await screen.findByText(/Could not check whether connector grants limit agent posting/)).toBeInTheDocument(); +}); + test('a sole join-policy refusal offers the owner an in-place fix and then allows retry', async () => { axios.post.mockRejectedValueOnce({ response: { data: { error: 'outsider_grant_pod_joinable', message: 'Agents can self-install into this pod. Make it invite-only to use this connector.', diff --git a/frontend/src/v2/components/V2ConnectorTools.tsx b/frontend/src/v2/components/V2ConnectorTools.tsx index 5c7d9d6eca..63036990cd 100644 --- a/frontend/src/v2/components/V2ConnectorTools.tsx +++ b/frontend/src/v2/components/V2ConnectorTools.tsx @@ -101,7 +101,14 @@ export interface HostedMcpManualRevokeNotice { revokePage: string; } -interface PodSeat { userId: string | null; displayName?: string; name: string; internal?: boolean } +interface PodSeat { + userId: string | null; + displayName?: string; + name: string; + internal?: boolean; + runtimeNarrowedByMultipleGrantPods?: boolean; + runtimeNarrowingUnavailable?: boolean; +} interface Props { pods: V2Pod[]; @@ -210,6 +217,7 @@ const V2ConnectorTools: React.FC = ({ pods, manualRevokeNotice = null }) const [catalog, setCatalog] = useState([]); const [hiddenConnections, setHiddenConnections] = useState([]); const [seats, setSeats] = useState>({}); + const [seatLookupFailed, setSeatLookupFailed] = useState(false); const [lastUse, setLastUse] = useState>({}); const [trail, setTrail] = useState(null); const [trailError, setTrailError] = useState(false); @@ -233,18 +241,24 @@ const V2ConnectorTools: React.FC = ({ pods, manualRevokeNotice = null }) const isAdmin = currentUser?.role === 'admin'; const podIds = useMemo(() => pods.slice(0, MAX_PODS).map((pod) => String(pod._id)), [pods]); + const blockedSeatNames = [...new Map(Object.values(seats).flat() + .filter((seat) => seat.runtimeNarrowedByMultipleGrantPods) + .map((seat) => [seat.userId || seat.name, seat.displayName || seat.name])).values()]; + const narrowingStatusUnavailable = seatLookupFailed || Object.values(seats).flat() + .some((seat) => seat.runtimeNarrowingUnavailable); const load = useCallback(async () => { const catalogRes = await api.get<{ installables?: ToolCatalogEntry[]; hiddenToolConnections?: HiddenToolConnection[] }>('/api/installables').catch(() => null); setCatalog((catalogRes?.installables || []).filter((entry) => entry.list === 'tools')); setHiddenConnections(catalogRes?.hiddenToolConnections || []); - if (podIds.length === 0) { setGrants([]); return; } + if (podIds.length === 0) { setGrants([]); setSeatLookupFailed(false); return; } const results = await Promise.all(podIds.map(async (podId) => { const [grantsRes, seatsRes] = await Promise.all([ api.get<{ grants: ToolGrant[] }>(`/api/pods/${podId}/grants`).catch(() => null), api.get<{ agents?: PodSeat[] }>(`/api/registry/pods/${podId}/agents`).catch(() => null), ]); - return { podId, grants: grantsRes?.grants ?? [], seats: (seatsRes?.agents ?? []).filter((seat) => !seat.internal) }; + return { podId, grants: grantsRes?.grants ?? [], seats: (seatsRes?.agents ?? []).filter((seat) => !seat.internal), + seatLookupFailed: !seatsRes }; })); const seen = new Set(); const merged: ToolGrant[] = []; @@ -260,6 +274,7 @@ const V2ConnectorTools: React.FC = ({ pods, manualRevokeNotice = null }) merged.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); setGrants(merged); setSeats(seatMap); + setSeatLookupFailed(results.some((result) => result.seatLookupFailed)); // "used in the last 10 min" reads the newest trail row's `at` (§6). const uses = await Promise.all(merged.filter((grant) => !isDead(grant)).map(async (grant) => { const res = await api.get(`/api/grants/${grant.grantId}/calls`, { params: { limit: 1 } }).catch(() => null); @@ -1083,6 +1098,21 @@ const V2ConnectorTools: React.FC = ({ pods, manualRevokeNotice = null }) onChange={(event) => setQuery(event.target.value)} /> + {blockedSeatNames.length > 0 && ( +

+ {t('tools.multiGrantPodPostingBlocked', { + defaultValue: '{{agents}} cannot post in any pod while connectors reach more than one of their pods. End a grant until only one pod has connector access.', + agents: blockedSeatNames.join(t('tools.listSeparator', { defaultValue: ', ' })), + })} +

+ )} + {narrowingStatusUnavailable && ( +

+ {t('tools.runtimePostingStatusUnavailable', { + defaultValue: 'Could not check whether connector grants limit agent posting. Try again shortly.', + })} +

+ )} {error && !draft && !selected && !githubAppSetup && (

{error}

)}