@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,263 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import { closeDb, createAgent, getDb, initDb } from "../be/db";
|
|
4
|
+
import {
|
|
5
|
+
getAuthorizationById,
|
|
6
|
+
getOAuthApp,
|
|
7
|
+
storeOAuthTokens,
|
|
8
|
+
upsertAuthorization,
|
|
9
|
+
upsertOAuthApp,
|
|
10
|
+
} from "../be/db-queries/oauth";
|
|
11
|
+
import { upsertCredentialBinding } from "../be/script-connections";
|
|
12
|
+
import { buildScriptCredentialBindingsWithFailures } from "../be/script-credential-broker";
|
|
13
|
+
import {
|
|
14
|
+
ensureAuthorizationTokenOrThrow,
|
|
15
|
+
forceRefreshAuthorizationOrThrow,
|
|
16
|
+
OAuthRefreshError,
|
|
17
|
+
} from "../oauth/ensure-token";
|
|
18
|
+
import { patchFetchWithCredentialBroker } from "../scripts-runtime/credential-broker";
|
|
19
|
+
import { clearVolatileSecretsForTesting } from "../utils/secret-scrubber";
|
|
20
|
+
|
|
21
|
+
const TEST_DB_PATH = "./test-oauth-refresh-failure.sqlite";
|
|
22
|
+
const LEAD_ID = "aaaa9500-0000-4000-8000-000000000001";
|
|
23
|
+
const originalFetch = globalThis.fetch;
|
|
24
|
+
|
|
25
|
+
function appConfig(provider: string, tokenUrl = `https://oauth.${provider}.test/token`) {
|
|
26
|
+
return {
|
|
27
|
+
clientId: `${provider}-client-id`,
|
|
28
|
+
clientSecret: `${provider}-client-secret`,
|
|
29
|
+
authorizeUrl: `https://oauth.${provider}.test/authorize`,
|
|
30
|
+
tokenUrl,
|
|
31
|
+
redirectUri: "http://localhost:3013/callback",
|
|
32
|
+
scopes: "read,write",
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Mock the token endpoint. Refresh requests whose body carries a refresh token
|
|
38
|
+
* listed in `failFor` get a 400 (invalid_grant); everything else gets a fresh
|
|
39
|
+
* token whose access value is derived from the incoming refresh token so
|
|
40
|
+
* per-authorization isolation is observable.
|
|
41
|
+
*/
|
|
42
|
+
function mockTokenEndpoint(failFor: string[] = []): void {
|
|
43
|
+
globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
|
|
44
|
+
const body = typeof init?.body === "string" ? init.body : "";
|
|
45
|
+
const failing = failFor.some((token) => body.includes(`refresh_token=${token}`));
|
|
46
|
+
if (failing) {
|
|
47
|
+
return new Response(JSON.stringify({ error: "invalid_grant" }), {
|
|
48
|
+
status: 400,
|
|
49
|
+
headers: { "content-type": "application/json" },
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return new Response(
|
|
53
|
+
JSON.stringify({
|
|
54
|
+
access_token: "healed-access-token",
|
|
55
|
+
token_type: "bearer",
|
|
56
|
+
expires_in: 3600,
|
|
57
|
+
refresh_token: "rotated-refresh-token",
|
|
58
|
+
scope: "read,write",
|
|
59
|
+
}),
|
|
60
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
61
|
+
);
|
|
62
|
+
}) as typeof fetch;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
beforeAll(async () => {
|
|
66
|
+
await unlink(TEST_DB_PATH).catch(() => {});
|
|
67
|
+
await unlink(`${TEST_DB_PATH}-wal`).catch(() => {});
|
|
68
|
+
await unlink(`${TEST_DB_PATH}-shm`).catch(() => {});
|
|
69
|
+
initDb(TEST_DB_PATH);
|
|
70
|
+
createAgent({ id: LEAD_ID, name: "OAuth Refresh Lead", isLead: true, status: "idle" });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
beforeEach(() => {
|
|
74
|
+
globalThis.fetch = originalFetch;
|
|
75
|
+
clearVolatileSecretsForTesting();
|
|
76
|
+
getDb().run("DELETE FROM oauth_refresh_locks");
|
|
77
|
+
getDb().run("DELETE FROM script_credential_bindings");
|
|
78
|
+
getDb().run("DELETE FROM oauth_authorizations");
|
|
79
|
+
getDb().run("DELETE FROM oauth_apps");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
afterAll(async () => {
|
|
83
|
+
globalThis.fetch = originalFetch;
|
|
84
|
+
closeDb();
|
|
85
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
86
|
+
await unlink(TEST_DB_PATH + suffix).catch(() => {});
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
function seedDefault(provider: string, refreshToken: string | null, expiresInMs: number): string {
|
|
91
|
+
storeOAuthTokens(provider, {
|
|
92
|
+
accessToken: `${provider}-access`,
|
|
93
|
+
refreshToken,
|
|
94
|
+
expiresAt: new Date(Date.now() + expiresInMs).toISOString(),
|
|
95
|
+
scope: "read,write",
|
|
96
|
+
});
|
|
97
|
+
const id = getDb()
|
|
98
|
+
.query(
|
|
99
|
+
`SELECT z.id AS id FROM oauth_authorizations z
|
|
100
|
+
JOIN oauth_apps a ON a.id = z.appId
|
|
101
|
+
WHERE a.provider = ? AND a.mcpServerId IS NULL AND z.label = 'default'`,
|
|
102
|
+
)
|
|
103
|
+
.get(provider) as { id: string } | null;
|
|
104
|
+
if (!id) throw new Error("default authorization not seeded");
|
|
105
|
+
return id.id;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
describe("OAuth refresh failure semantics", () => {
|
|
109
|
+
test("a rejected refresh persists refresh-failed + lastErrorMessage and throws typed error", async () => {
|
|
110
|
+
upsertOAuthApp("acme", appConfig("acme"));
|
|
111
|
+
const authId = seedDefault("acme", "acme-refresh", 60 * 1000);
|
|
112
|
+
mockTokenEndpoint(["acme-refresh"]);
|
|
113
|
+
|
|
114
|
+
await expect(forceRefreshAuthorizationOrThrow(authId)).rejects.toBeInstanceOf(
|
|
115
|
+
OAuthRefreshError,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const after = getAuthorizationById(authId);
|
|
119
|
+
expect(after?.status).toBe("refresh-failed");
|
|
120
|
+
expect(after?.lastErrorMessage).toBeTruthy();
|
|
121
|
+
expect(after?.lastErrorMessage).toContain("400");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("a recovered provider flips a refresh-failed authorization back to active", async () => {
|
|
125
|
+
upsertOAuthApp("acme", appConfig("acme"));
|
|
126
|
+
const authId = seedDefault("acme", "acme-refresh", 60 * 1000);
|
|
127
|
+
|
|
128
|
+
mockTokenEndpoint(["acme-refresh"]);
|
|
129
|
+
await expect(forceRefreshAuthorizationOrThrow(authId)).rejects.toBeInstanceOf(
|
|
130
|
+
OAuthRefreshError,
|
|
131
|
+
);
|
|
132
|
+
expect(getAuthorizationById(authId)?.status).toBe("refresh-failed");
|
|
133
|
+
|
|
134
|
+
// Provider recovers.
|
|
135
|
+
mockTokenEndpoint();
|
|
136
|
+
await forceRefreshAuthorizationOrThrow(authId);
|
|
137
|
+
const healed = getAuthorizationById(authId);
|
|
138
|
+
expect(healed?.status).toBe("active");
|
|
139
|
+
expect(healed?.lastErrorMessage).toBeNull();
|
|
140
|
+
expect(healed?.accessToken).toBe("healed-access-token");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("the broker surfaces a failed OAuth binding while other bindings still resolve", async () => {
|
|
144
|
+
upsertOAuthApp("acme", { ...appConfig("acme"), displayName: "Acme Corp" });
|
|
145
|
+
const authId = seedDefault("acme", "acme-refresh", 60 * 1000);
|
|
146
|
+
upsertCredentialBinding({
|
|
147
|
+
configKey: "ACME_OAUTH",
|
|
148
|
+
allowedHosts: ["api.acme.test"],
|
|
149
|
+
headerTemplate: "Authorization: Bearer [REDACTED:ACME_OAUTH]",
|
|
150
|
+
authKind: "oauth",
|
|
151
|
+
oauthAuthorizationId: authId,
|
|
152
|
+
});
|
|
153
|
+
upsertCredentialBinding({
|
|
154
|
+
configKey: "HEALTHY_CONFIG",
|
|
155
|
+
allowedHosts: ["api.acme.test"],
|
|
156
|
+
headerTemplate: "Authorization: Bearer [REDACTED:HEALTHY_CONFIG]",
|
|
157
|
+
});
|
|
158
|
+
process.env.HEALTHY_CONFIG = "healthy-config-value";
|
|
159
|
+
mockTokenEndpoint(["acme-refresh"]);
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
const { egressSecrets, failedBindings } = await buildScriptCredentialBindingsWithFailures({});
|
|
163
|
+
|
|
164
|
+
expect(egressSecrets.some((b) => b.configKey === "ACME_OAUTH")).toBe(false);
|
|
165
|
+
expect(egressSecrets).toContainEqual(
|
|
166
|
+
expect.objectContaining({ configKey: "HEALTHY_CONFIG", value: "healthy-config-value" }),
|
|
167
|
+
);
|
|
168
|
+
expect(failedBindings).toContainEqual(
|
|
169
|
+
expect.objectContaining({
|
|
170
|
+
placeholder: "[REDACTED:ACME_OAUTH]",
|
|
171
|
+
allowedHosts: ["api.acme.test"],
|
|
172
|
+
reason: "refresh_rejected",
|
|
173
|
+
authorizationLabel: "Acme Corp",
|
|
174
|
+
}),
|
|
175
|
+
);
|
|
176
|
+
// The authorization is now persisted as broken.
|
|
177
|
+
expect(getAuthorizationById(authId)?.status).toBe("refresh-failed");
|
|
178
|
+
} finally {
|
|
179
|
+
delete process.env.HEALTHY_CONFIG;
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("the sandbox fetch throws a typed error for a failed binding's host", async () => {
|
|
184
|
+
try {
|
|
185
|
+
// Observer stub so non-blocked requests pass through without real network I/O.
|
|
186
|
+
let observedCalls = 0;
|
|
187
|
+
globalThis.fetch = (async () => {
|
|
188
|
+
observedCalls++;
|
|
189
|
+
return Response.json({ ok: true });
|
|
190
|
+
}) as typeof fetch;
|
|
191
|
+
|
|
192
|
+
patchFetchWithCredentialBroker(
|
|
193
|
+
[],
|
|
194
|
+
[
|
|
195
|
+
{
|
|
196
|
+
placeholder: "[REDACTED:ACME_OAUTH]",
|
|
197
|
+
allowedHosts: ["api.acme.test"],
|
|
198
|
+
reason: "refresh_rejected",
|
|
199
|
+
authorizationLabel: "Acme Corp",
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
// Targets the failed binding's host WITH its placeholder → typed throw.
|
|
205
|
+
expect(() =>
|
|
206
|
+
fetch("https://api.acme.test/v1/things", {
|
|
207
|
+
headers: { Authorization: "Bearer [REDACTED:ACME_OAUTH]" },
|
|
208
|
+
}),
|
|
209
|
+
).toThrow(/OAuth authorization 'Acme Corp' is in refresh-failed state: refresh_rejected/);
|
|
210
|
+
|
|
211
|
+
// A request to the same host WITHOUT the placeholder is not blocked.
|
|
212
|
+
await fetch("https://api.acme.test/v1/public", { headers: { "x-foo": "bar" } });
|
|
213
|
+
// A different host is not blocked even with the placeholder present.
|
|
214
|
+
await fetch("https://example.com/leak", {
|
|
215
|
+
headers: { Authorization: "Bearer [REDACTED:ACME_OAUTH]" },
|
|
216
|
+
});
|
|
217
|
+
expect(observedCalls).toBe(2);
|
|
218
|
+
} finally {
|
|
219
|
+
globalThis.fetch = originalFetch;
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("two authorizations under one app refresh independently (per-authorization isolation)", async () => {
|
|
224
|
+
upsertOAuthApp("acme", appConfig("acme"));
|
|
225
|
+
const app = getOAuthApp("acme");
|
|
226
|
+
if (!app) throw new Error("app not created");
|
|
227
|
+
|
|
228
|
+
const broken = upsertAuthorization({
|
|
229
|
+
appId: app.id,
|
|
230
|
+
label: "default",
|
|
231
|
+
accessToken: "broken-access",
|
|
232
|
+
refreshToken: "broken-refresh",
|
|
233
|
+
expiresAt: new Date(Date.now() + 60 * 1000).toISOString(),
|
|
234
|
+
status: "active",
|
|
235
|
+
});
|
|
236
|
+
const healthy = upsertAuthorization({
|
|
237
|
+
appId: app.id,
|
|
238
|
+
label: "secondary",
|
|
239
|
+
accessToken: "healthy-access",
|
|
240
|
+
refreshToken: "healthy-refresh",
|
|
241
|
+
expiresAt: new Date(Date.now() + 60 * 1000).toISOString(),
|
|
242
|
+
status: "active",
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
mockTokenEndpoint(["broken-refresh"]);
|
|
246
|
+
|
|
247
|
+
const [brokenResult, healthyResult] = await Promise.allSettled([
|
|
248
|
+
ensureAuthorizationTokenOrThrow(broken.id),
|
|
249
|
+
ensureAuthorizationTokenOrThrow(healthy.id),
|
|
250
|
+
]);
|
|
251
|
+
|
|
252
|
+
expect(brokenResult.status).toBe("rejected");
|
|
253
|
+
if (brokenResult.status === "rejected") {
|
|
254
|
+
expect(brokenResult.reason).toBeInstanceOf(OAuthRefreshError);
|
|
255
|
+
}
|
|
256
|
+
expect(healthyResult.status).toBe("fulfilled");
|
|
257
|
+
|
|
258
|
+
expect(getAuthorizationById(broken.id)?.status).toBe("refresh-failed");
|
|
259
|
+
const healed = getAuthorizationById(healthy.id);
|
|
260
|
+
expect(healed?.status).toBe("active");
|
|
261
|
+
expect(healed?.accessToken).toBe("healed-access-token");
|
|
262
|
+
});
|
|
263
|
+
});
|
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { unlink } from "node:fs/promises";
|
|
3
3
|
import { closeDb, getDb, initDb } from "../be/db";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
getAuthorizationById,
|
|
6
|
+
getDefaultAuthorizationIdForProvider,
|
|
7
|
+
getOAuthTokens,
|
|
8
|
+
storeOAuthTokens,
|
|
9
|
+
upsertOAuthApp,
|
|
10
|
+
} from "../be/db-queries/oauth";
|
|
5
11
|
import { sweepOAuthTokenRefresh } from "../be/oauth-refresh-sweep";
|
|
6
12
|
|
|
13
|
+
function authorizationStatus(provider: string): string | undefined {
|
|
14
|
+
const id = getDefaultAuthorizationIdForProvider(provider);
|
|
15
|
+
return id ? getAuthorizationById(id)?.status : undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
7
18
|
const TEST_DB_PATH = "./test-oauth-refresh-sweep.sqlite";
|
|
8
19
|
|
|
9
20
|
const originalFetch = globalThis.fetch;
|
|
@@ -34,7 +45,11 @@ function seedTokens(
|
|
|
34
45
|
function backdateTokenRow(provider: string, ageMs: number): void {
|
|
35
46
|
const backdated = new Date(Date.now() - ageMs).toISOString();
|
|
36
47
|
getDb()
|
|
37
|
-
.query(
|
|
48
|
+
.query(
|
|
49
|
+
`UPDATE oauth_authorizations SET updatedAt = ?
|
|
50
|
+
WHERE appId = (SELECT id FROM oauth_apps WHERE provider = ? AND mcpServerId IS NULL LIMIT 1)
|
|
51
|
+
AND label = 'default'`,
|
|
52
|
+
)
|
|
38
53
|
.run(backdated, provider);
|
|
39
54
|
}
|
|
40
55
|
|
|
@@ -73,7 +88,7 @@ beforeAll(() => {
|
|
|
73
88
|
beforeEach(() => {
|
|
74
89
|
globalThis.fetch = originalFetch;
|
|
75
90
|
getDb().run("DELETE FROM oauth_refresh_locks");
|
|
76
|
-
getDb().run("DELETE FROM
|
|
91
|
+
getDb().run("DELETE FROM oauth_authorizations");
|
|
77
92
|
getDb().run("DELETE FROM oauth_apps");
|
|
78
93
|
});
|
|
79
94
|
|
|
@@ -112,6 +127,28 @@ describe("sweepOAuthTokenRefresh", () => {
|
|
|
112
127
|
expect(getOAuthTokens("vendor_a")?.accessToken).toBe("vendor_a-old-access-token");
|
|
113
128
|
});
|
|
114
129
|
|
|
130
|
+
test("skips a non-expiring (NULL expiry) row instead of proactively refreshing it", async () => {
|
|
131
|
+
upsertOAuthApp("vendor_a", appConfig("vendor_a"));
|
|
132
|
+
// GitHub-preset shape: a live token WITH a refresh token but NO expiry.
|
|
133
|
+
// NULL expiry means "does not expire" — the sweep must not treat it as
|
|
134
|
+
// expiring, must not refresh it proactively, and must never mark it
|
|
135
|
+
// refresh-failed.
|
|
136
|
+
storeOAuthTokens("vendor_a", {
|
|
137
|
+
accessToken: "vendor_a-old-access-token",
|
|
138
|
+
refreshToken: "vendor_a-refresh-token",
|
|
139
|
+
expiresAt: null,
|
|
140
|
+
scope: "read,write",
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const captured = mockTokenEndpoint();
|
|
144
|
+
const result = await sweepOAuthTokenRefresh();
|
|
145
|
+
|
|
146
|
+
expect(result).toEqual({ checked: 1, refreshed: 0, skipped: 1, failed: [] });
|
|
147
|
+
expect(captured).toHaveLength(0);
|
|
148
|
+
expect(authorizationStatus("vendor_a")).toBe("active");
|
|
149
|
+
expect(getOAuthTokens("vendor_a")?.accessToken).toBe("vendor_a-old-access-token");
|
|
150
|
+
});
|
|
151
|
+
|
|
115
152
|
test("skips fresh rows that are neither expiring nor stale", async () => {
|
|
116
153
|
upsertOAuthApp("vendor_a", appConfig("vendor_a"));
|
|
117
154
|
seedTokens("vendor_a", { expiresInMs: 24 * 60 * 60 * 1000 }); // expires in 24h, just updated
|
|
@@ -156,5 +193,48 @@ describe("sweepOAuthTokenRefresh", () => {
|
|
|
156
193
|
]);
|
|
157
194
|
expect(getOAuthTokens("a_broken")?.accessToken).toBe("a_broken-old-access-token");
|
|
158
195
|
expect(getOAuthTokens("b_healthy")?.accessToken).toBe("new-access-token");
|
|
196
|
+
// A failing refresh persists refresh-failed on the authorization; the
|
|
197
|
+
// healthy one stays active.
|
|
198
|
+
expect(authorizationStatus("a_broken")).toBe("refresh-failed");
|
|
199
|
+
expect(authorizationStatus("b_healthy")).toBe("active");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("a refresh-failed authorization stays in the sweep and heals on the next pass", async () => {
|
|
203
|
+
upsertOAuthApp("vendor_a", appConfig("vendor_a"));
|
|
204
|
+
// Not expiring on its own — the only reason it should be swept is the
|
|
205
|
+
// persisted refresh-failed status.
|
|
206
|
+
seedTokens("vendor_a", { expiresInMs: 24 * 60 * 60 * 1000 });
|
|
207
|
+
|
|
208
|
+
// Pass 1: provider is down → status flips to refresh-failed.
|
|
209
|
+
mockTokenEndpoint(["https://oauth.vendor_a.test/token"]);
|
|
210
|
+
// Force a first failure via a near-expiry token so the sweep attempts it.
|
|
211
|
+
getDb()
|
|
212
|
+
.query(
|
|
213
|
+
`UPDATE oauth_authorizations SET expiresAt = ?
|
|
214
|
+
WHERE appId = (SELECT id FROM oauth_apps WHERE provider = 'vendor_a' AND mcpServerId IS NULL LIMIT 1)
|
|
215
|
+
AND label = 'default'`,
|
|
216
|
+
)
|
|
217
|
+
.run(new Date(Date.now() + 60 * 1000).toISOString());
|
|
218
|
+
const first = await sweepOAuthTokenRefresh();
|
|
219
|
+
expect(first.failed).toHaveLength(1);
|
|
220
|
+
expect(authorizationStatus("vendor_a")).toBe("refresh-failed");
|
|
221
|
+
|
|
222
|
+
// Bump expiry far into the future so the ONLY reason to sweep is the
|
|
223
|
+
// refresh-failed status, proving failed rows are retried each pass.
|
|
224
|
+
getDb()
|
|
225
|
+
.query(
|
|
226
|
+
`UPDATE oauth_authorizations SET expiresAt = ?
|
|
227
|
+
WHERE appId = (SELECT id FROM oauth_apps WHERE provider = 'vendor_a' AND mcpServerId IS NULL LIMIT 1)
|
|
228
|
+
AND label = 'default'`,
|
|
229
|
+
)
|
|
230
|
+
.run(new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString());
|
|
231
|
+
|
|
232
|
+
// Pass 2: provider recovers → refresh succeeds → status back to active.
|
|
233
|
+
mockTokenEndpoint();
|
|
234
|
+
const second = await sweepOAuthTokenRefresh();
|
|
235
|
+
expect(second.refreshed).toBe(1);
|
|
236
|
+
expect(second.failed).toHaveLength(0);
|
|
237
|
+
expect(authorizationStatus("vendor_a")).toBe("active");
|
|
238
|
+
expect(getOAuthTokens("vendor_a")?.accessToken).toBe("new-access-token");
|
|
159
239
|
});
|
|
160
240
|
});
|
|
@@ -1,12 +1,10 @@
|
|
|
1
|
-
import { afterAll, beforeAll,
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test";
|
|
2
2
|
import { unlink } from "node:fs/promises";
|
|
3
3
|
import { closeDb, initDb } from "../be/db";
|
|
4
|
-
import { upsertOAuthApp } from "../be/db-queries/oauth";
|
|
4
|
+
import { consumeOAuthPending, upsertOAuthApp } from "../be/db-queries/oauth";
|
|
5
5
|
import {
|
|
6
|
-
_clearPendingStates,
|
|
7
|
-
_getPendingState,
|
|
8
6
|
buildAuthorizationUrl,
|
|
9
|
-
|
|
7
|
+
exchangeAuthorizationCode,
|
|
10
8
|
type OAuthProviderConfig,
|
|
11
9
|
} from "../oauth/wrapper";
|
|
12
10
|
|
|
@@ -18,14 +16,14 @@ const testConfig: OAuthProviderConfig = {
|
|
|
18
16
|
clientSecret: "test-client-secret",
|
|
19
17
|
authorizeUrl: "https://example.com/oauth/authorize",
|
|
20
18
|
tokenUrl: "https://example.com/oauth/token",
|
|
21
|
-
redirectUri: "http://localhost:3013/callback",
|
|
19
|
+
redirectUri: "http://localhost:3013/api/oauth/callback",
|
|
22
20
|
scopes: ["read", "write"],
|
|
23
21
|
extraParams: { actor: "app" },
|
|
24
22
|
};
|
|
25
23
|
|
|
26
24
|
beforeAll(() => {
|
|
27
25
|
initDb(TEST_DB_PATH);
|
|
28
|
-
// Create an oauth_app row so
|
|
26
|
+
// Create an oauth_app row so pending persistence works (FK constraint on appId).
|
|
29
27
|
upsertOAuthApp("test-provider", {
|
|
30
28
|
clientId: testConfig.clientId,
|
|
31
29
|
clientSecret: testConfig.clientSecret,
|
|
@@ -36,10 +34,6 @@ beforeAll(() => {
|
|
|
36
34
|
});
|
|
37
35
|
});
|
|
38
36
|
|
|
39
|
-
beforeEach(() => {
|
|
40
|
-
_clearPendingStates();
|
|
41
|
-
});
|
|
42
|
-
|
|
43
37
|
afterAll(async () => {
|
|
44
38
|
closeDb();
|
|
45
39
|
await unlink(TEST_DB_PATH).catch(() => {});
|
|
@@ -58,7 +52,7 @@ describe("buildAuthorizationUrl", () => {
|
|
|
58
52
|
const url = new URL(result.url);
|
|
59
53
|
expect(url.origin + url.pathname).toBe("https://example.com/oauth/authorize");
|
|
60
54
|
expect(url.searchParams.get("client_id")).toBe("test-client-id");
|
|
61
|
-
expect(url.searchParams.get("redirect_uri")).toBe("http://localhost:3013/callback");
|
|
55
|
+
expect(url.searchParams.get("redirect_uri")).toBe("http://localhost:3013/api/oauth/callback");
|
|
62
56
|
expect(url.searchParams.get("response_type")).toBe("code");
|
|
63
57
|
expect(url.searchParams.get("scope")).toBe("read,write");
|
|
64
58
|
expect(url.searchParams.get("state")).toBe(result.state);
|
|
@@ -72,14 +66,21 @@ describe("buildAuthorizationUrl", () => {
|
|
|
72
66
|
expect(url.searchParams.get("actor")).toBe("app");
|
|
73
67
|
});
|
|
74
68
|
|
|
75
|
-
test("
|
|
69
|
+
test("persists a DB pending row (default label, generic flow) with the code verifier", async () => {
|
|
76
70
|
const result = await buildAuthorizationUrl(testConfig);
|
|
77
|
-
const pending =
|
|
71
|
+
const pending = consumeOAuthPending(result.state);
|
|
78
72
|
|
|
79
73
|
expect(pending).toBeTruthy();
|
|
80
74
|
expect(pending!.codeVerifier).toBe(result.codeVerifier);
|
|
81
|
-
expect(pending!.
|
|
82
|
-
expect(pending!.
|
|
75
|
+
expect(pending!.label).toBe("default");
|
|
76
|
+
expect(pending!.flow).toBe("generic");
|
|
77
|
+
expect(pending!.redirectUri).toBe(testConfig.redirectUri);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("honors an explicit label option", async () => {
|
|
81
|
+
const result = await buildAuthorizationUrl(testConfig, { label: "support" });
|
|
82
|
+
const pending = consumeOAuthPending(result.state);
|
|
83
|
+
expect(pending!.label).toBe("support");
|
|
83
84
|
});
|
|
84
85
|
|
|
85
86
|
test("generates unique state for each call", async () => {
|
|
@@ -90,60 +91,81 @@ describe("buildAuthorizationUrl", () => {
|
|
|
90
91
|
expect(result1.codeVerifier).not.toBe(result2.codeVerifier);
|
|
91
92
|
});
|
|
92
93
|
|
|
93
|
-
test("
|
|
94
|
-
|
|
95
|
-
...testConfig,
|
|
96
|
-
|
|
97
|
-
|
|
94
|
+
test("throws when the provider has no configured app", async () => {
|
|
95
|
+
await expect(
|
|
96
|
+
buildAuthorizationUrl({ ...testConfig, provider: "no-such-provider" }),
|
|
97
|
+
).rejects.toThrow("is not configured");
|
|
98
|
+
});
|
|
98
99
|
|
|
100
|
+
test("works without extra params", async () => {
|
|
101
|
+
const configNoExtras: OAuthProviderConfig = { ...testConfig, extraParams: undefined };
|
|
99
102
|
const result = await buildAuthorizationUrl(configNoExtras);
|
|
100
103
|
const url = new URL(result.url);
|
|
101
104
|
expect(url.searchParams.get("actor")).toBeNull();
|
|
102
105
|
});
|
|
103
106
|
});
|
|
104
107
|
|
|
105
|
-
describe("
|
|
106
|
-
test("
|
|
107
|
-
|
|
108
|
-
|
|
108
|
+
describe("exchangeAuthorizationCode", () => {
|
|
109
|
+
test("exchanges a code for tokens (pure — no persistence)", async () => {
|
|
110
|
+
const fetchSpy = spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
|
111
|
+
Response.json({
|
|
112
|
+
access_token: "atk",
|
|
113
|
+
token_type: "Bearer",
|
|
114
|
+
expires_in: 3600,
|
|
115
|
+
refresh_token: "rtk",
|
|
116
|
+
scope: "read write",
|
|
117
|
+
}),
|
|
109
118
|
);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
test("rejects already-consumed state", async () => {
|
|
113
|
-
const result = await buildAuthorizationUrl(testConfig);
|
|
114
|
-
|
|
115
|
-
// Mock fetch to fail immediately (avoids real network call to example.com)
|
|
116
|
-
const fetchSpy = spyOn(globalThis, "fetch").mockRejectedValueOnce(new Error("Network error"));
|
|
117
119
|
try {
|
|
118
|
-
await
|
|
119
|
-
|
|
120
|
-
|
|
120
|
+
const tokens = await exchangeAuthorizationCode(testConfig, {
|
|
121
|
+
code: "the-code",
|
|
122
|
+
codeVerifier: "the-verifier",
|
|
123
|
+
redirectUri: testConfig.redirectUri,
|
|
124
|
+
});
|
|
125
|
+
expect(tokens.accessToken).toBe("atk");
|
|
126
|
+
expect(tokens.refreshToken).toBe("rtk");
|
|
127
|
+
expect(tokens.expiresIn).toBe(3600);
|
|
128
|
+
expect(tokens.scope).toBe("read write");
|
|
129
|
+
const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
130
|
+
expect(String(init.body)).toContain("grant_type=authorization_code");
|
|
131
|
+
expect(String(init.body)).toContain("code=the-code");
|
|
121
132
|
} finally {
|
|
122
133
|
fetchSpy.mockRestore();
|
|
123
134
|
}
|
|
135
|
+
});
|
|
124
136
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
"
|
|
137
|
+
test("throws on a non-OK token response", async () => {
|
|
138
|
+
const fetchSpy = spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
|
139
|
+
new Response("nope", { status: 400 }),
|
|
128
140
|
);
|
|
141
|
+
try {
|
|
142
|
+
await expect(
|
|
143
|
+
exchangeAuthorizationCode(testConfig, {
|
|
144
|
+
code: "x",
|
|
145
|
+
codeVerifier: "y",
|
|
146
|
+
redirectUri: testConfig.redirectUri,
|
|
147
|
+
}),
|
|
148
|
+
).rejects.toThrow("Token exchange failed");
|
|
149
|
+
} finally {
|
|
150
|
+
fetchSpy.mockRestore();
|
|
151
|
+
}
|
|
129
152
|
});
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
describe("state TTL cleanup", () => {
|
|
133
|
-
test("expired states are cleaned up on next buildAuthorizationUrl call", async () => {
|
|
134
|
-
// Manually insert an "expired" entry by backdating createdAt
|
|
135
|
-
const result = await buildAuthorizationUrl(testConfig);
|
|
136
|
-
const pending = _getPendingState(result.state);
|
|
137
|
-
expect(pending).toBeTruthy();
|
|
138
|
-
|
|
139
|
-
// Backdate to 11 minutes ago (past the 10-minute TTL)
|
|
140
|
-
pending!.createdAt = Date.now() - 11 * 60 * 1000;
|
|
141
153
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
154
|
+
test("sends Accept: application/json so form-encoding providers (GitHub) return JSON", async () => {
|
|
155
|
+
const fetchSpy = spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
|
156
|
+
Response.json({ access_token: "atk", token_type: "Bearer" }),
|
|
157
|
+
);
|
|
158
|
+
try {
|
|
159
|
+
await exchangeAuthorizationCode(testConfig, {
|
|
160
|
+
code: "the-code",
|
|
161
|
+
codeVerifier: "the-verifier",
|
|
162
|
+
redirectUri: testConfig.redirectUri,
|
|
163
|
+
});
|
|
164
|
+
const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
165
|
+
const headers = init.headers as Record<string, string>;
|
|
166
|
+
expect(headers.Accept).toBe("application/json");
|
|
167
|
+
} finally {
|
|
168
|
+
fetchSpy.mockRestore();
|
|
169
|
+
}
|
|
148
170
|
});
|
|
149
171
|
});
|
|
@@ -60,12 +60,13 @@ describe("Session templates — registration", () => {
|
|
|
60
60
|
await ensureTemplatesRegistered();
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
-
test("all
|
|
63
|
+
test("all 17 system templates are registered", () => {
|
|
64
64
|
const systemTemplates = [
|
|
65
65
|
"system.agent.role",
|
|
66
66
|
"system.agent.register",
|
|
67
67
|
"system.agent.lead",
|
|
68
68
|
"system.agent.slack",
|
|
69
|
+
"system.agent.messaging",
|
|
69
70
|
"system.agent.worker",
|
|
70
71
|
"system.agent.worker.slack",
|
|
71
72
|
"system.agent.filesystem",
|
|
@@ -98,13 +99,13 @@ describe("Session templates — registration", () => {
|
|
|
98
99
|
}
|
|
99
100
|
});
|
|
100
101
|
|
|
101
|
-
test("total of
|
|
102
|
+
test("total of 25 session/system templates registered", () => {
|
|
102
103
|
const all = getAllTemplateDefinitions();
|
|
103
104
|
const sessionSystem = all.filter((d) => d.category === "system" || d.category === "session");
|
|
104
|
-
//
|
|
105
|
+
// 25 = the original 19 + `system.session.worker.pi` + `system.agent.seed_scripts`
|
|
105
106
|
// + `system.agent.script_rubric` + `system.agent.scripts_only_mode`
|
|
106
|
-
// + `system.agent.scripts_only_mode.slack`.
|
|
107
|
-
expect(sessionSystem.length).toBe(
|
|
107
|
+
// + `system.agent.scripts_only_mode.slack` + `system.agent.messaging`.
|
|
108
|
+
expect(sessionSystem.length).toBe(25);
|
|
108
109
|
});
|
|
109
110
|
});
|
|
110
111
|
|