@desplega.ai/agent-swarm 1.151.0 → 1.152.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/{acp-adapter-ex8hzjfc.js → acp-adapter-d05sg0q4.js} +1 -1
- package/dist/{actions-2bgpyhww.js → actions-4sc0v342.js} +13 -13
- package/dist/{app-1706zhr1.js → app-2d0v6kt6.js} +3 -3
- package/dist/{assistant-bavke5p7.js → assistant-zs6wdnrf.js} +14 -14
- package/dist/{boot-reembed-nafcmmmc.js → boot-reembed-3dwdnnsr.js} +3 -3
- package/dist/{boot-reembed-3cmrrmpa.js → boot-reembed-vhh33520.js} +4 -4
- package/dist/{boot-scrub-logs-cd6a2sq0.js → boot-scrub-logs-e1fnabcg.js} +2 -2
- package/dist/{claude-adapter-wfp2gzvz.js → claude-adapter-h58xqzzd.js} +3 -3
- package/dist/{claude-sdk-session-bht9jjak.js → claude-sdk-session-pbaczbgt.js} +3 -3
- package/dist/{cli-a1z3h107.js → cli-4fqn2vpg.js} +4 -4
- package/dist/{cli-g69fdc9g.js → cli-5bqt4gwa.js} +2 -2
- package/dist/{cli-gjyvhmmh.js → cli-5p007m2q.js} +5 -5
- package/dist/{cli-5jd2z7av.js → cli-6k2athpf.js} +6 -2
- package/dist/{cli-5tpgq60f.js → cli-72vksk1n.js} +152 -23
- package/dist/{cli-vwm416m6.js → cli-87hdfyde.js} +1 -1
- package/dist/{cli-3v9xhjcn.js → cli-8fy9dmpv.js} +2 -2
- package/dist/{cli-czjtgfys.js → cli-awb59f08.js} +2 -2
- package/dist/{cli-j9xczhaq.js → cli-cs9z1hw3.js} +1 -1
- package/dist/{cli-r2q6pevn.js → cli-cwvey7n1.js} +43 -21
- package/dist/{cli-ppm4975n.js → cli-evgy5w3y.js} +1 -1
- package/dist/{cli-frcta2w4.js → cli-f1f9n77e.js} +1 -1
- package/dist/{cli-9004vfdf.js → cli-fhdssm2v.js} +2 -2
- package/dist/{cli-s14agm5s.js → cli-gacq5hg8.js} +12 -3
- package/dist/{cli-zkxymas5.js → cli-jh9txj4n.js} +1 -1
- package/dist/{cli-q88st9aj.js → cli-nd5s3m4y.js} +2 -2
- package/dist/{cli-sjaae6ff.js → cli-p2hx1pxm.js} +3 -3
- package/dist/{cli-2tbvf1z1.js → cli-t1mykkng.js} +8 -8
- package/dist/{cli-7tgcstn2.js → cli-t6axtdcg.js} +2 -2
- package/dist/{cli-f1wky6zp.js → cli-wp7cejrg.js} +1 -1
- package/dist/{cli-0qb1z6s2.js → cli-zdcdh02m.js} +1 -1
- package/dist/{cli-gbfwcd3k.js → cli-zfzznn7c.js} +7 -6
- package/dist/cli.js +11 -11
- package/dist/{codex-adapter-jjjk8w4m.js → codex-adapter-v3cz462q.js} +2 -2
- package/dist/{codex-session-runner-nd6ayxkx.js → codex-session-runner-de1a5g5z.js} +2 -2
- package/dist/{commands-9v4psyq1.js → commands-wfqqpn95.js} +2 -2
- package/dist/{db-wnt58tpe.js → db-9zzv5y05.js} +2 -2
- package/dist/{dispatcher-05jh1aqk.js → dispatcher-hfm1jbgx.js} +12 -12
- package/dist/{handlers-tw90pkh5.js → handlers-wrrhsack.js} +15 -15
- package/dist/{hook-sxe3r349.js → hook-9skye8zd.js} +1 -1
- package/dist/{hook-123e2scb.js → hook-fgjv62k5.js} +2 -2
- package/dist/{http-ecsar8ez.js → http-7grttjfq.js} +37 -34
- package/dist/{index-nnrbpcjp.js → index-29gxx9ar.js} +12 -12
- package/dist/{index-hw26vj40.js → index-30pz2h9w.js} +12 -12
- package/dist/{index-zj518gf9.js → index-4ek1df87.js} +13 -13
- package/dist/{index-16js521z.js → index-myfwqqyq.js} +8 -8
- package/dist/{index-ymn7em0p.js → index-pfhzrh1d.js} +3 -3
- package/dist/{keepalive-asqtcm4y.js → keepalive-j75m2m7k.js} +4 -4
- package/dist/{lead-xbfr7fvt.js → lead-a1hpjt0a.js} +16 -16
- package/dist/{maintenance-qma55qvw.js → maintenance-tngjzqdq.js} +4 -4
- package/dist/{oauth-refresh-sweep-3s04c6qw.js → oauth-refresh-sweep-em1fn044.js} +3 -3
- package/dist/{onboard-drskd386.js → onboard-fv7ezvwx.js} +2 -2
- package/dist/{otel-impl-943c96er.js → otel-impl-6vbb568y.js} +1 -1
- package/dist/{pricing-refresh-mx51az05.js → pricing-refresh-nmm949an.js} +4 -4
- package/dist/{rbac-roles-wcmrg6sv.js → rbac-roles-0fz5qryx.js} +4 -4
- package/dist/{rbac-roles-78baecqd.js → rbac-roles-xtp05syf.js} +3 -3
- package/dist/{render-v2-8zjntgf5.js → render-v2-fpb8whme.js} +3 -3
- package/dist/{seed-pricing-jqjs6cws.js → seed-pricing-zwa8bs3t.js} +3 -3
- package/dist/{setup-4pks0vfk.js → setup-3ybwbdpd.js} +2 -2
- package/dist/{work-objects-wangt792.js → work-objects-rmayxcx7.js} +18 -17
- package/dist/{worker-p2mr422x.js → worker-rng4jhnh.js} +16 -16
- package/openapi.json +4 -4
- package/package.json +1 -1
- package/src/be/db.ts +1 -1
- package/src/be/scripts/typecheck.ts +4 -0
- package/src/be/swarm-config-guard.ts +7 -1
- package/src/extensions/lifecycle.ts +26 -4
- package/src/http/extensions.ts +8 -3
- package/src/rbac/legacy-policy.ts +13 -1
- package/src/scripts-runtime/sdk-allowlist.ts +4 -0
- package/src/scripts-runtime/types/stdlib.d.ts +4 -0
- package/src/scripts-runtime/types/swarm-sdk.d.ts +4 -0
- package/src/server.ts +12 -0
- package/src/slack/render-v2.ts +10 -4
- package/src/slack/responses.ts +7 -4
- package/src/slack/task-output.ts +21 -0
- package/src/slack/watcher.ts +3 -2
- package/src/slack/work-objects.ts +2 -1
- package/src/tests/extensions-example-mcp-install.test.ts +2 -2
- package/src/tests/extensions-http.test.ts +84 -3
- package/src/tests/extensions-lead-equivalence.test.ts +21 -0
- package/src/tests/extensions-mcp-tools.test.ts +111 -3
- package/src/tests/fixtures/store-progress-malformed-output.json +4 -0
- package/src/tests/rbac-admission-e2e.test.ts +35 -4
- package/src/tests/rbac-e2e-helpers.ts +18 -0
- package/src/tests/rbac-engine.test.ts +2 -2
- package/src/tests/rbac-mcp-admission-e2e.test.ts +22 -4
- package/src/tests/reload-config.test.ts +17 -0
- package/src/tests/scripts-only-gating.test.ts +4 -0
- package/src/tests/slack-render-v2.test.ts +24 -2
- package/src/tests/slack-task-output.test.ts +36 -0
- package/src/tests/store-progress-attachments-handler.test.ts +100 -1
- package/src/tests/swarm-config-reserved-keys.test.ts +6 -1
- package/src/tests/tool-annotations.test.ts +1 -1
- package/src/tools/extension-activate-version.ts +38 -0
- package/src/tools/extension-common.ts +2 -1
- package/src/tools/extension-delete.ts +29 -0
- package/src/tools/extension-disable.ts +36 -0
- package/src/tools/extension-enable.ts +36 -0
- package/src/tools/extension-install.ts +2 -2
- package/src/tools/store-progress.ts +30 -0
- package/src/tools/tool-config.ts +5 -1
- package/templates/skills/agent-browser/SKILL.md +84 -0
- package/templates/skills/agent-browser/content.md +21 -0
package/src/slack/render-v2.ts
CHANGED
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
import { buildAskClosure, type ClosureState, closureState } from "./closure";
|
|
45
45
|
import { reactionName, type SlackReactionEvent } from "./reaction-shortcode";
|
|
46
46
|
import { getAgentDisplayName, getAgentEmoji } from "./responses";
|
|
47
|
+
import { isSlackEtaOnlyNotice, slackTaskOutput } from "./task-output";
|
|
47
48
|
|
|
48
49
|
const TREE_UPDATE_DEBOUNCE_MS = 500;
|
|
49
50
|
const TREE_UPDATE_MIN_INTERVAL_MS = 3_000;
|
|
@@ -757,7 +758,7 @@ async function outcomeContent(task: AgentTask, slackReplySent: boolean): Promise
|
|
|
757
758
|
: "Agent";
|
|
758
759
|
return `✅ ${agentName} completed`;
|
|
759
760
|
}
|
|
760
|
-
return `✅\n\n${outcomeText(task
|
|
761
|
+
return `✅\n\n${outcomeText(slackTaskOutput(task), "Task completed.")}`;
|
|
761
762
|
}
|
|
762
763
|
|
|
763
764
|
async function agentDisplayNameFor(task: AgentTask): Promise<string> {
|
|
@@ -786,7 +787,7 @@ export async function childOutcomeContent(
|
|
|
786
787
|
if (slackReplySent && !task.tags?.includes("deferred")) {
|
|
787
788
|
return `↳ ✅ ${agentName} completed`;
|
|
788
789
|
}
|
|
789
|
-
return `↳ ✅ ${agentName} — result\n\n${outcomeText(task
|
|
790
|
+
return `↳ ✅ ${agentName} — result\n\n${outcomeText(slackTaskOutput(task), "Task completed.")}`;
|
|
790
791
|
}
|
|
791
792
|
|
|
792
793
|
// A deferred wake continues a human conversation even though the scheduler
|
|
@@ -808,6 +809,7 @@ function isSlackContinuation(task: AgentTask): boolean {
|
|
|
808
809
|
*/
|
|
809
810
|
function isChildCardCandidate(task: AgentTask, delegationActivatedAt: string): boolean {
|
|
810
811
|
return (
|
|
812
|
+
!isSlackEtaOnlyNotice(task) &&
|
|
811
813
|
task.source !== "slack" &&
|
|
812
814
|
!isSlackContinuation(task) &&
|
|
813
815
|
!!task.slackChannelId &&
|
|
@@ -821,6 +823,7 @@ function isChildCardCandidate(task: AgentTask, delegationActivatedAt: string): b
|
|
|
821
823
|
|
|
822
824
|
function taskNeedsDirectOutcome(task: AgentTask, activatedAt: string): boolean {
|
|
823
825
|
return (
|
|
826
|
+
!isSlackEtaOnlyNotice(task) &&
|
|
824
827
|
((task.source === "slack" && isAskOutcomeStatus(task.status)) ||
|
|
825
828
|
(isSlackContinuation(task) && isOutcomeStatus(task.status))) &&
|
|
826
829
|
task.createdAt >= activatedAt
|
|
@@ -839,7 +842,7 @@ async function conclusionResultsLines(closure: AgentTask[]): Promise<string[]> {
|
|
|
839
842
|
const lines: string[] = [];
|
|
840
843
|
for (const member of closure) {
|
|
841
844
|
if (member.taskType === "follow-up" || member.taskType === "reroute-decision") continue;
|
|
842
|
-
if (!isOutcomeStatus(member.status)) continue;
|
|
845
|
+
if (!isOutcomeStatus(member.status) || isSlackEtaOnlyNotice(member)) continue;
|
|
843
846
|
const glyph = await taskStateGlyph(member, new Date());
|
|
844
847
|
const agentName = await agentDisplayNameFor(member);
|
|
845
848
|
const card = await getSlackOutcomeMessage(member.id);
|
|
@@ -847,7 +850,10 @@ async function conclusionResultsLines(closure: AgentTask[]): Promise<string[]> {
|
|
|
847
850
|
lines.push(`↳ ${glyph} ${agentName} — ${card.permalink}`);
|
|
848
851
|
continue;
|
|
849
852
|
}
|
|
850
|
-
const raw = outcomeText(
|
|
853
|
+
const raw = outcomeText(
|
|
854
|
+
member.status === "failed" ? member.failureReason : slackTaskOutput(member),
|
|
855
|
+
"",
|
|
856
|
+
);
|
|
851
857
|
const digest =
|
|
852
858
|
raw.length > CONCLUSION_DIGEST_LENGTH
|
|
853
859
|
? `${raw.slice(0, CONCLUSION_DIGEST_LENGTH).trimEnd()}…`
|
package/src/slack/responses.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
markdownToSlack,
|
|
16
16
|
splitSlackSectionText,
|
|
17
17
|
} from "./blocks";
|
|
18
|
+
import { isSlackEtaOnlyNotice, slackTaskOutput } from "./task-output";
|
|
18
19
|
|
|
19
20
|
// Re-export for backward compatibility
|
|
20
21
|
export { markdownToSlack } from "./blocks";
|
|
@@ -56,7 +57,7 @@ export function shouldPostInlineCompletionOutput(task: AgentTask): boolean {
|
|
|
56
57
|
if (!task.slackChannelId || !task.slackThreadTs) return false;
|
|
57
58
|
if (task.slackReplySent) return false;
|
|
58
59
|
|
|
59
|
-
const output = task
|
|
60
|
+
const output = slackTaskOutput(task)?.trim();
|
|
60
61
|
return !!output && isTreeOutputTruncated(markdownToSlack(output));
|
|
61
62
|
}
|
|
62
63
|
|
|
@@ -85,7 +86,7 @@ export async function sendInlineTaskOutput(task: AgentTask): Promise<boolean> {
|
|
|
85
86
|
const chunks = formatInlineCompletionOutputChunks({
|
|
86
87
|
agentName: agent.name,
|
|
87
88
|
taskId: task.id,
|
|
88
|
-
output: task
|
|
89
|
+
output: slackTaskOutput(task) ?? "",
|
|
89
90
|
});
|
|
90
91
|
|
|
91
92
|
try {
|
|
@@ -115,6 +116,7 @@ export async function sendInlineTaskOutput(task: AgentTask): Promise<boolean> {
|
|
|
115
116
|
* Send a task completion message to Slack with the agent's persona.
|
|
116
117
|
*/
|
|
117
118
|
export async function sendTaskResponse(task: AgentTask): Promise<boolean> {
|
|
119
|
+
if (isSlackEtaOnlyNotice(task)) return true;
|
|
118
120
|
const app = getSlackApp();
|
|
119
121
|
if (!app || !task.slackChannelId || !task.slackThreadTs) {
|
|
120
122
|
return false;
|
|
@@ -136,7 +138,7 @@ export async function sendTaskResponse(task: AgentTask): Promise<boolean> {
|
|
|
136
138
|
|
|
137
139
|
try {
|
|
138
140
|
if (task.status === "completed") {
|
|
139
|
-
const output = task
|
|
141
|
+
const output = slackTaskOutput(task) || "Task completed.";
|
|
140
142
|
const slackOutput = markdownToSlack(output);
|
|
141
143
|
const attachmentsBlock = formatAttachmentsBlockForSlack(await getTaskAttachments(task.id));
|
|
142
144
|
const body = slackOutput + attachmentsBlock;
|
|
@@ -285,6 +287,7 @@ export async function updateToFinal(
|
|
|
285
287
|
task: AgentTask,
|
|
286
288
|
messageTs: string,
|
|
287
289
|
): Promise<SlackUpdateResult> {
|
|
290
|
+
if (isSlackEtaOnlyNotice(task)) return "ok";
|
|
288
291
|
const app = getSlackApp();
|
|
289
292
|
if (!app || !task.slackChannelId || !task.agentId) return "failed";
|
|
290
293
|
|
|
@@ -297,7 +300,7 @@ export async function updateToFinal(
|
|
|
297
300
|
let completionBlockBatches: unknown[][] | undefined;
|
|
298
301
|
|
|
299
302
|
if (task.status === "completed") {
|
|
300
|
-
const output = task
|
|
303
|
+
const output = slackTaskOutput(task) || "Task completed.";
|
|
301
304
|
const slackOutput = markdownToSlack(output);
|
|
302
305
|
const attachmentsBlock = formatAttachmentsBlockForSlack(await getTaskAttachments(task.id));
|
|
303
306
|
const body = slackOutput + attachmentsBlock;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { AgentTask } from "../types";
|
|
2
|
+
|
|
3
|
+
/** Keep the stored deferral ETA for task details, but omit it from Slack. */
|
|
4
|
+
export function slackTaskOutput(
|
|
5
|
+
task: Pick<AgentTask, "output" | "tags" | "outputSchema" | "status">,
|
|
6
|
+
): string | undefined {
|
|
7
|
+
const output = task.output ?? undefined;
|
|
8
|
+
if (task.status !== "completed" || task.outputSchema || !task.tags?.includes("deferred")) {
|
|
9
|
+
return output;
|
|
10
|
+
}
|
|
11
|
+
// Only match the engine-authored line from defer-task, not continuation results.
|
|
12
|
+
const notice = output?.match(/^Deferred until [^\n]+? \([^\n]+?\) -> (.*)$/);
|
|
13
|
+
if (!notice) return output;
|
|
14
|
+
const pending = notice[1]?.trim();
|
|
15
|
+
return pending ? `Pending: ${pending}` : "";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** An ETA-only notice has no useful content left to post. */
|
|
19
|
+
export function isSlackEtaOnlyNotice(task: AgentTask): boolean {
|
|
20
|
+
return !!task.output && slackTaskOutput(task) === "";
|
|
21
|
+
}
|
package/src/slack/watcher.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
updateToFinal,
|
|
29
29
|
updateTreeMessage,
|
|
30
30
|
} from "./responses";
|
|
31
|
+
import { slackTaskOutput } from "./task-output";
|
|
31
32
|
|
|
32
33
|
let watcherInterval: ReturnType<typeof setInterval> | null = null;
|
|
33
34
|
let isProcessing = false;
|
|
@@ -169,7 +170,7 @@ export async function buildTreeNodes(tree: TreeMessageState): Promise<TreeNode[]
|
|
|
169
170
|
progress: child.progress ?? undefined,
|
|
170
171
|
duration: childDuration,
|
|
171
172
|
slackReplySent: child.slackReplySent,
|
|
172
|
-
output: child
|
|
173
|
+
output: slackTaskOutput(child),
|
|
173
174
|
failureReason: child.failureReason ?? undefined,
|
|
174
175
|
attachments: childAttachments,
|
|
175
176
|
steered: childSteered,
|
|
@@ -192,7 +193,7 @@ export async function buildTreeNodes(tree: TreeMessageState): Promise<TreeNode[]
|
|
|
192
193
|
progress: task.progress ?? undefined,
|
|
193
194
|
duration,
|
|
194
195
|
slackReplySent: task.slackReplySent,
|
|
195
|
-
output: task
|
|
196
|
+
output: slackTaskOutput(task),
|
|
196
197
|
failureReason: task.failureReason ?? undefined,
|
|
197
198
|
attachments: rootAttachments,
|
|
198
199
|
steered: rootSteered,
|
|
@@ -4,6 +4,7 @@ import { getAgentById, getTaskById } from "../be/db";
|
|
|
4
4
|
import { isEnvFlagEnabled } from "../utils/env-flag";
|
|
5
5
|
import { getTaskUrl } from "./blocks";
|
|
6
6
|
import { isUserAllowed } from "./handlers";
|
|
7
|
+
import { slackTaskOutput } from "./task-output";
|
|
7
8
|
|
|
8
9
|
type DetailsEvent = SlackEventMiddlewareArgs<"entity_details_requested">["event"];
|
|
9
10
|
type Details = Omit<EntityPresentDetailsArguments, "trigger_id" | "token">;
|
|
@@ -36,7 +37,7 @@ async function getDetails(event: DetailsEvent, client: WebClient): Promise<Detai
|
|
|
36
37
|
if (task.slackChannelId !== event.channel) return { error: { status: "restricted" } };
|
|
37
38
|
|
|
38
39
|
const agent = task.agentId ? await getAgentById(task.agentId) : null;
|
|
39
|
-
const result = task
|
|
40
|
+
const result = slackTaskOutput(task) || task.failureReason || task.progress;
|
|
40
41
|
return {
|
|
41
42
|
metadata: {
|
|
42
43
|
entity_type: "slack#/entities/task",
|
|
@@ -7,7 +7,7 @@ type RegisteredTool = {
|
|
|
7
7
|
handler: (args: unknown, extra: unknown) => Promise<unknown>;
|
|
8
8
|
};
|
|
9
9
|
|
|
10
|
-
test("a lead
|
|
10
|
+
test("a lead installs a disabled draft with explicit activation guidance", async () => {
|
|
11
11
|
const previousBaseUrl = process.env.MCP_BASE_URL;
|
|
12
12
|
const previousApiKey = process.env.AGENT_SWARM_API_KEY;
|
|
13
13
|
const savedFetch = globalThis.fetch;
|
|
@@ -52,7 +52,7 @@ test("a lead can draft the minimal bundle while an operator retains activation",
|
|
|
52
52
|
|
|
53
53
|
expect(result.isError).toBe(false);
|
|
54
54
|
expect(result.structuredContent).toMatchObject({ enabled: false, status: "disabled" });
|
|
55
|
-
expect(String(result.structuredContent.message)).toContain("
|
|
55
|
+
expect(String(result.structuredContent.message)).toContain("extension-enable");
|
|
56
56
|
} finally {
|
|
57
57
|
globalThis.fetch = savedFetch;
|
|
58
58
|
if (previousBaseUrl === undefined) delete process.env.MCP_BASE_URL;
|
|
@@ -2,11 +2,18 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:tes
|
|
|
2
2
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
3
3
|
import { Readable } from "node:stream";
|
|
4
4
|
import { closeDb, createAgent, getDbClient, initDb } from "../be/db";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
getExtensionByName,
|
|
7
|
+
insertExtensionRun,
|
|
8
|
+
listExtensionRuns,
|
|
9
|
+
setExtensionState,
|
|
10
|
+
} from "../be/extensions/db";
|
|
11
|
+
import { enqueueAuditRow, flushAuditBuffer } from "../be/rbac-audit";
|
|
6
12
|
import { stopExtensionRuntime } from "../extensions/lifecycle";
|
|
7
13
|
import { handleCore } from "../http/core";
|
|
8
14
|
import { handleExtensions } from "../http/extensions";
|
|
9
15
|
import { getPathSegments, parseQueryParams } from "../http/utils";
|
|
16
|
+
import { clearAuditSink, setAuditSink } from "../rbac";
|
|
10
17
|
import type { User } from "../types";
|
|
11
18
|
import { setRequestAuth } from "../utils/request-auth-context";
|
|
12
19
|
import { refreshSecretScrubberCache } from "../utils/secret-scrubber";
|
|
@@ -124,6 +131,80 @@ beforeEach(async () => {
|
|
|
124
131
|
});
|
|
125
132
|
|
|
126
133
|
describe("/api/extensions HTTP", () => {
|
|
134
|
+
test("lead activation defaults on, is attributable, and the deployment gate denies lifecycle calls", async () => {
|
|
135
|
+
const original = process.env.EXTENSION_ALLOW_LEAD_ACTIVATION;
|
|
136
|
+
const auditDisabled = process.env.RBAC_AUDIT_DISABLED;
|
|
137
|
+
delete process.env.EXTENSION_ALLOW_LEAD_ACTIVATION;
|
|
138
|
+
delete process.env.RBAC_AUDIT_DISABLED;
|
|
139
|
+
setAuditSink(enqueueAuditRow);
|
|
140
|
+
try {
|
|
141
|
+
expect((await install("minimal", leadId)).status).toBe(200);
|
|
142
|
+
const extension = (await getExtensionByName("minimal"))!;
|
|
143
|
+
const path = `/api/extensions/${extension.id}`;
|
|
144
|
+
expect((await dispatch(`${path}/enable`, { method: "POST", agentId: leadId })).status).toBe(
|
|
145
|
+
200,
|
|
146
|
+
);
|
|
147
|
+
expect(await listExtensionRuns(extension.id)).toContainEqual(
|
|
148
|
+
expect.objectContaining({
|
|
149
|
+
event: "lifecycle.enable.requested",
|
|
150
|
+
agentId: leadId,
|
|
151
|
+
version: 1,
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
for (const operation of ["activate-version", "disable"]) {
|
|
155
|
+
expect(
|
|
156
|
+
(
|
|
157
|
+
await dispatch(`${path}/${operation}`, {
|
|
158
|
+
method: "POST",
|
|
159
|
+
agentId: leadId,
|
|
160
|
+
body: JSON.stringify({ version: 1 }),
|
|
161
|
+
})
|
|
162
|
+
).status,
|
|
163
|
+
).toBe(200);
|
|
164
|
+
expect(await listExtensionRuns(extension.id)).toContainEqual(
|
|
165
|
+
expect.objectContaining({
|
|
166
|
+
event: `lifecycle.${operation}.requested`,
|
|
167
|
+
agentId: leadId,
|
|
168
|
+
version: 1,
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
for (const value of ["false", "0"]) {
|
|
173
|
+
process.env.EXTENSION_ALLOW_LEAD_ACTIVATION = value;
|
|
174
|
+
for (const operation of ["enable", "disable", "activate-version"]) {
|
|
175
|
+
expect(
|
|
176
|
+
(
|
|
177
|
+
await dispatch(`${path}/${operation}`, {
|
|
178
|
+
method: "POST",
|
|
179
|
+
agentId: leadId,
|
|
180
|
+
body: JSON.stringify({ version: 1 }),
|
|
181
|
+
})
|
|
182
|
+
).status,
|
|
183
|
+
).toBe(403);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
expect((await getExtensionByName("minimal"))!.enabled).toBe(false);
|
|
187
|
+
expect((await dispatch(`${path}/enable`, { method: "POST" })).status).toBe(200);
|
|
188
|
+
expect(
|
|
189
|
+
(await dispatch(`${path}/disable`, { method: "POST", asUser: crypto.randomUUID() })).status,
|
|
190
|
+
).toBe(200);
|
|
191
|
+
await flushAuditBuffer();
|
|
192
|
+
const decisions = await getDbClient().query<{ decision: string }>(
|
|
193
|
+
"SELECT decision FROM permission_audit WHERE verb = ? AND principalId = ?",
|
|
194
|
+
["extension.activate", leadId],
|
|
195
|
+
);
|
|
196
|
+
expect(decisions).toContainEqual({ decision: "allow" });
|
|
197
|
+
expect(decisions).toContainEqual({ decision: "deny" });
|
|
198
|
+
} finally {
|
|
199
|
+
await flushAuditBuffer();
|
|
200
|
+
clearAuditSink();
|
|
201
|
+
if (original === undefined) delete process.env.EXTENSION_ALLOW_LEAD_ACTIVATION;
|
|
202
|
+
else process.env.EXTENSION_ALLOW_LEAD_ACTIVATION = original;
|
|
203
|
+
if (auditDisabled === undefined) delete process.env.RBAC_AUDIT_DISABLED;
|
|
204
|
+
else process.env.RBAC_AUDIT_DISABLED = auditDisabled;
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
127
208
|
test("operator install, list, get, versions, and changed hooks round-trip", async () => {
|
|
128
209
|
const first = await install();
|
|
129
210
|
expect(first.status).toBe(200);
|
|
@@ -251,7 +332,7 @@ describe("/api/extensions HTTP", () => {
|
|
|
251
332
|
expect((await rejected.json()).error).toContain("Disable");
|
|
252
333
|
});
|
|
253
334
|
|
|
254
|
-
test("type definitions are plain text and
|
|
335
|
+
test("type definitions are plain text and workers cannot activate extensions", async () => {
|
|
255
336
|
await install();
|
|
256
337
|
const extension = await getExtensionByName("minimal");
|
|
257
338
|
const types = await dispatch("/api/extensions/type-defs");
|
|
@@ -262,7 +343,7 @@ describe("/api/extensions HTTP", () => {
|
|
|
262
343
|
(
|
|
263
344
|
await dispatch(`/api/extensions/${extension!.id}/enable`, {
|
|
264
345
|
method: "POST",
|
|
265
|
-
agentId:
|
|
346
|
+
agentId: workerId,
|
|
266
347
|
})
|
|
267
348
|
).status,
|
|
268
349
|
).toBe(403);
|
|
@@ -28,6 +28,27 @@ describe("extension agents act with lead privileges while registered", () => {
|
|
|
28
28
|
expect(can(check).allow).toBe(false);
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
+
test("the deployment activation gate also applies to lead-equivalent identities", () => {
|
|
32
|
+
const original = process.env.EXTENSION_ALLOW_LEAD_ACTIVATION;
|
|
33
|
+
grantLeadEquivalence(extensionAgentId);
|
|
34
|
+
try {
|
|
35
|
+
const check = {
|
|
36
|
+
principal: { kind: "agent" as const, agentId: extensionAgentId, isLead: false },
|
|
37
|
+
verb: "extension.activate" as const,
|
|
38
|
+
source: "mcp" as const,
|
|
39
|
+
};
|
|
40
|
+
delete process.env.EXTENSION_ALLOW_LEAD_ACTIVATION;
|
|
41
|
+
expect(can(check).allow).toBe(true);
|
|
42
|
+
process.env.EXTENSION_ALLOW_LEAD_ACTIVATION = "false";
|
|
43
|
+
expect(can(check).allow).toBe(false);
|
|
44
|
+
expect(can({ ...check, verb: "extension.write" }).allow).toBe(true);
|
|
45
|
+
} finally {
|
|
46
|
+
revokeLeadEquivalence(extensionAgentId);
|
|
47
|
+
if (original === undefined) delete process.env.EXTENSION_ALLOW_LEAD_ACTIVATION;
|
|
48
|
+
else process.env.EXTENSION_ALLOW_LEAD_ACTIVATION = original;
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
31
52
|
test("operators and plain workers are unaffected", () => {
|
|
32
53
|
expect(
|
|
33
54
|
can({
|
|
@@ -4,11 +4,16 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
|
|
4
4
|
import { Readable } from "node:stream";
|
|
5
5
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
6
6
|
import { closeDb, createAgent, getDbClient, initDb } from "../be/db";
|
|
7
|
-
import { setExtensionState } from "../be/extensions/db";
|
|
7
|
+
import { getExtensionById, setExtensionState } from "../be/extensions/db";
|
|
8
8
|
import { typecheckScript } from "../be/scripts/typecheck";
|
|
9
|
+
import { stopExtensionRuntime } from "../extensions/lifecycle";
|
|
9
10
|
import { handleCore } from "../http/core";
|
|
10
11
|
import { handleExtensions } from "../http/extensions";
|
|
11
12
|
import { getPathSegments, parseQueryParams } from "../http/utils";
|
|
13
|
+
import { registerExtensionActivateVersionTool } from "../tools/extension-activate-version";
|
|
14
|
+
import { registerExtensionDeleteTool } from "../tools/extension-delete";
|
|
15
|
+
import { registerExtensionDisableTool } from "../tools/extension-disable";
|
|
16
|
+
import { registerExtensionEnableTool } from "../tools/extension-enable";
|
|
12
17
|
import { registerExtensionInstallTool } from "../tools/extension-install";
|
|
13
18
|
import { registerExtensionListTool } from "../tools/extension-list";
|
|
14
19
|
import { refreshSecretScrubberCache } from "../utils/secret-scrubber";
|
|
@@ -40,11 +45,22 @@ async function removeDbFiles(): Promise<void> {
|
|
|
40
45
|
|
|
41
46
|
function buildToolServer() {
|
|
42
47
|
const server = new McpServer({ name: "extensions-mcp-tools", version: "1" });
|
|
48
|
+
registerExtensionDeleteTool(server);
|
|
49
|
+
registerExtensionEnableTool(server);
|
|
50
|
+
registerExtensionDisableTool(server);
|
|
51
|
+
registerExtensionActivateVersionTool(server);
|
|
43
52
|
registerExtensionInstallTool(server);
|
|
44
53
|
registerExtensionListTool(server);
|
|
45
54
|
const tools = (server as unknown as { _registeredTools: Record<string, RegisteredTool> })
|
|
46
55
|
._registeredTools;
|
|
47
|
-
return {
|
|
56
|
+
return {
|
|
57
|
+
install: tools["extension-install"]!,
|
|
58
|
+
list: tools["extension-list"]!,
|
|
59
|
+
enable: tools["extension-enable"]!,
|
|
60
|
+
disable: tools["extension-disable"]!,
|
|
61
|
+
activate: tools["extension-activate-version"]!,
|
|
62
|
+
delete: tools["extension-delete"]!,
|
|
63
|
+
};
|
|
48
64
|
}
|
|
49
65
|
|
|
50
66
|
function meta(agentId: string) {
|
|
@@ -128,6 +144,7 @@ beforeAll(async () => {
|
|
|
128
144
|
});
|
|
129
145
|
|
|
130
146
|
afterAll(async () => {
|
|
147
|
+
await stopExtensionRuntime();
|
|
131
148
|
globalThis.fetch = savedFetch;
|
|
132
149
|
closeDb();
|
|
133
150
|
await removeDbFiles();
|
|
@@ -142,11 +159,12 @@ afterAll(async () => {
|
|
|
142
159
|
});
|
|
143
160
|
|
|
144
161
|
beforeEach(async () => {
|
|
162
|
+
await stopExtensionRuntime();
|
|
145
163
|
await getDbClient().run("DELETE FROM extensions");
|
|
146
164
|
});
|
|
147
165
|
|
|
148
166
|
describe("extension MCP HTTP proxy tools", () => {
|
|
149
|
-
test("script SDK typechecks
|
|
167
|
+
test("script SDK typechecks the full extension lifecycle", async () => {
|
|
150
168
|
const result = await typecheckScript(`
|
|
151
169
|
import type { ScriptContext } from "swarm-sdk";
|
|
152
170
|
export default async (_args: unknown, ctx: ScriptContext) => {
|
|
@@ -154,6 +172,11 @@ describe("extension MCP HTTP proxy tools", () => {
|
|
|
154
172
|
manifest: {},
|
|
155
173
|
files: { "hooks.ts": "export default () => {}" },
|
|
156
174
|
});
|
|
175
|
+
const id = "00000000-0000-4000-8000-000000000001";
|
|
176
|
+
await ctx.swarm.extension_enable({ id });
|
|
177
|
+
await ctx.swarm.extension_activate_version({ id, version: 1 });
|
|
178
|
+
await ctx.swarm.extension_disable({ id });
|
|
179
|
+
await ctx.swarm.extension_delete({ id });
|
|
157
180
|
return await ctx.swarm.extension_list({ enabledOnly: true });
|
|
158
181
|
};
|
|
159
182
|
`);
|
|
@@ -243,3 +266,88 @@ describe("extension MCP HTTP proxy tools", () => {
|
|
|
243
266
|
expect(enabledOnly.structuredContent).toMatchObject({ success: true, extensions: [] });
|
|
244
267
|
});
|
|
245
268
|
});
|
|
269
|
+
|
|
270
|
+
describe("extension lifecycle MCP tools", () => {
|
|
271
|
+
test("lead enables, activates, disables, and deletes through the shared HTTP routes", async () => {
|
|
272
|
+
const tools = buildToolServer();
|
|
273
|
+
const installed = (await tools.install.handler(
|
|
274
|
+
await loadBundleFixture("minimal"),
|
|
275
|
+
meta(leadId),
|
|
276
|
+
)) as ToolResult;
|
|
277
|
+
const id = String(installed.structuredContent.id);
|
|
278
|
+
const enabled = (await tools.enable.handler({ id }, meta(leadId))) as ToolResult;
|
|
279
|
+
expect(enabled.structuredContent).toMatchObject({
|
|
280
|
+
success: true,
|
|
281
|
+
enabled: true,
|
|
282
|
+
activeVersion: 1,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const rejected = await dispatchExtensionsApi(`${API_URL}/api/extensions/${id}`, {
|
|
286
|
+
method: "DELETE",
|
|
287
|
+
headers: { "x-agent-id": leadId, authorization: `Bearer ${API_KEY}` },
|
|
288
|
+
});
|
|
289
|
+
expect(rejected.status).toBe(409);
|
|
290
|
+
const blocked = (await tools.delete.handler({ id }, meta(leadId))) as ToolResult;
|
|
291
|
+
expect(blocked.isError).toBe(true);
|
|
292
|
+
expect(blocked.structuredContent).toMatchObject({ success: false });
|
|
293
|
+
expect(blocked.content[0]?.text).toContain("Disable");
|
|
294
|
+
expect(await getExtensionById(id)).not.toBeNull();
|
|
295
|
+
|
|
296
|
+
const changed = await loadBundleFixture("minimal");
|
|
297
|
+
changed.files["hooks.ts"] += "\n// next lifecycle version\n";
|
|
298
|
+
await tools.install.handler(changed, meta(leadId));
|
|
299
|
+
const activated = (await tools.activate.handler(
|
|
300
|
+
{ id, version: 2 },
|
|
301
|
+
meta(leadId),
|
|
302
|
+
)) as ToolResult;
|
|
303
|
+
expect(activated.structuredContent).toMatchObject({
|
|
304
|
+
success: true,
|
|
305
|
+
activeVersion: 2,
|
|
306
|
+
enabled: true,
|
|
307
|
+
});
|
|
308
|
+
const disabled = (await tools.disable.handler({ id }, meta(leadId))) as ToolResult;
|
|
309
|
+
expect(disabled.structuredContent).toMatchObject({ success: true, enabled: false });
|
|
310
|
+
const deleted = (await tools.delete.handler({ id }, meta(leadId))) as ToolResult;
|
|
311
|
+
expect(deleted.isError).toBe(false);
|
|
312
|
+
expect(deleted.structuredContent).toMatchObject({ success: true, id, deleted: true });
|
|
313
|
+
expect(await getExtensionById(id)).toBeNull();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("workers cannot mutate the extension lifecycle", async () => {
|
|
317
|
+
const tools = buildToolServer();
|
|
318
|
+
const installed = (await tools.install.handler(
|
|
319
|
+
await loadBundleFixture("minimal"),
|
|
320
|
+
meta(leadId),
|
|
321
|
+
)) as ToolResult;
|
|
322
|
+
const id = String(installed.structuredContent.id);
|
|
323
|
+
for (const tool of [tools.enable, tools.disable, tools.activate, tools.delete]) {
|
|
324
|
+
const result = (await tool.handler({ id, version: 1 }, meta(workerId))) as ToolResult;
|
|
325
|
+
expect(result.isError).toBe(true);
|
|
326
|
+
expect(String(result.structuredContent.message)).toContain("Forbidden");
|
|
327
|
+
}
|
|
328
|
+
expect(await getExtensionById(id)).toMatchObject({ enabled: false, activeVersion: 1 });
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("missing extensions and versions return error envelopes", async () => {
|
|
332
|
+
const tools = buildToolServer();
|
|
333
|
+
const id = crypto.randomUUID();
|
|
334
|
+
for (const tool of [tools.enable, tools.disable, tools.activate, tools.delete]) {
|
|
335
|
+
const result = (await tool.handler({ id, version: 1 }, meta(leadId))) as ToolResult;
|
|
336
|
+
expect(result.isError).toBe(true);
|
|
337
|
+
expect(result.structuredContent.success).toBe(false);
|
|
338
|
+
}
|
|
339
|
+
const installed = (await tools.install.handler(
|
|
340
|
+
await loadBundleFixture("minimal"),
|
|
341
|
+
meta(leadId),
|
|
342
|
+
)) as ToolResult;
|
|
343
|
+
const result = (await tools.activate.handler(
|
|
344
|
+
{ id: installed.structuredContent.id, version: 99 },
|
|
345
|
+
meta(leadId),
|
|
346
|
+
)) as ToolResult;
|
|
347
|
+
expect(result.isError).toBe(true);
|
|
348
|
+
expect(await getExtensionById(String(installed.structuredContent.id))).toMatchObject({
|
|
349
|
+
activeVersion: 1,
|
|
350
|
+
enabled: false,
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
});
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
{
|
|
2
|
+
"sourceTaskId": "59811b57-05d4-47d1-9691-ad9013698a0a",
|
|
3
|
+
"output": "Replied in the thread: https://x.com/desplegalabs/status/2100595056939827590\n\nBuilt what Guli said nobody had shown and Jan asked to use: `jev-lead-qualifier`, a global swarm script anyone can drop into their own swarm, plus a live demo — https://api.desplega.agent-swarm.dev/p/556b11781dbb40bf9b1eb1b3315adae6\n\nIt scores ICP fit / intent / authority / urgency as calibrated Jev probabilities and abstains to human_review when genuinely unsure. Money shot: the student \"free trial\" lead reads 0.63 intent, 0.03 fit — eager, not a customer.\n\nOne cosmetic miss: X auto-prepends thread handles, so @ironcarbs and @GuliMoreno render twice. Not deleting a live post over it; say the word if you want it reposted clean.</output>\n<parameter name=\"attachments\">[{\"kind\": \"url\", \"url\": \"https://x.com/desplegalabs/status/2100595056939827590\", \"name\": \"X reply in the Jev thread\", \"description\": \"The @desplegalabs reply to @voidisomorphism tagging @ironcarbs and @GuliMoreno\", \"intent\": \"The deliverable Taras asked for: reply in that thread @ jan and guli\", \"isPrimary\": true}, {\"kind\": \"url\", \"url\": \"https://api.desplega.agent-swarm.dev/p/556b11781dbb40bf9b1eb1b3315adae6\", \"name\": \"Lead qualifier demo page\", \"description\": \"Public demo: 8 leads scored live, abstain band explained, copy-paste block to run it in your own swarm\", \"intent\": \"The demo Jan asked to see and the link the tweet points at\"}]"
|
|
4
|
+
}
|
|
@@ -13,13 +13,12 @@ import { join } from "node:path";
|
|
|
13
13
|
import {
|
|
14
14
|
api,
|
|
15
15
|
makeScratchDir,
|
|
16
|
-
readAuditRows,
|
|
17
16
|
registerAgent,
|
|
18
17
|
removeScratchDir,
|
|
19
18
|
type SwarmServer,
|
|
20
19
|
spawnSwarmServer,
|
|
21
20
|
WORKER_A,
|
|
22
|
-
|
|
21
|
+
waitForAuditRows,
|
|
23
22
|
} from "./rbac-e2e-helpers";
|
|
24
23
|
|
|
25
24
|
setDefaultTimeout(120_000);
|
|
@@ -358,8 +357,40 @@ describe("RBAC admission over real HTTP", () => {
|
|
|
358
357
|
});
|
|
359
358
|
expect(operatorCreate.status).toBe(201);
|
|
360
359
|
|
|
361
|
-
|
|
362
|
-
|
|
360
|
+
const userHttpRows = (
|
|
361
|
+
await waitForAuditRows(dbPath, (rows) => {
|
|
362
|
+
const userRows = rows.filter(
|
|
363
|
+
(row) =>
|
|
364
|
+
row.principalType === "user" &&
|
|
365
|
+
row.principalId === userId &&
|
|
366
|
+
row.source === "http" &&
|
|
367
|
+
row.resourceType === "http-route",
|
|
368
|
+
);
|
|
369
|
+
return (
|
|
370
|
+
[
|
|
371
|
+
"GET /api/agents/{id}/mcp-servers",
|
|
372
|
+
"GET /api/scripts/{id}/apis/{endpointId}/secret",
|
|
373
|
+
"PATCH /api/assets/app/{id}/key",
|
|
374
|
+
"POST /api/scripts/{id}/apis",
|
|
375
|
+
"POST /api/tasks",
|
|
376
|
+
].every((resourceId) =>
|
|
377
|
+
userRows.some((row) => row.resourceId === resourceId && row.decision === "deny"),
|
|
378
|
+
) &&
|
|
379
|
+
userRows.some(
|
|
380
|
+
(row) =>
|
|
381
|
+
row.resourceId === "PUT /api/favorites" &&
|
|
382
|
+
row.verb === "favorite.write.own" &&
|
|
383
|
+
row.decision === "allow",
|
|
384
|
+
) &&
|
|
385
|
+
userRows.some(
|
|
386
|
+
(row) =>
|
|
387
|
+
row.resourceId === "PATCH /api/assets/app/{id}/key" &&
|
|
388
|
+
row.verb === "app.manage" &&
|
|
389
|
+
row.decision === "allow",
|
|
390
|
+
)
|
|
391
|
+
);
|
|
392
|
+
})
|
|
393
|
+
).filter(
|
|
363
394
|
(row) =>
|
|
364
395
|
row.principalType === "user" &&
|
|
365
396
|
row.principalId === userId &&
|
|
@@ -314,3 +314,21 @@ export async function waitForAuditCount(
|
|
|
314
314
|
}
|
|
315
315
|
return n;
|
|
316
316
|
}
|
|
317
|
+
|
|
318
|
+
/** Poll until audit rows satisfy `predicate` (writer flushes every 2s). */
|
|
319
|
+
export async function waitForAuditRows(
|
|
320
|
+
dbPath: string,
|
|
321
|
+
predicate: (rows: AuditRow[]) => boolean,
|
|
322
|
+
deadlineMs = 8_000,
|
|
323
|
+
): Promise<AuditRow[]> {
|
|
324
|
+
const start = Date.now();
|
|
325
|
+
let rows = readAuditRows(dbPath);
|
|
326
|
+
while (!predicate(rows)) {
|
|
327
|
+
if (Date.now() - start >= deadlineMs) {
|
|
328
|
+
throw new Error(`audit rows did not satisfy predicate within ${deadlineMs}ms`);
|
|
329
|
+
}
|
|
330
|
+
await Bun.sleep(250);
|
|
331
|
+
rows = readAuditRows(dbPath);
|
|
332
|
+
}
|
|
333
|
+
return rows;
|
|
334
|
+
}
|
|
@@ -81,8 +81,8 @@ const LEAD_ONLY_VERBS: PermissionVerb[] = [
|
|
|
81
81
|
"script.api.delete",
|
|
82
82
|
];
|
|
83
83
|
|
|
84
|
-
const OPERATOR_ONLY_VERBS: PermissionVerb[] = [
|
|
85
|
-
const LEAD_OR_OPERATOR_VERBS: PermissionVerb[] = ["extension.write"];
|
|
84
|
+
const OPERATOR_ONLY_VERBS: PermissionVerb[] = [];
|
|
85
|
+
const LEAD_OR_OPERATOR_VERBS: PermissionVerb[] = ["extension.write", "extension.activate"];
|
|
86
86
|
|
|
87
87
|
const LEAD_OR_RESOURCE_OWNER_VERBS: PermissionVerb[] = [
|
|
88
88
|
"memory.edit.any",
|
|
@@ -12,12 +12,11 @@ import {
|
|
|
12
12
|
countAuditRows,
|
|
13
13
|
LEAD,
|
|
14
14
|
makeScratchDir,
|
|
15
|
-
readAuditRows,
|
|
16
15
|
registerAgent,
|
|
17
16
|
removeScratchDir,
|
|
18
17
|
type SwarmServer,
|
|
19
18
|
spawnSwarmServer,
|
|
20
|
-
|
|
19
|
+
waitForAuditRows,
|
|
21
20
|
} from "./rbac-e2e-helpers";
|
|
22
21
|
|
|
23
22
|
setDefaultTimeout(120_000);
|
|
@@ -312,8 +311,27 @@ describe("RBAC admission over /mcp-user", () => {
|
|
|
312
311
|
}),
|
|
313
312
|
);
|
|
314
313
|
|
|
315
|
-
|
|
316
|
-
|
|
314
|
+
const mcpRows = (
|
|
315
|
+
await waitForAuditRows(enabledDbPath, (rows) => {
|
|
316
|
+
const auditRows = rows.filter((row) => row.source === "mcp");
|
|
317
|
+
return (
|
|
318
|
+
auditRows.some(
|
|
319
|
+
(row) =>
|
|
320
|
+
row.principalId === requester.userId &&
|
|
321
|
+
row.resourceType === "mcp-tool" &&
|
|
322
|
+
row.resourceId === "send-task" &&
|
|
323
|
+
row.verb === "task.create.own" &&
|
|
324
|
+
row.decision === "allow",
|
|
325
|
+
) &&
|
|
326
|
+
["task.create.own", "task.cancel.own", "task.action.own"].every((verb) =>
|
|
327
|
+
auditRows.some(
|
|
328
|
+
(row) =>
|
|
329
|
+
row.principalId === empty.userId && row.decision === "deny" && row.verb === verb,
|
|
330
|
+
),
|
|
331
|
+
)
|
|
332
|
+
);
|
|
333
|
+
})
|
|
334
|
+
).filter((row) => row.source === "mcp");
|
|
317
335
|
expect(
|
|
318
336
|
mcpRows.some(
|
|
319
337
|
(row) =>
|