Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions backend/__tests__/service/runtimeTokenRevoke.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ jest.mock('../../services/starterTaskService', () => ({ completeConnectAgentStar
const Pod = require('../../models/Pod');
const User = require('../../models/User');
const AgentCredential = require('../../models/AgentCredential');
const Machine = require('../../models/Machine');
const { reapSpawnCredentials, SPAWN_CREDENTIAL_RETENTION_MS } = require('../../services/spawnCredentialReaper');
const { AgentInstallation } = require('../../models/AgentRegistry');
const AgentIdentityService = require('../../services/agentIdentityService');
const { issueRuntimeTokenForAgent } = require('../../routes/registry/tokens');
Expand Down Expand Up @@ -93,6 +95,49 @@ describe('revoking a runtime token kills it in every store', () => {
});
afterEach(() => jest.restoreAllMocks());

test('Settings lists long-lived keys with machine names, without spawn children', async () => {
await seed();
const agentUser = await AgentIdentityService.getOrCreateAgentUser(AGENT, { instanceId: 'default' });
const machineId = 'machine-settings-key';
await Machine.create({ ownerUserId: mockOwner.id, machineId, name: "Sam's MacBook" });
await AgentCredential.create([
{ tokenHash: 'long-lived-key', kind: 'runtime', ownerUserId: mockOwner.id,
agentUserId: agentUser._id, machineId, label: 'Runtime token' },
{ tokenHash: 'spawn-child-key', kind: 'runtime', ownerUserId: mockOwner.id,
agentUserId: agentUser._id, machineId, label: 'spawn:batch-1', scopes: ['spawn'],
expiresAt: new Date(Date.now() + 60_000) },
]);
const response = await request(app).get(`/api/credentials?agentName=${AGENT}&instanceId=default`);
expect(response.status).toBe(200);
expect(response.body).toEqual([expect.objectContaining({
label: 'Runtime token', machineName: "Sam's MacBook", status: 'active',
})]);
expect(JSON.stringify(response.body)).not.toContain('spawn:');
await AgentCredential.deleteOne({ tokenHash: 'long-lived-key' });
const spawnOnly = await request(app).get(`/api/credentials?agentName=${AGENT}&instanceId=default`);
expect(spawnOnly.status).toBe(200);
expect(spawnOnly.body).toEqual([]);
});

test('reaper removes old spawn children only, including revoked children', async () => {
const now = new Date('2026-10-24T08:00:00Z');
const cutoff = new Date(now.getTime() - SPAWN_CREDENTIAL_RETENTION_MS);
const agentUser = await AgentIdentityService.getOrCreateAgentUser(AGENT, { instanceId: 'default' });
const common = { kind: 'runtime', ownerUserId: mockOwner.id, agentUserId: agentUser._id };
await AgentCredential.create([
{ ...common, tokenHash: 'expired-spawn', scopes: ['spawn'],
expiresAt: new Date(cutoff.getTime() - 1000) },
{ ...common, tokenHash: 'revoked-spawn', scopes: ['spawn'], status: 'revoked',
revokedAt: new Date(cutoff.getTime() - 1000), expiresAt: new Date(cutoff.getTime() - 1000) },
{ ...common, tokenHash: 'recent-spawn', scopes: ['spawn'],
expiresAt: new Date(cutoff.getTime() + 1000) },
{ ...common, tokenHash: 'old-runtime', expiresAt: new Date(cutoff.getTime() - 1000) },
]);
expect(await reapSpawnCredentials(now)).toBe(2);
expect((await AgentCredential.find({}).select('tokenHash').lean()).map((row) => row.tokenHash).sort())
.toEqual(['old-runtime', 'recent-spawn']);
});

test('the owner revoking it from the token list: the next runtime call is a 401', async () => {
const { workspace } = await seed();
const issued = await request(app)
Expand Down
7 changes: 7 additions & 0 deletions backend/models/AgentCredential.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ AgentCredentialSchema.index({ ownerUserId: 1, status: 1 });
AgentCredentialSchema.index({ parentId: 1 });
AgentCredentialSchema.index({ agentUserId: 1 });
AgentCredentialSchema.index({ machineId: 1, kind: 1, status: 1 });
// Mongo's TTL monitor is the storage backstop for per-spawn children. The
// scheduler also reaps them so cleanup is observable and does not depend on
// the monitor catching up after a rollout.
AgentCredentialSchema.index(
{ expiresAt: 1 },
{ expireAfterSeconds: 3600, partialFilterExpression: { scopes: 'spawn' } },
);

// Revoke a credential AND every descendant, breadth-first. One level deep
// today (daemon → runtime), but written as a walk so a deeper chain can
Expand Down
16 changes: 14 additions & 2 deletions backend/routes/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import rateLimit from 'express-rate-limit';
import { cloudflareIpRateLimitKeyGenerator } from '../middleware/ipRateLimit';
import { createHash } from 'crypto';
import AgentCredential from '../models/AgentCredential';
import Machine from '../models/Machine';
import { AgentInstallation } from '../models/AgentRegistry';
import { ownsRuntimeIdentity } from '../services/runtimeIdentityOwnership';
import { revokeRuntimeTokenEverywhere } from './registry/tokens';
Expand Down Expand Up @@ -83,13 +84,24 @@ router.get('/', credentialRateLimit, auth, async (req: AuthReq, res: express.Res
} },
{ upsert: true },
)));
filter = { agentUserId: agent._id, kind: 'runtime' };
// Spawn children are short-lived implementation credentials. Filtering
// in Mongo keeps a busy seat's thousands of children out of Settings.
filter = { agentUserId: agent._id, kind: 'runtime', scopes: { $ne: 'spawn' } };
}
const rows = await AgentCredential.find(filter)
.select('-tokenHash')
.sort({ createdAt: -1 })
.lean();
return res.json(rows);
const machineIds = [...new Set(rows.map((row) => row.machineId).filter(Boolean))];
const machines = machineIds.length
? await Machine.find({ machineId: { $in: machineIds } }).select('machineId name').lean()
: [];
const machineNames = new Map(machines.map((machine) => [machine.machineId, machine.name]));
return res.json(rows.map((row) => ({
...row,
...(row.machineId && machineNames.has(row.machineId)
? { machineName: machineNames.get(row.machineId) } : {}),
})));
} catch (err) {
console.error('Error listing credentials:', err);
return res.status(500).json({ message: 'Server error' });
Expand Down
16 changes: 16 additions & 0 deletions backend/services/schedulerService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,21 @@ class SchedulerService {
{ scheduled: false, timezone: 'UTC' },
);

const spawnCredentialReapJob: CronJob = cron.schedule(
'*/5 * * * *',
async () => {
try {
// eslint-disable-next-line global-require, @typescript-eslint/no-require-imports
const { reapSpawnCredentials } = require('./spawnCredentialReaper');
const removed = await reapSpawnCredentials();
if (removed) console.log(`[spawn-credentials] reaped=${removed}`);
} catch (error) {
console.error('[spawn-credentials] reap failed:', error);
}
},
{ scheduled: false, timezone: 'UTC' },
);

// TASK-260: remove the stranger journey's throwaway accounts (they cannot
// delete themselves). Half an hour after each 02:17/14:17 journey run;
// only accounts older than two hours, so a run in flight is never touched.
Expand Down Expand Up @@ -587,6 +602,7 @@ class SchedulerService {
installableReconcileJob,
decisionCardReconcileJob,
approvalToolCallSweepJob,
spawnCredentialReapJob,
spendAlarmJob,
...(process.env.SYNTHETIC_ACCOUNT_PURGE === 'true' ? [syntheticAccountPurgeJob] : []),
];
Expand Down
17 changes: 17 additions & 0 deletions backend/services/spawnCredentialReaper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import AgentCredential from '../models/AgentCredential';

// Keep a short window for diagnosing a finished spawn, then remove its leaf
// credential. Authorization already stops at expiresAt; this is storage GC.
export const SPAWN_CREDENTIAL_RETENTION_MS = 60 * 60 * 1000;

export async function reapSpawnCredentials(now: Date = new Date()): Promise<number> {
const cutoff = new Date(now.getTime() - SPAWN_CREDENTIAL_RETENTION_MS);
const result = await AgentCredential.deleteMany({
scopes: 'spawn',
expiresAt: { $lte: cutoff },
});
return result.deletedCount || 0;
}

module.exports = { SPAWN_CREDENTIAL_RETENTION_MS, reapSpawnCredentials };
Object.assign(module.exports, exports);
3 changes: 3 additions & 0 deletions frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2424,6 +2424,9 @@
"done": "Done",
"empty": "No runtime keys yet.",
"runtimeKey": "Runtime key",
"cliKey": "CLI key",
"noActiveKeys": "No active keys.",
"revokedCount": "{{count}} revoked",
"active": "Active",
"revoked": "Revoked",
"created": "Created {{date}}",
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -2415,6 +2415,9 @@
"done": "完成",
"empty": "暂无运行时密钥。",
"runtimeKey": "运行时密钥",
"cliKey": "CLI 密钥",
"noActiveKeys": "没有有效的密钥。",
"revokedCount": "已撤销{{count}}个",
"active": "有效",
"revoked": "已撤销",
"created": "创建于 {{date}}",
Expand Down
35 changes: 32 additions & 3 deletions frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ test('nondefault wrapper instances never link to a default-instance reconnect',
test('regeneration waits for the explicit running-key replacement confirmation', async () => {
axios.post.mockResolvedValue({ data: { token: 'cm_agent_once' } });
renderPage();
await screen.findByText('Laptop');
await screen.findByText('Runtime key · Laptop');
fireEvent.click(screen.getByRole('button', { name: 'Regenerate key' }));
expect(axios.post).not.toHaveBeenCalled();
expect(screen.getByTestId('agent-key-replace-confirmation')).toHaveTextContent('Replacing it will disconnect');
Expand Down Expand Up @@ -260,7 +260,7 @@ test('an in-flight replacement never reveals its key on another agent page', asy
</MemoryRouter>
</AuthContext.Provider>,
);
await screen.findByText('Laptop');
await screen.findByText('Runtime key · Laptop');
fireEvent.click(screen.getByRole('button', { name: 'Regenerate key' }));
fireEvent.click(screen.getByRole('button', { name: /replace the running agent/i }));
await waitFor(() => expect(axios.post).toHaveBeenCalledTimes(1));
Expand Down Expand Up @@ -302,7 +302,7 @@ test('a partial installation owner cannot see identity-wide key controls', async
test('revocation uses the credential id only after confirmation', async () => {
axios.delete.mockResolvedValue({ data: { revoked: 1 } });
renderPage();
await screen.findByText('Laptop');
await screen.findByText('Runtime key · Laptop');
fireEvent.click(screen.getByRole('button', { name: 'Revoke' }));
expect(axios.delete).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: 'Revoke key' }));
Expand All @@ -311,6 +311,35 @@ test('revocation uses the credential id only after confirmation', async () => {
}));
});

test('shows active keys first and keeps revoked history collapsed', async () => {
const defaultGet = axios.get.getMockImplementation();
axios.get.mockImplementation((url, options) => (
url === '/api/credentials' ? Promise.resolve({ data: [
{ _id: 'old', label: 'Old token', status: 'revoked', revokedAt: '2026-10-02T00:00:00Z' },
{ _id: 'daemon', label: 'Runtime token', machineId: 'opaque-id', machineName: "Sam's MacBook", status: 'active' },
] }) : defaultGet(url, options)
));
renderPage();
expect(await screen.findByText("Runtime key · Sam's MacBook")).toBeInTheDocument();
const disclosure = screen.getByText('1 revoked');
expect(disclosure.closest('details')).not.toHaveAttribute('open');
fireEvent.click(disclosure);
expect(disclosure.closest('details')).toHaveAttribute('open');
expect(screen.getByText('Runtime key · Old token')).toBeInTheDocument();
});

test('a revoked-only key list has a useful empty state and accessible history', async () => {
const defaultGet = axios.get.getMockImplementation();
axios.get.mockImplementation((url, options) => (
url === '/api/credentials' ? Promise.resolve({ data: [
{ _id: 'old', label: 'Previous key', status: 'revoked' },
] }) : defaultGet(url, options)
));
renderPage();
expect(await screen.findByText('No active keys.')).toBeInTheDocument();
expect(screen.getByText('1 revoked').closest('details')).not.toHaveAttribute('open');
});

test('selects an installation the owner can manage without exposing a foreign pod', async () => {
axios.get.mockImplementation((url) => {
if (url.includes('/manage-settings')) return Promise.resolve({ data: {
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/v2/__tests__/v2-layout-invariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,13 @@ describe('v2 layout invariants (CSS rule presence)', () => {
expect(mediaBlockContaining(agentSettings, '.v2-agent-settings__key-meta')).toContain('display: grid');
});

test('agent Settings section headers stack at phone width so the key hint stays visible', () => {
const phone = mediaBlockContaining(agentSettings, '.v2-agent-settings__section-head');
const header = selectorRuleBody(phone, '.v2-agent-settings__section-head');
expect(header).toContain('flex-direction: column');
expect(header).toContain('align-items: flex-start');
});

test('agent Settings buttons retain their chrome over the v2 button reset', () => {
const button = ruleBody(agentSettings, '.v2-root .v2-agent-settings button:not(.MuiButtonBase-root)');
expect(button).toContain('border: 1px solid var(--v2-border)');
Expand Down
77 changes: 46 additions & 31 deletions frontend/src/v2/agents/V2AgentSettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import './v2-agent-settings.css';
interface RuntimeKey {
_id: string;
label: string;
machineId?: string | null;
machineName?: string;
createdAt?: string;
lastUsedAt?: string | null;
revokedAt?: string | null;
Expand Down Expand Up @@ -184,6 +186,41 @@ const V2AgentSettingsPage: React.FC = () => {
...runtimeControls,
...(runtimeManaged !== undefined ? { managed: runtimeManaged } : {}),
};
const activeKeys = keys.filter((key) => key.status === 'active');
const revokedKeys = keys.filter((key) => key.status === 'revoked');
const keyLabel = (key: RuntimeKey): string => {
const defaultLabel = !key.label || key.label === 'Runtime token' || key.label === 'Agent Settings replacement';
const kind = runtime.runtimeType === 'wrapper' ? t('agentSettings.cliKey') : t('agentSettings.runtimeKey');
const detail = key.machineId ? key.machineName : (defaultLabel ? null : key.label);
return detail ? `${kind} · ${detail}` : kind;
};
const renderKey = (key: RuntimeKey) => (
<li key={key._id} className="v2-agent-settings__key">
<div className="v2-agent-settings__key-main">
<strong>{keyLabel(key)}</strong>
<span>{key.status === 'revoked' ? t('agentSettings.revoked') : t('agentSettings.active')}</span>
</div>
<div className="v2-agent-settings__key-meta">
<span>{t('agentSettings.created', { date: dateLabel(key.createdAt, t) })}</span>
<span>{t('agentSettings.lastUsed', { date: dateLabel(key.lastUsedAt, t) })}</span>
{key.status === 'revoked' && <span>{t('agentSettings.revokedAt', { date: dateLabel(key.revokedAt, t) })}</span>}
</div>
{key.status === 'active' && pendingAction === key._id && (
<div className="v2-agent-settings__confirm">
<p>{t('agentSettings.revokeWarning')}</p>
<div className="v2-agent-settings__actions">
<button type="button" disabled={busy} onClick={() => revokeKey(key._id)}>{t('agentSettings.revokeKey')}</button>
<button type="button" disabled={busy} onClick={() => setPendingAction(null)}>{t('agentSettings.keepKey')}</button>
</div>
</div>
)}
{key.status === 'active' && pendingAction !== key._id && (
<button type="button" className="v2-agent-settings__quiet" disabled={busy} onClick={() => setPendingAction(key._id)}>
{t('agentSettings.revoke')}
</button>
)}
</li>
);

return (
<main className="v2-agent-settings">
Expand Down Expand Up @@ -222,37 +259,15 @@ const V2AgentSettingsPage: React.FC = () => {
<button type="button" onClick={() => setOneTimeKey(null)}>{t('agentSettings.done')}</button>
</div>
)}
{keysForbidden ? <p className="v2-agent-settings__empty">{t('agentSettings.keyUnavailable')}</p>
: keys.length === 0 ? <p className="v2-agent-settings__empty">{t('agentSettings.empty')}</p> : (
<ul className="v2-agent-settings__keys">
{keys.map((key) => (
<li key={key._id} className="v2-agent-settings__key">
<div className="v2-agent-settings__key-main">
<strong>{key.label || t('agentSettings.runtimeKey')}</strong>
<span>{key.status === 'revoked' ? t('agentSettings.revoked') : t('agentSettings.active')}</span>
</div>
<div className="v2-agent-settings__key-meta">
<span>{t('agentSettings.created', { date: dateLabel(key.createdAt, t) })}</span>
<span>{t('agentSettings.lastUsed', { date: dateLabel(key.lastUsedAt, t) })}</span>
{key.status === 'revoked' && <span>{t('agentSettings.revokedAt', { date: dateLabel(key.revokedAt, t) })}</span>}
</div>
{key.status === 'active' && pendingAction === key._id && (
<div className="v2-agent-settings__confirm">
<p>{t('agentSettings.revokeWarning')}</p>
<div className="v2-agent-settings__actions">
<button type="button" disabled={busy} onClick={() => revokeKey(key._id)}>{t('agentSettings.revokeKey')}</button>
<button type="button" disabled={busy} onClick={() => setPendingAction(null)}>{t('agentSettings.keepKey')}</button>
</div>
</div>
)}
{key.status === 'active' && pendingAction !== key._id && (
<button type="button" className="v2-agent-settings__quiet" disabled={busy} onClick={() => setPendingAction(key._id)}>
{t('agentSettings.revoke')}
</button>
)}
</li>
))}
</ul>
{keysForbidden ? <p className="v2-agent-settings__empty">{t('agentSettings.keyUnavailable')}</p> : (
<>
{activeKeys.length ? <ul className="v2-agent-settings__keys">{activeKeys.map(renderKey)}</ul>
: <p className="v2-agent-settings__empty">{t('agentSettings.noActiveKeys')}</p>}
{revokedKeys.length > 0 && <details className="v2-agent-settings__revoked-keys">
<summary>{t('agentSettings.revokedCount', { count: revokedKeys.length })}</summary>
<ul className="v2-agent-settings__keys">{revokedKeys.map(renderKey)}</ul>
</details>}
</>
)}
</section>
{installation && <V2AgentIdentitySection key={`${agentName}:${instanceId}`} installation={installation} update={update} />}
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/v2/agents/v2-agent-settings.css
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@
.v2-agent-settings__error { color: var(--v2-danger); }
.v2-agent-settings__empty { margin-top: 24px; color: var(--v2-text-secondary); }
.v2-agent-settings__keys { list-style: none; padding: 0; margin: 20px 0 0; }
.v2-agent-settings__revoked-keys { margin-top: 20px; border-top: 1px solid var(--v2-border-soft); padding-top: 14px; }
.v2-agent-settings__revoked-keys summary { width: fit-content; cursor: pointer; color: var(--v2-text-secondary); font-family: var(--v2-font-mono); font-size: 12px; }
.v2-agent-settings__revoked-keys summary:focus-visible { box-shadow: var(--v2-focus-ring); outline: 1px solid var(--v2-accent); }
.v2-agent-settings__revoked-keys .v2-agent-settings__keys { margin-top: 8px; }
.v2-agent-settings__key { position: relative; padding: 14px 0; border-top: 1px solid var(--v2-border-soft); }
.v2-agent-settings__key-main { display: flex; gap: 12px; align-items: baseline; font-size: 14px; }
.v2-agent-settings__key-main span { color: var(--v2-text-tertiary); font-size: 12px; }
Expand Down Expand Up @@ -102,5 +106,6 @@

@media (max-width: 760px) {
.v2-agent-settings { padding: 16px; }
.v2-agent-settings__section-head { flex-direction: column; align-items: flex-start; }
.v2-agent-settings__key-meta { display: grid; gap: 4px; }
}
Loading