@agent-native/dispatch 0.8.20 → 0.8.24

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.
Files changed (37) hide show
  1. package/dist/components/messaging-setup-panel.d.ts.map +1 -1
  2. package/dist/components/messaging-setup-panel.js +2 -3
  3. package/dist/components/messaging-setup-panel.js.map +1 -1
  4. package/dist/routes/pages/chat.d.ts +21 -2
  5. package/dist/routes/pages/chat.d.ts.map +1 -1
  6. package/dist/routes/pages/chat.js +12 -3
  7. package/dist/routes/pages/chat.js.map +1 -1
  8. package/dist/routes/pages/overview.d.ts +21 -2
  9. package/dist/routes/pages/overview.d.ts.map +1 -1
  10. package/dist/routes/pages/overview.js +13 -4
  11. package/dist/routes/pages/overview.js.map +1 -1
  12. package/dist/server/lib/dispatch-integrations.d.ts.map +1 -1
  13. package/dist/server/lib/dispatch-integrations.js +27 -3
  14. package/dist/server/lib/dispatch-integrations.js.map +1 -1
  15. package/dist/server/lib/mcp-gateway.d.ts.map +1 -1
  16. package/dist/server/lib/mcp-gateway.js +0 -6
  17. package/dist/server/lib/mcp-gateway.js.map +1 -1
  18. package/dist/server/lib/thread-link-preview.d.ts +24 -0
  19. package/dist/server/lib/thread-link-preview.d.ts.map +1 -0
  20. package/dist/server/lib/thread-link-preview.js +176 -0
  21. package/dist/server/lib/thread-link-preview.js.map +1 -0
  22. package/dist/server/lib/vault-store.d.ts +1 -0
  23. package/dist/server/lib/vault-store.d.ts.map +1 -1
  24. package/dist/server/lib/vault-store.js +67 -20
  25. package/dist/server/lib/vault-store.js.map +1 -1
  26. package/package.json +1 -1
  27. package/src/components/messaging-setup-panel.tsx +2 -3
  28. package/src/routes/pages/chat.tsx +20 -3
  29. package/src/routes/pages/overview.tsx +21 -8
  30. package/src/server/lib/dispatch-integrations.spec.ts +69 -0
  31. package/src/server/lib/dispatch-integrations.ts +26 -3
  32. package/src/server/lib/mcp-gateway.ts +0 -6
  33. package/src/server/lib/thread-link-preview.spec.ts +129 -0
  34. package/src/server/lib/thread-link-preview.ts +187 -0
  35. package/src/server/lib/vault-store.spec.ts +25 -0
  36. package/src/server/lib/vault-store.ts +75 -20
  37. package/src/server/lib/workspace-resource-approval-lifecycle.spec.ts +1 -1
