@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/dist/actions/create-workspace-resource.d.ts +13 -13
- package/dist/actions/provider-api-register.d.ts +4 -4
- package/dist/actions/update-workspace-resource.d.ts +13 -13
- package/dist/server/lib/app-creation-store.d.ts.map +1 -1
- package/dist/server/lib/app-creation-store.js +60 -9
- package/dist/server/lib/app-creation-store.js.map +1 -1
- package/package.json +4 -4
- package/src/server/lib/app-creation-store.spec.ts +91 -2
- package/src/server/lib/app-creation-store.ts +78 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-native/dispatch",
|
|
3
|
-
"version": "0.38.
|
|
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.
|
|
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.
|
|
105
|
+
"@agent-native/core": "0.182.1-nightly-20260916205829"
|
|
106
106
|
},
|
|
107
107
|
"peerDependencies": {
|
|
108
|
-
"@agent-native/core": "
|
|
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
|
-
"
|
|
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
|
-
|
|
1268
|
-
|
|
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 (
|
|
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, {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
{
|