@agent-native/dispatch 0.38.1 → 0.38.2-nightly-20260916205829

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/dispatch",
3
- "version": "0.38.1",
3
+ "version": "0.38.2-nightly-20260916205829",
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": {
@@ -88,7 +88,7 @@
88
88
  "tailwind-merge": "^3.5.0",
89
89
  "vaul": "^1.1.2",
90
90
  "zod": "^4.3.6",
91
- "@agent-native/toolkit": "0.20.3"
91
+ "@agent-native/toolkit": "0.20.4-nightly-20260916205829"
92
92
  },
93
93
  "devDependencies": {
94
94
  "@react-router/dev": "^8.1.0",
@@ -102,10 +102,10 @@
102
102
  "typescript-7": "npm:typescript@^7.0.2",
103
103
  "vite": "8.1.0",
104
104
  "vitest": "^4.1.5",
105
- "@agent-native/core": "0.182.0"
105
+ "@agent-native/core": "0.182.1-nightly-20260916205829"
106
106
  },
107
107
  "peerDependencies": {
108
- "@agent-native/core": ">=0.8.0",
108
+ "@agent-native/core": "0.182.1-nightly-20260916205829",
109
109
  "react": ">=19.2.7",
110
110
  "react-dom": ">=19.2.7",
111
111
  "react-router": ">=8"
@@ -28,9 +28,11 @@ const mocks = vi.hoisted(() => {
28
28
  const state = {
29
29
  orgRole: "admin" as string | null,
30
30
  };
31
+ const executedSql: string[] = [];
31
32
  return {
32
33
  settings,
33
34
  state,
35
+ executedSql,
34
36
  getSetting: vi.fn(async (key: string) => settings.get(key) ?? null),
35
37
  mutateSetting: vi.fn(
36
38
  async (key: string, updater: (current: any) => any) => {
@@ -54,6 +56,7 @@ const mocks = vi.hoisted(() => {
54
56
  typeof statement === "string"
55
57
  ? statement
56
58
  : String((statement as { sql?: unknown })?.sql ?? "");
59
+ executedSql.push(sql);
57
60
  if (sql.includes("SELECT id FROM workspace_apps")) {
58
61
  return { rows: [], rowsAffected: 0 };
59
62
  }
@@ -163,6 +166,7 @@ afterEach(() => {
163
166
  vi.unstubAllEnvs();
164
167
  vi.unstubAllGlobals();
165
168
  vi.clearAllMocks();
169
+ mocks.executedSql.length = 0;
166
170
  mocks.settings.clear();
167
171
  mocks.getOrgSetting.mockReset();
168
172
  mocks.getOrgSetting.mockResolvedValue(null);
@@ -184,6 +188,7 @@ afterEach(() => {
184
188
  typeof statement === "string"
185
189
  ? statement
186
190
  : String((statement as { sql?: unknown })?.sql ?? "");
191
+ mocks.executedSql.push(sql);
187
192
  if (sql.includes("SELECT id FROM workspace_apps")) {
188
193
  return { rows: [], rowsAffected: 0 };
189
194
  }
@@ -362,6 +367,35 @@ describe("listWorkspaceApps", () => {
362
367
  ]);
363
368
  });
364
369
 
370
+ it.each([401, 403])(
371
+ "keeps a local gateway denial on the unverified fallback path (%i)",
372
+ async (status) => {
373
+ const fetchMock = vi.fn(async () => new Response("denied", { status }));
374
+ vi.stubGlobal("fetch", fetchMock);
375
+ vi.stubEnv("NODE_ENV", "production");
376
+ vi.stubEnv("WORKSPACE_GATEWAY_URL", "http://127.0.0.1:8080");
377
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
378
+ stubManifest([
379
+ { id: "dispatch", name: "Dispatch", path: "/dispatch" },
380
+ { id: "clips", name: "Clips", path: "/clips" },
381
+ ]);
382
+
383
+ const apps = await runWithRequestContext(
384
+ { userEmail: "dev@example.test" },
385
+ () => listWorkspaceApps({ includeAgentCards: false }),
386
+ );
387
+
388
+ expect(apps.map((app) => app.id)).toEqual(["dispatch", "clips"]);
389
+ expect(fetchMock).toHaveBeenCalledTimes(1);
390
+ expect(warn).toHaveBeenCalledWith(
391
+ expect.stringContaining(
392
+ `workspace apps gateway denied the registry read with HTTP ${status}`,
393
+ ),
394
+ );
395
+ warn.mockRestore();
396
+ },
397
+ );
398
+
365
399
  it("derives manifest app URLs from the Vercel preview when no workspace origin is configured", async () => {
366
400
  stubNoPendingContext();
367
401
  stubManifest([
@@ -433,10 +467,12 @@ describe("listWorkspaceApps", () => {
433
467
  });
434
468
 
435
469
  it.each([401, 403])(
436
- "surfaces hosted registry authorization failures instead of using local manifests (%i)",
470
+ "does not expose manifest apps without ACL rows when the hosted registry denies the read (%i)",
437
471
  async (status) => {
438
472
  const fetchMock = vi.fn(async () => new Response("denied", { status }));
439
473
  vi.stubGlobal("fetch", fetchMock);
474
+ mocks.resolveAccess.mockResolvedValue(null);
475
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
440
476
  vi.stubEnv("A2A_SECRET", "test-a2a-secret");
441
477
  vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
442
478
  stubManifest([
@@ -444,6 +480,60 @@ describe("listWorkspaceApps", () => {
444
480
  { id: "clips", name: "Clips", path: "/clips" },
445
481
  ]);
446
482
 
483
+ const apps = await runWithRequestContext(
484
+ { userEmail: "dev@example.test" },
485
+ () => listWorkspaceApps({ includeAgentCards: false }),
486
+ );
487
+
488
+ expect(apps.map((app) => app.id)).toEqual(["dispatch"]);
489
+ expect(fetchMock).toHaveBeenCalledTimes(1);
490
+ expect(warn).toHaveBeenCalledWith(
491
+ expect.stringContaining(
492
+ `workspace apps gateway denied the registry read with HTTP ${status}`,
493
+ ),
494
+ );
495
+ expect(warn).toHaveBeenCalledWith(
496
+ expect.stringContaining("hidden from this response"),
497
+ );
498
+ warn.mockRestore();
499
+ },
500
+ );
501
+
502
+ it("never mutates registry state from the unverified fallback manifest", async () => {
503
+ const fetchMock = vi.fn(
504
+ async () => new Response("denied", { status: 403 }),
505
+ );
506
+ vi.stubGlobal("fetch", fetchMock);
507
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
508
+ vi.stubEnv("A2A_SECRET", "test-a2a-secret");
509
+ vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
510
+ stubManifest([
511
+ { id: "dispatch", name: "Dispatch", path: "/dispatch" },
512
+ { id: "clips", name: "Clips", path: "/clips" },
513
+ ]);
514
+
515
+ await runWithRequestContext(
516
+ { userEmail: "dev@example.test", orgId: "builder_io" },
517
+ () => listWorkspaceApps({ includeAgentCards: false }),
518
+ );
519
+
520
+ expect(
521
+ mocks.executedSql.filter((sql) =>
522
+ /\b(INSERT|UPDATE|DELETE)\b/i.test(sql),
523
+ ),
524
+ ).toEqual([]);
525
+ warn.mockRestore();
526
+ });
527
+
528
+ it.each([401, 403])(
529
+ "still rejects a denied registry read when no deployment manifest can answer (%i)",
530
+ async (status) => {
531
+ const fetchMock = vi.fn(async () => new Response("denied", { status }));
532
+ vi.stubGlobal("fetch", fetchMock);
533
+ vi.stubEnv("A2A_SECRET", "test-a2a-secret");
534
+ vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
535
+ vi.stubEnv("AGENT_NATIVE_WORKSPACE_APPS_JSON", "");
536
+
447
537
  await expect(
448
538
  runWithRequestContext({ userEmail: "dev@example.test" }, () =>
449
539
  listWorkspaceApps({ includeAgentCards: false }),
@@ -451,7 +541,6 @@ describe("listWorkspaceApps", () => {
451
541
  ).rejects.toThrow(
452
542
  `Workspace apps gateway rejected the request with HTTP ${status}.`,
453
543
  );
454
- expect(fetchMock).toHaveBeenCalledTimes(1);
455
544
  },
456
545
  );
457
546
 
@@ -119,6 +119,17 @@ class WorkspaceAppsGatewayAuthorizationError extends Error {
119
119
  statusCode: 401 | 403;
120
120
  }
121
121
 
122
+ /** A denied gateway read is visible in logs even when a fallback can answer. */
123
+ function warnWorkspaceAppsGatewayDenial(
124
+ denial: WorkspaceAppsGatewayAuthorizationError | null,
125
+ source: string,
126
+ ): void {
127
+ if (!denial) return;
128
+ console.warn(
129
+ `[dispatch] workspace apps gateway denied the registry read with HTTP ${denial.statusCode}; served the ${source} instead`,
130
+ );
131
+ }
132
+
122
133
  type WorkspaceAppAudience = "internal" | "public";
123
134
  type WorkspaceAppVisibility = "private" | "org";
124
135
 
@@ -154,6 +165,13 @@ export interface WorkspaceAppSummary {
154
165
  orgEnabled?: boolean;
155
166
  }
156
167
 
168
+ interface FinalizeWorkspaceAppsOptions {
169
+ /** Delete org rows absent from an authoritative manifest. */
170
+ reconcile?: boolean;
171
+ /** Write registry rows. False for a source the caller could not authenticate. */
172
+ persist?: boolean;
173
+ }
174
+
157
175
  interface WorkspaceAppDiscovery {
158
176
  apps: WorkspaceAppSummary[];
159
177
  authoritative: boolean;
@@ -1259,13 +1277,20 @@ function appRecordTimestamp(value: string | null | undefined): number {
1259
1277
  */
1260
1278
  async function ensureWorkspaceAppRecords(
1261
1279
  apps: WorkspaceAppSummary[],
1262
- options: { reconcile?: boolean } = {},
1280
+ options: { reconcile?: boolean; persist?: boolean } = {},
1263
1281
  ): Promise<WorkspaceAppSummary[]> {
1264
1282
  const readyApps = apps.filter(
1265
1283
  (app) => app.status !== "pending" && !app.isDispatch,
1266
1284
  );
1267
- const shouldReconcile = options.reconcile === true;
1268
- if (!shouldReconcile && readyApps.length === 0) return apps;
1285
+ // A source the caller could not authenticate annotates from existing rows
1286
+ // only. Minting a row here would create the very authorization the access
1287
+ // filter then checks, and reconciling would delete rows and shares on the
1288
+ // word of a manifest no authoritative registry confirmed.
1289
+ const shouldPersist = options.persist !== false;
1290
+ const shouldReconcile = options.reconcile === true && shouldPersist;
1291
+ if (!shouldReconcile && readyApps.length === 0) {
1292
+ return apps;
1293
+ }
1269
1294
 
1270
1295
  const orgId = currentOrgId();
1271
1296
  const metadata = await readWorkspaceAppMetadataSettings();
@@ -1279,6 +1304,7 @@ async function ensureWorkspaceAppRecords(
1279
1304
  orgEnabled: boolean;
1280
1305
  }
1281
1306
  >();
1307
+ const unresolvedIds: string[] = [];
1282
1308
 
1283
1309
  try {
1284
1310
  const existingRecords = new Map<
@@ -1312,6 +1338,10 @@ async function ensureWorkspaceAppRecords(
1312
1338
  for (const app of readyApps) {
1313
1339
  const existing = existingRecords.get(app.id);
1314
1340
  if (!existing) {
1341
+ if (!shouldPersist) {
1342
+ unresolvedIds.push(app.id);
1343
+ continue;
1344
+ }
1315
1345
  const override = metadata.apps[app.id];
1316
1346
  // Never infer ownership from the person who happened to list apps.
1317
1347
  // Legacy manifests without trusted creation metadata remain
@@ -1354,7 +1384,7 @@ async function ensureWorkspaceAppRecords(
1354
1384
  const existingOrgId = cleanOptionalText(existing.org_id) ?? null;
1355
1385
  // A registry row belongs to the org that created it. Never reassign a
1356
1386
  // row from another org just because a caller listed the same manifest.
1357
- if (existingOrgId && existingOrgId !== orgId) {
1387
+ if (!shouldPersist || (existingOrgId && existingOrgId !== orgId)) {
1358
1388
  records.set(app.id, {
1359
1389
  ownerEmail: existingOwnerEmail,
1360
1390
  orgId: existingOrgId,
@@ -1424,6 +1454,12 @@ async function ensureWorkspaceAppRecords(
1424
1454
  }
1425
1455
  }
1426
1456
 
1457
+ if (unresolvedIds.length > 0) {
1458
+ console.warn(
1459
+ `[dispatch] unverified workspace app read has no access record for ${unresolvedIds.length} app(s); hidden from this response: ${unresolvedIds.join(", ")}`,
1460
+ );
1461
+ }
1462
+
1427
1463
  if (shouldReconcile && orgId) {
1428
1464
  const currentAppIds = new Set(readyApps.map((app) => app.id));
1429
1465
  const result = await db.execute({
@@ -1539,6 +1575,8 @@ async function filterWorkspaceAppsByAccess(
1539
1575
  { userEmail, orgId },
1540
1576
  { skipResourceBody: true },
1541
1577
  );
1578
+ // A missing row is not an authorization result. This remains
1579
+ // fail-closed when a denied registry read falls back to a manifest.
1542
1580
  return { app, allowed: Boolean(access) };
1543
1581
  } catch (error) {
1544
1582
  // Rolling deployments may discover an app before the additive access
@@ -1747,6 +1785,9 @@ async function readWorkspaceAppsFromGateway(): Promise<WorkspaceAppDiscovery | n
1747
1785
  signal: controller.signal,
1748
1786
  },
1749
1787
  );
1788
+ if (localResponse.status === 401 || localResponse.status === 403) {
1789
+ throw new WorkspaceAppsGatewayAuthorizationError(localResponse.status);
1790
+ }
1750
1791
  if (localResponse.ok) {
1751
1792
  const apps = parseWorkspaceAppsManifest(
1752
1793
  // coercion-ok: malformed gateway JSON is an unavailable registry and
@@ -2026,11 +2067,17 @@ export async function updateWorkspaceAppMetadata(input: {
2026
2067
  export async function listWorkspaceApps(
2027
2068
  options: ListWorkspaceAppsOptions = {},
2028
2069
  ): Promise<WorkspaceAppSummary[]> {
2029
- const finalize = async (apps: WorkspaceAppSummary[], reconcile = false) => {
2070
+ const finalize = async (
2071
+ apps: WorkspaceAppSummary[],
2072
+ { reconcile = false, persist = true }: FinalizeWorkspaceAppsOptions = {},
2073
+ ) => {
2030
2074
  // Reconcile from the complete manifest. Archive and audience filters only
2031
2075
  // control the response; treating hidden apps as absent deletes their rows.
2032
2076
  const annotated = await applyArchivedAndPending(apps);
2033
- const recorded = await ensureWorkspaceAppRecords(annotated, { reconcile });
2077
+ const recorded = await ensureWorkspaceAppRecords(annotated, {
2078
+ reconcile,
2079
+ persist,
2080
+ });
2034
2081
  const listed = options.includeArchived
2035
2082
  ? recorded
2036
2083
  : recorded.filter((app) => !app.archived);
@@ -2039,10 +2086,21 @@ export async function listWorkspaceApps(
2039
2086
  );
2040
2087
  return maybeIncludeAgentCards(visible, options);
2041
2088
  };
2042
- const gatewayApps = await readWorkspaceAppsFromGateway();
2089
+ let gatewayDenial: WorkspaceAppsGatewayAuthorizationError | null = null;
2090
+ let gatewayApps: WorkspaceAppDiscovery | null = null;
2091
+ try {
2092
+ gatewayApps = await readWorkspaceAppsFromGateway();
2093
+ } catch (error) {
2094
+ if (!(error instanceof WorkspaceAppsGatewayAuthorizationError)) throw error;
2095
+ // A denial answers for the gateway hop, not for what this caller may see.
2096
+ // Serve deployment manifests below, but keep the degraded read read-only
2097
+ // because an unauthenticated manifest must not mint access rows.
2098
+ gatewayDenial = error;
2099
+ }
2043
2100
  if (gatewayApps) {
2044
- return finalize(gatewayApps.apps, gatewayApps.authoritative);
2101
+ return finalize(gatewayApps.apps, { reconcile: gatewayApps.authoritative });
2045
2102
  }
2103
+ const unverified = gatewayDenial !== null;
2046
2104
 
2047
2105
  const workspaceRoot = findWorkspaceRoot();
2048
2106
  const localFilesystemApps =
@@ -2050,15 +2108,25 @@ export async function listWorkspaceApps(
2050
2108
  ? await readWorkspaceAppsFromFilesystem(workspaceRoot)
2051
2109
  : null;
2052
2110
  if (localFilesystemApps) {
2053
- return finalize(localFilesystemApps, true);
2111
+ warnWorkspaceAppsGatewayDenial(gatewayDenial, "local filesystem");
2112
+ return finalize(localFilesystemApps, {
2113
+ reconcile: !unverified,
2114
+ persist: !unverified,
2115
+ });
2054
2116
  }
2055
2117
 
2056
2118
  const manifestApps =
2057
2119
  readWorkspaceAppsFromEnv() ?? readWorkspaceAppsFromManifestFile();
2058
2120
  if (manifestApps) {
2059
- return finalize(manifestApps, true);
2121
+ warnWorkspaceAppsGatewayDenial(gatewayDenial, "deployment manifest");
2122
+ return finalize(manifestApps, {
2123
+ reconcile: !unverified,
2124
+ persist: !unverified,
2125
+ });
2060
2126
  }
2061
2127
 
2128
+ if (gatewayDenial) throw gatewayDenial;
2129
+
2062
2130
  if (!workspaceRoot) {
2063
2131
  return finalize([
2064
2132
  {