@agent-native/dispatch 0.6.0 → 0.7.0

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 (159) hide show
  1. package/README.md +1 -1
  2. package/dist/actions/create-pylon-ticket.d.ts +3 -0
  3. package/dist/actions/create-pylon-ticket.d.ts.map +1 -0
  4. package/dist/actions/create-pylon-ticket.js +94 -0
  5. package/dist/actions/create-pylon-ticket.js.map +1 -0
  6. package/dist/actions/create-vault-grant.js +1 -1
  7. package/dist/actions/create-vault-grant.js.map +1 -1
  8. package/dist/actions/create-vault-secret.d.ts.map +1 -1
  9. package/dist/actions/create-vault-secret.js +4 -3
  10. package/dist/actions/create-vault-secret.js.map +1 -1
  11. package/dist/actions/get-vault-access-settings.d.ts +3 -0
  12. package/dist/actions/get-vault-access-settings.d.ts.map +1 -0
  13. package/dist/actions/get-vault-access-settings.js +10 -0
  14. package/dist/actions/get-vault-access-settings.js.map +1 -0
  15. package/dist/actions/grant-vault-secrets-to-app.js +1 -1
  16. package/dist/actions/grant-vault-secrets-to-app.js.map +1 -1
  17. package/dist/actions/index.d.ts.map +1 -1
  18. package/dist/actions/index.js +8 -0
  19. package/dist/actions/index.js.map +1 -1
  20. package/dist/actions/list-integrations-catalog.js +1 -1
  21. package/dist/actions/list-integrations-catalog.js.map +1 -1
  22. package/dist/actions/list-vault-grants.js +1 -1
  23. package/dist/actions/list-vault-grants.js.map +1 -1
  24. package/dist/actions/list-workspace-apps.d.ts.map +1 -1
  25. package/dist/actions/list-workspace-apps.js +5 -1
  26. package/dist/actions/list-workspace-apps.js.map +1 -1
  27. package/dist/actions/set-vault-access-settings.d.ts +3 -0
  28. package/dist/actions/set-vault-access-settings.d.ts.map +1 -0
  29. package/dist/actions/set-vault-access-settings.js +13 -0
  30. package/dist/actions/set-vault-access-settings.js.map +1 -0
  31. package/dist/actions/start-workspace-app-creation.d.ts.map +1 -1
  32. package/dist/actions/start-workspace-app-creation.js +6 -0
  33. package/dist/actions/start-workspace-app-creation.js.map +1 -1
  34. package/dist/actions/sync-vault-to-app.js +1 -1
  35. package/dist/actions/sync-vault-to-app.js.map +1 -1
  36. package/dist/actions/update-workspace-app-metadata.d.ts +3 -0
  37. package/dist/actions/update-workspace-app-metadata.d.ts.map +1 -0
  38. package/dist/actions/update-workspace-app-metadata.js +30 -0
  39. package/dist/actions/update-workspace-app-metadata.js.map +1 -0
  40. package/dist/actions/view-screen.d.ts.map +1 -1
  41. package/dist/actions/view-screen.js +4 -2
  42. package/dist/actions/view-screen.js.map +1 -1
  43. package/dist/components/app-keys-popover.d.ts.map +1 -1
  44. package/dist/components/app-keys-popover.js +17 -5
  45. package/dist/components/app-keys-popover.js.map +1 -1
  46. package/dist/components/create-app-popover.d.ts.map +1 -1
  47. package/dist/components/create-app-popover.js +38 -14
  48. package/dist/components/create-app-popover.js.map +1 -1
  49. package/dist/components/dispatch-shell.d.ts +4 -4
  50. package/dist/components/dispatch-shell.d.ts.map +1 -1
  51. package/dist/components/dispatch-shell.js +6 -6
  52. package/dist/components/dispatch-shell.js.map +1 -1
  53. package/dist/components/layout/Layout.d.ts.map +1 -1
  54. package/dist/components/layout/Layout.js +10 -3
  55. package/dist/components/layout/Layout.js.map +1 -1
  56. package/dist/components/messaging-setup-panel.d.ts.map +1 -1
  57. package/dist/components/messaging-setup-panel.js +2 -2
  58. package/dist/components/messaging-setup-panel.js.map +1 -1
  59. package/dist/components/workspace-app-card.d.ts.map +1 -1
  60. package/dist/components/workspace-app-card.js +41 -2
  61. package/dist/components/workspace-app-card.js.map +1 -1
  62. package/dist/hooks/use-navigation-state.js +12 -5
  63. package/dist/hooks/use-navigation-state.js.map +1 -1
  64. package/dist/lib/catch-all-target.d.ts +2 -0
  65. package/dist/lib/catch-all-target.d.ts.map +1 -0
  66. package/dist/lib/catch-all-target.js +95 -0
  67. package/dist/lib/catch-all-target.js.map +1 -0
  68. package/dist/lib/workspace-apps.d.ts +9 -0
  69. package/dist/lib/workspace-apps.d.ts.map +1 -1
  70. package/dist/lib/workspace-apps.js.map +1 -1
  71. package/dist/routes/pages/$appId.d.ts +2 -24
  72. package/dist/routes/pages/$appId.d.ts.map +1 -1
  73. package/dist/routes/pages/$appId.js +42 -8
  74. package/dist/routes/pages/$appId.js.map +1 -1
  75. package/dist/routes/pages/approval.d.ts.map +1 -1
  76. package/dist/routes/pages/approval.js +2 -1
  77. package/dist/routes/pages/approval.js.map +1 -1
  78. package/dist/routes/pages/apps.$appId.d.ts.map +1 -1
  79. package/dist/routes/pages/apps.$appId.js +2 -1
  80. package/dist/routes/pages/apps.$appId.js.map +1 -1
  81. package/dist/routes/pages/integrations.d.ts.map +1 -1
  82. package/dist/routes/pages/integrations.js +20 -15
  83. package/dist/routes/pages/integrations.js.map +1 -1
  84. package/dist/routes/pages/new-app.js +1 -1
  85. package/dist/routes/pages/new-app.js.map +1 -1
  86. package/dist/routes/pages/overview.d.ts.map +1 -1
  87. package/dist/routes/pages/overview.js +14 -1
  88. package/dist/routes/pages/overview.js.map +1 -1
  89. package/dist/routes/pages/vault.d.ts.map +1 -1
  90. package/dist/routes/pages/vault.js +25 -6
  91. package/dist/routes/pages/vault.js.map +1 -1
  92. package/dist/routes/pages/workspace.d.ts.map +1 -1
  93. package/dist/routes/pages/workspace.js +5 -3
  94. package/dist/routes/pages/workspace.js.map +1 -1
  95. package/dist/server/lib/app-creation-store.d.ts +13 -0
  96. package/dist/server/lib/app-creation-store.d.ts.map +1 -1
  97. package/dist/server/lib/app-creation-store.js +295 -9
  98. package/dist/server/lib/app-creation-store.js.map +1 -1
  99. package/dist/server/lib/env-config.d.ts.map +1 -1
  100. package/dist/server/lib/env-config.js +5 -0
  101. package/dist/server/lib/env-config.js.map +1 -1
  102. package/dist/server/lib/onboarding-steps.d.ts +12 -0
  103. package/dist/server/lib/onboarding-steps.d.ts.map +1 -0
  104. package/dist/server/lib/onboarding-steps.js +47 -0
  105. package/dist/server/lib/onboarding-steps.js.map +1 -0
  106. package/dist/server/lib/vault-store.d.ts +55 -0
  107. package/dist/server/lib/vault-store.d.ts.map +1 -1
  108. package/dist/server/lib/vault-store.js +210 -41
  109. package/dist/server/lib/vault-store.js.map +1 -1
  110. package/dist/server/plugins/agent-chat.d.ts.map +1 -1
  111. package/dist/server/plugins/agent-chat.js +2 -1
  112. package/dist/server/plugins/agent-chat.js.map +1 -1
  113. package/dist/server/plugins/core-routes.d.ts.map +1 -1
  114. package/dist/server/plugins/core-routes.js +4 -0
  115. package/dist/server/plugins/core-routes.js.map +1 -1
  116. package/dist/server/plugins/integrations.js +2 -2
  117. package/dist/server/plugins/integrations.js.map +1 -1
  118. package/package.json +13 -11
  119. package/src/actions/create-pylon-ticket.ts +109 -0
  120. package/src/actions/create-vault-grant.ts +1 -1
  121. package/src/actions/create-vault-secret.ts +4 -3
  122. package/src/actions/get-vault-access-settings.ts +11 -0
  123. package/src/actions/grant-vault-secrets-to-app.ts +1 -1
  124. package/src/actions/index.ts +8 -0
  125. package/src/actions/list-integrations-catalog.ts +1 -1
  126. package/src/actions/list-vault-grants.ts +1 -1
  127. package/src/actions/list-workspace-apps.ts +5 -1
  128. package/src/actions/set-vault-access-settings.ts +16 -0
  129. package/src/actions/start-workspace-app-creation.ts +8 -0
  130. package/src/actions/sync-vault-to-app.ts +1 -1
  131. package/src/actions/update-workspace-app-metadata.ts +32 -0
  132. package/src/actions/view-screen.ts +4 -1
  133. package/src/components/app-keys-popover.tsx +38 -8
  134. package/src/components/create-app-popover.tsx +47 -14
  135. package/src/components/dispatch-shell.tsx +16 -15
  136. package/src/components/layout/Layout.tsx +11 -5
  137. package/src/components/messaging-setup-panel.tsx +54 -39
  138. package/src/components/workspace-app-card.tsx +102 -0
  139. package/src/hooks/use-navigation-state.ts +10 -4
  140. package/src/lib/catch-all-target.spec.ts +218 -0
  141. package/src/lib/catch-all-target.ts +99 -0
  142. package/src/lib/workspace-apps.ts +9 -0
  143. package/src/routes/pages/$appId.tsx +45 -7
  144. package/src/routes/pages/approval.tsx +33 -3
  145. package/src/routes/pages/apps.$appId.tsx +6 -1
  146. package/src/routes/pages/integrations.tsx +57 -18
  147. package/src/routes/pages/new-app.tsx +1 -1
  148. package/src/routes/pages/overview.tsx +69 -29
  149. package/src/routes/pages/vault.tsx +101 -21
  150. package/src/routes/pages/workspace.tsx +21 -3
  151. package/src/server/lib/app-creation-store.spec.ts +61 -2
  152. package/src/server/lib/app-creation-store.ts +386 -11
  153. package/src/server/lib/env-config.ts +5 -0
  154. package/src/server/lib/onboarding-steps.ts +49 -0
  155. package/src/server/lib/vault-store.spec.ts +69 -0
  156. package/src/server/lib/vault-store.ts +266 -49
  157. package/src/server/plugins/agent-chat.ts +2 -1
  158. package/src/server/plugins/core-routes.ts +5 -0
  159. package/src/server/plugins/integrations.ts +2 -2
