@agent-native/dispatch 0.27.3 → 0.27.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/actions/create-workspace-resource.d.ts +13 -13
- package/dist/actions/delete-destination.d.ts +12 -12
- package/dist/actions/import-agent.d.ts +13 -13
- package/dist/server/lib/app-creation-store.d.ts.map +1 -1
- package/dist/server/lib/app-creation-store.js +86 -5
- package/dist/server/lib/app-creation-store.js.map +1 -1
- package/dist/server/lib/mcp-gateway.d.ts.map +1 -1
- package/dist/server/lib/mcp-gateway.js +42 -10
- package/dist/server/lib/mcp-gateway.js.map +1 -1
- package/dist/server/lib/workspace-app-action-auth.d.ts +9 -0
- package/dist/server/lib/workspace-app-action-auth.d.ts.map +1 -0
- package/dist/server/lib/workspace-app-action-auth.js +87 -0
- package/dist/server/lib/workspace-app-action-auth.js.map +1 -0
- package/dist/server/plugins/agent-chat.js +3 -0
- package/dist/server/plugins/agent-chat.js.map +1 -1
- package/package.json +2 -2
- package/src/server/lib/app-creation-store.spec.ts +86 -0
- package/src/server/lib/app-creation-store.ts +98 -9
- package/src/server/lib/mcp-gateway.spec.ts +49 -0
- package/src/server/lib/mcp-gateway.ts +34 -5
- package/src/server/lib/workspace-app-action-auth.spec.ts +212 -0
- package/src/server/lib/workspace-app-action-auth.ts +105 -0
- package/src/server/plugins/agent-chat.spec.ts +4 -0
- package/src/server/plugins/agent-chat.ts +6 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"agent-chat.js","sourceRoot":"","sources":["../../../src/server/plugins/agent-chat.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAC;AACvD,OAAO,EAAE,qBAAqB,EAAE,MAAM,2BAA2B,CAAC;AAElE,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;
|
|
1
|
+
{"version":3,"file":"agent-chat.js","sourceRoot":"","sources":["../../../src/server/plugins/agent-chat.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAC;AACvD,OAAO,EAAE,qBAAqB,EAAE,MAAM,2BAA2B,CAAC;AAElE,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AACzD,OAAO,EACL,2BAA2B,EAC3B,0BAA0B,GAC3B,MAAM,qCAAqC,CAAC;AAE7C,MAAM,kBAAkB,GAAG;IACzB,aAAa;IACb,qBAAqB;IACrB,+BAA+B;IAC/B,uBAAuB;IACvB,SAAS;IACT,UAAU;IACV,0BAA0B;IAC1B,2BAA2B;IAC3B,2BAA2B;IAC3B,cAAc;IACd,mBAAmB;IACnB,iBAAiB;IACjB,wBAAwB;IACxB,oBAAoB;IACpB,sBAAsB;IACtB,qBAAqB;IACrB,mBAAmB;IACnB,oBAAoB;IACpB,aAAa;IACb,8BAA8B;IAC9B,oCAAoC;IACpC,sBAAsB;IACtB,mBAAmB;IACnB,sBAAsB;IACtB,sBAAsB;IACtB,UAAU;CACX,CAAC;AAEF,eAAe,qBAAqB,CAAC;IACnC,KAAK,EAAE,UAAU;IACjB,qBAAqB,EAAE,IAAI;IAC3B,gBAAgB,EAAE,kBAAkB;IACpC,GAAG,EAAE;QACH,gBAAgB,EAAE;YAChB,oCAAoC;YACpC,8BAA8B;SAC/B;KACF;IACD,0EAA0E;IAC1E,2EAA2E;IAC3E,2EAA2E;IAC3E,YAAY,EAAE,KAAK,EAAE,KAAK,EAAE,EAAE;QAC5B,MAAM,GAAG,GAAG,MAAM,aAAa,CAAC,KAAK,CAAC,CAAC;QACvC,OAAO,GAAG,CAAC,KAAK,CAAC;IACnB,CAAC;IACD,2EAA2E;IAC3E,2EAA2E;IAC3E,wEAAwE;IACxE,OAAO,EAAE,eAAe;IACxB,eAAe,EAAE,2BAA2B;IAC5C,sBAAsB,EAAE,CAAC,0BAA0B,CAAC;IACpD,aAAa,EAAE,EAAE,UAAU,EAAE,WAAW,EAAE;IAC1C,YAAY,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;4EAmC4D;CAC3E,CAAC,CAAC","sourcesContent":["import { getOrgContext } from \"@agent-native/core/org\";\nimport { createAgentChatPlugin } from \"@agent-native/core/server\";\n\nimport { dispatchActions } from \"../../actions/index.js\";\nimport {\n workspaceAppActionRouteAuth,\n WORKSPACE_APPS_ACTION_PATH,\n} from \"../lib/workspace-app-action-auth.js\";\n\nconst INITIAL_TOOL_NAMES = [\n \"view-screen\",\n \"list-workspace-apps\",\n \"update-workspace-app-metadata\",\n \"list-connected-agents\",\n \"ask_app\",\n \"open_app\",\n \"list-workspace-resources\",\n \"create-workspace-resource\",\n \"update-workspace-resource\",\n \"import-agent\",\n \"import-agent-pack\",\n \"list-agent-pack\",\n \"connect-external-agent\",\n \"list-vault-secrets\",\n \"request-vault-secret\",\n \"create-vault-secret\",\n \"list-destinations\",\n \"upsert-destination\",\n \"list-dreams\",\n \"start-workspace-app-creation\",\n \"list-available-workspace-templates\",\n \"provider-api-catalog\",\n \"provider-api-docs\",\n \"provider-api-request\",\n \"query-staged-dataset\",\n \"navigate\",\n];\n\nexport default createAgentChatPlugin({\n appId: \"dispatch\",\n durableBackgroundRuns: true,\n initialToolNames: INITIAL_TOOL_NAMES,\n mcp: {\n connectorCatalog: [\n \"resolve-integration-source-context\",\n \"start-workspace-app-creation\",\n ],\n },\n // Without this, AGENT_ORG_ID is never set on agent action calls and every\n // row written through the frontend (vault secrets, destinations, workspace\n // resources) lands with org_id=NULL — breaking data isolation across orgs.\n resolveOrgId: async (event) => {\n const ctx = await getOrgContext(event);\n return ctx.orgId;\n },\n // Read actions directly from the package's own action map rather than from\n // a build-time-generated `.generated/actions-registry.ts` (the latter is a\n // template-only construct that the Vite plugin emits next to actions/).\n actions: dispatchActions,\n actionRouteAuth: workspaceAppActionRouteAuth,\n actionRoutePublicPaths: [WORKSPACE_APPS_ACTION_PATH],\n codeExecution: { production: \"sandboxed\" },\n systemPrompt: `You are the central dispatch for this workspace.\n\nDefault posture:\n- Treat Slack and Telegram as shared entrypoints into the workspace.\n- Heavily delegate domain work to specialized agents through A2A when another app owns the job.\n- Keep durable memory and operating instructions in resources rather than ephemeral chat.\n- Prefer replying in the current external thread unless the user explicitly asks you to send to a saved destination.\n\nUse the standard workspace primitives:\n- Read and update resources like AGENTS.md, LEARNINGS.md, jobs/*.md, agents/*.md, and remote-agents/*.json when appropriate.\n- Use recurring jobs for scheduled behavior.\n- Use custom agent profiles in agents/*.md for local spawned work and remote-agents/*.json for remote A2A apps.\n- For a Claude-style Markdown or JSON agent setup, use import-agent so the\n profile is normalized into agents/<slug>.md. Never import credentials, hooks,\n shell commands, or local environment settings.\n- For a folder of agent instructions, references, or skills, use\n import-agent-pack. It preserves the text files under agents/<slug>/ and\n creates the normalized profile alongside them. Use list-agent-pack to inspect\n the pack before editing or handing it off to an app.\n- For an existing HTTP/A2A endpoint, use connect-external-agent and let the\n normal A2A/MCP connection flow handle authentication.\n- You receive a compact available-apps block with sibling workspace app names and descriptions. Use it to pick the right A2A target, and call list-connected-agents or tool-search only when you need fresh details.\n- Hosted/connected A2A neighbors such as Analytics and Content come from the available-apps context or list-connected-agents. list-workspace-apps only inventories apps mounted inside this workspace deployment; never use a missing row there to conclude that a connected agent is unavailable.\n- When answering whether a mounted workspace app exposes an agent card or A2A endpoint, call list-workspace-apps with includeAgentCards=true. If you have not requested that probe, absence of agent-card fields means unchecked, not unavailable.\n- When creating a new workspace app, create a separate app under apps/<app-id> with apps/<app-id>/package.json including a concise generated description, mount it at /<app-id>, use relative /<app-id> links, never hardcode localhost or dev ports, use shadcn/ui with @tabler/icons-react rather than lucide-react, and ensure the React Router client entry preserves APP_BASE_PATH/VITE_APP_BASE_PATH via appBasePath(). There is no separate workspace app registry to edit.\n- When a user asks to rename an existing workspace app or change its Dispatch title/description, call update-workspace-app-metadata with the existing appId from list-workspace-apps. This is a metadata-only edit — never call start-workspace-app-creation, which creates a new app and Builder branch.\n- When an explicit app-creation request already includes a source brief or a concrete repeatable workflow, call start-workspace-app-creation without asking non-blocking product or UX questions. Choose recommended defaults, let the Builder handoff record assumptions, and ask only for authorization, credentials, a destructive action, an ambiguous target workspace, or a genuinely missing workflow.\n- If the chat template is used, treat it as scaffolding only: the finished app must be branded as the requested app with its own home screen/navigation/package metadata/manifest, and must not leave visible \"Chat\", \"Starter\", \"Blank app\", or \"New app\" UI behind.\n- Treat first-party apps such as Mail, Calendar, Analytics, Brain, Assets, and Dispatch as existing hosted/connected neighbors available through links and A2A/default connected agents. Do not create wrapper apps, child apps, nested routes, or cloned template copies just to give a new app access to them; build only the genuinely new workflow and delegate cross-app work to those existing apps.\n- Integration grants are not provider capability limits. For ad hoc provider inspection, querying, reporting, or troubleshooting, call provider-api-catalog/provider-api-docs, then provider-api-request against the provider's real HTTP API. Use connectionId for a specific shared grant and accountId for a specific OAuth account. Never expose secret values or silently widen app access while doing this.\n- For broad provider searches, joins, classification, corpus counts, or absence claims, fetch every relevant page or an explicitly bounded cohort, stage/save large responses with stageAs/saveToFile/fetchAllPages, and reduce them with query-staged-dataset or run-code. Report source, filters, row counts, pagination, truncation, failed pages, and uncovered gaps.\n\nWhen a user asks for something like a digest, reminder, routing rule, or saved behavior:\n- First decide whether it should be a resource, a recurring job, a destination, or a delegated task.\n- Keep responses concise and operational.\n- Avoid inventing integrations or destinations that are not configured yet.`,\n});\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-native/dispatch",
|
|
3
|
-
"version": "0.27.
|
|
3
|
+
"version": "0.27.5",
|
|
4
4
|
"description": "Dispatch — workspace control plane for agent-native apps. Vault, integrations, destinations, scheduled jobs, and cross-app delegation, shipped as a single drop-in package.",
|
|
5
5
|
"homepage": "https://github.com/BuilderIO/agent-native#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -101,7 +101,7 @@
|
|
|
101
101
|
"typescript-7": "npm:typescript@^7.0.2",
|
|
102
102
|
"vite": "8.1.0",
|
|
103
103
|
"vitest": "^4.1.5",
|
|
104
|
-
"@agent-native/core": "0.157.
|
|
104
|
+
"@agent-native/core": "0.157.16"
|
|
105
105
|
},
|
|
106
106
|
"peerDependencies": {
|
|
107
107
|
"@agent-native/core": ">=0.8.0",
|
|
@@ -259,6 +259,92 @@ describe("listWorkspaceApps", () => {
|
|
|
259
259
|
]);
|
|
260
260
|
});
|
|
261
261
|
|
|
262
|
+
it("falls back to the authenticated workspace action for hosted gateways", async () => {
|
|
263
|
+
const fetchMock = vi
|
|
264
|
+
.fn()
|
|
265
|
+
.mockResolvedValueOnce(new Response("not found", { status: 404 }))
|
|
266
|
+
.mockResolvedValueOnce(
|
|
267
|
+
new Response(
|
|
268
|
+
JSON.stringify([
|
|
269
|
+
{
|
|
270
|
+
id: "atlas",
|
|
271
|
+
name: "Atlas",
|
|
272
|
+
path: "/atlas",
|
|
273
|
+
url: "https://agent-workspace.builder.io/atlas",
|
|
274
|
+
},
|
|
275
|
+
]),
|
|
276
|
+
{ headers: { "content-type": "application/json" } },
|
|
277
|
+
),
|
|
278
|
+
);
|
|
279
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
280
|
+
vi.stubEnv("A2A_SECRET", "test-a2a-secret");
|
|
281
|
+
vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
|
|
282
|
+
|
|
283
|
+
const apps = await runWithRequestContext(
|
|
284
|
+
{ userEmail: "dev@example.test" },
|
|
285
|
+
() => listWorkspaceApps({ includeAgentCards: false }),
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
289
|
+
expect(String(fetchMock.mock.calls[1]?.[0])).toBe(
|
|
290
|
+
"https://agent-workspace.builder.io/_agent-native/actions/list-workspace-apps?includeAgentCards=false&audience=all",
|
|
291
|
+
);
|
|
292
|
+
expect(fetchMock.mock.calls[1]?.[1]).toEqual(
|
|
293
|
+
expect.objectContaining({
|
|
294
|
+
headers: expect.objectContaining({
|
|
295
|
+
accept: "application/json",
|
|
296
|
+
Authorization: expect.stringMatching(/^Bearer /),
|
|
297
|
+
}),
|
|
298
|
+
}),
|
|
299
|
+
);
|
|
300
|
+
expect(apps.map((app) => app.id)).toEqual(["atlas"]);
|
|
301
|
+
expect(apps[0]?.url).toBe("https://agent-workspace.builder.io/atlas");
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("keeps the exact request org in hosted registry tokens", async () => {
|
|
305
|
+
const fetchMock = vi
|
|
306
|
+
.fn()
|
|
307
|
+
.mockResolvedValueOnce(new Response("not found", { status: 404 }))
|
|
308
|
+
.mockResolvedValueOnce(
|
|
309
|
+
new Response(JSON.stringify([]), {
|
|
310
|
+
headers: { "content-type": "application/json" },
|
|
311
|
+
}),
|
|
312
|
+
);
|
|
313
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
314
|
+
vi.stubEnv("A2A_SECRET", "test-a2a-secret");
|
|
315
|
+
vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
|
|
316
|
+
|
|
317
|
+
await runWithRequestContext(
|
|
318
|
+
{ userEmail: "dev@example.test", orgId: "org-exact" },
|
|
319
|
+
() => listWorkspaceApps({ includeAgentCards: false }),
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
const authorization = fetchMock.mock.calls[1]?.[1]?.headers
|
|
323
|
+
?.Authorization as string;
|
|
324
|
+
const tokenPayload = JSON.parse(
|
|
325
|
+
Buffer.from(
|
|
326
|
+
authorization.slice("Bearer ".length).split(".")[1]!,
|
|
327
|
+
"base64url",
|
|
328
|
+
).toString(),
|
|
329
|
+
) as { org_id?: string };
|
|
330
|
+
expect(tokenPayload.org_id).toBe("org-exact");
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("falls back to local discovery when the gateway URL is malformed", async () => {
|
|
334
|
+
stubManifest();
|
|
335
|
+
vi.stubEnv("WORKSPACE_GATEWAY_URL", "not-a-url");
|
|
336
|
+
const fetchMock = vi.fn();
|
|
337
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
338
|
+
|
|
339
|
+
const apps = await runWithRequestContext(
|
|
340
|
+
{ userEmail: "dev@example.test" },
|
|
341
|
+
() => listWorkspaceApps({ includeAgentCards: false }),
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
345
|
+
expect(apps.map((app) => app.id)).toEqual(["dispatch"]);
|
|
346
|
+
});
|
|
347
|
+
|
|
262
348
|
it("filters workspace apps by audience", async () => {
|
|
263
349
|
stubNoPendingContext();
|
|
264
350
|
vi.stubEnv(
|
|
@@ -3,7 +3,9 @@ import fs from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
|
|
6
|
+
import { signA2AToken } from "@agent-native/core/a2a";
|
|
6
7
|
import { getDbExec } from "@agent-native/core/db";
|
|
8
|
+
import { getOrgA2ASecret, getOrgDomain } from "@agent-native/core/org";
|
|
7
9
|
import {
|
|
8
10
|
ensureBuilderProject,
|
|
9
11
|
getBuilderBranchProjectId,
|
|
@@ -23,6 +25,7 @@ import {
|
|
|
23
25
|
resolveLinkedOwner,
|
|
24
26
|
} from "./dispatch-store.js";
|
|
25
27
|
import { createRequest, listSecretOptions } from "./vault-store.js";
|
|
28
|
+
import { WORKSPACE_APPS_ACTION_PATH } from "./workspace-app-action-auth.js";
|
|
26
29
|
import {
|
|
27
30
|
grantWorkspaceResourcesToApp,
|
|
28
31
|
listWorkspaceResourceOptions,
|
|
@@ -42,7 +45,7 @@ const WORKSPACE_APP_METADATA_SETTINGS_KEY = "workspace-app-metadata";
|
|
|
42
45
|
const WORKSPACE_APPS_ENV_KEY = "AGENT_NATIVE_WORKSPACE_APPS_JSON";
|
|
43
46
|
const WORKSPACE_APPS_MANIFEST_FILE = "workspace-apps.json";
|
|
44
47
|
const WORKSPACE_APPS_GATEWAY_PATH = "/_workspace/apps";
|
|
45
|
-
const WORKSPACE_APPS_GATEWAY_TIMEOUT_MS =
|
|
48
|
+
const WORKSPACE_APPS_GATEWAY_TIMEOUT_MS = 2_500;
|
|
46
49
|
const MAX_PENDING_APPS = 50;
|
|
47
50
|
const PENDING_WORKSPACE_APP_TTL_MS = 7 * 24 * 60 * 60 * 1_000;
|
|
48
51
|
const AGENT_CARD_PATH = "/.well-known/agent-card.json";
|
|
@@ -1075,22 +1078,108 @@ async function readWorkspaceAppsFromGateway(): Promise<
|
|
|
1075
1078
|
const base = process.env.WORKSPACE_GATEWAY_URL;
|
|
1076
1079
|
if (!base) return null;
|
|
1077
1080
|
|
|
1081
|
+
let baseUrl: URL;
|
|
1082
|
+
try {
|
|
1083
|
+
baseUrl = new URL(base);
|
|
1084
|
+
} catch {
|
|
1085
|
+
// coercion-ok: malformed gateway configuration is an unavailable registry
|
|
1086
|
+
// and falls back to local sources.
|
|
1087
|
+
return null;
|
|
1088
|
+
}
|
|
1089
|
+
if (baseUrl.protocol !== "http:" && baseUrl.protocol !== "https:") {
|
|
1090
|
+
return null;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1078
1093
|
const controller = new AbortController();
|
|
1079
1094
|
const timeout = setTimeout(
|
|
1080
1095
|
() => controller.abort(),
|
|
1081
1096
|
WORKSPACE_APPS_GATEWAY_TIMEOUT_MS,
|
|
1082
1097
|
);
|
|
1083
1098
|
|
|
1099
|
+
const requestContext = getRequestContext();
|
|
1100
|
+
const authHeaders: Record<string, string> = {};
|
|
1101
|
+
if (requestContext?.userEmail) {
|
|
1102
|
+
const [orgDomain, orgSecret] = requestContext.orgId
|
|
1103
|
+
? await Promise.all([
|
|
1104
|
+
// coercion-ok: an unavailable org row falls back to the deployment secret.
|
|
1105
|
+
getOrgDomain(requestContext.orgId).catch(() => null),
|
|
1106
|
+
// coercion-ok: an unavailable org row falls back to the deployment secret.
|
|
1107
|
+
getOrgA2ASecret(requestContext.orgId).catch(() => null),
|
|
1108
|
+
])
|
|
1109
|
+
: [null, null];
|
|
1110
|
+
const usableOrgSecret =
|
|
1111
|
+
typeof orgSecret === "string" && orgSecret.trim().length > 0;
|
|
1112
|
+
const usableOrgDomain =
|
|
1113
|
+
typeof orgDomain === "string" && orgDomain.trim().length > 0;
|
|
1114
|
+
try {
|
|
1115
|
+
const token = await signA2AToken(
|
|
1116
|
+
requestContext.userEmail,
|
|
1117
|
+
usableOrgDomain ? orgDomain.trim() : undefined,
|
|
1118
|
+
usableOrgSecret ? orgSecret.trim() : undefined,
|
|
1119
|
+
{
|
|
1120
|
+
expiresIn: "1m",
|
|
1121
|
+
// The workspace registry is an authenticated read. Prefer the
|
|
1122
|
+
// shared deployment secret so a registry read does not depend on
|
|
1123
|
+
// every app having the same org row.
|
|
1124
|
+
preferGlobalSecret: true,
|
|
1125
|
+
// Keep the exact request scope even when the org-domain lookup is
|
|
1126
|
+
// unavailable. The receiver must never infer a different org from
|
|
1127
|
+
// the caller's email in that case.
|
|
1128
|
+
...(requestContext.orgId
|
|
1129
|
+
? { extraClaims: { org_id: requestContext.orgId } }
|
|
1130
|
+
: {}),
|
|
1131
|
+
},
|
|
1132
|
+
);
|
|
1133
|
+
authHeaders.Authorization = `Bearer ${token}`;
|
|
1134
|
+
} catch {
|
|
1135
|
+
// coercion-ok: absent signing credentials keep local unauthenticated
|
|
1136
|
+
// gateway discovery available and make hosted discovery fail closed.
|
|
1137
|
+
// Keep the unauthenticated local-dev gateway path available. A hosted
|
|
1138
|
+
// gateway will fail closed below when its action route needs identity.
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
const gatewayUrl = (pathname: string): URL => {
|
|
1143
|
+
const url = new URL(baseUrl.toString());
|
|
1144
|
+
const basePath = url.pathname.replace(/\/+$/, "");
|
|
1145
|
+
url.pathname = `${basePath}${pathname}` || "/";
|
|
1146
|
+
url.search = "";
|
|
1147
|
+
url.hash = "";
|
|
1148
|
+
return url;
|
|
1149
|
+
};
|
|
1150
|
+
|
|
1151
|
+
const headers = {
|
|
1152
|
+
accept: "application/json",
|
|
1153
|
+
...authHeaders,
|
|
1154
|
+
};
|
|
1155
|
+
|
|
1084
1156
|
try {
|
|
1085
|
-
const
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1157
|
+
const localResponse = await fetch(gatewayUrl(WORKSPACE_APPS_GATEWAY_PATH), {
|
|
1158
|
+
headers,
|
|
1159
|
+
signal: controller.signal,
|
|
1160
|
+
});
|
|
1161
|
+
if (localResponse.ok) {
|
|
1162
|
+
return parseWorkspaceAppsManifest(
|
|
1163
|
+
// coercion-ok: malformed gateway JSON is an unavailable registry and
|
|
1164
|
+
// must fall through to the local manifest sources.
|
|
1165
|
+
await localResponse.json().catch(() => null),
|
|
1166
|
+
);
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
if (!authHeaders.Authorization) return null;
|
|
1170
|
+
const actionUrl = gatewayUrl(WORKSPACE_APPS_ACTION_PATH);
|
|
1171
|
+
actionUrl.searchParams.set("includeAgentCards", "false");
|
|
1172
|
+
actionUrl.searchParams.set("audience", "all");
|
|
1173
|
+
const actionResponse = await fetch(actionUrl, {
|
|
1174
|
+
headers,
|
|
1175
|
+
signal: controller.signal,
|
|
1176
|
+
});
|
|
1177
|
+
if (!actionResponse.ok) return null;
|
|
1178
|
+
return parseWorkspaceAppsManifest(
|
|
1179
|
+
// coercion-ok: malformed gateway JSON is an unavailable registry and
|
|
1180
|
+
// must fall through to the local manifest sources.
|
|
1181
|
+
await actionResponse.json().catch(() => null),
|
|
1091
1182
|
);
|
|
1092
|
-
if (!response.ok) return null;
|
|
1093
|
-
return parseWorkspaceAppsManifest(await response.json().catch(() => null));
|
|
1094
1183
|
} catch {
|
|
1095
1184
|
return null;
|
|
1096
1185
|
} finally {
|
|
@@ -940,6 +940,55 @@ describe("createGrantedDispatchMcpEmbedSession", () => {
|
|
|
940
940
|
expect(mocks.managerConstructor).toHaveBeenCalled();
|
|
941
941
|
});
|
|
942
942
|
|
|
943
|
+
it("allows custom mounted workspace apps from the live workspace registry", async () => {
|
|
944
|
+
vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
|
|
945
|
+
mocks.discoverAgents.mockResolvedValue([]);
|
|
946
|
+
mocks.managerCallTool.mockResolvedValueOnce({
|
|
947
|
+
structuredContent: {
|
|
948
|
+
startUrl:
|
|
949
|
+
"https://agent-workspace.builder.io/atlas/_agent-native/embed/start?ticket=remote",
|
|
950
|
+
},
|
|
951
|
+
});
|
|
952
|
+
mocks.listWorkspaceApps.mockResolvedValue([
|
|
953
|
+
{
|
|
954
|
+
id: "atlas",
|
|
955
|
+
name: "Atlas",
|
|
956
|
+
description: "Workspace app",
|
|
957
|
+
path: "/atlas",
|
|
958
|
+
url: "https://agent-workspace.builder.io/atlas",
|
|
959
|
+
isDispatch: false,
|
|
960
|
+
audience: "internal",
|
|
961
|
+
publicPaths: [],
|
|
962
|
+
protectedPaths: [],
|
|
963
|
+
},
|
|
964
|
+
]);
|
|
965
|
+
|
|
966
|
+
const result = await runWithRequestContext(
|
|
967
|
+
{
|
|
968
|
+
userEmail: "owner@example.test",
|
|
969
|
+
requestOrigin: "https://dispatch.agent-native.com",
|
|
970
|
+
},
|
|
971
|
+
() =>
|
|
972
|
+
createWorkspaceSsoEmbedSession({
|
|
973
|
+
app: "atlas",
|
|
974
|
+
path: "/",
|
|
975
|
+
}),
|
|
976
|
+
);
|
|
977
|
+
|
|
978
|
+
expect(result).toMatchObject({
|
|
979
|
+
app: "atlas",
|
|
980
|
+
startUrl:
|
|
981
|
+
"https://agent-workspace.builder.io/atlas/_agent-native/embed/start?ticket=remote",
|
|
982
|
+
});
|
|
983
|
+
expect(mocks.managerConstructor).toHaveBeenCalledWith({
|
|
984
|
+
servers: {
|
|
985
|
+
target: expect.objectContaining({
|
|
986
|
+
url: "https://agent-workspace.builder.io/atlas/mcp",
|
|
987
|
+
}),
|
|
988
|
+
},
|
|
989
|
+
});
|
|
990
|
+
});
|
|
991
|
+
|
|
943
992
|
it("allows an exact custom registry entry and rejects an unregistered external app", async () => {
|
|
944
993
|
vi.stubEnv(
|
|
945
994
|
"IDENTITY_SSO_APP_REGISTRY_JSON",
|
|
@@ -621,6 +621,17 @@ function workspaceSsoOriginForApp(appId: string): string | null {
|
|
|
621
621
|
return custom?.origin ?? null;
|
|
622
622
|
}
|
|
623
623
|
|
|
624
|
+
function workspaceSsoGatewayOrigin(): string | null {
|
|
625
|
+
const configured = process.env.WORKSPACE_GATEWAY_URL?.trim();
|
|
626
|
+
if (!configured) return null;
|
|
627
|
+
try {
|
|
628
|
+
return new URL(configured).origin;
|
|
629
|
+
} catch {
|
|
630
|
+
// coercion-ok: malformed deployment configuration is not a trusted origin.
|
|
631
|
+
return null;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
624
635
|
function isWorkspaceSsoCanonicalApp(app: DispatchMcpAccessibleApp): boolean {
|
|
625
636
|
const origin = safeAppOrigin(app);
|
|
626
637
|
if (!origin) return false;
|
|
@@ -664,7 +675,8 @@ async function isEligibleWorkspaceSsoApp(
|
|
|
664
675
|
color: DISPATCH_COLOR,
|
|
665
676
|
granted: true,
|
|
666
677
|
});
|
|
667
|
-
|
|
678
|
+
const trustedMountedOrigin = workspaceSsoGatewayOrigin() ?? dispatchOrigin;
|
|
679
|
+
if (origin !== trustedMountedOrigin) return false;
|
|
668
680
|
return mountedApps.some((mounted) => sameWorkspaceApp(candidate, mounted));
|
|
669
681
|
}
|
|
670
682
|
|
|
@@ -673,15 +685,32 @@ async function listWorkspaceSsoApps(): Promise<DispatchMcpAccessibleApp[]> {
|
|
|
673
685
|
discoverAgents("dispatch"),
|
|
674
686
|
listWorkspaceApps({ includeAgentCards: false }),
|
|
675
687
|
]);
|
|
676
|
-
const
|
|
677
|
-
|
|
678
|
-
|
|
688
|
+
const candidatesById = new Map<string, DispatchMcpAccessibleApp>();
|
|
689
|
+
for (const agent of agents) {
|
|
690
|
+
if (normalizeAppId(agent.id) === DISPATCH_APP_ID) continue;
|
|
691
|
+
candidatesById.set(agent.id, {
|
|
679
692
|
...toAccessibleApp(agent, {
|
|
680
693
|
mode: "all-apps",
|
|
681
694
|
selectedAppIds: [],
|
|
682
695
|
}),
|
|
683
696
|
granted: true,
|
|
684
|
-
})
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
// The hosted Dispatch database is separate from the shared Workspace
|
|
700
|
+
// database. Overlay the live mounted registry so custom apps are eligible
|
|
701
|
+
// without copying a stale app list into Dispatch configuration.
|
|
702
|
+
for (const app of mountedApps) {
|
|
703
|
+
if (app.isDispatch || !app.url) continue;
|
|
704
|
+
candidatesById.set(app.id, {
|
|
705
|
+
id: app.id,
|
|
706
|
+
name: app.name,
|
|
707
|
+
description: app.description,
|
|
708
|
+
url: app.url,
|
|
709
|
+
color: DISPATCH_COLOR,
|
|
710
|
+
granted: true,
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
const candidates = [...candidatesById.values()];
|
|
685
714
|
const eligible = [] as DispatchMcpAccessibleApp[];
|
|
686
715
|
for (const candidate of candidates) {
|
|
687
716
|
if (await isEligibleWorkspaceSsoApp(candidate, mountedApps)) {
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const mocks = vi.hoisted(() => ({
|
|
4
|
+
verifyA2AToken: vi.fn(),
|
|
5
|
+
isOrgMember: vi.fn(),
|
|
6
|
+
resolveOrgByDomain: vi.fn(),
|
|
7
|
+
resolveOrgIdForEmail: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
vi.mock("@agent-native/core/a2a", () => ({
|
|
11
|
+
verifyA2AToken: mocks.verifyA2AToken,
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
vi.mock("@agent-native/core/org", () => ({
|
|
15
|
+
isOrgMember: mocks.isOrgMember,
|
|
16
|
+
resolveOrgByDomain: mocks.resolveOrgByDomain,
|
|
17
|
+
resolveOrgIdForEmail: mocks.resolveOrgIdForEmail,
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
workspaceAppActionRouteAuth,
|
|
22
|
+
WORKSPACE_APPS_ACTION_PATH,
|
|
23
|
+
} from "./workspace-app-action-auth.js";
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
vi.clearAllMocks();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
function eventFor(path: string, authorization?: string) {
|
|
30
|
+
return {
|
|
31
|
+
path,
|
|
32
|
+
context: {},
|
|
33
|
+
node: {
|
|
34
|
+
req: {
|
|
35
|
+
headers: authorization ? { authorization } : {},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("workspace app action auth", () => {
|
|
42
|
+
it("only resolves the mounted workspace registry action", async () => {
|
|
43
|
+
await expect(
|
|
44
|
+
workspaceAppActionRouteAuth.resolveCaller?.(
|
|
45
|
+
eventFor(
|
|
46
|
+
"/_agent-native/actions/list-connected-agents",
|
|
47
|
+
"Bearer token",
|
|
48
|
+
),
|
|
49
|
+
),
|
|
50
|
+
).resolves.toBeNull();
|
|
51
|
+
expect(mocks.verifyA2AToken).not.toHaveBeenCalled();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("rejects an invalid registry bearer instead of falling through to cookies", async () => {
|
|
55
|
+
mocks.verifyA2AToken.mockResolvedValue({ email: null, orgDomain: null });
|
|
56
|
+
|
|
57
|
+
await expect(
|
|
58
|
+
workspaceAppActionRouteAuth.resolveCaller?.(
|
|
59
|
+
eventFor(WORKSPACE_APPS_ACTION_PATH, "Bearer invalid"),
|
|
60
|
+
),
|
|
61
|
+
).rejects.toThrow("Invalid workspace registry authorization");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("returns the verified caller and local org scope", async () => {
|
|
65
|
+
mocks.verifyA2AToken.mockResolvedValue({
|
|
66
|
+
email: "steve@builder.io",
|
|
67
|
+
orgDomain: "builder.io",
|
|
68
|
+
});
|
|
69
|
+
mocks.resolveOrgByDomain.mockResolvedValue({
|
|
70
|
+
orgId: "org-builder",
|
|
71
|
+
orgName: "Builder.io",
|
|
72
|
+
});
|
|
73
|
+
mocks.isOrgMember.mockResolvedValue(true);
|
|
74
|
+
|
|
75
|
+
await expect(
|
|
76
|
+
workspaceAppActionRouteAuth.resolveCaller?.(
|
|
77
|
+
eventFor(WORKSPACE_APPS_ACTION_PATH, "Bearer verified"),
|
|
78
|
+
),
|
|
79
|
+
).resolves.toEqual({
|
|
80
|
+
owner: "steve@builder.io",
|
|
81
|
+
anonymous: false,
|
|
82
|
+
orgId: "org-builder",
|
|
83
|
+
});
|
|
84
|
+
expect(mocks.verifyA2AToken).toHaveBeenCalledWith(
|
|
85
|
+
"verified",
|
|
86
|
+
expect.anything(),
|
|
87
|
+
);
|
|
88
|
+
expect(mocks.resolveOrgByDomain).toHaveBeenCalledWith("builder.io");
|
|
89
|
+
expect(mocks.isOrgMember).toHaveBeenCalledWith(
|
|
90
|
+
"org-builder",
|
|
91
|
+
"steve@builder.io",
|
|
92
|
+
);
|
|
93
|
+
expect(mocks.resolveOrgIdForEmail).not.toHaveBeenCalled();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("uses the original mounted pathname after the route prefix is stripped", async () => {
|
|
97
|
+
mocks.verifyA2AToken.mockResolvedValue({
|
|
98
|
+
email: "steve@builder.io",
|
|
99
|
+
orgDomain: "builder.io",
|
|
100
|
+
});
|
|
101
|
+
mocks.resolveOrgByDomain.mockResolvedValue({
|
|
102
|
+
orgId: "org-builder",
|
|
103
|
+
orgName: "Builder.io",
|
|
104
|
+
});
|
|
105
|
+
mocks.isOrgMember.mockResolvedValue(true);
|
|
106
|
+
const event = eventFor("/", "Bearer mounted");
|
|
107
|
+
event.context = {
|
|
108
|
+
_mountedPathname: WORKSPACE_APPS_ACTION_PATH,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
await expect(
|
|
112
|
+
workspaceAppActionRouteAuth.resolveCaller?.(event),
|
|
113
|
+
).resolves.toMatchObject({
|
|
114
|
+
owner: "steve@builder.io",
|
|
115
|
+
orgId: "org-builder",
|
|
116
|
+
});
|
|
117
|
+
expect(mocks.verifyA2AToken).toHaveBeenCalledWith(
|
|
118
|
+
"mounted",
|
|
119
|
+
expect.anything(),
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("keeps legacy email-derived scope when the token has no org domain", async () => {
|
|
124
|
+
mocks.verifyA2AToken.mockResolvedValue({
|
|
125
|
+
email: "steve@builder.io",
|
|
126
|
+
orgDomain: null,
|
|
127
|
+
});
|
|
128
|
+
mocks.resolveOrgIdForEmail.mockResolvedValue("org-by-email");
|
|
129
|
+
|
|
130
|
+
await expect(
|
|
131
|
+
workspaceAppActionRouteAuth.resolveCaller?.(
|
|
132
|
+
eventFor(WORKSPACE_APPS_ACTION_PATH, "Bearer legacy"),
|
|
133
|
+
),
|
|
134
|
+
).resolves.toMatchObject({ orgId: "org-by-email" });
|
|
135
|
+
expect(mocks.resolveOrgIdForEmail).toHaveBeenCalledWith("steve@builder.io");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("preserves a signed org id when the token has no resolvable domain", async () => {
|
|
139
|
+
mocks.verifyA2AToken.mockResolvedValue({
|
|
140
|
+
email: "steve@builder.io",
|
|
141
|
+
orgDomain: null,
|
|
142
|
+
orgId: "org-builder",
|
|
143
|
+
});
|
|
144
|
+
mocks.isOrgMember.mockResolvedValue(true);
|
|
145
|
+
|
|
146
|
+
await expect(
|
|
147
|
+
workspaceAppActionRouteAuth.resolveCaller?.(
|
|
148
|
+
eventFor(WORKSPACE_APPS_ACTION_PATH, "Bearer scoped"),
|
|
149
|
+
),
|
|
150
|
+
).resolves.toMatchObject({ orgId: "org-builder" });
|
|
151
|
+
expect(mocks.isOrgMember).toHaveBeenCalledWith(
|
|
152
|
+
"org-builder",
|
|
153
|
+
"steve@builder.io",
|
|
154
|
+
);
|
|
155
|
+
expect(mocks.resolveOrgIdForEmail).not.toHaveBeenCalled();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("rejects mismatched domain and org id claims", async () => {
|
|
159
|
+
mocks.verifyA2AToken.mockResolvedValue({
|
|
160
|
+
email: "steve@builder.io",
|
|
161
|
+
orgDomain: "builder.io",
|
|
162
|
+
orgId: "org-other",
|
|
163
|
+
});
|
|
164
|
+
mocks.resolveOrgByDomain.mockResolvedValue({
|
|
165
|
+
orgId: "org-builder",
|
|
166
|
+
orgName: "Builder.io",
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
await expect(
|
|
170
|
+
workspaceAppActionRouteAuth.resolveCaller?.(
|
|
171
|
+
eventFor(WORKSPACE_APPS_ACTION_PATH, "Bearer mismatched"),
|
|
172
|
+
),
|
|
173
|
+
).rejects.toThrow("Invalid workspace registry authorization");
|
|
174
|
+
expect(mocks.isOrgMember).not.toHaveBeenCalled();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("rejects a verified domain that is not registered locally", async () => {
|
|
178
|
+
mocks.verifyA2AToken.mockResolvedValue({
|
|
179
|
+
email: "steve@builder.io",
|
|
180
|
+
orgDomain: "unknown.example",
|
|
181
|
+
});
|
|
182
|
+
mocks.resolveOrgByDomain.mockResolvedValue(null);
|
|
183
|
+
|
|
184
|
+
await expect(
|
|
185
|
+
workspaceAppActionRouteAuth.resolveCaller?.(
|
|
186
|
+
eventFor(WORKSPACE_APPS_ACTION_PATH, "Bearer unmapped"),
|
|
187
|
+
),
|
|
188
|
+
).rejects.toThrow("Invalid workspace registry authorization");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("rejects a global-token caller who is not a member of the claimed org", async () => {
|
|
192
|
+
mocks.verifyA2AToken.mockResolvedValue({
|
|
193
|
+
email: "outsider@example.com",
|
|
194
|
+
orgDomain: "builder.io",
|
|
195
|
+
});
|
|
196
|
+
mocks.resolveOrgByDomain.mockResolvedValue({
|
|
197
|
+
orgId: "org-builder",
|
|
198
|
+
orgName: "Builder.io",
|
|
199
|
+
});
|
|
200
|
+
mocks.isOrgMember.mockResolvedValue(false);
|
|
201
|
+
|
|
202
|
+
await expect(
|
|
203
|
+
workspaceAppActionRouteAuth.resolveCaller?.(
|
|
204
|
+
eventFor(WORKSPACE_APPS_ACTION_PATH, "Bearer global-token"),
|
|
205
|
+
),
|
|
206
|
+
).rejects.toThrow("Invalid workspace registry authorization");
|
|
207
|
+
expect(mocks.isOrgMember).toHaveBeenCalledWith(
|
|
208
|
+
"org-builder",
|
|
209
|
+
"outsider@example.com",
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
});
|