@desplega.ai/agent-swarm 1.119.6 → 1.121.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-qe5zjcbr.js → actions-mwshtkf7.js} +6 -6
- package/dist/{anthropic-messages-qjxzsabf.js → anthropic-messages-4gn944cn.js} +29 -13
- package/dist/{app-31x7k3vj.js → app-skbty91x.js} +4 -4
- package/dist/{assistant-4crt9dve.js → assistant-tg7dhfj8.js} +13 -13
- package/dist/{azure-openai-responses-ntfd05zd.js → azure-openai-responses-79zc0wyy.js} +28 -13
- package/dist/{boot-reembed-8g69ttz4.js → boot-reembed-ntjmeyh2.js} +4 -4
- package/dist/{boot-reembed-q648j733.js → boot-reembed-q5x0xpcp.js} +5 -5
- package/dist/{boot-scrub-logs-sfmzd2hj.js → boot-scrub-logs-5gznsfbe.js} +4 -4
- package/dist/{claude-adapter-9n3nzt65.js → claude-adapter-vxvcym3r.js} +3 -3
- package/dist/{claude-managed-adapter-pv2nbqy4.js → claude-managed-adapter-g807894s.js} +2 -2
- package/dist/{cli-bpvgs2y6.js → cli-072t49hf.js} +2123 -1533
- package/dist/{cli-g6qz2kts.js → cli-0df2g86x.js} +336 -18
- package/dist/cli-1728qh70.js +791 -0
- package/dist/{cli-vg1jxfqz.js → cli-3b1ayz83.js} +5 -5
- package/dist/{cli-r93nz80j.js → cli-3fxqwfh3.js} +1 -1
- package/dist/{cli-w6rhc30s.js → cli-3jrdwnn9.js} +20 -5
- package/dist/{cli-pasyk3vc.js → cli-46etyprn.js} +10 -5
- package/dist/{cli-433c0f4b.js → cli-4pfweq4x.js} +8 -8
- package/dist/{cli-2r7s8jhp.js → cli-5c4327dx.js} +7 -1
- package/dist/{cli-4s84mw72.js → cli-5hpvd15x.js} +2 -2
- package/dist/{cli-branks1w.js → cli-6s3ytc5x.js} +1 -1
- package/dist/{cli-da3x3btq.js → cli-7pt9sc83.js} +2 -2
- package/dist/{cli-2gv8ygwr.js → cli-7pzae66m.js} +2 -2
- package/dist/{cli-qs7nb3zn.js → cli-8wrympd7.js} +27 -1
- package/dist/{cli-qw1ccvxm.js → cli-95rbe11h.js} +70 -51
- package/dist/{cli-6rvm3jkk.js → cli-agdrxyex.js} +1 -1
- package/dist/{cli-wr706gez.js → cli-av2s7my4.js} +167 -66
- package/dist/{cli-59jy217p.js → cli-avw9ev3x.js} +55 -21
- package/dist/{cli-fkxxh5d4.js → cli-b2mqkg1h.js} +2 -2
- package/dist/{cli-1arw00dp.js → cli-ccjwgarc.js} +12 -3
- package/dist/{cli-pscrc6tw.js → cli-ch7t5v7n.js} +5 -0
- package/dist/{cli-tghre7g6.js → cli-cw70hymf.js} +1 -0
- package/dist/{cli-n61x2aph.js → cli-dmzv0986.js} +816 -1335
- package/dist/{cli-d1reabed.js → cli-g9vva2qh.js} +2 -2
- package/dist/{cli-ner6af7m.js → cli-gdpx70nc.js} +24048 -23715
- package/dist/{cli-96feg24n.js → cli-ghq3v4c9.js} +2 -2
- package/dist/{cli-wxbnjgce.js → cli-ky8esb8t.js} +3 -3
- package/dist/{cli-gdqdkr3j.js → cli-nf7sadev.js} +2 -2
- package/dist/{cli-h6cf69x5.js → cli-p9w7kb3h.js} +1 -1
- package/dist/{cli-xeatwfbe.js → cli-ry656xem.js} +24 -17
- package/dist/{cli-q8dway4w.js → cli-s8bdcke2.js} +1 -1
- package/dist/cli-s8s499xy.js +81 -0
- package/dist/{cli-sbkwvn5e.js → cli-srjqn7fd.js} +1 -1
- package/dist/cli-tv7vekye.js +367 -0
- package/dist/{cli-gg5bd32n.js → cli-vg3zrw6h.js} +4 -4
- package/dist/{cli-vx0y68jv.js → cli-vp9r4ap4.js} +5 -4
- package/dist/{cli-mg280wsv.js → cli-we156bmz.js} +1 -1
- package/dist/{cli-86fdnerz.js → cli-wwsaj97q.js} +9 -4
- package/dist/{cli-e0y603nn.js → cli-x0e0yvnq.js} +101 -1
- package/dist/{cli-mw3xpgwb.js → cli-x4apc9t3.js} +4 -4
- package/dist/cli.js +26 -21
- package/dist/{codex-adapter-6j2b38tg.js → codex-adapter-mmy741zd.js} +8 -9
- package/dist/{codex-session-runner-vz215wgw.js → codex-session-runner-sazmx9zt.js} +8 -9
- package/dist/{commands-6xr20jbe.js → commands-cxxye5e4.js} +3 -3
- package/dist/{db-1mmvk1z4.js → db-ab1954b7.js} +5 -3
- package/dist/{google-generative-ai-n1d3xt4n.js → google-generative-ai-wwa2ked5.js} +14 -18
- package/dist/{google-vertex-mxst23gy.js → google-vertex-71xmdd7n.js} +14 -18
- package/dist/{handlers-xvrknk8w.js → handlers-fmfe2343.js} +13 -13
- package/dist/{hook-qm7he8wn.js → hook-ygnw3cvt.js} +7 -8
- package/dist/{http-hj08zq67.js → http-kactcc0z.js} +1128 -536
- package/dist/{index-688jph6a.js → index-6erq0wvk.js} +11 -11
- package/dist/{index-gze4fg2w.js → index-g86j2yt5.js} +9 -9
- package/dist/{index-etz7d8md.js → index-gmzs6a2q.js} +10 -10
- package/dist/{index-wj8dfpwh.js → index-hmp82wn0.js} +12 -12
- package/dist/{keepalive-94wn9yxw.js → keepalive-3cvt26dw.js} +18 -14
- package/dist/{lead-rvcqkp11.js → lead-ysf26cjr.js} +31 -32
- package/dist/{maintenance-qhfc4dnp.js → maintenance-9npwptbb.js} +5 -5
- package/dist/{mistral-conversations-ykcg66za.js → mistral-conversations-26ya4yah.js} +17 -13
- package/dist/{oauth-refresh-sweep-rj8wgvsw.js → oauth-refresh-sweep-0r2v76vw.js} +23 -17
- package/dist/{onboard-8bv8q1wc.js → onboard-ddb1674a.js} +2 -2
- package/dist/{openai-codex-responses-zy0907j9.js → openai-codex-responses-6rr2ye0s.js} +78 -38
- package/dist/{openai-completions-xk4p56n9.js → openai-completions-zfa9nkvt.js} +130 -33
- package/dist/{openai-responses-4gv3ca5d.js → openai-responses-9wmdks0g.js} +38 -14
- package/dist/{opencode-adapter-5bwzvbwj.js → opencode-adapter-h6rkxwrk.js} +2 -2
- package/dist/{openrouter-images-4fsrrzes.js → openrouter-images-dgq98729.js} +10 -3
- package/dist/{otel-impl-1kf6fr2m.js → otel-impl-b43bkq8e.js} +4 -4
- package/dist/{pi-messages-zb8v76wx.js → pi-messages-nzjbbz47.js} +4 -6
- package/dist/{pi-mono-adapter-9sg69kg3.js → pi-mono-adapter-ke789738.js} +2328 -1281
- package/dist/{pricing-refresh-e84tar2s.js → pricing-refresh-2rqf114z.js} +5 -5
- package/dist/{rbac-roles-atytq5ch.js → rbac-roles-5wkhc8xv.js} +5 -5
- package/dist/{rbac-roles-pg0zvnwn.js → rbac-roles-6vtafvzb.js} +4 -4
- package/dist/{seed-pricing-xd6nxq7k.js → seed-pricing-23wrb873.js} +4 -4
- package/dist/{setup-4z389yk0.js → setup-6rny4fk6.js} +2 -2
- package/dist/{worker-m61r0t7m.js → worker-yapewe14.js} +31 -32
- package/openapi.json +2671 -1811
- package/package.json +10 -5
- package/plugin/commands/swarm-chat.md +2 -0
- package/plugin/pi-skills/swarm-chat/SKILL.md +2 -0
- package/src/be/connection-bindings-blob-migration.ts +123 -0
- package/src/be/db-queries/mcp-oauth.ts +409 -247
- package/src/be/db-queries/oauth.ts +813 -131
- package/src/be/db.ts +97 -6
- package/src/be/migrations/117_unified_oauth.sql +624 -0
- package/src/be/migrations/119_agent_avatar.sql +7 -0
- package/src/be/migrations/120_task_title.sql +3 -0
- package/src/be/oauth-credential-bindings.ts +48 -14
- package/src/be/oauth-encryption-backfill.ts +84 -0
- package/src/be/oauth-refresh-sweep.ts +38 -23
- package/src/be/script-connections.ts +1130 -163
- package/src/be/script-credential-broker.ts +37 -27
- package/src/be/scripts/typecheck.ts +3 -2
- package/src/be/seed-capabilities.ts +62 -0
- package/src/be/seed-pricing.ts +1 -1
- package/src/be/seed-scripts/catalog/ops-catalog-audit.inline.ts +1 -1
- package/src/be/seed-scripts/catalog/ops-catalog-audit.ts +1 -1
- package/src/be/vendored-openapi.ts +98 -0
- package/src/commands/runner.ts +69 -6
- package/src/http/agents.ts +23 -5
- package/src/http/all-routes.ts +1 -0
- package/src/http/core.ts +8 -5
- package/src/http/index.ts +18 -5
- package/src/http/mcp-bridge.ts +5 -2
- package/src/http/mcp-oauth.ts +103 -101
- package/src/http/oauth-callback.ts +332 -0
- package/src/http/oauth-generic.ts +14 -54
- package/src/http/poll.ts +5 -1
- package/src/http/script-connections.ts +651 -170
- package/src/http/scripts.ts +4 -2
- package/src/http/sessions.ts +1 -1
- package/src/http/tasks.ts +35 -0
- package/src/http/trackers/jira.ts +22 -34
- package/src/http/trackers/linear.ts +21 -36
- package/src/http/x.ts +6 -2
- package/src/jira/app.ts +12 -6
- package/src/jira/oauth.ts +48 -48
- package/src/linear/app.ts +44 -2
- package/src/linear/oauth.ts +12 -37
- package/src/oauth/app-validation.ts +27 -9
- package/src/oauth/ensure-mcp-token.ts +1 -0
- package/src/oauth/ensure-token.ts +331 -108
- package/src/oauth/identity-capture.ts +103 -0
- package/src/oauth/keepalive.ts +14 -9
- package/src/oauth/presets.ts +242 -0
- package/src/oauth/wrapper.ts +197 -76
- package/src/prompts/base-prompt.ts +48 -4
- package/src/prompts/session-templates.ts +13 -1
- package/src/providers/claude-managed-models.ts +8 -0
- package/src/rbac/legacy-policy.ts +2 -1
- package/src/rbac/permissions.ts +7 -3
- package/src/scripts-runtime/credential-broker/broker.ts +51 -8
- package/src/scripts-runtime/credential-broker/fetch-patch.ts +30 -3
- package/src/scripts-runtime/credential-broker/index.ts +1 -1
- package/src/scripts-runtime/credential-broker/types.ts +55 -8
- package/src/scripts-runtime/egress-secrets.ts +6 -2
- package/src/scripts-runtime/eval-harness.ts +2 -2
- package/src/scripts-runtime/executors/types.ts +2 -1
- package/src/scripts-runtime/loader.ts +3 -0
- package/src/scripts-runtime/types/stdlib.d.ts +11 -0
- package/src/server.ts +230 -126
- package/src/tests/agent-avatar-resolution.test.ts +123 -0
- package/src/tests/agents-list-model-display.test.ts +10 -0
- package/src/tests/base-prompt.test.ts +72 -0
- package/src/tests/claude-managed-adapter.test.ts +6 -0
- package/src/tests/connection-embedded-auth.test.ts +709 -0
- package/src/tests/context-window.test.ts +1 -0
- package/src/tests/credential-broker.test.ts +123 -125
- package/src/tests/db-queries-oauth.test.ts +126 -1
- package/src/tests/ensure-token.test.ts +44 -2
- package/src/tests/list-endpoint-slimming.test.ts +45 -0
- package/src/tests/mcp-oauth-manual-client.test.ts +20 -1
- package/src/tests/mcp-oauth-queries.test.ts +154 -8
- package/src/tests/oauth-access-token-tool.test.ts +37 -1
- package/src/tests/oauth-callback-flow.test.ts +534 -0
- package/src/tests/oauth-credential-bindings.test.ts +50 -22
- package/src/tests/oauth-presets.test.ts +275 -0
- package/src/tests/oauth-refresh-failure.test.ts +263 -0
- package/src/tests/oauth-refresh-sweep.test.ts +83 -3
- package/src/tests/oauth-wrapper.test.ts +77 -55
- package/src/tests/prompt-template-session.test.ts +6 -5
- package/src/tests/rbac-charact-misc-tools.test.ts +60 -0
- package/src/tests/rbac-engine.test.ts +2 -1
- package/src/tests/script-connections-http.test.ts +356 -11
- package/src/tests/script-connections.test.ts +102 -0
- package/src/tests/sdk-allowlist.test.ts +4 -1
- package/src/tests/seed-capabilities.test.ts +92 -0
- package/src/tests/session-costs-model-key-normalize.test.ts +1 -0
- package/src/tests/sessions.test.ts +46 -0
- package/src/tests/spec-base-url.test.ts +266 -0
- package/src/tests/status.test.ts +3 -4
- package/src/tests/swarm-config-reserved-keys.test.ts +4 -2
- package/src/tests/task-title-route.test.ts +106 -0
- package/src/tests/tool-annotations.test.ts +6 -3
- package/src/tests/tracker-fold.test.ts +412 -0
- package/src/tests/unified-oauth-migration.test.ts +455 -0
- package/src/tests/vendored-openapi.test.ts +263 -0
- package/src/tools/credential-bindings/tool.ts +232 -77
- package/src/tools/oauth-access-token.ts +55 -6
- package/src/tools/script-connections/tool.ts +73 -53
- package/src/tools/swarm-config/set-config.ts +24 -50
- package/src/tools/update-profile.ts +15 -5
- package/src/tracker/types.ts +12 -0
- package/src/types.ts +29 -0
- package/src/utils/context-window.ts +1 -0
- package/src/workflows/executors/swarm-script.ts +6 -2
- package/vendored-openapi/README.md +22 -0
- package/vendored-openapi/github.json +7759 -0
- package/vendored-openapi/gmail.json +393 -0
- package/vendored-openapi/jira.json +3396 -0
- package/vendored-openapi/linear.json +252 -0
- package/vendored-openapi/manifest.json +128 -0
- package/vendored-openapi/slack.json +2895 -0
- package/dist/cli-0pygq9r1.js +0 -429
- package/dist/cli-8hhms4hp.js +0 -393
- package/dist/cli-z9gzq07x.js +0 -27
- package/src/scripts-runtime/credential-broker/store.ts +0 -72
- package/dist/{otel-g299k41b.js → otel-63jr5v38.js} +1 -1
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import {
|
|
4
|
+
createServer as createHttpServer,
|
|
5
|
+
type IncomingMessage,
|
|
6
|
+
type Server,
|
|
7
|
+
type ServerResponse,
|
|
8
|
+
} from "node:http";
|
|
9
|
+
import { closeDb, createAgent, createMcpServer, getDb, initDb } from "../be/db";
|
|
10
|
+
import { getMcpServerAuthMethod, insertMcpOAuthPending } from "../be/db-queries/mcp-oauth";
|
|
11
|
+
import {
|
|
12
|
+
createOAuthPending,
|
|
13
|
+
gcOAuthPending,
|
|
14
|
+
getAuthorizationById,
|
|
15
|
+
getOAuthAppIdByProvider,
|
|
16
|
+
listAuthorizationsForApp,
|
|
17
|
+
upsertOAuthApp,
|
|
18
|
+
} from "../be/db-queries/oauth";
|
|
19
|
+
import {
|
|
20
|
+
getOAuthBindingTokenStatus,
|
|
21
|
+
getOAuthProviderConfig,
|
|
22
|
+
} from "../be/oauth-credential-bindings";
|
|
23
|
+
import { handleOAuthCallback } from "../http/oauth-callback";
|
|
24
|
+
import { handleGenericOAuth } from "../http/oauth-generic";
|
|
25
|
+
import { handleScriptConnections } from "../http/script-connections";
|
|
26
|
+
import { getPathSegments, parseQueryParams } from "../http/utils";
|
|
27
|
+
import { ensureAuthorizationTokenOrThrow } from "../oauth/ensure-token";
|
|
28
|
+
import { captureIdentity } from "../oauth/identity-capture";
|
|
29
|
+
import { buildAuthorizationUrl } from "../oauth/wrapper";
|
|
30
|
+
|
|
31
|
+
const TEST_DB_PATH = "./test-oauth-callback-flow.sqlite";
|
|
32
|
+
const LEAD_ID = "aaaa9100-0000-4000-8000-000000000001";
|
|
33
|
+
const NON_LEAD_ID = "aaaa9100-0000-4000-8000-000000000002";
|
|
34
|
+
|
|
35
|
+
// ─── Mock OAuth provider (token + userinfo endpoints) ────────────────────────
|
|
36
|
+
|
|
37
|
+
let providerServer: ReturnType<typeof Bun.serve>;
|
|
38
|
+
let providerBase = "";
|
|
39
|
+
let lastTokenBody = "";
|
|
40
|
+
|
|
41
|
+
// ─── App-side callback dispatcher (wraps the real handlers) ──────────────────
|
|
42
|
+
|
|
43
|
+
function callbackServer(): Server {
|
|
44
|
+
return createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
45
|
+
const pathSegments = getPathSegments(req.url || "");
|
|
46
|
+
const queryParams = parseQueryParams(req.url || "");
|
|
47
|
+
if (await handleOAuthCallback(req, res, pathSegments, queryParams)) return;
|
|
48
|
+
if (await handleGenericOAuth(req, res, pathSegments, queryParams)) return;
|
|
49
|
+
const agentId = (req.headers["x-agent-id"] as string | undefined) ?? undefined;
|
|
50
|
+
if (await handleScriptConnections(req, res, pathSegments, queryParams, agentId)) return;
|
|
51
|
+
res.writeHead(404);
|
|
52
|
+
res.end("not found");
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let appServer: Server;
|
|
57
|
+
let appBase = "";
|
|
58
|
+
|
|
59
|
+
async function listen(server: Server): Promise<number> {
|
|
60
|
+
await new Promise<void>((resolve) => server.listen(0, resolve));
|
|
61
|
+
const address = server.address();
|
|
62
|
+
if (!address || typeof address === "string") throw new Error("test server did not bind");
|
|
63
|
+
return address.port;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function testApp(provider: string) {
|
|
67
|
+
return {
|
|
68
|
+
clientId: `${provider}-client`,
|
|
69
|
+
clientSecret: `${provider}-secret`,
|
|
70
|
+
authorizeUrl: `${providerBase}/authorize`,
|
|
71
|
+
tokenUrl: `${providerBase}/token`,
|
|
72
|
+
redirectUri: `${appBase}/api/oauth/callback`,
|
|
73
|
+
userinfoUrl: `${providerBase}/userinfo`,
|
|
74
|
+
scopes: "read,write",
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const savedAllowPrivateHosts = process.env.MCP_OAUTH_ALLOW_PRIVATE_HOSTS;
|
|
79
|
+
|
|
80
|
+
beforeAll(async () => {
|
|
81
|
+
// The mock provider runs on localhost; MCP token exchange SSRF-guards loopback.
|
|
82
|
+
process.env.MCP_OAUTH_ALLOW_PRIVATE_HOSTS = "true";
|
|
83
|
+
initDb(TEST_DB_PATH);
|
|
84
|
+
createAgent({ id: LEAD_ID, name: "lead", isLead: true, status: "idle" });
|
|
85
|
+
createAgent({ id: NON_LEAD_ID, name: "worker", isLead: false, status: "idle" });
|
|
86
|
+
|
|
87
|
+
providerServer = Bun.serve({
|
|
88
|
+
port: 0,
|
|
89
|
+
async fetch(req) {
|
|
90
|
+
const url = new URL(req.url);
|
|
91
|
+
if (url.pathname === "/token") {
|
|
92
|
+
lastTokenBody = await req.text();
|
|
93
|
+
return Response.json({
|
|
94
|
+
access_token: "mock-access-token",
|
|
95
|
+
token_type: "Bearer",
|
|
96
|
+
expires_in: 3600,
|
|
97
|
+
refresh_token: "mock-refresh-token",
|
|
98
|
+
scope: "read write",
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
if (url.pathname === "/token-noexpiry") {
|
|
102
|
+
// GitHub-preset style: a long-lived token with NO expires_in and NO
|
|
103
|
+
// refresh_token. The callback must store a NULL expiry, not a
|
|
104
|
+
// fabricated one.
|
|
105
|
+
lastTokenBody = await req.text();
|
|
106
|
+
return Response.json({
|
|
107
|
+
access_token: "mock-access-token",
|
|
108
|
+
token_type: "bearer",
|
|
109
|
+
scope: "read",
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
if (url.pathname === "/token-fail") {
|
|
113
|
+
// Reject the exchange and echo the posted body (client_secret + PKCE
|
|
114
|
+
// code_verifier) back in the error — the leak this scrub guards against.
|
|
115
|
+
const body = await req.text();
|
|
116
|
+
return Response.json(
|
|
117
|
+
{ error: "invalid_grant", error_description: `rejected: ${body}` },
|
|
118
|
+
{ status: 400 },
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
if (url.pathname === "/userinfo") {
|
|
122
|
+
return Response.json({ email: "connected@example.test", sub: "user-123" });
|
|
123
|
+
}
|
|
124
|
+
if (url.pathname === "/userinfo-redirect") {
|
|
125
|
+
// A public userinfoUrl that 302s toward a would-be internal endpoint.
|
|
126
|
+
return new Response(null, { status: 302, headers: { location: "http://127.0.0.1/steal" } });
|
|
127
|
+
}
|
|
128
|
+
return new Response("not found", { status: 404 });
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
providerBase = `http://localhost:${providerServer.port}`;
|
|
132
|
+
|
|
133
|
+
appServer = callbackServer();
|
|
134
|
+
appBase = `http://localhost:${await listen(appServer)}`;
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
afterAll(async () => {
|
|
138
|
+
if (savedAllowPrivateHosts === undefined) delete process.env.MCP_OAUTH_ALLOW_PRIVATE_HOSTS;
|
|
139
|
+
else process.env.MCP_OAUTH_ALLOW_PRIVATE_HOSTS = savedAllowPrivateHosts;
|
|
140
|
+
appServer.close();
|
|
141
|
+
await providerServer.stop(true);
|
|
142
|
+
closeDb();
|
|
143
|
+
await unlink(TEST_DB_PATH).catch(() => {});
|
|
144
|
+
await unlink(`${TEST_DB_PATH}-wal`).catch(() => {});
|
|
145
|
+
await unlink(`${TEST_DB_PATH}-shm`).catch(() => {});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
afterEach(() => {
|
|
149
|
+
lastTokenBody = "";
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
async function driveStaticCallback(state: string, code = "auth-code"): Promise<Response> {
|
|
153
|
+
return fetch(`${appBase}/api/oauth/callback?code=${code}&state=${state}`, {
|
|
154
|
+
redirect: "manual",
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
describe("static OAuth callback + multi-authorization flow", () => {
|
|
159
|
+
test("GET /api/oauth/redirect-uri returns the static callback URL", async () => {
|
|
160
|
+
const res = await fetch(`${appBase}/api/oauth/redirect-uri`);
|
|
161
|
+
expect(res.status).toBe(200);
|
|
162
|
+
const body = (await res.json()) as { redirectUri: string };
|
|
163
|
+
expect(body.redirectUri).toContain("/api/oauth/callback");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("two labeled authorizations land independently with captured identity", async () => {
|
|
167
|
+
const provider = "flow-multi";
|
|
168
|
+
upsertOAuthApp(provider, testApp(provider));
|
|
169
|
+
const appId = getOAuthAppIdByProvider(provider);
|
|
170
|
+
expect(appId).toBeTruthy();
|
|
171
|
+
const config = getOAuthProviderConfig(provider);
|
|
172
|
+
if (!appId || !config) throw new Error("app setup failed");
|
|
173
|
+
|
|
174
|
+
// First label: "support"
|
|
175
|
+
const support = await buildAuthorizationUrl(config, {
|
|
176
|
+
appId,
|
|
177
|
+
label: "support",
|
|
178
|
+
flow: "generic",
|
|
179
|
+
});
|
|
180
|
+
const res1 = await driveStaticCallback(support.state);
|
|
181
|
+
expect(res1.status).toBe(200);
|
|
182
|
+
expect(lastTokenBody).toContain("grant_type=authorization_code");
|
|
183
|
+
|
|
184
|
+
let authorizations = listAuthorizationsForApp(appId);
|
|
185
|
+
expect(authorizations).toHaveLength(1);
|
|
186
|
+
const supportAuth = authorizations[0]!;
|
|
187
|
+
expect(supportAuth.label).toBe("support");
|
|
188
|
+
expect(supportAuth.status).toBe("active");
|
|
189
|
+
expect(supportAuth.accountEmail).toBe("connected@example.test");
|
|
190
|
+
// Tokens are encrypted at rest but decrypt to the mock values on read.
|
|
191
|
+
expect(supportAuth.tokensEncrypted).toBe(true);
|
|
192
|
+
expect(supportAuth.accessToken).toBe("mock-access-token");
|
|
193
|
+
expect(supportAuth.refreshToken).toBe("mock-refresh-token");
|
|
194
|
+
|
|
195
|
+
// Second label: "sales" — first authorization must be untouched.
|
|
196
|
+
const sales = await buildAuthorizationUrl(config, { appId, label: "sales", flow: "generic" });
|
|
197
|
+
const res2 = await driveStaticCallback(sales.state);
|
|
198
|
+
expect(res2.status).toBe(200);
|
|
199
|
+
|
|
200
|
+
authorizations = listAuthorizationsForApp(appId);
|
|
201
|
+
expect(authorizations).toHaveLength(2);
|
|
202
|
+
const labels = authorizations.map((a) => a.label).sort();
|
|
203
|
+
expect(labels).toEqual(["sales", "support"]);
|
|
204
|
+
// Same support authorization id preserved.
|
|
205
|
+
expect(getAuthorizationById(supportAuth.id)?.label).toBe("support");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("callback with no expires_in stores a NULL expiry and the binding resolves without a refresh-fail", async () => {
|
|
209
|
+
const provider = "flow-noexpiry";
|
|
210
|
+
// Long-lived token, no refresh token (GitHub-preset shape). Point the token
|
|
211
|
+
// URL at the endpoint that omits expires_in.
|
|
212
|
+
upsertOAuthApp(provider, {
|
|
213
|
+
...testApp(provider),
|
|
214
|
+
tokenUrl: `${providerBase}/token-noexpiry`,
|
|
215
|
+
});
|
|
216
|
+
const appId = getOAuthAppIdByProvider(provider)!;
|
|
217
|
+
const config = getOAuthProviderConfig(provider)!;
|
|
218
|
+
const pending = await buildAuthorizationUrl(config, {
|
|
219
|
+
appId,
|
|
220
|
+
label: "default",
|
|
221
|
+
flow: "generic",
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const res = await driveStaticCallback(pending.state);
|
|
225
|
+
expect(res.status).toBe(200);
|
|
226
|
+
|
|
227
|
+
const auth = listAuthorizationsForApp(appId)[0]!;
|
|
228
|
+
// The 24h fabrication is gone: no expires_in → NULL expiry, no refresh token.
|
|
229
|
+
expect(auth.expiresAt).toBeNull();
|
|
230
|
+
expect(auth.refreshToken).toBeNull();
|
|
231
|
+
expect(auth.status).toBe("active");
|
|
232
|
+
|
|
233
|
+
// A NULL expiry reads as "ok" (does not expire), so a binding resolves and
|
|
234
|
+
// the reactive refresh core is a no-op — it must NOT mark the row
|
|
235
|
+
// refresh-failed just because there's no refresh token to rotate.
|
|
236
|
+
expect(getOAuthBindingTokenStatus(auth.id)).toBe("ok");
|
|
237
|
+
await ensureAuthorizationTokenOrThrow(auth.id);
|
|
238
|
+
const after = getAuthorizationById(auth.id);
|
|
239
|
+
expect(after?.status).toBe("active");
|
|
240
|
+
expect(after?.lastErrorMessage ?? null).toBeNull();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("callback token-exchange failure scrubs the app clientSecret from the error", async () => {
|
|
244
|
+
const provider = "flow-scrub";
|
|
245
|
+
// clientSecret is `${provider}-secret` = "flow-scrub-secret" (testApp). Point
|
|
246
|
+
// the token URL at the failing endpoint that echoes the posted secrets.
|
|
247
|
+
upsertOAuthApp(provider, {
|
|
248
|
+
...testApp(provider),
|
|
249
|
+
tokenUrl: `${providerBase}/token-fail`,
|
|
250
|
+
});
|
|
251
|
+
const appId = getOAuthAppIdByProvider(provider)!;
|
|
252
|
+
const config = getOAuthProviderConfig(provider)!;
|
|
253
|
+
const pending = await buildAuthorizationUrl(config, {
|
|
254
|
+
appId,
|
|
255
|
+
label: "scrub",
|
|
256
|
+
flow: "generic",
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const res = await driveStaticCallback(pending.state);
|
|
260
|
+
// No finalRedirect → jsonError 502.
|
|
261
|
+
expect(res.status).toBe(502);
|
|
262
|
+
const text = await res.text();
|
|
263
|
+
// The DB-sourced client secret must NOT appear raw in the error response.
|
|
264
|
+
expect(text).not.toContain("flow-scrub-secret");
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("POST /api/oauth-apps/{id}/authorize-url (lead) issues a state the callback completes", async () => {
|
|
268
|
+
const provider = "flow-http-route";
|
|
269
|
+
upsertOAuthApp(provider, testApp(provider));
|
|
270
|
+
const appId = getOAuthAppIdByProvider(provider)!;
|
|
271
|
+
|
|
272
|
+
const authRes = await fetch(`${appBase}/api/oauth-apps/${appId}/authorize-url`, {
|
|
273
|
+
method: "POST",
|
|
274
|
+
headers: { "content-type": "application/json", "x-agent-id": LEAD_ID },
|
|
275
|
+
body: JSON.stringify({ label: "billing" }),
|
|
276
|
+
});
|
|
277
|
+
expect(authRes.status).toBe(200);
|
|
278
|
+
const payload = (await authRes.json()) as {
|
|
279
|
+
authorizeUrl: string;
|
|
280
|
+
state: string;
|
|
281
|
+
label: string;
|
|
282
|
+
redirectUri: string;
|
|
283
|
+
};
|
|
284
|
+
expect(payload.label).toBe("billing");
|
|
285
|
+
expect(payload.redirectUri).toContain("/api/oauth/callback");
|
|
286
|
+
expect(new URL(payload.authorizeUrl).searchParams.get("state")).toBe(payload.state);
|
|
287
|
+
|
|
288
|
+
const res = await driveStaticCallback(payload.state);
|
|
289
|
+
expect(res.status).toBe(200);
|
|
290
|
+
expect(listAuthorizationsForApp(appId).some((a) => a.label === "billing")).toBe(true);
|
|
291
|
+
|
|
292
|
+
// The list route surfaces the authorization (no token material).
|
|
293
|
+
const listRes = await fetch(`${appBase}/api/oauth-apps/${appId}/authorizations`);
|
|
294
|
+
const listed = (await listRes.json()) as {
|
|
295
|
+
authorizations: Array<{ label: string; accountEmail: string | null }>;
|
|
296
|
+
};
|
|
297
|
+
const billing = listed.authorizations.find((a) => a.label === "billing");
|
|
298
|
+
expect(billing?.accountEmail).toBe("connected@example.test");
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("state is single-use — replay is rejected", async () => {
|
|
302
|
+
const provider = "flow-replay";
|
|
303
|
+
upsertOAuthApp(provider, testApp(provider));
|
|
304
|
+
const appId = getOAuthAppIdByProvider(provider)!;
|
|
305
|
+
const config = getOAuthProviderConfig(provider)!;
|
|
306
|
+
const { state } = await buildAuthorizationUrl(config, { appId, flow: "generic" });
|
|
307
|
+
|
|
308
|
+
expect((await driveStaticCallback(state)).status).toBe(200);
|
|
309
|
+
const replay = await driveStaticCallback(state);
|
|
310
|
+
expect(replay.status).toBe(400);
|
|
311
|
+
expect((await replay.json()).error).toContain("Invalid or expired OAuth state");
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("expired pending rows are garbage-collected", async () => {
|
|
315
|
+
const provider = "flow-gc";
|
|
316
|
+
upsertOAuthApp(provider, testApp(provider));
|
|
317
|
+
const appId = getOAuthAppIdByProvider(provider)!;
|
|
318
|
+
createOAuthPending({
|
|
319
|
+
state: "gc-state",
|
|
320
|
+
appId,
|
|
321
|
+
flow: "generic",
|
|
322
|
+
codeVerifier: "verifier",
|
|
323
|
+
redirectUri: `${appBase}/api/oauth/callback`,
|
|
324
|
+
});
|
|
325
|
+
// Sweep everything created before "now + 1s" — removes the fresh row.
|
|
326
|
+
const removed = gcOAuthPending(-1000);
|
|
327
|
+
expect(removed).toBeGreaterThanOrEqual(1);
|
|
328
|
+
const res = await driveStaticCallback("gc-state");
|
|
329
|
+
expect(res.status).toBe(400);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test("legacy per-provider callback still completes a flow", async () => {
|
|
333
|
+
const provider = "flow-legacy";
|
|
334
|
+
upsertOAuthApp(provider, testApp(provider));
|
|
335
|
+
const appId = getOAuthAppIdByProvider(provider)!;
|
|
336
|
+
const config = getOAuthProviderConfig(provider)!;
|
|
337
|
+
const { state } = await buildAuthorizationUrl(config, { appId, flow: "generic" });
|
|
338
|
+
const res = await fetch(
|
|
339
|
+
`${appBase}/api/oauth/${provider}/callback?code=legacy-code&state=${state}`,
|
|
340
|
+
);
|
|
341
|
+
expect(res.status).toBe(200);
|
|
342
|
+
expect(await res.text()).toContain("You can close this tab.");
|
|
343
|
+
expect(listAuthorizationsForApp(appId)).toHaveLength(1);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("pending state is persisted in the DB (restart-safe, not an in-memory map)", async () => {
|
|
347
|
+
const provider = "flow-restart";
|
|
348
|
+
upsertOAuthApp(provider, testApp(provider));
|
|
349
|
+
const appId = getOAuthAppIdByProvider(provider)!;
|
|
350
|
+
const config = getOAuthProviderConfig(provider)!;
|
|
351
|
+
const { state, codeVerifier } = await buildAuthorizationUrl(config, {
|
|
352
|
+
appId,
|
|
353
|
+
label: "persisted",
|
|
354
|
+
flow: "generic",
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// The row lives in oauth_pending (encrypted verifier) — a process restart
|
|
358
|
+
// would keep it, unlike the old in-memory PKCE map. Assert it is on disk,
|
|
359
|
+
// then complete the flow.
|
|
360
|
+
const row = getDb()
|
|
361
|
+
.query("SELECT state, label, flow, codeVerifier FROM oauth_pending WHERE state = ?")
|
|
362
|
+
.get(state) as { state: string; label: string; flow: string; codeVerifier: string } | null;
|
|
363
|
+
expect(row).toBeTruthy();
|
|
364
|
+
expect(row!.label).toBe("persisted");
|
|
365
|
+
expect(row!.flow).toBe("generic");
|
|
366
|
+
// Stored encrypted — never the raw verifier.
|
|
367
|
+
expect(row!.codeVerifier).not.toBe(codeVerifier);
|
|
368
|
+
|
|
369
|
+
const res = await driveStaticCallback(state);
|
|
370
|
+
expect(res.status).toBe(200);
|
|
371
|
+
expect(listAuthorizationsForApp(appId).some((a) => a.label === "persisted")).toBe(true);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("MCP-flow pending routes through the static callback and flips authMethod", async () => {
|
|
375
|
+
const mcpServer = createMcpServer({
|
|
376
|
+
name: `mcp-static-flip-${crypto.randomUUID()}`,
|
|
377
|
+
transport: "http",
|
|
378
|
+
url: `${providerBase}/mcp`,
|
|
379
|
+
scope: "swarm",
|
|
380
|
+
});
|
|
381
|
+
insertMcpOAuthPending({
|
|
382
|
+
state: "mcp-static-state",
|
|
383
|
+
mcpServerId: mcpServer.id,
|
|
384
|
+
codeVerifier: "mcp-verifier",
|
|
385
|
+
resourceUrl: `${providerBase}/mcp`,
|
|
386
|
+
authorizationServerIssuer: providerBase,
|
|
387
|
+
authorizeUrl: `${providerBase}/authorize`,
|
|
388
|
+
tokenUrl: `${providerBase}/token`,
|
|
389
|
+
scopes: "read",
|
|
390
|
+
dcrClientId: "mcp-client",
|
|
391
|
+
dcrClientSecret: "mcp-secret",
|
|
392
|
+
redirectUri: `${appBase}/api/oauth/callback`,
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
expect(getMcpServerAuthMethod(mcpServer.id)).not.toBe("oauth");
|
|
396
|
+
const res = await driveStaticCallback("mcp-static-state", "mcp-code");
|
|
397
|
+
// MCP callbacks redirect (302) back to the dashboard.
|
|
398
|
+
expect(res.status).toBe(302);
|
|
399
|
+
expect(getMcpServerAuthMethod(mcpServer.id)).toBe("oauth");
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test("RBAC: non-lead agent is denied on the authorize-url manage route", async () => {
|
|
403
|
+
const provider = "flow-rbac";
|
|
404
|
+
upsertOAuthApp(provider, testApp(provider));
|
|
405
|
+
const appId = getOAuthAppIdByProvider(provider)!;
|
|
406
|
+
const res = await fetch(`${appBase}/api/oauth-apps/${appId}/authorize-url`, {
|
|
407
|
+
method: "POST",
|
|
408
|
+
headers: { "content-type": "application/json", "x-agent-id": NON_LEAD_ID },
|
|
409
|
+
body: JSON.stringify({ label: "support" }),
|
|
410
|
+
});
|
|
411
|
+
expect(res.status).toBe(403);
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
describe("step-4 security hardening", () => {
|
|
416
|
+
test("app-write rejects a private-host userinfoUrl (SSRF, production)", async () => {
|
|
417
|
+
const saved = process.env.NODE_ENV;
|
|
418
|
+
process.env.NODE_ENV = "production";
|
|
419
|
+
try {
|
|
420
|
+
const res = await fetch(`${appBase}/api/oauth-apps`, {
|
|
421
|
+
method: "POST",
|
|
422
|
+
headers: { "content-type": "application/json", "x-agent-id": LEAD_ID },
|
|
423
|
+
body: JSON.stringify({
|
|
424
|
+
provider: "sec-userinfo",
|
|
425
|
+
clientId: "c",
|
|
426
|
+
clientSecret: "s",
|
|
427
|
+
authorizeUrl: "https://oauth.example.test/authorize",
|
|
428
|
+
tokenUrl: "https://oauth.example.test/token",
|
|
429
|
+
userinfoUrl: "http://169.254.169.254/latest/meta-data/iam",
|
|
430
|
+
scopes: [],
|
|
431
|
+
}),
|
|
432
|
+
});
|
|
433
|
+
expect(res.status).toBe(400);
|
|
434
|
+
expect(getOAuthAppIdByProvider("sec-userinfo")).toBeNull();
|
|
435
|
+
} finally {
|
|
436
|
+
if (saved === undefined) delete process.env.NODE_ENV;
|
|
437
|
+
else process.env.NODE_ENV = saved;
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test("app-write rejects a private-host revocationUrl (SSRF, production)", async () => {
|
|
442
|
+
const saved = process.env.NODE_ENV;
|
|
443
|
+
process.env.NODE_ENV = "production";
|
|
444
|
+
try {
|
|
445
|
+
const res = await fetch(`${appBase}/api/oauth-apps`, {
|
|
446
|
+
method: "POST",
|
|
447
|
+
headers: { "content-type": "application/json", "x-agent-id": LEAD_ID },
|
|
448
|
+
body: JSON.stringify({
|
|
449
|
+
provider: "sec-revoke",
|
|
450
|
+
clientId: "c",
|
|
451
|
+
clientSecret: "s",
|
|
452
|
+
authorizeUrl: "https://oauth.example.test/authorize",
|
|
453
|
+
tokenUrl: "https://oauth.example.test/token",
|
|
454
|
+
revocationUrl: "http://127.0.0.1/revoke",
|
|
455
|
+
scopes: [],
|
|
456
|
+
}),
|
|
457
|
+
});
|
|
458
|
+
expect(res.status).toBe(400);
|
|
459
|
+
expect(getOAuthAppIdByProvider("sec-revoke")).toBeNull();
|
|
460
|
+
} finally {
|
|
461
|
+
if (saved === undefined) delete process.env.NODE_ENV;
|
|
462
|
+
else process.env.NODE_ENV = saved;
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test("captureIdentity fails closed on a private-host userinfo URL", async () => {
|
|
467
|
+
const saved = process.env.NODE_ENV;
|
|
468
|
+
process.env.NODE_ENV = "production";
|
|
469
|
+
try {
|
|
470
|
+
const identity = await captureIdentity({
|
|
471
|
+
userinfoUrl: "http://127.0.0.1/userinfo",
|
|
472
|
+
accessToken: "live-bearer",
|
|
473
|
+
});
|
|
474
|
+
expect(identity).toBeNull();
|
|
475
|
+
} finally {
|
|
476
|
+
if (saved === undefined) delete process.env.NODE_ENV;
|
|
477
|
+
else process.env.NODE_ENV = saved;
|
|
478
|
+
}
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
test("captureIdentity does not follow a redirect to a private host", async () => {
|
|
482
|
+
const identity = await captureIdentity({
|
|
483
|
+
userinfoUrl: `${providerBase}/userinfo-redirect`,
|
|
484
|
+
accessToken: "live-bearer",
|
|
485
|
+
});
|
|
486
|
+
// The 302 is not followed (redirect: manual → not .ok), so no identity.
|
|
487
|
+
expect(identity).toBeNull();
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test("success page HTML-escapes a hostile authorization label (XSS)", async () => {
|
|
491
|
+
const provider = "sec-xss";
|
|
492
|
+
upsertOAuthApp(provider, testApp(provider));
|
|
493
|
+
const appId = getOAuthAppIdByProvider(provider)!;
|
|
494
|
+
const config = getOAuthProviderConfig(provider)!;
|
|
495
|
+
const label = "</h1><script>alert(1)</script>";
|
|
496
|
+
const { state } = await buildAuthorizationUrl(config, { appId, label, flow: "generic" });
|
|
497
|
+
const res = await driveStaticCallback(state);
|
|
498
|
+
expect(res.status).toBe(200);
|
|
499
|
+
const html = await res.text();
|
|
500
|
+
expect(html).not.toContain("<script>alert(1)</script>");
|
|
501
|
+
expect(html).toContain("<script>alert(1)</script>");
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
test("authorize-url rejects a non-http(s) finalRedirect (open redirect)", async () => {
|
|
505
|
+
const provider = "sec-redirect";
|
|
506
|
+
upsertOAuthApp(provider, testApp(provider));
|
|
507
|
+
const appId = getOAuthAppIdByProvider(provider)!;
|
|
508
|
+
const res = await fetch(`${appBase}/api/oauth-apps/${appId}/authorize-url`, {
|
|
509
|
+
method: "POST",
|
|
510
|
+
headers: { "content-type": "application/json", "x-agent-id": LEAD_ID },
|
|
511
|
+
body: JSON.stringify({ label: "x", finalRedirect: "javascript:alert(1)" }),
|
|
512
|
+
});
|
|
513
|
+
expect(res.status).toBe(400);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
test("consume enforces the 10-minute pending TTL", async () => {
|
|
517
|
+
const provider = "sec-ttl";
|
|
518
|
+
upsertOAuthApp(provider, testApp(provider));
|
|
519
|
+
const appId = getOAuthAppIdByProvider(provider)!;
|
|
520
|
+
createOAuthPending({
|
|
521
|
+
state: "ttl-state",
|
|
522
|
+
appId,
|
|
523
|
+
flow: "generic",
|
|
524
|
+
codeVerifier: "verifier",
|
|
525
|
+
redirectUri: `${appBase}/api/oauth/callback`,
|
|
526
|
+
});
|
|
527
|
+
// Backdate past the 10-minute TTL.
|
|
528
|
+
getDb()
|
|
529
|
+
.query("UPDATE oauth_pending SET createdAt = ? WHERE state = ?")
|
|
530
|
+
.run(new Date(Date.now() - 11 * 60 * 1000).toISOString(), "ttl-state");
|
|
531
|
+
const res = await driveStaticCallback("ttl-state");
|
|
532
|
+
expect(res.status).toBe(400);
|
|
533
|
+
});
|
|
534
|
+
});
|