@@ -449,23 +449,34 @@ export function MessagingSetupPanel() {
449
449
  </p>
450
450
  </div>
451
451
  </div>
452
- <div className="flex items-center gap-3">
453
- <a
454
- href={platform.docsUrl}
455
- className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
452
+ <div className="flex shrink-0 items-center gap-1">
453
+ <Button
454
+ asChild
455
+ variant="ghost"
456
+ size="sm"
457
+ className="h-7 px-2 text-xs text-muted-foreground"
456
458
  >
457
- Docs
458
- </a>
459
+ <a href={platform.docsUrl} target="_blank" rel="noreferrer">
460
+ Docs
461
+ <IconExternalLink className="ml-1 h-3 w-3" />
462
+ </a>
463
+ </Button>
459
464
  {platform.externalUrl ? (
460
- <a
461
- href={platform.externalUrl}
462
- target="_blank"
463
- rel="noreferrer"
464
- className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
465
+ <Button
466
+ asChild
467
+ variant="ghost"
468
+ size="sm"
469
+ className="h-7 px-2 text-xs text-muted-foreground"
465
470
  >
466
- {platform.externalLabel ?? "Open"}
467
- <IconExternalLink className="h-3.5 w-3.5" />
468
- </a>
471
+ <a
472
+ href={platform.externalUrl}
473
+ target="_blank"
474
+ rel="noreferrer"
475
+ >
476
+ {platform.externalLabel ?? "Open"}
477
+ <IconExternalLink className="ml-1 h-3 w-3" />
478
+ </a>
479
+ </Button>
469
480
  ) : null}
470
481
  </div>
471
482
  </div>
@@ -609,7 +620,7 @@ export function MessagingSetupPanel() {
609
620
  </div>
610
621
  ) : null}
