Repository navigation
fix(agents): every path that removes an agent's last install retires its runtime token (TASK-625) #2979
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
lilyshen0722
wants to merge
3
commits into
main
Choose a base branch
from
fix/task-625-uninstall-revokes-cred
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
fix(agents): every path that removes an agent's last install retires its runtime token (TASK-625) #2979
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
4972f97
fix(agents): every path that removes an agent's last install revokes …
lilyshen0722 5864011
fix(agents): revoke before deleting, so a failed revoke is retried (T…
lilyshen0722 6f5d0eb
fix(agents): revoke before any deletion; fence the prune; drop deprov…
lilyshen0722 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
147 changes: 147 additions & 0 deletions
147
backend/__tests__/service/uninstallRevokesRuntimeCredential.test.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,147 @@ | ||
| // Every way an agent's last installation goes away retires its runtime | ||
| // token (TASK-625), the way the uninstall routes do (#2221): deleting the pod | ||
| // and pruning a stale install now do it too, and a reinstall of the same | ||
| // identity starts from a fresh token. | ||
| // | ||
| // A real mongod, because the claim is about which stored records survive; the | ||
| // probe is the real runtime auth middleware, so "dead" means what an agent | ||
| // would see: its next call is a 401. | ||
| const express = require('express'); | ||
| const rateLimit = require('express-rate-limit'); | ||
| const request = require('supertest'); | ||
| const mongoose = require('mongoose'); | ||
| const { setupMongoDb, closeMongoDb, clearMongoDb } = require('../utils/testUtils'); | ||
|
|
||
| process.env.PG_HOST = ''; | ||
| process.env.NODE_ENV = 'test'; | ||
|
|
||
| jest.mock('../../services/starterTaskService', () => ({ completeConnectAgentStarterTask: jest.fn() })); | ||
|
|
||
| const Pod = require('../../models/Pod'); | ||
| const Message = require('../../models/Message'); | ||
| const User = require('../../models/User'); | ||
| const AgentCredential = require('../../models/AgentCredential'); | ||
| const { AgentInstallation } = require('../../models/AgentRegistry'); | ||
| const AgentIdentityService = require('../../services/agentIdentityService'); | ||
| const tokens = require('../../routes/registry/tokens'); | ||
|
|
||
| const { issueRuntimeTokenForAgent } = tokens; | ||
| const { deletePodCascade } = require('../../services/podDeletionService'); | ||
| const { pruneStaleInstallations } = require('../../services/agentInstallationCleanupService'); | ||
| const agentRuntimeAuth = require('../../middleware/agentRuntimeAuth'); | ||
|
|
||
| const app = express(); | ||
| const probeLimit = rateLimit({ windowMs: 60 * 1000, max: 10000 }); | ||
| app.get('/probe', probeLimit, agentRuntimeAuth, (req, res) => res.json({ agent: req.agentUser?.username || null })); | ||
| const probe = (token) => request(app).get('/probe').set('Authorization', `Bearer ${token}`); | ||
|
|
||
| const AGENT = 'maya-claude'; | ||
| const ownerId = new mongoose.Types.ObjectId(); | ||
|
|
||
| const makePod = (name) => Pod.create({ name, type: 'chat', createdBy: ownerId, members: [ownerId] }); | ||
| const install = (pod, extra = {}) => AgentInstallation.create({ | ||
| agentName: AGENT, | ||
| instanceId: 'default', | ||
| podId: pod._id, | ||
| installedBy: ownerId, | ||
| status: 'active', | ||
| displayName: 'Maya Claude', | ||
| version: '1.0.0', | ||
| ...extra, | ||
| }); | ||
|
|
||
| const issue = async (installation) => { | ||
| const agentUser = await AgentIdentityService.getOrCreateAgentUser(AGENT, { instanceId: 'default' }); | ||
| const issued = await issueRuntimeTokenForAgent(agentUser, 'CLI key', installation, { ownerUserId: ownerId }); | ||
| return issued.token; | ||
| }; | ||
|
|
||
| const credentialStatus = async () => { | ||
| const rows = await AgentCredential.find({ kind: 'runtime' }).select('status').lean(); | ||
| return rows.map((row) => row.status); | ||
| }; | ||
|
|
||
| describe('the last installation going away revokes the runtime token', () => { | ||
| beforeAll(async () => { await setupMongoDb(); }); | ||
| afterAll(async () => { await closeMongoDb(); }); | ||
| beforeEach(async () => { | ||
| await clearMongoDb(); | ||
| jest.spyOn(console, 'warn').mockImplementation(() => {}); | ||
| }); | ||
| afterEach(() => jest.restoreAllMocks()); | ||
|
|
||
| test('deleting the only pod revokes it, and a reinstall does not revive it', async () => { | ||
| const pod = await makePod('Only pod'); | ||
| const token = await issue(await install(pod)); | ||
| expect((await probe(token)).status).toBe(200); | ||
|
|
||
| await deletePodCascade(String(pod._id)); | ||
|
|
||
| expect(await credentialStatus()).toEqual(['revoked']); | ||
| expect(await User.countDocuments({ 'agentRuntimeTokens.0': { $exists: true } })).toBe(0); | ||
| await install(await makePod('New pod')); | ||
| expect((await probe(token)).status).toBe(401); | ||
| }); | ||
|
|
||
| test('deleting one of two pods leaves the token working', async () => { | ||
| const pod = await makePod('Doomed'); | ||
| const keep = await makePod('Kept'); | ||
| const token = await issue(await install(pod)); | ||
| await install(keep); | ||
|
|
||
| await deletePodCascade(String(pod._id)); | ||
|
|
||
| expect(await credentialStatus()).toEqual(['active']); | ||
| expect((await probe(token)).status).toBe(200); | ||
| }); | ||
|
|
||
| test('pruning the last stale install revokes it, and a reinstall does not revive it', async () => { | ||
| const pod = await makePod('Stale pod'); | ||
| const installation = await install(pod); | ||
| const token = await issue(installation); | ||
| await AgentInstallation.updateOne( | ||
| { _id: installation._id }, | ||
| { $set: { status: 'stale', staleSince: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) } }, | ||
| ); | ||
|
|
||
| const result = await pruneStaleInstallations(14); | ||
|
|
||
| expect(result).toEqual({ deleted: 1, failures: 0 }); | ||
| expect(await credentialStatus()).toEqual(['revoked']); | ||
| await install(pod); | ||
| expect((await probe(token)).status).toBe(401); | ||
| }); | ||
| test('a prune whose revoke fails keeps the row, and the next run finishes the job', async () => { | ||
| const pod = await makePod('Retry pod'); | ||
| const installation = await install(pod); | ||
| await issue(installation); | ||
| await AgentInstallation.updateOne( | ||
| { _id: installation._id }, | ||
| { $set: { status: 'stale', staleSince: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) } }, | ||
| ); | ||
| jest.spyOn(console, 'error').mockImplementation(() => {}); | ||
| jest.spyOn(tokens, 'revokeRuntimeTokensAfterLastUninstall').mockRejectedValueOnce(new Error('db blip')); | ||
|
|
||
| expect(await pruneStaleInstallations(14)).toEqual({ deleted: 0, failures: 1 }); | ||
| expect(await AgentInstallation.countDocuments({ _id: installation._id })).toBe(1); | ||
| expect(await credentialStatus()).toEqual(['active']); | ||
|
|
||
| expect(await pruneStaleInstallations(14)).toEqual({ deleted: 1, failures: 0 }); | ||
| expect(await credentialStatus()).toEqual(['revoked']); | ||
| }); | ||
| test('a pod deletion whose revoke fails deletes nothing, and the retry revokes', async () => { | ||
| const pod = await makePod('Retry delete'); | ||
| await issue(await install(pod)); | ||
| await Message.create({ podId: pod._id, userId: ownerId, content: 'keep me' }); | ||
| jest.spyOn(tokens, 'revokeRuntimeTokensAfterLastUninstall').mockRejectedValueOnce(new Error('db blip')); | ||
|
|
||
| await expect(deletePodCascade(String(pod._id))).rejects.toThrow('db blip'); | ||
| expect(await Pod.countDocuments({ _id: pod._id })).toBe(1); | ||
| expect(await Message.countDocuments({ podId: pod._id })).toBe(1); | ||
| expect(await AgentInstallation.countDocuments({ podId: pod._id })).toBe(1); | ||
|
|
||
| await deletePodCascade(String(pod._id)); | ||
| expect(await credentialStatus()).toEqual(['revoked']); | ||
| expect(await Pod.countDocuments({ _id: pod._id })).toBe(0); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.