@desplega.ai/agent-swarm 1.151.0 → 1.152.1
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 +3 -1
- package/dist/{acp-adapter-ex8hzjfc.js → acp-adapter-4s7q25ft.js} +1 -1
- package/dist/{actions-2bgpyhww.js → actions-cs6rhmvc.js} +13 -13
- package/dist/{app-1706zhr1.js → app-j54aq0dj.js} +3 -3
- package/dist/{assistant-bavke5p7.js → assistant-w1pgn7vf.js} +14 -14
- package/dist/{boot-reembed-3cmrrmpa.js → boot-reembed-42vgkrrj.js} +4 -4
- package/dist/{boot-reembed-nafcmmmc.js → boot-reembed-smy30z32.js} +3 -3
- package/dist/{boot-scrub-logs-cd6a2sq0.js → boot-scrub-logs-b6s72fsw.js} +2 -2
- package/dist/{claude-adapter-wfp2gzvz.js → claude-adapter-fh7rmcj9.js} +3 -3
- package/dist/{claude-sdk-session-bht9jjak.js → claude-sdk-session-xhynatn9.js} +3 -3
- package/dist/{cli-f1wky6zp.js → cli-3g61md6v.js} +1 -1
- package/dist/{cli-a1z3h107.js → cli-3pft2z6r.js} +4 -4
- package/dist/{cli-5tpgq60f.js → cli-72tgtzr1.js} +152 -23
- package/dist/{cli-0qb1z6s2.js → cli-7bwaerjt.js} +1 -1
- package/dist/{cli-czjtgfys.js → cli-b4dyetqt.js} +2 -2
- package/dist/{cli-s14agm5s.js → cli-bdt7w021.js} +12 -3
- package/dist/{cli-sjaae6ff.js → cli-dsyg66vt.js} +3 -3
- package/dist/{cli-2tbvf1z1.js → cli-ftdjz1mk.js} +8 -8
- package/dist/{cli-zkxymas5.js → cli-gfez0qmk.js} +1 -1
- package/dist/{cli-q88st9aj.js → cli-gxf2ebyz.js} +2 -2
- package/dist/{cli-ppm4975n.js → cli-jtvs5m8q.js} +1 -1
- package/dist/{cli-7tgcstn2.js → cli-jytf5mw9.js} +2 -2
- package/dist/{cli-g69fdc9g.js → cli-kf8qp2vf.js} +2 -2
- package/dist/{cli-5jd2z7av.js → cli-ks8a1cgq.js} +6 -2
- package/dist/{cli-gjyvhmmh.js → cli-ng4q6a9t.js} +5 -5
- package/dist/{cli-3v9xhjcn.js → cli-sakxd2rg.js} +2 -2
- package/dist/{cli-frcta2w4.js → cli-sqmh7jj1.js} +1 -1
- package/dist/{cli-vwm416m6.js → cli-v9sk4dzw.js} +1 -1
- package/dist/{cli-j9xczhaq.js → cli-vbk025fm.js} +1 -1
- package/dist/{cli-r2q6pevn.js → cli-wfve107r.js} +43 -21
- package/dist/{cli-9004vfdf.js → cli-y8tayczk.js} +2 -2
- package/dist/{cli-gbfwcd3k.js → cli-zfzznn7c.js} +7 -6
- package/dist/cli.js +11 -11
- package/dist/{codex-adapter-jjjk8w4m.js → codex-adapter-k6yq95sn.js} +2 -2
- package/dist/{codex-session-runner-nd6ayxkx.js → codex-session-runner-3rp84pk8.js} +2 -2
- package/dist/{commands-9v4psyq1.js → commands-pbr5jzy5.js} +2 -2
- package/dist/{db-wnt58tpe.js → db-z0xaa59a.js} +2 -2
- package/dist/{dispatcher-05jh1aqk.js → dispatcher-pwpgsj0x.js} +12 -12
- package/dist/{handlers-tw90pkh5.js → handlers-5mzk4mbg.js} +15 -15
- package/dist/{hook-sxe3r349.js → hook-8bz33zee.js} +1 -1
- package/dist/{hook-123e2scb.js → hook-kbh84zb1.js} +2 -2
- package/dist/{http-ecsar8ez.js → http-pppxg5vr.js} +100 -36
- package/dist/{index-hw26vj40.js → index-68bd9b5f.js} +12 -12
- package/dist/{index-16js521z.js → index-k1hq1qt6.js} +8 -8
- package/dist/{index-zj518gf9.js → index-mjpxsr6k.js} +13 -13
- package/dist/{index-ymn7em0p.js → index-y0te09pn.js} +3 -3
- package/dist/{index-nnrbpcjp.js → index-z0zwjtkn.js} +12 -12
- package/dist/{keepalive-asqtcm4y.js → keepalive-xvmwnh1d.js} +4 -4
- package/dist/{lead-xbfr7fvt.js → lead-pf8nvxt8.js} +16 -16
- package/dist/{maintenance-qma55qvw.js → maintenance-jk4q0wgg.js} +4 -4
- package/dist/{oauth-refresh-sweep-3s04c6qw.js → oauth-refresh-sweep-6207x9sj.js} +3 -3
- package/dist/{onboard-drskd386.js → onboard-b0csgm0q.js} +2 -2
- package/dist/{otel-impl-943c96er.js → otel-impl-b6rfehth.js} +1 -1
- package/dist/{pricing-refresh-mx51az05.js → pricing-refresh-4e201wtd.js} +4 -4
- package/dist/{rbac-roles-wcmrg6sv.js → rbac-roles-mkc5n5qa.js} +4 -4
- package/dist/{rbac-roles-78baecqd.js → rbac-roles-zp6e4geh.js} +3 -3
- package/dist/{render-v2-8zjntgf5.js → render-v2-4fazz9tf.js} +3 -3
- package/dist/{seed-pricing-jqjs6cws.js → seed-pricing-thxpn9vn.js} +3 -3
- package/dist/{setup-4pks0vfk.js → setup-wb0z8cat.js} +2 -2
- package/dist/{work-objects-wangt792.js → work-objects-h3498kwe.js} +18 -17
- package/dist/{worker-p2mr422x.js → worker-2m2tzxp3.js} +16 -16
- package/openapi.json +5 -5
- package/package.json +1 -1
- package/src/agentmail/inbound-archive.ts +71 -0
- package/src/agentmail/types.ts +2 -0
- 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/http/webhooks.ts +13 -5
- 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/agentmail-inbound-archive.test.ts +418 -0
- 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
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
3
|
+
import { Readable } from "node:stream";
|
|
4
|
+
import { Webhook } from "svix";
|
|
5
|
+
import triage from "../../scripts/contact-inbox-triage";
|
|
6
|
+
import { initAgentMail, resetAgentMail } from "../agentmail/app";
|
|
7
|
+
import {
|
|
8
|
+
archiveInboundMessage,
|
|
9
|
+
INBOUND_ARCHIVE_TTL_MS,
|
|
10
|
+
inboundArchiveKey,
|
|
11
|
+
} from "../agentmail/inbound-archive";
|
|
12
|
+
import type { AgentMailWebhookPayload } from "../agentmail/types";
|
|
13
|
+
import { closeDb, getDbClient, getKv, initDb, upsertKv } from "../be/db";
|
|
14
|
+
import { handleWebhooks } from "../http/webhooks";
|
|
15
|
+
import { KvKeySchema, KvNamespaceSchema } from "../types";
|
|
16
|
+
|
|
17
|
+
const inbox = "desplega-contact@agent-swarm.dev";
|
|
18
|
+
const namespace = "agentmail-inbound";
|
|
19
|
+
const key = (id: string) => inboundArchiveKey(inbox, id);
|
|
20
|
+
const secret = `whsec_${Buffer.from("archive-test-secret").toString("base64")}`;
|
|
21
|
+
const savedEnv = { ...process.env };
|
|
22
|
+
function payload(id = "cold-unauthenticated"): AgentMailWebhookPayload {
|
|
23
|
+
return {
|
|
24
|
+
type: "event",
|
|
25
|
+
event_id: `event-${id}`,
|
|
26
|
+
event_type: "message.received.unauthenticated",
|
|
27
|
+
message: {
|
|
28
|
+
inbox_id: inbox,
|
|
29
|
+
message_id: id,
|
|
30
|
+
thread_id: "cold-thread",
|
|
31
|
+
organization_id: "test",
|
|
32
|
+
from_: "Alex <alex@acme.example>",
|
|
33
|
+
to: [inbox],
|
|
34
|
+
cc: [],
|
|
35
|
+
bcc: [],
|
|
36
|
+
reply_to: [],
|
|
37
|
+
subject: "Desplega pilot",
|
|
38
|
+
preview: "",
|
|
39
|
+
text: "Our team wants to evaluate Desplega.",
|
|
40
|
+
html: null,
|
|
41
|
+
labels: ["received", "unauthenticated"],
|
|
42
|
+
attachments: [],
|
|
43
|
+
in_reply_to: null,
|
|
44
|
+
references: [],
|
|
45
|
+
timestamp: "2026-01-01T00:00:00Z",
|
|
46
|
+
created_at: "2026-01-01T00:00:00Z",
|
|
47
|
+
updated_at: "2026-01-01T00:00:00Z",
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
initDb(":memory:");
|
|
54
|
+
process.env.AGENTMAIL_WEBHOOK_SECRET = secret;
|
|
55
|
+
delete process.env.AGENTMAIL_DISABLE;
|
|
56
|
+
// Routing rejects this inbox; capture must still happen.
|
|
57
|
+
process.env.AGENTMAIL_INBOX_DOMAIN_FILTER = "other.example";
|
|
58
|
+
resetAgentMail();
|
|
59
|
+
initAgentMail();
|
|
60
|
+
});
|
|
61
|
+
afterEach(() => {
|
|
62
|
+
closeDb();
|
|
63
|
+
resetAgentMail();
|
|
64
|
+
for (const key of [
|
|
65
|
+
"AGENTMAIL_WEBHOOK_SECRET",
|
|
66
|
+
"AGENTMAIL_DISABLE",
|
|
67
|
+
"AGENTMAIL_INBOX_DOMAIN_FILTER",
|
|
68
|
+
]) {
|
|
69
|
+
if (savedEnv[key] === undefined) delete process.env[key];
|
|
70
|
+
else process.env[key] = savedEnv[key];
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
async function deliver(event: AgentMailWebhookPayload, valid = true) {
|
|
75
|
+
const body = JSON.stringify(event);
|
|
76
|
+
const timestamp = new Date();
|
|
77
|
+
const req = Readable.from([Buffer.from(body)]) as IncomingMessage;
|
|
78
|
+
req.method = "POST";
|
|
79
|
+
req.headers = {
|
|
80
|
+
"svix-id": event.event_id,
|
|
81
|
+
"svix-timestamp": String(Math.floor(timestamp.getTime() / 1000)),
|
|
82
|
+
"svix-signature": valid ? new Webhook(secret).sign(event.event_id, timestamp, body) : "invalid",
|
|
83
|
+
};
|
|
84
|
+
let status = 0;
|
|
85
|
+
let archiveAtAck: ReturnType<typeof getKv> | undefined;
|
|
86
|
+
const res = {
|
|
87
|
+
writeHead(code: number) {
|
|
88
|
+
status = code;
|
|
89
|
+
},
|
|
90
|
+
end() {
|
|
91
|
+
if (status === 200) archiveAtAck = getKv(namespace, key(event.message!.message_id));
|
|
92
|
+
},
|
|
93
|
+
} as unknown as ServerResponse;
|
|
94
|
+
await handleWebhooks(req, res, ["api", "agentmail", "webhook"]);
|
|
95
|
+
return { status, archiveAtAck: await archiveAtAck };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Real in-memory SQL/KV, only provider enrichment is stubbed. There are no list
|
|
99
|
+
// or thread methods on this context: discovering cold mail cannot depend on them.
|
|
100
|
+
function context() {
|
|
101
|
+
return {
|
|
102
|
+
swarm: {
|
|
103
|
+
async db_query({ sql, params }: { sql: string; params?: unknown[] }) {
|
|
104
|
+
const rows = await getDbClient().query<Record<string, unknown>>(sql, params);
|
|
105
|
+
return { success: true, rows: rows.map((row) => Object.values(row)) };
|
|
106
|
+
},
|
|
107
|
+
async kv_getOrNull({ namespace: ns, key }: { namespace: string; key: string }) {
|
|
108
|
+
KvNamespaceSchema.parse(ns);
|
|
109
|
+
KvKeySchema.parse(key);
|
|
110
|
+
return await getKv(ns, key);
|
|
111
|
+
},
|
|
112
|
+
async kv_set(input: Parameters<typeof upsertKv>[0] & { expiresInSec?: number }) {
|
|
113
|
+
KvNamespaceSchema.parse(input.namespace);
|
|
114
|
+
KvKeySchema.parse(input.key);
|
|
115
|
+
await upsertKv({
|
|
116
|
+
...input,
|
|
117
|
+
expiresAt: input.expiresInSec ? Date.now() + input.expiresInSec * 1000 : null,
|
|
118
|
+
});
|
|
119
|
+
return { success: true };
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
stdlib: {
|
|
123
|
+
async fetchJson() {
|
|
124
|
+
return { Status: 0, Answer: [{ type: 15, data: "10 mx.acme.example" }] };
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
api: {},
|
|
128
|
+
} as unknown as Parameters<typeof triage>[1];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
describe("verified inbound archive", () => {
|
|
132
|
+
test("cold unauthenticated mail is durable before ACK despite routing filter", async () => {
|
|
133
|
+
const result = await deliver(payload());
|
|
134
|
+
expect(result.status).toBe(200);
|
|
135
|
+
expect(result.archiveAtAck?.value).toMatchObject({
|
|
136
|
+
payload: {
|
|
137
|
+
event_type: payload().event_type,
|
|
138
|
+
message: { message_id: payload().message!.message_id },
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
test("stores only bounded discovery fields and scrubs credentials before truncation", async () => {
|
|
143
|
+
const event = payload();
|
|
144
|
+
const token = `ghp_${"a".repeat(36)}`;
|
|
145
|
+
Object.assign(event.message!, {
|
|
146
|
+
subject: `${token} ${"s".repeat(500)}`,
|
|
147
|
+
text: `${token} ${"t".repeat(20000)}`,
|
|
148
|
+
html: "h".repeat(20000),
|
|
149
|
+
headers: { authorization: "Bearer private-credential" },
|
|
150
|
+
extra: "unexpected-secret",
|
|
151
|
+
attachments: [
|
|
152
|
+
{
|
|
153
|
+
filename: "pitch.zip",
|
|
154
|
+
content_type: "application/zip",
|
|
155
|
+
size: 123,
|
|
156
|
+
attachment_id: "private-id",
|
|
157
|
+
content: "private-bytes",
|
|
158
|
+
},
|
|
159
|
+
],
|
|
160
|
+
});
|
|
161
|
+
await archiveInboundMessage(event);
|
|
162
|
+
const entry = await getKv(namespace, key(event.message!.message_id));
|
|
163
|
+
const value = entry!.value as {
|
|
164
|
+
capturedAt: string;
|
|
165
|
+
payload: { message: Record<string, unknown> };
|
|
166
|
+
};
|
|
167
|
+
const message = value.payload.message;
|
|
168
|
+
expect(Object.keys(message).sort()).toEqual(
|
|
169
|
+
[
|
|
170
|
+
"inbox_id",
|
|
171
|
+
"message_id",
|
|
172
|
+
"thread_id",
|
|
173
|
+
"from_",
|
|
174
|
+
"reply_to",
|
|
175
|
+
"subject",
|
|
176
|
+
"text",
|
|
177
|
+
"html",
|
|
178
|
+
"timestamp",
|
|
179
|
+
"labels",
|
|
180
|
+
"attachments",
|
|
181
|
+
].sort(),
|
|
182
|
+
);
|
|
183
|
+
expect(String(message.subject)).toHaveLength(300);
|
|
184
|
+
expect(String(message.text)).toHaveLength(16000);
|
|
185
|
+
expect(String(message.html)).toHaveLength(16000);
|
|
186
|
+
expect(JSON.stringify(value)).not.toContain(token);
|
|
187
|
+
expect(JSON.stringify(value)).not.toContain("private-");
|
|
188
|
+
expect(JSON.stringify(value)).not.toContain("unexpected-secret");
|
|
189
|
+
expect(message.attachments).toEqual([
|
|
190
|
+
{ filename: "pitch.zip", content_type: "application/zip", size: 123 },
|
|
191
|
+
]);
|
|
192
|
+
expect(entry!.expiresAt).toBe(Date.parse(value.capturedAt) + INBOUND_ARCHIVE_TTL_MS);
|
|
193
|
+
const triaged = await triage({ dryRun: true }, context());
|
|
194
|
+
expect(triaged.briefs[0]?.flag).toBe("IGNORE");
|
|
195
|
+
});
|
|
196
|
+
test("live retries preserve content and expiry; expired replay starts a new window", async () => {
|
|
197
|
+
const event = payload();
|
|
198
|
+
await archiveInboundMessage(event);
|
|
199
|
+
const first = await getKv(namespace, key(event.message!.message_id));
|
|
200
|
+
event.message!.subject = "Changed retry";
|
|
201
|
+
await archiveInboundMessage(event);
|
|
202
|
+
expect(await getKv(namespace, key(event.message!.message_id))).toEqual(first);
|
|
203
|
+
await getDbClient().run("UPDATE kv_entries SET expires_at = ? WHERE namespace = ?", [
|
|
204
|
+
Date.now() - 1,
|
|
205
|
+
namespace,
|
|
206
|
+
]);
|
|
207
|
+
const expired = await triage({ dryRun: true }, context());
|
|
208
|
+
expect(expired.briefs).toHaveLength(0);
|
|
209
|
+
expect(expired.controls.countsValid).toBe(false);
|
|
210
|
+
await archiveInboundMessage(event);
|
|
211
|
+
const replay = await getKv(namespace, key(event.message!.message_id));
|
|
212
|
+
expect(replay?.value).toMatchObject({ payload: { message: { subject: "Changed retry" } } });
|
|
213
|
+
expect(replay!.expiresAt).toBeGreaterThan(Date.now());
|
|
214
|
+
});
|
|
215
|
+
test("new captures physically sweep expired archive rows", async () => {
|
|
216
|
+
await archiveInboundMessage(payload("expired"));
|
|
217
|
+
await getDbClient().run("UPDATE kv_entries SET expires_at = ? WHERE namespace = ?", [
|
|
218
|
+
Date.now() - 1,
|
|
219
|
+
namespace,
|
|
220
|
+
]);
|
|
221
|
+
await archiveInboundMessage(payload("fresh"));
|
|
222
|
+
const rows = await getDbClient().query("SELECT key FROM kv_entries WHERE namespace = ?", [
|
|
223
|
+
namespace,
|
|
224
|
+
]);
|
|
225
|
+
expect(rows).toEqual([{ key: key("fresh") }]);
|
|
226
|
+
});
|
|
227
|
+
test("invalid signatures never enter the archive", async () => {
|
|
228
|
+
expect((await deliver(payload(), false)).status).toBe(401);
|
|
229
|
+
expect(await getKv(namespace, key("cold-unauthenticated"))).toBeNull();
|
|
230
|
+
});
|
|
231
|
+
test("storage errors return 503, and redelivery succeeds after recovery", async () => {
|
|
232
|
+
await getDbClient().run("ALTER TABLE kv_entries RENAME TO unavailable_kv");
|
|
233
|
+
expect((await deliver(payload())).status).toBe(503);
|
|
234
|
+
await getDbClient().run("ALTER TABLE unavailable_kv RENAME TO kv_entries");
|
|
235
|
+
expect((await deliver(payload())).status).toBe(200);
|
|
236
|
+
});
|
|
237
|
+
test("concurrent retries preserve one minimized message, distinct inboxes remain separate", async () => {
|
|
238
|
+
await Promise.all(Array.from({ length: 8 }, () => archiveInboundMessage(payload())));
|
|
239
|
+
const other = payload();
|
|
240
|
+
other.message!.inbox_id = "other@example.com";
|
|
241
|
+
await archiveInboundMessage(other);
|
|
242
|
+
expect(
|
|
243
|
+
await getDbClient().query("SELECT key FROM kv_entries WHERE namespace = ?", [namespace]),
|
|
244
|
+
).toHaveLength(2);
|
|
245
|
+
expect(
|
|
246
|
+
await getKv(namespace, inboundArchiveKey("other@example.com", other.message!.message_id)),
|
|
247
|
+
).not.toBeNull();
|
|
248
|
+
});
|
|
249
|
+
test("blocked and spam deliveries are captured without enabling task routing", async () => {
|
|
250
|
+
for (const type of ["message.received.blocked", "message.received.spam"] as const) {
|
|
251
|
+
const event = payload(type);
|
|
252
|
+
event.event_type = type;
|
|
253
|
+
expect((await deliver(event)).archiveAtAck).toBeTruthy();
|
|
254
|
+
}
|
|
255
|
+
const sent = payload("sent");
|
|
256
|
+
sent.event_type = "message.sent";
|
|
257
|
+
await archiveInboundMessage(sent);
|
|
258
|
+
expect(await getKv(namespace, key("sent"))).toBeNull();
|
|
259
|
+
});
|
|
260
|
+
test("malformed inbound deliveries receive a retryable error", async () => {
|
|
261
|
+
const event = payload();
|
|
262
|
+
delete event.message;
|
|
263
|
+
expect((await deliver(event)).status).toBe(503);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
describe("contact triage archive reader", () => {
|
|
268
|
+
test("query and marker failures stay visible and retain pending mail", async () => {
|
|
269
|
+
await archiveInboundMessage(payload());
|
|
270
|
+
const brokenQuery = context();
|
|
271
|
+
brokenQuery.swarm.db_query = async () => ({ success: false });
|
|
272
|
+
expect((await triage({}, brokenQuery)).controls.countsValid).toBe(false);
|
|
273
|
+
const brokenWrite = context();
|
|
274
|
+
brokenWrite.swarm.kv_set = async () => {
|
|
275
|
+
throw new Error("storage unavailable");
|
|
276
|
+
};
|
|
277
|
+
const result = await triage({}, brokenWrite);
|
|
278
|
+
expect(result.stats.kvWritten).toBe(0);
|
|
279
|
+
expect(result.controls.countsValid).toBe(false);
|
|
280
|
+
expect(result.errors[0]).toContain("Failed dedupe write");
|
|
281
|
+
expect((await triage({}, context())).briefs).toHaveLength(1);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("discovers cold inbound without listing endpoints and never claims historical completeness", async () => {
|
|
285
|
+
const event = payload("<cold-message@example.com>");
|
|
286
|
+
event.message!.labels = [];
|
|
287
|
+
await archiveInboundMessage(event);
|
|
288
|
+
const result = await triage({ dryRun: true }, context());
|
|
289
|
+
expect(result.briefs).toHaveLength(1);
|
|
290
|
+
expect(result.briefs[0]?.flag).toBe("NEEDS_HUMAN");
|
|
291
|
+
expect(result.briefs[0]?.from.address).toBe("alex@acme.example");
|
|
292
|
+
expect(result.controls.ok).toBe(true);
|
|
293
|
+
expect(result.stats.archiveDrained).toBe(true);
|
|
294
|
+
expect(result.stats.coverageComplete).toBe(false);
|
|
295
|
+
expect(result.stats.historicalCoverage).toBe("unknown");
|
|
296
|
+
expect(result.stats.kvWritten).toBe(0);
|
|
297
|
+
});
|
|
298
|
+
test("bounded runs drain backlog, including old mail and multiple messages in one thread", async () => {
|
|
299
|
+
for (let i = 0; i < 27; i++)
|
|
300
|
+
await archiveInboundMessage(payload(`message-${String(i).padStart(2, "0")}`));
|
|
301
|
+
const first = await triage({ limit: 26 }, context());
|
|
302
|
+
expect(first.briefs).toHaveLength(26);
|
|
303
|
+
expect(first.stats.kvWritten).toBe(26);
|
|
304
|
+
const marker = await getKv("contact-triage-messages", key("message-00"));
|
|
305
|
+
expect(marker!.expiresAt).toBeGreaterThan(Date.now());
|
|
306
|
+
expect(marker!.expiresAt).toBeLessThanOrEqual(Date.now() + INBOUND_ARCHIVE_TTL_MS);
|
|
307
|
+
expect(first.stats.archiveDrained).toBe(false);
|
|
308
|
+
const second = await triage({}, context());
|
|
309
|
+
expect(second.briefs).toHaveLength(1);
|
|
310
|
+
expect(second.stats.archiveDrained).toBe(true);
|
|
311
|
+
const empty = await triage({}, context());
|
|
312
|
+
expect(empty.briefs).toHaveLength(0);
|
|
313
|
+
expect(empty.controls.countsValid).toBe(false);
|
|
314
|
+
expect(empty.stats.countsVoid).toBe(true);
|
|
315
|
+
});
|
|
316
|
+
test("archive failures and missing entries cannot produce valid counts or dedupe markers", async () => {
|
|
317
|
+
await archiveInboundMessage(payload());
|
|
318
|
+
const ctx = context();
|
|
319
|
+
ctx.swarm.kv_getOrNull = async () => null;
|
|
320
|
+
const result = await triage({}, ctx);
|
|
321
|
+
expect(result.controls.ok).toBe(false);
|
|
322
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
323
|
+
expect(await getKv("contact-triage-messages", key("cold-unauthenticated"))).toBeNull();
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
describe("contact enrichment approval", () => {
|
|
328
|
+
async function setup() {
|
|
329
|
+
const event = payload();
|
|
330
|
+
event.message!.from_ = "Alex Rivera <private-local@acme.example>";
|
|
331
|
+
event.message!.text =
|
|
332
|
+
"Our developer team wants a Desplega pilot. https://github.com/alex-rivera?secret=private-value#private-fragment";
|
|
333
|
+
await archiveInboundMessage(event);
|
|
334
|
+
const dns = mock(async (..._args: unknown[]) => ({
|
|
335
|
+
Status: 0,
|
|
336
|
+
Answer: [{ type: 15, data: "10 mx.acme.example" }],
|
|
337
|
+
}));
|
|
338
|
+
const exa = mock(async (..._args: unknown[]) => ({ results: [] }));
|
|
339
|
+
const github = mock(async (..._args: unknown[]) => ({ data: { user: null } }));
|
|
340
|
+
const ctx = context();
|
|
341
|
+
ctx.stdlib.fetchJson = dns;
|
|
342
|
+
ctx.api = { exa: { search: exa }, ghGraphql: { graphql: github } } as unknown as typeof ctx.api;
|
|
343
|
+
return { ctx, dns, exa, github };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
test("omitted and empty approvals make zero outbound calls and retain local triage", async () => {
|
|
347
|
+
const { ctx, dns, exa, github } = await setup();
|
|
348
|
+
for (const enrichment of [undefined, {}]) {
|
|
349
|
+
const result = await triage({ dryRun: true, enrichment }, ctx);
|
|
350
|
+
expect(result.controls.ok).toBe(true);
|
|
351
|
+
expect(result.briefs[0]?.flag).toBe("NEEDS_HUMAN");
|
|
352
|
+
expect(result.specGaps.join(" ")).toContain("enrichment disabled");
|
|
353
|
+
}
|
|
354
|
+
expect(dns).not.toHaveBeenCalled();
|
|
355
|
+
expect(exa).not.toHaveBeenCalled();
|
|
356
|
+
expect(github).not.toHaveBeenCalled();
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("each provider needs its own exact approval and receives only the approved identifier", async () => {
|
|
360
|
+
const { ctx, dns, exa, github } = await setup();
|
|
361
|
+
await triage({ dryRun: true, enrichment: { dnsDomains: ["acme.example"] } }, ctx);
|
|
362
|
+
expect(dns).toHaveBeenCalledTimes(1);
|
|
363
|
+
expect(exa).not.toHaveBeenCalled();
|
|
364
|
+
expect(github).not.toHaveBeenCalled();
|
|
365
|
+
expect(dns.mock.calls[0]?.[0]).toBe("https://dns.google/resolve?name=acme.example&type=MX");
|
|
366
|
+
await triage({ dryRun: true, enrichment: { exaDomains: ["acme.example"] } }, ctx);
|
|
367
|
+
expect(dns).toHaveBeenCalledTimes(1);
|
|
368
|
+
expect(exa).toHaveBeenCalledWith({
|
|
369
|
+
body: { query: "site:acme.example", numResults: 3, type: "fast" },
|
|
370
|
+
});
|
|
371
|
+
expect(github).not.toHaveBeenCalled();
|
|
372
|
+
await triage({ dryRun: true, enrichment: { githubLogins: ["alex-rivera"] } }, ctx);
|
|
373
|
+
expect(github).toHaveBeenCalledWith(expect.any(String), { login: "alex-rivera" });
|
|
374
|
+
expect(exa).toHaveBeenCalledTimes(1);
|
|
375
|
+
const sent = JSON.stringify([dns.mock.calls, exa.mock.calls, github.mock.calls]);
|
|
376
|
+
for (const privateField of [
|
|
377
|
+
"Alex Rivera",
|
|
378
|
+
"private-local",
|
|
379
|
+
"private-value",
|
|
380
|
+
"private-fragment",
|
|
381
|
+
"pilot",
|
|
382
|
+
]) {
|
|
383
|
+
expect(sent).not.toContain(privateField);
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("nonmatching allowlists cannot enable calls", async () => {
|
|
388
|
+
const { ctx, dns, exa, github } = await setup();
|
|
389
|
+
await triage(
|
|
390
|
+
{
|
|
391
|
+
dryRun: true,
|
|
392
|
+
enrichment: {
|
|
393
|
+
dnsDomains: ["example" + ".org"],
|
|
394
|
+
exaDomains: ["sub.acme.example"],
|
|
395
|
+
githubLogins: ["someone-else"],
|
|
396
|
+
},
|
|
397
|
+
},
|
|
398
|
+
ctx,
|
|
399
|
+
);
|
|
400
|
+
expect(dns).not.toHaveBeenCalled();
|
|
401
|
+
expect(exa).not.toHaveBeenCalled();
|
|
402
|
+
expect(github).not.toHaveBeenCalled();
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test("malformed approval identifiers fail closed before provider calls", async () => {
|
|
406
|
+
const { ctx, dns, exa, github } = await setup();
|
|
407
|
+
for (const enrichment of [
|
|
408
|
+
{ dnsDomains: ["*.example"] },
|
|
409
|
+
{ exaDomains: ["acme.example secret"] },
|
|
410
|
+
{ githubLogins: ["alex-rivera?token=private"] },
|
|
411
|
+
]) {
|
|
412
|
+
await expect(triage({ dryRun: true, enrichment }, ctx)).rejects.toThrow();
|
|
413
|
+
}
|
|
414
|
+
expect(dns).not.toHaveBeenCalled();
|
|
415
|
+
expect(exa).not.toHaveBeenCalled();
|
|
416
|
+
expect(github).not.toHaveBeenCalled();
|
|
417
|
+
});
|
|
418
|
+
});
|
|
@@ -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({
|