611
622
 
612
- <div className="mt-5 flex flex-wrap gap-2">
623
+ <div className="mt-5 flex flex-wrap items-center justify-end gap-2 border-t border-border pt-4">
613
624
  {platform.id === "telegram" && configured ? (
614
625
  <Button
615
626
  variant="outline"
@@ -626,31 +637,35 @@ export function MessagingSetupPanel() {
626
637
  )}
627
638
  </Button>
628
639
  ) : null}
629
- <Button
630
- onClick={() => togglePlatform(platform, enabled)}
631
- disabled={
632
- togglingPlatform === platform.id || (!enabled && !canEnable)
633
- }
634
- >
635
- {togglingPlatform === platform.id ? (
636
- <>
637
- <IconLoader2 className="mr-2 h-4 w-4 animate-spin" />
638
- Saving...
639
- </>
640
- ) : enabled ? (
641
- "Disable"
642
- ) : (
643
- "Enable"
644
- )}
645
- </Button>
640
+ {!configured && !enabled ? (
641
+ <Tooltip>
642
+ <TooltipTrigger asChild>
643
+ <span tabIndex={0}>
644
+ <Button disabled>Enable</Button>
645
+ </span>
646
+ </TooltipTrigger>
647
+ <TooltipContent>
648
+ Save the required credentials first.
649
+ </TooltipContent>
650
+ </Tooltip>
651
+ ) : (
652
+ <Button
653
+ onClick={() => togglePlatform(platform, enabled)}
654
+ disabled={togglingPlatform === platform.id}
655
+ >
656
+ {togglingPlatform === platform.id ? (
657
+ <>
658
+ <IconLoader2 className="mr-2 h-4 w-4 animate-spin" />
659
+ Saving...
660
+ </>
661
+ ) : enabled ? (
662
+ "Disable"
663
+ ) : (
664
+ "Enable"
665
+ )}
666
+ </Button>
667
+ )}
646
668
  </div>
