@desplega.ai/agent-swarm 2.4.3 → 2.4.4
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/dist/{acp-adapter-1885dpr7.js → acp-adapter-thq04adh.js} +1 -2
- package/dist/{actions-pn76n32q.js → actions-j6ywh6x5.js} +17 -17
- package/dist/{amp-adapter-af84rx1d.js → amp-adapter-k8b5xmj8.js} +2 -2
- package/dist/{app-6sv8mhzm.js → app-rb7vc02s.js} +3 -3
- package/dist/{assistant-sc2tf825.js → assistant-ftta1zjf.js} +18 -18
- package/dist/{boot-reembed-y3m0ydzj.js → boot-reembed-srp6v6af.js} +1 -1
- package/dist/{boot-reembed-gvxjs2py.js → boot-reembed-xns7h5h9.js} +1 -1
- package/dist/{boot-scrub-sweep-1j9mk05f.js → boot-scrub-sweep-3qqrh2mz.js} +2 -2
- package/dist/{boot-seeds-37sp626b.js → boot-seeds-9ks8hce7.js} +4 -4
- package/dist/{claude-adapter-rp0jtnwr.js → claude-adapter-k5ksyres.js} +2 -2
- package/dist/{claude-managed-setup-rdt248k7.js → claude-managed-setup-n5a5eqks.js} +1 -1
- package/dist/{claude-sdk-session-53rcw5we.js → claude-sdk-session-2j3j4kwn.js} +2 -2
- package/dist/{cli-v87dr6e3.js → cli-1d70crsy.js} +1 -1
- package/dist/{cli-1ma335fz.js → cli-1ephgwnq.js} +2 -2
- package/dist/{cli-4yef8ghv.js → cli-2v045xyf.js} +3 -3
- package/dist/{cli-73ezfhha.js → cli-2y91wnbh.js} +10 -5
- package/dist/{cli-1eszr343.js → cli-2zqpkvtt.js} +1 -1
- package/dist/{cli-e4d78xsv.js → cli-37yb7307.js} +1 -1
- package/dist/{cli-8y99kfey.js → cli-3cgngyd8.js} +9 -9
- package/dist/{cli-wk28hzqn.js → cli-44159k28.js} +1 -1
- package/dist/{cli-qp2m4btv.js → cli-4dg55wsk.js} +2 -2
- package/dist/{cli-0dxxwfkt.js → cli-51y09c6p.js} +17 -14
- package/dist/{cli-aa3tb29r.js → cli-55qmbp6z.js} +1 -1
- package/dist/{cli-mzkwkbdy.js → cli-82d3nkc3.js} +1 -1
- package/dist/{cli-sds5zzs8.js → cli-9fhkkpj5.js} +78 -12
- package/dist/{cli-vq9gqarq.js → cli-amyhm3gj.js} +1 -1
- package/dist/{cli-bxrqvpg5.js → cli-b8k7epj1.js} +5 -5
- package/dist/{cli-8vverzf7.js → cli-ba8ne1es.js} +5 -0
- package/dist/{cli-58vwn100.js → cli-bnz0r3kc.js} +1 -1
- package/dist/{cli-6eyncg0y.js → cli-cynvc9fj.js} +34 -6
- package/dist/{cli-f0p0f6ja.js → cli-dqb4n629.js} +7 -7
- package/dist/{cli-pygdsgh8.js → cli-esxerzg7.js} +1 -1
- package/dist/{cli-dm8p2dpf.js → cli-gqvh8d8a.js} +6 -6
- package/dist/{cli-p0qerd2r.js → cli-hj3debgw.js} +1 -1
- package/dist/{cli-836ehxmt.js → cli-nc35pxb7.js} +2 -2
- package/dist/{cli-vcgscaan.js → cli-p20r8w42.js} +8 -6
- package/dist/{cli-1xsraagq.js → cli-pccfmtb5.js} +4 -4
- package/dist/{cli-s9rr4gr2.js → cli-q6r7t299.js} +1036 -298
- package/dist/{cli-tkf4x6ha.js → cli-qkhc5aqy.js} +11 -11
- package/dist/{cli-1n9gdg56.js → cli-t7cyef5g.js} +4 -4
- package/dist/{cli-038fga5k.js → cli-te6b5wvg.js} +148 -183
- package/dist/{cli-kgnkyabk.js → cli-v3yr2gbg.js} +109 -35
- package/dist/{cli-h0mgxann.js → cli-vdm20wej.js} +2 -2
- package/dist/{cli-zvbb4n0e.js → cli-vq97g1jq.js} +2 -2
- package/dist/{cli-gx0j2khs.js → cli-w6nadkh4.js} +3 -3
- package/dist/{cli-99jez72r.js → cli-w9etnnxk.js} +6 -1
- package/dist/{cli-npfe9t1s.js → cli-xc27fwzg.js} +2 -2
- package/dist/{cli-6aen3ezb.js → cli-xj0re5tm.js} +1 -1
- package/dist/{cli-aypbg9r2.js → cli-zrk6jxv8.js} +1 -1
- package/dist/{cli-1012xb7j.js → cli-zrwpctp4.js} +20 -20
- package/dist/cli.js +41 -16
- package/dist/{codex-adapter-2zrmab9b.js → codex-adapter-968xwfy7.js} +1 -1
- package/dist/{codex-session-runner-kbkz62vc.js → codex-session-runner-ht4z99fa.js} +1 -1
- package/dist/{commands-hd574th6.js → commands-pcymaeg9.js} +2 -2
- package/dist/{controller-pz5mcfg4.js → controller-fpjyk46r.js} +2 -2
- package/dist/{controllers-connect-5rgq2hfk.js → controllers-connect-mwzx1fh6.js} +18 -18
- package/dist/{cursor-adapter-mgaprq0j.js → cursor-adapter-cp4s2e2f.js} +1 -1
- package/dist/{db-h06dxp05.js → db-sgd12bt9.js} +3 -3
- package/dist/{db-6nf1207q.js → db-xsjg9jqq.js} +1 -1
- package/dist/{dispatcher-zthpy4m1.js → dispatcher-x0jfw2c9.js} +16 -16
- package/dist/{dsh-adapter-64dhw0j7.js → dsh-adapter-jrq7ma7h.js} +1 -1
- package/dist/{external-channel-x01t7ke6.js → external-channel-8njj4h3b.js} +2 -2
- package/dist/{grok-adapter-h21jv594.js → grok-adapter-kkr97v2f.js} +2 -3
- package/dist/handlers-0pvee9nw.js +39 -0
- package/dist/{heartbeat-zzfksckz.js → heartbeat-wd03zhn0.js} +19 -19
- package/dist/{hook-z4zhzj1f.js → hook-f0vghj42.js} +1 -1
- package/dist/{hook-3xfqhh2s.js → hook-xzj2ts2p.js} +1 -1
- package/dist/{http-q813tmya.js → http-4hwxv62p.js} +255 -117
- package/dist/{index-va3y63es.js → index-6srwvd5b.js} +1 -2
- package/dist/{index-220tk9bw.js → index-97q4xn03.js} +19 -10
- package/dist/{index-s98037v0.js → index-cbxkmfc5.js} +1 -2
- package/dist/{index-m0a4ydqj.js → index-gdh8jrdm.js} +16 -16
- package/dist/{index-wbbs14c5.js → index-hg0x2cdv.js} +16 -16
- package/dist/{index-d0g18bax.js → index-thm0y6a8.js} +19 -19
- package/dist/{index-x8xbyn1t.js → index-ww2vznkp.js} +1 -1
- package/dist/{keepalive-y5t1my7m.js → keepalive-hrh7xnr9.js} +3 -3
- package/dist/{lead-2xwtq66b.js → lead-ee8vazz2.js} +26 -27
- package/dist/{maintenance-ybfsaedv.js → maintenance-e8wnwnqx.js} +1 -1
- package/dist/{oauth-refresh-sweep-909jmn12.js → oauth-refresh-sweep-zm0yj4sj.js} +2 -2
- package/dist/{onboard-xn6fkkys.js → onboard-dtwcq603.js} +2 -2
- package/dist/{otel-impl-e6mn0z13.js → otel-impl-w3fbhpn0.js} +1 -1
- package/dist/{pricing-refresh-ygd362ep.js → pricing-refresh-j38dehfs.js} +3 -3
- package/dist/{rbac-roles-c9axvfnz.js → rbac-roles-ae8yttq4.js} +2 -2
- package/dist/{rbac-roles-mb470p4e.js → rbac-roles-ejxx9b35.js} +2 -2
- package/dist/{render-v2-g0306nh1.js → render-v2-n16n0phe.js} +3 -3
- package/dist/{resume-v315hnq6.js → resume-z0fgjc38.js} +5 -5
- package/dist/{session-access-2zg285a1.js → session-access-cv3mq6mc.js} +6 -4
- package/dist/{setup-a4mtf02k.js → setup-mafhj3yt.js} +1 -1
- package/dist/{work-objects-9fr0fgtn.js → work-objects-8b86mgjp.js} +19 -19
- package/dist/{worker-qae22jeb.js → worker-16s75hm5.js} +26 -27
- package/dist/worker-token-xf11yyds.js +136 -0
- package/openapi.json +295 -12
- package/package.json +4 -4
- package/src/apps/action-runner.ts +4 -0
- package/src/apps/catalog.generated.json +3 -0
- package/src/apps/page-validator.ts +8 -2
- package/src/be/migrations/232_agent_session_tokens.sql +41 -0
- package/src/be/seed-scripts/catalog/heartbeat-jev-gate.ts +10 -1
- package/src/be/swarm-config-guard.ts +16 -0
- package/src/be/users.ts +169 -12
- package/src/cli.tsx +24 -0
- package/src/commands/worker-token.ts +164 -0
- package/src/http/apps.ts +43 -5
- package/src/http/codex-oauth-keep-warm.ts +27 -9
- package/src/http/config.ts +5 -3
- package/src/http/core.ts +2 -0
- package/src/http/session-access.ts +51 -2
- package/src/http/sessions.ts +249 -10
- package/src/http/tasks.ts +4 -32
- package/src/providers/acp-adapter.ts +33 -13
- package/src/providers/acp-swarm-events.ts +1 -1
- package/src/providers/amp-adapter.ts +1 -1
- package/src/providers/codex-oauth/storage.ts +1 -1
- package/src/rbac/legacy-policy.ts +3 -1
- package/src/rbac/permissions.ts +4 -0
- package/src/tests/acp-adapter.test.ts +10 -23
- package/src/tests/acp-fake-agent.ts +36 -0
- package/src/tests/acp-swarm-events.test.ts +1 -1
- package/src/tests/agent-session-tokens.test.ts +452 -0
- package/src/tests/apps-action-parent-task.test.ts +295 -0
- package/src/tests/codex-oauth-keep-warm.test.ts +85 -1
- package/src/tests/config-session-auth.test.ts +14 -0
- package/src/tests/docs-index-footprint.test.js +2 -2
- package/src/tests/grok-adapter.test.ts +3 -3
- package/src/tests/heartbeat-jev-gate.test.ts +23 -0
- package/src/tests/migration-232-agent-session-tokens.test.ts +67 -0
- package/src/tests/rbac-engine.test.ts +1 -0
- package/src/tests/runtime-child-token-cleanup.test.ts +3 -3
- package/src/tests/script-connections.test.ts +2 -0
- package/src/tests/session-route-posture.test.ts +2 -0
- package/src/tests/worker-token-cli.test.ts +76 -0
- package/src/tests/workflow-executors.test.ts +4 -2
- package/src/tests/workflow-system-one-extraction.test.ts +805 -0
- package/src/types.ts +2 -1
- package/src/utils/request-auth-context.ts +2 -1
- package/src/workflows/definition.ts +7 -1
- package/src/workflows/engine.ts +18 -12
- package/src/workflows/executors/index.ts +1 -0
- package/src/workflows/executors/registry.ts +2 -0
- package/src/workflows/executors/system-one-decision.ts +56 -255
- package/src/workflows/executors/system-one-extract-wire.ts +57 -0
- package/src/workflows/executors/system-one-extraction.ts +830 -0
- package/src/workflows/executors/system-one-providers.ts +277 -110
- package/src/workflows/executors/system-one-transport.ts +264 -0
- package/templates/official/lead/HEARTBEAT.md +1 -1
- package/templates/skills/apps/SKILL.md +3 -1
- package/templates/skills/apps/content.md +3 -1
- package/templates/skills/heartbeat-runbook/content.md +3 -3
- package/dist/cli-v1sjhn52.js +0 -7
- package/dist/handlers-2qjw434w.js +0 -39
|
@@ -3,7 +3,7 @@ import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
|
|
3
3
|
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
-
import type { Usage } from "@agentclientprotocol/sdk";
|
|
6
|
+
import type { Usage } from "@agentclientprotocol/sdk/unstable";
|
|
7
7
|
import { normalizeSessionLogs } from "../../apps/ui/src/logs-parser";
|
|
8
8
|
import {
|
|
9
9
|
closeDb,
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
resolveAcpTarget,
|
|
30
30
|
} from "../providers/acp-targets";
|
|
31
31
|
import type { ProviderEvent, ProviderSessionConfig } from "../providers/types";
|
|
32
|
+
import { ACP_SDK_PATH, serveFakeAcpAgent } from "./acp-fake-agent";
|
|
32
33
|
import { listenOnFreePort } from "./test-net";
|
|
33
34
|
|
|
34
35
|
const tmpDirs: string[] = [];
|
|
@@ -148,16 +149,11 @@ describe("ACPAdapter", () => {
|
|
|
148
149
|
}) => {
|
|
149
150
|
const cwd = makeTempDir();
|
|
150
151
|
const agentPath = join(cwd, "fake-acp-agent.ts");
|
|
151
|
-
const sdkPath = join(process.cwd(), "node_modules/@agentclientprotocol/sdk/dist/acp.js");
|
|
152
152
|
await Bun.write(
|
|
153
153
|
agentPath,
|
|
154
154
|
`
|
|
155
155
|
import { Readable, Writable } from "node:stream";
|
|
156
|
-
import {
|
|
157
|
-
AgentSideConnection,
|
|
158
|
-
PROTOCOL_VERSION,
|
|
159
|
-
ndJsonStream,
|
|
160
|
-
} from "${sdkPath}";
|
|
156
|
+
import { PROTOCOL_VERSION, agent, methods, ndJsonStream } from "${ACP_SDK_PATH}";
|
|
161
157
|
|
|
162
158
|
class FakeAgent {
|
|
163
159
|
constructor(connection) {
|
|
@@ -313,10 +309,7 @@ class FakeAgent {
|
|
|
313
309
|
async cancel() {}
|
|
314
310
|
}
|
|
315
311
|
|
|
316
|
-
|
|
317
|
-
const output = Readable.toWeb(process.stdin);
|
|
318
|
-
const stream = ndJsonStream(input, output);
|
|
319
|
-
new AgentSideConnection((connection) => new FakeAgent(connection), stream);
|
|
312
|
+
${serveFakeAcpAgent("FakeAgent")}
|
|
320
313
|
`,
|
|
321
314
|
);
|
|
322
315
|
|
|
@@ -952,7 +945,6 @@ new AgentSideConnection((connection) => new FakeAgent(connection), stream);
|
|
|
952
945
|
const binDir = makeTempDir();
|
|
953
946
|
const captureFile = join(binDir, "capture.json");
|
|
954
947
|
const agentPath = join(binDir, "fake-copilot-acp.ts");
|
|
955
|
-
const sdkPath = join(process.cwd(), "node_modules/@agentclientprotocol/sdk/dist/acp.js");
|
|
956
948
|
const repoAgentsMd = "# Repo rules\nKeep this file as is.\n";
|
|
957
949
|
await Bun.write(join(cwd, "AGENTS.md"), repoAgentsMd);
|
|
958
950
|
await Bun.write(
|
|
@@ -961,7 +953,7 @@ new AgentSideConnection((connection) => new FakeAgent(connection), stream);
|
|
|
961
953
|
import { readFileSync } from "node:fs";
|
|
962
954
|
import { join } from "node:path";
|
|
963
955
|
import { Readable, Writable } from "node:stream";
|
|
964
|
-
import {
|
|
956
|
+
import { PROTOCOL_VERSION, agent, methods, ndJsonStream } from "${ACP_SDK_PATH}";
|
|
965
957
|
class FakeCopilot {
|
|
966
958
|
constructor(connection) { this.connection = connection; }
|
|
967
959
|
async initialize() {
|
|
@@ -986,8 +978,7 @@ class FakeCopilot {
|
|
|
986
978
|
}
|
|
987
979
|
async cancel() {}
|
|
988
980
|
}
|
|
989
|
-
|
|
990
|
-
new AgentSideConnection((connection) => new FakeCopilot(connection), stream);
|
|
981
|
+
${serveFakeAcpAgent("FakeCopilot")}
|
|
991
982
|
`,
|
|
992
983
|
);
|
|
993
984
|
const copilotStub = join(binDir, "copilot");
|
|
@@ -1191,7 +1182,6 @@ new AgentSideConnection((connection) => new FakeCopilot(connection), stream);
|
|
|
1191
1182
|
test("completes before the revoke response and logs a failed revocation", async () => {
|
|
1192
1183
|
const cwd = makeTempDir();
|
|
1193
1184
|
const agentPath = join(cwd, "fake-acp-ephem-agent.ts");
|
|
1194
|
-
const sdkPath = join(process.cwd(), "node_modules/@agentclientprotocol/sdk/dist/acp.js");
|
|
1195
1185
|
|
|
1196
1186
|
const captureFile = join(cwd, "captured-auth.txt");
|
|
1197
1187
|
|
|
@@ -1199,7 +1189,7 @@ new AgentSideConnection((connection) => new FakeCopilot(connection), stream);
|
|
|
1199
1189
|
agentPath,
|
|
1200
1190
|
`
|
|
1201
1191
|
import { Readable, Writable } from "node:stream";
|
|
1202
|
-
import {
|
|
1192
|
+
import { PROTOCOL_VERSION, agent, methods, ndJsonStream } from "${ACP_SDK_PATH}";
|
|
1203
1193
|
class FakeAgent {
|
|
1204
1194
|
constructor(connection) { this.connection = connection; }
|
|
1205
1195
|
async initialize() {
|
|
@@ -1223,8 +1213,7 @@ class FakeAgent {
|
|
|
1223
1213
|
}
|
|
1224
1214
|
async cancel() {}
|
|
1225
1215
|
}
|
|
1226
|
-
|
|
1227
|
-
new AgentSideConnection((connection) => new FakeAgent(connection), stream);
|
|
1216
|
+
${serveFakeAcpAgent("FakeAgent")}
|
|
1228
1217
|
`,
|
|
1229
1218
|
);
|
|
1230
1219
|
|
|
@@ -1361,12 +1350,11 @@ new AgentSideConnection((connection) => new FakeAgent(connection), stream);
|
|
|
1361
1350
|
])("$name", async ({ costs, totalCostUsd, costSource }) => {
|
|
1362
1351
|
const cwd = makeTempDir();
|
|
1363
1352
|
const agentPath = join(cwd, "fake-acp-cost-agent.ts");
|
|
1364
|
-
const sdkPath = join(process.cwd(), "node_modules/@agentclientprotocol/sdk/dist/acp.js");
|
|
1365
1353
|
await Bun.write(
|
|
1366
1354
|
agentPath,
|
|
1367
1355
|
`
|
|
1368
1356
|
import { Readable, Writable } from "node:stream";
|
|
1369
|
-
import {
|
|
1357
|
+
import { PROTOCOL_VERSION, agent, methods, ndJsonStream } from "${ACP_SDK_PATH}";
|
|
1370
1358
|
class FakeAgent {
|
|
1371
1359
|
constructor(connection) { this.connection = connection; }
|
|
1372
1360
|
async initialize() {
|
|
@@ -1384,8 +1372,7 @@ class FakeAgent {
|
|
|
1384
1372
|
}
|
|
1385
1373
|
async cancel() {}
|
|
1386
1374
|
}
|
|
1387
|
-
|
|
1388
|
-
new AgentSideConnection((connection) => new FakeAgent(connection), stream);
|
|
1375
|
+
${serveFakeAcpAgent("FakeAgent")}
|
|
1389
1376
|
`,
|
|
1390
1377
|
);
|
|
1391
1378
|
const { close: closeTokenStub, apiUrl } = await startTokenStubServer(
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
|
|
3
|
+
// Fake ACP targets run from temp dirs, so they import the SDK by path. The
|
|
4
|
+
// unstable v1 entry point lets them return turn `usage` like OpenCode does.
|
|
5
|
+
export const ACP_SDK_PATH = join(
|
|
6
|
+
process.cwd(),
|
|
7
|
+
"node_modules/@agentclientprotocol/sdk/dist/v1/unstable/acp.js",
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Script tail that serves a fake agent class over stdio with the SDK's agent
|
|
12
|
+
* app API. The class implements the ACP agent methods and gets
|
|
13
|
+
* `{ sessionUpdate }` to notify the client. The script must import `agent`,
|
|
14
|
+
* `methods`, and `ndJsonStream` from ACP_SDK_PATH, and `Readable`/`Writable`
|
|
15
|
+
* from node:stream.
|
|
16
|
+
*/
|
|
17
|
+
export function serveFakeAcpAgent(className: string): string {
|
|
18
|
+
return `
|
|
19
|
+
const fake = new ${className}({
|
|
20
|
+
sessionUpdate: (params) => connection.client.notify(methods.client.session.update, params),
|
|
21
|
+
});
|
|
22
|
+
const app = agent()
|
|
23
|
+
.onRequest(methods.agent.initialize, ({ params }) => fake.initialize(params))
|
|
24
|
+
.onRequest(methods.agent.session.new, ({ params }) => fake.newSession(params))
|
|
25
|
+
.onRequest(methods.agent.session.prompt, ({ params }) => fake.prompt(params))
|
|
26
|
+
.onNotification(methods.agent.session.cancel, ({ params }) => fake.cancel(params));
|
|
27
|
+
if (fake.setSessionConfigOption) {
|
|
28
|
+
app.onRequest(methods.agent.session.setConfigOption, ({ params }) =>
|
|
29
|
+
fake.setSessionConfigOption(params),
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
const connection = app.connect(
|
|
33
|
+
ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin)),
|
|
34
|
+
);
|
|
35
|
+
`;
|
|
36
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import type { SessionNotification } from "@agentclientprotocol/sdk";
|
|
2
|
+
import type { SessionNotification } from "@agentclientprotocol/sdk/unstable";
|
|
3
3
|
import { acpReportedCostUsd, translateAcpSessionNotification } from "../providers/acp-swarm-events";
|
|
4
4
|
|
|
5
5
|
function notification(update: SessionNotification["update"]): SessionNotification {
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
4
|
+
import {
|
|
5
|
+
closeDb,
|
|
6
|
+
createAgent,
|
|
7
|
+
createTaskExtended,
|
|
8
|
+
createUser,
|
|
9
|
+
getAgentById,
|
|
10
|
+
getDbClient,
|
|
11
|
+
initDb,
|
|
12
|
+
upsertSwarmConfig,
|
|
13
|
+
} from "../be/db";
|
|
14
|
+
import { attachRole } from "../be/rbac-roles";
|
|
15
|
+
import { createRuntimeController } from "../be/runtime-pools/db";
|
|
16
|
+
import { mintControllerToken } from "../be/runtime-pools/tokens";
|
|
17
|
+
import {
|
|
18
|
+
AGENT_TOKEN_NO_EXPIRY,
|
|
19
|
+
type IdentityActor,
|
|
20
|
+
mintAgentToken,
|
|
21
|
+
mintRuntimeToken,
|
|
22
|
+
mintSessionToken,
|
|
23
|
+
mintToken,
|
|
24
|
+
resolveBySessionToken,
|
|
25
|
+
resolveSessionToken,
|
|
26
|
+
revokeAgentTokens,
|
|
27
|
+
} from "../be/users";
|
|
28
|
+
import { handleAgentRegister, handleAgentsRest } from "../http/agents";
|
|
29
|
+
import { handleConfig } from "../http/config";
|
|
30
|
+
import { handleCore } from "../http/core";
|
|
31
|
+
import { handlePoll } from "../http/poll";
|
|
32
|
+
import { handleSessions } from "../http/sessions";
|
|
33
|
+
import { getPathSegments, parseQueryParams } from "../http/utils";
|
|
34
|
+
import { scrubSecrets } from "../utils/secret-scrubber";
|
|
35
|
+
import { registerSessionTokenConnection } from "../utils/session-token-connections";
|
|
36
|
+
import { listenOnFreePort } from "./test-net";
|
|
37
|
+
|
|
38
|
+
// Synthetic credentials, built at runtime so no literal looks like a real secret.
|
|
39
|
+
const key = `agent-token-test-${randomBytes(8).toString("hex")}`;
|
|
40
|
+
const synthetic = (label: string) => `synthetic-${label}-${randomBytes(12).toString("hex")}`;
|
|
41
|
+
const USER_ACTOR: IdentityActor = { kind: "operator", id: "agent-session-tokens-test" };
|
|
42
|
+
let server: Server;
|
|
43
|
+
let baseUrl: string;
|
|
44
|
+
let workerId: string;
|
|
45
|
+
let otherId: string;
|
|
46
|
+
let leadId: string;
|
|
47
|
+
let ownTaskId: string;
|
|
48
|
+
let otherTaskId: string;
|
|
49
|
+
|
|
50
|
+
async function request(
|
|
51
|
+
path: string,
|
|
52
|
+
bearer: string,
|
|
53
|
+
options: { method?: string; body?: unknown; headers?: Record<string, string> } = {},
|
|
54
|
+
) {
|
|
55
|
+
const res = await fetch(`${baseUrl}${path}`, {
|
|
56
|
+
method: options.method ?? (options.body === undefined ? "GET" : "POST"),
|
|
57
|
+
headers: {
|
|
58
|
+
Authorization: `Bearer ${bearer}`,
|
|
59
|
+
"Content-Type": "application/json",
|
|
60
|
+
...options.headers,
|
|
61
|
+
},
|
|
62
|
+
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
|
63
|
+
});
|
|
64
|
+
const text = await res.text();
|
|
65
|
+
let body: any = text;
|
|
66
|
+
try {
|
|
67
|
+
body = JSON.parse(text);
|
|
68
|
+
} catch {}
|
|
69
|
+
return { status: res.status, text, body };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function dispatch(req: IncomingMessage, res: ServerResponse) {
|
|
73
|
+
if (await handleCore(req, res, req.headers["x-agent-id"] as string | undefined, key)) return;
|
|
74
|
+
const segments = getPathSegments(req.url ?? "");
|
|
75
|
+
const query = parseQueryParams(req.url ?? "");
|
|
76
|
+
const id = req.headers["x-agent-id"] as string | undefined;
|
|
77
|
+
if (await handleAgentRegister(req, res, segments, id)) return;
|
|
78
|
+
if (await handlePoll(req, res, segments, query, id)) return;
|
|
79
|
+
if (await handleAgentsRest(req, res, segments, query, id)) return;
|
|
80
|
+
if (await handleSessions(req, res, segments, query)) return;
|
|
81
|
+
if (await handleConfig(req, res, segments, query)) return;
|
|
82
|
+
res.writeHead(404).end();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
beforeAll(async () => {
|
|
86
|
+
initDb(":memory:");
|
|
87
|
+
workerId = (await createAgent({ name: "token-worker", isLead: false, status: "idle" })).id;
|
|
88
|
+
otherId = (await createAgent({ name: "token-other", isLead: false, status: "idle" })).id;
|
|
89
|
+
leadId = (await createAgent({ name: "token-lead", isLead: true, status: "idle" })).id;
|
|
90
|
+
ownTaskId = (await createTaskExtended("Own task", { agentId: workerId, source: "system" })).id;
|
|
91
|
+
otherTaskId = (await createTaskExtended("Other task", { agentId: otherId, source: "system" })).id;
|
|
92
|
+
server = createServer((req, res) => {
|
|
93
|
+
void dispatch(req, res).catch(() => res.writeHead(500).end());
|
|
94
|
+
});
|
|
95
|
+
baseUrl = `http://127.0.0.1:${await listenOnFreePort(server)}`;
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
afterAll(async () => {
|
|
99
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
100
|
+
closeDb();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("worker token management routes", () => {
|
|
104
|
+
test("operator mints a non-expiring agent token, once per agent", async () => {
|
|
105
|
+
const newId = crypto.randomUUID();
|
|
106
|
+
expect((await request(`/api/agents/${newId}/token`, key, { body: {} })).status).toBe(404);
|
|
107
|
+
|
|
108
|
+
const minted = await request(`/api/agents/${newId}/token`, key, {
|
|
109
|
+
body: { name: "minted-worker" },
|
|
110
|
+
});
|
|
111
|
+
expect(minted.status).toBe(200);
|
|
112
|
+
expect(minted.body).toMatchObject({ agentId: newId, expiresAt: null, agentCreated: true });
|
|
113
|
+
expect(await getAgentById(newId)).toMatchObject({
|
|
114
|
+
name: "minted-worker",
|
|
115
|
+
isLead: false,
|
|
116
|
+
status: "offline",
|
|
117
|
+
});
|
|
118
|
+
expect(await resolveBySessionToken(minted.body.plaintext)).toMatchObject({
|
|
119
|
+
tokenId: minted.body.tokenId,
|
|
120
|
+
tokenKind: "agent",
|
|
121
|
+
agentId: newId,
|
|
122
|
+
taskId: null,
|
|
123
|
+
runtimeId: null,
|
|
124
|
+
expiresAt: AGENT_TOKEN_NO_EXPIRY,
|
|
125
|
+
});
|
|
126
|
+
// The plaintext is registered with the scrubber at mint time.
|
|
127
|
+
expect(scrubSecrets(`token=${minted.body.plaintext}`)).not.toContain(minted.body.plaintext);
|
|
128
|
+
|
|
129
|
+
const again = await request(`/api/agents/${newId}/token`, key, { body: {} });
|
|
130
|
+
expect(again.status).toBe(409);
|
|
131
|
+
|
|
132
|
+
// The stored expiry is exact on every mint, so the active-token lookup
|
|
133
|
+
// (a string comparison) always finds the token the resolver accepts.
|
|
134
|
+
for (let i = 0; i < 25; i++) {
|
|
135
|
+
await revokeAgentTokens(newId);
|
|
136
|
+
const token = await mintAgentToken(newId);
|
|
137
|
+
expect((await resolveBySessionToken(token.plaintext))?.expiresAt).toBe(AGENT_TOKEN_NO_EXPIRY);
|
|
138
|
+
}
|
|
139
|
+
expect(await revokeAgentTokens(newId)).toBe(1);
|
|
140
|
+
|
|
141
|
+
const ttl = await request(`/api/agents/${otherId}/token`, key, { body: { ttlMs: 60_000 } });
|
|
142
|
+
expect(ttl.status).toBe(200);
|
|
143
|
+
expect(ttl.body.agentCreated).toBe(false);
|
|
144
|
+
expect(Date.parse(ttl.body.expiresAt)).toBeLessThanOrEqual(Date.now() + 60_000);
|
|
145
|
+
expect((await request(`/api/agents/${otherId}/token`, key, { method: "DELETE" })).body).toEqual(
|
|
146
|
+
{ revoked: 1 },
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("only an operator key without X-Agent-ID or a user manages worker tokens", async () => {
|
|
151
|
+
const own = await mintAgentToken(leadId);
|
|
152
|
+
const lead = await mintRuntimeToken(leadId, crypto.randomUUID(), 60_000);
|
|
153
|
+
const task = await mintSessionToken(workerId, ownTaskId, 60_000);
|
|
154
|
+
const path = `/api/agents/${workerId}/token`;
|
|
155
|
+
// Agent-first: the operator key acting as an agent, even the lead, is that agent.
|
|
156
|
+
for (const agentId of [workerId, leadId]) {
|
|
157
|
+
const res = await request(path, key, { body: {}, headers: { "X-Agent-ID": agentId } });
|
|
158
|
+
expect(res.status).toBe(403);
|
|
159
|
+
}
|
|
160
|
+
for (const bearer of [own.plaintext, lead.plaintext, task.plaintext]) {
|
|
161
|
+
for (const [method, suffix] of [
|
|
162
|
+
["POST", ""],
|
|
163
|
+
["POST", "/rotate"],
|
|
164
|
+
["DELETE", ""],
|
|
165
|
+
] as const) {
|
|
166
|
+
const res = await request(`${path}${suffix}`, bearer, {
|
|
167
|
+
method,
|
|
168
|
+
body: method === "POST" ? {} : undefined,
|
|
169
|
+
});
|
|
170
|
+
expect(res.status).toBe(403);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
expect((await request(`/api/agents/${leadId}/token`, key, { method: "DELETE" })).body).toEqual({
|
|
174
|
+
revoked: 1,
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("rotation keeps the old token for the grace window, then it expires", async () => {
|
|
179
|
+
expect((await request(`/api/agents/${workerId}/token/rotate`, key, { body: {} })).status).toBe(
|
|
180
|
+
404,
|
|
181
|
+
);
|
|
182
|
+
const first = await mintAgentToken(workerId);
|
|
183
|
+
const firstChild = await mintSessionToken(workerId, ownTaskId, 7 * 86_400_000, {
|
|
184
|
+
tokenId: first.tokenId,
|
|
185
|
+
runtimeId: null,
|
|
186
|
+
});
|
|
187
|
+
const rotated = await request(`/api/agents/${workerId}/token/rotate`, key, {
|
|
188
|
+
body: { graceSec: 60 },
|
|
189
|
+
});
|
|
190
|
+
expect(rotated.status).toBe(200);
|
|
191
|
+
expect(rotated.body.expiresAt).toBeNull();
|
|
192
|
+
expect(Date.parse(rotated.body.previousExpiresAt)).toBeLessThanOrEqual(Date.now() + 60_000);
|
|
193
|
+
expect((await request("/api/poll", first.plaintext)).status).toBe(200);
|
|
194
|
+
expect((await request("/api/poll", rotated.body.plaintext)).status).toBe(200);
|
|
195
|
+
// A child of the rotated token expires with it, not at its own longer TTL.
|
|
196
|
+
expect((await resolveBySessionToken(firstChild.plaintext))?.expiresAt).toBe(
|
|
197
|
+
rotated.body.previousExpiresAt,
|
|
198
|
+
);
|
|
199
|
+
const rotatedChild = await mintSessionToken(workerId, ownTaskId, 7 * 86_400_000, {
|
|
200
|
+
tokenId: rotated.body.tokenId,
|
|
201
|
+
runtimeId: null,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const again = await request(`/api/agents/${workerId}/token/rotate`, key, {
|
|
205
|
+
body: { graceSec: 0 },
|
|
206
|
+
});
|
|
207
|
+
expect(again.status).toBe(200);
|
|
208
|
+
expect((await request("/api/poll", rotated.body.plaintext)).status).toBe(401);
|
|
209
|
+
expect((await request("/api/poll", rotatedChild.plaintext)).status).toBe(401);
|
|
210
|
+
expect((await request("/api/poll", again.body.plaintext)).status).toBe(200);
|
|
211
|
+
expect(
|
|
212
|
+
(await request(`/api/agents/${workerId}/token`, key, { method: "DELETE" })).body,
|
|
213
|
+
).toEqual({ revoked: 1 });
|
|
214
|
+
expect((await request("/api/poll", again.body.plaintext)).status).toBe(401);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("a user without a grant-all role needs the agent's privileges and never mints a lead's", async () => {
|
|
218
|
+
const roleId = `token-manager-${crypto.randomUUID()}`;
|
|
219
|
+
await getDbClient().run(
|
|
220
|
+
"INSERT INTO roles (id, name, description, isBuiltin, grantsAll) VALUES (?, ?, '', 0, 0)",
|
|
221
|
+
[roleId, roleId],
|
|
222
|
+
);
|
|
223
|
+
const grant = (verb: string) =>
|
|
224
|
+
getDbClient().run("INSERT INTO role_permissions (roleId, verb) VALUES (?, ?)", [
|
|
225
|
+
roleId,
|
|
226
|
+
verb,
|
|
227
|
+
]);
|
|
228
|
+
await grant("agent.token.manage");
|
|
229
|
+
const limited = await createUser({ name: "Token manager" });
|
|
230
|
+
// New users start as admin (rbac-roles insert trigger); narrow this one.
|
|
231
|
+
await getDbClient().run("DELETE FROM principal_roles WHERE principalId = ?", [limited.id]);
|
|
232
|
+
await getDbClient().run(
|
|
233
|
+
"INSERT INTO principal_roles (principalType, principalId, roleId) VALUES ('user', ?, ?)",
|
|
234
|
+
[limited.id, roleId],
|
|
235
|
+
);
|
|
236
|
+
const limitedToken = (await mintToken(limited.id, "limited", USER_ACTOR)).plaintext;
|
|
237
|
+
const admin = await createUser({ name: "Token admin" });
|
|
238
|
+
await attachRole(admin.id, "admin");
|
|
239
|
+
const adminToken = (await mintToken(admin.id, "admin", USER_ACTOR)).plaintext;
|
|
240
|
+
|
|
241
|
+
// agent.token.manage alone would hand out the agent's privileges and secret reads.
|
|
242
|
+
for (const agentId of [workerId, leadId]) {
|
|
243
|
+
const res = await request(`/api/agents/${agentId}/token`, limitedToken, { body: {} });
|
|
244
|
+
expect(res.status).toBe(403);
|
|
245
|
+
}
|
|
246
|
+
await grant("agent.act.any");
|
|
247
|
+
await grant("config.read.secrets");
|
|
248
|
+
const minted = await request(`/api/agents/${workerId}/token`, limitedToken, { body: {} });
|
|
249
|
+
expect(minted.status).toBe(200);
|
|
250
|
+
expect(minted.body.agentId).toBe(workerId);
|
|
251
|
+
for (const [method, suffix] of [
|
|
252
|
+
["POST", ""],
|
|
253
|
+
["POST", "/rotate"],
|
|
254
|
+
["DELETE", ""],
|
|
255
|
+
] as const) {
|
|
256
|
+
const res = await request(`/api/agents/${leadId}/token${suffix}`, limitedToken, {
|
|
257
|
+
method,
|
|
258
|
+
body: method === "POST" ? {} : undefined,
|
|
259
|
+
});
|
|
260
|
+
expect(res.status).toBe(403);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const lead = await request(`/api/agents/${leadId}/token`, adminToken, { body: {} });
|
|
264
|
+
expect(lead.status).toBe(200);
|
|
265
|
+
for (const agentId of [workerId, leadId]) {
|
|
266
|
+
expect(
|
|
267
|
+
(await request(`/api/agents/${agentId}/token`, adminToken, { method: "DELETE" })).body,
|
|
268
|
+
).toEqual({ revoked: 1 });
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
describe("worker token as an agent principal", () => {
|
|
274
|
+
test("binds X-Agent-ID and X-Source-Task-Id to the token agent", async () => {
|
|
275
|
+
const token = await mintAgentToken(workerId);
|
|
276
|
+
try {
|
|
277
|
+
expect((await request("/api/poll", token.plaintext)).status).toBe(200);
|
|
278
|
+
const spoofed = await request("/api/poll", token.plaintext, {
|
|
279
|
+
headers: { "X-Agent-ID": otherId },
|
|
280
|
+
});
|
|
281
|
+
expect(spoofed.status).toBe(403);
|
|
282
|
+
const foreignTask = await request("/api/config/resolved", token.plaintext, {
|
|
283
|
+
headers: { "X-Source-Task-Id": otherTaskId },
|
|
284
|
+
});
|
|
285
|
+
expect(foreignTask.status).toBe(403);
|
|
286
|
+
const ownTask = await request("/api/config/resolved", token.plaintext, {
|
|
287
|
+
headers: { "X-Source-Task-Id": ownTaskId },
|
|
288
|
+
});
|
|
289
|
+
expect(ownTask.status).toBe(200);
|
|
290
|
+
// Registration accepts the token for its own agent and cannot promote it.
|
|
291
|
+
const registered = await request("/api/agents", token.plaintext, {
|
|
292
|
+
body: { name: "token-worker", isLead: true },
|
|
293
|
+
});
|
|
294
|
+
expect(registered.status).toBeLessThan(300);
|
|
295
|
+
expect((await getAgentById(workerId))?.isLead).toBe(false);
|
|
296
|
+
} finally {
|
|
297
|
+
await request(`/api/agents/${workerId}/token`, key, { method: "DELETE" });
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("mints and revokes child task tokens for its own assigned task only", async () => {
|
|
302
|
+
const token = await mintAgentToken(workerId);
|
|
303
|
+
const child = await request("/api/sessions/tokens", token.plaintext, {
|
|
304
|
+
body: { agentId: workerId, taskId: ownTaskId, ttlMs: 60_000 },
|
|
305
|
+
});
|
|
306
|
+
expect(child.status).toBe(200);
|
|
307
|
+
expect(await resolveBySessionToken(child.body.plaintext)).toMatchObject({
|
|
308
|
+
tokenKind: "task",
|
|
309
|
+
taskId: ownTaskId,
|
|
310
|
+
});
|
|
311
|
+
for (const body of [
|
|
312
|
+
{ agentId: workerId, taskId: otherTaskId, ttlMs: 60_000 },
|
|
313
|
+
{ agentId: otherId, taskId: otherTaskId, ttlMs: 60_000 },
|
|
314
|
+
{ agentId: workerId, runtimeId: crypto.randomUUID(), ttlMs: 60_000 },
|
|
315
|
+
]) {
|
|
316
|
+
expect((await request("/api/sessions/tokens", token.plaintext, { body })).status).toBe(403);
|
|
317
|
+
}
|
|
318
|
+
// A child task token cannot mint further tokens.
|
|
319
|
+
const grandchild = await request("/api/sessions/tokens", child.body.plaintext, {
|
|
320
|
+
body: { agentId: workerId, taskId: ownTaskId, ttlMs: 60_000 },
|
|
321
|
+
});
|
|
322
|
+
expect(grandchild.status).toBe(401);
|
|
323
|
+
|
|
324
|
+
const foreign = await mintSessionToken(workerId, ownTaskId, 60_000);
|
|
325
|
+
const revokeForeign = await request(
|
|
326
|
+
`/api/sessions/tokens/${foreign.tokenId}`,
|
|
327
|
+
token.plaintext,
|
|
328
|
+
{ method: "DELETE" },
|
|
329
|
+
);
|
|
330
|
+
expect(revokeForeign.status).toBe(403);
|
|
331
|
+
const revokeOwn = await request(`/api/sessions/tokens/${child.body.tokenId}`, token.plaintext, {
|
|
332
|
+
method: "DELETE",
|
|
333
|
+
});
|
|
334
|
+
expect(revokeOwn.status).toBe(204);
|
|
335
|
+
expect(await resolveBySessionToken(child.body.plaintext)).toBeNull();
|
|
336
|
+
await request(`/api/agents/${workerId}/token`, key, { method: "DELETE" });
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("revoking the agent token revokes its children and closes their connections", async () => {
|
|
340
|
+
for (const revoke of ["agent-route", "session-route"] as const) {
|
|
341
|
+
const token = await mintAgentToken(workerId);
|
|
342
|
+
const child = await request("/api/sessions/tokens", token.plaintext, {
|
|
343
|
+
body: { agentId: workerId, taskId: ownTaskId, ttlMs: 60_000 },
|
|
344
|
+
});
|
|
345
|
+
expect(child.status).toBe(200);
|
|
346
|
+
const closed: string[] = [];
|
|
347
|
+
const unregister = [token.tokenId, child.body.tokenId].map((id) =>
|
|
348
|
+
registerSessionTokenConnection(id, () => closed.push(id)),
|
|
349
|
+
);
|
|
350
|
+
try {
|
|
351
|
+
const res =
|
|
352
|
+
revoke === "agent-route"
|
|
353
|
+
? await request(`/api/agents/${workerId}/token`, key, { method: "DELETE" })
|
|
354
|
+
: await request(`/api/sessions/tokens/${token.tokenId}`, key, { method: "DELETE" });
|
|
355
|
+
expect(res.status).toBeLessThan(300);
|
|
356
|
+
expect((await request("/api/poll", token.plaintext)).status).toBe(401);
|
|
357
|
+
expect((await request("/api/poll", child.body.plaintext)).status).toBe(401);
|
|
358
|
+
expect(closed.sort()).toEqual([token.tokenId, child.body.tokenId].sort());
|
|
359
|
+
} finally {
|
|
360
|
+
for (const fn of unregister) fn();
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
describe("secret read scope by session token kind", () => {
|
|
367
|
+
const globalSecret = synthetic("global");
|
|
368
|
+
const ownSecret = synthetic("own");
|
|
369
|
+
const otherSecret = synthetic("other");
|
|
370
|
+
|
|
371
|
+
beforeAll(async () => {
|
|
372
|
+
await upsertSwarmConfig({
|
|
373
|
+
scope: "global",
|
|
374
|
+
key: "AGENT_TOKEN_TEST_GLOBAL",
|
|
375
|
+
value: globalSecret,
|
|
376
|
+
isSecret: true,
|
|
377
|
+
});
|
|
378
|
+
for (const [scopeId, value] of [
|
|
379
|
+
[workerId, ownSecret],
|
|
380
|
+
[otherId, otherSecret],
|
|
381
|
+
] as const) {
|
|
382
|
+
await upsertSwarmConfig({
|
|
383
|
+
scope: "agent",
|
|
384
|
+
scopeId,
|
|
385
|
+
key: "AGENT_TOKEN_TEST_AGENT",
|
|
386
|
+
value,
|
|
387
|
+
isSecret: true,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
const resolved = (bearer: string, agentId?: string) =>
|
|
393
|
+
request(
|
|
394
|
+
`/api/config/resolved?includeSecrets=true${agentId ? `&agentId=${agentId}` : ""}`,
|
|
395
|
+
bearer,
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
test("agent and runtime tokens read their own scope, defaulting agentId to the token", async () => {
|
|
399
|
+
const agent = await mintAgentToken(workerId);
|
|
400
|
+
const runtime = await mintRuntimeToken(workerId, crypto.randomUUID(), 60_000);
|
|
401
|
+
try {
|
|
402
|
+
for (const bearer of [agent.plaintext, runtime.plaintext]) {
|
|
403
|
+
for (const agentId of [undefined, workerId]) {
|
|
404
|
+
const res = await resolved(bearer, agentId);
|
|
405
|
+
expect(res.status).toBe(200);
|
|
406
|
+
expect(res.text).toContain(globalSecret);
|
|
407
|
+
expect(res.text).toContain(ownSecret);
|
|
408
|
+
expect(res.text).not.toContain(otherSecret);
|
|
409
|
+
}
|
|
410
|
+
const foreign = await resolved(bearer, otherId);
|
|
411
|
+
expect(foreign.status).toBe(200);
|
|
412
|
+
expect(foreign.text).not.toContain(otherSecret);
|
|
413
|
+
expect(foreign.text).not.toContain(globalSecret);
|
|
414
|
+
}
|
|
415
|
+
} finally {
|
|
416
|
+
await request(`/api/agents/${workerId}/token`, key, { method: "DELETE" });
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("task tokens read none, controller tokens are refused, the operator reads all", async () => {
|
|
421
|
+
const task = await mintSessionToken(workerId, ownTaskId, 60_000);
|
|
422
|
+
for (const agentId of [undefined, workerId]) {
|
|
423
|
+
const res = await resolved(task.plaintext, agentId);
|
|
424
|
+
expect(res.status).toBe(200);
|
|
425
|
+
expect(res.text).not.toContain(globalSecret);
|
|
426
|
+
expect(res.text).not.toContain(ownSecret);
|
|
427
|
+
expect(res.text).toContain("secret values masked");
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const controllerId = (
|
|
431
|
+
await createRuntimeController({ name: "remote", kind: "docker", placement: "remote" })
|
|
432
|
+
).id;
|
|
433
|
+
const controller = await mintControllerToken(controllerId);
|
|
434
|
+
expect((await resolveSessionToken(controller.plaintext))?.kind).toBe("controller");
|
|
435
|
+
expect((await resolved(controller.plaintext, workerId)).status).toBe(403);
|
|
436
|
+
|
|
437
|
+
// Positive control: the same request with the operator key returns plaintext.
|
|
438
|
+
const operator = await resolved(key, otherId);
|
|
439
|
+
expect(operator.status).toBe(200);
|
|
440
|
+
expect(operator.text).toContain(otherSecret);
|
|
441
|
+
expect(operator.text).toContain(globalSecret);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test("an unknown aseph_ token is unauthenticated", async () => {
|
|
445
|
+
const random = `aseph_${randomBytes(24).toString("base64url")}`;
|
|
446
|
+
expect(await resolveSessionToken(random)).toBeNull();
|
|
447
|
+
for (const path of ["/api/config/resolved?includeSecrets=true", "/api/poll"]) {
|
|
448
|
+
expect((await request(path, random)).status).toBe(401);
|
|
449
|
+
}
|
|
450
|
+
expect((await request(`/api/agents/${workerId}/token`, random, { body: {} })).status).toBe(401);
|
|
451
|
+
});
|
|
452
|
+
});
|