@desplega.ai/agent-swarm 2.3.0 → 2.4.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 -0
- package/dist/{acp-adapter-v85a2bqy.js → acp-adapter-kedstxx6.js} +6 -6
- package/dist/{actions-zgcxfspj.js → actions-vget6r1k.js} +18 -16
- package/dist/{amp-adapter-jw9xfz42.js → amp-adapter-xeb5mktv.js} +2 -2
- package/dist/{app-4e1s4675.js → app-mqap8t8m.js} +3 -3
- package/dist/{assistant-v238n9dk.js → assistant-h0gv97mv.js} +20 -18
- package/dist/{boot-reembed-p666cw5n.js → boot-reembed-qr8a894z.js} +1 -1
- package/dist/{boot-reembed-w6pm64j4.js → boot-reembed-rj85jjhw.js} +1 -1
- package/dist/{boot-scrub-sweep-jcyjagn3.js → boot-scrub-sweep-eyfvn6v9.js} +3 -3
- package/dist/{boot-seeds-gbedmamp.js → boot-seeds-xs9a8dx1.js} +4 -4
- package/dist/{claude-adapter-q7wspzt8.js → claude-adapter-bm23cye7.js} +5 -5
- package/dist/{claude-managed-adapter-n7ahjyx6.js → claude-managed-adapter-1mwrsm9d.js} +2 -2
- package/dist/{claude-managed-setup-wza784fb.js → claude-managed-setup-gkfgks4j.js} +1 -1
- package/dist/{claude-sdk-session-yf400ptr.js → claude-sdk-session-apb4ypz3.js} +5 -5
- package/dist/cli-02vhhssk.js +848 -0
- package/dist/{cli-4ync7m60.js → cli-0ebey9hg.js} +1 -1
- package/dist/{cli-665j3feb.js → cli-0xk505e8.js} +1 -1
- package/dist/{cli-30m5q8mc.js → cli-1cevz46c.js} +5 -5
- package/dist/{cli-gzvb3d4a.js → cli-1jx0srjt.js} +3 -3
- package/dist/{cli-zj4d6afs.js → cli-1pa3rz69.js} +21 -6
- package/dist/{cli-fhvqt2fp.js → cli-2rwet3r7.js} +1 -1
- package/dist/{cli-azs9bhc5.js → cli-3wjs22x3.js} +2 -2
- package/dist/{cli-dxwh4j32.js → cli-4fhyq711.js} +45 -36
- package/dist/{cli-yv3n83x8.js → cli-5ff2sjyt.js} +1 -1
- package/dist/{cli-6vcyw6v7.js → cli-5s5hx57y.js} +5 -3
- package/dist/{cli-hz64bcnj.js → cli-6ftrrwm1.js} +9 -371
- package/dist/{cli-zes4tsbb.js → cli-8b9wvjps.js} +1 -1
- package/dist/cli-8cxkw1f5.js +382 -0
- package/dist/{cli-4s1kqrqg.js → cli-8rwvksbc.js} +2 -2
- package/dist/{cli-e09hj50q.js → cli-8wkb09q5.js} +11 -11
- package/dist/{cli-yv0vgscc.js → cli-a1esmmee.js} +13 -2
- package/dist/{cli-4hmaheb6.js → cli-avmqhatn.js} +7 -7
- package/dist/{cli-g6p7c2my.js → cli-b80a1100.js} +1 -1
- package/dist/{cli-tr24eeny.js → cli-bfc3k1x5.js} +3 -3
- package/dist/{cli-ka01m5rb.js → cli-bhwa8rzc.js} +13 -11
- package/dist/{cli-8cvvytad.js → cli-bn26xbcs.js} +2 -2
- package/dist/{cli-k28p6xh7.js → cli-c8fwpgqb.js} +1 -1
- package/dist/{cli-rc3r0bnh.js → cli-ccn0eybf.js} +1 -1
- package/dist/{cli-9td5ss65.js → cli-dsgj3e7a.js} +2 -2
- package/dist/{cli-a17tzmn0.js → cli-ecshf504.js} +2 -2
- package/dist/{cli-d43xqzbe.js → cli-eqgfs202.js} +1 -1
- package/dist/{cli-ppapy34w.js → cli-f3nx1h8z.js} +2 -2
- package/dist/{cli-ea7chd26.js → cli-ffbrkh1r.js} +74 -35
- package/dist/{cli-xgn4rwyv.js → cli-kk2r80n5.js} +4 -4
- package/dist/{cli-hn3pftj2.js → cli-kz116kvw.js} +3 -3
- package/dist/{cli-1v8w782f.js → cli-mvg2hf8p.js} +4 -4
- package/dist/{cli-5d1m1qca.js → cli-mz1ezzzh.js} +1 -1
- package/dist/{cli-jhmjwqcg.js → cli-nme7wery.js} +6 -6
- package/dist/{cli-65q70v2v.js → cli-nrrg0fvy.js} +18 -4
- package/dist/{cli-8jtrekpy.js → cli-p1m2b6gc.js} +7 -7
- package/dist/{cli-re4wkbzx.js → cli-rg7ntypm.js} +328 -47
- package/dist/{cli-fp4xbw07.js → cli-rsd7ga7v.js} +10 -8
- package/dist/{cli-etvn5ej2.js → cli-sppc0cmw.js} +1 -1
- package/dist/{cli-qrwp40st.js → cli-tg9ksbn8.js} +1 -1
- package/dist/{cli-6p7ddg72.js → cli-tgvpmnwz.js} +2 -2
- package/dist/{cli-q1812jbw.js → cli-tj4j2ts4.js} +6 -6
- package/dist/{cli-kqypty34.js → cli-ttaz6c7m.js} +2 -2
- package/dist/{cli-w7z94e6c.js → cli-tx00c0h6.js} +4 -2
- package/dist/{cli-4g629sgr.js → cli-wrv09v7q.js} +20 -1
- package/dist/{cli-cby329ny.js → cli-wyrr38xm.js} +2 -2
- package/dist/{cli-y3dvz6ge.js → cli-x1p5dhq2.js} +3 -3
- package/dist/{cli-8qsx817b.js → cli-x719j3vs.js} +219 -629
- package/dist/{cli-9e9kjqsj.js → cli-xfe63zx3.js} +1 -1
- package/dist/{cli-wtg6edvd.js → cli-ywyrffff.js} +1 -1
- package/dist/{cli-0rh20bp3.js → cli-zvze9rbm.js} +1 -1
- package/dist/cli.js +14 -14
- package/dist/{codex-adapter-nhjj2veb.js → codex-adapter-pp2gq8gr.js} +3 -3
- package/dist/{codex-hook-x6tm6y5m.js → codex-hook-qwzccd64.js} +1 -1
- package/dist/{codex-session-runner-6hggrjbx.js → codex-session-runner-0vzn09t8.js} +3 -3
- package/dist/{commands-mrw1ph2w.js → commands-psqhkttb.js} +2 -2
- package/dist/{controller-pt1zam5w.js → controller-t20vz2k8.js} +2 -2
- package/dist/{controllers-connect-ymw5541d.js → controllers-connect-gkxmqw9d.js} +24 -20
- package/dist/{cursor-adapter-cf4zh5x0.js → cursor-adapter-3v542qe5.js} +1 -1
- package/dist/{db-hy80219q.js → db-2xbyenjr.js} +2 -2
- package/dist/{db-hhpy6m4x.js → db-nc1rd869.js} +3 -2
- package/dist/{dispatcher-6pdjtkr1.js → dispatcher-ewceb8ts.js} +17 -15
- package/dist/{dsh-adapter-fbx06re2.js → dsh-adapter-75xwh78t.js} +1 -1
- package/dist/{easter-egg-3d-6hg2r942.js → easter-egg-3d-xsc47jwf.js} +1 -1
- package/dist/{execute-bcqc69a5.js → execute-gzmw1mnw.js} +1 -1
- package/dist/{external-channel-j6vb6ya1.js → external-channel-a5dsmxn4.js} +2 -2
- package/dist/{google-generative-ai-phpmwxvg.js → google-generative-ai-bmq8gkf3.js} +1 -1
- package/dist/{google-vertex-es9ybgdy.js → google-vertex-b1rvp52s.js} +1 -1
- package/dist/{grok-adapter-1193sprp.js → grok-adapter-vj88xr23.js} +7 -7
- package/dist/handlers-zbs90bny.js +39 -0
- package/dist/{heartbeat-h1w1ktce.js → heartbeat-t2m9r3wa.js} +20 -18
- package/dist/{hook-skrbja18.js → hook-8femx1bg.js} +4 -4
- package/dist/{hook-ejja4bpe.js → hook-xa677f8r.js} +5 -5
- package/dist/{http-mzh41mfj.js → http-xpmmq028.js} +868 -253
- package/dist/{index-vjn3wbzf.js → index-8pwc1jc8.js} +17 -15
- package/dist/{index-epr2gnbw.js → index-cdzyfrsp.js} +1 -1
- package/dist/{index-xmmkyx82.js → index-dg2tmj99.js} +17 -15
- package/dist/index-nxa6me0q.js +41 -0
- package/dist/{index-6rmnn89d.js → index-s7ckdajq.js} +9 -9
- package/dist/{index-wgsw9xcz.js → index-syztqdt6.js} +1 -1
- package/dist/{index-x0q2a4f9.js → index-ysd45kgc.js} +1 -1
- package/dist/{index-g909y089.js → index-zgvqzjsw.js} +8 -8
- package/dist/{keepalive-4djvbm2j.js → keepalive-3nr4st3r.js} +3 -3
- package/dist/{lead-61kqp604.js → lead-td8h2sfn.js} +33 -31
- package/dist/{maintenance-f3xhnjsm.js → maintenance-verwpe2c.js} +1 -1
- package/dist/{oauth-refresh-sweep-fpd6x6rw.js → oauth-refresh-sweep-9q2t0y3q.js} +2 -2
- package/dist/{onboard-196tjgyn.js → onboard-aytqedmq.js} +2 -2
- package/dist/{opencode-adapter-eeesgfqk.js → opencode-adapter-f4hryjny.js} +4 -4
- package/dist/{otel-impl-g8fh4c21.js → otel-impl-hckw4wt1.js} +1 -1
- package/dist/{pi-mono-adapter-zasp3ze3.js → pi-mono-adapter-hxby0ce1.js} +6 -6
- package/dist/{pricing-refresh-sdcf5yaz.js → pricing-refresh-5nvme3sm.js} +3 -3
- package/dist/{rbac-roles-bsp5657v.js → rbac-roles-pd8c26m1.js} +5 -3
- package/dist/{rbac-roles-2x4g1wb5.js → rbac-roles-vbpy21mg.js} +2 -2
- package/dist/{render-v2-0pyb39bb.js → render-v2-7nqgvk2q.js} +3 -3
- package/dist/{resume-a86tkj6r.js → resume-b6djcj3c.js} +6 -6
- package/dist/{runtime-ar8310ba.js → runtime-txrxc9za.js} +1 -1
- package/dist/session-access-fc3f9y12.js +25 -0
- package/dist/{setup-ypbzcapd.js → setup-n39szm6b.js} +1 -1
- package/dist/{virtual-modules-wzk6jf3a.js → virtual-modules-wyp2k6z3.js} +2 -2
- package/dist/{work-objects-yjp8de12.js → work-objects-jem2fre0.js} +20 -18
- package/dist/{worker-4ptnn1ge.js → worker-wr6xgfr2.js} +33 -31
- package/openapi.json +844 -110
- package/package.json +1 -1
- package/src/apps/action-caller.ts +6 -0
- package/src/apps/script-types.ts +16 -3
- package/src/be/db/active-sessions.ts +5 -0
- package/src/be/db/session-visibility.ts +29 -0
- package/src/be/db/tasks/read.ts +61 -3
- package/src/be/db/tasks/write.ts +15 -6
- package/src/be/db.ts +92 -33
- package/src/be/events.ts +11 -0
- package/src/be/migrations/230_session_shares.sql +23 -0
- package/src/be/rbac-roles.ts +2 -0
- package/src/be/session-shares.ts +244 -0
- package/src/be/swarm-config-guard.ts +23 -2
- package/src/commands/runner.ts +11 -0
- package/src/http/active-sessions.ts +13 -2
- package/src/http/agents.ts +10 -3
- package/src/http/all-routes.ts +1 -0
- package/src/http/api-keys.ts +3 -1
- package/src/http/approval-requests.ts +2 -1
- package/src/http/apps.ts +36 -11
- package/src/http/config.ts +3 -0
- package/src/http/context.ts +2 -0
- package/src/http/core.ts +39 -2
- package/src/http/events.ts +6 -2
- package/src/http/favorite-owner.ts +1 -1
- package/src/http/fs.ts +38 -3
- package/src/http/index.ts +3 -1
- package/src/http/memory.ts +3 -1
- package/src/http/ranking.ts +367 -0
- package/src/http/realtime.ts +26 -21
- package/src/http/route-def.ts +77 -11
- package/src/http/script-runs.ts +2 -1
- package/src/http/scripts.ts +8 -0
- package/src/http/session-access.ts +179 -0
- package/src/http/session-data.ts +10 -2
- package/src/http/sessions.ts +212 -12
- package/src/http/stats.ts +7 -3
- package/src/http/tasks.ts +83 -7
- package/src/http/used-by.ts +11 -3
- package/src/prompts/session-templates.ts +3 -1
- package/src/providers/amp-adapter.ts +1 -1
- package/src/ranking/telemetry-ranking.ts +147 -0
- package/src/ranking/usage.ts +57 -0
- package/src/rbac/admission.ts +22 -7
- package/src/rbac/legacy-policy.ts +2 -0
- package/src/rbac/permissions.ts +9 -0
- package/src/realtime/auth.ts +69 -0
- package/src/realtime/tickets.ts +4 -1
- package/src/realtime/transport.ts +77 -9
- package/src/slack/blocks.ts +18 -0
- package/src/slack/dm.ts +29 -0
- package/src/telemetry-contract/EVENTS.md +3 -0
- package/src/telemetry-contract/SOURCE.json +37 -4
- package/src/telemetry-contract/catalog.json +69 -0
- package/src/telemetry-contract/ranking/fixtures/expected.json +30 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/bad-category.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/bad-claim.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/blank-description.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/blank-name.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/description-too-long.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/domain-uppercase.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/domain-with-scheme.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/domain-with-www.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/http-source-url.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/missing-description.json +10 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/missing-version.json +10 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/name-not-string.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/name-too-long.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/not-object.json +1 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/null.json +1 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/other-with-subcategory.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/oversize.json +1 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/short-claim.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/subcategory-other-category.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/trailing-data.json +1 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/unknown-field.json +12 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/unsupported-version.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/updated-at-not-date.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/updated-at-offset.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/invalid/wrong-case-field.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/valid/category-without-subcategory.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/valid/full.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/valid/minimal.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/valid/padded-text.json +11 -0
- package/src/telemetry-contract/ranking/fixtures/valid/unicode-name.json +11 -0
- package/src/telemetry-contract/ranking/listing.v1.schema.json +61 -0
- package/src/telemetry-contract/ranking/taxonomy.json +8 -0
- package/src/telemetry-contract/ranking-taxonomy.gen.ts +11 -0
- package/src/telemetry-contract/types.gen.ts +708 -1
- package/src/telemetry.ts +26 -2
- package/src/tests/apps-rbac.test.ts +189 -2
- package/src/tests/apps-script-types.test.ts +16 -3
- package/src/tests/check-pr-body.test.ts +23 -0
- package/src/tests/check-ranking-entry.test.ts +146 -0
- package/src/tests/db-extraction-contract.test.ts +1 -0
- package/src/tests/db-task-read-contract.test.ts +1 -0
- package/src/tests/docs-index-footprint.test.js +2 -2
- package/src/tests/org-identity.test.ts +44 -0
- package/src/tests/ranking-listing.test.ts +112 -0
- package/src/tests/ranking-route.test.ts +319 -0
- package/src/tests/ranking-usage.test.ts +50 -0
- package/src/tests/rbac-admission-e2e.test.ts +8 -0
- package/src/tests/rbac-admission.test.ts +38 -0
- package/src/tests/rbac-charact-http.test.ts +4 -3
- package/src/tests/rbac-engine.test.ts +2 -0
- package/src/tests/rbac-roles.test.ts +2 -0
- package/src/tests/realtime-room-surfaces.test.ts +3 -1
- package/src/tests/realtime-session-join-race.test.ts +142 -0
- package/src/tests/realtime-tickets.test.ts +24 -17
- package/src/tests/realtime-transport.test.ts +91 -0
- package/src/tests/session-route-posture.test.ts +639 -0
- package/src/tests/session-sharing-routes.test.ts +282 -0
- package/src/tests/session-sharing.test.ts +526 -0
- package/src/tests/sessions-detail-batch.test.ts +7 -1
- package/src/tests/slack-tool-blocks.test.ts +101 -0
- package/src/tests/slack-update.test.ts +55 -0
- package/src/tests/slack-user-dm.test.ts +177 -0
- package/src/tests/used-by-route.test.ts +10 -0
- package/src/tools/slack-post.ts +18 -12
- package/src/tools/slack-read.ts +13 -1
- package/src/tools/slack-reply.ts +7 -10
- package/src/tools/slack-start-thread.ts +18 -11
- package/src/tools/slack-update.ts +2 -9
- package/src/types.ts +35 -0
- package/src/utils/org-identity.ts +33 -0
- package/templates/skills/apps/SKILL.md +17 -0
- package/templates/skills/apps/content.md +17 -0
- package/dist/handlers-tb3xgjnm.js +0 -37
- package/dist/index-nyfcdpky.js +0 -39
- package/dist/{cli-n5mrsg0f.js → cli-60cgh1rv.js} +3 -3
- /package/dist/{cli-j0v75y13.js → cli-y83zk5sb.js} +0 -0
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import type { z } from "zod";
|
|
7
|
+
import {
|
|
8
|
+
closeDb,
|
|
9
|
+
createAgent,
|
|
10
|
+
createSteeringMessage,
|
|
11
|
+
createTaskExtended,
|
|
12
|
+
createUser,
|
|
13
|
+
getActiveSessions,
|
|
14
|
+
getDbClient,
|
|
15
|
+
getTaskById,
|
|
16
|
+
initDb,
|
|
17
|
+
insertActiveSession,
|
|
18
|
+
insertTaskAttachment,
|
|
19
|
+
} from "../be/db";
|
|
20
|
+
import { attachRole, detachRole, ensureRbacSeedsSynced } from "../be/rbac-roles";
|
|
21
|
+
import { setSessionShares } from "../be/session-shares";
|
|
22
|
+
import { mintToken } from "../be/users";
|
|
23
|
+
import { getFileStorageProvider, resetFileStorageProvider } from "../fs/registry";
|
|
24
|
+
import "../http/all-routes";
|
|
25
|
+
import { handleActiveSessions } from "../http/active-sessions";
|
|
26
|
+
import { handleApiKeys } from "../http/api-keys";
|
|
27
|
+
import { handleApprovalRequests } from "../http/approval-requests";
|
|
28
|
+
import { handleContext } from "../http/context";
|
|
29
|
+
import { handleCore } from "../http/core";
|
|
30
|
+
import { handleEvents } from "../http/events";
|
|
31
|
+
import { handleFs } from "../http/fs";
|
|
32
|
+
import { handleMcp } from "../http/mcp";
|
|
33
|
+
import { handleMemory } from "../http/memory";
|
|
34
|
+
import { handlePoll } from "../http/poll";
|
|
35
|
+
import { type RouteDef, routeRegistry } from "../http/route-def";
|
|
36
|
+
import { handleScriptRuns } from "../http/script-runs";
|
|
37
|
+
import {
|
|
38
|
+
SESSION_AGENT_WIDE_DENIED,
|
|
39
|
+
SESSION_READ_DENIED,
|
|
40
|
+
SESSION_WRITE_DENIED,
|
|
41
|
+
USER_AGENT_HEADER_DENIED,
|
|
42
|
+
} from "../http/session-access";
|
|
43
|
+
import { handleSessionData } from "../http/session-data";
|
|
44
|
+
import { handleSessions } from "../http/sessions";
|
|
45
|
+
import { handleTasks } from "../http/tasks";
|
|
46
|
+
import { getPathSegments, parseQueryParams } from "../http/utils";
|
|
47
|
+
import { beginRequestAuthScope } from "../utils/request-auth-context";
|
|
48
|
+
import { listenOnFreePort } from "./test-net";
|
|
49
|
+
|
|
50
|
+
// Walks the route registry. Every route keyed by a task, steering message or
|
|
51
|
+
// attachment, every write whose body names a task, and every active-session
|
|
52
|
+
// write (keyed by a row id or a whole agent) declares a session posture, and
|
|
53
|
+
// each posture holds against minted `aswt_` user tokens: an unshared user and
|
|
54
|
+
// a read-only sharee change nothing, the owner and a write sharee pass. A user
|
|
55
|
+
// token never acts as an agent through X-Agent-ID.
|
|
56
|
+
|
|
57
|
+
const OPERATOR_KEY = "session-posture-operator-key";
|
|
58
|
+
const TASK_PARAMS = new Set(["taskId", "rootTaskId", "parentTaskId", "attachmentId", "steeringId"]);
|
|
59
|
+
const BODY_TASK_KEYS = new Set(["taskId", "parentTaskId", "sourceTaskId", "rootTaskId"]);
|
|
60
|
+
const DENIALS = [SESSION_READ_DENIED, SESSION_WRITE_DENIED];
|
|
61
|
+
|
|
62
|
+
type Handler = (
|
|
63
|
+
req: IncomingMessage,
|
|
64
|
+
res: ServerResponse,
|
|
65
|
+
segments: string[],
|
|
66
|
+
query: URLSearchParams,
|
|
67
|
+
agentId: string | undefined,
|
|
68
|
+
) => Promise<boolean>;
|
|
69
|
+
|
|
70
|
+
const HANDLERS: Handler[] = [
|
|
71
|
+
handlePoll,
|
|
72
|
+
handleTasks,
|
|
73
|
+
handleContext,
|
|
74
|
+
handleSessionData,
|
|
75
|
+
handleEvents,
|
|
76
|
+
handleFs,
|
|
77
|
+
handleActiveSessions,
|
|
78
|
+
handleSessions,
|
|
79
|
+
(req, res, segments, query) => handleApprovalRequests(req, res, segments, query),
|
|
80
|
+
(req, res, segments, query) => handleApiKeys(req, res, segments, query),
|
|
81
|
+
(req, res, segments, query) => handleScriptRuns(req, res, segments, query),
|
|
82
|
+
(req, res, segments, _query, agentId) => handleMemory(req, res, segments, agentId),
|
|
83
|
+
(req, res) => handleMcp(req, res, {}, {}, {}),
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
const server = createServer(async (req, res) => {
|
|
87
|
+
try {
|
|
88
|
+
beginRequestAuthScope();
|
|
89
|
+
const agentId = req.headers["x-agent-id"] as string | undefined;
|
|
90
|
+
if (await handleCore(req, res, agentId, OPERATOR_KEY)) return;
|
|
91
|
+
const segments = getPathSegments(req.url ?? "");
|
|
92
|
+
const query = parseQueryParams(req.url ?? "");
|
|
93
|
+
for (const handler of HANDLERS) {
|
|
94
|
+
if (await handler(req, res, segments, query, req.headers["x-agent-id"] as string)) return;
|
|
95
|
+
}
|
|
96
|
+
res.writeHead(404);
|
|
97
|
+
res.end();
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (!res.headersSent) res.writeHead(500);
|
|
100
|
+
res.end(String(error));
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
type Who = "owner" | "writer" | "reader" | "viewer";
|
|
105
|
+
|
|
106
|
+
let port: number;
|
|
107
|
+
let agent: { id: string };
|
|
108
|
+
let owner: { id: string };
|
|
109
|
+
let writer: { id: string };
|
|
110
|
+
let reader: { id: string };
|
|
111
|
+
const tokens = {} as Record<Who, string>;
|
|
112
|
+
let filesDir: string;
|
|
113
|
+
const multiRuntime = process.env.MULTI_RUNTIME_ENABLED;
|
|
114
|
+
|
|
115
|
+
beforeAll(async () => {
|
|
116
|
+
// Agent-wide cleanup deletes rows only in single-runtime mode.
|
|
117
|
+
process.env.MULTI_RUNTIME_ENABLED = "false";
|
|
118
|
+
filesDir = await mkdtemp(join(tmpdir(), "session-posture-"));
|
|
119
|
+
process.env.AGENT_FS_LOCAL_DIR = filesDir;
|
|
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
|
+
resetFileStorageProvider();
|
|
124
|
+
initDb(":memory:");
|
|
125
|
+
ensureRbacSeedsSynced({ quiet: true });
|
|
126
|
+
agent = await createAgent({ name: "Worker", isLead: false, status: "idle" });
|
|
127
|
+
owner = await createUser({ name: "Owner" });
|
|
128
|
+
writer = await createUser({ name: "Writer" });
|
|
129
|
+
reader = await createUser({ name: "Reader" });
|
|
130
|
+
const viewer = await createUser({ name: "Viewer" });
|
|
131
|
+
const system = { kind: "system", id: "test" } as const;
|
|
132
|
+
tokens.owner = (await mintToken(owner.id, "test", system)).plaintext;
|
|
133
|
+
tokens.writer = (await mintToken(writer.id, "test", system)).plaintext;
|
|
134
|
+
tokens.reader = (await mintToken(reader.id, "test", system)).plaintext;
|
|
135
|
+
tokens.viewer = (await mintToken(viewer.id, "test", system)).plaintext;
|
|
136
|
+
port = await listenOnFreePort(server, "127.0.0.1");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
afterAll(async () => {
|
|
140
|
+
server.closeAllConnections();
|
|
141
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
142
|
+
closeDb();
|
|
143
|
+
resetFileStorageProvider();
|
|
144
|
+
if (multiRuntime === undefined) delete process.env.MULTI_RUNTIME_ENABLED;
|
|
145
|
+
else process.env.MULTI_RUNTIME_ENABLED = multiRuntime;
|
|
146
|
+
await rm(filesDir, { recursive: true, force: true });
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
function pathParams(path: string): string[] {
|
|
150
|
+
return path.match(/\{(\w+)\}/g)?.map((p) => p.slice(1, -1)) ?? [];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function idUnder(def: RouteDef, prefix: string): boolean {
|
|
154
|
+
return def.path.startsWith(`${prefix}/{id}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Top-level keys of a body schema, and of the objects in its array fields. */
|
|
158
|
+
function bodyKeys(schema: z.ZodType | undefined): string[] {
|
|
159
|
+
const shapeOf = (s: unknown): Record<string, unknown> | undefined => {
|
|
160
|
+
let current = s as { shape?: Record<string, unknown>; def?: { innerType?: unknown } };
|
|
161
|
+
while (current && !current.shape && current.def?.innerType) {
|
|
162
|
+
current = current.def.innerType as typeof current;
|
|
163
|
+
}
|
|
164
|
+
return current?.shape;
|
|
165
|
+
};
|
|
166
|
+
const shape = shapeOf(schema);
|
|
167
|
+
if (!shape) return [];
|
|
168
|
+
const keys = Object.keys(shape);
|
|
169
|
+
for (const value of Object.values(shape)) {
|
|
170
|
+
const element = (value as { def?: { element?: unknown } })?.def?.element;
|
|
171
|
+
keys.push(...Object.keys(shapeOf(element) ?? {}));
|
|
172
|
+
}
|
|
173
|
+
return keys;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function keyedByTaskPath(def: RouteDef): boolean {
|
|
177
|
+
return (
|
|
178
|
+
pathParams(def.path).some((name) => TASK_PARAMS.has(name)) ||
|
|
179
|
+
idUnder(def, "/api/tasks") ||
|
|
180
|
+
idUnder(def, "/api/steering-messages")
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** A write naming a task in its body; a task-keyed path already gates its route. */
|
|
185
|
+
function namesTaskInBody(def: RouteDef): boolean {
|
|
186
|
+
return (
|
|
187
|
+
def.method !== "get" &&
|
|
188
|
+
!keyedByTaskPath(def) &&
|
|
189
|
+
bodyKeys(def.body).some((key) => BODY_TASK_KEYS.has(key))
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** A write keyed by a task only indirectly: an active-session row id, or a whole agent. */
|
|
194
|
+
function activeSessionWrite(def: RouteDef): boolean {
|
|
195
|
+
return (
|
|
196
|
+
def.method !== "get" &&
|
|
197
|
+
def.path.startsWith("/api/active-sessions") &&
|
|
198
|
+
!keyedByTaskPath(def) &&
|
|
199
|
+
!namesTaskInBody(def)
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** A private root and child on `agent`: `writer` may write, `reader` may read, `viewer` nothing. */
|
|
204
|
+
async function privateSession(label: string) {
|
|
205
|
+
const root = await createTaskExtended(`${label} root`, {
|
|
206
|
+
requestedByUserId: owner.id,
|
|
207
|
+
agentId: agent.id,
|
|
208
|
+
source: "ui",
|
|
209
|
+
});
|
|
210
|
+
const child = await createTaskExtended(`${label} child`, {
|
|
211
|
+
requestedByUserId: owner.id,
|
|
212
|
+
agentId: agent.id,
|
|
213
|
+
parentTaskId: root.id,
|
|
214
|
+
source: "ui",
|
|
215
|
+
});
|
|
216
|
+
await setSessionShares(
|
|
217
|
+
{ id: root.id, requestedByUserId: owner.id },
|
|
218
|
+
{
|
|
219
|
+
everyone: "none",
|
|
220
|
+
users: [
|
|
221
|
+
{ userId: writer.id, access: "write" },
|
|
222
|
+
{ userId: reader.id, access: "read" },
|
|
223
|
+
],
|
|
224
|
+
},
|
|
225
|
+
owner.id,
|
|
226
|
+
);
|
|
227
|
+
const steering = await createSteeringMessage({
|
|
228
|
+
taskId: child.id,
|
|
229
|
+
body: `${label} steering`,
|
|
230
|
+
mode: "queue",
|
|
231
|
+
source: "ui",
|
|
232
|
+
createdByKind: "user",
|
|
233
|
+
createdByUserId: owner.id,
|
|
234
|
+
});
|
|
235
|
+
const provider = getFileStorageProvider();
|
|
236
|
+
const file = await provider.upload({ taskId: child.id, name: "notes.txt" }, Buffer.from(label), {
|
|
237
|
+
contentType: "text/plain",
|
|
238
|
+
});
|
|
239
|
+
const attachment = await insertTaskAttachment({
|
|
240
|
+
taskId: child.id,
|
|
241
|
+
agentId: agent.id,
|
|
242
|
+
kind: "shared-fs",
|
|
243
|
+
name: "notes.txt",
|
|
244
|
+
path: file.key,
|
|
245
|
+
providerId: provider.id,
|
|
246
|
+
providerKey: file.key,
|
|
247
|
+
});
|
|
248
|
+
return { root, child, steeringId: steering.id, attachmentId: attachment.id };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Every row any table holds for the session's tasks: a refused call must leave it identical. */
|
|
252
|
+
async function sessionState(taskIds: string[]): Promise<string> {
|
|
253
|
+
const db = getDbClient();
|
|
254
|
+
const tables = await db.query<{ name: string }>(
|
|
255
|
+
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
|
|
256
|
+
);
|
|
257
|
+
const marks = taskIds.map(() => "?").join(", ");
|
|
258
|
+
const parts: unknown[] = [];
|
|
259
|
+
for (const { name } of tables) {
|
|
260
|
+
const columns = await db.query<{ name: string }>(`PRAGMA table_info("${name}")`);
|
|
261
|
+
const keyed = columns
|
|
262
|
+
.map((column) => column.name)
|
|
263
|
+
.filter(
|
|
264
|
+
(column) => BODY_TASK_KEYS.has(column) || (name === "agent_tasks" && column === "id"),
|
|
265
|
+
);
|
|
266
|
+
for (const column of keyed) {
|
|
267
|
+
parts.push(
|
|
268
|
+
name,
|
|
269
|
+
column,
|
|
270
|
+
await db.query(`SELECT * FROM "${name}" WHERE "${column}" IN (${marks}) ORDER BY rowid`, [
|
|
271
|
+
...taskIds,
|
|
272
|
+
]),
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return JSON.stringify(parts);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function call(
|
|
280
|
+
method: string,
|
|
281
|
+
path: string,
|
|
282
|
+
bearer: string,
|
|
283
|
+
{ body, agentId }: { body?: unknown; agentId?: string } = {},
|
|
284
|
+
) {
|
|
285
|
+
const headers: Record<string, string> = {
|
|
286
|
+
Authorization: `Bearer ${bearer}`,
|
|
287
|
+
"Content-Type": "application/json",
|
|
288
|
+
Accept: "application/json, text/event-stream",
|
|
289
|
+
};
|
|
290
|
+
if (agentId) headers["X-Agent-ID"] = agentId;
|
|
291
|
+
const res = await fetch(`http://127.0.0.1:${port}${path}`, {
|
|
292
|
+
method,
|
|
293
|
+
headers,
|
|
294
|
+
body: method === "GET" ? undefined : JSON.stringify(body ?? {}),
|
|
295
|
+
});
|
|
296
|
+
return { status: res.status, body: await res.text() };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const pathRoutes = routeRegistry.filter(keyedByTaskPath);
|
|
300
|
+
const bodyRoutes = routeRegistry.filter(namesTaskInBody);
|
|
301
|
+
const indirectRoutes = routeRegistry.filter(activeSessionWrite);
|
|
302
|
+
|
|
303
|
+
async function activeSessionRows(agentId: string): Promise<string[]> {
|
|
304
|
+
return (await getActiveSessions(agentId)).map((row) => row.id);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
describe("route registry session posture", () => {
|
|
308
|
+
test("every task-keyed route and every task-naming write declares a session posture", () => {
|
|
309
|
+
expect(pathRoutes.length).toBeGreaterThan(30);
|
|
310
|
+
expect(bodyRoutes.length).toBeGreaterThan(8);
|
|
311
|
+
const missing = [...pathRoutes, ...bodyRoutes]
|
|
312
|
+
.filter((def) => !def.session)
|
|
313
|
+
.map((def) => `${def.method.toUpperCase()} ${def.path}`);
|
|
314
|
+
expect(missing).toEqual([]);
|
|
315
|
+
const wrongParam = pathRoutes
|
|
316
|
+
.filter((def) => {
|
|
317
|
+
const posture = def.session;
|
|
318
|
+
if (!posture) return false;
|
|
319
|
+
const name =
|
|
320
|
+
"taskParam" in posture
|
|
321
|
+
? posture.taskParam
|
|
322
|
+
: "steeringParam" in posture
|
|
323
|
+
? posture.steeringParam
|
|
324
|
+
: undefined;
|
|
325
|
+
return name === undefined || !pathParams(def.path).includes(name);
|
|
326
|
+
})
|
|
327
|
+
.map((def) => `${def.method.toUpperCase()} ${def.path}`);
|
|
328
|
+
expect(wrongParam).toEqual([]);
|
|
329
|
+
const wrongBody = bodyRoutes
|
|
330
|
+
.filter((def) => !(def.session && ("bodyTaskIds" in def.session || "none" in def.session)))
|
|
331
|
+
.map((def) => `${def.method.toUpperCase()} ${def.path}`);
|
|
332
|
+
expect(wrongBody).toEqual([]);
|
|
333
|
+
expect(indirectRoutes.length).toBeGreaterThanOrEqual(3);
|
|
334
|
+
const wrongIndirect = indirectRoutes
|
|
335
|
+
.filter((def) => {
|
|
336
|
+
const posture = def.session;
|
|
337
|
+
if (!posture) return true;
|
|
338
|
+
if ("activeSessionParam" in posture) {
|
|
339
|
+
return !pathParams(def.path).includes(posture.activeSessionParam);
|
|
340
|
+
}
|
|
341
|
+
return !("agentWide" in posture || "none" in posture);
|
|
342
|
+
})
|
|
343
|
+
.map((def) => `${def.method.toUpperCase()} ${def.path}`);
|
|
344
|
+
expect(wrongIndirect).toEqual([]);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
for (const def of pathRoutes) {
|
|
348
|
+
const label = `${def.method.toUpperCase()} ${def.path}`;
|
|
349
|
+
test(`${label}: unshared and read-only users change nothing; owner and writer pass`, async () => {
|
|
350
|
+
const session = await privateSession(label);
|
|
351
|
+
const ids: Record<string, string> = {
|
|
352
|
+
taskId: session.child.id,
|
|
353
|
+
rootTaskId: session.root.id,
|
|
354
|
+
attachmentId: session.attachmentId,
|
|
355
|
+
id: def.session && "steeringParam" in def.session ? session.steeringId : session.child.id,
|
|
356
|
+
};
|
|
357
|
+
const path = def.path.replace(/\{(\w+)\}/g, (_, name: string) => ids[name] ?? "x");
|
|
358
|
+
const method = def.method.toUpperCase();
|
|
359
|
+
const isRead = method === "GET";
|
|
360
|
+
const taskIds = [session.root.id, session.child.id];
|
|
361
|
+
|
|
362
|
+
const refused: Who[] = isRead ? ["viewer"] : ["viewer", "reader"];
|
|
363
|
+
for (const who of refused) {
|
|
364
|
+
const before = await sessionState(taskIds);
|
|
365
|
+
const res = await call(method, path, tokens[who]);
|
|
366
|
+
expect({ label, who, status: res.status }).toEqual({ label, who, status: 403 });
|
|
367
|
+
expect(res.body).toContain(isRead ? SESSION_READ_DENIED : SESSION_WRITE_DENIED);
|
|
368
|
+
expect(await sessionState(taskIds)).toBe(before);
|
|
369
|
+
}
|
|
370
|
+
const passing: Who[] = isRead ? ["reader", "writer", "owner"] : ["writer", "owner"];
|
|
371
|
+
for (const who of passing) {
|
|
372
|
+
const res = await call(method, path, tokens[who]);
|
|
373
|
+
const denied = DENIALS.some((denial) => res.body.includes(denial));
|
|
374
|
+
expect({ label, who, denied }).toEqual({ label, who, denied: false });
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
for (const def of bodyRoutes) {
|
|
380
|
+
const label = `${def.method.toUpperCase()} ${def.path}`;
|
|
381
|
+
test(`${label}: a body naming a session task needs write access`, async () => {
|
|
382
|
+
const session = await privateSession(label);
|
|
383
|
+
const id = session.child.id;
|
|
384
|
+
const posture = def.session;
|
|
385
|
+
const path = def.path.replace(/\{(\w+)\}/g, "x");
|
|
386
|
+
const method = def.method.toUpperCase();
|
|
387
|
+
const taskIds = [session.root.id, session.child.id];
|
|
388
|
+
if (posture && "none" in posture) {
|
|
389
|
+
// Its handler refuses every user token outright.
|
|
390
|
+
for (const who of ["viewer", "reader", "writer", "owner"] as const) {
|
|
391
|
+
const before = await sessionState(taskIds);
|
|
392
|
+
const res = await call(method, path, tokens[who], { body: { taskId: id } });
|
|
393
|
+
expect({ label, who, ok: res.status < 300 }).toEqual({ label, who, ok: false });
|
|
394
|
+
expect(await sessionState(taskIds)).toBe(before);
|
|
395
|
+
}
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
if (!posture || !("bodyTaskIds" in posture)) throw new Error(`${label} has no body posture`);
|
|
399
|
+
const candidates = [
|
|
400
|
+
{ taskId: id },
|
|
401
|
+
{ parentTaskId: id },
|
|
402
|
+
{ sourceTaskId: id },
|
|
403
|
+
{ rootTaskId: id },
|
|
404
|
+
{ events: [{ taskId: id }] },
|
|
405
|
+
];
|
|
406
|
+
const body = candidates.find((candidate) => posture.bodyTaskIds(candidate).includes(id));
|
|
407
|
+
expect({ label, body: body !== undefined }).toEqual({ label, body: true });
|
|
408
|
+
|
|
409
|
+
for (const who of ["viewer", "reader"] as const) {
|
|
410
|
+
const before = await sessionState(taskIds);
|
|
411
|
+
const res = await call(method, path, tokens[who], { body });
|
|
412
|
+
expect({ label, who, status: res.status }).toEqual({ label, who, status: 403 });
|
|
413
|
+
expect(res.body).toContain(SESSION_WRITE_DENIED);
|
|
414
|
+
expect(await sessionState(taskIds)).toBe(before);
|
|
415
|
+
}
|
|
416
|
+
for (const who of ["writer", "owner"] as const) {
|
|
417
|
+
const res = await call(method, path, tokens[who], { body });
|
|
418
|
+
expect({ label, who, denied: res.body.includes(SESSION_WRITE_DENIED) }).toEqual({
|
|
419
|
+
label,
|
|
420
|
+
who,
|
|
421
|
+
denied: false,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
describe("active-session writes keyed by a row id or a whole agent", () => {
|
|
429
|
+
for (const def of indirectRoutes) {
|
|
430
|
+
const label = `${def.method.toUpperCase()} ${def.path}`;
|
|
431
|
+
test(`${label}: no user without session write access changes a row`, async () => {
|
|
432
|
+
const session = await privateSession(label);
|
|
433
|
+
const taskIds = [session.root.id, session.child.id];
|
|
434
|
+
const worker = await createAgent({ name: `${label} worker`, isLead: false, status: "idle" });
|
|
435
|
+
const row = await insertActiveSession({
|
|
436
|
+
agentId: worker.id,
|
|
437
|
+
taskId: session.child.id,
|
|
438
|
+
triggerType: "task_assigned",
|
|
439
|
+
});
|
|
440
|
+
const posture = def.session;
|
|
441
|
+
const method = def.method.toUpperCase();
|
|
442
|
+
const path = def.path.replace("{id}", row.id);
|
|
443
|
+
const body = { agentId: worker.id };
|
|
444
|
+
|
|
445
|
+
if (posture && "activeSessionParam" in posture) {
|
|
446
|
+
for (const who of ["viewer", "reader"] as const) {
|
|
447
|
+
const before = await sessionState(taskIds);
|
|
448
|
+
const res = await call(method, path, tokens[who]);
|
|
449
|
+
expect({ label, who, status: res.status }).toEqual({ label, who, status: 403 });
|
|
450
|
+
expect(res.body).toContain(SESSION_WRITE_DENIED);
|
|
451
|
+
expect(await sessionState(taskIds)).toBe(before);
|
|
452
|
+
}
|
|
453
|
+
const writerRes = await call(method, path, tokens.writer);
|
|
454
|
+
expect({ label, status: writerRes.status }).toEqual({ label, status: 200 });
|
|
455
|
+
expect(await activeSessionRows(worker.id)).toEqual([]);
|
|
456
|
+
|
|
457
|
+
// A row with no task is the agent's own: no user deletes it, the agent does.
|
|
458
|
+
const own = await insertActiveSession({ agentId: worker.id, triggerType: "heartbeat" });
|
|
459
|
+
const ownPath = def.path.replace("{id}", own.id);
|
|
460
|
+
for (const who of ["viewer", "reader", "writer", "owner"] as const) {
|
|
461
|
+
const res = await call(method, ownPath, tokens[who]);
|
|
462
|
+
expect({ label, who, status: res.status }).toEqual({ label, who, status: 403 });
|
|
463
|
+
expect(await activeSessionRows(worker.id)).toEqual([own.id]);
|
|
464
|
+
}
|
|
465
|
+
const agentRes = await call(method, ownPath, OPERATOR_KEY, { agentId: worker.id });
|
|
466
|
+
expect(agentRes.status).toBe(200);
|
|
467
|
+
expect(await activeSessionRows(worker.id)).toEqual([]);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
for (const who of ["viewer", "reader", "writer", "owner"] as const) {
|
|
472
|
+
const before = await sessionState(taskIds);
|
|
473
|
+
const res = await call(method, path, tokens[who], { body });
|
|
474
|
+
expect({ label, who, ok: res.status < 300 }).toEqual({ label, who, ok: false });
|
|
475
|
+
if (posture && "agentWide" in posture)
|
|
476
|
+
expect(res.body).toContain(SESSION_AGENT_WIDE_DENIED);
|
|
477
|
+
expect(await sessionState(taskIds)).toBe(before);
|
|
478
|
+
expect(await activeSessionRows(worker.id)).toEqual([row.id]);
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
describe("B11 indirect active-session repros", () => {
|
|
485
|
+
test("a read-only sharee cannot delete by row id what it cannot delete by task id", async () => {
|
|
486
|
+
const session = await privateSession("delete by id");
|
|
487
|
+
const row = await insertActiveSession({
|
|
488
|
+
agentId: agent.id,
|
|
489
|
+
taskId: session.child.id,
|
|
490
|
+
triggerType: "task_assigned",
|
|
491
|
+
});
|
|
492
|
+
const listed = await call("GET", `/api/active-sessions?agentId=${agent.id}`, tokens.reader);
|
|
493
|
+
expect(listed.status).toBe(200);
|
|
494
|
+
expect(listed.body).toContain(row.id);
|
|
495
|
+
|
|
496
|
+
const byTask = await call(
|
|
497
|
+
"DELETE",
|
|
498
|
+
`/api/active-sessions/by-task/${session.child.id}`,
|
|
499
|
+
tokens.reader,
|
|
500
|
+
);
|
|
501
|
+
expect(byTask.status).toBe(403);
|
|
502
|
+
const byId = await call("DELETE", `/api/active-sessions/${row.id}`, tokens.reader);
|
|
503
|
+
expect({ status: byId.status, body: byId.body }).toEqual({
|
|
504
|
+
status: 403,
|
|
505
|
+
body: expect.stringContaining(SESSION_WRITE_DENIED),
|
|
506
|
+
});
|
|
507
|
+
expect(await activeSessionRows(agent.id)).toContain(row.id);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
test("an unshared user cannot clean up an agent's private sessions", async () => {
|
|
511
|
+
const session = await privateSession("cleanup");
|
|
512
|
+
const worker = await createAgent({ name: "Cleanup worker", isLead: false, status: "idle" });
|
|
513
|
+
const row = await insertActiveSession({
|
|
514
|
+
agentId: worker.id,
|
|
515
|
+
taskId: session.child.id,
|
|
516
|
+
triggerType: "task_assigned",
|
|
517
|
+
});
|
|
518
|
+
const res = await call("POST", "/api/active-sessions/cleanup", tokens.viewer, {
|
|
519
|
+
body: { agentId: worker.id },
|
|
520
|
+
});
|
|
521
|
+
expect({ status: res.status, body: res.body }).toEqual({
|
|
522
|
+
status: 403,
|
|
523
|
+
body: expect.stringContaining(SESSION_AGENT_WIDE_DENIED),
|
|
524
|
+
});
|
|
525
|
+
expect(await activeSessionRows(worker.id)).toEqual([row.id]);
|
|
526
|
+
|
|
527
|
+
// Control: the worker's own boot cleanup still deletes it.
|
|
528
|
+
const boot = await call("POST", "/api/active-sessions/cleanup", OPERATOR_KEY, {
|
|
529
|
+
body: { agentId: worker.id },
|
|
530
|
+
agentId: worker.id,
|
|
531
|
+
});
|
|
532
|
+
expect({ status: boot.status, body: JSON.parse(boot.body) }).toEqual({
|
|
533
|
+
status: 200,
|
|
534
|
+
body: { cleaned: 1 },
|
|
535
|
+
});
|
|
536
|
+
expect(await activeSessionRows(worker.id)).toEqual([]);
|
|
537
|
+
});
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
describe("a user token never acts as an agent", () => {
|
|
541
|
+
const initialize = {
|
|
542
|
+
jsonrpc: "2.0",
|
|
543
|
+
id: 1,
|
|
544
|
+
method: "initialize",
|
|
545
|
+
params: {
|
|
546
|
+
protocolVersion: "2025-03-26",
|
|
547
|
+
capabilities: {},
|
|
548
|
+
clientInfo: { name: "posture-test", version: "1" },
|
|
549
|
+
},
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
test("/api/poll refuses a user token naming the worker and leaves the task pending", async () => {
|
|
553
|
+
const secret = "PRIVATE POLLED PROMPT";
|
|
554
|
+
// A worker of its own, so no other test's pending task is polled first.
|
|
555
|
+
const poller = await createAgent({ name: "Poller", isLead: false, status: "idle" });
|
|
556
|
+
const task = await createTaskExtended(secret, {
|
|
557
|
+
requestedByUserId: owner.id,
|
|
558
|
+
agentId: poller.id,
|
|
559
|
+
source: "ui",
|
|
560
|
+
});
|
|
561
|
+
expect((await getTaskById(task.id))?.status).toBe("pending");
|
|
562
|
+
for (const who of ["viewer", "owner"] as const) {
|
|
563
|
+
const res = await call("GET", "/api/poll", tokens[who], { agentId: poller.id });
|
|
564
|
+
expect({ who, status: res.status }).toEqual({ who, status: 403 });
|
|
565
|
+
expect(res.body).toContain(USER_AGENT_HEADER_DENIED);
|
|
566
|
+
expect(res.body).not.toContain(secret);
|
|
567
|
+
expect((await getTaskById(task.id))?.status).toBe("pending");
|
|
568
|
+
}
|
|
569
|
+
// Control: the worker itself still receives and starts it.
|
|
570
|
+
const worker = await call("GET", "/api/poll", OPERATOR_KEY, { agentId: poller.id });
|
|
571
|
+
expect(worker.status).toBe(200);
|
|
572
|
+
expect(worker.body).toContain(secret);
|
|
573
|
+
expect((await getTaskById(task.id))?.status).toBe("in_progress");
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
test("/mcp initialize refuses a user token, with or without X-Agent-ID", async () => {
|
|
577
|
+
const task = await createTaskExtended("PRIVATE MCP TASK", {
|
|
578
|
+
requestedByUserId: owner.id,
|
|
579
|
+
agentId: agent.id,
|
|
580
|
+
source: "ui",
|
|
581
|
+
});
|
|
582
|
+
for (const agentId of [agent.id, undefined]) {
|
|
583
|
+
for (const who of ["viewer", "owner"] as const) {
|
|
584
|
+
const res = await call("POST", "/mcp", tokens[who], { body: initialize, agentId });
|
|
585
|
+
expect({ who, agentId, status: res.status }).toEqual({ who, agentId, status: 403 });
|
|
586
|
+
expect(res.body).toContain(USER_AGENT_HEADER_DENIED);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
expect((await getTaskById(task.id))?.status).toBe("pending");
|
|
590
|
+
// Control: the agent transport still opens for the agent.
|
|
591
|
+
const worker = await call("POST", "/mcp", OPERATOR_KEY, {
|
|
592
|
+
body: initialize,
|
|
593
|
+
agentId: agent.id,
|
|
594
|
+
});
|
|
595
|
+
expect(worker.status).toBe(200);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
test("worker-header routes refuse a user token naming the assigned worker", async () => {
|
|
599
|
+
const session = await privateSession("worker header");
|
|
600
|
+
const before = await sessionState([session.root.id, session.child.id]);
|
|
601
|
+
const routes: [string, string][] = [
|
|
602
|
+
["POST", `/api/tasks/${session.child.id}/finish`],
|
|
603
|
+
["POST", `/api/steering-messages/${session.steeringId}/delivered`],
|
|
604
|
+
["POST", `/api/steering-messages/${session.steeringId}/handled`],
|
|
605
|
+
["POST", `/api/steering-messages/${session.steeringId}/undeliverable`],
|
|
606
|
+
["POST", `/api/tasks/${session.child.id}/context`],
|
|
607
|
+
["GET", "/api/paused-tasks"],
|
|
608
|
+
];
|
|
609
|
+
for (const [method, path] of routes) {
|
|
610
|
+
for (const who of ["viewer", "reader", "writer", "owner"] as const) {
|
|
611
|
+
const res = await call(method, path, tokens[who], { agentId: agent.id });
|
|
612
|
+
expect({ path, who, status: res.status }).toEqual({ path, who, status: 403 });
|
|
613
|
+
expect(res.body).toContain(USER_AGENT_HEADER_DENIED);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
expect(await sessionState([session.root.id, session.child.id])).toBe(before);
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
test("the script playground admits a user token naming an agent only with agent.act.any", async () => {
|
|
620
|
+
const narrowed = await createUser({ name: "Requester" });
|
|
621
|
+
await detachRole(narrowed.id, "admin");
|
|
622
|
+
await attachRole(narrowed.id, "requester");
|
|
623
|
+
const system = { kind: "system", id: "test" } as const;
|
|
624
|
+
const narrowedToken = (await mintToken(narrowed.id, "test", system)).plaintext;
|
|
625
|
+
const run = { source: "export default async () => 1", intent: "posture" };
|
|
626
|
+
|
|
627
|
+
const admin = await call("POST", "/api/scripts/run", tokens.owner, {
|
|
628
|
+
body: run,
|
|
629
|
+
agentId: agent.id,
|
|
630
|
+
});
|
|
631
|
+
expect(admin.body).not.toContain(USER_AGENT_HEADER_DENIED);
|
|
632
|
+
const requester = await call("POST", "/api/scripts/run", narrowedToken, {
|
|
633
|
+
body: run,
|
|
634
|
+
agentId: agent.id,
|
|
635
|
+
});
|
|
636
|
+
expect(requester.status).toBe(403);
|
|
637
|
+
expect(requester.body).toContain(USER_AGENT_HEADER_DENIED);
|
|
638
|
+
});
|
|
639
|
+
});
|