647
-
648
- {!configured ? (
649
- <p className="mt-3 text-xs text-muted-foreground">
650
- Save the required credentials before enabling {platform.label}
651
- .
652
- </p>
653
- ) : null}
654
669
  </section>
655
670
  );
656
671
  })}
@@ -1,21 +1,35 @@
1
+ import { useEffect, useState, type FormEvent } from "react";
1
2
  import { useActionMutation } from "@agent-native/core/client";
2
3
  import {
3
4
  IconArrowUpRight,
4
5
  IconClockHour4,
5
6
  IconDots,
7
+ IconEdit,
6
8
  IconEye,
7
9
  IconEyeOff,
10
+ IconWorld,
8
11
  IconTrash,
9
12
  } from "@tabler/icons-react";
10
13
  import { toast } from "sonner";
11
14
  import { AppKeysPopover } from "@/components/app-keys-popover";
12
15
  import { Badge } from "@/components/ui/badge";
16
+ import { Button } from "@/components/ui/button";
17
+ import {
18
+ Dialog,
19
+ DialogContent,
20
+ DialogFooter,
21
+ DialogHeader,
22
+ DialogTitle,
23
+ } from "@/components/ui/dialog";
13
24
  import {
14
25
  DropdownMenu,
15
26
  DropdownMenuContent,
16
27
  DropdownMenuItem,
17
28
  DropdownMenuTrigger,
18
29
  } from "@/components/ui/dropdown-menu";
30
+ import { Input } from "@/components/ui/input";
31
+ import { Label } from "@/components/ui/label";
32
+ import { Textarea } from "@/components/ui/textarea";
19
33
  import { cn } from "@/lib/utils";
