@desplega.ai/agent-swarm 1.129.0 → 1.131.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-kfv2dawe.js → actions-w53mbns9.js} +11 -11
- package/dist/{anthropic-messages-3xgme8my.js → anthropic-messages-rjmd2e7n.js} +5 -5
- package/dist/{app-q8vjk6e8.js → app-m9gwqsqv.js} +8 -8
- package/dist/{artifact-hg4t1nx7.js → artifact-jysj3wrg.js} +2 -2
- package/dist/{assistant-g037r345.js → assistant-bp65m6f3.js} +13 -13
- package/dist/{azure-openai-responses-xa4sh9f6.js → azure-openai-responses-c0yjmbfd.js} +8 -5
- package/dist/{boot-reembed-18pzsrxa.js → boot-reembed-62p0at2k.js} +6 -6
- package/dist/{boot-reembed-wpks2cj8.js → boot-reembed-xmfp6wdh.js} +7 -7
- package/dist/{boot-scrub-logs-n7szd57r.js → boot-scrub-logs-rr8q2dms.js} +5 -5
- package/dist/{claude-adapter-qz4r0ajt.js → claude-adapter-2y47mrkd.js} +9 -9
- package/dist/{cli-mhjnbgb6.js → cli-0rcyq6d9.js} +450 -186
- package/dist/{cli-ttkmehy0.js → cli-2q72qz2s.js} +274 -25
- package/dist/{cli-gztgc3hz.js → cli-2wadx7p2.js} +2 -2
- package/dist/cli-3fj60etf.js +153 -0
- package/dist/{cli-7ecgm5v0.js → cli-3kf51j15.js} +1 -1
- package/dist/{cli-vgexpfsr.js → cli-57khth04.js} +6 -3
- package/dist/{cli-2mbe7vk0.js → cli-5dsbcjjw.js} +1 -1
- package/dist/{cli-etr62zy6.js → cli-5kx36evn.js} +2 -2
- package/dist/{cli-v2tr6amf.js → cli-5n6e5yfn.js} +30 -8
- package/dist/{cli-hx02s9zq.js → cli-5v4rw6yd.js} +2 -2
- package/dist/{cli-v37yr6mf.js → cli-5ze9rv97.js} +4 -4
- package/dist/{cli-ch7t5v7n.js → cli-6map995v.js} +9 -4
- package/dist/{cli-h0457a67.js → cli-70xqj0z3.js} +3 -3
- package/dist/{cli-kk4f9r17.js → cli-72q8m8gk.js} +8 -6
- package/dist/{cli-7padk69a.js → cli-78xwvkhj.js} +2 -2
- package/dist/{cli-dxtxacy7.js → cli-9dr5qzc0.js} +45 -8
- package/dist/{cli-17zxdgsq.js → cli-a40rnf71.js} +6 -6
- package/dist/{cli-t53vnnxz.js → cli-a4jzdpa3.js} +2514 -1055
- package/dist/{cli-w10fkrf1.js → cli-akdvwc9v.js} +354 -115
- package/dist/{cli-1qkk5er0.js → cli-bmze986d.js} +1 -1
- package/dist/{cli-ga12dc1d.js → cli-bqw0mt1n.js} +3 -3
- package/dist/{cli-0sf92efp.js → cli-cdeg08wx.js} +1 -1
- package/dist/{cli-a8ynhkyj.js → cli-cvgjwnh1.js} +17 -15
- package/dist/{cli-9fxg3v5m.js → cli-d2tfkcga.js} +4 -4
- package/dist/{cli-nhcd7x43.js → cli-d3x3x1c9.js} +2 -2
- package/dist/{cli-6nh2qxvh.js → cli-darcwax3.js} +1 -1
- package/dist/{cli-yjbgmysx.js → cli-e7ckcg4v.js} +12 -12
- package/dist/{cli-4n2gcjpd.js → cli-efxfnq6q.js} +4 -3
- package/dist/{cli-z9xqqhfk.js → cli-er3a4bb0.js} +1 -1
- package/dist/{cli-xg2eezyv.js → cli-f1am9szv.js} +1 -1
- package/dist/{cli-xy4cwsm9.js → cli-fywms69x.js} +1 -1
- package/dist/{cli-33jj76y8.js → cli-g6b8x1h8.js} +1 -1
- package/dist/{cli-epsvdckx.js → cli-gm9cbhbq.js} +20 -10
- package/dist/{cli-rkndnn90.js → cli-k9qkf1hw.js} +6 -4
- package/dist/{cli-zejvt395.js → cli-kh3ba2zf.js} +1 -1
- package/dist/{cli-gb25p1h2.js → cli-nm5gtp1x.js} +2 -2
- package/dist/{cli-akxnpx17.js → cli-pm6xbh84.js} +1829 -1629
- package/dist/{cli-2k0ztbfp.js → cli-psh3c4va.js} +1 -1
- package/dist/{cli-39vgrsep.js → cli-qv61hp37.js} +2 -2
- package/dist/{cli-hv2fndhm.js → cli-r6kqqbj0.js} +6 -4
- package/dist/{cli-1y1cm570.js → cli-rzbpmxdr.js} +1 -1
- package/dist/{cli-mmrk00vp.js → cli-t1xze5qh.js} +1 -1
- package/dist/{cli-g252a9ky.js → cli-t55yw3a1.js} +2 -2
- package/dist/{cli-7bz8dzqa.js → cli-t76vw5db.js} +1184 -1522
- package/dist/{cli-4n4mdv21.js → cli-xpxde7rm.js} +7 -3
- package/dist/{cli-fpaa5k2q.js → cli-yw8vghx2.js} +54 -18
- package/dist/cli-zsspvvm2.js +490 -0
- package/dist/cli.js +27 -25
- package/dist/{codex-adapter-7v6t7ff6.js → codex-adapter-d24q75wk.js} +7 -7
- package/dist/{codex-hook-amndawyb.js → codex-hook-hq54t9ft.js} +2 -2
- package/dist/{codex-session-runner-e7j4khe2.js → codex-session-runner-g50vcsex.js} +7 -7
- package/dist/{commands-ex0k5tbf.js → commands-c3nrc5hg.js} +5 -5
- package/dist/{db-df7jatfc.js → db-nt7fg3tw.js} +5 -5
- package/dist/{google-generative-ai-y42hnw9p.js → google-generative-ai-hysrd8qx.js} +7 -5
- package/dist/{google-vertex-4sg66gb6.js → google-vertex-p5arrga0.js} +7 -5
- package/dist/{handlers-1j2cfqt2.js → handlers-fkr6swat.js} +13 -13
- package/dist/{hook-nx9a0mj3.js → hook-77wpvnjc.js} +7 -7
- package/dist/{hook-w6yaf6sy.js → hook-g019yehy.js} +8 -8
- package/dist/{http-4tz5h8wg.js → http-jewj0syk.js} +2993 -716
- package/dist/{index-0bpkzb09.js → index-070v4eyb.js} +3 -3
- package/dist/{index-0s67qv7h.js → index-6gm5he8h.js} +11 -11
- package/dist/{index-35gtv6gj.js → index-7n2xpbd4.js} +310 -40
- package/dist/{index-ker3gccb.js → index-9znhar3x.js} +4 -4
- package/dist/{index-5n2c8yay.js → index-hase6zbe.js} +2 -2
- package/dist/{index-kcf3tm9h.js → index-hfvggd0z.js} +12 -12
- package/dist/{index-7c0jqna0.js → index-hp1kdbjj.js} +13 -13
- package/dist/{keepalive-3j4a9168.js → keepalive-j0fjvqfd.js} +7 -7
- package/dist/{lead-6kpevymb.js → lead-1n8x30xe.js} +33 -33
- package/dist/{maintenance-m1k82ean.js → maintenance-gvev11sa.js} +7 -7
- package/dist/{mistral-conversations-bjkdfb6z.js → mistral-conversations-81fwav0j.js} +6 -6
- package/dist/{oauth-refresh-sweep-881k8e4q.js → oauth-refresh-sweep-s9ecsvh0.js} +6 -6
- package/dist/{onboard-c0ykffjn.js → onboard-pvm5b38w.js} +2 -2
- package/dist/{openai-codex-responses-xetseqvt.js → openai-codex-responses-5g27pd6f.js} +43 -23
- package/dist/{openai-completions-5r2g5axa.js → openai-completions-3vkmfz2a.js} +55 -11
- package/dist/{openai-responses-ce67hd23.js → openai-responses-dg4t7j7a.js} +8 -5
- package/dist/{openrouter-images-pzfp22jb.js → openrouter-images-g3gm8931.js} +1 -1
- package/dist/{otel-impl-sqagjerv.js → otel-impl-p6jvgd9c.js} +6 -4
- package/dist/{otel-3mp81ppd.js → otel-nqa12wf3.js} +2 -2
- package/dist/{pi-mono-adapter-k584k16g.js → pi-mono-adapter-t091s27g.js} +17117 -7517
- package/dist/{pricing-refresh-y82whbf9.js → pricing-refresh-k49rcyed.js} +7 -7
- package/dist/{rbac-roles-nvkqmzyd.js → rbac-roles-30pspvy7.js} +7 -7
- package/dist/{rbac-roles-4ht8gegc.js → rbac-roles-cr80g96t.js} +6 -6
- package/dist/{render-v2-wvqdgvvp.js → render-v2-xm457ng1.js} +8 -8
- package/dist/{seed-pricing-fy3rxhde.js → seed-pricing-yhpv35fr.js} +6 -6
- package/dist/{setup-ak5cnc6f.js → setup-nytnmwwr.js} +2 -2
- package/dist/{worker-d8769q8a.js → worker-msytr2cw.js} +33 -33
- package/openapi.json +34039 -11036
- package/package.json +8 -6
- package/src/apps/definition.ts +368 -26
- package/src/apps/row-store.ts +66 -10
- package/src/apps/schema-migrate.ts +95 -1
- package/src/apps/store.ts +13 -4
- package/src/apps/sync-run-as.ts +18 -0
- package/src/apps/sync.ts +934 -0
- package/src/apps/version.ts +16 -2
- package/src/assets/key.ts +8 -1
- package/src/be/asset-key-audit.ts +3 -1
- package/src/be/db-queries/mcp-oauth.ts +297 -24
- package/src/be/db.ts +42 -1
- package/src/be/migrations/129_asset_keys_apps_scripts.sql +161 -0
- package/src/be/scripts/db.ts +5 -2
- package/src/be/scripts/typecheck.ts +1 -0
- package/src/be/seed-scripts/catalog/app-sync-run.ts +32 -0
- package/src/be/seed-scripts/catalog/github-issues-pull.ts +102 -0
- package/src/be/seed-scripts/index.ts +18 -0
- package/src/be/seed-skills/index.ts +7 -0
- package/src/http/active-sessions.ts +26 -18
- package/src/http/agents.ts +94 -27
- package/src/http/api-keys.ts +91 -17
- package/src/http/approval-requests.ts +87 -10
- package/src/http/apps.ts +361 -53
- package/src/http/assets.ts +165 -31
- package/src/http/budgets.ts +7 -5
- package/src/http/codex-oauth-keep-warm.ts +21 -3
- package/src/http/config.ts +46 -16
- package/src/http/context.ts +33 -6
- package/src/http/ecosystem.ts +20 -6
- package/src/http/events.ts +24 -9
- package/src/http/favorites.ts +20 -6
- package/src/http/fs.ts +60 -17
- package/src/http/heartbeat.ts +18 -4
- package/src/http/inbox-state.ts +12 -6
- package/src/http/integrations.ts +25 -5
- package/src/http/kv.ts +36 -15
- package/src/http/mcp-bridge.ts +5 -1
- package/src/http/mcp-oauth.ts +332 -17
- package/src/http/mcp-servers.ts +56 -18
- package/src/http/memory.ts +282 -29
- package/src/http/metrics.ts +80 -17
- package/src/http/oauth-callback.ts +10 -4
- package/src/http/oauth-generic.ts +5 -1
- package/src/http/oauth-locks.ts +6 -3
- package/src/http/openapi.ts +57 -9
- package/src/http/page-proxy.ts +25 -5
- package/src/http/pages-public.ts +37 -20
- package/src/http/pages.ts +93 -28
- package/src/http/poll.ts +89 -5
- package/src/http/pricing.ts +9 -6
- package/src/http/prompt-templates.ts +85 -21
- package/src/http/route-def.ts +67 -4
- package/src/http/schedules.ts +63 -14
- package/src/http/script-connection-proxy.ts +30 -4
- package/src/http/script-connections.ts +393 -43
- package/src/http/script-runs.ts +73 -19
- package/src/http/scripts.ts +257 -39
- package/src/http/session-data.ts +87 -14
- package/src/http/sessions.ts +80 -5
- package/src/http/skills.ts +115 -35
- package/src/http/stats.ts +94 -13
- package/src/http/task-templates.ts +6 -4
- package/src/http/tasks.ts +230 -61
- package/src/http/trackers/jira.ts +90 -30
- package/src/http/trackers/linear.ts +46 -12
- package/src/http/users.ts +162 -25
- package/src/http/utils.ts +12 -2
- package/src/http/webhooks.ts +30 -16
- package/src/http/workflow-events.ts +18 -5
- package/src/http/workflows.ts +128 -39
- package/src/http/x.ts +37 -6
- package/src/oauth/ensure-mcp-token.ts +16 -1
- package/src/oauth/mcp-wrapper.ts +281 -26
- package/src/providers/pi-mono-adapter.ts +1 -1
- package/src/scripts-runtime/credential-broker/README.md +1 -1
- package/src/scripts-runtime/credential-broker/fetch-patch.ts +12 -2
- package/src/scripts-runtime/sdk-allowlist.ts +1 -0
- package/src/scripts-runtime/stdlib/fetch.ts +6 -0
- package/src/scripts-runtime/types/stdlib.d.ts +1 -0
- package/src/scripts-runtime/types/swarm-sdk.d.ts +1 -0
- package/src/server.ts +2 -0
- package/src/tests/apps-rbac.test.ts +110 -0
- package/src/tests/apps-spike2.test.ts +4 -1
- package/src/tests/apps-sync-engine.test.ts +2540 -0
- package/src/tests/apps-sync.test.ts +1627 -0
- package/src/tests/asset-key-api.test.ts +48 -2
- package/src/tests/asset-key-invariants.test.ts +92 -0
- package/src/tests/asset-key-migration.test.ts +174 -8
- package/src/tests/asset-key-normalization.test.ts +2 -0
- package/src/tests/credential-broker.test.ts +27 -2
- package/src/tests/mcp-oauth-dcr-reuse.test.ts +743 -0
- package/src/tests/mcp-oauth-ensure-token.test.ts +45 -0
- package/src/tests/mcp-oauth-manual-client.test.ts +135 -2
- package/src/tests/mcp-oauth-queries.test.ts +153 -0
- package/src/tests/mcp-oauth-wrapper.test.ts +432 -0
- package/src/tests/memory-http-recall-gating.test.ts +2 -0
- package/src/tests/openapi-response-contract.test.ts +138 -0
- package/src/tests/rbac-admission-e2e.test.ts +68 -0
- package/src/tests/script-connections.test.ts +3 -0
- package/src/tests/scripts-runtime-fetch.test.ts +121 -0
- package/src/tests/scripts-runtime-secret-egress.test.ts +4 -2
- package/src/tests/seed-scripts.test.ts +2 -2
- package/src/tests/swarm-tool-result-gate.test.ts +32 -0
- package/src/tests/task-working-dir.test.ts +8 -4
- package/src/tests/whoami-route.test.ts +127 -0
- package/src/tests/workflow-engine-v2.test.ts +80 -7
- package/src/tests/workflow-swarm-script.test.ts +104 -0
- package/src/tools/app-get.ts +10 -2
- package/src/tools/app-rollback.ts +1 -0
- package/src/tools/app-sync.ts +112 -0
- package/src/tools/script-common.ts +20 -0
- package/src/tools/tool-config.ts +2 -1
- package/src/tools/utils.ts +11 -0
- package/src/tools/workflows/create-workflow.ts +4 -1
- package/src/types.ts +1394 -1206
- package/src/utils/template.ts +8 -1
- package/src/utils/zod-openapi.ts +11 -0
- package/src/workflows/engine.ts +27 -4
- package/src/workflows/executors/swarm-script.ts +28 -0
- package/src/workflows/retry-poller.ts +19 -2
- package/templates/skills/apps/content.md +94 -5
- package/templates/skills/asset-namespaces/content.md +2 -2
- package/templates/skills/scheduled-task-resilience/SKILL.md +102 -0
- package/templates/skills/scheduled-task-resilience/config.json +14 -0
- package/templates/skills/scheduled-task-resilience/content.md +97 -0
- package/dist/cli-7wmybrt5.js +0 -115
- package/dist/cli-d91pvmq1.js +0 -14
package/src/oauth/mcp-wrapper.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as oauth from "oauth4webapi";
|
|
2
2
|
import { isEnvFlagEnabled } from "../utils/env-flag";
|
|
3
|
+
import { registerVolatileSecret, scrubSecrets } from "../utils/secret-scrubber";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* MCP OAuth 2.1 wrapper.
|
|
@@ -54,6 +55,15 @@ function isPrivateIPv6(host: string): boolean {
|
|
|
54
55
|
return false;
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
// Deliberately does NOT match "invalid_client_metadata" (a distinct DCR-only
|
|
59
|
+
// error) — the `\b` boundary fails on the following underscore. Shared by
|
|
60
|
+
// every MCP OAuth call site (explicit /refresh route, automatic
|
|
61
|
+
// ensureMcpToken refresh, callback token exchange) that needs to decide
|
|
62
|
+
// whether a provider has disowned a stored DCR client.
|
|
63
|
+
export function isInvalidClientError(message: string): boolean {
|
|
64
|
+
return /invalid_client\b/i.test(message);
|
|
65
|
+
}
|
|
66
|
+
|
|
57
67
|
export interface SsrfGuardOptions {
|
|
58
68
|
/** Allow loopback and RFC1918 hosts (dev / self-hosting). Opt-in only. */
|
|
59
69
|
allowPrivateHosts?: boolean;
|
|
@@ -119,9 +129,23 @@ function defaultSsrfOptions(): SsrfGuardOptions {
|
|
|
119
129
|
};
|
|
120
130
|
}
|
|
121
131
|
|
|
122
|
-
|
|
132
|
+
// Every safeFetch call is bounded so a provider that accepts the connection
|
|
133
|
+
// and never responds can't hang indefinitely. This matters most for the
|
|
134
|
+
// calls made under `withAuthorizeFlowLock` (metadata discovery + DCR POST in
|
|
135
|
+
// mcp-oauth.ts) — an unbounded fetch there would block every subsequent
|
|
136
|
+
// /authorize for that connector+user behind the lock. 15s is generous for
|
|
137
|
+
// OAuth metadata/DCR/token-endpoint round trips while still bounding the
|
|
138
|
+
// lock hold time; token exchange, refresh, and revocation share the same
|
|
139
|
+
// default since none of them should legitimately take longer.
|
|
140
|
+
const DEFAULT_FETCH_TIMEOUT_MS = 15_000;
|
|
141
|
+
|
|
142
|
+
async function safeFetch(
|
|
143
|
+
url: string,
|
|
144
|
+
init?: RequestInit,
|
|
145
|
+
timeoutMs: number = DEFAULT_FETCH_TIMEOUT_MS,
|
|
146
|
+
): Promise<Response> {
|
|
123
147
|
assertUrlSafe(url, defaultSsrfOptions());
|
|
124
|
-
return fetch(url, init);
|
|
148
|
+
return fetch(url, { ...init, signal: init?.signal ?? AbortSignal.timeout(timeoutMs) });
|
|
125
149
|
}
|
|
126
150
|
|
|
127
151
|
// ─── Protected Resource Metadata (RFC 9728) ──────────────────────────────────
|
|
@@ -225,6 +249,179 @@ export async function discoverAuthorizationServerMetadata(
|
|
|
225
249
|
throw lastError ?? new Error(`Authorization server metadata not found at ${issuer}`);
|
|
226
250
|
}
|
|
227
251
|
|
|
252
|
+
// ─── Token endpoint client authentication (RFC 6749 §2.3.1 / RFC 7591 §2) ────
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The three `token_endpoint_auth_method` values agent-swarm knows how to
|
|
256
|
+
* apply at the token endpoint. Other RFC 7591 values (e.g. `private_key_jwt`)
|
|
257
|
+
* are not supported by any MCP provider we've integrated with; they fall
|
|
258
|
+
* back to the RFC 7591 §2 default below.
|
|
259
|
+
*/
|
|
260
|
+
export type TokenEndpointAuthMethod = "client_secret_basic" | "client_secret_post" | "none";
|
|
261
|
+
|
|
262
|
+
const KNOWN_TOKEN_ENDPOINT_AUTH_METHODS: readonly TokenEndpointAuthMethod[] = [
|
|
263
|
+
"client_secret_basic",
|
|
264
|
+
"client_secret_post",
|
|
265
|
+
"none",
|
|
266
|
+
];
|
|
267
|
+
|
|
268
|
+
/** RFC 7591 §2: "client_secret_basic" is the default when unspecified. */
|
|
269
|
+
export const DEFAULT_TOKEN_ENDPOINT_AUTH_METHOD: TokenEndpointAuthMethod = "client_secret_basic";
|
|
270
|
+
|
|
271
|
+
/** Coerce a value we read back from a provider (or our own storage) to a known method, defaulting per RFC 7591 §2. */
|
|
272
|
+
export function normalizeTokenEndpointAuthMethod(
|
|
273
|
+
value: string | null | undefined,
|
|
274
|
+
): TokenEndpointAuthMethod {
|
|
275
|
+
return (KNOWN_TOKEN_ENDPOINT_AUTH_METHODS as readonly string[]).includes(value ?? "")
|
|
276
|
+
? (value as TokenEndpointAuthMethod)
|
|
277
|
+
: DEFAULT_TOKEN_ENDPOINT_AUTH_METHOD;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export class UnsupportedTokenEndpointAuthMethodError extends Error {
|
|
281
|
+
constructor(value: string, source: string) {
|
|
282
|
+
super(
|
|
283
|
+
`Authorization server ${source} token_endpoint_auth_method "${value}", which agent-swarm cannot perform. ` +
|
|
284
|
+
`Supported methods: ${KNOWN_TOKEN_ENDPOINT_AUTH_METHODS.join(", ")}. ` +
|
|
285
|
+
`Register a client manually via POST /api/mcp-oauth/:id/manual-client with a supported method.`,
|
|
286
|
+
);
|
|
287
|
+
this.name = "UnsupportedTokenEndpointAuthMethodError";
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Resolve a method the authorization server stated EXPLICITLY. An omitted
|
|
293
|
+
* value falls back to the RFC 7591 §2 default, but a present-and-unsupported
|
|
294
|
+
* value (e.g. `private_key_jwt`) is an error rather than a silent downgrade:
|
|
295
|
+
* coercing it to Basic would send credentials in a scheme the AS did not
|
|
296
|
+
* select, contradicting "server wins" and failing later as invalid_client.
|
|
297
|
+
*/
|
|
298
|
+
export function resolveAdvertisedTokenEndpointAuthMethod(
|
|
299
|
+
value: string | null | undefined,
|
|
300
|
+
source: string,
|
|
301
|
+
): TokenEndpointAuthMethod {
|
|
302
|
+
// An empty string is treated as "not stated", not as an unsupported method.
|
|
303
|
+
// A provider that serializes the field empty has told us nothing, and the
|
|
304
|
+
// DCR caller falls back to the method it requested (which the AS advertised)
|
|
305
|
+
// rather than erroring. Failing here would break such a provider outright
|
|
306
|
+
// for no safety gain, since we still authenticate the way it advertised.
|
|
307
|
+
if (value == null || value === "") return DEFAULT_TOKEN_ENDPOINT_AUTH_METHOD;
|
|
308
|
+
if (!(KNOWN_TOKEN_ENDPOINT_AUTH_METHODS as readonly string[]).includes(value)) {
|
|
309
|
+
throw new UnsupportedTokenEndpointAuthMethodError(value, source);
|
|
310
|
+
}
|
|
311
|
+
return value as TokenEndpointAuthMethod;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Read the method from a persisted client. Older rows predate this field and
|
|
316
|
+
* used client_secret_post, so preserve that known-working behavior.
|
|
317
|
+
*/
|
|
318
|
+
export function authMethodForStoredClient(
|
|
319
|
+
value: string | null | undefined,
|
|
320
|
+
): TokenEndpointAuthMethod {
|
|
321
|
+
if (value == null || value === "") return "client_secret_post";
|
|
322
|
+
return normalizeTokenEndpointAuthMethod(value);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Pick the method to REQUEST during DCR from the AS's advertised
|
|
327
|
+
* `token_endpoint_auth_methods_supported`. Prefers the RFC-preferred Basic
|
|
328
|
+
* form, then body-post, then public (`none`); falls back to the RFC 7591 §2
|
|
329
|
+
* default when the AS didn't advertise anything we recognize.
|
|
330
|
+
*/
|
|
331
|
+
export function selectDcrTokenEndpointAuthMethod(
|
|
332
|
+
supportedMethods: string[] | undefined,
|
|
333
|
+
): TokenEndpointAuthMethod {
|
|
334
|
+
// An empty advertised list is degenerate rather than a claim to support
|
|
335
|
+
// nothing, so it takes the RFC 8414 omitted-value default. Only a non-empty
|
|
336
|
+
// list containing nothing we can perform is a genuine incompatibility.
|
|
337
|
+
if (!supportedMethods || supportedMethods.length === 0) {
|
|
338
|
+
return DEFAULT_TOKEN_ENDPOINT_AUTH_METHOD;
|
|
339
|
+
}
|
|
340
|
+
for (const method of KNOWN_TOKEN_ENDPOINT_AUTH_METHODS) {
|
|
341
|
+
if (supportedMethods.includes(method)) return method;
|
|
342
|
+
}
|
|
343
|
+
// The AS advertised a list and none of it is something we can perform.
|
|
344
|
+
// Defaulting to Basic here would register a client we can never authenticate.
|
|
345
|
+
throw new UnsupportedTokenEndpointAuthMethodError(supportedMethods.join(", "), "advertises only");
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Serialize one credential component per `application/x-www-form-urlencoded`,
|
|
350
|
+
* which RFC 6749 §2.3.1 requires before Base64-encoding the Basic header.
|
|
351
|
+
* `encodeURIComponent` is NOT that algorithm: it emits `%20` for a space where
|
|
352
|
+
* form encoding emits `+`, and it leaves `!'()*` unescaped.
|
|
353
|
+
*/
|
|
354
|
+
function formUrlEncodeComponent(value: string): string {
|
|
355
|
+
return new URLSearchParams({ v: value }).toString().slice(2);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Redact every credential representation we actually transmitted from an
|
|
360
|
+
* upstream error body before it can be logged, persisted, or reflected into a
|
|
361
|
+
* redirect.
|
|
362
|
+
*
|
|
363
|
+
* `scrubSecrets` alone is not enough here: `registerVolatileSecret` ignores
|
|
364
|
+
* values shorter than its minimum length, and RFC 7591 sets no floor on a
|
|
365
|
+
* `client_secret`. A provider echoing a short secret, or the form-encoded
|
|
366
|
+
* form of a longer one, would otherwise pass straight through.
|
|
367
|
+
*
|
|
368
|
+
* Every value is redacted in BOTH its raw and its form-encoded shape. Anything
|
|
369
|
+
* we put in a `URLSearchParams` body travels percent-encoded (a token like
|
|
370
|
+
* `a+b/c=` is sent as `a%2Bb%2Fc%3D`), so a provider that echoes its received
|
|
371
|
+
* body back would otherwise leak the encoded form. Doing it here rather than
|
|
372
|
+
* at each call site means a new sensitive field cannot forget one shape.
|
|
373
|
+
*/
|
|
374
|
+
function redactSentCredentials(text: string, sent: Array<string | null | undefined>): string {
|
|
375
|
+
let out = scrubSecrets(text);
|
|
376
|
+
for (const value of sent) {
|
|
377
|
+
if (!value) continue;
|
|
378
|
+
for (const shape of new Set([value, formUrlEncodeComponent(value)])) {
|
|
379
|
+
out = out.split(shape).join("[REDACTED]");
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return out;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Apply the client's registered auth method to a token-endpoint request.
|
|
387
|
+
* Mutates `body` and `headers` in place so callers can build the rest of the
|
|
388
|
+
* grant-specific params around it.
|
|
389
|
+
*
|
|
390
|
+
* - client_secret_basic → Authorization: Basic header; no creds in body.
|
|
391
|
+
* - client_secret_post → client_id/client_secret in the body; no header.
|
|
392
|
+
* - none (public client) → client_id only in the body; no secret anywhere.
|
|
393
|
+
*
|
|
394
|
+
* Returns every credential representation it put on the wire so the caller can
|
|
395
|
+
* redact exactly what it sent, whatever the encoding or length.
|
|
396
|
+
*/
|
|
397
|
+
function applyClientAuthentication(
|
|
398
|
+
method: TokenEndpointAuthMethod,
|
|
399
|
+
clientId: string,
|
|
400
|
+
clientSecret: string | null | undefined,
|
|
401
|
+
body: URLSearchParams,
|
|
402
|
+
headers: Record<string, string>,
|
|
403
|
+
): string[] {
|
|
404
|
+
if (method === "client_secret_basic" && clientSecret) {
|
|
405
|
+
const encodedSecret = formUrlEncodeComponent(clientSecret);
|
|
406
|
+
const credentials = `${formUrlEncodeComponent(clientId)}:${encodedSecret}`;
|
|
407
|
+
const encoded = Buffer.from(credentials).toString("base64");
|
|
408
|
+
// The Base64 blob is itself a credential. Register it so a provider that
|
|
409
|
+
// echoes the Authorization header back in an error body gets scrubbed.
|
|
410
|
+
registerVolatileSecret(encoded, "mcp_oauth_basic_credential");
|
|
411
|
+
headers.Authorization = `Basic ${encoded}`;
|
|
412
|
+
return [clientSecret, encodedSecret, encoded, credentials];
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// A public client must identify itself in the body, whatever method was
|
|
416
|
+
// recorded for a client with no secret.
|
|
417
|
+
body.set("client_id", clientId);
|
|
418
|
+
if (method === "client_secret_post" && clientSecret) {
|
|
419
|
+
body.set("client_secret", clientSecret);
|
|
420
|
+
return [clientSecret, formUrlEncodeComponent(clientSecret)];
|
|
421
|
+
}
|
|
422
|
+
return [];
|
|
423
|
+
}
|
|
424
|
+
|
|
228
425
|
// ─── Dynamic Client Registration (RFC 7591) ──────────────────────────────────
|
|
229
426
|
|
|
230
427
|
export interface DcrRequest {
|
|
@@ -329,6 +526,7 @@ export interface ExchangeCodeInput {
|
|
|
329
526
|
tokenUrl: string;
|
|
330
527
|
clientId: string;
|
|
331
528
|
clientSecret?: string | null;
|
|
529
|
+
tokenEndpointAuthMethod?: string | null;
|
|
332
530
|
redirectUri: string;
|
|
333
531
|
code: string;
|
|
334
532
|
codeVerifier: string;
|
|
@@ -344,27 +542,48 @@ export interface TokenResponse {
|
|
|
344
542
|
}
|
|
345
543
|
|
|
346
544
|
export async function exchangeCodeForTokens(input: ExchangeCodeInput): Promise<TokenResponse> {
|
|
545
|
+
// A provider can echo a submitted credential back verbatim in an error body.
|
|
546
|
+
// The thrown message is logged by the callback AND reflected into the
|
|
547
|
+
// dashboard redirect as error_description, so redact before it can escape.
|
|
548
|
+
if (input.clientSecret) registerVolatileSecret(input.clientSecret, "mcp_oauth_client_secret");
|
|
549
|
+
|
|
550
|
+
const method = normalizeTokenEndpointAuthMethod(input.tokenEndpointAuthMethod);
|
|
347
551
|
const body = new URLSearchParams({
|
|
348
552
|
grant_type: "authorization_code",
|
|
349
553
|
code: input.code,
|
|
350
554
|
redirect_uri: input.redirectUri,
|
|
351
|
-
client_id: input.clientId,
|
|
352
555
|
code_verifier: input.codeVerifier,
|
|
353
556
|
resource: input.resource,
|
|
354
557
|
});
|
|
355
|
-
|
|
558
|
+
const headers: Record<string, string> = {
|
|
559
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
560
|
+
Accept: "application/json",
|
|
561
|
+
};
|
|
562
|
+
const sentCredentials = applyClientAuthentication(
|
|
563
|
+
method,
|
|
564
|
+
input.clientId,
|
|
565
|
+
input.clientSecret,
|
|
566
|
+
body,
|
|
567
|
+
headers,
|
|
568
|
+
);
|
|
356
569
|
|
|
357
570
|
const res = await safeFetch(input.tokenUrl, {
|
|
358
571
|
method: "POST",
|
|
359
|
-
headers
|
|
360
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
361
|
-
Accept: "application/json",
|
|
362
|
-
},
|
|
572
|
+
headers,
|
|
363
573
|
body: body.toString(),
|
|
364
574
|
});
|
|
365
575
|
if (!res.ok) {
|
|
366
576
|
const text = await res.text().catch(() => "");
|
|
367
|
-
|
|
577
|
+
// The code and verifier are single-use but the exchange just failed, so
|
|
578
|
+
// treat them as live. Both callback sinks (console.error and the dashboard
|
|
579
|
+
// redirect's error_description) receive this message verbatim.
|
|
580
|
+
throw new Error(
|
|
581
|
+
`Token exchange failed (${res.status}): ${redactSentCredentials(text, [
|
|
582
|
+
...sentCredentials,
|
|
583
|
+
input.code,
|
|
584
|
+
input.codeVerifier,
|
|
585
|
+
])}`,
|
|
586
|
+
);
|
|
368
587
|
}
|
|
369
588
|
return (await res.json()) as TokenResponse;
|
|
370
589
|
}
|
|
@@ -373,32 +592,53 @@ export interface RefreshTokenInput {
|
|
|
373
592
|
tokenUrl: string;
|
|
374
593
|
clientId: string;
|
|
375
594
|
clientSecret?: string | null;
|
|
595
|
+
tokenEndpointAuthMethod?: string | null;
|
|
376
596
|
refreshToken: string;
|
|
377
597
|
resource: string;
|
|
378
598
|
scopes?: string[];
|
|
379
599
|
}
|
|
380
600
|
|
|
381
601
|
export async function refreshMcpToken(input: RefreshTokenInput): Promise<TokenResponse> {
|
|
602
|
+
// A misbehaving/compromised provider can echo a submitted credential back
|
|
603
|
+
// verbatim in an error body (e.g. "invalid client_secret: <value>"). Both
|
|
604
|
+
// credentials we send are registered as volatile secrets so the scrub pass
|
|
605
|
+
// below redacts any echo BEFORE the message is persisted to
|
|
606
|
+
// lastErrorMessage or returned by the status endpoint.
|
|
607
|
+
if (input.clientSecret) registerVolatileSecret(input.clientSecret, "mcp_oauth_client_secret");
|
|
608
|
+
registerVolatileSecret(input.refreshToken, "mcp_oauth_refresh_token");
|
|
609
|
+
|
|
610
|
+
const method = normalizeTokenEndpointAuthMethod(input.tokenEndpointAuthMethod);
|
|
382
611
|
const body = new URLSearchParams({
|
|
383
612
|
grant_type: "refresh_token",
|
|
384
613
|
refresh_token: input.refreshToken,
|
|
385
|
-
client_id: input.clientId,
|
|
386
614
|
resource: input.resource,
|
|
387
615
|
});
|
|
388
|
-
if (input.clientSecret) body.set("client_secret", input.clientSecret);
|
|
389
616
|
if (input.scopes && input.scopes.length > 0) body.set("scope", input.scopes.join(" "));
|
|
617
|
+
const headers: Record<string, string> = {
|
|
618
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
619
|
+
Accept: "application/json",
|
|
620
|
+
};
|
|
621
|
+
const sentCredentials = applyClientAuthentication(
|
|
622
|
+
method,
|
|
623
|
+
input.clientId,
|
|
624
|
+
input.clientSecret,
|
|
625
|
+
body,
|
|
626
|
+
headers,
|
|
627
|
+
);
|
|
390
628
|
|
|
391
629
|
const res = await safeFetch(input.tokenUrl, {
|
|
392
630
|
method: "POST",
|
|
393
|
-
headers
|
|
394
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
395
|
-
Accept: "application/json",
|
|
396
|
-
},
|
|
631
|
+
headers,
|
|
397
632
|
body: body.toString(),
|
|
398
633
|
});
|
|
399
634
|
if (!res.ok) {
|
|
400
635
|
const text = await res.text().catch(() => "");
|
|
401
|
-
throw new Error(
|
|
636
|
+
throw new Error(
|
|
637
|
+
`Token refresh failed (${res.status}): ${redactSentCredentials(text, [
|
|
638
|
+
...sentCredentials,
|
|
639
|
+
input.refreshToken,
|
|
640
|
+
])}`,
|
|
641
|
+
);
|
|
402
642
|
}
|
|
403
643
|
return (await res.json()) as TokenResponse;
|
|
404
644
|
}
|
|
@@ -409,28 +649,43 @@ export interface RevokeInput {
|
|
|
409
649
|
tokenTypeHint?: "access_token" | "refresh_token";
|
|
410
650
|
clientId: string;
|
|
411
651
|
clientSecret?: string | null;
|
|
652
|
+
tokenEndpointAuthMethod?: string | null;
|
|
412
653
|
}
|
|
413
654
|
|
|
414
655
|
export async function revokeMcpToken(input: RevokeInput): Promise<void> {
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
656
|
+
// Same exposure as the exchange path: the caller logs this error verbatim.
|
|
657
|
+
if (input.clientSecret) registerVolatileSecret(input.clientSecret, "mcp_oauth_client_secret");
|
|
658
|
+
registerVolatileSecret(input.token, "mcp_oauth_revoked_token");
|
|
659
|
+
|
|
660
|
+
const method = normalizeTokenEndpointAuthMethod(input.tokenEndpointAuthMethod);
|
|
661
|
+
const body = new URLSearchParams({ token: input.token });
|
|
419
662
|
if (input.tokenTypeHint) body.set("token_type_hint", input.tokenTypeHint);
|
|
420
|
-
|
|
663
|
+
const headers: Record<string, string> = {
|
|
664
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
665
|
+
Accept: "application/json",
|
|
666
|
+
};
|
|
667
|
+
const sentCredentials = applyClientAuthentication(
|
|
668
|
+
method,
|
|
669
|
+
input.clientId,
|
|
670
|
+
input.clientSecret,
|
|
671
|
+
body,
|
|
672
|
+
headers,
|
|
673
|
+
);
|
|
421
674
|
|
|
422
675
|
const res = await safeFetch(input.revocationUrl, {
|
|
423
676
|
method: "POST",
|
|
424
|
-
headers
|
|
425
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
426
|
-
Accept: "application/json",
|
|
427
|
-
},
|
|
677
|
+
headers,
|
|
428
678
|
body: body.toString(),
|
|
429
679
|
});
|
|
430
680
|
if (!res.ok && res.status !== 200 && res.status !== 204) {
|
|
431
681
|
// RFC 7009: 200 even for already-revoked. Treat any non-2xx as informational only.
|
|
432
682
|
const text = await res.text().catch(() => "");
|
|
433
|
-
throw new Error(
|
|
683
|
+
throw new Error(
|
|
684
|
+
`Token revocation failed (${res.status}): ${redactSentCredentials(text, [
|
|
685
|
+
...sentCredentials,
|
|
686
|
+
input.token,
|
|
687
|
+
])}`,
|
|
688
|
+
);
|
|
434
689
|
}
|
|
435
690
|
}
|
|
436
691
|
|
|
@@ -363,7 +363,7 @@ export async function createPiRuntimeAuth(
|
|
|
363
363
|
for (const [envKey, provider] of PI_RUNTIME_API_KEYS) {
|
|
364
364
|
const apiKey = env[envKey];
|
|
365
365
|
if (apiKey) {
|
|
366
|
-
await modelRuntime.setRuntimeApiKey(provider, apiKey
|
|
366
|
+
await modelRuntime.setRuntimeApiKey(provider, apiKey);
|
|
367
367
|
}
|
|
368
368
|
}
|
|
369
369
|
|
|
@@ -26,7 +26,7 @@ Example binding document:
|
|
|
26
26
|
}
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
-
Scripts still opt in per request by sending a header value that contains the placeholder from `headerTemplate`. Query-string bindings work the same way: configure `queryTemplate` as `param=[REDACTED:CONFIG_KEY]`, then send a URL with that placeholder-bearing query value. The broker does not auto-add headers or query parameters in this draft.
|
|
29
|
+
A header whose value still contains an unresolved `[REDACTED:...]` placeholder after substitution is dropped before egress — the literal is useless to any provider, and omitting it lets optional-auth APIs (e.g. GitHub public repos) serve unauthenticated requests on installs without the credential. Scripts still opt in per request by sending a header value that contains the placeholder from `headerTemplate`. Query-string bindings work the same way: configure `queryTemplate` as `param=[REDACTED:CONFIG_KEY]`, then send a URL with that placeholder-bearing query value. The broker does not auto-add headers or query parameters in this draft.
|
|
30
30
|
|
|
31
31
|
## Management
|
|
32
32
|
|
|
@@ -83,8 +83,10 @@ export function patchFetchWithCredentialBroker(
|
|
|
83
83
|
bindings: ResolvedCredentialBinding[],
|
|
84
84
|
failedBindings: FailedCredentialBinding[] = [],
|
|
85
85
|
): void {
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
// Always install: even with zero resolved bindings (fresh install, identity
|
|
87
|
+
// without the credential) the wrapper must strip unresolved placeholder
|
|
88
|
+
// headers below, or the literal placeholder rides to the provider as a
|
|
89
|
+
// guaranteed 401 and optional-auth APIs stop serving public data.
|
|
88
90
|
const originalFetch = globalThis.fetch;
|
|
89
91
|
|
|
90
92
|
globalThis.fetch = function patchedFetch(
|
|
@@ -112,6 +114,14 @@ export function patchFetchWithCredentialBroker(
|
|
|
112
114
|
headersModified = true;
|
|
113
115
|
}
|
|
114
116
|
}
|
|
117
|
+
// A placeholder still present after substitution means the credential is
|
|
118
|
+
// not configured (or not allowed) for this identity and host: drop the
|
|
119
|
+
// header instead of sending the literal, so optional-auth providers can
|
|
120
|
+
// still serve public data unauthenticated.
|
|
121
|
+
if (value.includes("[REDACTED:")) {
|
|
122
|
+
headersModified = true;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
115
125
|
newHeaders.set(key, value);
|
|
116
126
|
}
|
|
117
127
|
|
|
@@ -148,6 +148,7 @@ export const SDK_TOOL_NAME_MAP = {
|
|
|
148
148
|
app_patch: "app-patch",
|
|
149
149
|
app_query: "app-query",
|
|
150
150
|
app_rollback: "app-rollback",
|
|
151
|
+
app_sync: "app-sync",
|
|
151
152
|
app_upsert: "app-upsert",
|
|
152
153
|
page_create: "create_page",
|
|
153
154
|
page_delete: "delete-page", // destructive
|
|
@@ -11,10 +11,14 @@ export async function runtimeFetch(
|
|
|
11
11
|
let lastError: unknown;
|
|
12
12
|
|
|
13
13
|
for (let attempt = 0; attempt < retries; attempt++) {
|
|
14
|
+
if (signal?.aborted) {
|
|
15
|
+
throw signal.reason ?? new DOMException("This operation was aborted", "AbortError");
|
|
16
|
+
}
|
|
14
17
|
const controller = new AbortController();
|
|
15
18
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
16
19
|
const onAbort = () => controller.abort();
|
|
17
20
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
21
|
+
if (signal?.aborted) controller.abort();
|
|
18
22
|
|
|
19
23
|
try {
|
|
20
24
|
const res = await fetch(input, { ...init, signal: controller.signal });
|
|
@@ -24,6 +28,8 @@ export async function runtimeFetch(
|
|
|
24
28
|
}
|
|
25
29
|
return res;
|
|
26
30
|
} catch (error) {
|
|
31
|
+
// Caller abort is terminal — only the controller's own timeout abort retries.
|
|
32
|
+
if (signal?.aborted) throw signal.reason ?? error;
|
|
27
33
|
lastError = error;
|
|
28
34
|
if (attempt === retries - 1) break;
|
|
29
35
|
} finally {
|
|
@@ -455,6 +455,7 @@ declare module "swarm-sdk" {
|
|
|
455
455
|
migration?: Record<string, unknown>;
|
|
456
456
|
forceElementBreak?: string[];
|
|
457
457
|
}): Promise<unknown>;
|
|
458
|
+
app_sync(args: { appId: string; model?: string; source?: string }): Promise<unknown>;
|
|
458
459
|
app_upsert(args: {
|
|
459
460
|
name: string;
|
|
460
461
|
description?: string;
|
|
@@ -437,6 +437,7 @@ declare module "swarm-sdk" {
|
|
|
437
437
|
migration?: Record<string, unknown>;
|
|
438
438
|
forceElementBreak?: string[];
|
|
439
439
|
}): Promise<unknown>;
|
|
440
|
+
app_sync(args: { appId: string; model?: string; source?: string }): Promise<unknown>;
|
|
440
441
|
app_upsert(args: {
|
|
441
442
|
name: string;
|
|
442
443
|
description?: string;
|
package/src/server.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { registerAppListTool } from "./tools/app-list";
|
|
|
16
16
|
import { registerAppPatchTool } from "./tools/app-patch";
|
|
17
17
|
import { registerAppQueryTool } from "./tools/app-query";
|
|
18
18
|
import { registerAppRollbackTool } from "./tools/app-rollback";
|
|
19
|
+
import { registerAppSyncTool } from "./tools/app-sync";
|
|
19
20
|
import { registerAppUpsertTool } from "./tools/app-upsert";
|
|
20
21
|
import { registerCancelTaskTool } from "./tools/cancel-task";
|
|
21
22
|
import { registerContextDiffTool } from "./tools/context-diff";
|
|
@@ -477,6 +478,7 @@ export function createServer(opts: { scriptsOnly?: boolean; fullSurface?: boolea
|
|
|
477
478
|
registerAppPatchTool(server);
|
|
478
479
|
registerAppQueryTool(server);
|
|
479
480
|
registerAppRollbackTool(server);
|
|
481
|
+
registerAppSyncTool(server);
|
|
480
482
|
registerAppUpsertTool(server);
|
|
481
483
|
registerCreatePageTool(server);
|
|
482
484
|
registerDeletePageTool(server);
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
} from "node:http";
|
|
9
9
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
10
10
|
import { closeDb, createAgent, createUser, getDb, getTaskById, initDb } from "../be/db";
|
|
11
|
+
import { upsertScriptByName } from "../be/scripts/db";
|
|
11
12
|
import { type IdentityActor, mintToken } from "../be/users";
|
|
12
13
|
import { handleApps } from "../http/apps";
|
|
13
14
|
import { resolveHttpRequestAuth } from "../http/auth";
|
|
@@ -340,3 +341,112 @@ describe("app viewer identity", () => {
|
|
|
340
341
|
expect(getTaskById(agentAction.body.taskId)?.requestedByUserId).toBeUndefined();
|
|
341
342
|
});
|
|
342
343
|
});
|
|
344
|
+
|
|
345
|
+
describe("user writers and the script ownership gate", () => {
|
|
346
|
+
async function saveScript(scope: "agent" | "global", ownerId: string | null): Promise<string> {
|
|
347
|
+
const result = await upsertScriptByName({
|
|
348
|
+
name: `apps_rbac_${scope}_${crypto.randomUUID().replaceAll("-", "")}`,
|
|
349
|
+
scope,
|
|
350
|
+
scopeId: ownerId,
|
|
351
|
+
agentId: ownerId,
|
|
352
|
+
source: "export default function run() { return { ok: true }; }",
|
|
353
|
+
description: "Ownership-gate fixture",
|
|
354
|
+
intent: "Prove the user-writer ownership gate",
|
|
355
|
+
signatureJson: JSON.stringify({ args: { type: "object" }, result: { type: "object" } }),
|
|
356
|
+
typeChecked: true,
|
|
357
|
+
embeddingMode: "skip",
|
|
358
|
+
});
|
|
359
|
+
return result.script.id;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function withSource(scriptId: string) {
|
|
363
|
+
return {
|
|
364
|
+
...definition,
|
|
365
|
+
models: {
|
|
366
|
+
note: {
|
|
367
|
+
columns: {
|
|
368
|
+
title: { kind: "string" },
|
|
369
|
+
issueKey: { kind: "string" },
|
|
370
|
+
body: { kind: "string", source: { of: "gh", field: "body" } },
|
|
371
|
+
},
|
|
372
|
+
sources: { gh: { connector: "script", scriptId, joinKey: "issueKey" } },
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
test("a web user cannot wire agent-scoped or catalog scripts; stored paths stay editable", async () => {
|
|
379
|
+
const ownerId = crypto.randomUUID();
|
|
380
|
+
createAgent({ id: ownerId, name: "apps-rbac-script-owner", isLead: false, status: "idle" });
|
|
381
|
+
const foreignId = await saveScript("agent", ownerId);
|
|
382
|
+
const globalId = await saveScript("global", null);
|
|
383
|
+
type Issues = { issues?: Array<{ path: string; message: string }> };
|
|
384
|
+
|
|
385
|
+
// A user write carries no agent id — it must NOT pass like the operator.
|
|
386
|
+
const asSource = await request<Issues>("/api/apps", "user", {
|
|
387
|
+
method: "POST",
|
|
388
|
+
body: JSON.stringify({ name: "User foreign source", definition: withSource(foreignId) }),
|
|
389
|
+
});
|
|
390
|
+
expect(asSource.status).toBe(400);
|
|
391
|
+
expect(
|
|
392
|
+
asSource.body.issues?.some(
|
|
393
|
+
(issue) =>
|
|
394
|
+
issue.path === "models.note.sources.gh.scriptId" &&
|
|
395
|
+
issue.message.includes("agent-scoped to another agent"),
|
|
396
|
+
),
|
|
397
|
+
).toBe(true);
|
|
398
|
+
|
|
399
|
+
const asAction = await request<Issues>("/api/apps", "user", {
|
|
400
|
+
method: "POST",
|
|
401
|
+
body: JSON.stringify({
|
|
402
|
+
name: "User foreign action",
|
|
403
|
+
definition: {
|
|
404
|
+
...definition,
|
|
405
|
+
actions: { steal: { kind: "script", scriptId: foreignId } },
|
|
406
|
+
},
|
|
407
|
+
}),
|
|
408
|
+
});
|
|
409
|
+
expect(asAction.status).toBe(400);
|
|
410
|
+
expect(
|
|
411
|
+
asAction.body.issues?.some(
|
|
412
|
+
(issue) =>
|
|
413
|
+
issue.path === "actions.steal.scriptId" &&
|
|
414
|
+
issue.message.includes("agent-scoped to another agent"),
|
|
415
|
+
),
|
|
416
|
+
).toBe(true);
|
|
417
|
+
|
|
418
|
+
// Owner-less global (catalog) scripts sync with the LEAD's credentials —
|
|
419
|
+
// a user may not wire those as sources either.
|
|
420
|
+
const globalApp = await request<Issues>("/api/apps", "user", {
|
|
421
|
+
method: "POST",
|
|
422
|
+
body: JSON.stringify({ name: "User global source", definition: withSource(globalId) }),
|
|
423
|
+
});
|
|
424
|
+
expect(globalApp.status).toBe(400);
|
|
425
|
+
expect(
|
|
426
|
+
globalApp.body.issues?.some(
|
|
427
|
+
(issue) =>
|
|
428
|
+
issue.path === "models.note.sources.gh.scriptId" &&
|
|
429
|
+
issue.message.includes("only that agent or the operator"),
|
|
430
|
+
),
|
|
431
|
+
).toBe(true);
|
|
432
|
+
|
|
433
|
+
// An operator-authored app carrying the foreign script at a stored path
|
|
434
|
+
// stays editable for users — grandfathering is path-exact, not skipped.
|
|
435
|
+
const operatorApp = await request<{ app: { id: string } }>("/api/apps", "operator", {
|
|
436
|
+
method: "POST",
|
|
437
|
+
body: JSON.stringify({
|
|
438
|
+
name: "Operator foreign action",
|
|
439
|
+
definition: { ...definition, actions: { run: { kind: "script", scriptId: foreignId } } },
|
|
440
|
+
}),
|
|
441
|
+
});
|
|
442
|
+
expect(operatorApp.status).toBe(201);
|
|
443
|
+
|
|
444
|
+
const edited = await request<Issues>(`/api/apps/${operatorApp.body.app.id}`, "user", {
|
|
445
|
+
method: "PATCH",
|
|
446
|
+
body: JSON.stringify({
|
|
447
|
+
definition: { models: { note: { columns: { extra: { kind: "string" } } } } },
|
|
448
|
+
}),
|
|
449
|
+
});
|
|
450
|
+
expect(edited.status).toBe(200);
|
|
451
|
+
});
|
|
452
|
+
});
|
|
@@ -1064,7 +1064,7 @@ describe("custom app actions", () => {
|
|
|
1064
1064
|
);
|
|
1065
1065
|
expect(started.status).toBe(200);
|
|
1066
1066
|
expect(started.body).toMatchObject({ ok: true, status: "pending" });
|
|
1067
|
-
const observed = await request<{ id: string; agentId: string; task: string }>(
|
|
1067
|
+
const observed = await request<{ id: string; agentId: string; task: string; key: string }>(
|
|
1068
1068
|
`/api/tasks/${started.body.taskId}`,
|
|
1069
1069
|
);
|
|
1070
1070
|
expect(observed.status).toBe(200);
|
|
@@ -1072,6 +1072,9 @@ describe("custom app actions", () => {
|
|
|
1072
1072
|
expect(observed.body.agentId).toBe(LEAD_ID);
|
|
1073
1073
|
expect(observed.body.task).toContain(`[App action] app=${appId}`);
|
|
1074
1074
|
expect(observed.body.task).toContain('input={"idea":"42"}');
|
|
1075
|
+
// The app-namespaced asset key lets a swarm-tasks source pull these back
|
|
1076
|
+
// via config.assetKey.
|
|
1077
|
+
expect(observed.body.key).toBe(`shared/app:${appId}/action:investigate/`);
|
|
1075
1078
|
});
|
|
1076
1079
|
});
|
|
1077
1080
|
|