@sentry/junior 0.223.0 → 0.225.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/{agent-hooks-DZU5nsd1.js → agent-hooks-CRuRcOg9.js} +9 -9
- package/dist/{agent-Bzbf92vv.js → agent-runner-Cad1RtWn.js} +1016 -144
- package/dist/api/schema.js +2 -2
- package/dist/api.js +16 -16
- package/dist/app.js +1538 -71
- package/dist/{briefs-BJPfCrlT.js → briefs-BtnkPWT5.js} +5 -5
- package/dist/{catalog-runtime-BthunUDD.js → catalog-runtime-pv4grNNb.js} +1 -1
- package/dist/{checkpoint-Deu-OVb3.js → checkpoint-6fkAdB3g.js} +8 -8
- package/dist/cli/chat.js +14 -15
- package/dist/cli/check.js +3 -3
- package/dist/cli/main.js +1 -1
- package/dist/cli/plugins.js +3 -3
- package/dist/cli/snapshot-create.js +3 -3
- package/dist/cli/upgrade.js +3 -3
- package/dist/{client-DjC6-6iU.js → client-ihmkx60d.js} +2 -2
- package/dist/{config-CtoUVs72.js → config-DMg679BW.js} +11 -13
- package/dist/{conversation-1HUoZr2l.js → conversation-Bygi39Xj.js} +1 -1
- package/dist/{conversation-privacy-xS1t92xa.js → conversation-privacy-ZYy05Y3q.js} +1 -1
- package/dist/credential-sync-DCB2SwnQ.js +940 -0
- package/dist/{execution-stats-CrXs9vSH.js → execution-stats-CR9CYpyj.js} +3 -3
- package/dist/{executor-AOhG9X1t.js → executor-B9R4cHte.js} +1 -1
- package/dist/instrumentation.js +2 -2
- package/dist/{job-queue-CNd9cpIy.js → job-queue-D2SiTrnt.js} +1 -1
- package/dist/{logging-DiN3o3nB.js → logging-CcTvYQ8A.js} +2 -2
- package/dist/{model-profile-CZmVoGdX.js → model-profile-BwU8mqwD.js} +14 -1
- package/dist/nitro.js +3 -3
- package/dist/{pending-auth-D7vmttn_.js → oauth-authorization-BS4vPJmu.js} +6 -48
- package/dist/{oauth-callback-server-Ca0VLUEw.js → oauth-callback-server-DnlQsGBJ.js} +4 -4
- package/dist/{profile-BsNrw8lj.js → profile-CV97YuiL.js} +5 -9
- package/dist/{prompt-90miy8VV.js → prompt-CL8zxsmm.js} +1 -1
- package/dist/{registry-Dankt-Pl.js → registry-C8I4IejN.js} +3 -3
- package/dist/{resolve-B38CpJe2.js → resolve-DXVAzYa3.js} +2 -2
- package/dist/{runner-Ck5rEjCm.js → runner-Die1RJ_z.js} +9 -10
- package/dist/{sandbox-egress-signals-BOfFkvly.js → sandbox-egress-signals-BZepyVh1.js} +1 -1
- package/dist/{schema-RpbW5Od2.js → schema-BE4uso4i.js} +1 -1
- package/dist/{short-title-BHd_jUFX.js → short-title-3wYH3ipl.js} +11 -5
- package/dist/{skills-DbP4TDft.js → skills-DUcDEgDI.js} +2 -2
- package/dist/{spawn-DzXc_PKO.js → spawn-o15i3tJu.js} +2 -2
- package/dist/{stats-pNGp3G5q.js → stats-DawHjcTw.js} +6 -6
- package/dist/{task-queue-Cy9y1foT.js → task-queue-D3PLY59C.js} +3 -3
- package/dist/{task-runner-DI1FBJFJ.js → task-runner-BMl2aM2v.js} +9 -84
- package/dist/{thread-state-DXgxOYpZ.js → thread-state-CAR6gdAm.js} +5 -5
- package/dist/{auth-pause-state-Bn2vX4PA.js → turn-failure-response-CZyoUMkD.js} +74 -73
- package/dist/{turn-lifecycle-Bjf6y2GO.js → turn-lifecycle-DPDHKi2U.js} +1 -1
- package/dist/turn-session-routing-Jkmmxpp-.js +77 -0
- package/dist/{validation-D5CIslEu.js → validation-BcGGxOa6.js} +1 -1
- package/dist/{version-_ZQFlslB.js → version-DYcuOFQb.js} +1 -1
- package/dist/version.js +1 -1
- package/dist/{web-authorization-6mmfS_o-.js → web-authorization-B09B8A8R.js} +1 -1
- package/dist/{web-input-BJpWm6go.js → web-input-CGE58TpK.js} +7 -7
- package/dist/{work-DvIlJR3C.js → work-Bm90pR9P.js} +52 -10
- package/dist/{worker-D1qidg-Y.js → worker-BvHr6JhE.js} +5 -5
- package/package.json +4 -4
- package/dist/agent-runner-2VQ9WhkG.js +0 -679
- package/dist/credential-sync-CKdXEUDO.js +0 -2811
- package/dist/experimental-CIXf7Uye.js +0 -46
- package/dist/turn-execution-BNOXrZ-M.js +0 -47
|
@@ -0,0 +1,940 @@
|
|
|
1
|
+
import { C as resolvePluginOAuthAccount, D as lookupSlackUserProfile, V as getMcpProviderErrorAttributes, _ as putMcpStoredOAuthCredentials, at as parseOAuthStatePayload, b as getConversationPendingAuth, d as deleteMcpAuthSession, g as getMcpStoredOAuthCredentials, h as getMcpAuthSession, it as formatProviderLabel, mt as postSlackMessage, ot as resolveBaseUrl, u as finalizeMcpAuthorization, vt as createUserTokenStore } from "./agent-runner-Cad1RtWn.js";
|
|
2
|
+
import { m as escapeXml } from "./client-ihmkx60d.js";
|
|
3
|
+
import { d as logException, h as runBestEffort, p as logWarn } from "./logging-CcTvYQ8A.js";
|
|
4
|
+
import { n as botConfig, o as getRuntimeMetadata } from "./config-DMg679BW.js";
|
|
5
|
+
import { g as parseSlackUserId } from "./context-Bplcgypl.js";
|
|
6
|
+
import { Jt as getSqlExecutor, U as getSlackClient, Wt as getConversationEventStore, ln as upsertIdentity, un as upsertLinkedIdentity } from "./agent-hooks-CRuRcOg9.js";
|
|
7
|
+
import { i as homeDir } from "./package-discovery-7a9YqLhE.js";
|
|
8
|
+
import { n as buildOAuthTokenRequest, r as parseOAuthTokenResponse, u as hasRequiredOAuthScope } from "./registry-C8I4IejN.js";
|
|
9
|
+
import { n as pluginCatalogRuntime } from "./catalog-runtime-pv4grNNb.js";
|
|
10
|
+
import { t as discoverSkills } from "./skills-DUcDEgDI.js";
|
|
11
|
+
import { B as recordAuthorizationCompleted, n as loadTurnCheckpoint, o as failTurnRecord, s as getTurnRecord, z as recordAuthenticationLinked } from "./checkpoint-6fkAdB3g.js";
|
|
12
|
+
import { E as acquireActiveLock, w as getStateAdapter } from "./profile-CV97YuiL.js";
|
|
13
|
+
import { Pt as requestConversationWork, wt as ensureConversationWake } from "./stats-DawHjcTw.js";
|
|
14
|
+
import { t as getVercelConversationWorkQueue } from "./vercel-queue-DIkVcfK4.js";
|
|
15
|
+
import { r as getPersistedThreadState, s as coerceThreadConversationState } from "./thread-state-CAR6gdAm.js";
|
|
16
|
+
import { n as resolveTurnSessionRouting } from "./turn-session-routing-Jkmmxpp-.js";
|
|
17
|
+
import { n as relayLocalOAuthCallback } from "./oauth-relay-CdCnu80W.js";
|
|
18
|
+
import { n as deleteWebAuthorization } from "./web-authorization-B09B8A8R.js";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
import { createHmac, createSecretKey, timingSafeEqual } from "node:crypto";
|
|
22
|
+
import { pluginStoredTokensSchema } from "@sentry/junior-plugin-api";
|
|
23
|
+
import fs from "node:fs";
|
|
24
|
+
//#region src/chat/task-execution/authorized-turn.ts
|
|
25
|
+
/**
|
|
26
|
+
* OAuth callbacks save readiness, not agent history. The conversation worker
|
|
27
|
+
* consumes this event under its lease before it resumes the paused Turn.
|
|
28
|
+
*/
|
|
29
|
+
const authorizationSchema = z.object({
|
|
30
|
+
kind: z.enum(["mcp", "plugin"]),
|
|
31
|
+
provider: z.string().min(1),
|
|
32
|
+
actorId: z.string().min(1),
|
|
33
|
+
authorizationId: z.string().min(1),
|
|
34
|
+
authSessionId: z.string().optional(),
|
|
35
|
+
scope: z.string().optional(),
|
|
36
|
+
configuration: z.record(z.string(), z.unknown()).optional(),
|
|
37
|
+
toolChannelId: z.string().optional()
|
|
38
|
+
});
|
|
39
|
+
function authorizationKey(turn) {
|
|
40
|
+
return `turn:${turn.turnId}:authorization:${turn.version}`;
|
|
41
|
+
}
|
|
42
|
+
/** Save authorization for this parked boundary without changing model history. */
|
|
43
|
+
async function recordTurnAuthorization(args) {
|
|
44
|
+
const turn = await getTurnRecord(args.conversationId, args.turnId);
|
|
45
|
+
if (!turn || turn.state !== "paused" || turn.resumeReason !== "auth") return;
|
|
46
|
+
await getConversationEventStore().append(args.conversationId, [{
|
|
47
|
+
idempotencyKey: authorizationKey(turn),
|
|
48
|
+
createdAtMs: Date.now(),
|
|
49
|
+
data: {
|
|
50
|
+
type: "structured_event",
|
|
51
|
+
namespace: "junior",
|
|
52
|
+
name: "turn_authorized",
|
|
53
|
+
version: 1,
|
|
54
|
+
turnId: turn.turnId,
|
|
55
|
+
content: authorizationSchema.parse(args)
|
|
56
|
+
}
|
|
57
|
+
}], { activity: "preserve" });
|
|
58
|
+
return {
|
|
59
|
+
conversationId: args.conversationId,
|
|
60
|
+
destination: args.destination,
|
|
61
|
+
turnId: turn.turnId,
|
|
62
|
+
expectedVersion: turn.version
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/** Read authorization for the exact pause; a later auth pause needs its own callback. */
|
|
66
|
+
async function getTurnAuthorization(turn) {
|
|
67
|
+
const event = await getConversationEventStore().loadByIdempotencyKey(turn.conversationId, authorizationKey(turn));
|
|
68
|
+
if (event?.data.type !== "structured_event") return void 0;
|
|
69
|
+
return authorizationSchema.parse(event.data.content);
|
|
70
|
+
}
|
|
71
|
+
//#endregion
|
|
72
|
+
//#region src/chat/task-execution/turn-wake.ts
|
|
73
|
+
/** Build the worker input for a paused turn. */
|
|
74
|
+
async function getPausedTurnRequest(args) {
|
|
75
|
+
const turn = (await loadTurnCheckpoint({
|
|
76
|
+
conversationId: args.conversationId,
|
|
77
|
+
turnId: args.turnId
|
|
78
|
+
})).record;
|
|
79
|
+
if (!turn || turn.state !== "paused" || turn.resumeReason !== "timeout" && turn.resumeReason !== "yield" && turn.resumeReason !== "retry" && !(turn.resumeReason === "auth" && await getTurnAuthorization(turn)) || turn.resumeReason === "timeout" && turn.sliceId < 2) return;
|
|
80
|
+
let routing;
|
|
81
|
+
try {
|
|
82
|
+
routing = await resolveTurnSessionRouting({
|
|
83
|
+
conversationId: args.conversationId,
|
|
84
|
+
conversationStore: args.conversationStore
|
|
85
|
+
});
|
|
86
|
+
} catch (error) {
|
|
87
|
+
await failTurnRecord({
|
|
88
|
+
conversationId: args.conversationId,
|
|
89
|
+
expectedVersion: turn.version,
|
|
90
|
+
turnId: args.turnId,
|
|
91
|
+
errorMessage: error instanceof Error ? error.message : String(error)
|
|
92
|
+
});
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
conversationId: args.conversationId,
|
|
97
|
+
destination: routing.destination,
|
|
98
|
+
turnId: args.turnId,
|
|
99
|
+
expectedVersion: turn.version
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/** Wake the conversation worker for a paused turn. */
|
|
103
|
+
async function wakePausedTurn(request, options = {}) {
|
|
104
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
105
|
+
await requestConversationWork({
|
|
106
|
+
conversationId: request.conversationId,
|
|
107
|
+
destination: request.destination,
|
|
108
|
+
nowMs,
|
|
109
|
+
state: options.state
|
|
110
|
+
});
|
|
111
|
+
const queue = options.queue ?? getVercelConversationWorkQueue();
|
|
112
|
+
await ensureConversationWake({
|
|
113
|
+
conversationId: request.conversationId,
|
|
114
|
+
idempotencyKey: [
|
|
115
|
+
"agent-continue",
|
|
116
|
+
request.conversationId,
|
|
117
|
+
request.turnId,
|
|
118
|
+
request.expectedVersion,
|
|
119
|
+
nowMs
|
|
120
|
+
].join(":"),
|
|
121
|
+
nowMs,
|
|
122
|
+
queue,
|
|
123
|
+
state: options.state
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
/** Create paused Turn operations for one app. */
|
|
127
|
+
function createPausedTurns(options = {}) {
|
|
128
|
+
return {
|
|
129
|
+
get: (args) => getPausedTurnRequest({
|
|
130
|
+
...args,
|
|
131
|
+
conversationStore: options.conversationStore
|
|
132
|
+
}),
|
|
133
|
+
wake: (request) => wakePausedTurn(request, options)
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/** Record completed authorization and wake the sole execution owner. */
|
|
137
|
+
async function wakeAuthorizedTurn(args, queue) {
|
|
138
|
+
const request = await recordTurnAuthorization(args);
|
|
139
|
+
if (request) await wakePausedTurn(request, { queue });
|
|
140
|
+
}
|
|
141
|
+
//#endregion
|
|
142
|
+
//#region src/handlers/oauth-html.ts
|
|
143
|
+
/** Build pre-escaped callback HTML with the shared response security policy. */
|
|
144
|
+
function htmlCallbackResponse(title, message, status, options = {}) {
|
|
145
|
+
const footerMessage = options.footerMessage ?? `You can close this tab and return to ${botConfig.userName}.`;
|
|
146
|
+
const isSuccess = status >= 200 && status < 300;
|
|
147
|
+
const statusClass = isSuccess ? "success" : "error";
|
|
148
|
+
const statusIcon = isSuccess ? "" : "!";
|
|
149
|
+
const html = `<!DOCTYPE html>
|
|
150
|
+
<html lang="en">
|
|
151
|
+
<head>
|
|
152
|
+
<meta charset="utf-8">
|
|
153
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
154
|
+
<meta name="color-scheme" content="light dark">
|
|
155
|
+
<title>${title}</title>
|
|
156
|
+
<style>
|
|
157
|
+
:root {
|
|
158
|
+
color-scheme: light dark;
|
|
159
|
+
--canvas: #f4f1fa;
|
|
160
|
+
--surface: #ffffff;
|
|
161
|
+
--text: #211a2c;
|
|
162
|
+
--muted: #6e6479;
|
|
163
|
+
--border: #ded7e8;
|
|
164
|
+
--accent: #6c5fc7;
|
|
165
|
+
--success: #16835c;
|
|
166
|
+
--success-soft: #ddf5ea;
|
|
167
|
+
--error: #bc3d50;
|
|
168
|
+
--error-soft: #fce7eb;
|
|
169
|
+
--shadow: 0 24px 64px rgba(45, 31, 63, 0.14);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
* { box-sizing: border-box; }
|
|
173
|
+
|
|
174
|
+
body {
|
|
175
|
+
min-height: 100vh;
|
|
176
|
+
margin: 0;
|
|
177
|
+
display: grid;
|
|
178
|
+
place-items: center;
|
|
179
|
+
padding: 24px;
|
|
180
|
+
background: var(--canvas);
|
|
181
|
+
color: var(--text);
|
|
182
|
+
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
main {
|
|
186
|
+
width: min(100%, 480px);
|
|
187
|
+
padding: 48px 40px 40px;
|
|
188
|
+
text-align: center;
|
|
189
|
+
background: var(--surface);
|
|
190
|
+
border: 1px solid var(--border);
|
|
191
|
+
border-radius: 24px;
|
|
192
|
+
box-shadow: var(--shadow);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.brand {
|
|
196
|
+
display: inline-flex;
|
|
197
|
+
align-items: center;
|
|
198
|
+
gap: 8px;
|
|
199
|
+
margin-bottom: 32px;
|
|
200
|
+
color: var(--muted);
|
|
201
|
+
font-size: 14px;
|
|
202
|
+
font-weight: 700;
|
|
203
|
+
letter-spacing: 0.08em;
|
|
204
|
+
text-transform: uppercase;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.brand-mark {
|
|
208
|
+
width: 10px;
|
|
209
|
+
height: 10px;
|
|
210
|
+
background: var(--accent);
|
|
211
|
+
border-radius: 3px;
|
|
212
|
+
transform: rotate(12deg);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.status {
|
|
216
|
+
width: 64px;
|
|
217
|
+
height: 64px;
|
|
218
|
+
margin: 0 auto 24px;
|
|
219
|
+
display: grid;
|
|
220
|
+
place-items: center;
|
|
221
|
+
border-radius: 20px;
|
|
222
|
+
font-size: 32px;
|
|
223
|
+
font-weight: 750;
|
|
224
|
+
line-height: 1;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.status.success { color: var(--success); background: var(--success-soft); }
|
|
228
|
+
.status.success::before {
|
|
229
|
+
content: "";
|
|
230
|
+
width: 12px;
|
|
231
|
+
height: 24px;
|
|
232
|
+
margin-top: -5px;
|
|
233
|
+
border-right: 4px solid currentColor;
|
|
234
|
+
border-bottom: 4px solid currentColor;
|
|
235
|
+
transform: rotate(45deg);
|
|
236
|
+
}
|
|
237
|
+
.status.error { color: var(--error); background: var(--error-soft); }
|
|
238
|
+
|
|
239
|
+
h1 {
|
|
240
|
+
margin: 0;
|
|
241
|
+
font-size: clamp(28px, 7vw, 36px);
|
|
242
|
+
line-height: 1.12;
|
|
243
|
+
letter-spacing: -0.035em;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
.message {
|
|
247
|
+
margin: 16px auto 0;
|
|
248
|
+
color: var(--muted);
|
|
249
|
+
font-size: 17px;
|
|
250
|
+
line-height: 1.6;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
.footer {
|
|
254
|
+
margin: 32px 0 0;
|
|
255
|
+
padding-top: 24px;
|
|
256
|
+
color: var(--muted);
|
|
257
|
+
border-top: 1px solid var(--border);
|
|
258
|
+
font-size: 14px;
|
|
259
|
+
line-height: 1.5;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
@media (prefers-color-scheme: dark) {
|
|
263
|
+
:root {
|
|
264
|
+
--canvas: #15121a;
|
|
265
|
+
--surface: #211c28;
|
|
266
|
+
--text: #f6f1fb;
|
|
267
|
+
--muted: #b8adbf;
|
|
268
|
+
--border: #3a3243;
|
|
269
|
+
--accent: #a99af0;
|
|
270
|
+
--success: #72d6ad;
|
|
271
|
+
--success-soft: #193c30;
|
|
272
|
+
--error: #ff9aaa;
|
|
273
|
+
--error-soft: #48262e;
|
|
274
|
+
--shadow: 0 24px 64px rgba(0, 0, 0, 0.36);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
@media (max-width: 520px) {
|
|
279
|
+
body { padding: 16px; }
|
|
280
|
+
main { padding: 40px 24px 32px; border-radius: 20px; }
|
|
281
|
+
}
|
|
282
|
+
</style>
|
|
283
|
+
</head>
|
|
284
|
+
<body>
|
|
285
|
+
<main>
|
|
286
|
+
<div class="brand"><span class="brand-mark" aria-hidden="true"></span>${botConfig.userName}</div>
|
|
287
|
+
<div class="status ${statusClass}" aria-hidden="true">${statusIcon}</div>
|
|
288
|
+
<h1>${title}</h1>
|
|
289
|
+
<p class="message">${message}</p>
|
|
290
|
+
<p class="footer">${footerMessage}</p>
|
|
291
|
+
</main>
|
|
292
|
+
</body>
|
|
293
|
+
</html>`;
|
|
294
|
+
return new Response(html, {
|
|
295
|
+
status,
|
|
296
|
+
headers: {
|
|
297
|
+
"Cache-Control": "no-store",
|
|
298
|
+
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'",
|
|
299
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
300
|
+
"Referrer-Policy": "no-referrer",
|
|
301
|
+
"X-Content-Type-Options": "nosniff"
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
//#endregion
|
|
306
|
+
//#region src/handlers/mcp-oauth-callback.ts
|
|
307
|
+
/**
|
|
308
|
+
* MCP OAuth callback handler.
|
|
309
|
+
*
|
|
310
|
+
* Public signed callbacks relay to the owning CLI. The loopback callback
|
|
311
|
+
* finalizes local MCP authorization and leaves continuation to that CLI.
|
|
312
|
+
* Slack callbacks save authorization readiness and wake the conversation
|
|
313
|
+
* worker. Only that worker can change agent history or resume the Turn.
|
|
314
|
+
*/
|
|
315
|
+
function callbackPages(botName) {
|
|
316
|
+
return {
|
|
317
|
+
missing_state: {
|
|
318
|
+
title: "Authorization failed",
|
|
319
|
+
message: "Missing state parameter.",
|
|
320
|
+
status: 400
|
|
321
|
+
},
|
|
322
|
+
provider_error: {
|
|
323
|
+
title: "Authorization failed",
|
|
324
|
+
message: "The provider returned an authorization error.",
|
|
325
|
+
status: 400
|
|
326
|
+
},
|
|
327
|
+
missing_code: {
|
|
328
|
+
title: "Authorization failed",
|
|
329
|
+
message: "Missing code parameter.",
|
|
330
|
+
status: 400
|
|
331
|
+
},
|
|
332
|
+
expired: {
|
|
333
|
+
title: "Authorization expired",
|
|
334
|
+
message: `This authorization link is no longer active. Return to ${botName} and retry the original request.`,
|
|
335
|
+
status: 400
|
|
336
|
+
},
|
|
337
|
+
success: {
|
|
338
|
+
title: "Authorization complete",
|
|
339
|
+
message: `Your MCP access is connected. ${botName} will continue the paused request.`,
|
|
340
|
+
status: 200
|
|
341
|
+
},
|
|
342
|
+
failure: {
|
|
343
|
+
title: "Authorization failed",
|
|
344
|
+
message: `${botName} could not finish the authorization callback. Return to ${botName} and retry the original request.`,
|
|
345
|
+
status: 500
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
var McpOAuthAttemptExpiredError = class extends Error {
|
|
350
|
+
constructor() {
|
|
351
|
+
super("MCP OAuth authorization attempt is no longer current");
|
|
352
|
+
this.name = "McpOAuthAttemptExpiredError";
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
function mcpAuthorizationId(args) {
|
|
356
|
+
return `${args.sessionId}:mcp:${args.provider}`;
|
|
357
|
+
}
|
|
358
|
+
function htmlResponse(kind, options) {
|
|
359
|
+
const botName = botConfig.userName;
|
|
360
|
+
const pages = callbackPages(botName);
|
|
361
|
+
const page = kind === "success" && options?.local ? {
|
|
362
|
+
...pages.success,
|
|
363
|
+
message: `Your MCP access is connected. ${botName} will continue the paused request in the local client.`
|
|
364
|
+
} : pages[kind];
|
|
365
|
+
const footerMessage = kind === "success" && !options?.local ? `You can close this tab and return to ${botName}.` : void 0;
|
|
366
|
+
return htmlCallbackResponse(page.title, page.message, page.status, { footerMessage });
|
|
367
|
+
}
|
|
368
|
+
/** Queue the exact MCP authorization attempt without changing thread state. */
|
|
369
|
+
async function wakeAuthorizedMcpTurn(args) {
|
|
370
|
+
const { authSession, provider } = args;
|
|
371
|
+
if (!authSession.destination) return;
|
|
372
|
+
await wakeAuthorizedTurn({
|
|
373
|
+
conversationId: authSession.conversationId,
|
|
374
|
+
destination: authSession.destination,
|
|
375
|
+
turnId: authSession.sessionId,
|
|
376
|
+
kind: "mcp",
|
|
377
|
+
provider,
|
|
378
|
+
actorId: authSession.userId,
|
|
379
|
+
authSessionId: authSession.authSessionId,
|
|
380
|
+
configuration: authSession.configuration,
|
|
381
|
+
toolChannelId: authSession.toolChannelId,
|
|
382
|
+
authorizationId: mcpAuthorizationId({
|
|
383
|
+
provider,
|
|
384
|
+
sessionId: authSession.sessionId
|
|
385
|
+
})
|
|
386
|
+
}, args.queue);
|
|
387
|
+
}
|
|
388
|
+
async function isCurrentMcpAuthorizationAttempt(authSession, provider) {
|
|
389
|
+
const conversationId = mcpConversationId(authSession);
|
|
390
|
+
if (!conversationId) return false;
|
|
391
|
+
const currentState = await getPersistedThreadState(conversationId);
|
|
392
|
+
const conversation = coerceThreadConversationState(currentState);
|
|
393
|
+
const pendingAuth = getConversationPendingAuth({
|
|
394
|
+
conversation,
|
|
395
|
+
kind: "mcp",
|
|
396
|
+
provider,
|
|
397
|
+
actorId: authSession.userId
|
|
398
|
+
});
|
|
399
|
+
return pendingAuth?.authSessionId === authSession.authSessionId && pendingAuth.sessionId === authSession.sessionId;
|
|
400
|
+
}
|
|
401
|
+
function mcpConversationId(authSession) {
|
|
402
|
+
if (authSession.destination?.platform === "local" || authSession.source?.kind === "web") return authSession.conversationId;
|
|
403
|
+
if (authSession.channelId && authSession.threadTs) return `slack:${authSession.channelId}:${authSession.threadTs}`;
|
|
404
|
+
}
|
|
405
|
+
/** Commit one shared credential mutation only while this attempt owns the thread. */
|
|
406
|
+
async function runCurrentMcpCredentialMutation(authSession, provider, mutation) {
|
|
407
|
+
const conversationId = mcpConversationId(authSession);
|
|
408
|
+
if (!conversationId) throw new McpOAuthAttemptExpiredError();
|
|
409
|
+
const stateAdapter = getStateAdapter();
|
|
410
|
+
await stateAdapter.connect();
|
|
411
|
+
const lock = await acquireActiveLock(stateAdapter, conversationId);
|
|
412
|
+
if (!lock) throw new Error(`Could not acquire MCP OAuth callback lock for ${conversationId}`);
|
|
413
|
+
try {
|
|
414
|
+
if (!await isCurrentMcpAuthorizationAttempt(authSession, provider)) throw new McpOAuthAttemptExpiredError();
|
|
415
|
+
return await mutation();
|
|
416
|
+
} finally {
|
|
417
|
+
await stateAdapter.releaseLock(lock);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
/** Exchange MCP credentials and queue the authorized Turn before reporting success. */
|
|
421
|
+
async function GET$1(request, provider, waitUntil, options) {
|
|
422
|
+
const localRelay = await relayLocalOAuthCallback(request);
|
|
423
|
+
if (localRelay) return localRelay;
|
|
424
|
+
const url = new URL(request.url);
|
|
425
|
+
const state = url.searchParams.get("state")?.trim();
|
|
426
|
+
const code = url.searchParams.get("code")?.trim();
|
|
427
|
+
const error = url.searchParams.get("error")?.trim();
|
|
428
|
+
if (!state) return htmlResponse("missing_state");
|
|
429
|
+
if (error) {
|
|
430
|
+
try {
|
|
431
|
+
const pendingSession = await getMcpAuthSession(state);
|
|
432
|
+
if (pendingSession && pendingSession.provider === provider) {
|
|
433
|
+
try {
|
|
434
|
+
await runCurrentMcpCredentialMutation(pendingSession, provider, async () => {
|
|
435
|
+
const credentials = await getMcpStoredOAuthCredentials(pendingSession.userId, provider);
|
|
436
|
+
if (!credentials?.clientInformation && !credentials?.discoveryState) return;
|
|
437
|
+
const nextCredentials = credentials.tokens ? { tokens: credentials.tokens } : {};
|
|
438
|
+
await putMcpStoredOAuthCredentials(pendingSession.userId, provider, nextCredentials);
|
|
439
|
+
});
|
|
440
|
+
} catch (cleanupError) {
|
|
441
|
+
if (!(cleanupError instanceof McpOAuthAttemptExpiredError)) logException(cleanupError, "mcp.oauth_callback.provider_error_cleanup.failed", { "app.credential.provider": provider });
|
|
442
|
+
}
|
|
443
|
+
await deleteMcpAuthSession(pendingSession.authSessionId);
|
|
444
|
+
}
|
|
445
|
+
} catch (cleanupError) {
|
|
446
|
+
logException(cleanupError, "mcp.oauth_callback.provider_error_cleanup.failed", { "app.credential.provider": provider });
|
|
447
|
+
}
|
|
448
|
+
return htmlResponse("provider_error");
|
|
449
|
+
}
|
|
450
|
+
if (!code) return htmlResponse("missing_code");
|
|
451
|
+
try {
|
|
452
|
+
const pendingSession = await getMcpAuthSession(state);
|
|
453
|
+
if (!pendingSession || pendingSession.provider !== provider || !await isCurrentMcpAuthorizationAttempt(pendingSession, provider)) {
|
|
454
|
+
if (pendingSession) await deleteMcpAuthSession(pendingSession.authSessionId);
|
|
455
|
+
return htmlResponse("expired");
|
|
456
|
+
}
|
|
457
|
+
const authSession = await finalizeMcpAuthorization(provider, state, code, async (mutation) => await runCurrentMcpCredentialMutation(pendingSession, provider, mutation));
|
|
458
|
+
let providerLabel;
|
|
459
|
+
try {
|
|
460
|
+
providerLabel = formatProviderLabel(provider);
|
|
461
|
+
} catch {
|
|
462
|
+
providerLabel = void 0;
|
|
463
|
+
}
|
|
464
|
+
try {
|
|
465
|
+
await recordAuthenticationLinked({
|
|
466
|
+
conversationId: authSession.conversationId,
|
|
467
|
+
provider,
|
|
468
|
+
actorId: authSession.userId,
|
|
469
|
+
authorizationId: mcpAuthorizationId({
|
|
470
|
+
provider,
|
|
471
|
+
sessionId: authSession.sessionId
|
|
472
|
+
}),
|
|
473
|
+
turnId: authSession.sessionId,
|
|
474
|
+
...providerLabel ? { providerLabel } : void 0
|
|
475
|
+
});
|
|
476
|
+
} catch (error) {
|
|
477
|
+
logException(error, "mcp.oauth_callback.authentication_event.failed", { "app.credential.provider": provider });
|
|
478
|
+
}
|
|
479
|
+
try {
|
|
480
|
+
await deleteMcpAuthSession(authSession.authSessionId);
|
|
481
|
+
} catch (cleanupError) {
|
|
482
|
+
logException(cleanupError, "mcp.oauth_callback.session_cleanup.failed", { "app.credential.provider": provider });
|
|
483
|
+
}
|
|
484
|
+
if (authSession.source?.kind === "web" && authSession.destination) waitUntil(async () => {
|
|
485
|
+
const turn = await getTurnRecord(authSession.conversationId, authSession.sessionId);
|
|
486
|
+
await deleteWebAuthorization({
|
|
487
|
+
actorId: authSession.userId,
|
|
488
|
+
conversationId: authSession.conversationId
|
|
489
|
+
});
|
|
490
|
+
if (!turn || turn.state !== "paused" || turn.resumeReason !== "auth") return;
|
|
491
|
+
await recordAuthorizationCompleted({
|
|
492
|
+
conversationId: authSession.conversationId,
|
|
493
|
+
kind: "mcp",
|
|
494
|
+
provider,
|
|
495
|
+
actorId: authSession.userId,
|
|
496
|
+
authorizationId: mcpAuthorizationId({
|
|
497
|
+
provider,
|
|
498
|
+
sessionId: authSession.sessionId
|
|
499
|
+
})
|
|
500
|
+
});
|
|
501
|
+
await wakePausedTurn({
|
|
502
|
+
conversationId: authSession.conversationId,
|
|
503
|
+
destination: authSession.destination,
|
|
504
|
+
turnId: authSession.sessionId,
|
|
505
|
+
expectedVersion: turn.version
|
|
506
|
+
}, options.conversationWorkQueue ? { queue: options.conversationWorkQueue } : void 0);
|
|
507
|
+
});
|
|
508
|
+
else if (authSession.destination?.platform !== "local") await wakeAuthorizedMcpTurn({
|
|
509
|
+
authSession,
|
|
510
|
+
queue: options.conversationWorkQueue,
|
|
511
|
+
provider
|
|
512
|
+
});
|
|
513
|
+
return htmlResponse("success", { local: authSession.destination?.platform === "local" && authSession.source?.kind !== "web" });
|
|
514
|
+
} catch (callbackError) {
|
|
515
|
+
if (callbackError instanceof McpOAuthAttemptExpiredError) {
|
|
516
|
+
await deleteMcpAuthSession(state);
|
|
517
|
+
return htmlResponse("expired");
|
|
518
|
+
}
|
|
519
|
+
logException(callbackError, "mcp.oauth_callback.failed", {
|
|
520
|
+
"app.credential.provider": provider,
|
|
521
|
+
...getMcpProviderErrorAttributes(callbackError)
|
|
522
|
+
});
|
|
523
|
+
return htmlResponse("failure");
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
//#endregion
|
|
527
|
+
//#region src/chat/slack/app-home.ts
|
|
528
|
+
const DEFAULT_DESCRIPTION_TEXT = "I help your team investigate, summarize, and act on work in Slack.";
|
|
529
|
+
const MAX_HOME_SKILLS = 6;
|
|
530
|
+
const MAX_SECTION_TEXT_CHARS = 3e3;
|
|
531
|
+
const HIDDEN_HOME_SKILLS = /* @__PURE__ */ new Set(["jr-rpc"]);
|
|
532
|
+
function clampSectionText(text) {
|
|
533
|
+
if (text.length <= MAX_SECTION_TEXT_CHARS) return text;
|
|
534
|
+
return `${text.slice(0, 2999)}…`;
|
|
535
|
+
}
|
|
536
|
+
function loadDescriptionText() {
|
|
537
|
+
const descriptionPath = path.join(homeDir(), "DESCRIPTION.md");
|
|
538
|
+
try {
|
|
539
|
+
const raw = fs.readFileSync(descriptionPath, "utf8").trim();
|
|
540
|
+
if (raw.length > 0) return clampSectionText(raw);
|
|
541
|
+
} catch {}
|
|
542
|
+
return DEFAULT_DESCRIPTION_TEXT;
|
|
543
|
+
}
|
|
544
|
+
async function buildSkillsSummaryText() {
|
|
545
|
+
const skills = (await discoverSkills()).filter((skill) => !HIDDEN_HOME_SKILLS.has(skill.name));
|
|
546
|
+
if (skills.length === 0) return "No skills installed.";
|
|
547
|
+
const visible = skills.slice(0, MAX_HOME_SKILLS);
|
|
548
|
+
const lines = visible.map((skill) => `• *${skill.name}* — ${skill.description}`);
|
|
549
|
+
if (skills.length > visible.length) lines.push(`• …and ${skills.length - visible.length} more`);
|
|
550
|
+
return lines.join("\n");
|
|
551
|
+
}
|
|
552
|
+
function accountLabel(account) {
|
|
553
|
+
const label = account.label ?? account.id;
|
|
554
|
+
return account.url ? `<${account.url}|${label}>` : label;
|
|
555
|
+
}
|
|
556
|
+
function connectedAccountText(plugin, account) {
|
|
557
|
+
return account ? `*${plugin.manifest.name}*\nConnected as ${accountLabel(account)}` : `*${plugin.manifest.name}*\n${plugin.manifest.description}`;
|
|
558
|
+
}
|
|
559
|
+
async function connectedOAuthTokens(userId, plugin, userTokenStore) {
|
|
560
|
+
if (plugin.manifest.oauth || plugin.manifest.credentials) {
|
|
561
|
+
const stored = await userTokenStore.get(userId, plugin.manifest.name);
|
|
562
|
+
return stored && hasRequiredOAuthScope(stored.scope, plugin.manifest.oauth?.scope) ? stored : void 0;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
async function hasConnectedMcpAccount(userId, plugin) {
|
|
566
|
+
if (!plugin.manifest.mcp) return false;
|
|
567
|
+
const credentials = await getMcpStoredOAuthCredentials(userId, plugin.manifest.name);
|
|
568
|
+
return Boolean(credentials?.tokens || credentials?.clientInformation || credentials?.discoveryState);
|
|
569
|
+
}
|
|
570
|
+
/** Build the Slack App Home tab view showing skills, connected accounts, and version. */
|
|
571
|
+
async function buildHomeView(userId, userTokenStore) {
|
|
572
|
+
const runtimeMetadata = getRuntimeMetadata();
|
|
573
|
+
const descriptionText = loadDescriptionText();
|
|
574
|
+
const skillsSummaryText = await buildSkillsSummaryText();
|
|
575
|
+
const providers = pluginCatalogRuntime.getProviders();
|
|
576
|
+
const connectedSections = [];
|
|
577
|
+
for (const plugin of providers) {
|
|
578
|
+
const tokens = await connectedOAuthTokens(userId, plugin, userTokenStore);
|
|
579
|
+
if (!tokens && !await hasConnectedMcpAccount(userId, plugin)) continue;
|
|
580
|
+
connectedSections.push({
|
|
581
|
+
type: "section",
|
|
582
|
+
text: {
|
|
583
|
+
type: "mrkdwn",
|
|
584
|
+
text: connectedAccountText(plugin, tokens?.account)
|
|
585
|
+
},
|
|
586
|
+
accessory: {
|
|
587
|
+
type: "button",
|
|
588
|
+
text: {
|
|
589
|
+
type: "plain_text",
|
|
590
|
+
text: "Unlink"
|
|
591
|
+
},
|
|
592
|
+
action_id: "app_home_disconnect",
|
|
593
|
+
value: plugin.manifest.name,
|
|
594
|
+
style: "danger"
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
const accountBlocks = connectedSections.length > 0 ? connectedSections : [{
|
|
599
|
+
type: "section",
|
|
600
|
+
text: {
|
|
601
|
+
type: "mrkdwn",
|
|
602
|
+
text: "No connected accounts"
|
|
603
|
+
}
|
|
604
|
+
}];
|
|
605
|
+
return {
|
|
606
|
+
type: "home",
|
|
607
|
+
blocks: [
|
|
608
|
+
{
|
|
609
|
+
type: "header",
|
|
610
|
+
text: {
|
|
611
|
+
type: "plain_text",
|
|
612
|
+
text: "Junior"
|
|
613
|
+
}
|
|
614
|
+
},
|
|
615
|
+
{
|
|
616
|
+
type: "section",
|
|
617
|
+
text: {
|
|
618
|
+
type: "mrkdwn",
|
|
619
|
+
text: descriptionText
|
|
620
|
+
}
|
|
621
|
+
},
|
|
622
|
+
{ type: "divider" },
|
|
623
|
+
{
|
|
624
|
+
type: "header",
|
|
625
|
+
text: {
|
|
626
|
+
type: "plain_text",
|
|
627
|
+
text: "What I can help with"
|
|
628
|
+
}
|
|
629
|
+
},
|
|
630
|
+
{
|
|
631
|
+
type: "section",
|
|
632
|
+
text: {
|
|
633
|
+
type: "mrkdwn",
|
|
634
|
+
text: skillsSummaryText
|
|
635
|
+
}
|
|
636
|
+
},
|
|
637
|
+
{ type: "divider" },
|
|
638
|
+
{
|
|
639
|
+
type: "header",
|
|
640
|
+
text: {
|
|
641
|
+
type: "plain_text",
|
|
642
|
+
text: "Connected accounts"
|
|
643
|
+
}
|
|
644
|
+
},
|
|
645
|
+
...accountBlocks,
|
|
646
|
+
{
|
|
647
|
+
type: "context",
|
|
648
|
+
elements: [{
|
|
649
|
+
type: "mrkdwn",
|
|
650
|
+
text: `*junior version:* \`${runtimeMetadata.version ?? "unknown"}\``
|
|
651
|
+
}]
|
|
652
|
+
}
|
|
653
|
+
]
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
/** Publish the App Home view to a specific Slack user. */
|
|
657
|
+
async function publishAppHomeView(slackClient, userId, userTokenStore) {
|
|
658
|
+
const view = await buildHomeView(userId, userTokenStore);
|
|
659
|
+
await slackClient.views.publish({
|
|
660
|
+
user_id: userId,
|
|
661
|
+
view
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
//#endregion
|
|
665
|
+
//#region src/handlers/oauth-callback.ts
|
|
666
|
+
/**
|
|
667
|
+
* OAuth callbacks exchange and store credentials. Public signed callbacks
|
|
668
|
+
* relay to the owning CLI, which controls local continuation. Slack callbacks
|
|
669
|
+
* save readiness and wake the conversation worker. They do not run the agent
|
|
670
|
+
* or change its history.
|
|
671
|
+
*/
|
|
672
|
+
function htmlErrorResponse(title, message, status) {
|
|
673
|
+
return htmlCallbackResponse(escapeXml(title), escapeXml(message), status);
|
|
674
|
+
}
|
|
675
|
+
function oauthTokenErrorAttributes(provider, endpoint, status) {
|
|
676
|
+
return {
|
|
677
|
+
"app.credential.provider": provider,
|
|
678
|
+
"app.oauth.error.phase": "token_exchange",
|
|
679
|
+
"server.address": new URL(endpoint).hostname,
|
|
680
|
+
...status !== void 0 ? { "http.response.status_code": status } : void 0
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
function pluginAuthorizationId(args) {
|
|
684
|
+
return `${args.sessionId}:plugin:${args.provider}`;
|
|
685
|
+
}
|
|
686
|
+
/** Queue the pending Turn, including a newer Turn that reused this auth link. */
|
|
687
|
+
async function wakeAuthorizedPluginTurn(stored, options) {
|
|
688
|
+
if (!stored.resumeConversationId || !stored.resumeSessionId || !stored.destination) return;
|
|
689
|
+
const conversation = coerceThreadConversationState(await getPersistedThreadState(stored.resumeConversationId));
|
|
690
|
+
const turnId = getConversationPendingAuth({
|
|
691
|
+
conversation,
|
|
692
|
+
kind: "plugin",
|
|
693
|
+
provider: stored.provider,
|
|
694
|
+
actorId: stored.userId,
|
|
695
|
+
...stored.scope ? { scope: stored.scope } : void 0
|
|
696
|
+
})?.sessionId ?? stored.resumeSessionId;
|
|
697
|
+
await wakeAuthorizedTurn({
|
|
698
|
+
conversationId: stored.resumeConversationId,
|
|
699
|
+
destination: stored.destination,
|
|
700
|
+
turnId,
|
|
701
|
+
kind: "plugin",
|
|
702
|
+
provider: stored.provider,
|
|
703
|
+
actorId: stored.userId,
|
|
704
|
+
scope: stored.scope,
|
|
705
|
+
authorizationId: pluginAuthorizationId({
|
|
706
|
+
provider: stored.provider,
|
|
707
|
+
sessionId: turnId
|
|
708
|
+
})
|
|
709
|
+
}, options.conversationWorkQueue);
|
|
710
|
+
}
|
|
711
|
+
/** Complete provider authorization and queue any paused Slack Turn. */
|
|
712
|
+
async function GET(request, provider, waitUntil, options) {
|
|
713
|
+
const localRelay = await relayLocalOAuthCallback(request);
|
|
714
|
+
if (localRelay) return localRelay;
|
|
715
|
+
const providerConfig = pluginCatalogRuntime.getOAuthConfig(provider);
|
|
716
|
+
if (!providerConfig) return htmlErrorResponse("Unknown provider", "The OAuth provider in this link is not recognized.", 404);
|
|
717
|
+
const providerLabel = formatProviderLabel(provider);
|
|
718
|
+
const url = new URL(request.url);
|
|
719
|
+
const errorParam = url.searchParams.get("error");
|
|
720
|
+
const code = url.searchParams.get("code");
|
|
721
|
+
const state = url.searchParams.get("state");
|
|
722
|
+
if (errorParam) {
|
|
723
|
+
if (state) await getStateAdapter().delete(`oauth-state:${state}`);
|
|
724
|
+
if (errorParam === "access_denied") return htmlErrorResponse("Authorization declined", `You declined the ${providerLabel} authorization request. Return to Junior and connect your ${providerLabel} account again if you change your mind.`, 400);
|
|
725
|
+
return htmlErrorResponse("Authorization failed", `${providerLabel} returned an error: ${errorParam}. Return to Junior and try again.`, 400);
|
|
726
|
+
}
|
|
727
|
+
if (!code || !state) return htmlErrorResponse("Invalid request", "This authorization link is missing required parameters.", 400);
|
|
728
|
+
const stateAdapter = getStateAdapter();
|
|
729
|
+
const stateKey = `oauth-state:${state}`;
|
|
730
|
+
const stored = parseOAuthStatePayload(await stateAdapter.get(stateKey));
|
|
731
|
+
if (!stored) return htmlErrorResponse("Link expired", `This authorization link has expired (links are valid for 10 minutes). Return to Junior and connect your ${providerLabel} account again to get a new link.`, 400);
|
|
732
|
+
if (stored.provider !== provider) return htmlErrorResponse("Provider mismatch", "This authorization link does not match the expected provider.", 400);
|
|
733
|
+
await stateAdapter.delete(stateKey);
|
|
734
|
+
const clientId = process.env[providerConfig.clientIdEnv]?.trim();
|
|
735
|
+
const clientSecret = process.env[providerConfig.clientSecretEnv]?.trim();
|
|
736
|
+
if (!clientId || !clientSecret) return htmlErrorResponse("Configuration error", "OAuth client credentials are not configured on the server.", 500);
|
|
737
|
+
const baseUrl = resolveBaseUrl();
|
|
738
|
+
if (!baseUrl) return htmlErrorResponse("Configuration error", "The server cannot determine its base URL.", 500);
|
|
739
|
+
const redirectUri = `${baseUrl}${providerConfig.callbackPath}`;
|
|
740
|
+
const requestedScope = stored.scope ?? providerConfig.scope;
|
|
741
|
+
let tokenResponse;
|
|
742
|
+
try {
|
|
743
|
+
const tokenRequest = buildOAuthTokenRequest({
|
|
744
|
+
clientId,
|
|
745
|
+
clientSecret,
|
|
746
|
+
payload: {
|
|
747
|
+
grant_type: "authorization_code",
|
|
748
|
+
code,
|
|
749
|
+
redirect_uri: redirectUri
|
|
750
|
+
},
|
|
751
|
+
tokenAuthMethod: providerConfig.tokenAuthMethod,
|
|
752
|
+
tokenExtraHeaders: providerConfig.tokenExtraHeaders
|
|
753
|
+
});
|
|
754
|
+
tokenResponse = await fetch(providerConfig.tokenEndpoint, {
|
|
755
|
+
method: "POST",
|
|
756
|
+
headers: tokenRequest.headers,
|
|
757
|
+
body: tokenRequest.body
|
|
758
|
+
});
|
|
759
|
+
} catch {
|
|
760
|
+
logWarn("oauth.token_exchange.failed", oauthTokenErrorAttributes(provider, providerConfig.tokenEndpoint));
|
|
761
|
+
return htmlErrorResponse("Connection failed", "Failed to exchange the authorization code. Please try again.", 500);
|
|
762
|
+
}
|
|
763
|
+
if (!tokenResponse.ok) {
|
|
764
|
+
logWarn("oauth.token_exchange.failed", oauthTokenErrorAttributes(provider, providerConfig.tokenEndpoint, tokenResponse.status));
|
|
765
|
+
return htmlErrorResponse("Connection failed", "The token exchange with the provider failed. Please try again.", 500);
|
|
766
|
+
}
|
|
767
|
+
let parsedTokenResponse;
|
|
768
|
+
try {
|
|
769
|
+
const tokenData = await tokenResponse.json();
|
|
770
|
+
parsedTokenResponse = parseOAuthTokenResponse(tokenData, requestedScope, { treatEmptyScopeAsUnreported: providerConfig.treatEmptyScopeAsUnreported });
|
|
771
|
+
} catch {
|
|
772
|
+
logWarn("oauth.token_exchange.failed", oauthTokenErrorAttributes(provider, providerConfig.tokenEndpoint, tokenResponse.status));
|
|
773
|
+
return htmlErrorResponse("Connection failed", "The provider returned an incomplete token response. Please try again.", 500);
|
|
774
|
+
}
|
|
775
|
+
if (!hasRequiredOAuthScope(parsedTokenResponse.scope, requestedScope)) return htmlErrorResponse("Connection failed", `The ${providerLabel} authorization did not grant the access Junior requires. Return to Junior and connect your ${providerLabel} account again.`, 400);
|
|
776
|
+
const userTokenStore = createUserTokenStore();
|
|
777
|
+
let account;
|
|
778
|
+
try {
|
|
779
|
+
account = await resolvePluginOAuthAccount({
|
|
780
|
+
provider,
|
|
781
|
+
tokens: parsedTokenResponse
|
|
782
|
+
});
|
|
783
|
+
} catch {
|
|
784
|
+
return htmlErrorResponse("Connection failed", `Junior could not verify the connected ${providerLabel} account. Please try again.`, 500);
|
|
785
|
+
}
|
|
786
|
+
await userTokenStore.set(stored.userId, provider, {
|
|
787
|
+
...parsedTokenResponse,
|
|
788
|
+
...account ? { account } : void 0
|
|
789
|
+
});
|
|
790
|
+
const slackActor = stored.actor?.platform === "slack" ? stored.actor : void 0;
|
|
791
|
+
if (account && slackActor) await runBestEffort(async () => {
|
|
792
|
+
const slackUserId = parseSlackUserId(slackActor.userId);
|
|
793
|
+
if (!slackUserId) throw new Error("OAuth Slack actor user id is invalid");
|
|
794
|
+
const profile = await lookupSlackUserProfile(slackUserId);
|
|
795
|
+
const slackIdentity = await upsertIdentity(getSqlExecutor(), {
|
|
796
|
+
kind: "user",
|
|
797
|
+
provider: "slack",
|
|
798
|
+
providerTenantId: slackActor.teamId,
|
|
799
|
+
providerSubjectId: profile.id,
|
|
800
|
+
displayName: profile.real_name || profile.display_name,
|
|
801
|
+
handle: profile.name,
|
|
802
|
+
...profile.email ? {
|
|
803
|
+
email: profile.email,
|
|
804
|
+
emailVerified: true
|
|
805
|
+
} : void 0
|
|
806
|
+
});
|
|
807
|
+
if (!slackIdentity.userId) throw new Error("OAuth Slack identity is not linked to a user");
|
|
808
|
+
await upsertLinkedIdentity(getSqlExecutor(), slackIdentity.userId, {
|
|
809
|
+
kind: "user",
|
|
810
|
+
provider,
|
|
811
|
+
providerSubjectId: account.id,
|
|
812
|
+
displayName: account.displayName,
|
|
813
|
+
handle: account.handle ?? account.label
|
|
814
|
+
});
|
|
815
|
+
}, "oauth.callback.identity_link.failed", { "app.credential.provider": provider });
|
|
816
|
+
const resumeConversationId = stored.resumeConversationId;
|
|
817
|
+
if (resumeConversationId) await runBestEffort(async () => {
|
|
818
|
+
await recordAuthenticationLinked({
|
|
819
|
+
conversationId: resumeConversationId,
|
|
820
|
+
provider,
|
|
821
|
+
actorId: stored.userId,
|
|
822
|
+
providerLabel,
|
|
823
|
+
...account?.label ? { accountLabel: account.label } : void 0,
|
|
824
|
+
...stored.resumeSessionId ? {
|
|
825
|
+
authorizationId: pluginAuthorizationId({
|
|
826
|
+
provider,
|
|
827
|
+
sessionId: stored.resumeSessionId
|
|
828
|
+
}),
|
|
829
|
+
turnId: stored.resumeSessionId
|
|
830
|
+
} : void 0
|
|
831
|
+
});
|
|
832
|
+
}, "oauth.callback.authentication_event.failed", { "app.credential.provider": provider });
|
|
833
|
+
if (stored.destination?.platform !== "local" && stored.source?.kind !== "web") waitUntil(async () => {
|
|
834
|
+
try {
|
|
835
|
+
await publishAppHomeView(getSlackClient(), stored.userId, userTokenStore);
|
|
836
|
+
} catch {}
|
|
837
|
+
});
|
|
838
|
+
const resumesWebTurn = Boolean(stored.source?.kind === "web" && stored.destination && stored.resumeConversationId && stored.resumeSessionId);
|
|
839
|
+
const resumesAgentTurn = Boolean(stored.destination?.platform !== "local" && stored.resumeConversationId && stored.resumeSessionId);
|
|
840
|
+
if (resumesWebTurn) waitUntil(async () => {
|
|
841
|
+
const turn = await getTurnRecord(stored.resumeConversationId, stored.resumeSessionId);
|
|
842
|
+
await deleteWebAuthorization({
|
|
843
|
+
actorId: stored.userId,
|
|
844
|
+
conversationId: stored.resumeConversationId
|
|
845
|
+
});
|
|
846
|
+
if (!turn || turn.state !== "paused" || turn.resumeReason !== "auth") return;
|
|
847
|
+
await recordAuthorizationCompleted({
|
|
848
|
+
conversationId: stored.resumeConversationId,
|
|
849
|
+
kind: "plugin",
|
|
850
|
+
provider: stored.provider,
|
|
851
|
+
actorId: stored.userId,
|
|
852
|
+
authorizationId: pluginAuthorizationId({
|
|
853
|
+
provider: stored.provider,
|
|
854
|
+
sessionId: stored.resumeSessionId
|
|
855
|
+
})
|
|
856
|
+
});
|
|
857
|
+
await wakePausedTurn({
|
|
858
|
+
conversationId: stored.resumeConversationId,
|
|
859
|
+
destination: stored.destination,
|
|
860
|
+
turnId: stored.resumeSessionId,
|
|
861
|
+
expectedVersion: turn.version
|
|
862
|
+
}, options.conversationWorkQueue ? { queue: options.conversationWorkQueue } : void 0);
|
|
863
|
+
});
|
|
864
|
+
else if (resumesAgentTurn) await wakeAuthorizedPluginTurn(stored, options);
|
|
865
|
+
else if (stored.channelId && stored.threadTs) {
|
|
866
|
+
const { channelId, threadTs } = stored;
|
|
867
|
+
waitUntil(() => postSlackMessage({
|
|
868
|
+
channelId,
|
|
869
|
+
threadTs,
|
|
870
|
+
text: `Your ${providerLabel} account is now connected. You can start using ${providerLabel} commands.`
|
|
871
|
+
}));
|
|
872
|
+
}
|
|
873
|
+
const botName = botConfig.userName;
|
|
874
|
+
const statusMessage = stored.destination?.platform === "local" && !resumesWebTurn ? `Your request is continuing in the local ${botName} client.` : resumesWebTurn ? `Your request is continuing in ${botName}.` : resumesAgentTurn ? "Your request is being processed in Slack." : `Your ${providerLabel} account is connected.`;
|
|
875
|
+
const footerMessage = stored.destination?.platform === "local" || resumesWebTurn ? `You can close this tab and return to ${botName}.` : "You can close this tab and return to Slack.";
|
|
876
|
+
return htmlCallbackResponse(escapeXml(`${providerLabel} account connected`), escapeXml(statusMessage), 200, { footerMessage: escapeXml(footerMessage) });
|
|
877
|
+
}
|
|
878
|
+
//#endregion
|
|
879
|
+
//#region src/chat/local/credential-sync.ts
|
|
880
|
+
const LOCAL_CREDENTIAL_SYNC_CONTEXT = "junior.local-credential-sync.v1";
|
|
881
|
+
const LOCAL_CREDENTIAL_SYNC_MAX_AGE_MS = 6e4;
|
|
882
|
+
const localCredentialSyncSchema = z.object({
|
|
883
|
+
createdAtMs: z.number().finite(),
|
|
884
|
+
provider: z.string().regex(/^[a-z][a-z0-9-]*$/),
|
|
885
|
+
tokens: pluginStoredTokensSchema
|
|
886
|
+
}).strict();
|
|
887
|
+
function syncSecret() {
|
|
888
|
+
const secret = process.env.JUNIOR_SECRET?.trim();
|
|
889
|
+
if (!secret) throw new Error("Local credential sync requires JUNIOR_SECRET");
|
|
890
|
+
return secret;
|
|
891
|
+
}
|
|
892
|
+
/** Sign one local credential payload in its own HMAC domain. */
|
|
893
|
+
function signature(body) {
|
|
894
|
+
const signingKey = createSecretKey(Buffer.from(syncSecret(), "utf8"));
|
|
895
|
+
return createHmac("sha256", signingKey).update(`${LOCAL_CREDENTIAL_SYNC_CONTEXT}:${body}`).digest("base64url");
|
|
896
|
+
}
|
|
897
|
+
/** Compare credential signatures without leaking a matching prefix by timing. */
|
|
898
|
+
function signaturesMatch(expected, actual) {
|
|
899
|
+
const expectedBuffer = Buffer.from(expected);
|
|
900
|
+
const actualBuffer = Buffer.from(actual);
|
|
901
|
+
return expectedBuffer.length === actualBuffer.length && timingSafeEqual(expectedBuffer, actualBuffer);
|
|
902
|
+
}
|
|
903
|
+
/** Copy a local user's provider credential to the loopback dev-server broker. */
|
|
904
|
+
async function syncLocalOAuthCredential(provider, tokens) {
|
|
905
|
+
const body = JSON.stringify({
|
|
906
|
+
createdAtMs: Date.now(),
|
|
907
|
+
provider,
|
|
908
|
+
tokens
|
|
909
|
+
});
|
|
910
|
+
const port = process.env.PORT?.trim() || "3000";
|
|
911
|
+
const response = await fetch(`http://127.0.0.1:${port}/api/internal/local-oauth-credentials`, {
|
|
912
|
+
method: "POST",
|
|
913
|
+
headers: {
|
|
914
|
+
"content-type": "application/json",
|
|
915
|
+
"x-junior-local-credential-signature": signature(body)
|
|
916
|
+
},
|
|
917
|
+
body
|
|
918
|
+
});
|
|
919
|
+
if (!response.ok) throw new Error(`Could not sync local OAuth credential: HTTP ${response.status}`);
|
|
920
|
+
}
|
|
921
|
+
/** Verify and store one credential sent by the local CLI over loopback. */
|
|
922
|
+
async function receiveLocalOAuthCredential(request) {
|
|
923
|
+
const hostname = new URL(request.url).hostname;
|
|
924
|
+
if (process.env.NODE_ENV !== "development" || hostname !== "127.0.0.1" && hostname !== "localhost") return new Response(null, { status: 404 });
|
|
925
|
+
const body = await request.text();
|
|
926
|
+
const actualSignature = request.headers.get("x-junior-local-credential-signature");
|
|
927
|
+
if (!actualSignature || !signaturesMatch(signature(body), actualSignature)) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
928
|
+
let value;
|
|
929
|
+
try {
|
|
930
|
+
value = JSON.parse(body);
|
|
931
|
+
} catch {
|
|
932
|
+
return Response.json({ error: "Invalid request" }, { status: 400 });
|
|
933
|
+
}
|
|
934
|
+
const payload = localCredentialSyncSchema.safeParse(value);
|
|
935
|
+
if (!payload.success || Math.abs(Date.now() - payload.data.createdAtMs) > LOCAL_CREDENTIAL_SYNC_MAX_AGE_MS) return Response.json({ error: "Invalid request" }, { status: 400 });
|
|
936
|
+
await createUserTokenStore().set("local-cli", payload.data.provider, payload.data.tokens);
|
|
937
|
+
return new Response(null, { status: 204 });
|
|
938
|
+
}
|
|
939
|
+
//#endregion
|
|
940
|
+
export { GET$1 as a, wakePausedTurn as c, publishAppHomeView as i, getTurnAuthorization as l, syncLocalOAuthCredential as n, createPausedTurns as o, GET as r, getPausedTurnRequest as s, receiveLocalOAuthCredential as t };
|