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
Original file line number Diff line number Diff line change
Expand Up @@ -701,11 +701,10 @@ describe('agentRoutes', () => {
const res = await request(app).put('/agents/mybot/publish');

expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.published).toBe(true);
expect(res.body.lifecycleBypassed).toBe(true);
});

it('publishes agent that is already in staging', async () => {
it('does not report bypass when publishing from staging', async () => {
const { app } = setup({
isAdmin: true,
initialConfig: {
Expand All @@ -725,8 +724,7 @@ describe('agentRoutes', () => {
const res = await request(app).put('/agents/mybot/publish');

expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.published).toBe(true);
expect(res.body.lifecycleBypassed).toBe(false);
});

it('rejects non-admin access', async () => {
Expand Down Expand Up @@ -874,8 +872,7 @@ describe('agentRoutes', () => {
.send({ agentIds: ['mybot'], published: true });

expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.count).toBe(1);
expect(res.body.lifecycleBypassed).toContain('mybot');
});

it('rejects invalid payload', async () => {
Expand Down Expand Up @@ -923,11 +920,42 @@ describe('agentRoutes', () => {

expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.cleanupResults.chatAgents).toBe('success');

const configs = store.chatAgents as Array<{ agentId: string }>;
expect(configs.find(c => c.agentId === 'mybot')).toBeUndefined();
});

it('cascading delete cleans up orchestration config for orchestration agents', async () => {
const { app, store } = setup({
isAdmin: true,
initialConfig: {
agents: {
orchbot: { name: 'Orch Bot', instructions: 'Route queries' },
otherbot: { name: 'Other', instructions: 'Keep me' },
},
chatAgents: [
{
agentId: 'orchbot',
lifecycleStage: 'draft',
published: false,
visible: false,
featured: false,
},
],
},
});

const res = await request(app).delete('/agents/orchbot');

expect(res.status).toBe(200);
expect(res.body.cleanupResults.orchestration).toBe('success');

const agentMap = store.agents as Record<string, unknown>;
expect(agentMap.orchbot).toBeUndefined();
expect(agentMap.otherbot).toBeDefined();
});

it('non-admin can delete their own draft agent', async () => {
const { app } = setup({
isAdmin: false,
Expand Down Expand Up @@ -1041,7 +1069,9 @@ describe('agentRoutes', () => {
);

expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.cleanupResults.kagenti).toContain(
'requires DELETE /kagenti/agents',
);
});
});

Expand Down
142 changes: 125 additions & 17 deletions workspaces/augment/plugins/augment-backend/src/routes/agentRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,7 @@

// ---------------------------------------------------------------------------
// PUT /agents/:agentId/publish -- shortcut: promote to production
// Logs audit warning when bypassing lifecycle stages.
// ---------------------------------------------------------------------------
router.put(
'/agents/:agentId/publish',
Expand All @@ -503,6 +504,9 @@
const configs = await loadChatAgentConfigs();
const existing = configs.find(c => c.agentId === agentId);
const now = new Date().toISOString();
const currentStage = normalizeLifecycleStage(existing?.lifecycleStage);

const bypassed = !isValidTransition(currentStage, 'production');

if (existing) {
existing.lifecycleStage = 'production';
Expand Down Expand Up @@ -531,10 +535,26 @@
target: agentId,
outcome: 'success',
sourceIp: AuditLogger.extractIp(req),
meta: { to: 'production', direction: 'publish' },
meta: {
from: currentStage,
to: 'production',
direction: 'publish',
lifecycleBypassed: bypassed,
},
});
if (bypassed) {
logger.warn(
`Admin bypass: "${agentId}" published from "${currentStage}" (skipped lifecycle) by ${userRef}`,
);
} else {
logger.info(`Agent "${agentId}" published by ${userRef}`);
}
res.json({
success: true,
agentId,
published: true,
lifecycleBypassed: bypassed,
});
logger.info(`Agent "${agentId}" published by ${userRef}`);
res.json({ success: true, agentId, published: true });
},
),
);
Expand Down Expand Up @@ -590,6 +610,7 @@

// ---------------------------------------------------------------------------
// PUT /agents/bulk-publish -- bulk publish/unpublish
// Logs audit warning per agent when lifecycle is bypassed.
// ---------------------------------------------------------------------------
router.put(
'/agents/bulk-publish',
Expand All @@ -597,7 +618,7 @@
withRoute(
'PUT /agents/bulk-publish',
'Failed to bulk update publish state',
async (req, res) => {

Check failure on line 621 in workspaces/augment/plugins/augment-backend/src/routes/agentRoutes.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 18 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=redhat-developer_rhdh-plugins&issues=AZ5LY92lKhw97-c5PKRq&open=AZ5LY92lKhw97-c5PKRq&pullRequest=3225
const { agentIds, published } = req.body as {
agentIds: string[];
published: boolean;
Expand All @@ -617,8 +638,18 @@
? 'production'
: 'staging';

const bypassed: string[] = [];

for (const agentId of agentIds) {
const existing = configMap.get(agentId);
const currentStage = normalizeLifecycleStage(
existing?.lifecycleStage,
);

if (!isValidTransition(currentStage, targetStage)) {
bypassed.push(agentId);
}

if (existing) {
existing.lifecycleStage = targetStage;
existing.published = published;
Expand Down Expand Up @@ -648,10 +679,34 @@
}

await saveChatAgentConfigs(configs, userRef);

if (bypassed.length > 0) {
audit.log({
action: 'agent.lifecycle',
actor: userRef,
target: bypassed.join(', '),
outcome: 'success',
sourceIp: AuditLogger.extractIp(req),
meta: {
direction: 'bulk-publish',
lifecycleBypassed: true,
bypassedCount: bypassed.length,
},
});
logger.warn(
`Admin bypass: bulk-publish of ${bypassed.length}/${agentIds.length} agents skipped lifecycle by ${userRef}`,
);
}

logger.info(
`Bulk ${published ? 'publish' : 'unpublish'} of ${agentIds.length} agents by ${userRef}`,
);
res.json({ success: true, count: agentIds.length, published });
res.json({
success: true,
count: agentIds.length,
published,
lifecycleBypassed: bypassed.length > 0 ? bypassed : undefined,
});
},
),
);
Expand Down Expand Up @@ -691,7 +746,11 @@
);

// ---------------------------------------------------------------------------
// DELETE /agents/:agentId -- remove agent lifecycle config entry
// DELETE /agents/:agentId -- enterprise-grade cascading delete
// Detects the agent's source and cleans up all stores:
// - chatAgents lifecycle config entry
// - orchestration admin config ('agents' key) for responses-api agents
// Kagenti K8s resource cleanup requires the dedicated admin endpoint.
// Non-admins can only delete their own draft agents.
// Admins can delete any agent's config.
// ---------------------------------------------------------------------------
Expand All @@ -700,51 +759,100 @@
withRoute(
'DELETE /agents/:agentId',
'Failed to delete agent config',
async (req, res) => {

Check failure on line 762 in workspaces/augment/plugins/augment-backend/src/routes/agentRoutes.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 28 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=redhat-developer_rhdh-plugins&issues=AZ5LY92lKhw97-c5PKRr&open=AZ5LY92lKhw97-c5PKRr&pullRequest=3225
const agentId = decodeURIComponent(req.params.agentId);
const userRef = await ctx.getUserRef(req);
const isAdmin = await ctx.checkIsAdmin(req);
const admin = await ctx.checkIsAdmin(req);

const { agents: allAgents } = await buildUnifiedAgentList();
const agentRecord = allAgents.find(a => a.id === agentId);
const source = agentRecord?.source ?? 'unknown';

const configs = await loadChatAgentConfigs();
const idx = configs.findIndex(c => c.agentId === agentId);

if (idx === -1) {
res.status(404).json({ error: 'Agent config not found' });
if (idx === -1 && !agentRecord) {
res.status(404).json({ error: 'Agent not found' });
return;
}

const existing = configs[idx];
const stage = normalizeLifecycleStage(existing.lifecycleStage);
const existing = idx !== -1 ? configs[idx] : undefined;

Check warning on line 779 in workspaces/augment/plugins/augment-backend/src/routes/agentRoutes.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unexpected negated condition.

See more on https://sonarcloud.io/project/issues?id=redhat-developer_rhdh-plugins&issues=AZ5LY92lKhw97-c5PKRs&open=AZ5LY92lKhw97-c5PKRs&pullRequest=3225
const stage = normalizeLifecycleStage(existing?.lifecycleStage);

if (!isAdmin) {
if (!admin) {
if (stage !== 'draft') {
res.status(403).json({
error: 'Non-admin users can only delete agents in draft stage.',
});
return;
}
if (existing.createdBy && existing.createdBy !== userRef) {
if (existing?.createdBy && existing.createdBy !== userRef) {
res.status(403).json({
error: 'You can only delete agents you created.',
});
return;
}
}

configs.splice(idx, 1);
await saveChatAgentConfigs(configs, userRef);
const cleanupResults: Record<string, string> = {};

if (idx !== -1) {

Check warning on line 799 in workspaces/augment/plugins/augment-backend/src/routes/agentRoutes.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unexpected negated condition.

See more on https://sonarcloud.io/project/issues?id=redhat-developer_rhdh-plugins&issues=AZ5LY92lKhw97-c5PKRt&open=AZ5LY92lKhw97-c5PKRt&pullRequest=3225
configs.splice(idx, 1);
await saveChatAgentConfigs(configs, userRef);
cleanupResults.chatAgents = 'success';
} else {
cleanupResults.chatAgents = 'skipped';
}

if (source === 'orchestration') {
try {
const raw = await adminConfig.get('agents');
if (raw && typeof raw === 'object') {
const agentMap = { ...(raw as Record<string, unknown>) };
if (Object.hasOwn(agentMap, agentId)) {
delete agentMap[agentId];
await adminConfig.set('agents', agentMap, userRef);
cleanupResults.orchestration = 'success';
} else {
cleanupResults.orchestration = 'skipped';
}
} else {
cleanupResults.orchestration = 'skipped';
}
} catch (err) {
cleanupResults.orchestration = `failed: ${err instanceof Error ? err.message : 'unknown'}`;
logger.warn(
`Orchestration cleanup failed for "${agentId}": ${cleanupResults.orchestration}`,
);
}
} else {
cleanupResults.orchestration = 'skipped';
}

if (source === 'kagenti') {
cleanupResults.kagenti =
'requires DELETE /kagenti/agents/:ns/:name (admin endpoint)';
} else {
cleanupResults.kagenti = 'skipped';
}

audit.log({
action: 'agent.delete',
actor: userRef,
target: agentId,
outcome: 'success',
sourceIp: AuditLogger.extractIp(req),
meta: { lifecycleStage: stage, isAdmin },
meta: {
lifecycleStage: stage,
isAdmin: admin,
source,
cleanupResults,
},
});
logger.info(
`Agent config "${agentId}" deleted (stage: ${stage}) by ${userRef}`,
`Agent "${agentId}" deleted (source: ${source}, stage: ${stage}) by ${userRef}`,
);
res.json({ success: true, agentId });
res.json({ success: true, agentId, source, cleanupResults });
},
),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ import { useApi } from '@backstage/core-plugin-api';
import type { ChatAgent } from '@red-hat-developer-hub/backstage-plugin-augment-common';
import { normalizeLifecycleStage } from '@red-hat-developer-hub/backstage-plugin-augment-common';
import { augmentApiRef } from '../../../api';
import { useAdminConfig } from '../../../hooks';
import { useEffectiveConfig } from '../../../hooks/useEffectiveConfig';
import { agentFromConfig } from '../AgentsPanel/agentValidation';
import { ConfirmDialog } from '../shared/ConfirmDialog';
Expand Down Expand Up @@ -88,7 +87,6 @@ export function OrchAgentDetailView({
const theme = useTheme();
const api = useApi(augmentApiRef);

const { entry: agentsEntry, save: saveAgents } = useAdminConfig('agents');
const { config: effectiveConfig } = useEffectiveConfig();

const [lifecycleStage, setLifecycleStage] = useState<string>(
Expand Down Expand Up @@ -180,25 +178,15 @@ export function OrchAgentDetailView({
const handleDelete = useCallback(async () => {
setError(null);
try {
if (
!agentsEntry?.configValue ||
typeof agentsEntry.configValue !== 'object'
) {
throw new Error('Unable to load current agent configuration');
}
const existing = {
...(agentsEntry.configValue as Record<string, unknown>),
};
delete existing[agent.id];
await saveAgents(existing);
await api.deleteAgentConfig(agent.id);
setToast({ message: 'Agent deleted', severity: 'success' });
onBack();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete agent');
} finally {
setDeleteOpen(false);
}
}, [agentsEntry, agent.id, saveAgents, onBack]);
}, [api, agent.id, onBack]);

const roleLabel = agent.agentRole
? agent.agentRole.charAt(0).toUpperCase() + agent.agentRole.slice(1)
Expand Down
Loading
Loading