@agent-native/dispatch 0.27.19 → 0.28.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.
- package/dist/actions/update-workspace-app-metadata.js +1 -1
- package/dist/actions/update-workspace-app-metadata.js.map +1 -1
- package/dist/actions/view-screen.js +50 -1
- package/dist/actions/view-screen.js.map +1 -1
- package/dist/components/layout/Layout.d.ts.map +1 -1
- package/dist/components/layout/Layout.js +3 -8
- package/dist/components/layout/Layout.js.map +1 -1
- package/dist/components/workspace-app-host.d.ts +17 -0
- package/dist/components/workspace-app-host.d.ts.map +1 -1
- package/dist/components/workspace-app-host.js +90 -8
- package/dist/components/workspace-app-host.js.map +1 -1
- package/dist/hooks/use-navigation-state.d.ts +2 -0
- package/dist/hooks/use-navigation-state.d.ts.map +1 -1
- package/dist/hooks/use-navigation-state.js +18 -0
- package/dist/hooks/use-navigation-state.js.map +1 -1
- package/dist/server/lib/app-creation-store.d.ts.map +1 -1
- package/dist/server/lib/app-creation-store.js +0 -1
- package/dist/server/lib/app-creation-store.js.map +1 -1
- package/dist/server/lib/workspace-app-chat-proxy.d.ts +52 -0
- package/dist/server/lib/workspace-app-chat-proxy.d.ts.map +1 -0
- package/dist/server/lib/workspace-app-chat-proxy.js +306 -0
- package/dist/server/lib/workspace-app-chat-proxy.js.map +1 -0
- package/dist/server/plugins/core-routes.d.ts +3 -2
- package/dist/server/plugins/core-routes.d.ts.map +1 -1
- package/dist/server/plugins/core-routes.js +12 -4
- package/dist/server/plugins/core-routes.js.map +1 -1
- package/dist/shared/chat-first-pane.d.ts +8 -0
- package/dist/shared/chat-first-pane.d.ts.map +1 -0
- package/dist/shared/chat-first-pane.js +8 -0
- package/dist/shared/chat-first-pane.js.map +1 -0
- package/dist/shared/workspace-app-chat.d.ts +23 -0
- package/dist/shared/workspace-app-chat.d.ts.map +1 -0
- package/dist/shared/workspace-app-chat.js +45 -0
- package/dist/shared/workspace-app-chat.js.map +1 -0
- package/package.json +2 -2
- package/src/actions/update-workspace-app-metadata.ts +1 -1
- package/src/actions/view-screen.spec.ts +93 -1
- package/src/actions/view-screen.ts +74 -1
- package/src/components/layout/Layout.app-chat.spec.ts +22 -13
- package/src/components/layout/Layout.tsx +6 -19
- package/src/components/workspace-app-chat-rail.spec.tsx +182 -0
- package/src/components/workspace-app-host.tsx +172 -21
- package/src/hooks/use-navigation-state.spec.ts +33 -0
- package/src/hooks/use-navigation-state.ts +28 -1
- package/src/server/lib/app-creation-store.spec.ts +32 -20
- package/src/server/lib/app-creation-store.ts +0 -1
- package/src/server/lib/workspace-app-chat-proxy.spec.ts +322 -0
- package/src/server/lib/workspace-app-chat-proxy.ts +389 -0
- package/src/server/plugins/core-routes.ts +21 -4
- package/src/shared/chat-first-pane.ts +7 -0
- package/src/shared/workspace-app-chat.ts +45 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const mocks = vi.hoisted(() => ({
|
|
4
|
+
getSession: vi.fn(),
|
|
5
|
+
getOrgContext: vi.fn(),
|
|
6
|
+
createWorkspaceSsoEmbedSession: vi.fn(),
|
|
7
|
+
createGrantedDispatchMcpEmbedSession: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
vi.mock("@agent-native/core/server", async (importOriginal) => {
|
|
11
|
+
const actual =
|
|
12
|
+
await importOriginal<typeof import("@agent-native/core/server")>();
|
|
13
|
+
return { ...actual, getSession: mocks.getSession };
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
vi.mock("@agent-native/core/org", () => ({
|
|
17
|
+
getOrgContext: mocks.getOrgContext,
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
vi.mock("./mcp-gateway.js", () => ({
|
|
21
|
+
createWorkspaceSsoEmbedSession: mocks.createWorkspaceSsoEmbedSession,
|
|
22
|
+
createGrantedDispatchMcpEmbedSession:
|
|
23
|
+
mocks.createGrantedDispatchMcpEmbedSession,
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
clearWorkspaceAppChatSessions,
|
|
28
|
+
createWorkspaceAppChatProxyHandler,
|
|
29
|
+
} from "./workspace-app-chat-proxy.js";
|
|
30
|
+
|
|
31
|
+
const APP_ORIGIN = "https://analytics.agent-native.com";
|
|
32
|
+
const START_URL = `${APP_ORIGIN}/_agent-native/embed/start?ticket=ticket-1`;
|
|
33
|
+
|
|
34
|
+
function embedToken(expiresInSeconds = 3600): string {
|
|
35
|
+
const payload = Buffer.from(
|
|
36
|
+
JSON.stringify({
|
|
37
|
+
kind: "agent-native-embed-session",
|
|
38
|
+
ownerEmail: "owner@example.com",
|
|
39
|
+
targetPath: "/",
|
|
40
|
+
exp: Math.floor(Date.now() / 1000) + expiresInSeconds,
|
|
41
|
+
}),
|
|
42
|
+
).toString("base64url");
|
|
43
|
+
return `${payload}.signature`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function proxyEvent(path: string, init: RequestInit = {}) {
|
|
47
|
+
const url = new URL(`https://dispatch.agent-native.com${path}`);
|
|
48
|
+
return { url, req: new Request(url, init) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Upstream stub: the embed-start redirect plus whatever the chat route returns. */
|
|
52
|
+
function stubFetch(
|
|
53
|
+
chatResponse: (
|
|
54
|
+
url: string,
|
|
55
|
+
init: RequestInit,
|
|
56
|
+
) => Response | Promise<Response>,
|
|
57
|
+
options: { token?: string; startStatus?: number } = {},
|
|
58
|
+
) {
|
|
59
|
+
return vi.fn(async (input: string | URL, init: RequestInit = {}) => {
|
|
60
|
+
const url = String(input);
|
|
61
|
+
if (url.includes("/_agent-native/embed/start")) {
|
|
62
|
+
if (options.startStatus && options.startStatus >= 400) {
|
|
63
|
+
return new Response("expired", { status: options.startStatus });
|
|
64
|
+
}
|
|
65
|
+
const token = options.token ?? embedToken();
|
|
66
|
+
return new Response("", {
|
|
67
|
+
status: 302,
|
|
68
|
+
headers: {
|
|
69
|
+
location: `/?embedded=1&__an_embed_token=${encodeURIComponent(token)}`,
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return chatResponse(url, init);
|
|
74
|
+
}) as unknown as typeof fetch;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe("workspace app chat proxy", () => {
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
clearWorkspaceAppChatSessions();
|
|
80
|
+
mocks.getSession.mockResolvedValue({ email: "owner@example.com" });
|
|
81
|
+
mocks.getOrgContext.mockResolvedValue({ orgId: "org-1" });
|
|
82
|
+
mocks.createWorkspaceSsoEmbedSession.mockResolvedValue({
|
|
83
|
+
startUrl: START_URL,
|
|
84
|
+
app: "analytics",
|
|
85
|
+
});
|
|
86
|
+
mocks.createGrantedDispatchMcpEmbedSession.mockReset();
|
|
87
|
+
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
afterEach(() => {
|
|
91
|
+
vi.restoreAllMocks();
|
|
92
|
+
vi.clearAllMocks();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("forwards a chat turn to the target app's own agent-chat route", async () => {
|
|
96
|
+
const fetchImpl = stubFetch(() => new Response("ok", { status: 200 }));
|
|
97
|
+
const handler = createWorkspaceAppChatProxyHandler({ fetchImpl });
|
|
98
|
+
|
|
99
|
+
const response = await handler(
|
|
100
|
+
proxyEvent("/_agent-native/workspace-app-chat/analytics", {
|
|
101
|
+
method: "POST",
|
|
102
|
+
body: JSON.stringify({ messages: [] }),
|
|
103
|
+
headers: { "content-type": "application/json" },
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
expect(response.status).toBe(200);
|
|
108
|
+
const chatCall = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock
|
|
109
|
+
.calls[1];
|
|
110
|
+
expect(chatCall[0]).toBe(`${APP_ORIGIN}/_agent-native/agent-chat`);
|
|
111
|
+
const headers = chatCall[1].headers as Headers;
|
|
112
|
+
expect(headers.get("authorization")).toMatch(/^Bearer /);
|
|
113
|
+
expect(headers.get("x-agent-native-embed-target")).toBe("/");
|
|
114
|
+
expect(headers.get("x-agent-native-csrf")).toBe("1");
|
|
115
|
+
expect(headers.get("cookie")).toBeNull();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("forwards agent-chat sub-paths and query strings unchanged", async () => {
|
|
119
|
+
const fetchImpl = stubFetch(() => new Response("[]", { status: 200 }));
|
|
120
|
+
const handler = createWorkspaceAppChatProxyHandler({ fetchImpl });
|
|
121
|
+
|
|
122
|
+
await handler(
|
|
123
|
+
proxyEvent(
|
|
124
|
+
"/_agent-native/workspace-app-chat/analytics/runs/active?threadId=t-1",
|
|
125
|
+
),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const chatCall = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock
|
|
129
|
+
.calls[1];
|
|
130
|
+
expect(chatCall[0]).toBe(
|
|
131
|
+
`${APP_ORIGIN}/_agent-native/agent-chat/runs/active?threadId=t-1`,
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("streams the app's response through instead of buffering it", async () => {
|
|
136
|
+
let emitSecondChunk: (() => void) | undefined;
|
|
137
|
+
const secondChunk = new Promise<void>((resolve) => {
|
|
138
|
+
emitSecondChunk = resolve;
|
|
139
|
+
});
|
|
140
|
+
const encoder = new TextEncoder();
|
|
141
|
+
const upstreamBody = new ReadableStream<Uint8Array>({
|
|
142
|
+
async start(controller) {
|
|
143
|
+
controller.enqueue(encoder.encode("first"));
|
|
144
|
+
await secondChunk;
|
|
145
|
+
controller.enqueue(encoder.encode("second"));
|
|
146
|
+
controller.close();
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
const fetchImpl = stubFetch(
|
|
150
|
+
() =>
|
|
151
|
+
new Response(upstreamBody, {
|
|
152
|
+
status: 200,
|
|
153
|
+
headers: { "content-type": "text/event-stream" },
|
|
154
|
+
}),
|
|
155
|
+
);
|
|
156
|
+
const handler = createWorkspaceAppChatProxyHandler({ fetchImpl });
|
|
157
|
+
|
|
158
|
+
const response = await handler(
|
|
159
|
+
proxyEvent("/_agent-native/workspace-app-chat/analytics/runs/r-1/events"),
|
|
160
|
+
);
|
|
161
|
+
expect(response.headers.get("content-type")).toBe("text/event-stream");
|
|
162
|
+
|
|
163
|
+
const reader = response.body!.getReader();
|
|
164
|
+
const first = await reader.read();
|
|
165
|
+
// The first chunk arrives while the upstream stream is still open — a
|
|
166
|
+
// buffering proxy could not have produced it yet.
|
|
167
|
+
expect(new TextDecoder().decode(first.value)).toBe("first");
|
|
168
|
+
|
|
169
|
+
emitSecondChunk!();
|
|
170
|
+
const second = await reader.read();
|
|
171
|
+
expect(new TextDecoder().decode(second.value)).toBe("second");
|
|
172
|
+
expect((await reader.read()).done).toBe(true);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("falls back to the Dispatch app-grant mint before giving up", async () => {
|
|
176
|
+
mocks.createWorkspaceSsoEmbedSession.mockRejectedValue(
|
|
177
|
+
new Error("Dispatch workspace sign-in is not enabled."),
|
|
178
|
+
);
|
|
179
|
+
mocks.createGrantedDispatchMcpEmbedSession.mockResolvedValue({
|
|
180
|
+
startUrl: START_URL,
|
|
181
|
+
app: "analytics",
|
|
182
|
+
});
|
|
183
|
+
const fetchImpl = stubFetch(() => new Response("ok", { status: 200 }));
|
|
184
|
+
const handler = createWorkspaceAppChatProxyHandler({ fetchImpl });
|
|
185
|
+
|
|
186
|
+
const response = await handler(
|
|
187
|
+
proxyEvent("/_agent-native/workspace-app-chat/analytics", {
|
|
188
|
+
method: "POST",
|
|
189
|
+
}),
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
expect(response.status).toBe(200);
|
|
193
|
+
expect(mocks.createGrantedDispatchMcpEmbedSession).toHaveBeenCalled();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("reports an error and never answers from Dispatch's own agent when no app session can be minted", async () => {
|
|
197
|
+
mocks.createWorkspaceSsoEmbedSession.mockRejectedValue(
|
|
198
|
+
new Error("Dispatch workspace sign-in is not enabled."),
|
|
199
|
+
);
|
|
200
|
+
mocks.createGrantedDispatchMcpEmbedSession.mockRejectedValue(
|
|
201
|
+
new Error('Unknown app "analytics".'),
|
|
202
|
+
);
|
|
203
|
+
const fetchImpl = stubFetch(() => new Response("ok", { status: 200 }));
|
|
204
|
+
const handler = createWorkspaceAppChatProxyHandler({ fetchImpl });
|
|
205
|
+
|
|
206
|
+
const response = await handler(
|
|
207
|
+
proxyEvent("/_agent-native/workspace-app-chat/analytics", {
|
|
208
|
+
method: "POST",
|
|
209
|
+
}),
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
expect(response.status).toBe(502);
|
|
213
|
+
expect(((await response.json()) as { error: string }).error).toContain(
|
|
214
|
+
"analytics chat is unavailable",
|
|
215
|
+
);
|
|
216
|
+
expect(fetchImpl).not.toHaveBeenCalled();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("reports an error when the app rejects the embed ticket exchange", async () => {
|
|
220
|
+
const fetchImpl = stubFetch(() => new Response("ok", { status: 200 }), {
|
|
221
|
+
startStatus: 401,
|
|
222
|
+
});
|
|
223
|
+
const handler = createWorkspaceAppChatProxyHandler({ fetchImpl });
|
|
224
|
+
|
|
225
|
+
const response = await handler(
|
|
226
|
+
proxyEvent("/_agent-native/workspace-app-chat/analytics", {
|
|
227
|
+
method: "POST",
|
|
228
|
+
}),
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
expect(response.status).toBe(502);
|
|
232
|
+
expect(((await response.json()) as { error: string }).error).toContain(
|
|
233
|
+
"status 401",
|
|
234
|
+
);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("reports an error when the app's server is unreachable", async () => {
|
|
238
|
+
const fetchImpl = stubFetch(() => {
|
|
239
|
+
throw new Error("fetch failed");
|
|
240
|
+
});
|
|
241
|
+
const handler = createWorkspaceAppChatProxyHandler({ fetchImpl });
|
|
242
|
+
|
|
243
|
+
const response = await handler(
|
|
244
|
+
proxyEvent("/_agent-native/workspace-app-chat/analytics", {
|
|
245
|
+
method: "POST",
|
|
246
|
+
}),
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
expect(response.status).toBe(502);
|
|
250
|
+
expect(((await response.json()) as { error: string }).error).toContain(
|
|
251
|
+
"did not respond",
|
|
252
|
+
);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("requires a signed-in Dispatch user", async () => {
|
|
256
|
+
mocks.getSession.mockResolvedValue(null);
|
|
257
|
+
const fetchImpl = stubFetch(() => new Response("ok", { status: 200 }));
|
|
258
|
+
const handler = createWorkspaceAppChatProxyHandler({ fetchImpl });
|
|
259
|
+
|
|
260
|
+
const response = await handler(
|
|
261
|
+
proxyEvent("/_agent-native/workspace-app-chat/analytics", {
|
|
262
|
+
method: "POST",
|
|
263
|
+
}),
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
expect(response.status).toBe(401);
|
|
267
|
+
expect(fetchImpl).not.toHaveBeenCalled();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("rejects a request with no app segment instead of proxying somewhere", async () => {
|
|
271
|
+
const fetchImpl = stubFetch(() => new Response("ok", { status: 200 }));
|
|
272
|
+
const handler = createWorkspaceAppChatProxyHandler({ fetchImpl });
|
|
273
|
+
|
|
274
|
+
const response = await handler(
|
|
275
|
+
proxyEvent("/_agent-native/workspace-app-chat"),
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
expect(response.status).toBe(404);
|
|
279
|
+
expect(fetchImpl).not.toHaveBeenCalled();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("reuses one app session across requests and re-mints after a rejection", async () => {
|
|
283
|
+
const chatStatuses = [200, 401, 200];
|
|
284
|
+
let call = 0;
|
|
285
|
+
const fetchImpl = stubFetch(
|
|
286
|
+
() => new Response("ok", { status: chatStatuses[call++] ?? 200 }),
|
|
287
|
+
);
|
|
288
|
+
const handler = createWorkspaceAppChatProxyHandler({ fetchImpl });
|
|
289
|
+
const send = () =>
|
|
290
|
+
handler(
|
|
291
|
+
proxyEvent("/_agent-native/workspace-app-chat/analytics", {
|
|
292
|
+
method: "POST",
|
|
293
|
+
}),
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
await send();
|
|
297
|
+
await send();
|
|
298
|
+
expect(mocks.createWorkspaceSsoEmbedSession).toHaveBeenCalledTimes(1);
|
|
299
|
+
|
|
300
|
+
// The 401 above invalidated the cached credential.
|
|
301
|
+
await send();
|
|
302
|
+
expect(mocks.createWorkspaceSsoEmbedSession).toHaveBeenCalledTimes(2);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("refuses an embed token with no readable expiry rather than guessing one", async () => {
|
|
306
|
+
const fetchImpl = stubFetch(() => new Response("ok", { status: 200 }), {
|
|
307
|
+
token: `${Buffer.from(JSON.stringify({ ownerEmail: "o@e.com" })).toString("base64url")}.sig`,
|
|
308
|
+
});
|
|
309
|
+
const handler = createWorkspaceAppChatProxyHandler({ fetchImpl });
|
|
310
|
+
|
|
311
|
+
const response = await handler(
|
|
312
|
+
proxyEvent("/_agent-native/workspace-app-chat/analytics", {
|
|
313
|
+
method: "POST",
|
|
314
|
+
}),
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
expect(response.status).toBe(502);
|
|
318
|
+
expect(((await response.json()) as { error: string }).error).toContain(
|
|
319
|
+
"no usable expiry",
|
|
320
|
+
);
|
|
321
|
+
});
|
|
322
|
+
});
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side agent-chat proxy for an app opened inside Dispatch.
|
|
3
|
+
*
|
|
4
|
+
* The Desktop app already solves this by pointing the rail's `apiUrl` at a
|
|
5
|
+
* loopback relay onto the target app's `/_agent-native/agent-chat`, so parity
|
|
6
|
+
* with the app's native chat holds structurally instead of by copying tools and
|
|
7
|
+
* instructions into Dispatch's agent. This is the web equivalent: Dispatch
|
|
8
|
+
* forwards the whole agent-chat subtree to the app's own server.
|
|
9
|
+
*
|
|
10
|
+
* Credential: the app's OWN embed session, minted through the existing paths in
|
|
11
|
+
* `mcp-gateway.ts` — the same credential the app pane's iframe already runs on.
|
|
12
|
+
* Dispatch consumes the one-time ticket server-side and reuses the resulting
|
|
13
|
+
* short-lived embed token as a bearer, which is an authentication path the
|
|
14
|
+
* framework already accepts (`resolveEmbedSessionFromRequest`). No new auth
|
|
15
|
+
* scheme, and no token gains scope it did not already have.
|
|
16
|
+
*
|
|
17
|
+
* A dedicated Nitro route rather than an action: agent-chat streams (SSE for
|
|
18
|
+
* `/runs/:id/events`, a token stream for the turn itself) and returns non-JSON
|
|
19
|
+
* bodies, which is the documented exception in CLAUDE.md to the
|
|
20
|
+
* actions-over-routes rule.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { getOrgContext } from "@agent-native/core/org";
|
|
24
|
+
import { getSession, runWithRequestContext } from "@agent-native/core/server";
|
|
25
|
+
import {
|
|
26
|
+
EMBED_START_PATH,
|
|
27
|
+
EMBED_TOKEN_QUERY_PARAM,
|
|
28
|
+
} from "@agent-native/core/shared";
|
|
29
|
+
|
|
30
|
+
import { parseWorkspaceAppChatProxyPath } from "../../shared/workspace-app-chat.js";
|
|
31
|
+
import {
|
|
32
|
+
createGrantedDispatchMcpEmbedSession,
|
|
33
|
+
createWorkspaceSsoEmbedSession,
|
|
34
|
+
} from "./mcp-gateway.js";
|
|
35
|
+
|
|
36
|
+
const TARGET_CHAT_PATH = "/_agent-native/agent-chat";
|
|
37
|
+
/**
|
|
38
|
+
* `X-Agent-Native-Embed-Target` names the path a bearer embed token was minted
|
|
39
|
+
* for. Without it the target only honours the token on requests whose own path
|
|
40
|
+
* matches, which an agent-chat call never does. Duplicated from
|
|
41
|
+
* `@agent-native/core/shared/embed-auth` because that constant is not part of
|
|
42
|
+
* the package's public export surface.
|
|
43
|
+
*/
|
|
44
|
+
const EMBED_TARGET_HEADER = "x-agent-native-embed-target";
|
|
45
|
+
const CSRF_MARKER_HEADER = "x-agent-native-csrf";
|
|
46
|
+
/** Re-mint this far before the token's own expiry so a long turn cannot age out mid-stream. */
|
|
47
|
+
const SESSION_REFRESH_MARGIN_MS = 5 * 60 * 1000;
|
|
48
|
+
|
|
49
|
+
const HOP_BY_HOP_HEADERS = new Set([
|
|
50
|
+
"connection",
|
|
51
|
+
"keep-alive",
|
|
52
|
+
"proxy-authenticate",
|
|
53
|
+
"proxy-authorization",
|
|
54
|
+
"te",
|
|
55
|
+
"trailer",
|
|
56
|
+
"transfer-encoding",
|
|
57
|
+
"upgrade",
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
/** Headers Dispatch owns on the upstream call, or that must not cross origins. */
|
|
61
|
+
const DROPPED_REQUEST_HEADERS = new Set([
|
|
62
|
+
"accept-encoding",
|
|
63
|
+
"authorization",
|
|
64
|
+
"content-length",
|
|
65
|
+
"cookie",
|
|
66
|
+
"host",
|
|
67
|
+
"origin",
|
|
68
|
+
"referer",
|
|
69
|
+
EMBED_TARGET_HEADER,
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* `set-cookie` would land on Dispatch's origin, and the encoding/length headers
|
|
74
|
+
* describe the upstream wire body, which `fetch` has already decoded.
|
|
75
|
+
*/
|
|
76
|
+
const DROPPED_RESPONSE_HEADERS = new Set([
|
|
77
|
+
"content-encoding",
|
|
78
|
+
"content-length",
|
|
79
|
+
"set-cookie",
|
|
80
|
+
"transfer-encoding",
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
export interface WorkspaceAppChatSession {
|
|
84
|
+
/** Target app root, including any configured base path. */
|
|
85
|
+
appBaseUrl: string;
|
|
86
|
+
token: string;
|
|
87
|
+
embedTarget: string;
|
|
88
|
+
expiresAt: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const sessionCache = new Map<string, Promise<WorkspaceAppChatSession>>();
|
|
92
|
+
|
|
93
|
+
function sessionCacheKey(input: {
|
|
94
|
+
appId: string;
|
|
95
|
+
userEmail: string;
|
|
96
|
+
orgId: string | undefined;
|
|
97
|
+
}): string {
|
|
98
|
+
return [input.userEmail, input.orgId ?? "", input.appId].join("\u0000");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function errorMessage(error: unknown): string {
|
|
102
|
+
return error instanceof Error ? error.message : String(error ?? "");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Read the embed token's own `exp` claim. Guessing a TTL here would silently
|
|
107
|
+
* produce a session that the target has already rejected, so a token we cannot
|
|
108
|
+
* read an expiry from is an error rather than a default.
|
|
109
|
+
*/
|
|
110
|
+
export function embedSessionTokenExpiry(token: string): number {
|
|
111
|
+
const payload = token.split(".")[0];
|
|
112
|
+
if (!payload) throw new Error("Embed session token is malformed.");
|
|
113
|
+
let claims: unknown;
|
|
114
|
+
try {
|
|
115
|
+
claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
116
|
+
} catch {
|
|
117
|
+
throw new Error("Embed session token payload could not be decoded.");
|
|
118
|
+
}
|
|
119
|
+
const exp = (claims as { exp?: unknown } | null)?.exp;
|
|
120
|
+
if (typeof exp !== "number" || !Number.isFinite(exp)) {
|
|
121
|
+
throw new Error("Embed session token has no usable expiry.");
|
|
122
|
+
}
|
|
123
|
+
return exp * 1000;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function mintEmbedStartUrl(appId: string): Promise<string> {
|
|
127
|
+
const input = { app: appId, path: "/", chrome: "minimal" } as const;
|
|
128
|
+
// Mirrors `WorkspaceAppFrame`: the workspace-sign-in mint when the rollout
|
|
129
|
+
// covers this app, the Dispatch MCP grant mint otherwise. Both return the
|
|
130
|
+
// target app's own embed session — neither is a fallback onto Dispatch's
|
|
131
|
+
// agent, which this proxy must never do.
|
|
132
|
+
try {
|
|
133
|
+
return (await createWorkspaceSsoEmbedSession(input)).startUrl;
|
|
134
|
+
} catch (ssoError) {
|
|
135
|
+
try {
|
|
136
|
+
return (await createGrantedDispatchMcpEmbedSession(input)).startUrl;
|
|
137
|
+
} catch (grantError) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
`Could not create an app session for "${appId}". ` +
|
|
140
|
+
`Workspace sign-in: ${errorMessage(ssoError)}. ` +
|
|
141
|
+
`Dispatch app grant: ${errorMessage(grantError)}.`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Exchange the one-time embed ticket for the reusable short-lived token. The
|
|
149
|
+
* target's embed-start route answers a redirect whose location carries both the
|
|
150
|
+
* token and the path the token is bound to.
|
|
151
|
+
*/
|
|
152
|
+
export async function exchangeEmbedStartUrl(
|
|
153
|
+
startUrl: string,
|
|
154
|
+
fetchImpl: typeof fetch = fetch,
|
|
155
|
+
): Promise<WorkspaceAppChatSession> {
|
|
156
|
+
const start = new URL(startUrl);
|
|
157
|
+
const startIndex = start.pathname.lastIndexOf(EMBED_START_PATH);
|
|
158
|
+
if (startIndex < 0) {
|
|
159
|
+
throw new Error("App session start URL is not an embed start route.");
|
|
160
|
+
}
|
|
161
|
+
const appBaseUrl = `${start.origin}${start.pathname.slice(0, startIndex)}`;
|
|
162
|
+
|
|
163
|
+
const response = await fetchImpl(start.toString(), { redirect: "manual" });
|
|
164
|
+
await response.body?.cancel().catch(() => undefined);
|
|
165
|
+
const location = response.headers.get("location");
|
|
166
|
+
if (response.status < 300 || response.status >= 400 || !location) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`App session exchange failed with status ${response.status}.`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const resolved = new URL(location, start);
|
|
173
|
+
const token = resolved.searchParams.get(EMBED_TOKEN_QUERY_PARAM);
|
|
174
|
+
if (!token) {
|
|
175
|
+
throw new Error("App session exchange returned no embed token.");
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
appBaseUrl,
|
|
179
|
+
token,
|
|
180
|
+
embedTarget: resolved.pathname,
|
|
181
|
+
expiresAt: embedSessionTokenExpiry(token),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function resolveWorkspaceAppChatSession(input: {
|
|
186
|
+
appId: string;
|
|
187
|
+
userEmail: string;
|
|
188
|
+
orgId: string | undefined;
|
|
189
|
+
fetchImpl: typeof fetch;
|
|
190
|
+
}): Promise<WorkspaceAppChatSession> {
|
|
191
|
+
const key = sessionCacheKey(input);
|
|
192
|
+
const cached = sessionCache.get(key);
|
|
193
|
+
if (cached) {
|
|
194
|
+
try {
|
|
195
|
+
const session = await cached;
|
|
196
|
+
if (session.expiresAt - Date.now() > SESSION_REFRESH_MARGIN_MS) {
|
|
197
|
+
return session;
|
|
198
|
+
}
|
|
199
|
+
// coercion-ok: a mint that already failed is retried below, and that fresh
|
|
200
|
+
// attempt reports its own error rather than replaying a stale one.
|
|
201
|
+
} catch {
|
|
202
|
+
void 0;
|
|
203
|
+
}
|
|
204
|
+
sessionCache.delete(key);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const pending = Promise.resolve(
|
|
208
|
+
runWithRequestContext(
|
|
209
|
+
{
|
|
210
|
+
userEmail: input.userEmail,
|
|
211
|
+
...(input.orgId ? { orgId: input.orgId } : {}),
|
|
212
|
+
},
|
|
213
|
+
async () =>
|
|
214
|
+
exchangeEmbedStartUrl(
|
|
215
|
+
await mintEmbedStartUrl(input.appId),
|
|
216
|
+
input.fetchImpl,
|
|
217
|
+
),
|
|
218
|
+
),
|
|
219
|
+
);
|
|
220
|
+
sessionCache.set(key, pending);
|
|
221
|
+
try {
|
|
222
|
+
return await pending;
|
|
223
|
+
} catch (error) {
|
|
224
|
+
sessionCache.delete(key);
|
|
225
|
+
throw error;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Test seam: drop cached app sessions so a spec starts from a cold proxy. */
|
|
230
|
+
export function clearWorkspaceAppChatSessions(): void {
|
|
231
|
+
sessionCache.clear();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function upstreamRequestHeaders(
|
|
235
|
+
request: Request,
|
|
236
|
+
session: WorkspaceAppChatSession,
|
|
237
|
+
): Headers {
|
|
238
|
+
const headers = new Headers();
|
|
239
|
+
for (const [name, value] of request.headers) {
|
|
240
|
+
const lower = name.toLowerCase();
|
|
241
|
+
if (HOP_BY_HOP_HEADERS.has(lower) || DROPPED_REQUEST_HEADERS.has(lower)) {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
headers.set(name, value);
|
|
245
|
+
}
|
|
246
|
+
headers.set("authorization", `Bearer ${session.token}`);
|
|
247
|
+
headers.set(EMBED_TARGET_HEADER, session.embedTarget);
|
|
248
|
+
// Documented same-origin marker (see packages/core/src/server/csrf.ts). The
|
|
249
|
+
// proxy sends no cookies, but stating first-party intent keeps the contract
|
|
250
|
+
// explicit rather than relying on the no-cookie shortcut.
|
|
251
|
+
headers.set(CSRF_MARKER_HEADER, "1");
|
|
252
|
+
return headers;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function proxyErrorResponse(status: number, message: string): Response {
|
|
256
|
+
// An unavailable proxy is a visible failure. Dispatch must never answer an
|
|
257
|
+
// app-scoped chat request with its own agent: that would look like it worked
|
|
258
|
+
// while silently supplying the wrong tools and instructions.
|
|
259
|
+
return new Response(JSON.stringify({ error: message }), {
|
|
260
|
+
status,
|
|
261
|
+
headers: {
|
|
262
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
263
|
+
"Cache-Control": "no-store",
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** h3 v2 request event: `url` is the parsed URL and `req` is the web Request. */
|
|
269
|
+
interface WorkspaceAppChatProxyEvent {
|
|
270
|
+
url: URL;
|
|
271
|
+
req: Request;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function createWorkspaceAppChatProxyHandler(
|
|
275
|
+
options: { fetchImpl?: typeof fetch } = {},
|
|
276
|
+
) {
|
|
277
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
278
|
+
return async (event: WorkspaceAppChatProxyEvent): Promise<Response> => {
|
|
279
|
+
const url = event.url;
|
|
280
|
+
const parsed = parseWorkspaceAppChatProxyPath(url.pathname);
|
|
281
|
+
if (!parsed) {
|
|
282
|
+
return proxyErrorResponse(404, "Workspace app chat route not found.");
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// "Not signed in" and "the session store did not answer" are different
|
|
286
|
+
// failures. Telling a signed-in user to sign in during an outage is the
|
|
287
|
+
// confidently-wrong report this proxy must not produce.
|
|
288
|
+
let session: Awaited<ReturnType<typeof getSession>>;
|
|
289
|
+
let orgId: string | undefined;
|
|
290
|
+
try {
|
|
291
|
+
session = await getSession(event as never);
|
|
292
|
+
orgId = (await getOrgContext(event as never)).orgId ?? undefined;
|
|
293
|
+
} catch (error) {
|
|
294
|
+
return proxyErrorResponse(
|
|
295
|
+
503,
|
|
296
|
+
`Dispatch could not read your workspace session: ${errorMessage(error)}`,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
if (!session?.email) {
|
|
300
|
+
return proxyErrorResponse(401, "Sign in to Dispatch to use app chat.");
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
let appSession: WorkspaceAppChatSession;
|
|
304
|
+
try {
|
|
305
|
+
appSession = await resolveWorkspaceAppChatSession({
|
|
306
|
+
appId: parsed.appId,
|
|
307
|
+
userEmail: session.email,
|
|
308
|
+
orgId,
|
|
309
|
+
fetchImpl,
|
|
310
|
+
});
|
|
311
|
+
} catch (error) {
|
|
312
|
+
console.warn("[dispatch] workspace app chat proxy could not connect", {
|
|
313
|
+
app: parsed.appId,
|
|
314
|
+
error: errorMessage(error),
|
|
315
|
+
});
|
|
316
|
+
return proxyErrorResponse(
|
|
317
|
+
502,
|
|
318
|
+
`${parsed.appId} chat is unavailable: ${errorMessage(error)}`,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const request = event.req;
|
|
323
|
+
const target = new URL(
|
|
324
|
+
`${appSession.appBaseUrl}${TARGET_CHAT_PATH}${parsed.targetSubPath}`,
|
|
325
|
+
);
|
|
326
|
+
target.search = url.search;
|
|
327
|
+
|
|
328
|
+
const method = request.method.toUpperCase();
|
|
329
|
+
// The turn body is a small JSON payload, so buffering the REQUEST costs
|
|
330
|
+
// nothing and avoids half-duplex streaming support varying by runtime. The
|
|
331
|
+
// RESPONSE is the streaming half and is piped through untouched below.
|
|
332
|
+
const body =
|
|
333
|
+
method === "GET" || method === "HEAD"
|
|
334
|
+
? undefined
|
|
335
|
+
: await request.arrayBuffer();
|
|
336
|
+
|
|
337
|
+
let upstream: Response;
|
|
338
|
+
try {
|
|
339
|
+
upstream = await fetchImpl(target.toString(), {
|
|
340
|
+
method,
|
|
341
|
+
headers: upstreamRequestHeaders(request, appSession),
|
|
342
|
+
...(body === undefined ? {} : { body }),
|
|
343
|
+
redirect: "manual",
|
|
344
|
+
});
|
|
345
|
+
} catch (error) {
|
|
346
|
+
console.warn("[dispatch] workspace app chat proxy request failed", {
|
|
347
|
+
app: parsed.appId,
|
|
348
|
+
targetPath: target.pathname,
|
|
349
|
+
error: errorMessage(error),
|
|
350
|
+
});
|
|
351
|
+
return proxyErrorResponse(
|
|
352
|
+
502,
|
|
353
|
+
`${parsed.appId} chat did not respond: ${errorMessage(error)}`,
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// A rejected embed session is not a transport failure: drop the cached
|
|
358
|
+
// credential so the next request re-mints instead of looping on a token the
|
|
359
|
+
// app has already stopped honouring.
|
|
360
|
+
if (upstream.status === 401 || upstream.status === 403) {
|
|
361
|
+
sessionCache.delete(
|
|
362
|
+
sessionCacheKey({
|
|
363
|
+
appId: parsed.appId,
|
|
364
|
+
userEmail: session.email,
|
|
365
|
+
orgId,
|
|
366
|
+
}),
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const headers = new Headers();
|
|
371
|
+
for (const [name, value] of upstream.headers) {
|
|
372
|
+
const lower = name.toLowerCase();
|
|
373
|
+
if (
|
|
374
|
+
HOP_BY_HOP_HEADERS.has(lower) ||
|
|
375
|
+
DROPPED_RESPONSE_HEADERS.has(lower)
|
|
376
|
+
) {
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
headers.set(name, value);
|
|
380
|
+
}
|
|
381
|
+
// `upstream.body` is the live stream: SSE run events and the turn's token
|
|
382
|
+
// stream reach the browser as the app emits them, never buffered here.
|
|
383
|
+
return new Response(upstream.body, {
|
|
384
|
+
status: upstream.status,
|
|
385
|
+
statusText: upstream.statusText,
|
|
386
|
+
headers,
|
|
387
|
+
});
|
|
388
|
+
};
|
|
389
|
+
}
|