@desplega.ai/agent-swarm 1.146.0 → 1.147.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-jnq4612m.js → acp-adapter-9n319wqc.js} +42 -4
- package/dist/{actions-s41xcn3f.js → actions-2vxqvpr9.js} +9 -8
- package/dist/{app-j8bfw3f6.js → app-w066xfy0.js} +6 -5
- package/dist/assistant-kt31dj2h.js +236 -0
- package/dist/{boot-reembed-jxdz46jp.js → boot-reembed-az5rassp.js} +7 -6
- package/dist/{boot-reembed-x1476agm.js → boot-reembed-j3mm3rfz.js} +8 -7
- package/dist/{boot-scrub-logs-rab9h5bt.js → boot-scrub-logs-b6h54817.js} +4 -4
- package/dist/{claude-adapter-ep6qekbm.js → claude-adapter-w1z04ann.js} +8 -6
- package/dist/{claude-managed-setup-w619jncn.js → claude-managed-setup-f5pmsyrp.js} +24 -3
- package/dist/{claude-sdk-session-0wp3qrcy.js → claude-sdk-session-f0cc1kec.js} +8 -6
- package/dist/{cli-bafd6xnf.js → cli-0b6y7kxm.js} +1 -1
- package/dist/{cli-jyy3e193.js → cli-12fz972k.js} +4 -4
- package/dist/{cli-pzfc7j7g.js → cli-1th1728g.js} +2 -2
- package/dist/{cli-68h2mfrn.js → cli-3tgwnf43.js} +3 -3
- package/dist/{cli-q3qr220d.js → cli-4nr988h6.js} +1 -1
- package/dist/{cli-m4xsy03s.js → cli-53s590z8.js} +1 -1
- package/dist/{cli-c8q7292f.js → cli-54wwve05.js} +5 -4
- package/dist/{cli-rymrjneb.js → cli-719k9j2c.js} +2 -2
- package/dist/{cli-zn65x0w8.js → cli-89xfd7f9.js} +21 -47
- package/dist/{cli-506z6jqa.js → cli-8v0g1yc8.js} +1 -1
- package/dist/cli-9d8ntetj.js +42 -0
- package/dist/{cli-dhg6j8h3.js → cli-atcve9y8.js} +826 -737
- package/dist/{cli-46yck6nj.js → cli-b5bq9crk.js} +14 -31
- package/dist/cli-d8brzxjb.js +190 -0
- package/dist/{cli-a960nkfb.js → cli-de14znh8.js} +2 -2
- package/dist/{cli-s8sbe32s.js → cli-dttph1bw.js} +2 -2
- package/dist/cli-ep19k3mb.js +19 -0
- package/dist/{cli-d9pnxgth.js → cli-f146mn5s.js} +7 -50
- package/dist/{cli-qar1f940.js → cli-f7dsy61y.js} +5 -3
- package/dist/{cli-d4xqde2b.js → cli-ftht3zzj.js} +2780 -2587
- package/dist/{cli-pmwbdw36.js → cli-hbv0kq6w.js} +2 -2
- package/dist/{cli-fhsz3twr.js → cli-jsp14b6c.js} +171 -1
- package/dist/{cli-2zvtzevh.js → cli-kpv03zhj.js} +13 -11
- package/dist/{cli-a9bn9svs.js → cli-m8yrsg97.js} +2 -2
- package/dist/cli-m9sxbkhm.js +779 -0
- package/dist/cli-mmemxxdc.js +823 -0
- package/dist/{cli-6xwhtc64.js → cli-n8508nre.js} +1 -1
- package/dist/{cli-q0qq1sxw.js → cli-p1d5b073.js} +37 -2
- package/dist/{cli-yqeb27t1.js → cli-rw51bq3j.js} +13 -10
- package/dist/{cli-zww3vcnm.js → cli-smby5m17.js} +7 -6
- package/dist/{cli-ah5hjk49.js → cli-tzvk9haz.js} +94 -372
- package/dist/{cli-yxwe913z.js → cli-w9kb3bk1.js} +1 -1
- package/dist/{cli-skd674mt.js → cli-y6b6fb76.js} +25 -3
- package/dist/{cli-3k2g3kg2.js → cli-yz3djrm0.js} +9 -9
- package/dist/{cli-4secrq32.js → cli-zhjmqxzh.js} +1 -1
- package/dist/cli.js +13 -13
- package/dist/{codex-adapter-d6qajxj3.js → codex-adapter-zw4dwsx7.js} +2 -2
- package/dist/{codex-hook-n7m5k7cr.js → codex-hook-bw8p5nh4.js} +2 -2
- package/dist/{codex-session-runner-v9krqe4x.js → codex-session-runner-k981s65v.js} +2 -2
- package/dist/{commands-923wsas7.js → commands-sykrxx7e.js} +4 -4
- package/dist/{db-m1hy4zh5.js → db-sfbsc8wt.js} +6 -4
- package/dist/handlers-0aspcejm.js +603 -0
- package/dist/{hook-ee4hff56.js → hook-4nvqj2sb.js} +7 -5
- package/dist/{hook-bxe3bkdy.js → hook-hxccs7m5.js} +24 -49
- package/dist/{http-a8rx91me.js → http-y06z517d.js} +223 -92
- package/dist/{index-697zfzv1.js → index-hssww7kj.js} +15 -14
- package/dist/{index-3x9g3nbg.js → index-k8znfx0z.js} +13 -12
- package/dist/{index-1j9va48r.js → index-tce1rss4.js} +371 -110
- package/dist/{index-jj4q3x59.js → index-x5j894zf.js} +14 -13
- package/dist/{keepalive-sds4e4ce.js → keepalive-w733ax66.js} +6 -6
- package/dist/{lead-ggq6szk8.js → lead-75bjqa9d.js} +29 -27
- package/dist/{maintenance-093p41d4.js → maintenance-w4f1zjck.js} +8 -7
- package/dist/{oauth-refresh-sweep-dvzq4ckj.js → oauth-refresh-sweep-wdetky26.js} +5 -5
- package/dist/{onboard-ydr9tzw5.js → onboard-bb5net8s.js} +2 -2
- package/dist/{otel-impl-zx3tdf3j.js → otel-impl-0w7c14ap.js} +1 -1
- package/dist/{pi-mono-adapter-dtkz4e7j.js → pi-mono-adapter-2has4gnp.js} +12 -2
- package/dist/{pricing-refresh-7e6pqnzp.js → pricing-refresh-9nzsrgcq.js} +6 -6
- package/dist/{rbac-roles-p2rh6d0r.js → rbac-roles-r3hqjp09.js} +5 -5
- package/dist/{rbac-roles-0dv1wt0w.js → rbac-roles-z1jrffbt.js} +4 -4
- package/dist/{registry-9kdck5ye.js → registry-v5wytg0s.js} +1 -2
- package/dist/{render-v2-4k3jt4r2.js → render-v2-vbr89fnb.js} +6 -5
- package/dist/{seed-pricing-2cc1qmcd.js → seed-pricing-g62hy1pk.js} +7 -5
- package/dist/{setup-c5yss30k.js → setup-gnfqnptp.js} +2 -2
- package/dist/{worker-7srgeqcr.js → worker-kznxp4qm.js} +29 -27
- package/openapi.json +263 -12
- package/package.json +1 -1
- package/plugin/commands/start-worker.md +4 -9
- package/plugin/pi-skills/start-worker/SKILL.md +4 -9
- package/src/be/db/agents.ts +556 -0
- package/src/be/db/context-versions.ts +117 -0
- package/src/be/db/runtime.ts +406 -0
- package/src/be/db/tasks/read.ts +1167 -0
- package/src/be/db/tasks/write.ts +953 -0
- package/src/be/db.ts +583 -3428
- package/src/be/migrations/147_scheduled_tasks_parent_task.sql +7 -0
- package/src/be/migrations/148_pages_svg.sql +123 -0
- package/src/be/migrations/149_session_tokens.sql +24 -0
- package/src/be/scripts/typecheck.ts +1 -0
- package/src/be/seed-pricing.ts +13 -8
- package/src/be/seed-scripts/catalog/ops-catalog-audit.inline.ts +1 -1
- package/src/be/seed-scripts/catalog/ops-catalog-audit.ts +1 -1
- package/src/be/seed-scripts/catalog/star-history-refresh.ts +101 -0
- package/src/be/seed-scripts/catalog/star-history-renderer.ts +119 -0
- package/src/be/seed-scripts/index.ts +17 -0
- package/src/be/seed-skills/index.ts +14 -0
- package/src/be/swarm-config-guard.ts +1 -1
- package/src/be/task-attachment-store.ts +70 -0
- package/src/be/users.ts +77 -0
- package/src/commands/attachments-section.ts +37 -0
- package/src/commands/claude-managed-setup.ts +45 -3
- package/src/commands/claude-md-session.ts +408 -0
- package/src/commands/context-preamble.ts +15 -3
- package/src/commands/profile-sync.ts +205 -5
- package/src/commands/provider-credentials.ts +60 -27
- package/src/commands/runner.ts +63 -58
- package/src/commands/templates.ts +25 -2
- package/src/hooks/hook.ts +26 -67
- package/src/http/agents.ts +40 -1
- package/src/http/auth.ts +13 -1
- package/src/http/fs.ts +21 -51
- package/src/http/mcp.ts +32 -0
- package/src/http/pages-public.ts +20 -4
- package/src/http/poll.ts +4 -0
- package/src/http/repos.ts +3 -3
- package/src/http/sessions.ts +82 -0
- package/src/http/tasks.ts +10 -2
- package/src/prompts/session-templates.ts +4 -2
- package/src/providers/acp-adapter.ts +42 -6
- package/src/providers/pi-mono-adapter.ts +23 -6
- package/src/scheduler/scheduler.test.ts +49 -2
- package/src/scheduler/scheduler.ts +4 -0
- package/src/scripts-runtime/sdk-allowlist.ts +1 -0
- package/src/scripts-runtime/types/stdlib.d.ts +8 -0
- package/src/scripts-runtime/types/swarm-sdk.d.ts +8 -0
- package/src/server.ts +2 -0
- package/src/slack/assistant.ts +87 -46
- package/src/slack/handlers.test.ts +1 -3
- package/src/slack/handlers.ts +102 -87
- package/src/slack/inbound-files.ts +466 -0
- package/src/slack/templates.ts +14 -0
- package/src/tasks/task-terminal-effects.ts +158 -0
- package/src/tests/acp-adapter.test.ts +327 -192
- package/src/tests/budget-claim-gate.test.ts +1 -0
- package/src/tests/build-pi-skills.test.ts +5 -7
- package/src/tests/claude-managed-setup.test.ts +52 -4
- package/src/tests/claude-md-session.test.ts +603 -0
- package/src/tests/codex-skill-resolver.test.ts +19 -0
- package/src/tests/context-versioning.test.ts +107 -0
- package/src/tests/credential-check.test.ts +157 -14
- package/src/tests/db-agents.test.ts +91 -0
- package/src/tests/db-context-versions.test.ts +37 -0
- package/src/tests/db-extraction-contract.test.ts +45 -0
- package/src/tests/db-runtime.test.ts +70 -0
- package/src/tests/db-task-read-contract.test.ts +624 -0
- package/src/tests/db-task-write-contract.test.ts +653 -0
- package/src/tests/db-tasks-read.test.ts +183 -0
- package/src/tests/db-tasks-write.test.ts +230 -0
- package/src/tests/defer-task.test.ts +389 -0
- package/src/tests/docs-index-footprint.test.js +2 -2
- package/src/tests/e2e-visuals-stage.test.ts +151 -0
- package/src/tests/file-lock.test.ts +144 -0
- package/src/tests/fixtures/file-lock-holder.ts +13 -0
- package/src/tests/fixtures/hold-file-lock.ts +25 -0
- package/src/tests/heartbeat-expiry.test.ts +188 -0
- package/src/tests/http-api-integration.test.ts +78 -1
- package/src/tests/pages-public-html.test.ts +43 -0
- package/src/tests/pages-svg-migration.test.ts +42 -0
- package/src/tests/preload.ts +4 -1
- package/src/tests/profile-sync-warning.test.ts +16 -0
- package/src/tests/profile-sync.test.ts +126 -0
- package/src/tests/prompt-template-session.test.ts +20 -4
- package/src/tests/provider-registration.test.ts +93 -0
- package/src/tests/rbac-e2e-helpers.test.ts +53 -0
- package/src/tests/rbac-e2e-helpers.ts +19 -2
- package/src/tests/routing-affinity-inventory.test.ts +1 -1
- package/src/tests/runner-attachments-section.test.ts +22 -2
- package/src/tests/runner-context-preamble.test.ts +30 -0
- package/src/tests/runner-deployment-resume.test.ts +107 -0
- package/src/tests/runner-repo-autostash.test.ts +12 -0
- package/src/tests/script-connections.test.ts +4 -0
- package/src/tests/seed-scripts.test.ts +2 -2
- package/src/tests/session-tokens.test.ts +279 -0
- package/src/tests/slack-file-tools-attachments.test.ts +408 -0
- package/src/tests/slack-inbound-files.test.ts +847 -0
- package/src/tests/star-history-refresh.test.ts +102 -0
- package/src/tests/swarm-repos.test.ts +20 -0
- package/src/tests/tool-annotations.test.ts +4 -3
- package/src/tests/ui-logs-parser.test.ts +176 -0
- package/src/tools/create-page.ts +4 -2
- package/src/tools/defer-task.ts +239 -0
- package/src/tools/repos/update-repo.ts +2 -2
- package/src/tools/schedules/create-schedule.ts +1 -0
- package/src/tools/schedules/list-schedules.ts +1 -0
- package/src/tools/schedules/patch-schedule.ts +1 -0
- package/src/tools/schedules/update-schedule.ts +1 -0
- package/src/tools/slack-download-file.ts +60 -29
- package/src/tools/slack-read.ts +73 -32
- package/src/tools/store-progress.ts +16 -146
- package/src/tools/tool-config.ts +1 -0
- package/src/tools/utils.ts +21 -0
- package/src/types.ts +37 -4
- package/src/utils/acp-session-token.ts +56 -0
- package/src/utils/file-lock.ts +111 -0
- package/src/utils/heartbeat-expiry.ts +78 -0
- package/src/utils/request-auth-context.ts +2 -0
- package/src/utils/task-attachment-links.ts +22 -0
- package/templates/skills/code-quality/content.md +8 -5
- package/templates/skills/download-task-attachment/content.md +2 -1
- package/{plugin/pi-skills → templates/skills}/review-offered-task/SKILL.md +5 -5
- package/templates/skills/review-offered-task/config.json +14 -0
- package/{plugin/commands/review-offered-task.md → templates/skills/review-offered-task/content.md} +3 -8
- package/templates/skills/scheduling/content.md +4 -0
- package/templates/skills/slack-interaction/content.md +2 -2
- package/templates/skills/work-on-task/SKILL.md +16 -0
- package/templates/skills/work-on-task/config.json +14 -0
- package/templates/skills/work-on-task/content.md +11 -0
- package/dist/assistant-dx3ew4d8.js +0 -212
- package/dist/cli-00kyrp1a.js +0 -317
- package/dist/cli-n1q8q14d.js +0 -177
- package/dist/cli-ss6vybmt.js +0 -269
- package/dist/handlers-e9vp2fr8.js +0 -592
- package/plugin/commands/work-on-task.md +0 -39
- package/plugin/pi-skills/work-on-task/SKILL.md +0 -39
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the session-token lifecycle:
|
|
3
|
+
* - mintSessionToken / revokeSessionToken / resolveBySessionToken (DB layer)
|
|
4
|
+
* - resolveHttpRequestAuth with aseph_ bearer (auth middleware)
|
|
5
|
+
* - POST /api/sessions/tokens and DELETE /api/sessions/tokens/:id (HTTP layer)
|
|
6
|
+
*
|
|
7
|
+
* Uses the same in-memory SQLite + real HTTP server pattern as http-users.test.ts.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
11
|
+
import {
|
|
12
|
+
createServer as createHttpServer,
|
|
13
|
+
type IncomingMessage,
|
|
14
|
+
type Server,
|
|
15
|
+
type ServerResponse,
|
|
16
|
+
} from "node:http";
|
|
17
|
+
import { closeDb, getDbClient, initDb } from "../be/db";
|
|
18
|
+
import { mintSessionToken, resolveBySessionToken, revokeSessionToken } from "../be/users";
|
|
19
|
+
import { resolveHttpRequestAuth } from "../http/auth";
|
|
20
|
+
import { handleCore } from "../http/core";
|
|
21
|
+
import { handleMcp } from "../http/mcp";
|
|
22
|
+
import { handleSessions } from "../http/sessions";
|
|
23
|
+
import { getPathSegments, parseQueryParams } from "../http/utils";
|
|
24
|
+
import { listenOnFreePort } from "./test-net";
|
|
25
|
+
|
|
26
|
+
const API_KEY = "test-session-token-key";
|
|
27
|
+
|
|
28
|
+
function createTestServer(apiKey: string): Server {
|
|
29
|
+
return createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
30
|
+
const myAgentId = req.headers["x-agent-id"] as string | undefined;
|
|
31
|
+
const handled = await handleCore(req, res, myAgentId, apiKey);
|
|
32
|
+
if (handled) return;
|
|
33
|
+
const pathSegments = getPathSegments(req.url || "");
|
|
34
|
+
const queryParams = parseQueryParams(req.url || "");
|
|
35
|
+
const ok = await handleSessions(req, res, pathSegments, queryParams);
|
|
36
|
+
if (!ok) {
|
|
37
|
+
res.writeHead(404);
|
|
38
|
+
res.end("Not Found");
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let server: Server;
|
|
44
|
+
let port: number;
|
|
45
|
+
|
|
46
|
+
beforeAll(async () => {
|
|
47
|
+
initDb(":memory:");
|
|
48
|
+
server = createTestServer(API_KEY);
|
|
49
|
+
port = await listenOnFreePort(server);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterAll(async () => {
|
|
53
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
54
|
+
closeDb();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
beforeEach(async () => {
|
|
58
|
+
await getDbClient().run("DELETE FROM session_tokens");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// ─── DB-layer unit tests ──────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
describe("mintSessionToken / resolveBySessionToken / revokeSessionToken", () => {
|
|
64
|
+
test("mints a token that resolves to its principal", async () => {
|
|
65
|
+
const { tokenId, plaintext } = await mintSessionToken("agent-1", "task-1", 60_000);
|
|
66
|
+
expect(plaintext).toMatch(/^aseph_/);
|
|
67
|
+
expect(tokenId).toBeTruthy();
|
|
68
|
+
|
|
69
|
+
const principal = await resolveBySessionToken(plaintext);
|
|
70
|
+
expect(principal).toMatchObject({ agentId: "agent-1", taskId: "task-1" });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("returns null for an unknown token", async () => {
|
|
74
|
+
expect(await resolveBySessionToken("aseph_nonexistent")).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("returns null for a revoked token", async () => {
|
|
78
|
+
const { tokenId, plaintext } = await mintSessionToken("agent-2", "task-2", 60_000);
|
|
79
|
+
await revokeSessionToken(tokenId);
|
|
80
|
+
expect(await resolveBySessionToken(plaintext)).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("returns null for an expired token", async () => {
|
|
84
|
+
const { plaintext } = await mintSessionToken("agent-3", "task-3", -1);
|
|
85
|
+
expect(await resolveBySessionToken(plaintext)).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("returns null for a non-aseph_ bearer", async () => {
|
|
89
|
+
expect(await resolveBySessionToken("aswt_shouldnotmatch")).toBeNull();
|
|
90
|
+
expect(await resolveBySessionToken("some-random-token")).toBeNull();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("revokeSessionToken is idempotent", async () => {
|
|
94
|
+
const { tokenId, plaintext } = await mintSessionToken("agent-4", "task-4", 60_000);
|
|
95
|
+
await revokeSessionToken(tokenId);
|
|
96
|
+
await expect(revokeSessionToken(tokenId)).resolves.toBeUndefined();
|
|
97
|
+
expect(await resolveBySessionToken(plaintext)).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// ─── Auth middleware tests ────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
describe("resolveHttpRequestAuth with aseph_ token", () => {
|
|
104
|
+
test("resolves a valid aseph_ token to kind:agent", async () => {
|
|
105
|
+
const { plaintext } = await mintSessionToken("agent-5", "task-5", 60_000);
|
|
106
|
+
const req = { headers: { authorization: `Bearer ${plaintext}` } } as IncomingMessage;
|
|
107
|
+
const auth = await resolveHttpRequestAuth(req, API_KEY);
|
|
108
|
+
expect(auth).toMatchObject({ kind: "agent", agentId: "agent-5", taskId: "task-5" });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("returns null for a revoked aseph_ token", async () => {
|
|
112
|
+
const { tokenId, plaintext } = await mintSessionToken("agent-6", "task-6", 60_000);
|
|
113
|
+
await revokeSessionToken(tokenId);
|
|
114
|
+
const req = { headers: { authorization: `Bearer ${plaintext}` } } as IncomingMessage;
|
|
115
|
+
expect(await resolveHttpRequestAuth(req, API_KEY)).toBeNull();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("aseph_ token resolves as kind:agent even when a different API key is configured", async () => {
|
|
119
|
+
const { plaintext } = await mintSessionToken("agent-7", "task-7", 60_000);
|
|
120
|
+
const req = { headers: { authorization: `Bearer ${plaintext}` } } as IncomingMessage;
|
|
121
|
+
// The configured operator key is different, so the bearer does not hit the
|
|
122
|
+
// operator-key branch and falls through to the aseph_ resolution.
|
|
123
|
+
const auth = await resolveHttpRequestAuth(req, "some-other-operator-key");
|
|
124
|
+
expect(auth?.kind).toBe("agent");
|
|
125
|
+
if (auth?.kind === "agent") {
|
|
126
|
+
expect(auth.agentId).toBe("agent-7");
|
|
127
|
+
expect(auth.taskId).toBe("task-7");
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ─── HTTP endpoint tests ──────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
describe("POST /api/sessions/tokens", () => {
|
|
135
|
+
test("mints a token and returns plaintext + tokenId", async () => {
|
|
136
|
+
const res = await fetch(`http://localhost:${port}/api/sessions/tokens`, {
|
|
137
|
+
method: "POST",
|
|
138
|
+
headers: {
|
|
139
|
+
Authorization: `Bearer ${API_KEY}`,
|
|
140
|
+
"Content-Type": "application/json",
|
|
141
|
+
},
|
|
142
|
+
body: JSON.stringify({ agentId: "ag-1", taskId: "tk-1", ttlMs: 60_000 }),
|
|
143
|
+
});
|
|
144
|
+
expect(res.status).toBe(200);
|
|
145
|
+
const body = (await res.json()) as { tokenId: string; plaintext: string };
|
|
146
|
+
expect(body.plaintext).toMatch(/^aseph_/);
|
|
147
|
+
expect(body.tokenId).toBeTruthy();
|
|
148
|
+
// Verify the token actually resolves.
|
|
149
|
+
const principal = await resolveBySessionToken(body.plaintext);
|
|
150
|
+
expect(principal).toMatchObject({ agentId: "ag-1", taskId: "tk-1" });
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("returns 401 without auth", async () => {
|
|
154
|
+
const res = await fetch(`http://localhost:${port}/api/sessions/tokens`, {
|
|
155
|
+
method: "POST",
|
|
156
|
+
headers: { "Content-Type": "application/json" },
|
|
157
|
+
body: JSON.stringify({ agentId: "ag-1", taskId: "tk-1", ttlMs: 60_000 }),
|
|
158
|
+
});
|
|
159
|
+
expect(res.status).toBe(401);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("returns 400 when ttlMs exceeds the maximum", async () => {
|
|
163
|
+
const tooLong = 8 * 24 * 60 * 60 * 1000; // 8 days
|
|
164
|
+
const res = await fetch(`http://localhost:${port}/api/sessions/tokens`, {
|
|
165
|
+
method: "POST",
|
|
166
|
+
headers: {
|
|
167
|
+
Authorization: `Bearer ${API_KEY}`,
|
|
168
|
+
"Content-Type": "application/json",
|
|
169
|
+
},
|
|
170
|
+
body: JSON.stringify({ agentId: "ag-1", taskId: "tk-1", ttlMs: tooLong }),
|
|
171
|
+
});
|
|
172
|
+
expect(res.status).toBe(400);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("DELETE /api/sessions/tokens/:tokenId", () => {
|
|
177
|
+
test("revokes a token so it can no longer be resolved", async () => {
|
|
178
|
+
const { tokenId, plaintext } = await mintSessionToken("ag-2", "tk-2", 60_000);
|
|
179
|
+
|
|
180
|
+
const res = await fetch(`http://localhost:${port}/api/sessions/tokens/${tokenId}`, {
|
|
181
|
+
method: "DELETE",
|
|
182
|
+
headers: { Authorization: `Bearer ${API_KEY}` },
|
|
183
|
+
});
|
|
184
|
+
expect(res.status).toBe(204);
|
|
185
|
+
|
|
186
|
+
expect(await resolveBySessionToken(plaintext)).toBeNull();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("returns 401 without auth", async () => {
|
|
190
|
+
const { tokenId } = await mintSessionToken("ag-3", "tk-3", 60_000);
|
|
191
|
+
const res = await fetch(`http://localhost:${port}/api/sessions/tokens/${tokenId}`, {
|
|
192
|
+
method: "DELETE",
|
|
193
|
+
});
|
|
194
|
+
expect(res.status).toBe(401);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// ─── /mcp identity enforcement tests ─────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
describe("/mcp aseph_ token identity enforcement", () => {
|
|
201
|
+
let mcpServer: import("node:http").Server;
|
|
202
|
+
let mcpPort: number;
|
|
203
|
+
|
|
204
|
+
beforeAll(async () => {
|
|
205
|
+
mcpServer = createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
206
|
+
const myAgentId = req.headers["x-agent-id"] as string | undefined;
|
|
207
|
+
const handled = await handleCore(req, res, myAgentId, API_KEY);
|
|
208
|
+
if (handled) return;
|
|
209
|
+
const didHandle = await handleMcp(req, res, {});
|
|
210
|
+
if (!didHandle) {
|
|
211
|
+
res.writeHead(404);
|
|
212
|
+
res.end("Not Found");
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
mcpPort = await listenOnFreePort(mcpServer);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
afterAll(async () => {
|
|
219
|
+
await new Promise<void>((resolve) => mcpServer.close(() => resolve()));
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("rejects with 403 when X-Agent-ID does not match the token's bound agentId", async () => {
|
|
223
|
+
const { plaintext } = await mintSessionToken("mcp-agent-A", "mcp-task-A", 60_000);
|
|
224
|
+
const res = await fetch(`http://localhost:${mcpPort}/mcp`, {
|
|
225
|
+
method: "POST",
|
|
226
|
+
headers: {
|
|
227
|
+
Authorization: `Bearer ${plaintext}`,
|
|
228
|
+
"Content-Type": "application/json",
|
|
229
|
+
"X-Agent-ID": "mcp-agent-B",
|
|
230
|
+
"X-Source-Task-Id": "mcp-task-A",
|
|
231
|
+
},
|
|
232
|
+
body: JSON.stringify({ jsonrpc: "2.0", method: "initialize", id: 1, params: {} }),
|
|
233
|
+
});
|
|
234
|
+
expect(res.status).toBe(403);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("rejects with 403 when X-Source-Task-Id does not match the token's bound taskId", async () => {
|
|
238
|
+
const { plaintext } = await mintSessionToken("mcp-agent-C", "mcp-task-C", 60_000);
|
|
239
|
+
const res = await fetch(`http://localhost:${mcpPort}/mcp`, {
|
|
240
|
+
method: "POST",
|
|
241
|
+
headers: {
|
|
242
|
+
Authorization: `Bearer ${plaintext}`,
|
|
243
|
+
"Content-Type": "application/json",
|
|
244
|
+
"X-Agent-ID": "mcp-agent-C",
|
|
245
|
+
"X-Source-Task-Id": "mcp-task-D",
|
|
246
|
+
},
|
|
247
|
+
body: JSON.stringify({ jsonrpc: "2.0", method: "initialize", id: 1, params: {} }),
|
|
248
|
+
});
|
|
249
|
+
expect(res.status).toBe(403);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("rejects with 403 when X-Agent-ID header is missing", async () => {
|
|
253
|
+
const { plaintext } = await mintSessionToken("mcp-agent-E", "mcp-task-E", 60_000);
|
|
254
|
+
const res = await fetch(`http://localhost:${mcpPort}/mcp`, {
|
|
255
|
+
method: "POST",
|
|
256
|
+
headers: {
|
|
257
|
+
Authorization: `Bearer ${plaintext}`,
|
|
258
|
+
"Content-Type": "application/json",
|
|
259
|
+
"X-Source-Task-Id": "mcp-task-E",
|
|
260
|
+
},
|
|
261
|
+
body: JSON.stringify({ jsonrpc: "2.0", method: "initialize", id: 1, params: {} }),
|
|
262
|
+
});
|
|
263
|
+
expect(res.status).toBe(403);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("rejects with 403 when X-Source-Task-Id header is missing", async () => {
|
|
267
|
+
const { plaintext } = await mintSessionToken("mcp-agent-F", "mcp-task-F", 60_000);
|
|
268
|
+
const res = await fetch(`http://localhost:${mcpPort}/mcp`, {
|
|
269
|
+
method: "POST",
|
|
270
|
+
headers: {
|
|
271
|
+
Authorization: `Bearer ${plaintext}`,
|
|
272
|
+
"Content-Type": "application/json",
|
|
273
|
+
"X-Agent-ID": "mcp-agent-F",
|
|
274
|
+
},
|
|
275
|
+
body: JSON.stringify({ jsonrpc: "2.0", method: "initialize", id: 1, params: {} }),
|
|
276
|
+
});
|
|
277
|
+
expect(res.status).toBe(403);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `slack-download-file` and `slack-read` store Slack files as attachments of
|
|
3
|
+
* the calling agent's task instead of on the API server's disk, where a worker
|
|
4
|
+
* container can't read them.
|
|
5
|
+
*/
|
|
6
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
7
|
+
import { mkdtemp, rm, unlink } from "node:fs/promises";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
11
|
+
import {
|
|
12
|
+
closeDb,
|
|
13
|
+
createAgent,
|
|
14
|
+
createTaskExtended,
|
|
15
|
+
getDbClient,
|
|
16
|
+
getTaskAttachments,
|
|
17
|
+
initDb,
|
|
18
|
+
} from "../be/db";
|
|
19
|
+
import { getFileStorageProvider, resetFileStorageProviderForTests } from "../fs/registry";
|
|
20
|
+
import type { SlackFile } from "../slack/files";
|
|
21
|
+
import { createSlackTaskWithFiles, fetchSlackFiles } from "../slack/inbound-files";
|
|
22
|
+
|
|
23
|
+
const TEST_DB_PATH = "./test-slack-file-tools-attachments.sqlite";
|
|
24
|
+
const BOT_TOKEN = "xoxb-file-tools-test";
|
|
25
|
+
const AGENT_ID = "aaaaaaaa-0000-4000-8000-00000000f001";
|
|
26
|
+
const OTHER_ID = "aaaaaaaa-0000-4000-8000-00000000f002";
|
|
27
|
+
const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 9, 9]);
|
|
28
|
+
const PNG_2 = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 8, 8, 8]);
|
|
29
|
+
|
|
30
|
+
let host: ReturnType<typeof Bun.serve>;
|
|
31
|
+
let fsDir: string;
|
|
32
|
+
const slackFiles = new Map<string, SlackFile>();
|
|
33
|
+
const bytesById = new Map<string, Uint8Array>();
|
|
34
|
+
let threadMessages: Array<Record<string, unknown>> = [];
|
|
35
|
+
|
|
36
|
+
mock.module("../slack/app", () => ({
|
|
37
|
+
getSlackApp: () => ({
|
|
38
|
+
client: {
|
|
39
|
+
token: BOT_TOKEN,
|
|
40
|
+
auth: { test: async () => ({ user_id: "U_BOT" }) },
|
|
41
|
+
users: { info: async ({ user }: { user: string }) => ({ user: { real_name: user } }) },
|
|
42
|
+
files: {
|
|
43
|
+
info: async ({ file }: { file: string }) =>
|
|
44
|
+
slackFiles.has(file) ? { ok: true, file: slackFiles.get(file) } : { ok: false },
|
|
45
|
+
},
|
|
46
|
+
conversations: {
|
|
47
|
+
replies: async () => ({ messages: threadMessages }),
|
|
48
|
+
history: async () => ({ messages: threadMessages }),
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
}),
|
|
52
|
+
initSlackApp: async () => null,
|
|
53
|
+
startSlackApp: async () => {},
|
|
54
|
+
stopSlackApp: async () => {},
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
function addSlackFile(id: string, name: string, bytes: Uint8Array, path = "files-pri"): SlackFile {
|
|
58
|
+
const file: SlackFile = {
|
|
59
|
+
id,
|
|
60
|
+
name,
|
|
61
|
+
mimetype: "image/png",
|
|
62
|
+
filetype: "png",
|
|
63
|
+
size: bytes.byteLength,
|
|
64
|
+
url_private: `${host.url}${path}/T0TEAM-${id}/${name}`,
|
|
65
|
+
url_private_download: `${host.url}${path}/T0TEAM-${id}/download/${name}`,
|
|
66
|
+
};
|
|
67
|
+
slackFiles.set(id, file);
|
|
68
|
+
bytesById.set(id, bytes);
|
|
69
|
+
return file;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
type ToolResult = { isError?: boolean; structuredContent: Record<string, unknown> };
|
|
73
|
+
type Handler = (args: unknown, extra: unknown) => Promise<ToolResult>;
|
|
74
|
+
let downloadTool: Handler;
|
|
75
|
+
let readTool: Handler;
|
|
76
|
+
|
|
77
|
+
function extra(headers: Record<string, string>) {
|
|
78
|
+
return {
|
|
79
|
+
sessionId: "file-tools-test",
|
|
80
|
+
requestInfo: { headers: { "x-agent-id": AGENT_ID, ...headers } },
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function storedBytes(taskId: string, name: string): Promise<Uint8Array> {
|
|
85
|
+
const attachment = (await getTaskAttachments(taskId)).find((a) => a.name === name);
|
|
86
|
+
if (!attachment) throw new Error(`no attachment named ${name}`);
|
|
87
|
+
const res = await getFileStorageProvider().download({
|
|
88
|
+
taskId,
|
|
89
|
+
name: attachment.name,
|
|
90
|
+
key: attachment.providerKey,
|
|
91
|
+
});
|
|
92
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let taskId: string;
|
|
96
|
+
let othersTaskId: string;
|
|
97
|
+
|
|
98
|
+
beforeAll(async () => {
|
|
99
|
+
host = Bun.serve({
|
|
100
|
+
port: 0,
|
|
101
|
+
fetch(req) {
|
|
102
|
+
const url = new URL(req.url);
|
|
103
|
+
if (url.pathname.startsWith("/forbidden")) return new Response("nope", { status: 403 });
|
|
104
|
+
if (req.headers.get("authorization") !== `Bearer ${BOT_TOKEN}`) {
|
|
105
|
+
return new Response("<html>sign in</html>", { headers: { "content-type": "text/html" } });
|
|
106
|
+
}
|
|
107
|
+
const id = /T0TEAM-([A-Z0-9]+)\//.exec(url.pathname)?.[1] ?? "";
|
|
108
|
+
const bytes = bytesById.get(id);
|
|
109
|
+
return bytes
|
|
110
|
+
? new Response(bytes, { headers: { "content-type": "image/png" } })
|
|
111
|
+
: new Response("missing", { status: 404 });
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
fsDir = await mkdtemp(join(tmpdir(), "slack-file-tools-"));
|
|
115
|
+
process.env.AGENT_FS_LOCAL_DIR = fsDir;
|
|
116
|
+
delete process.env.AGENT_FS_API_URL;
|
|
117
|
+
process.env.SLACK_BOT_TOKEN = BOT_TOKEN;
|
|
118
|
+
resetFileStorageProviderForTests();
|
|
119
|
+
for (const suffix of ["", "-wal", "-shm"]) await unlink(TEST_DB_PATH + suffix).catch(() => {});
|
|
120
|
+
initDb(TEST_DB_PATH);
|
|
121
|
+
await createAgent({ id: AGENT_ID, name: "Worker", isLead: false, status: "idle" });
|
|
122
|
+
await createAgent({ id: OTHER_ID, name: "Other", isLead: false, status: "idle" });
|
|
123
|
+
|
|
124
|
+
const [{ registerSlackDownloadFileTool }, { registerSlackReadTool }] = await Promise.all([
|
|
125
|
+
import("../tools/slack-download-file"),
|
|
126
|
+
import("../tools/slack-read"),
|
|
127
|
+
]);
|
|
128
|
+
const server = new McpServer({ name: "slack-file-tools", version: "1.0.0" });
|
|
129
|
+
registerSlackDownloadFileTool(server);
|
|
130
|
+
registerSlackReadTool(server);
|
|
131
|
+
const tools = (server as unknown as { _registeredTools: Record<string, { handler: Handler }> })
|
|
132
|
+
._registeredTools;
|
|
133
|
+
downloadTool = tools["slack-download-file"]!.handler;
|
|
134
|
+
readTool = tools["slack-read"]!.handler;
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
afterAll(async () => {
|
|
138
|
+
host.stop(true);
|
|
139
|
+
closeDb();
|
|
140
|
+
for (const suffix of ["", "-wal", "-shm"]) await unlink(TEST_DB_PATH + suffix).catch(() => {});
|
|
141
|
+
await rm(fsDir, { recursive: true, force: true });
|
|
142
|
+
delete process.env.AGENT_FS_LOCAL_DIR;
|
|
143
|
+
delete process.env.SLACK_BOT_TOKEN;
|
|
144
|
+
resetFileStorageProviderForTests();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
beforeEach(async () => {
|
|
148
|
+
await getDbClient().run("DELETE FROM task_attachments");
|
|
149
|
+
await getDbClient().run("DELETE FROM agent_tasks");
|
|
150
|
+
slackFiles.clear();
|
|
151
|
+
bytesById.clear();
|
|
152
|
+
taskId = (
|
|
153
|
+
await createTaskExtended("look at the thread", {
|
|
154
|
+
agentId: AGENT_ID,
|
|
155
|
+
slackChannelId: "C0THREAD",
|
|
156
|
+
slackThreadTs: "1700000000.000100",
|
|
157
|
+
})
|
|
158
|
+
).id;
|
|
159
|
+
othersTaskId = (await createTaskExtended("not yours", { agentId: OTHER_ID })).id;
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("slack-download-file", () => {
|
|
163
|
+
test("attaches the file to the task the agent is working on", async () => {
|
|
164
|
+
addSlackFile("F0SHOT0001", "screenshot.png", PNG);
|
|
165
|
+
|
|
166
|
+
const result = await downloadTool(
|
|
167
|
+
{ fileId: "F0SHOT0001" },
|
|
168
|
+
extra({ "x-source-task-id": taskId }),
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
expect(result.isError).toBeFalsy();
|
|
172
|
+
const [attachment] = await getTaskAttachments(taskId);
|
|
173
|
+
expect(attachment?.name).toBe("screenshot.png");
|
|
174
|
+
expect(result.structuredContent.attachmentId).toBe(attachment!.id);
|
|
175
|
+
expect(result.structuredContent.taskId).toBe(taskId);
|
|
176
|
+
expect(String(result.structuredContent.fetchCommand)).toContain(
|
|
177
|
+
`/api/fs/tasks/${taskId}/files/${attachment!.id}/raw" --create-dirs -o '/tmp/attachments/${attachment!.id}/screenshot.png'`,
|
|
178
|
+
);
|
|
179
|
+
expect(await storedBytes(taskId, "screenshot.png")).toEqual(PNG);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("an explicit taskId the agent doesn't own is refused", async () => {
|
|
183
|
+
addSlackFile("F0SHOT0001", "screenshot.png", PNG);
|
|
184
|
+
|
|
185
|
+
const result = await downloadTool(
|
|
186
|
+
{ fileId: "F0SHOT0001", taskId: othersTaskId },
|
|
187
|
+
extra({ "x-source-task-id": taskId }),
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
expect(result.isError).toBe(true);
|
|
191
|
+
expect(String(result.structuredContent.message)).toContain("don't have context");
|
|
192
|
+
expect(await getTaskAttachments(othersTaskId)).toEqual([]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("downloading the same file twice keeps one attachment", async () => {
|
|
196
|
+
addSlackFile("F0SHOT0001", "screenshot.png", PNG);
|
|
197
|
+
const first = await downloadTool(
|
|
198
|
+
{ fileId: "F0SHOT0001" },
|
|
199
|
+
extra({ "x-source-task-id": taskId }),
|
|
200
|
+
);
|
|
201
|
+
const second = await downloadTool(
|
|
202
|
+
{ fileId: "F0SHOT0001" },
|
|
203
|
+
extra({ "x-source-task-id": taskId }),
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
expect(second.structuredContent.attachmentId).toBe(first.structuredContent.attachmentId);
|
|
207
|
+
expect(await getTaskAttachments(taskId)).toHaveLength(1);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("a second file with a name already taken doesn't overwrite the first", async () => {
|
|
211
|
+
addSlackFile("F0IMAGE001", "image.png", PNG);
|
|
212
|
+
addSlackFile("F0IMAGE002", "image.png", PNG_2);
|
|
213
|
+
await downloadTool({ fileId: "F0IMAGE001" }, extra({ "x-source-task-id": taskId }));
|
|
214
|
+
await downloadTool({ fileId: "F0IMAGE002" }, extra({ "x-source-task-id": taskId }));
|
|
215
|
+
|
|
216
|
+
const names = (await getTaskAttachments(taskId)).map((a) => a.name).sort();
|
|
217
|
+
expect(names).toEqual(["F0IMAGE002-image.png", "image.png"]);
|
|
218
|
+
expect(await storedBytes(taskId, "image.png")).toEqual(PNG);
|
|
219
|
+
expect(await storedBytes(taskId, "F0IMAGE002-image.png")).toEqual(PNG_2);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("a file Slack won't serve is an error, not an attachment", async () => {
|
|
223
|
+
addSlackFile("F0DENIED01", "denied.png", PNG, "forbidden");
|
|
224
|
+
|
|
225
|
+
const result = await downloadTool(
|
|
226
|
+
{ fileId: "F0DENIED01" },
|
|
227
|
+
extra({ "x-source-task-id": taskId }),
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
expect(result.isError).toBe(true);
|
|
231
|
+
expect(String(result.structuredContent.message)).toContain("HTTP 403");
|
|
232
|
+
expect(await getTaskAttachments(taskId)).toEqual([]);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("a url_private_download URL alone is enough", async () => {
|
|
236
|
+
const file = addSlackFile("F0BYURL001", "by-url.png", PNG);
|
|
237
|
+
|
|
238
|
+
const result = await downloadTool(
|
|
239
|
+
{ url: file.url_private_download },
|
|
240
|
+
extra({ "x-source-task-id": taskId }),
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
expect(result.isError).toBeFalsy();
|
|
244
|
+
expect(await storedBytes(taskId, "by-url.png")).toEqual(PNG);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("without a task it still saves on the API server, and says so", async () => {
|
|
248
|
+
addSlackFile("F0SHOT0001", "screenshot.png", PNG);
|
|
249
|
+
const dir = join(fsDir, "legacy");
|
|
250
|
+
|
|
251
|
+
const result = await downloadTool({ fileId: "F0SHOT0001", savePath: `${dir}/` }, extra({}));
|
|
252
|
+
|
|
253
|
+
expect(result.isError).toBeFalsy();
|
|
254
|
+
expect(result.structuredContent.savedPath).toBe(`${dir}/screenshot.png`);
|
|
255
|
+
expect(String(result.structuredContent.message)).toContain("on the API server");
|
|
256
|
+
expect(String(result.structuredContent.nudge)).toContain("not in your container");
|
|
257
|
+
expect(new Uint8Array(await Bun.file(`${dir}/screenshot.png`).arrayBuffer())).toEqual(PNG);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("a source-task header for someone else's task falls back instead of attaching there", async () => {
|
|
261
|
+
addSlackFile("F0SHOT0001", "screenshot.png", PNG);
|
|
262
|
+
|
|
263
|
+
const result = await downloadTool(
|
|
264
|
+
{ fileId: "F0SHOT0001", savePath: `${join(fsDir, "legacy-2")}/` },
|
|
265
|
+
extra({ "x-source-task-id": othersTaskId }),
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
expect(result.structuredContent.savedPath).toBeDefined();
|
|
269
|
+
expect(await getTaskAttachments(othersTaskId)).toEqual([]);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("a task the agent created for someone else accepts the file", async () => {
|
|
273
|
+
addSlackFile("F0SHOT0001", "screenshot.png", PNG);
|
|
274
|
+
const delegated = await createTaskExtended("delegated", {
|
|
275
|
+
agentId: OTHER_ID,
|
|
276
|
+
creatorAgentId: AGENT_ID,
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
const result = await downloadTool({ fileId: "F0SHOT0001", taskId: delegated.id }, extra({}));
|
|
280
|
+
|
|
281
|
+
expect(result.isError).toBeFalsy();
|
|
282
|
+
const [attachment] = await getTaskAttachments(delegated.id);
|
|
283
|
+
expect(attachment).toMatchObject({ intent: "slack-file", agentId: AGENT_ID });
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("a URL that names no Slack file can't be attached", async () => {
|
|
287
|
+
const result = await downloadTool(
|
|
288
|
+
{ url: `${host.url}elsewhere/some.png` },
|
|
289
|
+
extra({ "x-source-task-id": taskId }),
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
expect(result.isError).toBe(true);
|
|
293
|
+
expect(String(result.structuredContent.message)).toContain("pass fileId");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("the file a user already sent the bot is reused, not stored again", async () => {
|
|
297
|
+
const file = addSlackFile("F0SHOT0001", "screenshot.png", PNG);
|
|
298
|
+
await using inbound = await fetchSlackFiles({ token: BOT_TOKEN } as never, [file]);
|
|
299
|
+
const { task } = await createSlackTaskWithFiles("from Slack", { agentId: AGENT_ID }, inbound);
|
|
300
|
+
const [shared] = await getTaskAttachments(task.id);
|
|
301
|
+
|
|
302
|
+
const result = await downloadTool(
|
|
303
|
+
{ fileId: "F0SHOT0001" },
|
|
304
|
+
extra({ "x-source-task-id": task.id }),
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
expect(result.structuredContent.attachmentId).toBe(shared!.id);
|
|
308
|
+
expect(await getTaskAttachments(task.id)).toHaveLength(1);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("parallel downloads of different files with the same name keep both blobs intact", async () => {
|
|
312
|
+
addSlackFile("F0IMAGE001", "image.png", PNG);
|
|
313
|
+
addSlackFile("F0IMAGE002", "image.png", PNG_2);
|
|
314
|
+
|
|
315
|
+
const results = await Promise.all([
|
|
316
|
+
downloadTool({ fileId: "F0IMAGE001" }, extra({ "x-source-task-id": taskId })),
|
|
317
|
+
downloadTool({ fileId: "F0IMAGE002" }, extra({ "x-source-task-id": taskId })),
|
|
318
|
+
]);
|
|
319
|
+
// Both may end up named image.png; what matters is that fetching one
|
|
320
|
+
// can't overwrite the other in the worker's /tmp.
|
|
321
|
+
const [first, second] = results.map((r) => String(r.structuredContent.fetchCommand));
|
|
322
|
+
expect(first).not.toBe(second);
|
|
323
|
+
expect(first!.split(" -o ")[1]).not.toBe(second!.split(" -o ")[1]);
|
|
324
|
+
|
|
325
|
+
const attachments = await getTaskAttachments(taskId);
|
|
326
|
+
expect(attachments).toHaveLength(2);
|
|
327
|
+
const provider = getFileStorageProvider();
|
|
328
|
+
const stored = await Promise.all(
|
|
329
|
+
attachments.map(async (a) => {
|
|
330
|
+
const res = await provider.download({ taskId, name: a.name, key: a.providerKey });
|
|
331
|
+
return { sha: a.sha256, bytes: new Uint8Array(await res.arrayBuffer()) };
|
|
332
|
+
}),
|
|
333
|
+
);
|
|
334
|
+
for (const { sha, bytes } of stored) {
|
|
335
|
+
expect(new Bun.CryptoHasher("sha256").update(bytes).digest("hex")).toBe(sha!);
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
describe("slack-read", () => {
|
|
341
|
+
test("stores the thread's files as attachments of the task, with a fetch command each", async () => {
|
|
342
|
+
const file = addSlackFile("F0THREAD01", "diagram.png", PNG);
|
|
343
|
+
threadMessages = [
|
|
344
|
+
{ user: "U_HUMAN", text: "see this", ts: "1700000000.000100", files: [file] },
|
|
345
|
+
];
|
|
346
|
+
|
|
347
|
+
const result = await readTool({ taskId }, extra({}));
|
|
348
|
+
|
|
349
|
+
const [attachment] = await getTaskAttachments(taskId);
|
|
350
|
+
expect(attachment?.name).toBe("diagram.png");
|
|
351
|
+
const messages = result.structuredContent.messages as Array<{
|
|
352
|
+
files: Array<Record<string, string>>;
|
|
353
|
+
}>;
|
|
354
|
+
expect(messages[0]!.files[0]!.attachmentId).toBe(attachment!.id);
|
|
355
|
+
expect(messages[0]!.files[0]!.fetchCommand).toContain(`/files/${attachment!.id}/raw`);
|
|
356
|
+
expect(messages[0]!.files[0]!.localPath).toBeUndefined();
|
|
357
|
+
expect(String(result.structuredContent.details)).toContain(`Task attachment ${attachment!.id}`);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("a file that can't be downloaded is reported per file", async () => {
|
|
361
|
+
const good = addSlackFile("F0GOOD0001", "good.png", PNG);
|
|
362
|
+
const bad = addSlackFile("F0BAD00001", "bad.png", PNG_2, "forbidden");
|
|
363
|
+
threadMessages = [
|
|
364
|
+
{ user: "U_HUMAN", text: "two", ts: "1700000000.000100", files: [good, bad] },
|
|
365
|
+
];
|
|
366
|
+
|
|
367
|
+
const result = await readTool({ taskId }, extra({}));
|
|
368
|
+
|
|
369
|
+
const files = (
|
|
370
|
+
result.structuredContent.messages as Array<{ files: Array<Record<string, string>> }>
|
|
371
|
+
)[0]!.files;
|
|
372
|
+
expect(files.find((f) => f.id === "F0GOOD0001")?.attachmentId).toBeDefined();
|
|
373
|
+
expect(files.find((f) => f.id === "F0BAD00001")?.notAttached).toContain("HTTP 403");
|
|
374
|
+
expect(String(result.structuredContent.details)).toContain(
|
|
375
|
+
"[Not attached: download failed (HTTP 403)]",
|
|
376
|
+
);
|
|
377
|
+
expect(await getTaskAttachments(taskId)).toHaveLength(1);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("includeFiles: false stores nothing", async () => {
|
|
381
|
+
threadMessages = [
|
|
382
|
+
{
|
|
383
|
+
user: "U_HUMAN",
|
|
384
|
+
text: "x",
|
|
385
|
+
ts: "1700000000.000100",
|
|
386
|
+
files: [addSlackFile("F0SKIP0001", "s.png", PNG)],
|
|
387
|
+
},
|
|
388
|
+
];
|
|
389
|
+
|
|
390
|
+
await readTool({ taskId, includeFiles: false }, extra({}));
|
|
391
|
+
|
|
392
|
+
expect(await getTaskAttachments(taskId)).toEqual([]);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test("attaches to the source task when reading by inbox/channel context", async () => {
|
|
396
|
+
await getDbClient().run("UPDATE agents SET isLead = 1 WHERE id = ?", [AGENT_ID]);
|
|
397
|
+
const file = addSlackFile("F0CHAN0001", "chan.png", PNG);
|
|
398
|
+
threadMessages = [{ user: "U_HUMAN", text: "c", ts: "1700000000.000200", files: [file] }];
|
|
399
|
+
|
|
400
|
+
try {
|
|
401
|
+
await readTool({ channelId: "C0THREAD" }, extra({ "x-source-task-id": taskId }));
|
|
402
|
+
} finally {
|
|
403
|
+
await getDbClient().run("UPDATE agents SET isLead = 0 WHERE id = ?", [AGENT_ID]);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
expect((await getTaskAttachments(taskId)).map((a) => a.name)).toEqual(["chan.png"]);
|
|
407
|
+
});
|
|
408
|
+
});
|