@opengeni/api-router 1.4.5-canary.38533733713001 → 2.0.1
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/attached-browser-viewer.d.ts +11 -0
- package/dist/auth/managed-auth-session-adapter.d.ts +6 -0
- package/dist/{chunk-D5WJ24GY.js → chunk-ZDTWXRFX.js} +137 -83
- package/dist/{chunk-D5WJ24GY.js.map → chunk-ZDTWXRFX.js.map} +1 -1
- package/dist/connection-ownership.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/integrations/marketplace-plugin.d.ts +43 -43
- package/dist/routes/native-app-auth.d.ts +5 -0
- package/dist/sandbox/screen-control.d.ts +1 -1
- package/package.json +23 -22
- package/src/app.ts +1 -0
- package/src/attached-browser-viewer.ts +26 -0
- package/src/auth/managed-auth-session-adapter.ts +38 -25
- package/src/connection-ownership.ts +2 -2
- package/src/integrations/oauth-client.ts +18 -19
- package/src/mcp/action-descriptions.ts +1 -1
- package/src/mcp/server.ts +6 -6
- package/src/routes/browser-sessions.ts +7 -11
- package/src/routes/codex-core.ts +5 -2
- package/src/routes/computer-sessions.ts +5 -7
- package/src/routes/machines.ts +1 -1
- package/src/routes/native-app-auth.ts +51 -4
- package/src/routes/sessions.ts +21 -2
- package/src/routes/subscription-pool-core.ts +2 -2
- package/src/routes/vault-providers.ts +3 -3
- package/src/sandbox/screen-control.ts +1 -1
|
@@ -62,7 +62,7 @@ export function requireLegacyOAuthActor(access: AccessGrantAuthorization): void
|
|
|
62
62
|
* The allow-list that actually decides is `principalKind === "human_session"`.
|
|
63
63
|
* This list is defence-in-depth against exactly one residual threat: a holder
|
|
64
64
|
* of the first-party delegation secret signing a human claim over an
|
|
65
|
-
*
|
|
65
|
+
* Opengeni-minted machine subject.
|
|
66
66
|
*
|
|
67
67
|
* `connection-ownership.test.ts` asserts that every machine subject named in
|
|
68
68
|
* its list is rejected. Note what that does and does not buy: it catches a
|
|
@@ -108,7 +108,7 @@ export function isPersonalConnectionOwnerPrincipal(access: AccessGrantAuthorizat
|
|
|
108
108
|
);
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
/** Rejects an
|
|
111
|
+
/** Rejects an Opengeni-minted machine subject; see the namespace list's rationale. */
|
|
112
112
|
export function isPersonalConnectionOwnerSubject(subjectId: string): boolean {
|
|
113
113
|
const separator = subjectId.indexOf(":");
|
|
114
114
|
if (separator < 0) {
|
|
@@ -672,28 +672,27 @@ async function startMcpOAuthWithinDeadline(
|
|
|
672
672
|
...(existing ? { connectionId: existing.id, connectionVersion: existing.version } : {}),
|
|
673
673
|
});
|
|
674
674
|
const expiresAt = new Date(Date.now() + oauthStateTtlMs);
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
675
|
+
// Providers cap `state` length (Stripe at 1024 characters), so only a short
|
|
676
|
+
// signed reference leaves; the full context waits encrypted at rest.
|
|
677
|
+
const stateId = randomUUID();
|
|
678
|
+
const state = createSignedState(requireIntegrationsStateSecret(settings), {
|
|
679
|
+
kind: "mcp_oauth_reference",
|
|
680
|
+
id: stateId,
|
|
681
|
+
accountId: context.accountId,
|
|
682
|
+
workspaceId: context.workspaceId,
|
|
683
|
+
});
|
|
684
|
+
if (state.length > 512) {
|
|
685
|
+
throw new Error("MCP OAuth reference state exceeded its provider-safe length");
|
|
686
|
+
}
|
|
687
|
+
await deadline.run("state_persist", async () =>
|
|
688
|
+
storeIntegrationOAuthPendingState(db, {
|
|
680
689
|
id: stateId,
|
|
681
690
|
accountId: context.accountId,
|
|
682
691
|
workspaceId: context.workspaceId,
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
await deadline.run("state_persist", async () =>
|
|
688
|
-
storeIntegrationOAuthPendingState(db, {
|
|
689
|
-
id: stateId,
|
|
690
|
-
accountId: context.accountId,
|
|
691
|
-
workspaceId: context.workspaceId,
|
|
692
|
-
stateEncrypted: encryptEnvironmentValue(key, fullState),
|
|
693
|
-
expiresAt,
|
|
694
|
-
}),
|
|
695
|
-
);
|
|
696
|
-
}
|
|
692
|
+
stateEncrypted: encryptEnvironmentValue(key, fullState),
|
|
693
|
+
expiresAt,
|
|
694
|
+
}),
|
|
695
|
+
);
|
|
697
696
|
const authorizationUrl = buildAuthorizationUrl({
|
|
698
697
|
endpoint: discovery.as.authorizationEndpoint,
|
|
699
698
|
settings,
|
|
@@ -51,7 +51,7 @@ const RULES: Rule[] = [
|
|
|
51
51
|
method: "PATCH",
|
|
52
52
|
pattern: /\/(codex|claude|supergrok)\/accounts\/:accountId\/allocator$/,
|
|
53
53
|
text: ({ provider }) =>
|
|
54
|
-
`Turn "
|
|
54
|
+
`Turn "Enable account" on or off for a ${provider} account (enable or disable it, sometimes called pause or resume). Disabled accounts aren't picked for new chats or schedules; running work continues. Send the current allocator version.`,
|
|
55
55
|
},
|
|
56
56
|
{
|
|
57
57
|
method: "PATCH",
|
package/src/mcp/server.ts
CHANGED
|
@@ -4262,7 +4262,7 @@ function registerFleetTools(
|
|
|
4262
4262
|
"sandbox_provision",
|
|
4263
4263
|
{
|
|
4264
4264
|
description:
|
|
4265
|
-
"Provision a new sandbox for the fleet. kind=selfhosted posts the Connected Machine card in this chat: the person copies a one-line connect command, runs it on their own computer, and picks Use in this chat; the card also offers the
|
|
4265
|
+
"Provision a new sandbox for the fleet. kind=selfhosted posts the Connected Machine card in this chat: the person copies a one-line connect command, runs it on their own computer, and picks Use in this chat; the card also offers the Opengeni Browser Chrome extension once the machine is connected. When you can already run commands on the target machine, install it yourself with connected_machine_enroll_token instead. kind=modal creates a named Modal sandbox record, but it is NOT yet attachable as a swap target: routing a session onto a second Modal box is not supported yet, so sandbox_swap to its id is rejected. Use the session's own box (the default) or attach a Connected Machine instead.",
|
|
4266
4266
|
inputSchema: {
|
|
4267
4267
|
kind: z4.enum(["selfhosted", "modal"]),
|
|
4268
4268
|
name: z4.string().min(1).max(120).optional(),
|
|
@@ -4303,7 +4303,7 @@ function registerFleetTools(
|
|
|
4303
4303
|
setup.status === "ready"
|
|
4304
4304
|
? `${setup.detail} The Connected Machine card lists them with Use in this chat and can connect another.`
|
|
4305
4305
|
: "The Connected Machine card was posted. The person copies its one-line connect command, runs it on their machine, and picks Use in this chat; nothing is connected yet."
|
|
4306
|
-
} Check sandboxes_list after they confirm. Chrome needs the
|
|
4306
|
+
} Check sandboxes_list after they confirm. Chrome needs the Opengeni Browser extension (${OPENGENI_BROWSER_EXTENSION_URL}) on the connected machine.`,
|
|
4307
4307
|
});
|
|
4308
4308
|
}
|
|
4309
4309
|
return json(
|
|
@@ -4332,7 +4332,7 @@ function registerConnectedMachineTools(
|
|
|
4332
4332
|
"connected_machine_enroll_token",
|
|
4333
4333
|
{
|
|
4334
4334
|
description:
|
|
4335
|
-
"Create a single-use Connected Machine enrollment token for this workspace (expires in one hour) using existing enrollments:manage authority. Returns the token, expiry and Unix/PowerShell install commands. Use it when you can run commands on the target machine yourself (run_on, SSH, a VM you control): run the matching command there, then verify the machine with sandboxes_list. One token connects one machine; mint another for each additional machine (the same machine may rerun its command). When the person must run it on their own computer, post the Connected Machine card instead (capability_authorization_request with api:connected-machine), which mints the command in their browser; do not paste a token into the chat. Screen control is optional and defaults off; to turn it on for a machine that is already connected, use connected_machine_enable_screen_control instead of reconnecting. The token grants whole-machine access on enrollment; do not put it in public code or unrelated logs. Existing machine connections are preserved. For Chrome, the person installs the
|
|
4335
|
+
"Create a single-use Connected Machine enrollment token for this workspace (expires in one hour) using existing enrollments:manage authority. Returns the token, expiry and Unix/PowerShell install commands. Use it when you can run commands on the target machine yourself (run_on, SSH, a VM you control): run the matching command there, then verify the machine with sandboxes_list. One token connects one machine; mint another for each additional machine (the same machine may rerun its command). When the person must run it on their own computer, post the Connected Machine card instead (capability_authorization_request with api:connected-machine), which mints the command in their browser; do not paste a token into the chat. Screen control is optional and defaults off; to turn it on for a machine that is already connected, use connected_machine_enable_screen_control instead of reconnecting. The token grants whole-machine access on enrollment; do not put it in public code or unrelated logs. Existing machine connections are preserved. For Chrome, the person installs the Opengeni Browser extension (" +
|
|
4336
4336
|
OPENGENI_BROWSER_EXTENSION_URL +
|
|
4337
4337
|
") after the machine is connected.",
|
|
4338
4338
|
inputSchema: { allowScreenControl: z4.boolean().optional() },
|
|
@@ -4363,7 +4363,7 @@ function registerConnectedMachineTools(
|
|
|
4363
4363
|
"connected_machine_enable_screen_control",
|
|
4364
4364
|
{
|
|
4365
4365
|
description:
|
|
4366
|
-
"Turn screen control on for a machine that is already connected, in place, so agents can see and use its screen (computer and attached-Chrome tools). `target` is a sandboxes_list id or an enrollment id. Needs no human click, token, terminal or reconnect:
|
|
4366
|
+
"Turn screen control on for a machine that is already connected, in place, so agents can see and use its screen (computer and attached-Chrome tools). `target` is a sandboxes_list id or an enrollment id. Needs no human click, token, terminal or reconnect: Opengeni records the consent on that machine and its agent refreshes its own credentials over the live connection. status=active means it is on now. status=pending means it is allowed and turns on by itself: reason=offline when the machine next connects; reason=agent_update_required after its agent is updated (update it with the machine update action, POST /v1/workspaces/{workspaceId}/machines/{enrollmentId}/update); reason=renewal_failed: call again shortly; reason=reconnect_required: the machine's connection predates in-place changes, so rerun the connect command on it (connected_machine_enroll_token with allowScreenControl=true). On a Mac the OS must also allow Screen Recording, Accessibility and Input Monitoring for Opengeni: open each pane on the Mac with run_on exec `open 'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture'` (Privacy_Accessibility, Privacy_ListenEvent; also the privacy-settings machine action) and ask the person to switch Opengeni on there, choosing Quit & Reopen if macOS asks; the machine list reports runtime.macPermissions. Chrome additionally needs the Opengeni Browser extension (" +
|
|
4367
4367
|
OPENGENI_BROWSER_EXTENSION_URL +
|
|
4368
4368
|
") on that machine.",
|
|
4369
4369
|
inputSchema: { target: z4.string().min(1) },
|
|
@@ -6555,7 +6555,7 @@ function registerCapabilityDiscoveryTools(
|
|
|
6555
6555
|
"capability_authorization_request",
|
|
6556
6556
|
{
|
|
6557
6557
|
description:
|
|
6558
|
-
"Show a Connect card in this chat for a suitable capability returned by capability_catalog_search with setup.nextAction. Supply its capability ID and a brief rationale explaining how it helps the task; no separate confirmation is needed before showing the card. Requesting setup needs no integration-management permission and grants no access. The authenticated human completes setup through the card; never ask them to paste credentials into chat. Do not request another card for the same pending setup, or for a candidate reported ready or unavailable. GitHub App (api:github-app) and Connected Machine (api:connected-machine) are exceptions: when already connected their card is still shown, listing repositories or machines the person can use in this chat. Use api:connected-machine when the person should connect their own computer: the card gives them a one-line connect command and the
|
|
6558
|
+
"Show a Connect card in this chat for a suitable capability returned by capability_catalog_search with setup.nextAction. Supply its capability ID and a brief rationale explaining how it helps the task; no separate confirmation is needed before showing the card. Requesting setup needs no integration-management permission and grants no access. The authenticated human completes setup through the card; never ask them to paste credentials into chat. Do not request another card for the same pending setup, or for a candidate reported ready or unavailable. GitHub App (api:github-app) and Connected Machine (api:connected-machine) are exceptions: when already connected their card is still shown, listing repositories or machines the person can use in this chat. Use api:connected-machine when the person should connect their own computer: the card gives them a one-line connect command and the Opengeni Browser Chrome extension link; never paste an enroll token into the chat yourself." +
|
|
6559
6559
|
(secretsSelected
|
|
6560
6560
|
? ' For a single password, API key or code for a service with no connector here, ask with a kind: "secret" question in request_human_input instead; you get a saved reference back.'
|
|
6561
6561
|
: ""),
|
|
@@ -6610,7 +6610,7 @@ function registerCapabilityDiscoveryTools(
|
|
|
6610
6610
|
capabilityId: item.id,
|
|
6611
6611
|
status: "ready",
|
|
6612
6612
|
eventId,
|
|
6613
|
-
message: `${setup.detail} The Connected Machine card is in this chat: the person can pick a machine with "Use in this chat" or connect another. Chrome needs the
|
|
6613
|
+
message: `${setup.detail} The Connected Machine card is in this chat: the person can pick a machine with "Use in this chat" or connect another. Chrome needs the Opengeni Browser extension (${OPENGENI_BROWSER_EXTENSION_URL}) on a connected machine.`,
|
|
6614
6614
|
});
|
|
6615
6615
|
}
|
|
6616
6616
|
if (githubCardWhenReady) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { attachedBrowserViewer } from "../attached-browser-viewer";
|
|
1
2
|
import {
|
|
2
3
|
assertEphemeralBrowserCreateEnabled,
|
|
3
4
|
ephemeralBrowserPartition,
|
|
@@ -271,8 +272,7 @@ export function registerBrowserSessionRoutes(
|
|
|
271
272
|
return context.json(
|
|
272
273
|
AttachedBrowserDeviceListResponse.parse(
|
|
273
274
|
await listAttachedBrowserDevices(deps.db, {
|
|
274
|
-
|
|
275
|
-
workspaceId,
|
|
275
|
+
...(await attachedBrowserViewer(deps, grant, workspaceId)),
|
|
276
276
|
includeDisconnected,
|
|
277
277
|
}),
|
|
278
278
|
),
|
|
@@ -287,8 +287,7 @@ export function registerBrowserSessionRoutes(
|
|
|
287
287
|
return context.json(
|
|
288
288
|
AttachedBrowserDevice.parse(
|
|
289
289
|
await getAttachedBrowserDevice(deps.db, {
|
|
290
|
-
|
|
291
|
-
workspaceId,
|
|
290
|
+
...(await attachedBrowserViewer(deps, grant, workspaceId)),
|
|
292
291
|
deviceId,
|
|
293
292
|
}),
|
|
294
293
|
),
|
|
@@ -2692,20 +2691,17 @@ export function registerBrowserSessionRoutes(
|
|
|
2692
2691
|
): Promise<T> {
|
|
2693
2692
|
if (expectedPlacement?.kind === "attached_device") {
|
|
2694
2693
|
waitSignal.throwIfAborted();
|
|
2694
|
+
// Personal machines are controlled only for their owner, as everywhere else.
|
|
2695
|
+
const viewer = await attachedBrowserViewer(deps, grant, sourceSession.workspaceId);
|
|
2695
2696
|
const device = await getAttachedBrowserDevice(deps.db, {
|
|
2696
|
-
|
|
2697
|
-
workspaceId: sourceSession.workspaceId,
|
|
2697
|
+
...viewer,
|
|
2698
2698
|
deviceId: expectedPlacement.deviceId,
|
|
2699
2699
|
});
|
|
2700
2700
|
if (device.state !== "connected") {
|
|
2701
2701
|
recordAttachedBrowserUnavailable(deps.observability, "disconnected");
|
|
2702
2702
|
throw new BrowserSessionStateError("Attached browser is disconnected");
|
|
2703
2703
|
}
|
|
2704
|
-
const enrollment = await getLiveEnrollmentConnection(
|
|
2705
|
-
deps.db,
|
|
2706
|
-
sourceSession.workspaceId,
|
|
2707
|
-
device.enrollmentId,
|
|
2708
|
-
);
|
|
2704
|
+
const enrollment = await getLiveEnrollmentConnection(deps.db, viewer, device.enrollmentId);
|
|
2709
2705
|
if (!enrollment || enrollment.status !== "active" || !enrollment.connectionInstanceId) {
|
|
2710
2706
|
recordAttachedBrowserUnavailable(deps.observability, "machine_unavailable");
|
|
2711
2707
|
throw new BrowserSessionStateError("Attached browser machine is unavailable");
|
package/src/routes/codex-core.ts
CHANGED
|
@@ -125,7 +125,9 @@ export async function coreCodexConnected(
|
|
|
125
125
|
default:
|
|
126
126
|
throw new HTTPException(403, {
|
|
127
127
|
message:
|
|
128
|
-
|
|
128
|
+
input.workspaceId === null
|
|
129
|
+
? "only an organization administrator can connect a Codex account for the whole organization"
|
|
130
|
+
: "only an administrator of this workspace or of the organization can connect a Codex account here",
|
|
129
131
|
});
|
|
130
132
|
}
|
|
131
133
|
}
|
|
@@ -155,7 +157,8 @@ export async function coreCodexConnected(
|
|
|
155
157
|
function disconnectRefused(outcome: SubscriptionCoreCodexDisconnectOutcome): void {
|
|
156
158
|
if (outcome === "forbidden") {
|
|
157
159
|
throw new HTTPException(403, {
|
|
158
|
-
message:
|
|
160
|
+
message:
|
|
161
|
+
"only an organization administrator can disconnect this Codex account; a workspace administrator can disconnect only an account that serves just their workspace",
|
|
159
162
|
});
|
|
160
163
|
}
|
|
161
164
|
if (outcome === "unresolved_redemption") {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { attachedBrowserViewer } from "../attached-browser-viewer";
|
|
1
2
|
import { randomUUID } from "node:crypto";
|
|
2
3
|
import {
|
|
3
4
|
resolveFirstPartyDelegationSecret,
|
|
@@ -1114,19 +1115,16 @@ export function registerComputerSessionRoutes(
|
|
|
1114
1115
|
}
|
|
1115
1116
|
if (expectedPlacement?.kind === "attached_device") {
|
|
1116
1117
|
waitSignal.throwIfAborted();
|
|
1118
|
+
// Personal machines are controlled only for their owner, as everywhere else.
|
|
1119
|
+
const viewer = await attachedBrowserViewer(deps, grant, sourceSession.workspaceId);
|
|
1117
1120
|
const device = await getAttachedBrowserDevice(deps.db, {
|
|
1118
|
-
|
|
1119
|
-
workspaceId: sourceSession.workspaceId,
|
|
1121
|
+
...viewer,
|
|
1120
1122
|
deviceId: expectedPlacement.deviceId,
|
|
1121
1123
|
});
|
|
1122
1124
|
if (device.state !== "connected") {
|
|
1123
1125
|
throw new ComputerSessionStateError("Attached browser machine is disconnected");
|
|
1124
1126
|
}
|
|
1125
|
-
const enrollment = await getLiveEnrollmentConnection(
|
|
1126
|
-
deps.db,
|
|
1127
|
-
sourceSession.workspaceId,
|
|
1128
|
-
device.enrollmentId,
|
|
1129
|
-
);
|
|
1127
|
+
const enrollment = await getLiveEnrollmentConnection(deps.db, viewer, device.enrollmentId);
|
|
1130
1128
|
if (!enrollment || enrollment.status !== "active" || !enrollment.connectionInstanceId) {
|
|
1131
1129
|
throw new ComputerSessionStateError("Attached browser machine is unavailable");
|
|
1132
1130
|
}
|
package/src/routes/machines.ts
CHANGED
|
@@ -315,7 +315,7 @@ export function registerMachineRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
315
315
|
|
|
316
316
|
// ── POST /workspaces/:ws/machines/:enrollmentId/privacy-settings ───────────
|
|
317
317
|
// Open one macOS Privacy & Security pane on the connected Mac, so the person
|
|
318
|
-
// only flips the switch for
|
|
318
|
+
// only flips the switch for Opengeni. Opening a pane grants nothing.
|
|
319
319
|
app.post("/v1/workspaces/:workspaceId/machines/:enrollmentId/privacy-settings", async (c) => {
|
|
320
320
|
const workspaceId = c.req.param("workspaceId");
|
|
321
321
|
const authorization = await requireAccessGrantAuthorization(
|
|
@@ -17,6 +17,7 @@ import type { Hono } from "hono";
|
|
|
17
17
|
import { HTTPException } from "hono/http-exception";
|
|
18
18
|
import { z } from "zod";
|
|
19
19
|
import { runManagedAuthProvider } from "../auth/managed-auth-attempt-context";
|
|
20
|
+
import { managedAuthSessionCookieHeaders } from "../auth/managed-auth-session-adapter";
|
|
20
21
|
import { admitManagedSignInMutation } from "./managed-sign-in-methods";
|
|
21
22
|
|
|
22
23
|
// Native app sign-in (RFC 8252 shape: authorization code + PKCE over a custom
|
|
@@ -32,13 +33,30 @@ const CODE_IDENTIFIER_PREFIX = "opengeni-native-app-code:";
|
|
|
32
33
|
const PKCE_VERIFIER = /^[A-Za-z0-9._~-]{43,128}$/u;
|
|
33
34
|
const PKCE_CHALLENGE = /^[A-Za-z0-9_-]{43}$/u;
|
|
34
35
|
const MANAGED_PROVIDERS = ["credential", "google", "github"] as const;
|
|
36
|
+
export const NATIVE_APP_PLATFORMS = ["ios", "android", "macos", "windows", "linux"] as const;
|
|
37
|
+
type NativeAppPlatform = (typeof NATIVE_APP_PLATFORMS)[number];
|
|
38
|
+
const PLATFORM_LABELS: Record<NativeAppPlatform, string> = {
|
|
39
|
+
ios: "iOS",
|
|
40
|
+
android: "Android",
|
|
41
|
+
macos: "macOS",
|
|
42
|
+
windows: "Windows",
|
|
43
|
+
linux: "Linux",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** The device label shown in the person's signed-in sessions. */
|
|
47
|
+
export function nativeAppSessionUserAgent(
|
|
48
|
+
platform: NativeAppPlatform,
|
|
49
|
+
deviceName: string | null,
|
|
50
|
+
): string {
|
|
51
|
+
return `Opengeni app (${PLATFORM_LABELS[platform]}${deviceName ? `; ${deviceName}` : ""})`;
|
|
52
|
+
}
|
|
35
53
|
|
|
36
54
|
const AuthorizeBody = z.object({
|
|
37
55
|
redirectUri: z.string().min(1).max(512),
|
|
38
56
|
codeChallenge: z.string().regex(PKCE_CHALLENGE),
|
|
39
57
|
codeChallengeMethod: z.literal("S256"),
|
|
40
58
|
state: z.string().min(8).max(256).optional(),
|
|
41
|
-
platform: z.enum(
|
|
59
|
+
platform: z.enum(NATIVE_APP_PLATFORMS),
|
|
42
60
|
deviceName: z.string().trim().min(1).max(80).optional(),
|
|
43
61
|
});
|
|
44
62
|
|
|
@@ -61,7 +79,7 @@ const StoredGrant = z.object({
|
|
|
61
79
|
providerId: z.enum(MANAGED_PROVIDERS),
|
|
62
80
|
redirectUri: z.string(),
|
|
63
81
|
codeChallenge: z.string().regex(PKCE_CHALLENGE),
|
|
64
|
-
platform: z.enum(
|
|
82
|
+
platform: z.enum(NATIVE_APP_PLATFORMS),
|
|
65
83
|
deviceName: z.string().nullable(),
|
|
66
84
|
});
|
|
67
85
|
|
|
@@ -177,10 +195,9 @@ export function registerNativeAppAuthRoutes(app: Hono, deps: ApiRouteDeps): void
|
|
|
177
195
|
) {
|
|
178
196
|
return context.json({ error: "invalid_grant" }, 400);
|
|
179
197
|
}
|
|
180
|
-
const platform = grant.platform === "ios" ? "iOS" : "Android";
|
|
181
198
|
const session = await runManagedAuthProvider(grant.providerId, () =>
|
|
182
199
|
authContext.internalAdapter.createSession(grant.userId, false, {
|
|
183
|
-
userAgent:
|
|
200
|
+
userAgent: nativeAppSessionUserAgent(grant.platform, grant.deviceName),
|
|
184
201
|
}),
|
|
185
202
|
);
|
|
186
203
|
if (!session?.token || new Date(session.expiresAt).getTime() <= Date.now()) {
|
|
@@ -209,6 +226,36 @@ export function registerNativeAppAuthRoutes(app: Hono, deps: ApiRouteDeps): void
|
|
|
209
226
|
return context.json({ signedOut: true });
|
|
210
227
|
});
|
|
211
228
|
|
|
229
|
+
// A desktop app shows the deployment's own web app. It signs that window in
|
|
230
|
+
// with the same device session, so the window is an ordinary first-party web
|
|
231
|
+
// session and signing the device out ends both. The bearer is required: an
|
|
232
|
+
// ambient browser request can never mint this cookie.
|
|
233
|
+
app.post(`${base}/web-session`, async (context) => {
|
|
234
|
+
const auth = deps.managedAuth;
|
|
235
|
+
if (!auth) throw new HTTPException(404);
|
|
236
|
+
if (deps.settings.managedAuthSessionSetMode === "broker") {
|
|
237
|
+
throw new HTTPException(409, {
|
|
238
|
+
message: "This deployment signs browser windows in through login session sets",
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
const credential = bearer(context.req.header("authorization"));
|
|
242
|
+
if (!credential?.startsWith(NATIVE_APP_CREDENTIAL_PREFIX)) {
|
|
243
|
+
throw new HTTPException(401, { message: "Native app credential required" });
|
|
244
|
+
}
|
|
245
|
+
const session = await getNativeAppManagedSession(auth, credential, deps.db).catch(() => null);
|
|
246
|
+
if (typeof session?.session?.id !== "string") {
|
|
247
|
+
throw new HTTPException(401, { message: "Sign in again" });
|
|
248
|
+
}
|
|
249
|
+
for (const cookie of await managedAuthSessionCookieHeaders(
|
|
250
|
+
auth,
|
|
251
|
+
credential.slice(NATIVE_APP_CREDENTIAL_PREFIX.length),
|
|
252
|
+
context.req.header("cookie") ?? null,
|
|
253
|
+
)) {
|
|
254
|
+
context.header("set-cookie", cookie, { append: true });
|
|
255
|
+
}
|
|
256
|
+
return context.json({ signedIn: true });
|
|
257
|
+
});
|
|
258
|
+
|
|
212
259
|
// Push registration belongs to the app session that registered it: signing
|
|
213
260
|
// the app out, or revoking its session anywhere, removes it.
|
|
214
261
|
const appSessionId = async (authorization: string | undefined): Promise<string> => {
|
package/src/routes/sessions.ts
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
RealtimeVoiceUnavailableError,
|
|
23
23
|
TranscriptionBillingRefusedError,
|
|
24
24
|
} from "@opengeni/core";
|
|
25
|
-
import type { CreditDebitAttribution } from "@opengeni/db";
|
|
25
|
+
import type { CreditDebitAttribution, SessionForkPointRefusal } from "@opengeni/db";
|
|
26
26
|
import { creditDebitAttributionForGrant } from "../access-grant-rls";
|
|
27
27
|
import { transcriptionBillingRefusal } from "../transcription/billing-refusal";
|
|
28
28
|
import { resolveSiteSessionOrigin, withOptionalSiteCommandOrigin } from "../site-session-origin";
|
|
@@ -606,7 +606,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
606
606
|
});
|
|
607
607
|
|
|
608
608
|
// Every deployment has one fail-closed authorization seam for every HTTP
|
|
609
|
-
// session surface. The core boundary always enforces durable
|
|
609
|
+
// session surface. The core boundary always enforces durable Opengeni-owned
|
|
610
610
|
// private-session rules; an embedding host port can add narrower policy.
|
|
611
611
|
// Register it before the routes so a newly added path cannot accidentally
|
|
612
612
|
// inherit workspace access without an explicit operation classification. The
|
|
@@ -5603,6 +5603,17 @@ function sessionAuthorizationHttpError(error: unknown): HTTPException {
|
|
|
5603
5603
|
throw error;
|
|
5604
5604
|
}
|
|
5605
5605
|
|
|
5606
|
+
const SESSION_FORK_POINT_REFUSAL_MESSAGES: Record<SessionForkPointRefusal, string> = {
|
|
5607
|
+
message_unavailable:
|
|
5608
|
+
"Only your messages and the agent's replies can be fork points. Choose one of those, or fork the whole session.",
|
|
5609
|
+
boundary_not_retained:
|
|
5610
|
+
"This message is from before the conversation was compacted, so it can't be a fork point. Choose a more recent message, or fork the whole session.",
|
|
5611
|
+
history_compacted:
|
|
5612
|
+
"The conversation up to this message was compacted, so it can't be copied as it was then. Choose a more recent message, or fork the whole session.",
|
|
5613
|
+
unfinished_tool_exchange:
|
|
5614
|
+
"The agent was still waiting for a tool result at this message. Choose a later message, or fork the whole session.",
|
|
5615
|
+
};
|
|
5616
|
+
|
|
5606
5617
|
export function sessionTenancyHttpError(error: unknown): Error {
|
|
5607
5618
|
if (error instanceof SessionTenancyManagedHumanRequiredError) {
|
|
5608
5619
|
return new ApiHttpError(403, {
|
|
@@ -5665,6 +5676,14 @@ export function sessionTenancyHttpError(error: unknown): Error {
|
|
|
5665
5676
|
});
|
|
5666
5677
|
}
|
|
5667
5678
|
if (error instanceof SessionTenancyInvalidRequestError) {
|
|
5679
|
+
if (error.forkPointRefusal) {
|
|
5680
|
+
return new ApiHttpError(422, {
|
|
5681
|
+
code: "validation_failed",
|
|
5682
|
+
message: SESSION_FORK_POINT_REFUSAL_MESSAGES[error.forkPointRefusal],
|
|
5683
|
+
retryable: false,
|
|
5684
|
+
details: { reason: `fork_point_${error.forkPointRefusal}` },
|
|
5685
|
+
});
|
|
5686
|
+
}
|
|
5668
5687
|
return new ApiHttpError(422, {
|
|
5669
5688
|
code: "validation_failed",
|
|
5670
5689
|
message: "Invalid session tenancy request.",
|
|
@@ -184,7 +184,7 @@ export function coreConnectRefused(
|
|
|
184
184
|
return new HTTPException(personal ? 409 : 403, {
|
|
185
185
|
message: personal
|
|
186
186
|
? `a private ${displayName} account is connected from your Personal workspace`
|
|
187
|
-
: `only an organization
|
|
187
|
+
: `only an administrator of this workspace or of the organization can connect a ${displayName} account here`,
|
|
188
188
|
});
|
|
189
189
|
}
|
|
190
190
|
}
|
|
@@ -194,7 +194,7 @@ function disconnectRefused(outcome: SubscriptionCoreDisconnectOutcome, displayNa
|
|
|
194
194
|
throw new HTTPException(404, { message: `${displayName} account not found` });
|
|
195
195
|
if (outcome === "forbidden")
|
|
196
196
|
throw new HTTPException(403, {
|
|
197
|
-
message: `only an organization administrator can disconnect
|
|
197
|
+
message: `only an organization administrator can disconnect this ${displayName} account; a workspace administrator can disconnect only an account that serves just their workspace`,
|
|
198
198
|
});
|
|
199
199
|
if (outcome === "unresolved_redemption")
|
|
200
200
|
throw new HTTPException(409, {
|
|
@@ -385,7 +385,7 @@ export function registerVaultProviderRoutes(
|
|
|
385
385
|
);
|
|
386
386
|
});
|
|
387
387
|
|
|
388
|
-
// The vaults a stored 1Password connection reaches, with the
|
|
388
|
+
// The vaults a stored 1Password connection reaches, with the Opengeni vault
|
|
389
389
|
// each one is linked to (if any). One `vaults.list` request.
|
|
390
390
|
app.get("/v1/workspaces/:workspaceId/connections/:connectionId/provider-vaults", async (c) => {
|
|
391
391
|
const workspaceId = c.req.param("workspaceId");
|
|
@@ -427,7 +427,7 @@ export function registerVaultProviderRoutes(
|
|
|
427
427
|
});
|
|
428
428
|
|
|
429
429
|
/**
|
|
430
|
-
* Link a provider vault as an
|
|
430
|
+
* Link a provider vault as an Opengeni vault, then mirror its metadata.
|
|
431
431
|
* The link stands even if the first sync fails; its status says why.
|
|
432
432
|
*/
|
|
433
433
|
app.post("/v1/workspaces/:workspaceId/vaults/link", async (c) => {
|
|
@@ -555,7 +555,7 @@ export function registerVaultProviderRoutes(
|
|
|
555
555
|
}
|
|
556
556
|
const created = await provider.createVault(routeProviderContext(connectionId), {
|
|
557
557
|
name: payload.name,
|
|
558
|
-
description: "Managed by
|
|
558
|
+
description: "Managed by Opengeni",
|
|
559
559
|
allowAdminsAccess: true,
|
|
560
560
|
...(payload.groupId ? { groupId: payload.groupId } : {}),
|
|
561
561
|
});
|
|
@@ -251,7 +251,7 @@ export async function reconcileScreenControlOnHello(
|
|
|
251
251
|
}
|
|
252
252
|
|
|
253
253
|
/** Open one macOS Privacy & Security pane on a connected Mac. Opening a pane
|
|
254
|
-
* grants nothing; the person flips the switch for
|
|
254
|
+
* grants nothing; the person flips the switch for Opengeni. */
|
|
255
255
|
export async function openMachinePrivacySettings(
|
|
256
256
|
services: Services,
|
|
257
257
|
input: {
|