@@ -0,0 +1,129 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { ChatThread } from "@agent-native/core/server";
3
+
4
+ const getRequestContextMock = vi.hoisted(() => vi.fn());
5
+ const getThreadMock = vi.hoisted(() => vi.fn());
6
+
7
+ vi.mock("@agent-native/core/server", () => ({
8
+ getRequestContext: getRequestContextMock,
9
+ getThread: getThreadMock,
10
+ }));
11
+
12
+ import {
13
+ extractThreadPreviewImageUrl,
14
+ loadThreadLinkPreview,
15
+ } from "./thread-link-preview";
16
+
17
+ function threadDataWithResult(toolName: string, result: unknown) {
18
+ return JSON.stringify({
19
+ messages: [
20
+ {
21
+ message: {
22
+ role: "assistant",
23
+ content: [
24
+ {
25
+ type: "tool-call",
26
+ toolName,
27
+ result:
28
+ typeof result === "string" ? result : JSON.stringify(result),
29
+ },
30
+ ],
31
+ },
32
+ parentId: null,
33
+ },
34
+ ],
35
+ });
36
+ }
37
+
38
+ function previewThread(overrides: Partial<ChatThread> = {}): ChatThread {
39
+ return {
40
+ id: "thread-1",
41
+ ownerEmail: "owner@example.test",
42
+ title: "Launch image",
43
+ preview: "Generated a launch image",
44
+ threadData: threadDataWithResult("generate-image", {
45
+ previewUrl: "https://cdn.example.com/generated-social.webp",
46
+ }),
47
+ messageCount: 1,
48
+ createdAt: 1,
49
+ updatedAt: 2,
50
+ scope: null,
51
+ pinnedAt: null,
52
+ archivedAt: null,
53
+ ...overrides,
54
+ };
55
+ }
56
+
57
+ beforeEach(() => {
58
+ getRequestContextMock.mockReset();
59
+ getThreadMock.mockReset();
60
+ });
61
+
62
+ describe("thread link preview image extraction", () => {
63
+ it("uses generated image preview URLs from generate-image results", () => {
64
+ expect(
65
+ extractThreadPreviewImageUrl(
66
+ threadDataWithResult("generate-image", {
67
+ url: "https://app.example.com/assets/asset/asset-1",
68
+ previewUrl: "https://cdn.example.com/generated-social.webp",
69
+ thumbnailUrl: "https://cdn.example.com/generated-social-thumb.webp",
70
+ }),
71
+ ),
72
+ ).toBe("https://cdn.example.com/generated-social.webp");
73
+ });
74
+
75
+ it("uses the newest image from batched generation results", () => {
76
+ expect(
77
+ extractThreadPreviewImageUrl(
78
+ threadDataWithResult("generate-image-batch", {
79
+ images: [
80
+ { previewUrl: "https://cdn.example.com/first.png" },
81
+ { previewUrl: "https://cdn.example.com/latest.png" },
82
+ ],
83
+ }),
84
+ ),
85
+ ).toBe("https://cdn.example.com/latest.png");
86
+ });
87
+
88
+ it("ignores asset page URLs that are not image media", () => {
89
+ expect(
90
+ extractThreadPreviewImageUrl(
91
+ threadDataWithResult("generate-image", {
92
+ url: "https://app.example.com/assets/asset/asset-1",
93
+ }),
94
+ ),
95
+ ).toBeNull();
96
+ });
97
+ });
98
+
99
+ describe("thread link preview access", () => {
100
+ it("loads preview metadata for the owning user", async () => {
101
+ getRequestContextMock.mockReturnValue({
102
+ userEmail: "owner@example.test",
103
+ });
104
+ getThreadMock.mockResolvedValue(previewThread());
105
+
106
+ await expect(loadThreadLinkPreview(" thread-1 ")).resolves.toEqual({
107
+ title: "Launch image",
108
+ description: "Generated a launch image",
109
+ imageUrl: "https://cdn.example.com/generated-social.webp",
110
+ });
111
+ expect(getThreadMock).toHaveBeenCalledWith("thread-1");
112
+ });
113
+
114
+ it("does not read thread metadata without an authenticated request context", async () => {
115
+ getRequestContextMock.mockReturnValue(undefined);
116
+
117
+ await expect(loadThreadLinkPreview("thread-1")).resolves.toBeNull();
118
+ expect(getThreadMock).not.toHaveBeenCalled();
119
+ });
120
+
121
+ it("does not emit another user's thread metadata", async () => {
122
+ getRequestContextMock.mockReturnValue({
123
+ userEmail: "viewer@example.test",
124
+ });
125
+ getThreadMock.mockResolvedValue(previewThread());
126
+
127
+ await expect(loadThreadLinkPreview("thread-1")).resolves.toBeNull();
128
+ });
129
+ });
@@ -0,0 +1,187 @@
1
+ import type { ChatThread } from "@agent-native/core/server";
2
+ import { getRequestContext, getThread } from "@agent-native/core/server";
3
+
4
+ export interface ThreadLinkPreview {
5
+ title: string;
6
+ description: string;
7
+ imageUrl: string | null;
8
+ }
9
+
10
+ const IMAGE_URL_KEYS = new Set([
11
+ "previewUrl",
12
+ "thumbnailUrl",
13
+ "imageUrl",
14
+ "image",
15
+ "downloadUrl",
16
+ ]);
17
+
18
+ const GENERATION_TOOL_NAMES = new Set([
19
+ "generate-image",
20
+ "generate-image-batch",
21
+ "refine-image",
22
+ "rerun-generation-run",
23
+ ]);
24
+
25
+ function safeJsonParse(value: string): unknown {
26
+ try {
27
+ return JSON.parse(value);
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ function cleanUrlCandidate(value: string): string {
34
+ return value
35
+ .trim()
36
+ .replace(/[),.;\]}]+$/g, "")
37
+ .replace(/^["'(<]+/g, "");
38
+ }
39
+
40
+ function isAbsoluteHttpUrl(value: string): boolean {
41
+ try {
42
+ const url = new URL(value);
43
+ return url.protocol === "https:" || url.protocol === "http:";
44
+ } catch {
45
+ return false;
46
+ }
47
+ }
48
+
49
+ function isImageLikeUrl(value: string): boolean {
50
+ try {
51
+ const url = new URL(value);
52
+ return (
53
+ /\.(?:png|jpe?g|webp|gif|avif)(?:$|[?#])/i.test(url.pathname) ||
54
+ /\/api\/assets\/[^/]+\/content(?:$|[?#])/i.test(url.pathname)
55
+ );
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ function validPreviewImageUrl(value: unknown, key?: string): string | null {
62
+ if (typeof value !== "string") return null;
63
+ const candidate = cleanUrlCandidate(value);
64
+ if (!isAbsoluteHttpUrl(candidate)) return null;
65
+ if (key && IMAGE_URL_KEYS.has(key)) return candidate;
66
+ return isImageLikeUrl(candidate) ? candidate : null;
67
+ }
68
+
69
+ function imageUrlFromStructuredValue(value: unknown): string | null {
70
+ if (!value || typeof value !== "object") return null;
71
+ if (Array.isArray(value)) {
72
+ for (let i = value.length - 1; i >= 0; i--) {
73
+ const found = imageUrlFromStructuredValue(value[i]);
74
+ if (found) return found;
75
+ }
76
+ return null;
77
+ }
78
+
79
+ const record = value as Record<string, unknown>;
80
+ for (const key of IMAGE_URL_KEYS) {
81
+ const found = validPreviewImageUrl(record[key], key);
82
+ if (found) return found;
83
+ }
84
+ for (const [key, child] of Object.entries(record).reverse()) {
85
+ const direct = validPreviewImageUrl(child, key);
86
+ if (direct) return direct;
87
+ if (child && typeof child === "object") {
88
+ const nested = imageUrlFromStructuredValue(child);
89
+ if (nested) return nested;
90
+ }
91
+ }
92
+ return null;
93
+ }
94
+
95
+ function imageUrlFromText(value: string): string | null {
96
+ const matches = value.match(/https?:\/\/[^\s<>"']+/g);
97
+ if (!matches) return null;
98
+ for (let i = matches.length - 1; i >= 0; i--) {
99
+ const candidate = validPreviewImageUrl(matches[i]);
100
+ if (candidate) return candidate;
101
+ }
102
+ return null;
103
+ }
104
+
105
+ export function extractThreadPreviewImageUrl(
106
+ threadData: string,
107
+ ): string | null {
108
+ const parsed = safeJsonParse(threadData);
109
+ if (!parsed || typeof parsed !== "object") return null;
110
+ const messages = (parsed as { messages?: unknown }).messages;
111
+ if (!Array.isArray(messages)) return null;
112
+
113
+ for (
114
+ let messageIndex = messages.length - 1;
115
+ messageIndex >= 0;
116
+ messageIndex--
117
+ ) {
118
+ const entry = messages[messageIndex] as any;
119
+ const message = entry?.message ?? entry;
120
+ const content = message?.content;
121
+ if (!Array.isArray(content)) continue;
122
+
123
+ for (let partIndex = content.length - 1; partIndex >= 0; partIndex--) {
124
+ const part = content[partIndex] as Record<string, unknown>;
125
+ const result = typeof part.result === "string" ? part.result : "";
126
+ if (!result.trim()) continue;
127
+
128
+ const toolName = typeof part.toolName === "string" ? part.toolName : "";
129
+ const parsedResult = safeJsonParse(result);
130
+ if (parsedResult && GENERATION_TOOL_NAMES.has(toolName)) {
131
+ const structured = imageUrlFromStructuredValue(parsedResult);
132
+ if (structured) return structured;
133
+ }
134
+
135
+ const fromText = imageUrlFromText(result);
136
+ if (fromText) return fromText;
137
+ }
138
+ }
139
+ return null;
140
+ }
141
+
142
+ function previewDescription(thread: ChatThread): string {
143
+ const preview = thread.preview.trim();
144
+ if (preview) return preview.slice(0, 180);
145
+ return "Open this Agent-Native thread in Dispatch.";
146
+ }
147
+
148
+ export async function loadThreadLinkPreview(
149
+ threadId: string | null | undefined,
150
+ ): Promise<ThreadLinkPreview | null> {
151
+ const id = threadId?.trim();
152
+ if (!id) return null;
153
+ const viewerEmail = getRequestContext()?.userEmail?.trim();
154
+ if (!viewerEmail) return null;
155
+ const thread = await getThread(id).catch(() => null);
156
+ if (!thread) return null;
157
+ if (thread.ownerEmail !== viewerEmail) return null;
158
+ const title = thread.title.trim() || "Agent-Native thread";
159
+ return {
160
+ title,
161
+ description: previewDescription(thread),
162
+ imageUrl: extractThreadPreviewImageUrl(thread.threadData),
163
+ };
164
+ }
165
+
166
+ export function buildThreadLinkPreviewMeta(preview: ThreadLinkPreview | null) {
167
+ const title = preview?.title ? `${preview.title} - Dispatch` : "Dispatch";
168
+ const description =
169
+ preview?.description ||
170
+ "Open this Agent-Native thread in the Dispatch workspace.";
171
+ const image = preview?.imageUrl ?? null;
172
+ return [
173
+ { title },
174
+ { name: "description", content: description },
175
+ { property: "og:title", content: title },
176
+ { property: "og:description", content: description },
177
+ { property: "og:type", content: "website" },
178
+ ...(image ? [{ property: "og:image", content: image }] : []),
179
+ {
180
+ name: "twitter:card",
181
+ content: image ? "summary_large_image" : "summary",
182
+ },
183
+ { name: "twitter:title", content: title },
184
+ { name: "twitter:description", content: description },
185
+ ...(image ? [{ name: "twitter:image", content: image }] : []),
186
+ ];
187
+ }
@@ -24,11 +24,13 @@ vi.mock("../../db/index.js", async (importOriginal) => {
24
24
  import {
25
25
  cleanupSyncedCredentialKeysIfUnused,
26
26
  credentialStoreScopeForVaultCtx,
27
+ isTrustedEnvVarSyncAgentUrl,
27
28
  syncSecretsToCredentialStore,
28
29
  } from "./vault-store.js";
29
30
 
30
31
  afterEach(() => {
31
32
  vi.clearAllMocks();
33
+ vi.unstubAllEnvs();
32
34
  });
33
35
 
34
36
  describe("credentialStoreScopeForVaultCtx", () => {
@@ -54,6 +56,29 @@ describe("credentialStoreScopeForVaultCtx", () => {
54
56
  });
55
57
  });
56
58
 
59
+ describe("isTrustedEnvVarSyncAgentUrl", () => {
60
+ it("allows localhost development app URLs", () => {
61
+ expect(isTrustedEnvVarSyncAgentUrl("http://localhost:9201")).toBe(true);
62
+ expect(isTrustedEnvVarSyncAgentUrl("http://127.0.0.1:9201")).toBe(true);
63
+ });
64
+
65
+ it("allows same-origin workspace app URLs from deploy metadata", () => {
66
+ vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://workspace.example.test");
67
+
68
+ expect(
69
+ isTrustedEnvVarSyncAgentUrl("https://workspace.example.test/slides"),
70
+ ).toBe(true);
71
+ });
72
+
73
+ it("rejects remote custom agent origins", () => {
74
+ vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://workspace.example.test");
75
+
76
+ expect(isTrustedEnvVarSyncAgentUrl("https://attacker.example.test")).toBe(
77
+ false,
78
+ );
79
+ });
80
+ });
81
+
57
82
  describe("syncSecretsToCredentialStore", () => {
58
83
  it("writes vault secrets into app_secrets without returning values", async () => {
59
84
  const result = await syncSecretsToCredentialStore(
@@ -1,6 +1,7 @@
1
1
  import crypto from "node:crypto";
2
2
  import { and, desc, eq, isNull, or } from "drizzle-orm";
3
3
  import { discoverAgents } from "@agent-native/core/server/agent-discovery";
4
+ import { ssrfSafeFetch } from "@agent-native/core/extensions/url-safety";
4
5
  import {
5
6
  deleteAppSecret,
6
7
  listAppSecretsForScope,
@@ -95,6 +96,44 @@ function safeJson(value: unknown) {
95
96
  return JSON.stringify(value ?? null);
96
97
  }
97
98
 
99
+ function workspaceBaseOrigins(): Set<string> {
100
+ const out = new Set<string>();
101
+ for (const value of [
102
+ process.env.WORKSPACE_GATEWAY_URL,
103
+ process.env.APP_URL,
104
+ process.env.URL,
105
+ process.env.DEPLOY_URL,
106
+ process.env.BETTER_AUTH_URL,
107
+ ]) {
108
+ if (!value) continue;
109
+ try {
110
+ out.add(new URL(value).origin);
111
+ } catch {
112
+ // Ignore malformed deploy metadata.
113
+ }
114
+ }
115
+ return out;
116
+ }
117
+
118
+ export function isTrustedEnvVarSyncAgentUrl(agentUrl: string): boolean {
119
+ let parsed: URL;
120
+ try {
121
+ parsed = new URL(agentUrl);
122
+ } catch {
123
+ return false;
124
+ }
125
+ const hostname = parsed.hostname.toLowerCase();
126
+ if (
127
+ hostname === "localhost" ||
128
+ hostname === "127.0.0.1" ||
129
+ hostname === "::1" ||
130
+ hostname.endsWith(".localhost")
131
+ ) {
132
+ return true;
133
+ }
134
+ return workspaceBaseOrigins().has(parsed.origin);
135
+ }
136
+
98
137
  function scopedFilter<T extends { ownerEmail: any; orgId: any }>(table: T) {
99
138
  return ctxScope(table, requireVaultCtx());
100
139
  }
@@ -759,25 +798,37 @@ export async function syncGrantsToApp(
759
798
  // still read process.env directly. Production/shared-DB apps intentionally
760
799
  // reject env writes; the encrypted app_secrets sync above is the canonical
761
800
  // path for request-scoped credentials.
762
- try {
763
- const res = await fetch(`${agent.url}/_agent-native/env-vars`, {
764
- method: "POST",
765
- headers: { "Content-Type": "application/json" },
766
- body: JSON.stringify({ vars }),
767
- });
768
-
769
- if (res.ok) {
770
- const result = await res.json();
771
- envVarSync = { status: "synced", keys: result.saved || [] };
772
- } else {
773
- const err = await res.text().catch(() => "Unknown error");
774
- envVarSync = { status: "skipped", reason: err };
775
- }
776
- } catch (err) {
801
+ if (!isTrustedEnvVarSyncAgentUrl(agent.url)) {
777
802
  envVarSync = {
778
- status: "failed",
779
- reason: err instanceof Error ? err.message : String(err),
803
+ status: "skipped",
804
+ reason: "env-var sync is limited to localhost or workspace-owned apps",
780
805
  };
806
+ } else {
807
+ try {
808
+ const res = await ssrfSafeFetch(
809
+ `${agent.url}/_agent-native/env-vars`,
810
+ {
811
+ method: "POST",
812
+ headers: { "Content-Type": "application/json" },
813
+ body: JSON.stringify({ vars }),
814
+ signal: AbortSignal.timeout(10_000),
815
+ },
816
+ { maxRedirects: 3 },
817
+ );
818
+
819
+ if (res.ok) {
820
+ const result = await res.json();
821
+ envVarSync = { status: "synced", keys: result.saved || [] };
822
+ } else {
823
+ const err = await res.text().catch(() => "Unknown error");
824
+ envVarSync = { status: "skipped", reason: err };
825
+ }
826
+ } catch (err) {
827
+ envVarSync = {
828
+ status: "failed",
829
+ reason: err instanceof Error ? err.message : String(err),
830
+ };
831
+ }
781
832
  }
782
833
 
783
834
  const syncedKeys = credentialStoreSync.keys;
@@ -1041,9 +1092,13 @@ export async function listIntegrationsCatalog(): Promise<AppIntegrations[]> {
1041
1092
 
1042
1093
  for (const agent of agents) {
1043
1094
  try {
1044
- const res = await fetch(`${agent.url}/_agent-native/env-status`, {
1045
- signal: AbortSignal.timeout(3000),
1046
- });
1095
+ const res = await ssrfSafeFetch(
1096
+ `${agent.url}/_agent-native/env-status`,
1097
+ {
1098
+ signal: AbortSignal.timeout(3000),
1099
+ },
1100
+ { maxRedirects: 3 },
1101
+ );
1047
1102
  if (!res.ok) {
1048
1103
  results.push({
1049
1104
  appId: agent.id,
@@ -222,5 +222,5 @@ describe("workspace resource approval lifecycle", () => {
222
222
  personalOverride.layers.find((layer) => layer.scope === "personal"),
223
223
  ).toMatchObject({ exists: true, effective: true });
224
224
  });
225
- });
225
+ }, 30_000);
226
226
  });