@opengeni/api-router 0.11.2 → 0.12.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/app.d.ts +3 -2
- package/dist/app.js +3 -1
- package/dist/{chunk-BFWSDESE.js → chunk-S2N4252E.js} +3731 -2009
- package/dist/chunk-S2N4252E.js.map +1 -0
- package/dist/index.js +21 -3
- package/dist/index.js.map +1 -1
- package/package.json +10 -10
- package/src/app.ts +142 -26
- package/src/github-access.ts +117 -9
- package/src/github-browser-flow.ts +4 -4
- package/src/http/auth.ts +6 -4
- package/src/index.ts +20 -1
- package/src/integrations/slack-bot.ts +694 -0
- package/src/mcp/documents.ts +19 -5
- package/src/mcp/server.ts +158 -12
- package/src/routes/connections.ts +155 -1
- package/src/routes/documents.ts +191 -9
- package/src/routes/files.ts +1 -1
- package/src/routes/github.ts +331 -23
- package/src/routes/sessions.ts +37 -0
- package/src/routes/workspace-instruction-policies.ts +243 -0
- package/src/sandbox/channel-a.ts +131 -70
- package/dist/chunk-BFWSDESE.js.map +0 -1
package/src/routes/github.ts
CHANGED
|
@@ -1,32 +1,52 @@
|
|
|
1
|
-
import { GitHubAppManifestCreate } from "@opengeni/contracts";
|
|
2
|
-
import { deleteGitHubInstallationBinding } from "@opengeni/db";
|
|
3
1
|
import {
|
|
2
|
+
GitHubAppManifestCreate,
|
|
3
|
+
type AccessGrant,
|
|
4
|
+
type GitHubInstallationBindingProof,
|
|
5
|
+
} from "@opengeni/contracts";
|
|
6
|
+
import {
|
|
7
|
+
bindAuthorizedGitHubInstallationRepositories,
|
|
8
|
+
deleteGitHubInstallationBinding,
|
|
9
|
+
GitHubInstallationAuthorityCommitError,
|
|
10
|
+
} from "@opengeni/db";
|
|
11
|
+
import {
|
|
12
|
+
authorizeGitHubInstallationBinding,
|
|
4
13
|
buildGitHubAppManifest,
|
|
5
14
|
convertGitHubAppManifest,
|
|
6
15
|
createSignedState,
|
|
7
16
|
envLinesFromGitHubManifestConversion,
|
|
8
17
|
GitHubAppApiError,
|
|
9
18
|
GitHubAppConfigurationError,
|
|
19
|
+
GitHubInstallationAuthorityError,
|
|
10
20
|
githubAppMissingSettings,
|
|
21
|
+
githubOAuthAuthorizeUrl,
|
|
11
22
|
organizationAppManifestUrl,
|
|
12
23
|
personalAppManifestUrl,
|
|
13
24
|
readSignedState,
|
|
14
25
|
stateMaxAgeSeconds,
|
|
26
|
+
type GitHubSignedStatePayload,
|
|
15
27
|
verifySignedState,
|
|
16
28
|
} from "@opengeni/github";
|
|
17
29
|
import type { Context, Hono } from "hono";
|
|
18
|
-
import { setCookie } from "hono/cookie";
|
|
30
|
+
import { deleteCookie, setCookie } from "hono/cookie";
|
|
19
31
|
import { HTTPException } from "hono/http-exception";
|
|
20
|
-
import { requireAccessGrant } from "@opengeni/core";
|
|
32
|
+
import { hasPermission, requireAccessGrant } from "@opengeni/core";
|
|
21
33
|
import type { ApiRouteDeps } from "@opengeni/core";
|
|
22
34
|
import {
|
|
35
|
+
continuedGitHubBrowserGrantClaims,
|
|
36
|
+
githubBrowserBaseUrl,
|
|
37
|
+
githubBrowserGrantClaims,
|
|
38
|
+
githubBrowserGrantFromState,
|
|
39
|
+
} from "../github-browser-flow";
|
|
40
|
+
import {
|
|
41
|
+
githubBindingStatus,
|
|
23
42
|
listWorkspaceGitHubInstallationBindings,
|
|
24
43
|
listWorkspaceGitHubRepositories,
|
|
25
44
|
} from "../github-access";
|
|
26
45
|
|
|
27
46
|
const githubStateCookie = "opengeni_github_state";
|
|
28
|
-
const
|
|
29
|
-
|
|
47
|
+
const githubBindingStateMaxAgeSeconds = 10 * 60;
|
|
48
|
+
const legacyInstallationChooserDisabledMessage =
|
|
49
|
+
"The legacy repository-admin GitHub installation chooser is disabled; use the GitHub owner-consent connect flow";
|
|
30
50
|
|
|
31
51
|
export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
32
52
|
const { db, settings, githubStateSecret } = deps;
|
|
@@ -36,23 +56,41 @@ export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
36
56
|
const grant = await requireAccessGrant(c, deps, workspaceId, "github:use");
|
|
37
57
|
const missing = githubAppMissingSettings(settings);
|
|
38
58
|
const slug = settings.githubAppSlug?.trim() || null;
|
|
59
|
+
const installations =
|
|
60
|
+
missing.length === 0
|
|
61
|
+
? await listWorkspaceGitHubInstallationBindings(deps, grant.workspaceId)
|
|
62
|
+
: [];
|
|
63
|
+
const status = githubBindingStatus(missing.length === 0, installations);
|
|
64
|
+
const canManage = hasPermission(grant.permissions, "github:manage");
|
|
65
|
+
const connectState =
|
|
66
|
+
missing.length === 0 && slug && canManage
|
|
67
|
+
? createSignedState(githubStateSecret, {
|
|
68
|
+
accountId: grant.accountId,
|
|
69
|
+
workspaceId: grant.workspaceId,
|
|
70
|
+
intent: "installation_authority",
|
|
71
|
+
...githubBrowserGrantClaims(settings, grant),
|
|
72
|
+
})
|
|
73
|
+
: null;
|
|
74
|
+
const connectUrl = connectState
|
|
75
|
+
? `${openGeniBaseUrl(settings, c)}/v1/workspaces/${grant.workspaceId}/github/connect?state=${encodeURIComponent(connectState)}`
|
|
76
|
+
: null;
|
|
39
77
|
return c.json({
|
|
40
78
|
configured: missing.length === 0,
|
|
79
|
+
status,
|
|
41
80
|
appId: settings.githubAppId ?? null,
|
|
42
81
|
clientId: settings.githubClientId ?? null,
|
|
43
82
|
appSlug: slug,
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
installUrl: null,
|
|
48
|
-
linkUrl: null,
|
|
49
|
-
installations: await listWorkspaceGitHubInstallationBindings(deps, grant.workspaceId),
|
|
83
|
+
installUrl: connectUrl,
|
|
84
|
+
linkUrl: connectUrl,
|
|
85
|
+
installations,
|
|
50
86
|
missing,
|
|
51
87
|
});
|
|
52
88
|
});
|
|
53
89
|
|
|
54
|
-
//
|
|
55
|
-
//
|
|
90
|
+
// The signed state is a short browser handoff minted only for an OpenGeni
|
|
91
|
+
// github:manage grant. GitHub remains responsible for installation/config
|
|
92
|
+
// consent; the later OAuth callback independently proves current owner
|
|
93
|
+
// authority before any workspace binding write.
|
|
56
94
|
app.get("/v1/workspaces/:workspaceId/github/connect", async (c) => {
|
|
57
95
|
const workspaceId = c.req.param("workspaceId");
|
|
58
96
|
const state = c.req.query("state");
|
|
@@ -60,10 +98,28 @@ export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
60
98
|
throw new HTTPException(400, { message: "missing GitHub installation state" });
|
|
61
99
|
}
|
|
62
100
|
const statePayload = readSignedState(state, githubStateSecret);
|
|
63
|
-
if (
|
|
101
|
+
if (
|
|
102
|
+
!statePayload ||
|
|
103
|
+
statePayload.intent !== "installation_authority" ||
|
|
104
|
+
statePayload.workspaceId !== workspaceId ||
|
|
105
|
+
typeof statePayload.accountId !== "string" ||
|
|
106
|
+
!isFreshGitHubBindingState(statePayload)
|
|
107
|
+
) {
|
|
64
108
|
throw new HTTPException(400, { message: "invalid or expired GitHub installation state" });
|
|
65
109
|
}
|
|
66
|
-
|
|
110
|
+
const slug = settings.githubAppSlug?.trim();
|
|
111
|
+
if (!slug || githubAppMissingSettings(settings).length > 0) {
|
|
112
|
+
throw new HTTPException(409, {
|
|
113
|
+
message: JSON.stringify({
|
|
114
|
+
message: "GitHub App is not configured",
|
|
115
|
+
missing: githubAppMissingSettings(settings),
|
|
116
|
+
}),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
setGitHubStateCookie(c, deps, state);
|
|
120
|
+
return c.redirect(
|
|
121
|
+
`https://github.com/apps/${slug}/installations/new?state=${encodeURIComponent(state)}`,
|
|
122
|
+
);
|
|
67
123
|
});
|
|
68
124
|
|
|
69
125
|
app.get("/v1/workspaces/:workspaceId/github/repositories", async (c) => {
|
|
@@ -177,31 +233,153 @@ export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
177
233
|
const statePayload = readSignedState(state, githubStateSecret);
|
|
178
234
|
if (
|
|
179
235
|
!statePayload ||
|
|
236
|
+
statePayload.intent !== "installation_authority" ||
|
|
180
237
|
typeof statePayload.accountId !== "string" ||
|
|
181
|
-
typeof statePayload.workspaceId !== "string"
|
|
238
|
+
typeof statePayload.workspaceId !== "string" ||
|
|
239
|
+
!isFreshGitHubBindingState(statePayload)
|
|
182
240
|
) {
|
|
183
241
|
throw new HTTPException(400, { message: "invalid or expired GitHub installation state" });
|
|
184
242
|
}
|
|
185
|
-
|
|
243
|
+
requireGitHubStateCookie(c, state);
|
|
244
|
+
const grant = await requireGitHubManageGrant(c, deps, statePayload.workspaceId, statePayload);
|
|
245
|
+
if (grant.accountId !== statePayload.accountId) {
|
|
246
|
+
throw new HTTPException(403, {
|
|
247
|
+
message: "GitHub installation state does not match this workspace",
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
const setupAction = c.req.query("setup_action");
|
|
251
|
+
if (setupAction === "request") {
|
|
252
|
+
return c.html(githubSetupPendingHtml());
|
|
253
|
+
}
|
|
254
|
+
if (setupAction !== "install" && setupAction !== "update") {
|
|
255
|
+
throw new HTTPException(400, { message: "unsupported GitHub setup action" });
|
|
256
|
+
}
|
|
257
|
+
const installationId = parsePositiveInteger(c.req.query("installation_id"));
|
|
258
|
+
if (installationId === null) {
|
|
259
|
+
throw new HTTPException(400, { message: "missing or invalid GitHub installation_id" });
|
|
260
|
+
}
|
|
261
|
+
const clientId = settings.githubClientId?.trim();
|
|
262
|
+
if (!clientId) {
|
|
263
|
+
throw new HTTPException(409, {
|
|
264
|
+
message: JSON.stringify({
|
|
265
|
+
message: "GitHub App is not configured",
|
|
266
|
+
missing: ["OPENGENI_GITHUB_CLIENT_ID"],
|
|
267
|
+
}),
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
const oauthState = createSignedState(githubStateSecret, {
|
|
271
|
+
accountId: grant.accountId,
|
|
272
|
+
workspaceId: grant.workspaceId,
|
|
273
|
+
installationId,
|
|
274
|
+
intent: "installation_authority_oauth",
|
|
275
|
+
...continuedGitHubBrowserGrantClaims(statePayload),
|
|
276
|
+
});
|
|
277
|
+
setGitHubStateCookie(c, deps, oauthState);
|
|
278
|
+
return c.redirect(
|
|
279
|
+
githubOAuthAuthorizeUrl({
|
|
280
|
+
clientId,
|
|
281
|
+
state: oauthState,
|
|
282
|
+
redirectUri: `${openGeniBaseUrl(settings, c)}/v1/github/oauth/callback`,
|
|
283
|
+
}),
|
|
284
|
+
);
|
|
186
285
|
};
|
|
187
286
|
|
|
188
287
|
app.get("/v1/github/setup", handleGitHubInstallCallback);
|
|
189
288
|
app.get("/v1/github/install/callback", handleGitHubInstallCallback);
|
|
190
289
|
|
|
191
290
|
app.get("/v1/github/oauth/callback", async (c) => {
|
|
291
|
+
const code = c.req.query("code");
|
|
192
292
|
const state = c.req.query("state");
|
|
293
|
+
if (!code) {
|
|
294
|
+
throw new HTTPException(400, { message: "missing GitHub OAuth code" });
|
|
295
|
+
}
|
|
193
296
|
if (!state) {
|
|
194
297
|
throw new HTTPException(400, { message: "missing GitHub OAuth state" });
|
|
195
298
|
}
|
|
196
299
|
const statePayload = readSignedState(state, githubStateSecret);
|
|
197
300
|
if (
|
|
198
301
|
!statePayload ||
|
|
302
|
+
statePayload.intent !== "installation_authority_oauth" ||
|
|
199
303
|
typeof statePayload.accountId !== "string" ||
|
|
200
|
-
typeof statePayload.workspaceId !== "string"
|
|
304
|
+
typeof statePayload.workspaceId !== "string" ||
|
|
305
|
+
!isFreshGitHubBindingState(statePayload)
|
|
201
306
|
) {
|
|
202
307
|
throw new HTTPException(400, { message: "invalid or expired GitHub OAuth state" });
|
|
203
308
|
}
|
|
204
|
-
|
|
309
|
+
const installationId = parsePositiveInteger(String(statePayload.installationId ?? ""));
|
|
310
|
+
if (installationId === null) {
|
|
311
|
+
throw new HTTPException(400, { message: "invalid GitHub installation id" });
|
|
312
|
+
}
|
|
313
|
+
requireGitHubStateCookie(c, state);
|
|
314
|
+
const grant = await requireGitHubManageGrant(c, deps, statePayload.workspaceId, statePayload);
|
|
315
|
+
if (grant.accountId !== statePayload.accountId) {
|
|
316
|
+
throw new HTTPException(403, {
|
|
317
|
+
message: "GitHub OAuth state does not match this workspace",
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
let proof;
|
|
321
|
+
try {
|
|
322
|
+
proof = deps.githubAppApi?.authorizeInstallationBinding
|
|
323
|
+
? await deps.githubAppApi.authorizeInstallationBinding({ code, installationId })
|
|
324
|
+
: deps.githubAppApi
|
|
325
|
+
? null
|
|
326
|
+
: await authorizeGitHubInstallationBinding(settings, { code, installationId });
|
|
327
|
+
} catch (error) {
|
|
328
|
+
throw githubAuthorityHttpError(error);
|
|
329
|
+
}
|
|
330
|
+
if (!proof) {
|
|
331
|
+
throw new HTTPException(409, {
|
|
332
|
+
message:
|
|
333
|
+
"The configured GitHub provider cannot prove personal-owner or organization-owner authority",
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
if (!isConsistentGitHubBindingProof(proof, installationId)) {
|
|
337
|
+
throw new HTTPException(409, { message: "GitHub installation proof is stale or invalid" });
|
|
338
|
+
}
|
|
339
|
+
const repositoryIds = [...new Set(proof.repositories.map((repository) => repository.id))];
|
|
340
|
+
if (repositoryIds.length !== proof.repositories.length) {
|
|
341
|
+
throw new HTTPException(409, { message: "GitHub returned duplicate repository identities" });
|
|
342
|
+
}
|
|
343
|
+
// The provider contract revalidates organization ownership after its final
|
|
344
|
+
// repository read, so this commit-near timestamp records that live check.
|
|
345
|
+
const authorityCheckedAt = new Date();
|
|
346
|
+
const expiresAt = new Date((statePayload.iat + githubBindingStateMaxAgeSeconds) * 1_000);
|
|
347
|
+
let bound;
|
|
348
|
+
try {
|
|
349
|
+
bound = await bindAuthorizedGitHubInstallationRepositories(db, {
|
|
350
|
+
accountId: grant.accountId,
|
|
351
|
+
workspaceId: grant.workspaceId,
|
|
352
|
+
installationId,
|
|
353
|
+
githubAccountId: proof.installation.accountId,
|
|
354
|
+
accountLogin: proof.installation.accountLogin,
|
|
355
|
+
accountType: proof.installation.accountType,
|
|
356
|
+
linkedBySubjectId: grant.subjectId,
|
|
357
|
+
githubActorId: proof.actorId,
|
|
358
|
+
githubActorLogin: proof.actorLogin,
|
|
359
|
+
authorityKind: proof.authorityKind,
|
|
360
|
+
authorityCheckedAt,
|
|
361
|
+
authorityExpiresAt: expiresAt,
|
|
362
|
+
authorityNonce: statePayload.nonce,
|
|
363
|
+
repositoryIds,
|
|
364
|
+
});
|
|
365
|
+
} catch (error) {
|
|
366
|
+
if (error instanceof GitHubInstallationAuthorityCommitError) {
|
|
367
|
+
throw new HTTPException(409, { message: error.message });
|
|
368
|
+
}
|
|
369
|
+
throw error;
|
|
370
|
+
}
|
|
371
|
+
if (!bound) {
|
|
372
|
+
throw new HTTPException(409, {
|
|
373
|
+
message: "GitHub installation authorization was already used",
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
deleteCookie(c, githubStateCookie, { path: "/v1" });
|
|
377
|
+
return c.html(
|
|
378
|
+
githubSetupSuccessHtml(
|
|
379
|
+
proof.installation.accountLogin ?? `installation ${installationId}`,
|
|
380
|
+
openGeniReturnUrl(settings, c, grant.workspaceId),
|
|
381
|
+
),
|
|
382
|
+
);
|
|
205
383
|
});
|
|
206
384
|
|
|
207
385
|
app.post("/v1/workspaces/:workspaceId/github/installations", async (c) => {
|
|
@@ -220,12 +398,12 @@ export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
220
398
|
) {
|
|
221
399
|
throw new HTTPException(400, { message: "invalid or expired GitHub OAuth state" });
|
|
222
400
|
}
|
|
223
|
-
throw
|
|
401
|
+
throw legacyInstallationChooserDisabled();
|
|
224
402
|
});
|
|
225
403
|
}
|
|
226
404
|
|
|
227
|
-
function
|
|
228
|
-
return new HTTPException(410, { message:
|
|
405
|
+
function legacyInstallationChooserDisabled(): HTTPException {
|
|
406
|
+
return new HTTPException(410, { message: legacyInstallationChooserDisabledMessage });
|
|
229
407
|
}
|
|
230
408
|
|
|
231
409
|
function setGitHubStateCookie(c: Context, deps: ApiRouteDeps, state: string): void {
|
|
@@ -238,6 +416,74 @@ function setGitHubStateCookie(c: Context, deps: ApiRouteDeps, state: string): vo
|
|
|
238
416
|
});
|
|
239
417
|
}
|
|
240
418
|
|
|
419
|
+
function requireGitHubStateCookie(c: Context, state: string): void {
|
|
420
|
+
if (!allCookieValues(c, githubStateCookie).includes(state)) {
|
|
421
|
+
throw new HTTPException(400, {
|
|
422
|
+
message: "invalid or expired GitHub installation browser state",
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function requireGitHubManageGrant(
|
|
428
|
+
c: Context,
|
|
429
|
+
deps: ApiRouteDeps,
|
|
430
|
+
workspaceId: string,
|
|
431
|
+
expectedState: GitHubSignedStatePayload,
|
|
432
|
+
): Promise<AccessGrant> {
|
|
433
|
+
try {
|
|
434
|
+
return await requireAccessGrant(c, deps, workspaceId, "github:manage");
|
|
435
|
+
} catch (error) {
|
|
436
|
+
if (!(error instanceof HTTPException) || error.status !== 401) {
|
|
437
|
+
throw error;
|
|
438
|
+
}
|
|
439
|
+
const grant = githubBrowserGrantFromState(deps.settings, expectedState, workspaceId);
|
|
440
|
+
if (grant) {
|
|
441
|
+
return grant;
|
|
442
|
+
}
|
|
443
|
+
throw error;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function allCookieValues(c: Context, name: string): string[] {
|
|
448
|
+
const prefix = `${name}=`;
|
|
449
|
+
return (c.req.header("cookie") ?? "")
|
|
450
|
+
.split(";")
|
|
451
|
+
.map((part) => part.trim())
|
|
452
|
+
.filter((part) => part.startsWith(prefix))
|
|
453
|
+
.map((part) => {
|
|
454
|
+
const raw = part.slice(prefix.length);
|
|
455
|
+
try {
|
|
456
|
+
return decodeURIComponent(raw);
|
|
457
|
+
} catch {
|
|
458
|
+
return raw;
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function githubAuthorityHttpError(error: unknown): HTTPException {
|
|
464
|
+
if (error instanceof HTTPException) {
|
|
465
|
+
return error;
|
|
466
|
+
}
|
|
467
|
+
if (error instanceof GitHubInstallationAuthorityError) {
|
|
468
|
+
if (error.reason === "authority_denied") {
|
|
469
|
+
return new HTTPException(403, { message: error.message });
|
|
470
|
+
}
|
|
471
|
+
if (error.reason === "installation_missing") {
|
|
472
|
+
return new HTTPException(404, { message: error.message });
|
|
473
|
+
}
|
|
474
|
+
return new HTTPException(409, { message: error.message });
|
|
475
|
+
}
|
|
476
|
+
if (error instanceof GitHubAppConfigurationError) {
|
|
477
|
+
return new HTTPException(409, {
|
|
478
|
+
message: JSON.stringify({ message: error.message, missing: error.missing }),
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
if (error instanceof GitHubAppApiError) {
|
|
482
|
+
return new HTTPException(502, { message: error.message });
|
|
483
|
+
}
|
|
484
|
+
return new HTTPException(502, { message: "GitHub authority verification failed" });
|
|
485
|
+
}
|
|
486
|
+
|
|
241
487
|
function isSecureRequest(c: Context, deps: ApiRouteDeps): boolean {
|
|
242
488
|
return (
|
|
243
489
|
deps.settings.publicBaseUrl?.startsWith("https://") ||
|
|
@@ -252,6 +498,14 @@ function githubSuccessHtml(envLines: string[]): string {
|
|
|
252
498
|
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>GitHub App Created</title><style>body{font-family:system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#0b0b0d;color:#f4f4f5}main{width:min(760px,calc(100vw - 32px));border:1px solid #27272a;border-radius:8px;padding:28px;background:#111114}h1{margin:0 0 10px;font-size:24px;line-height:1.2}p{margin:0 0 18px;color:#d4d4d8}.env-header{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:22px 0 8px}.env-header h2{margin:0;font-size:13px;line-height:1.2;text-transform:uppercase;letter-spacing:.08em;color:#a1a1aa}pre{white-space:pre-wrap;word-break:break-word;max-height:380px;overflow:auto;background:#09090b;border:1px solid #27272a;border-radius:8px;padding:16px;font-size:13px;line-height:1.5}button{display:inline-flex;align-items:center;justify-content:center;min-height:36px;border-radius:6px;border:1px solid #3f3f46;padding:0 12px;background:#f4f4f5;color:#09090b;font:600 14px system-ui,sans-serif;cursor:pointer}button:disabled{cursor:not-allowed;opacity:.7}</style></head><body><main><h1>GitHub App created</h1><p>Add these values to .env, then restart API and worker.</p><div class="env-header"><h2>Environment variables</h2><button id="copy-env" type="button">Copy env</button></div><pre id="env-lines">${escaped}</pre><script>(()=>{const button=document.getElementById("copy-env");const env=document.getElementById("env-lines");async function copyText(text){if(navigator.clipboard&&window.isSecureContext){await navigator.clipboard.writeText(text);return;}const area=document.createElement("textarea");area.value=text;area.setAttribute("readonly","");area.style.position="fixed";area.style.inset="-9999px";document.body.append(area);area.select();document.execCommand("copy");area.remove();}button?.addEventListener("click",async()=>{try{await copyText(env?.textContent||"");button.textContent="Copied";setTimeout(()=>button.textContent="Copy env",1600);}catch{button.textContent="Copy failed";setTimeout(()=>button.textContent="Copy env",2200);}});})();</script></main></body></html>`;
|
|
253
499
|
}
|
|
254
500
|
|
|
501
|
+
function githubSetupSuccessHtml(account: string, returnUrl: string): string {
|
|
502
|
+
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>GitHub App Connected</title><style>body{font-family:system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#0b0b0d;color:#f4f4f5}main{width:min(640px,calc(100vw - 32px));border:1px solid #27272a;border-radius:8px;padding:28px;background:#111114}h1{margin:0 0 10px;font-size:24px;line-height:1.2}p{margin:0 0 18px;color:#d4d4d8}.button{display:inline-flex;align-items:center;justify-content:center;min-height:36px;border-radius:6px;border:1px solid #3f3f46;padding:0 12px;background:#f4f4f5;color:#09090b;font:600 14px system-ui,sans-serif;text-decoration:none}</style></head><body><main><h1>GitHub App connected</h1><p>${escapeHtml(account)} is now available to this OpenGeni workspace through an explicit repository allowlist.</p><a class="button" href="${escapeHtml(returnUrl)}">Back to OpenGeni</a></main></body></html>`;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function githubSetupPendingHtml(): string {
|
|
506
|
+
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>GitHub App Requested</title><style>body{font-family:system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#0b0b0d;color:#f4f4f5}main{width:min(640px,calc(100vw - 32px));border:1px solid #27272a;border-radius:8px;padding:28px;background:#111114}h1{margin:0 0 10px;font-size:24px;line-height:1.2}p{margin:0;color:#d4d4d8}</style></head><body><main><h1>GitHub App request sent</h1><p>A GitHub organization owner must approve the installation. OpenGeni has not created a workspace binding.</p></main></body></html>`;
|
|
507
|
+
}
|
|
508
|
+
|
|
255
509
|
function parsePositiveInteger(value: string | undefined | null): number | null {
|
|
256
510
|
if (!value || !/^\d+$/.test(value)) {
|
|
257
511
|
return null;
|
|
@@ -260,6 +514,46 @@ function parsePositiveInteger(value: string | undefined | null): number | null {
|
|
|
260
514
|
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
|
|
261
515
|
}
|
|
262
516
|
|
|
517
|
+
function isFreshGitHubBindingState(payload: GitHubSignedStatePayload): boolean {
|
|
518
|
+
const age = Math.floor(Date.now() / 1_000) - payload.iat;
|
|
519
|
+
return age >= 0 && age < githubBindingStateMaxAgeSeconds;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function isConsistentGitHubBindingProof(
|
|
523
|
+
proof: GitHubInstallationBindingProof,
|
|
524
|
+
installationId: number,
|
|
525
|
+
): boolean {
|
|
526
|
+
const installation = proof.installation;
|
|
527
|
+
if (
|
|
528
|
+
installation.installationId !== installationId ||
|
|
529
|
+
!Number.isSafeInteger(installation.accountId) ||
|
|
530
|
+
installation.accountId <= 0 ||
|
|
531
|
+
!installation.accountLogin?.trim() ||
|
|
532
|
+
installation.suspended ||
|
|
533
|
+
!Number.isSafeInteger(proof.actorId) ||
|
|
534
|
+
proof.actorId <= 0 ||
|
|
535
|
+
!proof.actorLogin.trim() ||
|
|
536
|
+
proof.repositories.length === 0
|
|
537
|
+
) {
|
|
538
|
+
return false;
|
|
539
|
+
}
|
|
540
|
+
if (
|
|
541
|
+
proof.authorityKind === "personal_owner"
|
|
542
|
+
? installation.accountType !== "User" || proof.actorId !== installation.accountId
|
|
543
|
+
: installation.accountType !== "Organization"
|
|
544
|
+
) {
|
|
545
|
+
return false;
|
|
546
|
+
}
|
|
547
|
+
return proof.repositories.every(
|
|
548
|
+
(repository) =>
|
|
549
|
+
Number.isSafeInteger(repository.id) &&
|
|
550
|
+
repository.id > 0 &&
|
|
551
|
+
repository.installationId === installationId &&
|
|
552
|
+
repository.accountLogin === installation.accountLogin &&
|
|
553
|
+
repository.accountType === installation.accountType,
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
|
|
263
557
|
function escapeHtml(value: string): string {
|
|
264
558
|
return value.replace(
|
|
265
559
|
/[&<>"']/g,
|
|
@@ -273,3 +567,17 @@ function escapeHtml(value: string): string {
|
|
|
273
567
|
})[char] ?? char,
|
|
274
568
|
);
|
|
275
569
|
}
|
|
570
|
+
|
|
571
|
+
function openGeniReturnUrl(
|
|
572
|
+
settings: ApiRouteDeps["settings"],
|
|
573
|
+
c: Context,
|
|
574
|
+
workspaceId: string,
|
|
575
|
+
): string {
|
|
576
|
+
const url = new URL(openGeniBaseUrl(settings, c) || new URL(c.req.url).origin);
|
|
577
|
+
url.searchParams.set("workspaceId", workspaceId);
|
|
578
|
+
return url.toString();
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function openGeniBaseUrl(settings: ApiRouteDeps["settings"], c: Context): string {
|
|
582
|
+
return githubBrowserBaseUrl(settings, new URL(c.req.url).origin);
|
|
583
|
+
}
|
package/src/routes/sessions.ts
CHANGED
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
UpdateSessionGoalRequest,
|
|
43
43
|
UpdateSessionMcpApprovalPolicyRequest,
|
|
44
44
|
UpdateSessionRequest,
|
|
45
|
+
UpdateSessionToolPolicyRequest,
|
|
45
46
|
ViewerHeartbeatRequest,
|
|
46
47
|
WORKSPACE_CONTROL_ACTOR_MAX_BYTES,
|
|
47
48
|
workspaceControlUtf8Bytes,
|
|
@@ -97,6 +98,7 @@ import {
|
|
|
97
98
|
NewSessionDraftConflictError,
|
|
98
99
|
SessionCommandIdempotencyError,
|
|
99
100
|
SessionControlConflictError,
|
|
101
|
+
SessionToolPolicyVersionConflictError,
|
|
100
102
|
SessionContextBusyError,
|
|
101
103
|
HumanInputResponseValidationError,
|
|
102
104
|
latestWorkspaceCapture,
|
|
@@ -156,6 +158,7 @@ import {
|
|
|
156
158
|
sessionSpawnDenialEnvelope,
|
|
157
159
|
steerHumanQueuePrompt,
|
|
158
160
|
updateSessionMcpApprovalPolicy,
|
|
161
|
+
updateSessionToolPolicy,
|
|
159
162
|
updateSessionTitle,
|
|
160
163
|
workflowIdForSession,
|
|
161
164
|
sessionWithEffectiveToolPolicy,
|
|
@@ -188,6 +191,11 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
188
191
|
handle: ChannelAHandle,
|
|
189
192
|
pty: SandboxOpenPtySessionRow,
|
|
190
193
|
): Promise<SandboxRetainedProcess> => {
|
|
194
|
+
if (!handle.lease) {
|
|
195
|
+
throw new HTTPException(409, {
|
|
196
|
+
message: "durable interactive terminals require a session-home provider lease",
|
|
197
|
+
});
|
|
198
|
+
}
|
|
191
199
|
const process = await getRetainedProcess(db, {
|
|
192
200
|
workspaceId: ctx.workspaceId,
|
|
193
201
|
sessionId: ctx.session.id,
|
|
@@ -671,6 +679,29 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
671
679
|
},
|
|
672
680
|
);
|
|
673
681
|
|
|
682
|
+
app.put("/v1/workspaces/:workspaceId/sessions/:sessionId/tool-policy", async (c) => {
|
|
683
|
+
const workspaceId = c.req.param("workspaceId");
|
|
684
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
|
|
685
|
+
const sessionId = c.req.param("sessionId");
|
|
686
|
+
const payload = UpdateSessionToolPolicyRequest.parse(await c.req.json().catch(() => null));
|
|
687
|
+
try {
|
|
688
|
+
const session = await updateSessionToolPolicy(deps, grant, sessionId, payload);
|
|
689
|
+
return c.json(await withEffectivePolicy(deps, workspaceId, session));
|
|
690
|
+
} catch (error) {
|
|
691
|
+
if (error instanceof SessionToolPolicyVersionConflictError) {
|
|
692
|
+
return c.json(
|
|
693
|
+
{
|
|
694
|
+
code: error.code,
|
|
695
|
+
message: error.message,
|
|
696
|
+
currentVersion: error.currentVersion,
|
|
697
|
+
},
|
|
698
|
+
409,
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
throw error;
|
|
702
|
+
}
|
|
703
|
+
});
|
|
704
|
+
|
|
674
705
|
app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/goal", async (c) => {
|
|
675
706
|
const workspaceId = c.req.param("workspaceId");
|
|
676
707
|
await requireAccessGrant(c, deps, workspaceId, "sessions:read");
|
|
@@ -2157,6 +2188,11 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2157
2188
|
}
|
|
2158
2189
|
const ptyId = crypto.randomUUID();
|
|
2159
2190
|
const out = await withChannelA({ db, settings, bus }, ctx, async (handle) => {
|
|
2191
|
+
if (!handle.lease) {
|
|
2192
|
+
throw new HTTPException(409, {
|
|
2193
|
+
message: "durable interactive terminals require a session-home provider lease",
|
|
2194
|
+
});
|
|
2195
|
+
}
|
|
2160
2196
|
const { service } = handle;
|
|
2161
2197
|
const opened = await service.ptyOpen(req, ptyId);
|
|
2162
2198
|
const execSessionId = opened.execSessionId;
|
|
@@ -2406,6 +2442,7 @@ export function sessionAuthorizationOperationForHttp(
|
|
|
2406
2442
|
return null;
|
|
2407
2443
|
}
|
|
2408
2444
|
if (suffix === "/pin" && verb === "PUT") return "session.pin.write";
|
|
2445
|
+
if (suffix === "/tool-policy" && verb === "PUT") return "session.tool_policy.write";
|
|
2409
2446
|
if (/^\/mcp-servers\/[^/]+\/approval-policy$/.test(suffix) && verb === "PATCH") {
|
|
2410
2447
|
return "session.mcp.approval_policy.write";
|
|
2411
2448
|
}
|