@opengeni/api-router 2.2.0-canary.0 → 2.3.2-canary.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.js +1 -1
- package/dist/auth/managed-auth.d.ts +5 -2
- package/dist/auth/managed-email.d.ts +29 -0
- package/dist/auth/organization-user-setup.d.ts +57 -0
- package/dist/{chunk-3TP54PPX.js → chunk-IBV7Z6F4.js} +3545 -2009
- package/dist/chunk-IBV7Z6F4.js.map +1 -0
- package/dist/index.js +1 -1
- package/dist/integrations/slack-app-home.d.ts +1 -1
- package/dist/mcp/server.d.ts +1 -1
- package/dist/mcp/session-view.d.ts +1 -0
- package/dist/routes/automations.d.ts +13 -0
- package/dist/routes/insights.d.ts +2 -1
- package/dist/routes/managed-onboarding.d.ts +29 -0
- package/dist/routes/pr-review-github.d.ts +3 -0
- package/package.json +18 -18
- package/src/app.ts +56 -5
- package/src/auth/managed-auth.ts +29 -34
- package/src/auth/managed-email.ts +174 -0
- package/src/auth/organization-user-setup.ts +217 -0
- package/src/http/auth.ts +15 -0
- package/src/http/sse.ts +62 -13
- package/src/integrations/slack-app-home.ts +2 -2
- package/src/mcp/company-brain-governed-writes.ts +4 -4
- package/src/mcp/company-profile-agent-admin.ts +11 -18
- package/src/mcp/remember.ts +4 -4
- package/src/mcp/server.ts +50 -4
- package/src/mcp/session-view.ts +8 -2
- package/src/routes/automations.ts +3 -3
- package/src/routes/documents.ts +2 -0
- package/src/routes/insights.ts +61 -19
- package/src/routes/managed-onboarding.ts +317 -0
- package/src/routes/organization-memberships.ts +212 -155
- package/src/routes/pr-review-github.ts +844 -0
- package/src/routes/pr-review.ts +20 -0
- package/src/routes/rigs.ts +37 -4
- package/src/routes/sessions.ts +101 -0
- package/src/sandbox/channel-a.ts +34 -7
- package/src/sandbox/viewer.ts +47 -11
- package/dist/chunk-3TP54PPX.js.map +0 -1
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import type { Settings } from "@opengeni/config";
|
|
2
|
+
import type { ManagedEmailTransport } from "@opengeni/core";
|
|
3
|
+
|
|
4
|
+
const encoder = new TextEncoder();
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Prove that the stable invited-user setup bearer can be constructed before an
|
|
8
|
+
* invitation commits. Provider availability is intentionally outside this
|
|
9
|
+
* precondition: the durable delivery journal records a failed or ambiguous
|
|
10
|
+
* transport outcome after the invitation exists.
|
|
11
|
+
*/
|
|
12
|
+
export function assertOrganizationUserSetupDeliveryConfigured(
|
|
13
|
+
settings: Settings,
|
|
14
|
+
transport: ManagedEmailTransport,
|
|
15
|
+
): void {
|
|
16
|
+
requiredSetupSecret(settings);
|
|
17
|
+
requiredPublicBaseUrl(settings);
|
|
18
|
+
assertManagedEmailTransportMetadata(transport);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Reject an invalid embedded-provider contract before any durable boundary. */
|
|
22
|
+
export function assertManagedEmailTransportMetadata(transport: ManagedEmailTransport): void {
|
|
23
|
+
if (
|
|
24
|
+
transport.sender.trim() !== transport.sender ||
|
|
25
|
+
encoder.encode(transport.sender).byteLength < 3 ||
|
|
26
|
+
encoder.encode(transport.sender).byteLength > 320
|
|
27
|
+
) {
|
|
28
|
+
throw new Error("Managed email sender is invalid");
|
|
29
|
+
}
|
|
30
|
+
const { scope, retentionSeconds } = transport.idempotency;
|
|
31
|
+
if (
|
|
32
|
+
scope.trim() !== scope ||
|
|
33
|
+
!/^[a-z0-9][a-z0-9:._-]*$/.test(scope) ||
|
|
34
|
+
encoder.encode(scope).byteLength > 200 ||
|
|
35
|
+
!Number.isInteger(retentionSeconds) ||
|
|
36
|
+
retentionSeconds < 0 ||
|
|
37
|
+
retentionSeconds > 31_536_000
|
|
38
|
+
) {
|
|
39
|
+
throw new Error("Managed email idempotency contract is invalid");
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function deriveOrganizationUserSetupToken(
|
|
44
|
+
settings: Settings,
|
|
45
|
+
input: { invitationId: string; deliveryId: string },
|
|
46
|
+
): Promise<{ token: string; digest: string; url: string }> {
|
|
47
|
+
const secret = requiredSetupSecret(settings);
|
|
48
|
+
const key = await crypto.subtle.importKey(
|
|
49
|
+
"raw",
|
|
50
|
+
encoder.encode(secret),
|
|
51
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
52
|
+
false,
|
|
53
|
+
["sign"],
|
|
54
|
+
);
|
|
55
|
+
const signature = await crypto.subtle.sign(
|
|
56
|
+
"HMAC",
|
|
57
|
+
key,
|
|
58
|
+
encoder.encode(
|
|
59
|
+
`opengeni:organization-user-setup-delivery:v1:${input.deliveryId}:${input.invitationId}`,
|
|
60
|
+
),
|
|
61
|
+
);
|
|
62
|
+
const token = base64Url(new Uint8Array(signature));
|
|
63
|
+
const digest = await sha256Hex(token);
|
|
64
|
+
const url = new URL("/setup-account", requiredPublicBaseUrl(settings));
|
|
65
|
+
url.hash = new URLSearchParams({ token }).toString();
|
|
66
|
+
return { token, digest, url: url.toString() };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type OrganizationUserSetupEmailSnapshot = {
|
|
70
|
+
senderEmail: string;
|
|
71
|
+
recipientEmail: string;
|
|
72
|
+
recipientName: string | null;
|
|
73
|
+
organizationName: string;
|
|
74
|
+
organizationRole: "owner" | "admin" | "member";
|
|
75
|
+
sharedWorkspaceAccess: Array<{
|
|
76
|
+
workspaceId: string;
|
|
77
|
+
workspaceName: string;
|
|
78
|
+
role: "viewer" | "member" | "admin";
|
|
79
|
+
}>;
|
|
80
|
+
setupUrl: string;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export function renderOrganizationUserSetupEmail(input: OrganizationUserSetupEmailSnapshot): {
|
|
84
|
+
from: string;
|
|
85
|
+
to: string;
|
|
86
|
+
subject: string;
|
|
87
|
+
text: string;
|
|
88
|
+
html: string;
|
|
89
|
+
} {
|
|
90
|
+
const greeting = input.recipientName ? `Hi ${input.recipientName},` : "Hello,";
|
|
91
|
+
const role = titleCase(input.organizationRole);
|
|
92
|
+
const workspaceSummary =
|
|
93
|
+
input.sharedWorkspaceAccess.length === 0
|
|
94
|
+
? "No shared workspaces are assigned yet."
|
|
95
|
+
: `Shared workspace access:\n${input.sharedWorkspaceAccess
|
|
96
|
+
.map((workspace) => `- ${workspace.workspaceName}: ${titleCase(workspace.role)}`)
|
|
97
|
+
.join("\n")}`;
|
|
98
|
+
const workspaceHtml =
|
|
99
|
+
input.sharedWorkspaceAccess.length === 0
|
|
100
|
+
? "<p>No shared workspaces are assigned yet.</p>"
|
|
101
|
+
: `<p>Shared workspace access:</p><ul>${input.sharedWorkspaceAccess
|
|
102
|
+
.map(
|
|
103
|
+
(workspace) =>
|
|
104
|
+
`<li>${escapeHtml(workspace.workspaceName)}: ${escapeHtml(titleCase(workspace.role))}</li>`,
|
|
105
|
+
)
|
|
106
|
+
.join("")}</ul>`;
|
|
107
|
+
return {
|
|
108
|
+
from: input.senderEmail,
|
|
109
|
+
to: input.recipientEmail,
|
|
110
|
+
subject: `Join ${input.organizationName} on OpenGeni`,
|
|
111
|
+
text: `${greeting}\n\nYou have been invited to ${input.organizationName} as ${role}.\n\n${workspaceSummary}\n\nThis invitation grants only the organization role and shared workspace access listed above. It never shares anyone's Personal workspace.\n\nSet up your account: ${input.setupUrl}\n\nIf you already have an OpenGeni account, sign in and accept the invitation instead.`,
|
|
112
|
+
html: `<p>${escapeHtml(greeting)}</p><p>You have been invited to <strong>${escapeHtml(input.organizationName)}</strong> as ${escapeHtml(role)}.</p>${workspaceHtml}<p>This invitation grants only the organization role and shared workspace access listed above. It never shares anyone's Personal workspace.</p><p><a href="${escapeHtml(input.setupUrl)}">Set up your account</a></p><p>If you already have an OpenGeni account, sign in and accept the invitation instead.</p>`,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function organizationUserSetupPayloadDigest(input: {
|
|
117
|
+
from: string;
|
|
118
|
+
to: string;
|
|
119
|
+
subject: string;
|
|
120
|
+
text: string;
|
|
121
|
+
html: string;
|
|
122
|
+
providerIdempotencyScope: string;
|
|
123
|
+
}): Promise<string> {
|
|
124
|
+
return await sha256Hex(
|
|
125
|
+
JSON.stringify({
|
|
126
|
+
version: 2,
|
|
127
|
+
providerIdempotencyScope: input.providerIdempotencyScope,
|
|
128
|
+
from: input.from,
|
|
129
|
+
to: input.to,
|
|
130
|
+
subject: input.subject,
|
|
131
|
+
text: input.text,
|
|
132
|
+
html: input.html,
|
|
133
|
+
}),
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function organizationUserSetupRequestFingerprint(
|
|
138
|
+
settings: Settings,
|
|
139
|
+
input: { tokenDigest: string; name: string; password: string },
|
|
140
|
+
): Promise<string> {
|
|
141
|
+
const key = await crypto.subtle.importKey(
|
|
142
|
+
"raw",
|
|
143
|
+
encoder.encode(requiredSetupSecret(settings)),
|
|
144
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
145
|
+
false,
|
|
146
|
+
["sign"],
|
|
147
|
+
);
|
|
148
|
+
const signature = await crypto.subtle.sign(
|
|
149
|
+
"HMAC",
|
|
150
|
+
key,
|
|
151
|
+
encoder.encode(
|
|
152
|
+
JSON.stringify({
|
|
153
|
+
tokenDigest: input.tokenDigest,
|
|
154
|
+
name: input.name,
|
|
155
|
+
password: input.password,
|
|
156
|
+
}),
|
|
157
|
+
),
|
|
158
|
+
);
|
|
159
|
+
return hex(new Uint8Array(signature));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function selfServiceOrganizationSetupRequestFingerprint(input: {
|
|
163
|
+
authUserId: string;
|
|
164
|
+
organizationName: string;
|
|
165
|
+
}): Promise<string> {
|
|
166
|
+
const actorSubjectId = `user:${input.authUserId}`;
|
|
167
|
+
const organizationNameBytes = encoder.encode(input.organizationName);
|
|
168
|
+
return await sha256Hex(
|
|
169
|
+
`opengeni:self-service-organization:v1:${actorSubjectId}:${organizationNameBytes.byteLength}:${input.organizationName}`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function organizationUserSetupTokenDigest(token: string): Promise<string> {
|
|
174
|
+
return await sha256Hex(token);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function requiredSetupSecret(settings: Settings): string {
|
|
178
|
+
if (!settings.betterAuthSecret) {
|
|
179
|
+
throw new Error("OPENGENI_BETTER_AUTH_SECRET is required for organization user setup");
|
|
180
|
+
}
|
|
181
|
+
return settings.betterAuthSecret;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function requiredPublicBaseUrl(settings: Settings): string {
|
|
185
|
+
if (!settings.publicBaseUrl) {
|
|
186
|
+
throw new Error("OPENGENI_PUBLIC_BASE_URL is required for organization user setup");
|
|
187
|
+
}
|
|
188
|
+
return settings.publicBaseUrl;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function sha256Hex(value: string): Promise<string> {
|
|
192
|
+
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(value));
|
|
193
|
+
return hex(new Uint8Array(digest));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function hex(bytes: Uint8Array): string {
|
|
197
|
+
return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function base64Url(bytes: Uint8Array): string {
|
|
201
|
+
let binary = "";
|
|
202
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
203
|
+
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function titleCase(value: string): string {
|
|
207
|
+
return `${value.slice(0, 1).toUpperCase()}${value.slice(1)}`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function escapeHtml(value: string): string {
|
|
211
|
+
return value
|
|
212
|
+
.replaceAll("&", "&")
|
|
213
|
+
.replaceAll("<", "<")
|
|
214
|
+
.replaceAll(">", ">")
|
|
215
|
+
.replaceAll('"', """)
|
|
216
|
+
.replaceAll("'", "'");
|
|
217
|
+
}
|
package/src/http/auth.ts
CHANGED
|
@@ -5,6 +5,8 @@ import { installExactPaths, isInstallRedirectPath } from "../routes/install";
|
|
|
5
5
|
|
|
6
6
|
const githubConnectPathPattern = /^\/v1\/workspaces\/[^/]+\/github\/connect$/;
|
|
7
7
|
const githubInstallationLinkPathPattern = /^\/v1\/workspaces\/[^/]+\/github\/installations$/;
|
|
8
|
+
const prReviewGithubBrowserPathPattern =
|
|
9
|
+
/^\/v1\/workspaces\/[^/]+\/pr-review\/github\/(?:connect|installations\/select|installations\/[^/]+\/configure)$/;
|
|
8
10
|
|
|
9
11
|
export function requireAccessKey(settings: Settings): MiddlewareHandler {
|
|
10
12
|
return async (c, next) => {
|
|
@@ -43,6 +45,9 @@ function isAuthExempt(c: Context, settings: Settings): boolean {
|
|
|
43
45
|
if (c.req.method === "POST" && path.startsWith("/v1/webhooks/automations/")) {
|
|
44
46
|
return true;
|
|
45
47
|
}
|
|
48
|
+
if (c.req.method === "POST" && path === "/v1/webhooks/pr-review/github") {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
46
51
|
if (
|
|
47
52
|
path === "/v1/github/setup" ||
|
|
48
53
|
path === "/v1/github/install/callback" ||
|
|
@@ -51,6 +56,13 @@ function isAuthExempt(c: Context, settings: Settings): boolean {
|
|
|
51
56
|
) {
|
|
52
57
|
return true;
|
|
53
58
|
}
|
|
59
|
+
if (
|
|
60
|
+
path === "/v1/pr-review/github/setup" ||
|
|
61
|
+
path === "/v1/pr-review/github/install/callback" ||
|
|
62
|
+
path === "/v1/pr-review/github/oauth/callback"
|
|
63
|
+
) {
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
54
66
|
if (
|
|
55
67
|
path === "/v1/integrations/oauth/callback" ||
|
|
56
68
|
path === "/v1/integrations/provider-oauth/callback" ||
|
|
@@ -83,6 +95,9 @@ function isAuthExempt(c: Context, settings: Settings): boolean {
|
|
|
83
95
|
if (githubConnectPathPattern.test(path)) {
|
|
84
96
|
return true;
|
|
85
97
|
}
|
|
98
|
+
if (prReviewGithubBrowserPathPattern.test(path)) {
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
86
101
|
// Compatibility endpoint for stale chooser submissions. It remains public
|
|
87
102
|
// only so already-rendered forms can authenticate their signed account and
|
|
88
103
|
// workspace state locally before terminating with 410; it does not parse a
|
package/src/http/sse.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
coalesceSessionEventDeltas,
|
|
14
14
|
formatSessionEventSse,
|
|
15
15
|
formatWorkspaceControlEventSse,
|
|
16
|
+
requireSessionEventDurableFanoutCapability,
|
|
16
17
|
SESSION_EVENT_SSE_FRAME_MAX_BYTES,
|
|
17
18
|
sessionEventResumeSequence,
|
|
18
19
|
type EventBus,
|
|
@@ -269,12 +270,14 @@ export async function sseSessionStream(
|
|
|
269
270
|
signal: AbortSignal,
|
|
270
271
|
options: SessionSseDeliveryOptions = {},
|
|
271
272
|
): Promise<Response> {
|
|
273
|
+
const durableFanout = requireSessionEventDurableFanoutCapability(bus);
|
|
272
274
|
const heartbeatIntervalMs = resolveHeartbeatInterval(options.heartbeatIntervalMs);
|
|
273
275
|
let lastSent = after;
|
|
274
276
|
let bootstrapping = true;
|
|
275
277
|
let newestBuffered: SessionEvent | null = null;
|
|
276
278
|
let unsubscribe: (() => void) | null = null;
|
|
277
279
|
let delivery: LatestWinsDelivery<SessionEvent> | null = null;
|
|
280
|
+
let stopReconnectObservation = () => {};
|
|
278
281
|
let stopReauthorization = () => {};
|
|
279
282
|
let heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
|
|
280
283
|
let detachAbortListener = () => {};
|
|
@@ -282,6 +285,8 @@ export async function sseSessionStream(
|
|
|
282
285
|
const stopUpstream = () => {
|
|
283
286
|
closeMetrics();
|
|
284
287
|
detachAbortListener();
|
|
288
|
+
stopReconnectObservation();
|
|
289
|
+
stopReconnectObservation = () => {};
|
|
285
290
|
stopReauthorization();
|
|
286
291
|
if (heartbeatTimer) {
|
|
287
292
|
clearTimeout(heartbeatTimer);
|
|
@@ -316,17 +321,6 @@ export async function sseSessionStream(
|
|
|
316
321
|
writeTail = write.catch(() => {});
|
|
317
322
|
return write;
|
|
318
323
|
};
|
|
319
|
-
const scheduleHeartbeat = () => {
|
|
320
|
-
if (channel.stopped()) return;
|
|
321
|
-
heartbeatTimer = setTimeout(() => {
|
|
322
|
-
heartbeatTimer = null;
|
|
323
|
-
void writeFrame(": heartbeat\n\n")
|
|
324
|
-
.then(scheduleHeartbeat)
|
|
325
|
-
.catch((error) => {
|
|
326
|
-
if (!(error instanceof SseStreamStoppedError)) fail(error);
|
|
327
|
-
});
|
|
328
|
-
}, heartbeatIntervalMs);
|
|
329
|
-
};
|
|
330
324
|
const deliverDurableThrough = async (targetSequence?: number) => {
|
|
331
325
|
while (true) {
|
|
332
326
|
if (targetSequence !== undefined && lastSent >= targetSequence) return;
|
|
@@ -364,12 +358,66 @@ export async function sseSessionStream(
|
|
|
364
358
|
if (targetSequence === undefined && page.length < limit) return;
|
|
365
359
|
}
|
|
366
360
|
};
|
|
361
|
+
let durableDeliveryTail = Promise.resolve();
|
|
362
|
+
const reconcileDurableThrough = (targetSequence?: number): Promise<void> => {
|
|
363
|
+
const deliveryRun = durableDeliveryTail.then(() => deliverDurableThrough(targetSequence));
|
|
364
|
+
durableDeliveryTail = deliveryRun.catch(() => {});
|
|
365
|
+
return deliveryRun;
|
|
366
|
+
};
|
|
367
|
+
let newestReconnectGeneration = 0;
|
|
368
|
+
let reconnectReconcilePending = false;
|
|
369
|
+
let reconnectReconcileRunning = false;
|
|
370
|
+
const drainReconnectReconciliation = () => {
|
|
371
|
+
if (
|
|
372
|
+
bootstrapping ||
|
|
373
|
+
reconnectReconcileRunning ||
|
|
374
|
+
!reconnectReconcilePending ||
|
|
375
|
+
channel.stopped()
|
|
376
|
+
) {
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
reconnectReconcileRunning = true;
|
|
380
|
+
void (async () => {
|
|
381
|
+
while (reconnectReconcilePending && !channel.stopped()) {
|
|
382
|
+
// Multiple reconnects during one durable read collapse into one newest
|
|
383
|
+
// catch-up. Postgres is authoritative, so that later read covers every
|
|
384
|
+
// disconnect window without one query per heartbeat or buffered event.
|
|
385
|
+
reconnectReconcilePending = false;
|
|
386
|
+
await reconcileDurableThrough();
|
|
387
|
+
}
|
|
388
|
+
})()
|
|
389
|
+
.catch((error) => {
|
|
390
|
+
if (!(error instanceof SseStreamStoppedError)) fail(error);
|
|
391
|
+
})
|
|
392
|
+
.finally(() => {
|
|
393
|
+
reconnectReconcileRunning = false;
|
|
394
|
+
drainReconnectReconciliation();
|
|
395
|
+
});
|
|
396
|
+
};
|
|
397
|
+
const scheduleReconnectReconciliation = (generation: number) => {
|
|
398
|
+
if (generation <= newestReconnectGeneration || channel.stopped()) return;
|
|
399
|
+
newestReconnectGeneration = generation;
|
|
400
|
+
reconnectReconcilePending = true;
|
|
401
|
+
drainReconnectReconciliation();
|
|
402
|
+
};
|
|
367
403
|
const send = async (event: SessionEvent) => {
|
|
368
404
|
const targetSequence = sessionEventResumeSequence(event);
|
|
369
405
|
if (targetSequence <= lastSent) return;
|
|
370
|
-
await
|
|
406
|
+
await reconcileDurableThrough(targetSequence);
|
|
407
|
+
};
|
|
408
|
+
const scheduleHeartbeat = () => {
|
|
409
|
+
if (channel.stopped()) return;
|
|
410
|
+
heartbeatTimer = setTimeout(() => {
|
|
411
|
+
heartbeatTimer = null;
|
|
412
|
+
void writeFrame(": heartbeat\n\n")
|
|
413
|
+
.then(scheduleHeartbeat)
|
|
414
|
+
.catch((error) => {
|
|
415
|
+
if (!(error instanceof SseStreamStoppedError)) fail(error);
|
|
416
|
+
});
|
|
417
|
+
}, heartbeatIntervalMs);
|
|
371
418
|
};
|
|
372
419
|
delivery = createLatestWinsDelivery(send, fail);
|
|
420
|
+
stopReconnectObservation = durableFanout.subscribeRecovery(scheduleReconnectReconciliation);
|
|
373
421
|
|
|
374
422
|
void (async () => {
|
|
375
423
|
const release = await bus.subscribe(workspaceId, sessionId, (events) => {
|
|
@@ -389,10 +437,11 @@ export async function sseSessionStream(
|
|
|
389
437
|
}
|
|
390
438
|
unsubscribe = release;
|
|
391
439
|
|
|
392
|
-
await
|
|
440
|
+
await reconcileDurableThrough();
|
|
393
441
|
await writeFrame(": connected\n\n");
|
|
394
442
|
scheduleHeartbeat();
|
|
395
443
|
bootstrapping = false;
|
|
444
|
+
drainReconnectReconciliation();
|
|
396
445
|
const buffered = newestBuffered;
|
|
397
446
|
newestBuffered = null;
|
|
398
447
|
if (buffered) delivery.publish([buffered]);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { AUTOMATIC_SESSION_TITLE_FALLBACK, type Session } from "@opengeni/contracts";
|
|
2
2
|
import type { SlackHomeBlock } from "./slack-bot";
|
|
3
3
|
|
|
4
4
|
const ATTENTION_LIMIT = 5;
|
|
@@ -186,7 +186,7 @@ function appendSessionGroup(
|
|
|
186
186
|
);
|
|
187
187
|
for (const session of sessions) {
|
|
188
188
|
const url = sessionUrl(session.id);
|
|
189
|
-
const title = (session.title ||
|
|
189
|
+
const title = (session.title?.trim() || AUTOMATIC_SESSION_TITLE_FALLBACK).slice(0, 180);
|
|
190
190
|
blocks.push({
|
|
191
191
|
type: "section",
|
|
192
192
|
block_id: `opengeni_home_session_${session.id}`,
|
|
@@ -125,7 +125,7 @@ export function registerCompanyBrainGovernedWriteTools(
|
|
|
125
125
|
{
|
|
126
126
|
description:
|
|
127
127
|
"Atomically promote one still-active note from this exact root task tree into an inactive workspace instruction-policy draft. The note bytes remain exact evidence and draft content. " +
|
|
128
|
-
`Once a human activates the draft, those bytes are composed verbatim into the prompt of every session the target applies to, so a note over ${AGENT_AUTHORED_INSTRUCTION_POLICY_CONTENT_MAX_CHARS} characters is rejected here rather than truncated: write a fresh
|
|
128
|
+
`Use this only for a universal always-on rule, never for an incident, fact, decision, outcome, or conditional procedure. Once a human activates the draft, those bytes are composed verbatim into the prompt of every session the target applies to, so a note over ${AGENT_AUTHORED_INSTRUCTION_POLICY_CONTENT_MAX_CHARS} characters is rejected here rather than truncated: write a fresh minimal imperative note instead of promoting a long working note. ` +
|
|
129
129
|
"The frozen learning policy records a decision receipt, but mandatory policy still requires human activation even under Automatic; this never widens scope.",
|
|
130
130
|
inputSchema: {
|
|
131
131
|
...taskNotePromotion,
|
|
@@ -149,7 +149,7 @@ export function registerCompanyBrainGovernedWriteTools(
|
|
|
149
149
|
"task_note_promote_preference",
|
|
150
150
|
{
|
|
151
151
|
description:
|
|
152
|
-
"Atomically promote one still-active note from this exact root task tree into a workspace preference
|
|
152
|
+
"Atomically promote one still-active note from this exact root task tree into a workspace Skill proposal backed by the structured preference authority. Use this for reusable conditional how-to guidance, never for an incident, fact, decision, outcome, or universal always-on rule. The note bytes remain exact evidence and full proposal content. " +
|
|
153
153
|
`The title and description you supply are what gets composed into every session prompt, so write them as one short imperative statement; the note content is retrieved on demand and a note over ${AGENT_AUTHORED_PREFERENCE_CONTENT_MAX_CHARS} characters is rejected here rather than truncated. ` +
|
|
154
154
|
"Under Suggest the proposal waits for human review; under Automatic an eligible decision is activated through the preference lifecycle and remains undoable. This never widens scope.",
|
|
155
155
|
inputSchema: {
|
|
@@ -186,7 +186,7 @@ export function registerCompanyBrainGovernedWriteTools(
|
|
|
186
186
|
{
|
|
187
187
|
description:
|
|
188
188
|
"Materialize an evidence-backed inactive workspace instruction-policy draft. " +
|
|
189
|
-
`Once a human activates it, this content is composed verbatim into the prompt of every session the target applies to (every session in this workspace for a global charter or policy, every session bound to the role for a role policy), so keep it under ${AGENT_AUTHORED_INSTRUCTION_POLICY_CONTENT_MAX_CHARS} characters. ${AGENT_AUTHORED_DURABLE_TEXT_STYLE} ` +
|
|
189
|
+
`Use this only for a minimal universal rule, never for an incident, fact, decision, outcome, or conditional procedure. Once a human activates it, this content is composed verbatim into the prompt of every session the target applies to (every session in this workspace for a global charter or policy, every session bound to the role for a role policy), so keep it under ${AGENT_AUTHORED_INSTRUCTION_POLICY_CONTENT_MAX_CHARS} characters. ${AGENT_AUTHORED_DURABLE_TEXT_STYLE} ` +
|
|
190
190
|
"The frozen learning policy records a decision receipt, but this tool cannot activate mandatory behavior, including when learning mode is Automatic; a human must activate the draft.",
|
|
191
191
|
inputSchema: {
|
|
192
192
|
...evidence,
|
|
@@ -219,7 +219,7 @@ export function registerCompanyBrainGovernedWriteTools(
|
|
|
219
219
|
"preference_propose",
|
|
220
220
|
{
|
|
221
221
|
description:
|
|
222
|
-
"Materialize an evidence-backed workspace preference
|
|
222
|
+
"Materialize an evidence-backed workspace Skill proposal in the structured preference authority. Use this only for reusable conditional how-to guidance, never for an incident, fact, decision, outcome, or universal always-on rule. " +
|
|
223
223
|
`Its short title and description are what get composed into every session prompt; the content is retrieved on demand, so its length is retrieval cost rather than standing prompt cost. Keep the content under ${AGENT_AUTHORED_PREFERENCE_CONTENT_MAX_CHARS} characters. ${AGENT_AUTHORED_DURABLE_TEXT_STYLE} ` +
|
|
224
224
|
"Under Suggest it stays inactive for human review; under Automatic an eligible decision is activated through the governed preference lifecycle with an undoable receipt. It never creates mandatory authority.",
|
|
225
225
|
inputSchema: {
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
|
-
AGENT_AUTHORED_COMPANY_PROFILE_CONTENT_MAX_UTF8_BYTES,
|
|
3
2
|
AGENT_AUTHORED_COMPANY_PROFILE_ENTRY_MAX_CHARS,
|
|
4
3
|
AGENT_AUTHORED_COMPANY_PROFILE_SCALAR_MAX_CHARS,
|
|
5
4
|
AGENT_AUTHORED_COMPANY_PROFILE_TOO_LONG_MESSAGE,
|
|
6
5
|
AgentAuthoredCompanyProfileContent,
|
|
7
|
-
COMPANY_PROFILE_ENTRY_MAX_COUNT,
|
|
8
6
|
COMPANY_PROFILE_REASON_MAX_CHARS,
|
|
9
7
|
COMPANY_PROFILE_STABLE_KEY_MAX_CHARS,
|
|
10
8
|
normalizeCompanyProfileStableKey,
|
|
@@ -32,9 +30,9 @@ export type RegisterCompanyProfileAgentAdminToolsInput = {
|
|
|
32
30
|
router?: Pick<ReturnType<typeof createCompanyProfileAgentAdminRouter>, "propose" | "confirm">;
|
|
33
31
|
};
|
|
34
32
|
|
|
35
|
-
// Agent-only bounds. The human `account:admin`
|
|
36
|
-
// `COMPANY_PROFILE_*` limits;
|
|
37
|
-
//
|
|
33
|
+
// Agent-only bounds. The human `account:admin` API keeps the wider historical
|
|
34
|
+
// `COMPANY_PROFILE_*` limits; the current agent tool authors only the two
|
|
35
|
+
// always-on identity fields and therefore gets a much smaller budget.
|
|
38
36
|
const scalar = z
|
|
39
37
|
.string()
|
|
40
38
|
.trim()
|
|
@@ -55,8 +53,7 @@ const entry = z.object({
|
|
|
55
53
|
AGENT_AUTHORED_COMPANY_PROFILE_TOO_LONG_MESSAGE,
|
|
56
54
|
),
|
|
57
55
|
});
|
|
58
|
-
const
|
|
59
|
-
const DEFAULT_PROPOSAL_REASON = "Activate agent-proposed organization company profile";
|
|
56
|
+
const DEFAULT_PROPOSAL_REASON = "Activate agent-proposed organization identity";
|
|
60
57
|
const STABLE_KEY_WORDS = 6;
|
|
61
58
|
|
|
62
59
|
type EntryInput = z.infer<typeof entry>;
|
|
@@ -127,18 +124,14 @@ export function registerCompanyProfileAgentAdminTools(
|
|
|
127
124
|
"company_profile_propose",
|
|
128
125
|
{
|
|
129
126
|
description:
|
|
130
|
-
"Prepare
|
|
131
|
-
"Once activated,
|
|
132
|
-
`
|
|
127
|
+
"Prepare the organization's small, stable identity: identity says who the organization is, and mission says why it exists. " +
|
|
128
|
+
"Once activated, both fields are mandatory prompt context in every root session for the whole organization, so use one plain descriptive statement per field with no products, customers, goals, constraints, procedures, or marketing copy. Those details belong in organization-scoped Documents and are retrieved only when relevant. " +
|
|
129
|
+
`Each field is bounded to ${AGENT_AUTHORED_COMPANY_PROFILE_SCALAR_MAX_CHARS} characters for agent-authored proposals. ` +
|
|
133
130
|
"This creates only an immutable inactive proposal for the exact live turn initiated by the organization owner and does not use workspace learning policy. The receipt returns the exact `humanInput` payload; call `request_human_input` with it verbatim, then call `company_profile_confirm` with the returned requestId.",
|
|
134
131
|
inputSchema: {
|
|
135
132
|
operationId: z.string().uuid(),
|
|
136
133
|
identity: scalar,
|
|
137
134
|
mission: scalar,
|
|
138
|
-
products: entries,
|
|
139
|
-
customers: entries,
|
|
140
|
-
goals: entries,
|
|
141
|
-
constraints: entries,
|
|
142
135
|
reason: z.string().trim().min(1).max(COMPANY_PROFILE_REASON_MAX_CHARS).optional(),
|
|
143
136
|
},
|
|
144
137
|
},
|
|
@@ -147,10 +140,10 @@ export function registerCompanyProfileAgentAdminTools(
|
|
|
147
140
|
const parsed = AgentAuthoredCompanyProfileContent.safeParse({
|
|
148
141
|
identity: request.identity,
|
|
149
142
|
mission: request.mission,
|
|
150
|
-
products:
|
|
151
|
-
customers:
|
|
152
|
-
goals:
|
|
153
|
-
constraints:
|
|
143
|
+
products: [],
|
|
144
|
+
customers: [],
|
|
145
|
+
goals: [],
|
|
146
|
+
constraints: [],
|
|
154
147
|
});
|
|
155
148
|
if (!parsed.success) {
|
|
156
149
|
return input.json({
|
package/src/mcp/remember.ts
CHANGED
|
@@ -70,9 +70,9 @@ export function registerRememberTools(input: RegisterRememberToolsInput): void {
|
|
|
70
70
|
"remember",
|
|
71
71
|
{
|
|
72
72
|
description:
|
|
73
|
-
"Durably remember something the user explicitly asked to keep for this workspace.
|
|
74
|
-
`Write
|
|
75
|
-
"Under Automatic learning a
|
|
73
|
+
"Durably remember something the user explicitly asked to keep for this workspace. Route by purpose: lane=knowledge for a fact, decision, incident, bug fix, or outcome that should become searchable Memory; lane=preference creates a Skill for reusable conditional how-to guidance; lane=instruction_policy is only for a universal always/never rule that should apply to nearly every task. " +
|
|
74
|
+
`Write the instruction lane as the shortest complete rule, at most ${AGENT_AUTHORED_INSTRUCTION_POLICY_CONTENT_MAX_CHARS} characters and normally 1-3 imperative sentences, with no numbered steps, examples, rationale, or restated defaults. Keep a Skill under ${AGENT_AUTHORED_PREFERENCE_CONTENT_MAX_CHARS} characters; only its one-sentence descriptor is composed and the full instructions are retrieved on demand. Do not copy one item into multiple lanes. ` +
|
|
75
|
+
"Under Automatic learning a Skill may activate immediately; otherwise the receipt returns status=confirmation_required with the exact `humanInput` payload: call `request_human_input` with it verbatim, then call `remember_confirm` with the returned requestId. Workspace instructions and Memory always need that confirmation. Do not use this for facts you merely inferred; use knowledge_propose or task notes for those. Confirmed lane=knowledge content keeps its reviewed claim provenance and materializes its exact approved text into Memory for later `memory_search` retrieval.",
|
|
76
76
|
inputSchema: {
|
|
77
77
|
lane: z.enum(["preference", "instruction_policy", "knowledge"]),
|
|
78
78
|
...laneFields,
|
|
@@ -130,7 +130,7 @@ export function registerRememberTools(input: RegisterRememberToolsInput): void {
|
|
|
130
130
|
"remember_confirm",
|
|
131
131
|
{
|
|
132
132
|
description:
|
|
133
|
-
"Complete a `remember` that returned status=confirmation_required after the human answered the bound `request_human_input` question. For preference/instruction_policy pass proposalId and learning.receiptId (as decisionReceiptId) from that receipt; for knowledge pass claimId. Always pass the requestId returned by request_human_input. Activation only succeeds when the exact initiating human answered Save on this turn;
|
|
133
|
+
"Complete a `remember` that returned status=confirmation_required after the human answered the bound `request_human_input` question. For preference/instruction_policy pass proposalId and learning.receiptId (as decisionReceiptId) from that receipt; for knowledge pass claimId. Always pass the requestId returned by request_human_input. Activation only succeeds when the exact initiating human answered Save on this turn; confirmed knowledge is materialized into searchable Memory from the exact approved text.",
|
|
134
134
|
inputSchema: {
|
|
135
135
|
operationId: z.string().uuid(),
|
|
136
136
|
proposalId: z.string().uuid().optional(),
|
package/src/mcp/server.ts
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
SESSION_GOAL_SUCCESS_CRITERIA_MAX_BYTES,
|
|
39
39
|
SESSION_GOAL_TEXT_MAX_BYTES,
|
|
40
40
|
SESSION_INSTRUCTIONS_MAX_CHARACTERS,
|
|
41
|
+
MAX_SELECTED_VARIABLE_SETS,
|
|
41
42
|
sessionGoalUtf8Bytes,
|
|
42
43
|
TASK_NOTE_LIST_DEFAULT_LIMIT,
|
|
43
44
|
TASK_NOTE_LIST_MAX_LIMIT,
|
|
@@ -755,7 +756,7 @@ export function buildOpenGeniMcpServer(
|
|
|
755
756
|
"set_session_title",
|
|
756
757
|
{
|
|
757
758
|
description:
|
|
758
|
-
"Set this session's display title to a concise 3-7 word
|
|
759
|
+
"Set this session's display title to a concise 3-7 word topic label. Use a stable noun phrase about the actual task or subject, never a quote/prefix of a prompt, greeting, request boilerplate, URL, identifier, credential, token, or other sensitive value. Call once on a new session, then only when the topic materially changes. Never call it as routine setup after a continuation, resume, or interruption, or merely to reassert the same title. A human-set title cannot be replaced.",
|
|
759
760
|
inputSchema: { title: z4.string().min(1).max(200) },
|
|
760
761
|
},
|
|
761
762
|
async ({ title }) => {
|
|
@@ -3256,7 +3257,7 @@ function registerPreferenceRegistryTools(
|
|
|
3256
3257
|
"preference_registry_summary",
|
|
3257
3258
|
{
|
|
3258
3259
|
description:
|
|
3259
|
-
"List bounded deterministic descriptors for organization, workspace, and immutable initiating
|
|
3260
|
+
"List bounded deterministic Skill descriptors for organization, workspace, and the immutable initiating human, frozen to this exact attempt. Full Skill instructions are omitted; retrieve only a relevant returned handle.",
|
|
3260
3261
|
inputSchema: {},
|
|
3261
3262
|
},
|
|
3262
3263
|
async () => json(await getOrCreatePreferenceRegistrySnapshot(deps.db, attemptClaims())),
|
|
@@ -3266,7 +3267,7 @@ function registerPreferenceRegistryTools(
|
|
|
3266
3267
|
"preference_registry_get",
|
|
3267
3268
|
{
|
|
3268
3269
|
description:
|
|
3269
|
-
"Retrieve full
|
|
3270
|
+
"Retrieve the full instructions for one Skill in this exact attempt snapshot. Handles from another account, workspace, human, or attempt are rejected.",
|
|
3270
3271
|
inputSchema: { retrievalHandle: z4.string().min(1).max(512) },
|
|
3271
3272
|
},
|
|
3272
3273
|
async ({ retrievalHandle }) =>
|
|
@@ -4295,6 +4296,7 @@ function registerWorkspaceOrchestrationTools(
|
|
|
4295
4296
|
tools: z4.array(z4.unknown()).optional(),
|
|
4296
4297
|
mcpServers: z4.array(z4.unknown()).optional(),
|
|
4297
4298
|
variableSetId: z4.string().uuid().optional(),
|
|
4299
|
+
variableSetIds: z4.array(z4.string().uuid()).max(MAX_SELECTED_VARIABLE_SETS).optional(),
|
|
4298
4300
|
environmentId: z4.string().uuid().optional(),
|
|
4299
4301
|
rigId: z4.string().uuid().optional(),
|
|
4300
4302
|
model: z4
|
|
@@ -4345,6 +4347,26 @@ function registerWorkspaceOrchestrationTools(
|
|
|
4345
4347
|
.union([z4.literal("new"), z4.object({ groupId: z4.string().uuid() })])
|
|
4346
4348
|
.optional(),
|
|
4347
4349
|
})
|
|
4350
|
+
.superRefine((value, context) => {
|
|
4351
|
+
if (!value.variableSetIds) return;
|
|
4352
|
+
if (new Set(value.variableSetIds).size !== value.variableSetIds.length) {
|
|
4353
|
+
context.addIssue({
|
|
4354
|
+
code: z4.ZodIssueCode.custom,
|
|
4355
|
+
path: ["variableSetIds"],
|
|
4356
|
+
message: "variableSetIds must not contain duplicates",
|
|
4357
|
+
});
|
|
4358
|
+
}
|
|
4359
|
+
const singular = value.variableSetId ?? value.environmentId;
|
|
4360
|
+
if (singular === undefined) return;
|
|
4361
|
+
const expected = value.variableSetIds[value.variableSetIds.length - 1];
|
|
4362
|
+
if (singular !== expected) {
|
|
4363
|
+
context.addIssue({
|
|
4364
|
+
code: z4.ZodIssueCode.custom,
|
|
4365
|
+
path: ["variableSetId"],
|
|
4366
|
+
message: "variableSetId must match the last variableSetIds entry",
|
|
4367
|
+
});
|
|
4368
|
+
}
|
|
4369
|
+
})
|
|
4348
4370
|
.strict();
|
|
4349
4371
|
server.registerTool(
|
|
4350
4372
|
"session_create",
|
|
@@ -4355,6 +4377,11 @@ function registerWorkspaceOrchestrationTools(
|
|
|
4355
4377
|
},
|
|
4356
4378
|
async (args) => {
|
|
4357
4379
|
try {
|
|
4380
|
+
requireVariableSetsUseForMcpAttachments(grant, {
|
|
4381
|
+
variableSetIds: args.variableSetIds,
|
|
4382
|
+
variableSetId: args.variableSetId,
|
|
4383
|
+
environmentId: args.environmentId,
|
|
4384
|
+
});
|
|
4358
4385
|
if (callerSessionId !== null) {
|
|
4359
4386
|
await authorizeFirstPartySession(deps, grant, callerSessionId, "session.child.create");
|
|
4360
4387
|
}
|
|
@@ -4744,7 +4771,7 @@ function registerWorkspaceOrchestrationTools(
|
|
|
4744
4771
|
"set_other_session_title",
|
|
4745
4772
|
{
|
|
4746
4773
|
description:
|
|
4747
|
-
"Set another session's display title to a concise 3-7 word
|
|
4774
|
+
"Set another session's display title to a concise 3-7 word topic label. Use a stable noun phrase about the actual task or subject, never a quote/prefix of a prompt, greeting, request boilerplate, URL, identifier, credential, token, or other sensitive value. The target session must belong to this workspace. Replaces an existing automatic title unless a human has manually set it.",
|
|
4748
4775
|
inputSchema: {
|
|
4749
4776
|
session_id: z4.string().uuid(),
|
|
4750
4777
|
title: z4.string().min(1).max(200),
|
|
@@ -5342,6 +5369,25 @@ function requireVariableSetsUseForMcpAttachment(
|
|
|
5342
5369
|
}
|
|
5343
5370
|
}
|
|
5344
5371
|
|
|
5372
|
+
function requireVariableSetsUseForMcpAttachments(
|
|
5373
|
+
grant: AccessGrant,
|
|
5374
|
+
selection: {
|
|
5375
|
+
variableSetIds?: string[] | undefined;
|
|
5376
|
+
variableSetId?: string | undefined;
|
|
5377
|
+
environmentId?: string | undefined;
|
|
5378
|
+
},
|
|
5379
|
+
): void {
|
|
5380
|
+
const singular = selection.variableSetId ?? selection.environmentId;
|
|
5381
|
+
const variableSetIds = selection.variableSetIds ?? (singular ? [singular] : undefined);
|
|
5382
|
+
if (variableSetIds === undefined) return;
|
|
5383
|
+
if (!hasPermission(grant.permissions, "variable-sets:attach")) {
|
|
5384
|
+
throw new HTTPException(403, { message: "missing permission: variable-sets:attach" });
|
|
5385
|
+
}
|
|
5386
|
+
if (variableSetIds.length > 0 && !hasPermission(grant.permissions, "variable-sets:use")) {
|
|
5387
|
+
throw new HTTPException(403, { message: "missing permission: variable-sets:use" });
|
|
5388
|
+
}
|
|
5389
|
+
}
|
|
5390
|
+
|
|
5345
5391
|
/**
|
|
5346
5392
|
* Project one allowlisted GitHub App repository into the resource an agent or
|
|
5347
5393
|
* scheduled task attaches. Every listed repository is in the workspace
|