@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.
@@ -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;AAEzD,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,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\";\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 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"]}
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",
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.10"
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 = 1_000;
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 response = await fetch(
1086
- new URL(WORKSPACE_APPS_GATEWAY_PATH, `${base.replace(/\/$/, "")}/`),
1087
- {
1088
- headers: { accept: "application/json" },
1089
- signal: controller.signal,
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
- if (origin !== dispatchOrigin) return false;
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 candidates = agents
677
- .filter((agent) => normalizeAppId(agent.id) !== DISPATCH_APP_ID)
678
- .map((agent) => ({
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
+ });