@desplega.ai/agent-swarm 1.112.0 → 1.114.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 +2 -2
- package/dist/{actions-eckb4g8r.js → actions-bm8vdxys.js} +7 -8
- package/dist/{app-b05sgm02.js → app-bhwdxmvp.js} +5 -5
- package/dist/{assistant-7255sbyw.js → assistant-15mp2apr.js} +18 -17
- package/dist/{boot-reembed-y1bt3ttn.js → boot-reembed-awr2179r.js} +6 -6
- package/dist/{boot-reembed-yv7awg4y.js → boot-reembed-b9sbk87z.js} +5 -5
- package/dist/{boot-scrub-logs-ddyaeqar.js → boot-scrub-logs-c1zsaw9m.js} +4 -4
- package/dist/{claude-adapter-mma9a1yr.js → claude-adapter-2k45y7p7.js} +2 -2
- package/dist/{claude-managed-adapter-pv2nbqy4.js → claude-managed-adapter-kysmkz89.js} +2 -2
- package/dist/cli-0kgcv878.js +344 -0
- package/dist/cli-0pygq9r1.js +429 -0
- package/dist/{cli-7xkwgkrs.js → cli-113wmegp.js} +90 -3
- package/dist/{cli-7ga43spr.js → cli-1yjrbg1f.js} +1 -1
- package/dist/{cli-56s4ehzw.js → cli-2t4e4ayk.js} +1 -1
- package/dist/{cli-0c3n0eba.js → cli-312v3c41.js} +1 -1
- package/dist/{cli-ceewqajw.js → cli-46b6kc7f.js} +5 -5
- package/dist/cli-49j095n9.js +393 -0
- package/dist/{cli-h9jx5mag.js → cli-5afkmcfj.js} +10 -7
- package/dist/{cli-76d1vtvq.js → cli-5ktc9fd4.js} +1 -1
- package/dist/cli-7rgh0w0k.js +262 -0
- package/dist/{cli-9cy6nc5m.js → cli-8fjwym53.js} +1 -1
- package/dist/{cli-tg0f2wmz.js → cli-a6tzw6vq.js} +175 -28
- package/dist/{cli-q21d49ac.js → cli-bw80ck94.js} +1 -1
- package/dist/{cli-709t753c.js → cli-byjcad11.js} +2 -2
- package/dist/{cli-15598a4k.js → cli-e2xbxmhd.js} +1808 -302
- package/dist/{cli-6wscj9z0.js → cli-e7306swv.js} +14 -14
- package/dist/{cli-8f0dwca2.js → cli-f5pzfvwb.js} +6 -5
- package/dist/{cli-s9pj3c7z.js → cli-f9n2jg31.js} +2 -2
- package/dist/{cli-dcfyyh3t.js → cli-ftyf58xj.js} +2 -2
- package/dist/{cli-hm27prrc.js → cli-hq39e630.js} +5 -5
- package/dist/{cli-a5e8n5hg.js → cli-hvg05qwm.js} +1 -1
- package/dist/cli-jkhwmmgg.js +136 -0
- package/dist/{cli-951w7c79.js → cli-jzwtn8rm.js} +136 -21
- package/dist/{cli-rp8pca98.js → cli-k6h2zkw3.js} +1 -1
- package/dist/{cli-3r5y6ayj.js → cli-pnfyyp6h.js} +1240 -335
- package/dist/{cli-94vzr3nq.js → cli-q1z5d5ss.js} +13 -7
- package/dist/{cli-pt7c7qxz.js → cli-q4e2x252.js} +3 -3
- package/dist/{cli-tch0ypp6.js → cli-r3mtrrzp.js} +16 -16
- package/dist/{cli-ywez6yps.js → cli-sq7f72d6.js} +1 -1
- package/dist/{cli-r1btc895.js → cli-w3nq88yc.js} +2 -2
- package/dist/{cli-30d2ct3k.js → cli-w4wk3kx6.js} +12 -7
- package/dist/{cli-anrj584m.js → cli-xz9aq0rf.js} +1 -1
- package/dist/{cli-tp7e60s3.js → cli-zmmp2r79.js} +22 -9
- package/dist/cli.js +35 -17
- package/dist/{codex-adapter-5cmy858w.js → codex-adapter-4z8m9mb5.js} +9 -9
- package/dist/{codex-session-runner-46q8mrba.js → codex-session-runner-rbct46dg.js} +9 -9
- package/dist/{commands-14g7taf4.js → commands-zk4d6esf.js} +4 -4
- package/dist/{db-3tge4jrn.js → db-stn16jp2.js} +4 -4
- package/dist/{handlers-sy64egg6.js → handlers-gshprvpf.js} +16 -30
- package/dist/{hook-rnkzw8vp.js → hook-amzx9tap.js} +7 -7
- package/dist/{http-xx9xptcs.js → http-6g0hfzax.js} +1977 -786
- package/dist/{index-wz1ea15p.js → index-2dgfj9av.js} +3 -3
- package/dist/{index-ha8e9xn2.js → index-df8fmphk.js} +55 -17
- package/dist/{index-zfx5p959.js → index-f03rnnrs.js} +13 -10
- package/dist/{index-3wr6v2mm.js → index-jbm7qcqk.js} +12 -9
- package/dist/{index-ykvs43z4.js → index-t2snazez.js} +4 -4
- package/dist/{index-brma88d4.js → index-ty42rxw9.js} +14 -11
- package/dist/{keepalive-rbz05zad.js → keepalive-pm6g2wpw.js} +7 -6
- package/dist/{lead-s790v7r0.js → lead-h79fzpzw.js} +33 -29
- package/dist/{maintenance-420rmadb.js → maintenance-433zndy1.js} +6 -6
- package/dist/{mistral-conversations-26yrnbf1.js → mistral-conversations-7gqnz2wt.js} +7 -7
- package/dist/oauth-refresh-sweep-mjsbmsk2.js +114 -0
- package/dist/{onboard-jgqamqnw.js → onboard-vdr9hc3f.js} +2 -2
- package/dist/{otel-zjd9zaxs.js → otel-g299k41b.js} +2 -2
- package/dist/{otel-impl-ch3n3783.js → otel-impl-yswd0fty.js} +1 -1
- package/dist/{pi-mono-adapter-kxjxpfgy.js → pi-mono-adapter-vbrvg1p8.js} +16 -108
- package/dist/{pricing-refresh-rmtrby6h.js → pricing-refresh-2kk2ap9b.js} +6 -6
- package/dist/rbac-roles-2wgh9b5t.js +276 -0
- package/dist/rbac-roles-xdc5208b.js +28 -0
- package/dist/{seed-pricing-1szjzfj1.js → seed-pricing-m9h69d2t.js} +5 -5
- package/dist/{setup-8jfzkyzv.js → setup-eynkvafa.js} +2 -2
- package/dist/{worker-40vq0pm8.js → worker-136q8dc2.js} +33 -29
- package/openapi.json +1634 -20
- package/package.json +6 -5
- package/src/agentmail/handlers.ts +16 -6
- package/src/agentmail/templates.ts +25 -5
- package/src/be/audit-user.ts +28 -4
- package/src/be/db-queries/oauth.ts +42 -0
- package/src/be/db.ts +104 -11
- package/src/be/identity.ts +79 -0
- package/src/be/mcp-proxy.ts +132 -0
- package/src/be/memory/providers/sqlite-store.ts +5 -1
- package/src/be/migrations/108_rbac_permission_audit.sql +26 -0
- package/src/be/migrations/109_rbac_roles.sql +79 -0
- package/src/be/migrations/111_oauth_credential_bindings.sql +6 -0
- package/src/be/migrations/112_script_connections_graphql.sql +96 -0
- package/src/be/oauth-credential-bindings.ts +35 -0
- package/src/be/oauth-refresh-sweep.ts +159 -0
- package/src/be/rbac-audit.ts +246 -0
- package/src/be/rbac-roles.ts +417 -0
- package/src/be/script-connections.ts +456 -37
- package/src/be/script-credential-broker.ts +29 -3
- package/src/be/scripts/typecheck.ts +40 -7
- package/src/be/users.ts +24 -0
- package/src/cli.tsx +17 -0
- package/src/github/handlers.ts +10 -9
- package/src/github/templates.ts +45 -9
- package/src/gitlab/handlers.ts +20 -8
- package/src/gitlab/templates.ts +18 -6
- package/src/http/all-routes.ts +61 -0
- package/src/http/approval-requests.ts +12 -0
- package/src/http/config.ts +98 -9
- package/src/http/core.ts +30 -3
- package/src/http/favorites.ts +5 -0
- package/src/http/fs.ts +27 -7
- package/src/http/index.ts +50 -0
- package/src/http/kv.ts +21 -4
- package/src/http/mcp-oauth.ts +2 -0
- package/src/http/mcp-servers.ts +6 -3
- package/src/http/oauth-generic.ts +105 -0
- package/src/http/poll.ts +13 -1
- package/src/http/route-def.ts +9 -0
- package/src/http/schedules.ts +56 -22
- package/src/http/script-connection-proxy.ts +116 -0
- package/src/http/script-connections.ts +1657 -0
- package/src/http/scripts.ts +54 -7
- package/src/http/tasks.ts +16 -14
- package/src/http/workflows.ts +13 -2
- package/src/http/x.ts +6 -2
- package/src/integrations/kapso/inbound.ts +23 -6
- package/src/jira/sync.ts +111 -17
- package/src/jira/templates.ts +10 -2
- package/src/mcp-client/http-client.ts +194 -0
- package/src/oauth/app-validation.ts +16 -0
- package/src/oauth/ensure-token.ts +69 -14
- package/src/oauth/mcp-wrapper.ts +15 -0
- package/src/oauth/wrapper.ts +48 -20
- package/src/prompts/base-prompt.ts +7 -6
- package/src/prompts/session-templates.ts +38 -11
- package/src/providers/pi-mono-mcp-client.ts +2 -133
- package/src/rbac/admission.ts +44 -0
- package/src/rbac/can.ts +44 -0
- package/src/rbac/index.ts +15 -0
- package/src/rbac/legacy-policy.ts +188 -0
- package/src/rbac/permissions.ts +194 -0
- package/src/rbac/types.ts +45 -0
- package/src/scheduler/scheduler.ts +9 -1
- package/src/scripts-runtime/api-client.ts +70 -14
- package/src/scripts-runtime/api-types.ts +38 -6
- package/src/scripts-runtime/credential-broker/default-bindings.ts +1 -0
- package/src/scripts-runtime/credential-broker/types.ts +8 -0
- package/src/scripts-runtime/ctx.ts +11 -1
- package/src/scripts-runtime/eval-harness.ts +5 -1
- package/src/scripts-runtime/executors/types.ts +2 -1
- package/src/scripts-runtime/loader.ts +3 -1
- package/src/scripts-runtime/mcp-client.ts +87 -0
- package/src/scripts-runtime/sdk-allowlist.ts +2 -0
- package/src/scripts-runtime/swarm-sdk.ts +81 -0
- package/src/scripts-runtime/types/stdlib.d.ts +24 -2
- package/src/scripts-runtime/types/swarm-sdk.d.ts +26 -2
- package/src/server.ts +14 -0
- package/src/slack/assistant.ts +14 -6
- package/src/slack/enrich.ts +17 -0
- package/src/slack/handlers.ts +8 -22
- package/src/slack/thread-buffer.ts +6 -3
- package/src/stdio.ts +43 -0
- package/src/tests/agentmail-handlers.test.ts +32 -3
- package/src/tests/approval-requests.test.ts +61 -0
- package/src/tests/audit-user.test.ts +47 -1
- package/src/tests/base-prompt.test.ts +7 -4
- package/src/tests/credential-broker.test.ts +5 -2
- package/src/tests/delete-page-tool.test.ts +197 -0
- package/src/tests/github-handlers.test.ts +28 -2
- package/src/tests/gitlab-handlers.test.ts +125 -0
- package/src/tests/harness-provider-resolution.test.ts +5 -0
- package/src/tests/http-api-integration.test.ts +8 -1
- package/src/tests/identity.test.ts +117 -0
- package/src/tests/jira-sync.test.ts +99 -1
- package/src/tests/kapso-inbound.test.ts +7 -0
- package/src/tests/mcp-tools-user.test.ts +60 -6
- package/src/tests/oauth-credential-bindings.test.ts +490 -0
- package/src/tests/oauth-refresh-sweep.test.ts +160 -0
- package/src/tests/prompt-template-remaining.test.ts +2 -2
- package/src/tests/prompt-template-session.test.ts +21 -8
- package/src/tests/rbac-admission-e2e.test.ts +241 -0
- package/src/tests/rbac-admission.test.ts +433 -0
- package/src/tests/rbac-audit.test.ts +345 -0
- package/src/tests/rbac-charact-http.test.ts +272 -0
- package/src/tests/rbac-charact-misc-tools.test.ts +428 -0
- package/src/tests/rbac-charact-skills.test.ts +492 -0
- package/src/tests/rbac-charact-slack.test.ts +283 -0
- package/src/tests/rbac-e2e-helpers.ts +308 -0
- package/src/tests/rbac-engine.test.ts +474 -0
- package/src/tests/rbac-lifecycle-e2e.test.ts +262 -0
- package/src/tests/rbac-roles.test.ts +326 -0
- package/src/tests/rbac-wire-e2e.test.ts +574 -0
- package/src/tests/schedule-http-triage-tools.test.ts +121 -0
- package/src/tests/schedule-target-type.test.ts +50 -0
- package/src/tests/scheduled-tasks.test.ts +74 -0
- package/src/tests/script-connections-http.test.ts +1158 -0
- package/src/tests/script-connections-mcp.test.ts +894 -0
- package/src/tests/script-connections.test.ts +727 -5
- package/src/tests/scripts-external-api.test.ts +49 -1
- package/src/tests/scripts-http.test.ts +56 -7
- package/src/tests/scripts-runtime-secret-egress.test.ts +1 -0
- package/src/tests/scripts-runtime.test.ts +104 -0
- package/src/tests/slack-identity-resolution.test.ts +37 -3
- package/src/tests/slack-thread-buffer.test.ts +28 -0
- package/src/tests/swarm-config-reserved-keys.test.ts +17 -0
- package/src/tests/tool-annotations.test.ts +2 -0
- package/src/tests/update-schedule-mcp-tool.test.ts +57 -0
- package/src/tests/user-token-rest-auth.test.ts +30 -1
- package/src/tests/workflow-http-v2.test.ts +80 -0
- package/src/tests/workflow-swarm-script.test.ts +44 -0
- package/src/tests/workflow-triggers-v2.test.ts +300 -1
- package/src/tools/cancel-task.ts +13 -3
- package/src/tools/context-diff.ts +12 -1
- package/src/tools/context-history.ts +12 -1
- package/src/tools/credential-bindings/tool.ts +289 -19
- package/src/tools/delete-channel.ts +12 -1
- package/src/tools/delete-page.ts +145 -0
- package/src/tools/get-task-details.ts +1 -1
- package/src/tools/inject-learning.ts +12 -1
- package/src/tools/kv/kv-delete.ts +3 -18
- package/src/tools/kv/kv-incr.ts +3 -18
- package/src/tools/kv/kv-set.ts +3 -20
- package/src/tools/kv/kv-write-auth.ts +40 -0
- package/src/tools/manage-user.ts +12 -1
- package/src/tools/mcp-servers/mcp-server-create.ts +12 -1
- package/src/tools/mcp-servers/mcp-server-delete.ts +19 -5
- package/src/tools/mcp-servers/mcp-server-install.ts +12 -1
- package/src/tools/mcp-servers/mcp-server-uninstall.ts +12 -1
- package/src/tools/mcp-servers/mcp-server-update.ts +12 -1
- package/src/tools/memory-delete.ts +12 -4
- package/src/tools/register-kapso-number.ts +23 -2
- package/src/tools/request-human-input.ts +2 -0
- package/src/tools/resolve-user.ts +77 -27
- package/src/tools/schedules/create-schedule.ts +4 -3
- package/src/tools/schedules/index.ts +1 -0
- package/src/tools/schedules/list-schedules.ts +36 -2
- package/src/tools/schedules/patch-schedule.ts +405 -0
- package/src/tools/schedules/update-schedule.ts +2 -2
- package/src/tools/script-connections/tool.ts +287 -32
- package/src/tools/skills/skill-create.ts +12 -1
- package/src/tools/skills/skill-delete.ts +12 -1
- package/src/tools/skills/skill-install-remote.ts +12 -1
- package/src/tools/skills/skill-install.ts +12 -1
- package/src/tools/skills/skill-uninstall.ts +12 -1
- package/src/tools/skills/skill-update.ts +25 -2
- package/src/tools/slack-delete.ts +8 -1
- package/src/tools/slack-post.ts +8 -1
- package/src/tools/slack-read.ts +8 -1
- package/src/tools/slack-start-thread.ts +8 -1
- package/src/tools/slack-update.ts +8 -1
- package/src/tools/slack-upload-file.ts +8 -1
- package/src/tools/swarm-config/delete-config.ts +28 -1
- package/src/tools/swarm-config/get-config.ts +32 -5
- package/src/tools/swarm-config/list-config.ts +31 -5
- package/src/tools/swarm-config/set-config.ts +38 -1
- package/src/tools/task-action.ts +1 -1
- package/src/tools/task-tool-ctx.ts +19 -3
- package/src/tools/templates.ts +5 -1
- package/src/tools/tool-config.ts +4 -2
- package/src/tools/update-profile.ts +8 -1
- package/src/tools/workflows/create-workflow.ts +8 -1
- package/src/tools/workflows/list-workflows.ts +14 -2
- package/src/tools/workflows/update-workflow.ts +11 -2
- package/src/types.ts +63 -6
- package/src/utils/scoped-resource.ts +25 -0
- package/src/workflows/executors/human-in-the-loop.ts +1 -0
- package/src/workflows/executors/swarm-script.ts +7 -1
- package/src/workflows/triggers.ts +146 -18
- package/templates/skills/swarm-scripts/SKILL.md +4 -9
- package/templates/skills/swarm-scripts/content.md +45 -9
- package/dist/cli-52tbq17n.js +0 -771
- package/dist/cli-9p0qhead.js +0 -43
- package/dist/{anthropic-messages-8nc6r2dw.js → anthropic-messages-kq3pjhbp.js} +6 -6
- package/dist/{azure-openai-responses-6yfzw3qw.js → azure-openai-responses-gm1ejtmn.js} +7 -7
- package/dist/{cli-vbraamxr.js → cli-6b4wwmm8.js} +9 -9
- package/dist/{cli-2fc97xm5.js → cli-ggy8c7tk.js} +3 -3
- package/dist/{cli-2r7s8jhp.js → cli-sznberje.js} +3 -3
- package/dist/{google-generative-ai-vfq6ymz9.js → google-generative-ai-hyrra2jm.js} +3 -3
- package/dist/{google-vertex-mj5y5rfj.js → google-vertex-8bs2pzgn.js} +3 -3
- package/dist/{openai-codex-responses-8h6kvfv8.js → openai-codex-responses-3nkvje79.js} +4 -4
- package/dist/{openai-completions-x6kjnqjd.js → openai-completions-0asftkwg.js} +11 -11
- package/dist/{openai-responses-7jwyz3ds.js → openai-responses-pmatek45.js} +10 -10
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RBAC permission-audit writer tests (DES-445, Phase 6).
|
|
3
|
+
*
|
|
4
|
+
* Covers the batched writer in src/be/rbac-audit.ts end-to-end against a real
|
|
5
|
+
* migrated DB: buffer + flush persistence (allow AND deny, all columns),
|
|
6
|
+
* the 200-row threshold, the interval flush, the RBAC_AUDIT_DISABLED
|
|
7
|
+
* kill-switch, flush resilience to a throwing DB, retention purge, the
|
|
8
|
+
* shutdown drain, and one migrated MCP gate (inject-learning) exercised
|
|
9
|
+
* through its real handler.
|
|
10
|
+
*
|
|
11
|
+
* Timer hygiene (plan Review Errata): bun test leaks module state
|
|
12
|
+
* process-wide, so EVERY interval started here is stopped in afterEach —
|
|
13
|
+
* dangling timers poison unrelated suites.
|
|
14
|
+
*/
|
|
15
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
16
|
+
import { unlink } from "node:fs/promises";
|
|
17
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
18
|
+
import { closeDb, createAgent, getDb, initDb } from "../be/db";
|
|
19
|
+
import {
|
|
20
|
+
enqueueAuditRow,
|
|
21
|
+
flushAuditBuffer,
|
|
22
|
+
purgeExpiredAuditRows,
|
|
23
|
+
startAuditGc,
|
|
24
|
+
startAuditWriter,
|
|
25
|
+
stopAuditGc,
|
|
26
|
+
stopAuditWriter,
|
|
27
|
+
} from "../be/rbac-audit";
|
|
28
|
+
import { can, clearAuditSink, setAuditSink } from "../rbac";
|
|
29
|
+
import { registerInjectLearningTool } from "../tools/inject-learning";
|
|
30
|
+
|
|
31
|
+
const TEST_DB_PATH = "./test-rbac-audit.sqlite";
|
|
32
|
+
|
|
33
|
+
const LEAD_ID = "aaaa6000-0000-4000-8000-000000000001";
|
|
34
|
+
const WORKER_ID = "bbbb6000-0000-4000-8000-000000000002";
|
|
35
|
+
|
|
36
|
+
type AuditRowSelect = {
|
|
37
|
+
principalType: string;
|
|
38
|
+
principalId: string | null;
|
|
39
|
+
originatorUserId: string | null;
|
|
40
|
+
verb: string;
|
|
41
|
+
resourceType: string | null;
|
|
42
|
+
resourceId: string | null;
|
|
43
|
+
decision: string;
|
|
44
|
+
reason: string | null;
|
|
45
|
+
source: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function selectAuditRows(): AuditRowSelect[] {
|
|
49
|
+
return getDb()
|
|
50
|
+
.prepare(
|
|
51
|
+
`SELECT principalType, principalId, originatorUserId, verb, resourceType, resourceId,
|
|
52
|
+
decision, reason, source
|
|
53
|
+
FROM permission_audit ORDER BY ts, id`,
|
|
54
|
+
)
|
|
55
|
+
.all() as AuditRowSelect[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function countAuditRows(): number {
|
|
59
|
+
const row = getDb().prepare("SELECT count(*) AS n FROM permission_audit").get() as { n: number };
|
|
60
|
+
return row.n;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function removeDbFiles() {
|
|
64
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
65
|
+
try {
|
|
66
|
+
await unlink(TEST_DB_PATH + suffix);
|
|
67
|
+
} catch {
|
|
68
|
+
// File doesn't exist
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let savedEnv: NodeJS.ProcessEnv;
|
|
74
|
+
|
|
75
|
+
beforeAll(async () => {
|
|
76
|
+
savedEnv = { ...process.env };
|
|
77
|
+
delete process.env.RBAC_AUDIT_DISABLED;
|
|
78
|
+
delete process.env.RBAC_AUDIT_RETENTION_DAYS;
|
|
79
|
+
|
|
80
|
+
closeDb();
|
|
81
|
+
await removeDbFiles();
|
|
82
|
+
initDb(TEST_DB_PATH);
|
|
83
|
+
|
|
84
|
+
createAgent({ id: LEAD_ID, name: "Audit Lead", isLead: true, status: "idle" });
|
|
85
|
+
createAgent({ id: WORKER_ID, name: "Audit Worker", isLead: false, status: "idle" });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
afterAll(async () => {
|
|
89
|
+
stopAuditWriter();
|
|
90
|
+
stopAuditGc();
|
|
91
|
+
clearAuditSink();
|
|
92
|
+
closeDb();
|
|
93
|
+
await removeDbFiles();
|
|
94
|
+
|
|
95
|
+
for (const key of Object.keys(process.env)) {
|
|
96
|
+
if (!(key in savedEnv)) delete process.env[key];
|
|
97
|
+
}
|
|
98
|
+
for (const [key, value] of Object.entries(savedEnv)) {
|
|
99
|
+
if (value === undefined) delete process.env[key];
|
|
100
|
+
else process.env[key] = value;
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
beforeEach(() => {
|
|
105
|
+
// Drain any leftover buffered rows, then start each test from a clean table.
|
|
106
|
+
flushAuditBuffer();
|
|
107
|
+
getDb().run("DELETE FROM permission_audit");
|
|
108
|
+
delete process.env.RBAC_AUDIT_DISABLED;
|
|
109
|
+
delete process.env.RBAC_AUDIT_RETENTION_DAYS;
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
afterEach(() => {
|
|
113
|
+
// CRITICAL: never leak intervals across test files (bun test module-state
|
|
114
|
+
// leakage) — stop both timers and detach the sink after every test.
|
|
115
|
+
stopAuditWriter();
|
|
116
|
+
stopAuditGc();
|
|
117
|
+
clearAuditSink();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("buffer + flush persistence", () => {
|
|
121
|
+
test("persists allow AND deny rows from can() with correct columns", () => {
|
|
122
|
+
setAuditSink(enqueueAuditRow);
|
|
123
|
+
|
|
124
|
+
const allow = can({
|
|
125
|
+
principal: { kind: "agent", agentId: LEAD_ID, isLead: true },
|
|
126
|
+
verb: "memory.learning.inject",
|
|
127
|
+
resource: { kind: "agent", agentId: WORKER_ID },
|
|
128
|
+
source: "mcp",
|
|
129
|
+
});
|
|
130
|
+
const deny = can({
|
|
131
|
+
principal: { kind: "agent", agentId: WORKER_ID, isLead: false },
|
|
132
|
+
verb: "memory.learning.inject",
|
|
133
|
+
resource: { kind: "agent", agentId: LEAD_ID },
|
|
134
|
+
source: "http",
|
|
135
|
+
});
|
|
136
|
+
expect(allow.allow).toBe(true);
|
|
137
|
+
expect(deny.allow).toBe(false);
|
|
138
|
+
|
|
139
|
+
// Buffered, not yet written.
|
|
140
|
+
expect(countAuditRows()).toBe(0);
|
|
141
|
+
|
|
142
|
+
flushAuditBuffer();
|
|
143
|
+
|
|
144
|
+
const rows = selectAuditRows();
|
|
145
|
+
expect(rows.length).toBe(2);
|
|
146
|
+
|
|
147
|
+
const allowRow = rows.find((r) => r.decision === "allow");
|
|
148
|
+
expect(allowRow).toMatchObject({
|
|
149
|
+
principalType: "agent",
|
|
150
|
+
principalId: LEAD_ID,
|
|
151
|
+
originatorUserId: null,
|
|
152
|
+
verb: "memory.learning.inject",
|
|
153
|
+
resourceType: "agent",
|
|
154
|
+
resourceId: WORKER_ID,
|
|
155
|
+
decision: "allow",
|
|
156
|
+
reason: null,
|
|
157
|
+
source: "mcp",
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const denyRow = rows.find((r) => r.decision === "deny");
|
|
161
|
+
expect(denyRow).toMatchObject({
|
|
162
|
+
principalType: "agent",
|
|
163
|
+
principalId: WORKER_ID,
|
|
164
|
+
verb: "memory.learning.inject",
|
|
165
|
+
resourceType: "agent",
|
|
166
|
+
resourceId: LEAD_ID,
|
|
167
|
+
decision: "deny",
|
|
168
|
+
source: "http",
|
|
169
|
+
});
|
|
170
|
+
expect(denyRow?.reason).toBeTruthy();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("auto-flushes at the 200-row threshold without the interval writer", async () => {
|
|
174
|
+
for (let i = 0; i < 200; i++) {
|
|
175
|
+
enqueueAuditRow(
|
|
176
|
+
{ principal: { kind: "operator" }, verb: "user.manage", source: "http" },
|
|
177
|
+
{ allow: true },
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
// The threshold flush is deferred off the enqueue path (zero-delay
|
|
181
|
+
// timeout) so the 200th can() call never blocks on the transaction —
|
|
182
|
+
// nothing is persisted synchronously, everything lands a tick later.
|
|
183
|
+
expect(countAuditRows()).toBe(0);
|
|
184
|
+
await Bun.sleep(10);
|
|
185
|
+
expect(countAuditRows()).toBe(200);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("interval flush drains the buffer", async () => {
|
|
189
|
+
startAuditWriter(25);
|
|
190
|
+
enqueueAuditRow(
|
|
191
|
+
{ principal: { kind: "user", userId: "user-1" }, verb: "user.manage", source: "http" },
|
|
192
|
+
{ allow: true },
|
|
193
|
+
);
|
|
194
|
+
expect(countAuditRows()).toBe(0);
|
|
195
|
+
|
|
196
|
+
await Bun.sleep(120);
|
|
197
|
+
expect(countAuditRows()).toBe(1);
|
|
198
|
+
const row = selectAuditRows()[0];
|
|
199
|
+
expect(row).toMatchObject({ principalType: "user", principalId: "user-1" });
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe("kill-switch", () => {
|
|
204
|
+
test("RBAC_AUDIT_DISABLED=true writes nothing", () => {
|
|
205
|
+
process.env.RBAC_AUDIT_DISABLED = "true";
|
|
206
|
+
setAuditSink(enqueueAuditRow);
|
|
207
|
+
|
|
208
|
+
can({
|
|
209
|
+
principal: { kind: "agent", agentId: WORKER_ID, isLead: false },
|
|
210
|
+
verb: "memory.learning.inject",
|
|
211
|
+
source: "mcp",
|
|
212
|
+
});
|
|
213
|
+
enqueueAuditRow(
|
|
214
|
+
{ principal: { kind: "operator" }, verb: "user.manage", source: "http" },
|
|
215
|
+
{ allow: true },
|
|
216
|
+
);
|
|
217
|
+
flushAuditBuffer();
|
|
218
|
+
|
|
219
|
+
expect(countAuditRows()).toBe(0);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
describe("flush resilience", () => {
|
|
224
|
+
test("throwing DB during flush does not propagate (batch dropped)", () => {
|
|
225
|
+
enqueueAuditRow(
|
|
226
|
+
{ principal: { kind: "operator" }, verb: "user.manage", source: "http" },
|
|
227
|
+
{ allow: true },
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
getDb().run("ALTER TABLE permission_audit RENAME TO permission_audit_broken");
|
|
231
|
+
try {
|
|
232
|
+
expect(() => flushAuditBuffer()).not.toThrow();
|
|
233
|
+
} finally {
|
|
234
|
+
getDb().run("ALTER TABLE permission_audit_broken RENAME TO permission_audit");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Batch was dropped, not retried.
|
|
238
|
+
expect(countAuditRows()).toBe(0);
|
|
239
|
+
flushAuditBuffer();
|
|
240
|
+
expect(countAuditRows()).toBe(0);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe("retention purge", () => {
|
|
245
|
+
test("deletes only rows older than the cutoff (default 30 days)", () => {
|
|
246
|
+
const insert = getDb().prepare(
|
|
247
|
+
`INSERT INTO permission_audit (ts, principalType, verb, decision, source)
|
|
248
|
+
VALUES (datetime('now', ?), 'agent', 'user.manage', 'deny', 'mcp')`,
|
|
249
|
+
);
|
|
250
|
+
insert.run("-40 days");
|
|
251
|
+
insert.run("-29 days");
|
|
252
|
+
insert.run("-0 seconds");
|
|
253
|
+
expect(countAuditRows()).toBe(3);
|
|
254
|
+
|
|
255
|
+
const purged = purgeExpiredAuditRows();
|
|
256
|
+
expect(purged).toBe(1);
|
|
257
|
+
expect(countAuditRows()).toBe(2);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("respects RBAC_AUDIT_RETENTION_DAYS override and runs via startAuditGc", () => {
|
|
261
|
+
process.env.RBAC_AUDIT_RETENTION_DAYS = "7";
|
|
262
|
+
const insert = getDb().prepare(
|
|
263
|
+
`INSERT INTO permission_audit (ts, principalType, verb, decision, source)
|
|
264
|
+
VALUES (datetime('now', ?), 'agent', 'user.manage', 'deny', 'mcp')`,
|
|
265
|
+
);
|
|
266
|
+
insert.run("-10 days");
|
|
267
|
+
insert.run("-1 days");
|
|
268
|
+
|
|
269
|
+
// startAuditGc purges immediately on start (pattern: startMemoryGc).
|
|
270
|
+
startAuditGc();
|
|
271
|
+
expect(countAuditRows()).toBe(1);
|
|
272
|
+
stopAuditGc();
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
describe("shutdown flush", () => {
|
|
277
|
+
test("final flushAuditBuffer() drains everything below the thresholds", () => {
|
|
278
|
+
enqueueAuditRow(
|
|
279
|
+
{
|
|
280
|
+
principal: { kind: "agent", agentId: WORKER_ID, isLead: false },
|
|
281
|
+
verb: "user.manage",
|
|
282
|
+
source: "mcp",
|
|
283
|
+
},
|
|
284
|
+
{ allow: false, reason: "requires lead agent", missing: "user.manage" },
|
|
285
|
+
);
|
|
286
|
+
enqueueAuditRow(
|
|
287
|
+
{ principal: { kind: "operator" }, verb: "user.manage", source: "http" },
|
|
288
|
+
{ allow: true },
|
|
289
|
+
);
|
|
290
|
+
expect(countAuditRows()).toBe(0);
|
|
291
|
+
|
|
292
|
+
// Shutdown path: stop the timer first, then drain.
|
|
293
|
+
stopAuditWriter();
|
|
294
|
+
flushAuditBuffer();
|
|
295
|
+
expect(countAuditRows()).toBe(2);
|
|
296
|
+
|
|
297
|
+
// Buffer is empty afterwards — a second drain writes nothing.
|
|
298
|
+
flushAuditBuffer();
|
|
299
|
+
expect(countAuditRows()).toBe(2);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
describe("migrated gate end-to-end (inject-learning)", () => {
|
|
304
|
+
type ToolResult = {
|
|
305
|
+
content: Array<{ type: string; text: string }>;
|
|
306
|
+
structuredContent: { success: boolean; message: string };
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
test("worker call → soft denial → flush → deny audit row with expected verb", async () => {
|
|
310
|
+
setAuditSink(enqueueAuditRow);
|
|
311
|
+
|
|
312
|
+
const server = new McpServer({ name: "test-rbac-audit", version: "1.0.0" });
|
|
313
|
+
registerInjectLearningTool(server);
|
|
314
|
+
const tools = (
|
|
315
|
+
server as unknown as {
|
|
316
|
+
// biome-ignore lint/complexity/noBannedTypes: internal MCP SDK type, test-only
|
|
317
|
+
_registeredTools: Record<string, { handler: Function }>;
|
|
318
|
+
}
|
|
319
|
+
)._registeredTools;
|
|
320
|
+
const handler = tools["inject-learning"]?.handler;
|
|
321
|
+
expect(handler).toBeDefined();
|
|
322
|
+
|
|
323
|
+
const result = (await handler?.(
|
|
324
|
+
{ agentId: LEAD_ID, learning: "audit e2e learning", category: "best-practice" },
|
|
325
|
+
{
|
|
326
|
+
sessionId: "test-session",
|
|
327
|
+
requestInfo: { headers: { "x-agent-id": WORKER_ID } },
|
|
328
|
+
},
|
|
329
|
+
)) as ToolResult;
|
|
330
|
+
expect(result.structuredContent.success).toBe(false);
|
|
331
|
+
|
|
332
|
+
flushAuditBuffer();
|
|
333
|
+
|
|
334
|
+
const rows = selectAuditRows();
|
|
335
|
+
expect(rows.length).toBe(1);
|
|
336
|
+
expect(rows[0]).toMatchObject({
|
|
337
|
+
principalType: "agent",
|
|
338
|
+
principalId: WORKER_ID,
|
|
339
|
+
verb: "memory.learning.inject",
|
|
340
|
+
decision: "deny",
|
|
341
|
+
source: "mcp",
|
|
342
|
+
});
|
|
343
|
+
expect(rows[0]?.reason).toBeTruthy();
|
|
344
|
+
});
|
|
345
|
+
});
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// RBAC slice-1 characterization — HTTP-surface gates (Phase 2 of
|
|
2
|
+
// thoughts/taras/plans/2026-07-07-des-445-rbac-slice1-can-audit.md).
|
|
3
|
+
//
|
|
4
|
+
// Pins TODAY's behavior of `canMutateTask` (src/http/fs.ts:432-444, appendix
|
|
5
|
+
// row 36). Decision order matters and is part of the characterization:
|
|
6
|
+
//
|
|
7
|
+
// 1. request auth kind "operator" (swarm key) → allow — BEFORE any agent check,
|
|
8
|
+
// so an operator bearer with a non-owner X-Agent-ID is still allowed.
|
|
9
|
+
// 2. request auth kind "user" (aswt_ token) → allow.
|
|
10
|
+
// 3. otherwise (auth context unset): lead OR task-assignee OR task-creator
|
|
11
|
+
// → allow; anything else → 403 {"error":"Caller cannot mutate this task's files"}.
|
|
12
|
+
//
|
|
13
|
+
// Branch 3 is only reachable when `getRequestAuth(req)` is null. Through the
|
|
14
|
+
// production pipeline `handleCore` 401s such requests first (every accepted
|
|
15
|
+
// bearer is operator or user), so we characterize it by invoking `handleFs`
|
|
16
|
+
// WITHOUT `handleCore` — the exact auth-null condition under which those
|
|
17
|
+
// branches decide. Phase 5 must preserve this full decision table in can().
|
|
18
|
+
// These tests must pass unchanged before AND after the Phase-5 migration.
|
|
19
|
+
//
|
|
20
|
+
// Other HTTP gates from the Phase-1 inventory:
|
|
21
|
+
// - kv `authorizeWrite` (src/http/kv.ts:329, appendix row 35): characterized
|
|
22
|
+
// by the pre-existing kv-http.test.ts:269-311 — not duplicated here.
|
|
23
|
+
// - http/agents.ts: NO hard gates (all isLead hits are NON-AUTHZ —
|
|
24
|
+
// registration schema / pass-through / telemetry), so no tests needed.
|
|
25
|
+
// - http/scripts.ts global write/delete: intentionally NOT characterized here;
|
|
26
|
+
// the permissive behavior flips in Phase 5 (scripts-http.test.ts:319-345).
|
|
27
|
+
|
|
28
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
29
|
+
import { rm, unlink } from "node:fs/promises";
|
|
30
|
+
import {
|
|
31
|
+
createServer as createHttpServer,
|
|
32
|
+
type IncomingMessage,
|
|
33
|
+
type Server,
|
|
34
|
+
type ServerResponse,
|
|
35
|
+
} from "node:http";
|
|
36
|
+
import {
|
|
37
|
+
closeDb,
|
|
38
|
+
createAgent,
|
|
39
|
+
createTaskExtended,
|
|
40
|
+
createUser,
|
|
41
|
+
getTaskAttachments,
|
|
42
|
+
initDb,
|
|
43
|
+
} from "../be/db";
|
|
44
|
+
import { type IdentityActor, mintToken } from "../be/users";
|
|
45
|
+
import { resetFileStorageProviderForTests } from "../fs/registry";
|
|
46
|
+
import { handleCore } from "../http/core";
|
|
47
|
+
import { handleFs } from "../http/fs";
|
|
48
|
+
import { getPathSegments, parseQueryParams } from "../http/utils";
|
|
49
|
+
|
|
50
|
+
const TEST_DB_PATH = "./test-rbac-charact-http.sqlite";
|
|
51
|
+
const TEST_FS_DIR = "./test-rbac-charact-http-data";
|
|
52
|
+
const API_KEY = "test-rbac-http-key";
|
|
53
|
+
const ACTOR: IdentityActor = { kind: "operator", id: "test" };
|
|
54
|
+
|
|
55
|
+
async function removeDbFiles(path: string): Promise<void> {
|
|
56
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
57
|
+
try {
|
|
58
|
+
await unlink(path + suffix);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function listen(server: Server): Promise<number> {
|
|
66
|
+
await new Promise<void>((resolve) => server.listen(0, resolve));
|
|
67
|
+
const addr = server.address();
|
|
68
|
+
if (!addr || typeof addr === "string") throw new Error("no port");
|
|
69
|
+
return addr.port;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Full production pipeline: auth via handleCore, then handleFs
|
|
73
|
+
// (pattern: kv-http.test.ts:39-52).
|
|
74
|
+
function createPipelineServer(apiKey: string): Server {
|
|
75
|
+
return createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
76
|
+
const myAgentId = req.headers["x-agent-id"] as string | undefined;
|
|
77
|
+
const handled = await handleCore(req, res, myAgentId, apiKey);
|
|
78
|
+
if (handled) return;
|
|
79
|
+
const pathSegments = getPathSegments(req.url || "");
|
|
80
|
+
const queryParams = parseQueryParams(req.url || "");
|
|
81
|
+
const ok = await handleFs(req, res, pathSegments, queryParams, myAgentId);
|
|
82
|
+
if (!ok) {
|
|
83
|
+
res.writeHead(404);
|
|
84
|
+
res.end("Not Found");
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// handleFs WITHOUT handleCore: `getRequestAuth(req)` stays null, which is the
|
|
90
|
+
// only condition under which canMutateTask's agent-identity branches
|
|
91
|
+
// (lead / assignee / creator / deny) decide. See header comment.
|
|
92
|
+
function createNoAuthContextServer(): Server {
|
|
93
|
+
return createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
94
|
+
const myAgentId = req.headers["x-agent-id"] as string | undefined;
|
|
95
|
+
const pathSegments = getPathSegments(req.url || "");
|
|
96
|
+
const queryParams = parseQueryParams(req.url || "");
|
|
97
|
+
const ok = await handleFs(req, res, pathSegments, queryParams, myAgentId);
|
|
98
|
+
if (!ok) {
|
|
99
|
+
res.writeHead(404);
|
|
100
|
+
res.end("Not Found");
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let pipelineServer: Server;
|
|
106
|
+
let pipelinePort: number;
|
|
107
|
+
let bareServer: Server;
|
|
108
|
+
let barePort: number;
|
|
109
|
+
let assigneeId: string;
|
|
110
|
+
let creatorId: string;
|
|
111
|
+
let outsiderId: string;
|
|
112
|
+
let leadId: string;
|
|
113
|
+
let userToken: string;
|
|
114
|
+
let taskId: string;
|
|
115
|
+
|
|
116
|
+
beforeAll(async () => {
|
|
117
|
+
await removeDbFiles(TEST_DB_PATH);
|
|
118
|
+
await rm(TEST_FS_DIR, { recursive: true, force: true });
|
|
119
|
+
process.env.AGENT_FS_LOCAL_DIR = TEST_FS_DIR;
|
|
120
|
+
delete process.env.AGENT_FS_API_URL;
|
|
121
|
+
delete process.env.API_AGENT_FS_API_KEY;
|
|
122
|
+
delete process.env.AGENT_FS_API_KEY;
|
|
123
|
+
resetFileStorageProviderForTests();
|
|
124
|
+
|
|
125
|
+
initDb(TEST_DB_PATH);
|
|
126
|
+
pipelineServer = createPipelineServer(API_KEY);
|
|
127
|
+
pipelinePort = await listen(pipelineServer);
|
|
128
|
+
bareServer = createNoAuthContextServer();
|
|
129
|
+
barePort = await listen(bareServer);
|
|
130
|
+
|
|
131
|
+
assigneeId = createAgent({ name: "rbac-fs-assignee", isLead: false, status: "idle" }).id;
|
|
132
|
+
creatorId = createAgent({ name: "rbac-fs-creator", isLead: false, status: "idle" }).id;
|
|
133
|
+
outsiderId = createAgent({ name: "rbac-fs-outsider", isLead: false, status: "idle" }).id;
|
|
134
|
+
leadId = createAgent({ name: "rbac-fs-lead", isLead: true, status: "idle" }).id;
|
|
135
|
+
|
|
136
|
+
const user = createUser({ name: "RBAC FS User" });
|
|
137
|
+
userToken = mintToken(user.id, "rbac-charact", ACTOR).plaintext;
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
afterAll(async () => {
|
|
141
|
+
await new Promise<void>((resolve) => pipelineServer.close(() => resolve()));
|
|
142
|
+
await new Promise<void>((resolve) => bareServer.close(() => resolve()));
|
|
143
|
+
closeDb();
|
|
144
|
+
await removeDbFiles(TEST_DB_PATH);
|
|
145
|
+
await rm(TEST_FS_DIR, { recursive: true, force: true });
|
|
146
|
+
delete process.env.AGENT_FS_LOCAL_DIR;
|
|
147
|
+
resetFileStorageProviderForTests();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
beforeEach(async () => {
|
|
151
|
+
await rm(TEST_FS_DIR, { recursive: true, force: true });
|
|
152
|
+
resetFileStorageProviderForTests();
|
|
153
|
+
taskId = createTaskExtended("rbac fs charact task", {
|
|
154
|
+
agentId: assigneeId,
|
|
155
|
+
creatorAgentId: creatorId,
|
|
156
|
+
source: "mcp",
|
|
157
|
+
}).id;
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const DENY_BODY = { error: "Caller cannot mutate this task's files" };
|
|
161
|
+
|
|
162
|
+
function uploadPath(): string {
|
|
163
|
+
return `/api/fs/tasks/${taskId}/files?name=charact.txt`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function pipelineUpload(opts: { bearer?: string; agentId?: string }): Promise<Response> {
|
|
167
|
+
const headers: Record<string, string> = {
|
|
168
|
+
Authorization: `Bearer ${opts.bearer ?? API_KEY}`,
|
|
169
|
+
"Content-Type": "text/plain",
|
|
170
|
+
};
|
|
171
|
+
if (opts.agentId !== undefined) headers["X-Agent-ID"] = opts.agentId;
|
|
172
|
+
return fetch(`http://localhost:${pipelinePort}${uploadPath()}`, {
|
|
173
|
+
method: "POST",
|
|
174
|
+
body: Buffer.from("rbac characterization"),
|
|
175
|
+
headers,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function bareFetch(path: string, init: RequestInit & { agentId?: string } = {}): Promise<Response> {
|
|
180
|
+
const headers: Record<string, string> = {
|
|
181
|
+
...((init.headers as Record<string, string>) ?? {}),
|
|
182
|
+
};
|
|
183
|
+
if (init.agentId !== undefined) headers["X-Agent-ID"] = init.agentId;
|
|
184
|
+
return fetch(`http://localhost:${barePort}${path}`, { ...init, headers });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function bareUpload(opts: { agentId?: string }): Promise<Response> {
|
|
188
|
+
return bareFetch(uploadPath(), {
|
|
189
|
+
method: "POST",
|
|
190
|
+
body: Buffer.from("rbac characterization"),
|
|
191
|
+
headers: { "Content-Type": "text/plain" },
|
|
192
|
+
...opts,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
describe("canMutateTask — real pipeline (handleCore auth → handleFs)", () => {
|
|
197
|
+
test("operator (swarm key, no X-Agent-ID) is allowed", async () => {
|
|
198
|
+
const res = await pipelineUpload({});
|
|
199
|
+
expect(res.status).toBe(201);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("operator bearer short-circuits BEFORE agent identity: non-owner X-Agent-ID is still allowed", async () => {
|
|
203
|
+
const res = await pipelineUpload({ agentId: outsiderId });
|
|
204
|
+
expect(res.status).toBe(201);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("authenticated user (aswt_ token, no X-Agent-ID) is allowed", async () => {
|
|
208
|
+
const res = await pipelineUpload({ bearer: userToken });
|
|
209
|
+
expect(res.status).toBe(201);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("authenticated user with a non-owner X-Agent-ID is still allowed", async () => {
|
|
213
|
+
const res = await pipelineUpload({ bearer: userToken, agentId: outsiderId });
|
|
214
|
+
expect(res.status).toBe(201);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe("canMutateTask — agent-identity branches (auth context unset)", () => {
|
|
219
|
+
test("lead agent is allowed", async () => {
|
|
220
|
+
const res = await bareUpload({ agentId: leadId });
|
|
221
|
+
expect(res.status).toBe(201);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("assignee agent (task.agentId) is allowed", async () => {
|
|
225
|
+
const res = await bareUpload({ agentId: assigneeId });
|
|
226
|
+
expect(res.status).toBe(201);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("creator agent (task.creatorAgentId, not assignee) is allowed", async () => {
|
|
230
|
+
const res = await bareUpload({ agentId: creatorId });
|
|
231
|
+
expect(res.status).toBe(201);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("non-owner worker is denied with 403 and the exact body; DB untouched", async () => {
|
|
235
|
+
const res = await bareUpload({ agentId: outsiderId });
|
|
236
|
+
expect(res.status).toBe(403);
|
|
237
|
+
expect(await res.json()).toEqual(DENY_BODY);
|
|
238
|
+
expect(getTaskAttachments(taskId)).toEqual([]);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("missing X-Agent-ID (no auth, no agent) is denied with 403", async () => {
|
|
242
|
+
const res = await bareUpload({});
|
|
243
|
+
expect(res.status).toBe(403);
|
|
244
|
+
expect(await res.json()).toEqual(DENY_BODY);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("DELETE: non-owner worker is denied before attachment lookup; row survives", async () => {
|
|
248
|
+
const seeded = await bareUpload({ agentId: assigneeId });
|
|
249
|
+
expect(seeded.status).toBe(201);
|
|
250
|
+
const attachment = (await seeded.json()) as { id: string };
|
|
251
|
+
|
|
252
|
+
const res = await bareFetch(`/api/fs/tasks/${taskId}/files/${attachment.id}`, {
|
|
253
|
+
method: "DELETE",
|
|
254
|
+
agentId: outsiderId,
|
|
255
|
+
});
|
|
256
|
+
expect(res.status).toBe(403);
|
|
257
|
+
expect(await res.json()).toEqual(DENY_BODY);
|
|
258
|
+
expect(getTaskAttachments(taskId)).toHaveLength(1);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("DELETE: assignee agent can delete", async () => {
|
|
262
|
+
const seeded = await bareUpload({ agentId: assigneeId });
|
|
263
|
+
const attachment = (await seeded.json()) as { id: string };
|
|
264
|
+
|
|
265
|
+
const res = await bareFetch(`/api/fs/tasks/${taskId}/files/${attachment.id}`, {
|
|
266
|
+
method: "DELETE",
|
|
267
|
+
agentId: assigneeId,
|
|
268
|
+
});
|
|
269
|
+
expect(res.status).toBe(204);
|
|
270
|
+
expect(getTaskAttachments(taskId)).toEqual([]);
|
|
271
|
+
});
|
|
272
|
+
});
|