20
34
  import {
21
35
  isPendingBuilderHref,
@@ -34,6 +48,18 @@ export function WorkspaceAppCard({
34
48
  const openInNewTab = isPendingBuilderHref(app);
35
49
  const isPending = app.status === "pending";
36
50
  const isArchived = !!app.archived;
51
+ const audience = app.audience ?? "internal";
52
+ const [editOpen, setEditOpen] = useState(false);
53
+ const [draftName, setDraftName] = useState(app.name);
54
+ const [draftDescription, setDraftDescription] = useState(
55
+ app.description || "",
56
+ );
57
+
58
+ useEffect(() => {
59
+ if (editOpen) return;
60
+ setDraftName(app.name);
61
+ setDraftDescription(app.description || "");
62
+ }, [app.description, app.name, editOpen]);
37
63
 
38
64
  const archive = useActionMutation("archive-workspace-app", {
39
65
  onError: (err) =>
@@ -47,6 +73,14 @@ export function WorkspaceAppCard({
47
73
  onError: (err) =>
48
74
  toast.error(`Could not remove ${app.name}: ${stringifyError(err)}`),
49
75
  });
76
+ const updateMetadata = useActionMutation("update-workspace-app-metadata", {
77
+ onSuccess: () => {
78
+ toast.success(`Updated ${draftName.trim() || app.name}`);
79
+ setEditOpen(false);
80
+ },
81
+ onError: (err) =>
82
+ toast.error(`Could not update ${app.name}: ${stringifyError(err)}`),
83
+ });
50
84
 
51
85
  const handleArchive = () => {
52
86
  archive.mutate({ appId: app.id });
@@ -60,6 +94,19 @@ export function WorkspaceAppCard({
60
94
  removePending.mutate({ appId: app.id });
61
95
  toast.success(`Removed pending ${app.name}`);
62
96
  };
97
+ const handleMetadataSubmit = (event: FormEvent<HTMLFormElement>) => {
98
+ event.preventDefault();
99
+ const name = draftName.trim();
100
+ if (!name) {
101
+ toast.error("App name is required.");
102
+ return;
103
+ }
104
+ updateMetadata.mutate({
105
+ appId: app.id,
106
+ name,
107
+ description: draftDescription.trim(),
108
+ });
109
+ };
63
110
 
64
111
  return (
65
112
  <div
@@ -101,6 +148,12 @@ export function WorkspaceAppCard({
101
148
  Hidden
102
149
  </Badge>
103
150
  ) : null}
151
+ {audience === "public" ? (
152
+ <Badge variant="outline" className="shrink-0 gap-1">
153
+ <IconWorld size={12} />
154
+ Public
155
+ </Badge>
156
+ ) : null}
104
157
  </div>
105
158
  <p className="mt-1 truncate font-mono text-xs text-muted-foreground">
106
159
  {app.path}
@@ -135,6 +188,15 @@ export function WorkspaceAppCard({
135
188
  </button>
136
189
  </DropdownMenuTrigger>
137
190
  <DropdownMenuContent align="end" className="w-44">
191
+ <DropdownMenuItem
192
+ onSelect={(event) => {
193
+ event.preventDefault();
194
+ setEditOpen(true);
195
+ }}
196
+ >
197
+ <IconEdit size={14} className="mr-2" />
198
+ Edit details
199
+ </DropdownMenuItem>
138
200
  {isPending ? (
139
201
  <DropdownMenuItem
140
202
  onSelect={handleRemovePending}
@@ -165,6 +227,46 @@ export function WorkspaceAppCard({
165
227
  ) : null}
166
228
  </div>
167
229
  </div>
230
+ <Dialog open={editOpen} onOpenChange={setEditOpen}>
231
+ <DialogContent>
232
+ <DialogHeader>
233
+ <DialogTitle>Edit app details</DialogTitle>
234
+ </DialogHeader>
235
+ <form className="space-y-4" onSubmit={handleMetadataSubmit}>
236
+ <div className="space-y-2">
237
+ <Label htmlFor={`app-name-${app.id}`}>Name</Label>
238
+ <Input
239
+ id={`app-name-${app.id}`}
240
+ value={draftName}
241
+ maxLength={120}
242
+ onChange={(event) => setDraftName(event.target.value)}
243
+ />
244
+ </div>
245
+ <div className="space-y-2">
246
+ <Label htmlFor={`app-description-${app.id}`}>Description</Label>
247
+ <Textarea
248
+ id={`app-description-${app.id}`}
249
+ value={draftDescription}
250
+ maxLength={500}
251
+ rows={4}
252
+ onChange={(event) => setDraftDescription(event.target.value)}
253
+ />
254
+ </div>
255
+ <DialogFooter>
256
+ <Button
257
+ type="button"
258
+ variant="outline"
259
+ onClick={() => setEditOpen(false)}
260
+ >
261
+ Cancel
262
+ </Button>
263
+ <Button type="submit" disabled={updateMetadata.isPending}>
264
+ {updateMetadata.isPending ? "Saving..." : "Save"}
265
+ </Button>
266
+ </DialogFooter>
267
+ </form>
268
+ </DialogContent>
269
+ </Dialog>
168
270
  </div>
169
271
  );
170
272
  }
@@ -77,11 +77,17 @@ export function useNavigationState(extensions?: DispatchExtensionConfig) {
77
77
  function routerPath(path: string): string {
78
78
  const basePath = appBasePath();
79
79
  if (!basePath) return path;
80
- if (path === basePath) return "/";
81
- if (path.startsWith(`${basePath}/`)) {
82
- return path.slice(basePath.length) || "/";
80
+ let result = path;
81
+ // Iteratively strip basename. A path that arrives doubly-prefixed
82
+ // (e.g. "/dispatch/dispatch/overview", possibly from a stale link or a
83
+ // prior bug) would otherwise get partially stripped here and then
84
+ // re-prefixed by react-router's basename, restoring the bad URL.
85
+ for (let i = 0; i < 4; i += 1) {
86
+ if (result === basePath) return "/";
87
+ if (!result.startsWith(`${basePath}/`)) break;
88
+ result = result.slice(basePath.length) || "/";
83
89
  }
84
- return path;
90
+ return result;
85
91
  }
86
92
 
87
93
  function extensionItemMatchesPath(
@@ -0,0 +1,218 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const loadWorkspaceAppsManifestMock = vi.hoisted(() => vi.fn());
4
+ const getBuiltinAgentsMock = vi.hoisted(() => vi.fn());
5
+
6
+ vi.mock("@agent-native/core/server/agent-discovery", () => ({
7
+ loadWorkspaceAppsManifest: loadWorkspaceAppsManifestMock,
8
+ getBuiltinAgents: getBuiltinAgentsMock,
9
+ }));
10
+
11
+ import { resolveCatchAllTarget } from "./catch-all-target.js";
12
+
13
+ beforeEach(() => {
14
+ vi.clearAllMocks();
15
+ });
16
+
17
+ afterEach(() => {
18
+ vi.clearAllMocks();
19
+ });
20
+
21
+ describe("resolveCatchAllTarget", () => {
22
+ it("prefers the workspace manifest entry when one matches", () => {
23
+ loadWorkspaceAppsManifestMock.mockReturnValue([
24
+ { id: "todo", name: "Todo", path: "/todo" },
25
+ ]);
26
+ getBuiltinAgentsMock.mockReturnValue([
27
+ {
28
+ id: "todo",
29
+ name: "Todo",
30
+ description: "",
31
+ url: "https://todo.example.com",
32
+ color: "#000",
33
+ },
34
+ ]);
35
+
36
+ expect(resolveCatchAllTarget("todo")).toBe("/todo");
37
+ });
38
+
39
+ it("falls back to the built-in template URL when no workspace manifest exists", () => {
40
+ loadWorkspaceAppsManifestMock.mockReturnValue(null);
41
+ getBuiltinAgentsMock.mockReturnValue([
42
+ {
43
+ id: "forms",
44
+ name: "Forms",
45
+ description: "",
46
+ url: "http://localhost:8084",
47
+ color: "#06B6D4",
48
+ },
49
+ ]);
50
+
51
+ expect(resolveCatchAllTarget("forms")).toBe("http://localhost:8084");
52
+ });
53
+
54
+ it("falls back to the built-in template URL when the workspace manifest does not include the app", () => {
55
+ loadWorkspaceAppsManifestMock.mockReturnValue([
56
+ { id: "dispatch", name: "Dispatch", path: "/dispatch" },
57
+ ]);
58
+ getBuiltinAgentsMock.mockReturnValue([
59
+ {
60
+ id: "forms",
61
+ name: "Forms",
62
+ description: "",
63
+ url: "http://localhost:8084",
64
+ color: "#06B6D4",
65
+ },
66
+ ]);
67
+
68
+ expect(resolveCatchAllTarget("forms")).toBe("http://localhost:8084");
69
+ });
70
+
71
+ it("normalizes a manifest entry without a leading slash", () => {
72
+ loadWorkspaceAppsManifestMock.mockReturnValue([
73
+ { id: "todo", name: "Todo", path: "todo" },
74
+ ]);
75
+ getBuiltinAgentsMock.mockReturnValue([]);
76
+
77
+ expect(resolveCatchAllTarget("todo")).toBe("/todo");
78
+ });
79
+
80
+ it("uses app.path when id !== path (not /${appId})", () => {
81
+ // Before the fix, an entry whose mounted path differs from its id —
82
+ // e.g. id: "forms", path: "my-forms" without a leading slash — was
83
+ // silently rewritten to `/forms` (the appId) and routed to the wrong
84
+ // app. The normalizer now keeps the manifest path and only prepends
85
+ // the missing slash.
86
+ loadWorkspaceAppsManifestMock.mockReturnValue([
87
+ { id: "forms", name: "Forms", path: "my-forms" },
88
+ ]);
89
+ getBuiltinAgentsMock.mockReturnValue([]);
90
+
91
+ expect(resolveCatchAllTarget("forms")).toBe("/my-forms");
92
+ });
93
+
94
+ it("prefers app.url when the manifest entry has an externally-hosted URL", () => {
95
+ // Workspaces can point at remote deploys. The catch-all should bounce
96
+ // to the absolute URL instead of mounting a local path that doesn't
97
+ // exist inside the gateway.
98
+ loadWorkspaceAppsManifestMock.mockReturnValue([
99
+ {
100
+ id: "forms",
101
+ name: "Forms",
102
+ path: "/forms",
103
+ url: "https://forms.example.com",
104
+ },
105
+ ]);
106
+ getBuiltinAgentsMock.mockReturnValue([]);
107
+
108
+ expect(resolveCatchAllTarget("forms")).toBe("https://forms.example.com");
109
+ });
110
+
111
+ it("ignores app.url that isn't an absolute http(s) URL and falls back to path", () => {
112
+ // Bare hostname — `new URL("forms.example.com")` throws, so the value
113
+ // is rejected and we fall through to the (validated) path. Without
114
+ // this, the catch-all would `throw redirect("forms.example.com")`
115
+ // and the browser would treat the value as a relative path inside the
116
+ // gateway, producing a broken redirect.
117
+ loadWorkspaceAppsManifestMock.mockReturnValue([
118
+ {
119
+ id: "forms",
120
+ name: "Forms",
121
+ path: "/forms",
122
+ url: "forms.example.com",
123
+ },
124
+ ]);
125
+ getBuiltinAgentsMock.mockReturnValue([]);
126
+
127
+ expect(resolveCatchAllTarget("forms")).toBe("/forms");
128
+ });
129
+
130
+ it("rejects non-http(s) URL schemes (e.g. javascript:) and falls back to path", () => {
131
+ // Defense in depth — a hostile manifest entry can't produce a
132
+ // `javascript:` redirect target. Validation enforces http(s) only.
133
+ loadWorkspaceAppsManifestMock.mockReturnValue([
134
+ {
135
+ id: "forms",
136
+ name: "Forms",
137
+ path: "/forms",
138
+ url: "javascript:alert(1)",
139
+ },
140
+ ]);
141
+ getBuiltinAgentsMock.mockReturnValue([]);
142
+
143
+ expect(resolveCatchAllTarget("forms")).toBe("/forms");
144
+ });
145
+
146
+ it("strips a trailing slash from app.url", () => {
147
+ loadWorkspaceAppsManifestMock.mockReturnValue([
148
+ {
149
+ id: "forms",
150
+ name: "Forms",
151
+ path: "/forms",
152
+ url: "https://forms.example.com/",
153
+ },
154
+ ]);
155
+ getBuiltinAgentsMock.mockReturnValue([]);
156
+
157
+ expect(resolveCatchAllTarget("forms")).toBe("https://forms.example.com");
158
+ });
159
+
160
+ it("ignores an empty/whitespace app.url and falls back to path", () => {
161
+ loadWorkspaceAppsManifestMock.mockReturnValue([
162
+ {
163
+ id: "forms",
164
+ name: "Forms",
165
+ path: "/forms",
166
+ url: " ",
167
+ },
168
+ ]);
169
+ getBuiltinAgentsMock.mockReturnValue([]);
170
+
171
+ expect(resolveCatchAllTarget("forms")).toBe("/forms");
172
+ });
173
+
174
+ it("collapses leading slashes/backslashes in app.path so `/\\evil.example` can't redirect off-origin", () => {
175
+ // Browsers normalize backslashes to forward slashes during URL
176
+ // parsing, so `throw redirect("/\\evil.example")` would resolve to
177
+ // `https://evil.example`. The regex covers both slash types.
178
+ loadWorkspaceAppsManifestMock.mockReturnValue([
179
+ { id: "forms", name: "Forms", path: "/\\evil.example" },
180
+ ]);
181
+ getBuiltinAgentsMock.mockReturnValue([]);
182
+
183
+ expect(resolveCatchAllTarget("forms")).toBe("/evil.example");
184
+ });
185
+
186
+ it("collapses leading double slashes in app.path so `//evil.example` can't redirect off-origin", () => {
187
+ // The manifest parser only checks `startsWith("/")`, so a path of
188
+ // `//evil.example` slips through. Browsers treat that as a network-
189
+ // path reference and `throw redirect("//evil.example")` would redirect
190
+ // to `https://evil.example` — the same phishing vector the `app.url`
191
+ // validator closes. Collapse the leading slashes so the redirect
192
+ // stays on the gateway.
193
+ loadWorkspaceAppsManifestMock.mockReturnValue([
194
+ { id: "forms", name: "Forms", path: "//evil.example" },
195
+ ]);
196
+ getBuiltinAgentsMock.mockReturnValue([]);
197
+
198
+ expect(resolveCatchAllTarget("forms")).toBe("/evil.example");
199
+ });
200
+
201
+ it("falls back to /${appId} when the manifest entry has neither path nor url", () => {
202
+ loadWorkspaceAppsManifestMock.mockReturnValue([
203
+ { id: "forms", name: "Forms", path: "" },
204
+ ]);
205
+ getBuiltinAgentsMock.mockReturnValue([]);
206
+
207
+ expect(resolveCatchAllTarget("forms")).toBe("/forms");
208
+ });
209
+
210
+ it("returns null when nothing matches", () => {
211
+ loadWorkspaceAppsManifestMock.mockReturnValue([
212
+ { id: "dispatch", name: "Dispatch", path: "/dispatch" },
213
+ ]);
214
+ getBuiltinAgentsMock.mockReturnValue([]);
215
+
216
+ expect(resolveCatchAllTarget("unknown-app")).toBeNull();
217
+ });
218
+ });
@@ -0,0 +1,99 @@
1
+ import {
2
+ getBuiltinAgents,
3
+ loadWorkspaceAppsManifest,
4
+ } from "@agent-native/core/server/agent-discovery";
5
+
6
+ /**
7
+ * Resolve where `/dispatch/<appId>` should bounce to when it doesn't match
8
+ * an explicit dispatch route. Used by the `$appId` catch-all route loader.
9
+ *
10
+ * Resolution order:
11
+ *
12
+ * 1. Workspace apps manifest (env, .agent-native/workspace-apps.json, or a
13
+ * filesystem scan of `apps/`).
14
+ * - `app.url` (absolute URL — externally hosted workspace app) wins if
15
+ * present.
16
+ * - Otherwise the `app.path` mounted under the workspace gateway is
17
+ * used. Path is normalized to a leading slash if missing
18
+ * (e.g. manifest entry `path: "my-forms"` → `/my-forms`), so an app
19
+ * whose mounted path differs from its id ends up at the right place
20
+ * instead of being silently rewritten to `/${appId}`.
21
+ * - Bare entry with no path / url falls back to `/${appId}`.
22
+ * 2. First-party template registry. When no workspace manifest matches
23
+ * (framework dev with each template on its own port, hosted dispatch
24
+ * with no sibling apps), return the matching template's deploy URL —
25
+ * dev URL in development (e.g. http://localhost:8084 for forms), prod
26
+ * URL in production (e.g. https://forms.agent-native.com).
27
+ *
28
+ * Returns `null` if neither lookup matches, letting the route render its
29
+ * "Page not found" pane.
30
+ */
31
+ /**
32
+ * Validate `app.url` is an absolute http(s) URL before we trust it as a
33
+ * redirect target. A bare hostname (`"forms.example.com"`) or a
34
+ * `javascript:` scheme would otherwise get returned verbatim from
35
+ * `resolveCatchAllTarget` and produce a broken redirect (or a phishing
36
+ * vector). Mirrors `normalizeWorkspaceAppUrl` in
37
+ * `packages/core/src/deploy/workspace-deploy.ts` — but inlined to avoid
38
+ * pulling the deploy CLI module into a runtime path.
39
+ */
40
+ function validatedAbsoluteUrl(value: unknown): string | undefined {
41
+ if (typeof value !== "string" || !value.trim()) return undefined;
42
+ try {
43
+ const parsed = new URL(value.trim());
44
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
45
+ return undefined;
46
+ }
47
+ return parsed.toString().replace(/\/$/, "");
48
+ } catch {
49
+ return undefined;
50
+ }
51
+ }
52
+
53
+ export function resolveCatchAllTarget(appId: string): string | null {
54
+ const apps = loadWorkspaceAppsManifest();
55
+ if (apps) {
56
+ const app = apps.find((entry) => entry?.id === appId);
57
+ if (app) {
58
+ // Explicit externally-hosted URL wins. Workspaces that point at a
59
+ // remote deploy (e.g. a sibling app on Netlify) set `url` and we
60
+ // should bounce the user there rather than mounting a local path
61
+ // that doesn't exist inside the gateway. Validate the URL first —
62
+ // a bare hostname or non-http(s) scheme would produce a broken
63
+ // redirect (and a `javascript:` value would be a phishing vector).
64
+ const url = validatedAbsoluteUrl(app.url);
65
+ if (url) {
66
+ return url;
67
+ }
68
+ // Fall back to the mounted path. Normalize to leading slash so an
69
+ // entry whose path differs from its id (e.g. `id: "forms"`,
70
+ // `path: "my-forms"`) still lands on the correct gateway mount —
71
+ // not on `/${appId}`, which would silently route to the wrong app.
72
+ //
73
+ // Reject scheme-relative paths. Three variants reach this point —
74
+ // all of them get collapsed to a single leading slash so the
75
+ // redirect stays on the gateway:
76
+ //
77
+ // `//evil.example` — network-path reference, browser treats as
78
+ // absolute (https://evil.example).
79
+ // `/\evil.example` — browsers normalize backslashes to forward
80
+ // slashes during URL parsing, same result.
81
+ // `\/evil.example` — same idea, leading-backslash variant.
82
+ //
83
+ // The manifest parser only checks `startsWith("/")` for the first
84
+ // case, and even that allows `//evil…`. Defend in depth here by
85
+ // collapsing any run of leading slashes-or-backslashes to one
86
+ // forward slash. Same phishing vector that `validatedAbsoluteUrl`
87
+ // closes for `app.url`.
88
+ if (typeof app.path === "string" && app.path.trim()) {
89
+ const normalized = app.path.trim().replace(/^[/\\]+/, "/");
90
+ return normalized.startsWith("/") ? normalized : `/${normalized}`;
91
+ }
92
+ return `/${appId}`;
93
+ }
94
+ }
95
+ const builtin = getBuiltinAgents("dispatch").find(
96
+ (agent) => agent.id === appId,
97
+ );
98
+ return builtin?.url ?? null;
99
+ }
@@ -5,10 +5,19 @@ export interface WorkspaceAppSummary {
5
5
  path: string;
6
6
  url?: string | null;
7
7
  isDispatch?: boolean;
8
+ audience?: "internal" | "public";
9
+ publicPaths?: string[];
10
+ protectedPaths?: string[];
8
11
  status?: "ready" | "pending";
9
12
  statusLabel?: string;
10
13
  builderUrl?: string | null;
11
14
  branchName?: string | null;
15
+ createdAt?: string | null;
16
+ agentCardUrl?: string | null;
17
+ agentCardReachable?: boolean;
18
+ a2aEndpointUrl?: string | null;
19
+ agentName?: string | null;
20
+ agentSkillsCount?: number | null;
12
21
  archived?: boolean;
13
22
  }
14
23