@open-everywhere/adapters 0.3.0 → 0.5.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/README.md +0 -1
- package/agent/aws/config.ts +13 -16
- package/agent/aws/register.ts +2 -2
- package/agent/aws/sso.ts +2 -2
- package/agent/browser/register.ts +12 -1
- package/agent/directory/config.ts +22 -47
- package/agent/model/gateway.ts +26 -1
- package/agent/oe-oauth/config.ts +25 -45
- package/agent/oe-oauth/register.ts +14 -12
- package/agent/oe-oauth/token-vault.ts +19 -9
- package/agent/oe-oauth/user-auth-flow.ts +2 -3
- package/agent/register-defaults.ts +18 -19
- package/agent/sandbox/backend-stop.ts +10 -10
- package/dist/agent/aws/config.d.ts +5 -3
- package/dist/agent/aws/config.d.ts.map +1 -1
- package/dist/agent/aws/config.js +12 -16
- package/dist/agent/aws/config.js.map +1 -1
- package/dist/agent/aws/register.js +2 -2
- package/dist/agent/aws/register.js.map +1 -1
- package/dist/agent/aws/sso.js +2 -2
- package/dist/agent/aws/sso.js.map +1 -1
- package/dist/agent/browser/register.d.ts +8 -0
- package/dist/agent/browser/register.d.ts.map +1 -1
- package/dist/agent/browser/register.js +10 -1
- package/dist/agent/browser/register.js.map +1 -1
- package/dist/agent/directory/config.d.ts +7 -4
- package/dist/agent/directory/config.d.ts.map +1 -1
- package/dist/agent/directory/config.js +17 -41
- package/dist/agent/directory/config.js.map +1 -1
- package/dist/agent/model/gateway.d.ts +4 -0
- package/dist/agent/model/gateway.d.ts.map +1 -1
- package/dist/agent/model/gateway.js +24 -2
- package/dist/agent/model/gateway.js.map +1 -1
- package/dist/agent/oe-oauth/config.d.ts +5 -8
- package/dist/agent/oe-oauth/config.d.ts.map +1 -1
- package/dist/agent/oe-oauth/config.js +25 -46
- package/dist/agent/oe-oauth/config.js.map +1 -1
- package/dist/agent/oe-oauth/register.d.ts +4 -6
- package/dist/agent/oe-oauth/register.d.ts.map +1 -1
- package/dist/agent/oe-oauth/register.js +9 -8
- package/dist/agent/oe-oauth/register.js.map +1 -1
- package/dist/agent/oe-oauth/token-vault.d.ts +4 -1
- package/dist/agent/oe-oauth/token-vault.d.ts.map +1 -1
- package/dist/agent/oe-oauth/token-vault.js +13 -10
- package/dist/agent/oe-oauth/token-vault.js.map +1 -1
- package/dist/agent/oe-oauth/user-auth-flow.d.ts +2 -3
- package/dist/agent/oe-oauth/user-auth-flow.d.ts.map +1 -1
- package/dist/agent/oe-oauth/user-auth-flow.js.map +1 -1
- package/dist/agent/register-defaults.d.ts +7 -3
- package/dist/agent/register-defaults.d.ts.map +1 -1
- package/dist/agent/register-defaults.js +15 -19
- package/dist/agent/register-defaults.js.map +1 -1
- package/dist/agent/sandbox/backend-stop.d.ts +5 -6
- package/dist/agent/sandbox/backend-stop.d.ts.map +1 -1
- package/dist/agent/sandbox/backend-stop.js +9 -9
- package/dist/agent/sandbox/backend-stop.js.map +1 -1
- package/package.json +3 -9
- package/agent/blob/hosting.ts +0 -24
package/README.md
CHANGED
|
@@ -9,7 +9,6 @@ It implements core's host seams against concrete services:
|
|
|
9
9
|
|
|
10
10
|
- **AI Gateway** — model routing and the pricing the spend reports read
|
|
11
11
|
- **Sandbox** — the isolated workspace a turn runs its commands in
|
|
12
|
-
- **Blob** — file storage
|
|
13
12
|
- **Neon** — Postgres for the operational and memory stores
|
|
14
13
|
- **OpenTelemetry** — the traces and the telemetry export
|
|
15
14
|
- **AWS** and **Okta** — the directory and credential integrations
|
package/agent/aws/config.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
// The `aws` access master arming flag and config block. Flag-off ⇒ NOTHING registers and core's AWS paths
|
|
2
|
-
// fail closed, with no fallback.
|
|
2
|
+
// fail closed, with no fallback. The two CHOICES are settings since OPE-2411 — `aws.enabled` (was
|
|
3
|
+
// OE_AWS_ENABLED) and `aws.ssoScopes` (was OE_AWS_SSO_SCOPES) — because arming the plane and widening
|
|
4
|
+
// what it asks for are policy. The WIRING (regions, the public OIDC client id) stays env-sourced at the
|
|
5
|
+
// adapter boundary, like DATABASE_URL.
|
|
6
|
+
|
|
7
|
+
import { getSettings } from "@open-everywhere/core/agent/adapter-api.js";
|
|
3
8
|
|
|
4
|
-
/** The master arming flag env var — one string this module's readers share. */
|
|
5
|
-
const OE_AWS_ENABLED_ENV = "OE_AWS_ENABLED";
|
|
6
9
|
/** Optional default region for STS + the CLI when a grant names none. */
|
|
7
10
|
const OE_AWS_REGION_ENV = "OE_AWS_REGION";
|
|
8
11
|
|
|
@@ -14,16 +17,11 @@ const OE_AWS_REGION_ENV = "OE_AWS_REGION";
|
|
|
14
17
|
const OE_AWS_SSO_REGION_ENV = "OE_AWS_SSO_REGION";
|
|
15
18
|
/** The pre-registered PUBLIC OIDC client id for the Identity Center auth-code + PKCE link flow. */
|
|
16
19
|
const OE_AWS_SSO_CLIENT_ID_ENV = "OE_AWS_SSO_CLIENT_ID";
|
|
17
|
-
/**
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
/** Truthiness for the master flag — same off-spellings as the other connector-plane flags (default
|
|
21
|
-
* OFF: off is the inert posture; arming is a deliberate operator act). */
|
|
20
|
+
/** The master flag: the `aws.enabled` setting (default OFF: off is the inert posture; arming is a
|
|
21
|
+
* deliberate, reviewed commit). Read from the registered settings, so it is called at boot AFTER
|
|
22
|
+
* `registerSettings` (register-defaults.ts runs from the app's register-adapters, after register-config). */
|
|
22
23
|
export function awsAccessEnabled(): boolean {
|
|
23
|
-
|
|
24
|
-
if (raw === undefined) return false;
|
|
25
|
-
const v = raw.trim().toLowerCase();
|
|
26
|
-
return v !== "" && v !== "0" && v !== "false" && v !== "off" && v !== "no";
|
|
24
|
+
return getSettings().aws.enabled;
|
|
27
25
|
}
|
|
28
26
|
|
|
29
27
|
/** The AWS-access config block (env-sourced). Only a default region today; the identity/executor DI
|
|
@@ -53,16 +51,15 @@ export interface AwsSsoConfig {
|
|
|
53
51
|
readonly scopes?: readonly string[];
|
|
54
52
|
}
|
|
55
53
|
|
|
56
|
-
/** Load the Identity Center SSO link config
|
|
54
|
+
/** Load the Identity Center SSO link config: the region and client id from env, the scopes from the
|
|
55
|
+
* `aws.ssoScopes` setting (empty ⇒ the minter's default). Region falls back to the general AWS default. */
|
|
57
56
|
export function loadAwsSsoConfig(): AwsSsoConfig {
|
|
58
57
|
const region =
|
|
59
58
|
process.env[OE_AWS_SSO_REGION_ENV]?.trim() ||
|
|
60
59
|
process.env[OE_AWS_REGION_ENV]?.trim() ||
|
|
61
60
|
process.env.AWS_REGION?.trim();
|
|
62
61
|
const clientId = process.env[OE_AWS_SSO_CLIENT_ID_ENV]?.trim();
|
|
63
|
-
const scopes =
|
|
64
|
-
? process.env[OE_AWS_SSO_SCOPES_ENV]!.trim().split(/\s+/u).filter(Boolean)
|
|
65
|
-
: undefined;
|
|
62
|
+
const scopes = getSettings().aws.ssoScopes;
|
|
66
63
|
return {
|
|
67
64
|
...(region ? { region } : {}),
|
|
68
65
|
...(clientId ? { clientId } : {}),
|
package/agent/aws/register.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Register the AWS-access layer, called only when the
|
|
1
|
+
// Register the AWS-access layer, called only when the `aws.enabled` master flag is armed. Flag-off ⇒
|
|
2
2
|
// nothing registers and core's AWS mint, exec and link paths fail closed; rollback is not arming the flag.
|
|
3
3
|
|
|
4
4
|
import {
|
|
@@ -53,7 +53,7 @@ export function registerAwsAccess(opts: RegisterAwsAccessOptions = {}): {
|
|
|
53
53
|
});
|
|
54
54
|
|
|
55
55
|
// Wire the `GET OE_AWS_SSO_CALLBACK_PATH` route to the SSO minter's code→token exchange, mirroring the
|
|
56
|
-
// own-rolled OAuth adapter's registerOeOauthCallbackCompleter. Fires under the
|
|
56
|
+
// own-rolled OAuth adapter's registerOeOauthCallbackCompleter. Fires under the `aws.enabled` boot
|
|
57
57
|
// guard; flag-off ⇒ the completer is never registered and the route fails closed (generic error page).
|
|
58
58
|
registerAwsSsoCallbackCompleter({ complete: ({ state, code }) => sso.completeLink(state, code) });
|
|
59
59
|
|
package/agent/aws/sso.ts
CHANGED
|
@@ -25,7 +25,7 @@ export { OE_AWS_SSO_CALLBACK_PATH };
|
|
|
25
25
|
* what makes the token response carry an id-token, and the id-token's `sub` is the only statement of
|
|
26
26
|
* WHO completed the sign-in the callback can observe. Without it {@link AwsSsoMinter.completeLink}
|
|
27
27
|
* refuses every completion, because it cannot name the signer (OPE-1955). An operator who overrides
|
|
28
|
-
* `
|
|
28
|
+
* the `aws.ssoScopes` setting must keep `openid` in the list. */
|
|
29
29
|
const DEFAULT_SSO_SCOPES = ["sso:account:access", "openid"] as const;
|
|
30
30
|
/** Consent window for a pending link (15 min, matching the own-rolled OAuth pending TTL). */
|
|
31
31
|
const DEFAULT_PENDING_TTL_MS = 15 * 60 * 1000;
|
|
@@ -281,7 +281,7 @@ export function makeAwsSsoMinter(opts: AwsSsoMinterOptions = {}): AwsSsoMinter {
|
|
|
281
281
|
function requireOpenIdScope(): void {
|
|
282
282
|
if (scopes.includes("openid")) return;
|
|
283
283
|
throw new Error(
|
|
284
|
-
"AWS SSO link: the configured scopes omit \"openid\" (
|
|
284
|
+
"AWS SSO link: the configured scopes omit \"openid\" (the aws.ssoScopes setting). Without it the Identity " +
|
|
285
285
|
"Center token response carries no id-token, so the account that signs in cannot be named and the " +
|
|
286
286
|
"link can never complete. Fail-closed: no link was started.",
|
|
287
287
|
);
|
|
@@ -12,8 +12,18 @@ interface BoundBrowserConfig {
|
|
|
12
12
|
readonly autoInstall: boolean;
|
|
13
13
|
readonly contentBoundaries: boolean;
|
|
14
14
|
readonly inlineScreenshots: boolean;
|
|
15
|
+
readonly session?: string;
|
|
15
16
|
}
|
|
16
17
|
|
|
18
|
+
/**
|
|
19
|
+
* The agent-browser session name every tool call passes as `--session`. 🔴 IT MUST BE BOUND. Without
|
|
20
|
+
* it the vendor derives the name from `sandbox.id`, and eve 0.69.0+ hands the tool a `SandboxSession`
|
|
21
|
+
* that has no `id`: `sanitizeSessionPart(undefined).trim()` then throws on EVERY browser call
|
|
22
|
+
* ("Cannot read properties of undefined (reading 'trim')", F-ART leg 4, 2026-10-09). One sandbox runs
|
|
23
|
+
* one agent-browser daemon, so a fixed name is per-sandbox — the same isolation the vendor default gave.
|
|
24
|
+
*/
|
|
25
|
+
export const BROWSER_SESSION_NAME = "oe-browser";
|
|
26
|
+
|
|
17
27
|
/**
|
|
18
28
|
* Arm the toolkit for a validated config. Returns false, LOUDLY, when the extension-config binding did not
|
|
19
29
|
* take: unbound tools would run auto-install on and UNCONTAINED, so they are not registered at all.
|
|
@@ -24,6 +34,7 @@ export function registerAgentBrowser(cfg: AgentBrowserConfig): boolean {
|
|
|
24
34
|
autoInstall: false,
|
|
25
35
|
contentBoundaries: true,
|
|
26
36
|
inlineScreenshots: false,
|
|
37
|
+
session: BROWSER_SESSION_NAME,
|
|
27
38
|
});
|
|
28
39
|
|
|
29
40
|
const bound = browserExtension.config as unknown as BoundBrowserConfig;
|
|
@@ -31,7 +42,7 @@ export function registerAgentBrowser(cfg: AgentBrowserConfig): boolean {
|
|
|
31
42
|
Array.isArray(bound.allowedDomains) &&
|
|
32
43
|
bound.allowedDomains.length === cfg.domains.length &&
|
|
33
44
|
cfg.domains.every((d, i) => bound.allowedDomains![i] === d);
|
|
34
|
-
if (bound.autoInstall !== false || !domainsBound) {
|
|
45
|
+
if (bound.autoInstall !== false || !domainsBound || bound.session !== BROWSER_SESSION_NAME) {
|
|
35
46
|
console.error(
|
|
36
47
|
"[open-everywhere:agent-browser] extension config binding FAILED (namespace patch missing? see patches/@agent-browser+eve+*.patch) — " +
|
|
37
48
|
"REFUSING to arm the browser toolkit: unbound tools would run with vendor defaults (auto-install on, no domain containment).",
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// The directory-adapter SELECTION and Okta config. Selector-off or config-absent ⇒ NOTHING registers, with no
|
|
2
2
|
// partial arming. 🔴 The API token is a `secretRef` through a broker (default `db`), never a raw env read.
|
|
3
3
|
|
|
4
|
+
import { SETTINGS, getSettings, type OktaAppSetting } from "@open-everywhere/core/agent/adapter-api.js";
|
|
5
|
+
|
|
4
6
|
/** Which directory adapter to arm ("okta"). Unset ⇒ the directory plane stays off. */
|
|
5
7
|
const OE_DIRECTORY_PROVIDER_ENV = "OE_DIRECTORY_PROVIDER";
|
|
6
8
|
/** The Okta org URL, e.g. `https://acme.okta.com` (or an `*.oktapreview.com` sandbox). MUST be
|
|
@@ -11,8 +13,8 @@ const OE_DIRECTORY_OKTA_ORG_URL_ENV = "OE_DIRECTORY_OKTA_ORG_URL";
|
|
|
11
13
|
const OE_DIRECTORY_OKTA_TOKEN_REF_ENV = "OE_DIRECTORY_OKTA_TOKEN_REF";
|
|
12
14
|
/** Which broker holds that ref. Default "db" (the encrypted credential store — OPE-408 step 2). */
|
|
13
15
|
const OE_DIRECTORY_OKTA_TOKEN_BROKER_ENV = "OE_DIRECTORY_OKTA_TOKEN_BROKER";
|
|
14
|
-
|
|
15
|
-
|
|
16
|
+
// The APP MAP is not env (OPE-2411): it is the `directory.oktaApps` setting, because its `field`
|
|
17
|
+
// half decides which mappings `directory.autoConfirm` may confirm — see {@link oktaAppMappings}.
|
|
16
18
|
|
|
17
19
|
/** Which app-assignment field carries the app-side identity for one mapped app. */
|
|
18
20
|
export type OktaAppIdField = "externalId" | "userName";
|
|
@@ -49,49 +51,20 @@ export function oktaDirectoryEnabled(env: NodeJS.ProcessEnv = process.env): bool
|
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
+
* Normalise the `directory.oktaApps` setting into the adapter's mappings: the default `field` is
|
|
55
|
+
* `externalId`. FAIL-LOUD on a malformed table — a dropped entry is a silent gap. The rules are the
|
|
56
|
+
* setting's own validator (`SETTINGS.directory.oktaApps.validate`), so settings load and this adapter
|
|
57
|
+
* refuse the same tables; it runs here too because a test can register an unvalidated value.
|
|
54
58
|
*/
|
|
55
|
-
export function
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
`with an optional ":externalId"/":userName" suffix, e.g. "slack:0oa1a2b3c4d5" or "slack/T0AB1CD2E:0oa1a2b3c4d5".`,
|
|
65
|
-
);
|
|
66
|
-
}
|
|
67
|
-
if (field !== undefined && field !== "externalId" && field !== "userName") {
|
|
68
|
-
throw new Error(`${OE_DIRECTORY_OKTA_APPS_ENV} entry "${entry}" names field "${field}" — only "externalId" (default) or "userName" are valid.`);
|
|
69
|
-
}
|
|
70
|
-
const slash = head.indexOf("/");
|
|
71
|
-
const platform = (slash < 0 ? head : head.slice(0, slash)).toLowerCase();
|
|
72
|
-
const tenant = slash < 0 ? undefined : head.slice(slash + 1).trim();
|
|
73
|
-
if (!platform || (tenant !== undefined && (tenant.length === 0 || tenant.includes("/")))) {
|
|
74
|
-
throw new Error(
|
|
75
|
-
`${OE_DIRECTORY_OKTA_APPS_ENV} entry "${entry}" has an unusable platform/workspace half ("${head}") — write "<platform>" ` +
|
|
76
|
-
`or "<platform>/<workspace-id>" with exactly one slash, e.g. "slack/T0AB1CD2E".`,
|
|
77
|
-
);
|
|
78
|
-
}
|
|
79
|
-
// Two apps mapped to the SAME (platform, workspace) is a genuine misconfiguration rather than a
|
|
80
|
-
// widening: the two would write suggestions that are indistinguishable downstream, and whichever
|
|
81
|
-
// ran second would silently redefine the first's ids. Refused here, where the operator can see
|
|
82
|
-
// both entries, rather than as a mystery in a sync warning.
|
|
83
|
-
const key = `${platform}/${tenant ?? ""}`;
|
|
84
|
-
if (seen.has(key)) {
|
|
85
|
-
throw new Error(
|
|
86
|
-
`${OE_DIRECTORY_OKTA_APPS_ENV} maps two apps to the same target "${key.replace(/\/$/u, "")}" — an app map entry names WHERE ` +
|
|
87
|
-
"the ids land, so two entries for one place cannot both be right. Give each workspace its own rung " +
|
|
88
|
-
'("slack/T0AB1CD2E:…,slack/T9ZYX8W7V:…"), or remove the duplicate.',
|
|
89
|
-
);
|
|
90
|
-
}
|
|
91
|
-
seen.add(key);
|
|
92
|
-
out.push({ platform, ...(tenant !== undefined ? { tenant } : {}), appId, field: field ?? "externalId" });
|
|
93
|
-
}
|
|
94
|
-
return out;
|
|
59
|
+
export function oktaAppMappings(entries: readonly OktaAppSetting[]): readonly OktaAppMapping[] {
|
|
60
|
+
const problem = SETTINGS.directory.oktaApps.validate(entries);
|
|
61
|
+
if (problem !== undefined) throw new Error(`directory.oktaApps: ${problem}`);
|
|
62
|
+
return entries.map((a) => ({
|
|
63
|
+
platform: a.platform,
|
|
64
|
+
...(a.tenant !== undefined ? { tenant: a.tenant } : {}),
|
|
65
|
+
appId: a.appId,
|
|
66
|
+
field: a.field ?? "externalId",
|
|
67
|
+
}));
|
|
95
68
|
}
|
|
96
69
|
|
|
97
70
|
/**
|
|
@@ -118,7 +91,10 @@ function requireHttpsOrgUrl(raw: string): void {
|
|
|
118
91
|
* Load the Okta config block. FAIL-LOUD when the selector is armed but a required field is missing. An unset
|
|
119
92
|
* SELECTOR stays silent — that is a deployment with no IdP, not a broken one.
|
|
120
93
|
*/
|
|
121
|
-
export function loadOktaDirectoryConfig(
|
|
94
|
+
export function loadOktaDirectoryConfig(
|
|
95
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
96
|
+
apps: readonly OktaAppSetting[] = getSettings().directory.oktaApps,
|
|
97
|
+
): OktaDirectoryConfig {
|
|
122
98
|
const orgUrl = env[OE_DIRECTORY_OKTA_ORG_URL_ENV]?.trim();
|
|
123
99
|
const tokenRef = env[OE_DIRECTORY_OKTA_TOKEN_REF_ENV]?.trim();
|
|
124
100
|
const missing: string[] = [];
|
|
@@ -128,11 +104,10 @@ export function loadOktaDirectoryConfig(env: NodeJS.ProcessEnv = process.env): O
|
|
|
128
104
|
throw new Error(`${OE_DIRECTORY_PROVIDER_ENV}="okta" but ${missing.join(" and ")} ${missing.length > 1 ? "are" : "is"} not set — directory sync cannot be armed.`);
|
|
129
105
|
}
|
|
130
106
|
requireHttpsOrgUrl(orgUrl!);
|
|
131
|
-
const apps = parseOktaAppMap(env[OE_DIRECTORY_OKTA_APPS_ENV]);
|
|
132
107
|
return {
|
|
133
108
|
orgUrl: orgUrl!.replace(/\/+$/u, ""),
|
|
134
109
|
tokenRef: tokenRef!,
|
|
135
110
|
tokenBroker: env[OE_DIRECTORY_OKTA_TOKEN_BROKER_ENV]?.trim() || "db",
|
|
136
|
-
apps,
|
|
111
|
+
apps: oktaAppMappings(apps),
|
|
137
112
|
};
|
|
138
113
|
}
|
package/agent/model/gateway.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type BindableContext,
|
|
8
8
|
type ModelBackend,
|
|
9
9
|
type RoutedCallOptions,
|
|
10
|
+
hostPlaneFetch,
|
|
10
11
|
liveIdentity,
|
|
11
12
|
resolvePrimaryModel,
|
|
12
13
|
} from "@open-everywhere/core/agent/adapter-api.js";
|
|
@@ -19,9 +20,33 @@ type GatewayModel = ReturnType<typeof gateway>;
|
|
|
19
20
|
*/
|
|
20
21
|
const GATEWAY_BASE_URL = process.env.OE_AI_GATEWAY_URL?.trim();
|
|
21
22
|
|
|
23
|
+
/** The gateway's own host when {@link GATEWAY_BASE_URL} is unset. It must match the `ai` package's default base URL. */
|
|
24
|
+
export const DEFAULT_GATEWAY_HOST = "ai-gateway.vercel.sh";
|
|
25
|
+
|
|
26
|
+
/** The one host the model call reaches: the retarget's host, else the default. An unparseable retarget falls back to the default host. */
|
|
27
|
+
export function gatewayHost(baseUrl: string | undefined): string {
|
|
28
|
+
if (!baseUrl) return DEFAULT_GATEWAY_HOST;
|
|
29
|
+
try {
|
|
30
|
+
return new URL(baseUrl).hostname;
|
|
31
|
+
} catch {
|
|
32
|
+
return DEFAULT_GATEWAY_HOST;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* THE MODEL CALL'S TRANSPORT ON THE SELF-HOST PROXY TIER (OPE-2406). With an egress proxy wired, the model call goes out
|
|
38
|
+
* on the app's HOST PLANE: the proxy decides it against the gateway host alone, never against a session's policy. That is
|
|
39
|
+
* what lets a turn reach its model while the session stays default-deny. Off that tier it is `undefined` and the provider
|
|
40
|
+
* keeps its default fetch, byte-identical to before.
|
|
41
|
+
*/
|
|
42
|
+
const HOST_PLANE_FETCH = hostPlaneFetch([gatewayHost(GATEWAY_BASE_URL)]);
|
|
43
|
+
|
|
22
44
|
/** The gateway provider this backend resolves model ids through — retargeted only when
|
|
23
45
|
* {@link GATEWAY_BASE_URL} names a different host. */
|
|
24
|
-
const provider =
|
|
46
|
+
const provider =
|
|
47
|
+
GATEWAY_BASE_URL || HOST_PLANE_FETCH
|
|
48
|
+
? createGateway({ ...(GATEWAY_BASE_URL ? { baseURL: GATEWAY_BASE_URL } : {}), ...(HOST_PLANE_FETCH ? { fetch: HOST_PLANE_FETCH } : {}) })
|
|
49
|
+
: gateway;
|
|
25
50
|
|
|
26
51
|
/** Static defaults for one routed slot — mirrors what a plain string config said. */
|
|
27
52
|
export interface GatewayRoutedDefaults {
|
package/agent/oe-oauth/config.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
// The `oe-oauth`
|
|
2
|
-
//
|
|
1
|
+
// The `oe-oauth` provider config: each connector's SIGN-IN, declared in its own connection module next to its
|
|
2
|
+
// URL (core connections/connection-sign-in.ts) and validated here. 🔴 The client SECRET is a REF — a literal
|
|
3
|
+
// in a declaration is REFUSED. ⚠️ A declaration's key must equal the credential home's ref, or no mint reaches it.
|
|
4
|
+
//
|
|
5
|
+
// OPE-2411 deleted the env path: `OE_OAUTH_CONFIG` (the JSON map), `OE_OAUTH_NOTION_RESOURCE` (its Notion
|
|
6
|
+
// shortcut) and the `OE_OAUTH_ENABLED` master flag. A scope list reaches the authorize URL, so a dashboard
|
|
7
|
+
// could widen what the agent asks for with no diff. There is no master flag any more: a connector that
|
|
8
|
+
// declares a sign-in offers "Connect my account" to whoever a bundle grants its `user-oauth` credential.
|
|
3
9
|
|
|
4
|
-
|
|
5
|
-
const OE_OAUTH_ENABLED_ENV = "OE_OAUTH_ENABLED";
|
|
6
|
-
/** The JSON provider-config block env var. */
|
|
7
|
-
export const OE_OAUTH_CONFIG_ENV = "OE_OAUTH_CONFIG";
|
|
8
|
-
/** Convenience: a `notion` provider's resource (mcp.notion.com), without hand-writing the JSON. */
|
|
9
|
-
const OE_OAUTH_NOTION_RESOURCE_ENV = "OE_OAUTH_NOTION_RESOURCE";
|
|
10
|
+
import { declaredConnectionSignIns } from "@open-everywhere/core/agent/adapter-api.js";
|
|
10
11
|
|
|
11
12
|
/** The discovery + DCR + PKCE public-client dialect — the original and default mode. */
|
|
12
13
|
export const OE_OAUTH_MODE_DISCOVERY = "discovery-dcr-pkce";
|
|
@@ -74,14 +75,6 @@ export function isStaticConfidential(cfg: OeOauthProviderConfig): cfg is OeOauth
|
|
|
74
75
|
return cfg.mode === OE_OAUTH_MODE_STATIC;
|
|
75
76
|
}
|
|
76
77
|
|
|
77
|
-
/** Truthiness for the master flag — same off-spellings as the connector flags (default OFF). */
|
|
78
|
-
export function oeOauthEnabled(): boolean {
|
|
79
|
-
const raw = process.env[OE_OAUTH_ENABLED_ENV];
|
|
80
|
-
if (raw === undefined) return false;
|
|
81
|
-
const v = raw.trim().toLowerCase();
|
|
82
|
-
return v !== "" && v !== "0" && v !== "false" && v !== "off" && v !== "no";
|
|
83
|
-
}
|
|
84
|
-
|
|
85
78
|
/** Authorize-URL parameters this adapter composes itself. A config entry that named one of these
|
|
86
79
|
* could point the consent at another client id or another redirect target, so the parser refuses
|
|
87
80
|
* them instead of letting the last writer win. */
|
|
@@ -127,11 +120,11 @@ function parseScopes(v: unknown): readonly string[] | undefined {
|
|
|
127
120
|
|
|
128
121
|
/** Parse ONE `static-confidential` entry, with every refusal stated. */
|
|
129
122
|
function parseStaticProvider(ref: string, rec: Record<string, unknown>): OeOauthStaticProviderConfig {
|
|
130
|
-
const where =
|
|
131
|
-
// 🔴 A LITERAL SECRET IS REFUSED, NOT ACCEPTED. See the module header:
|
|
123
|
+
const where = `sign-in "${ref}"`;
|
|
124
|
+
// 🔴 A LITERAL SECRET IS REFUSED, NOT ACCEPTED. See the module header: a declaration is committed source.
|
|
132
125
|
if ("clientSecret" in rec) {
|
|
133
126
|
throw new Error(
|
|
134
|
-
`${where} carries a literal "clientSecret". The client secret must NEVER live in
|
|
127
|
+
`${where} carries a literal "clientSecret". The client secret must NEVER live in a sign-in declaration — ` +
|
|
135
128
|
`store it in the credential store and name it with "clientSecretRef" (for example "db:notion:rest-client-secret").`,
|
|
136
129
|
);
|
|
137
130
|
}
|
|
@@ -165,12 +158,13 @@ function parseStaticProvider(ref: string, rec: Record<string, unknown>): OeOauth
|
|
|
165
158
|
...(authorizeParams ? { authorizeParams } : {}),
|
|
166
159
|
...(tokenHeaders ? { tokenHeaders } : {}),
|
|
167
160
|
bodyEncoding,
|
|
161
|
+
...(scopes ? { scopes } : {}),
|
|
168
162
|
};
|
|
169
163
|
}
|
|
170
164
|
|
|
171
165
|
/** Parse ONE `discovery-dcr-pkce` entry. */
|
|
172
166
|
function parseDiscoveryProvider(ref: string, rec: Record<string, unknown>): OeOauthDiscoveryProviderConfig {
|
|
173
|
-
const where =
|
|
167
|
+
const where = `sign-in "${ref}"`;
|
|
174
168
|
const resource = typeof rec.resource === "string" ? rec.resource.trim() : "";
|
|
175
169
|
if (!resource) throw new Error(`${where} is missing a string "resource".`);
|
|
176
170
|
const scopes = parseScopes(rec.scopes);
|
|
@@ -183,18 +177,10 @@ function parseDiscoveryProvider(ref: string, rec: Record<string, unknown>): OeOa
|
|
|
183
177
|
};
|
|
184
178
|
}
|
|
185
179
|
|
|
186
|
-
|
|
180
|
+
/** Parse the declared sign-ins into provider configs. Every refusal names the declaration's home ref. */
|
|
181
|
+
function parseDeclarations(declared: ReadonlyMap<string, unknown>): Map<string, OeOauthProviderConfig> {
|
|
187
182
|
const out = new Map<string, OeOauthProviderConfig>();
|
|
188
|
-
|
|
189
|
-
try {
|
|
190
|
-
obj = JSON.parse(raw);
|
|
191
|
-
} catch {
|
|
192
|
-
throw new Error(`${OE_OAUTH_CONFIG_ENV} is not valid JSON — expected {"<ref>":{"resource":"..."}}.`);
|
|
193
|
-
}
|
|
194
|
-
if (typeof obj !== "object" || obj === null || Array.isArray(obj)) {
|
|
195
|
-
throw new Error(`${OE_OAUTH_CONFIG_ENV} must be a JSON object mapping provider ref → one provider entry.`);
|
|
196
|
-
}
|
|
197
|
-
for (const [ref, v] of Object.entries(obj as Record<string, unknown>)) {
|
|
183
|
+
for (const [ref, v] of declared) {
|
|
198
184
|
if (typeof v !== "object" || v === null) continue;
|
|
199
185
|
const rec = v as Record<string, unknown>;
|
|
200
186
|
// The MODE decides how the rest is read. An absent `mode` is the discovery dialect, which is
|
|
@@ -208,7 +194,7 @@ function parseJsonConfig(raw: string): Map<string, OeOauthProviderConfig> {
|
|
|
208
194
|
}
|
|
209
195
|
if (mode !== OE_OAUTH_MODE_DISCOVERY) {
|
|
210
196
|
throw new Error(
|
|
211
|
-
|
|
197
|
+
`sign-in "${ref}".mode ${JSON.stringify(mode)} is not a known oe-oauth mode — use "${OE_OAUTH_MODE_DISCOVERY}" or "${OE_OAUTH_MODE_STATIC}".`,
|
|
212
198
|
);
|
|
213
199
|
}
|
|
214
200
|
const parsed = parseDiscoveryProvider(ref, rec);
|
|
@@ -218,7 +204,7 @@ function parseJsonConfig(raw: string): Map<string, OeOauthProviderConfig> {
|
|
|
218
204
|
for (const field of ["clientId", "clientSecretRef", "clientSecret", "tokenEndpoint", "authorizationEndpoint"]) {
|
|
219
205
|
if (field in rec) {
|
|
220
206
|
throw new Error(
|
|
221
|
-
|
|
207
|
+
`sign-in "${ref}" carries "${field}" but declares no mode, so it reads as "${OE_OAUTH_MODE_DISCOVERY}" ` +
|
|
222
208
|
`(discovery + dynamic registration + PKCE), which ignores that field. Add "mode": "${OE_OAUTH_MODE_STATIC}" if that is what you meant.`,
|
|
223
209
|
);
|
|
224
210
|
}
|
|
@@ -229,18 +215,12 @@ function parseJsonConfig(raw: string): Map<string, OeOauthProviderConfig> {
|
|
|
229
215
|
}
|
|
230
216
|
|
|
231
217
|
/**
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
218
|
+
* The provider config map from the DECLARED sign-ins, or `undefined` when no connector declares one.
|
|
219
|
+
* Read PER CALL by the vault (see register.ts): a connection module declares at its own load, which
|
|
220
|
+
* eve's module map runs at server start, and a per-call read never depends on that order. A malformed
|
|
221
|
+
* declaration throws here, naming the field, so the mint that reaches it fails loudly.
|
|
235
222
|
*/
|
|
236
|
-
export function loadOeOauthConfig(): Map<string, OeOauthProviderConfig> | undefined {
|
|
237
|
-
const providers =
|
|
238
|
-
|
|
239
|
-
const notionResource = process.env[OE_OAUTH_NOTION_RESOURCE_ENV]?.trim();
|
|
240
|
-
if (notionResource) providers.set("notion", { mode: OE_OAUTH_MODE_DISCOVERY, ref: "notion", resource: notionResource });
|
|
241
|
-
|
|
242
|
-
const rawJson = process.env[OE_OAUTH_CONFIG_ENV]?.trim();
|
|
243
|
-
if (rawJson) for (const [ref, cfg] of parseJsonConfig(rawJson)) providers.set(ref, cfg);
|
|
244
|
-
|
|
223
|
+
export function loadOeOauthConfig(declared: ReadonlyMap<string, unknown> = declaredConnectionSignIns()): Map<string, OeOauthProviderConfig> | undefined {
|
|
224
|
+
const providers = parseDeclarations(declared);
|
|
245
225
|
return providers.size > 0 ? providers : undefined;
|
|
246
226
|
}
|
|
@@ -1,27 +1,29 @@
|
|
|
1
1
|
// Register the own-rolled OAuth adapter: the user-auth flow, the callback completer and the credential-purge
|
|
2
|
-
// hook, from one shared vault.
|
|
2
|
+
// hook, from one shared vault. Since OPE-2411 there is no master flag: the flow ALWAYS registers, and each
|
|
3
|
+
// mint reads the declared sign-ins PER CALL (core connections/connection-sign-in.ts). An OAuth home no
|
|
4
|
+
// connector declares fails its mint closed with a message naming the fix, unless the vault already
|
|
5
|
+
// holds a client record or an unexpired token for it (that row keeps serving). The off switch is the bundle's
|
|
6
|
+
// `user-oauth` grant: with no grant, no mint reaches this flow.
|
|
3
7
|
|
|
4
8
|
import { registerUserAuthFlow, registerOeOauthCallbackCompleter, registerCredentialPurgeHook } from "@open-everywhere/core/agent/adapter-api.js";
|
|
5
|
-
import { loadOeOauthConfig
|
|
9
|
+
import { loadOeOauthConfig } from "./config.js";
|
|
6
10
|
import { makeOeOauth } from "./user-auth-flow.js";
|
|
7
|
-
import type { OeOauthVaultDeps } from "./token-vault.js";
|
|
11
|
+
import type { OeOauthProviderSource, OeOauthVaultDeps } from "./token-vault.js";
|
|
8
12
|
|
|
9
|
-
/** Registration input. `providers` defaults to
|
|
13
|
+
/** Registration input. `providers` defaults to the declared sign-ins, read per call; `deps` is a test/DI seam. */
|
|
10
14
|
export interface RegisterOeOauthOptions {
|
|
11
|
-
readonly providers?:
|
|
15
|
+
readonly providers?: OeOauthProviderSource;
|
|
12
16
|
readonly deps?: OeOauthVaultDeps;
|
|
13
17
|
}
|
|
14
18
|
|
|
19
|
+
/** The deployed provider source: the declared sign-ins, parsed per call. */
|
|
20
|
+
const declaredProviders = () => loadOeOauthConfig() ?? new Map();
|
|
21
|
+
|
|
15
22
|
/**
|
|
16
|
-
* Construct the own-rolled OAuth provider and callback completer and register both.
|
|
17
|
-
* config is available — the caller invokes this only once it has decided to arm.
|
|
23
|
+
* Construct the own-rolled OAuth provider and callback completer and register both.
|
|
18
24
|
*/
|
|
19
25
|
export function registerOeOauth(opts: RegisterOeOauthOptions = {}) {
|
|
20
|
-
const
|
|
21
|
-
if (!providers || providers.size === 0) {
|
|
22
|
-
throw new Error("registerOeOauth: no oe-oauth provider config (set OE_OAUTH_CONFIG / OE_OAUTH_NOTION_RESOURCE).");
|
|
23
|
-
}
|
|
24
|
-
const { flow, completer, purge } = makeOeOauth(providers, opts.deps ?? {});
|
|
26
|
+
const { flow, completer, purge } = makeOeOauth(opts.providers ?? declaredProviders, opts.deps ?? {});
|
|
25
27
|
registerUserAuthFlow(flow);
|
|
26
28
|
registerOeOauthCallbackCompleter(completer);
|
|
27
29
|
// (OPE-287) The purge hook registers ALONGSIDE the mint flow, in the same call, deliberately: a
|
|
@@ -169,11 +169,16 @@ function defaultRedirectUri(): string | undefined {
|
|
|
169
169
|
return `${withScheme}${OE_OAUTH_CALLBACK_PATH}`;
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
/** The provider set a vault reads: a fixed map (a test), or a function read PER CALL (the deployed
|
|
173
|
+
* path, over the declared sign-ins — see oe-oauth/config.ts `loadOeOauthConfig`). */
|
|
174
|
+
export type OeOauthProviderSource = ReadonlyMap<string, OeOauthProviderConfig> | (() => ReadonlyMap<string, OeOauthProviderConfig>);
|
|
175
|
+
|
|
172
176
|
/**
|
|
173
177
|
* Construct a token vault over the configured providers. One instance is built at registration and
|
|
174
178
|
* shared, so the in-process single-flight lock (core withLock) coordinates all callers in the process.
|
|
175
179
|
*/
|
|
176
|
-
export function makeOeOauthTokenVault(
|
|
180
|
+
export function makeOeOauthTokenVault(source: OeOauthProviderSource, deps: OeOauthVaultDeps = {}) {
|
|
181
|
+
const providersNow = typeof source === "function" ? source : () => source;
|
|
177
182
|
const vault = deps.vault ?? encryptedKvVault();
|
|
178
183
|
const doFetch = deps.fetch ?? ((input: string, init?: RequestInit) => fetch(input, init));
|
|
179
184
|
const now = deps.now ?? Date.now;
|
|
@@ -192,8 +197,12 @@ export function makeOeOauthTokenVault(providers: ReadonlyMap<string, OeOauthProv
|
|
|
192
197
|
const tokRef = (providerRef: string, subject: OeOauthSubject) => `oe-oauth:tok:${providerRef}:${subjectKey(subject)}`;
|
|
193
198
|
|
|
194
199
|
function providerConfig(providerRef: string): OeOauthProviderConfig {
|
|
195
|
-
const cfg =
|
|
196
|
-
if (!cfg)
|
|
200
|
+
const cfg = providersNow().get(providerRef);
|
|
201
|
+
if (!cfg) {
|
|
202
|
+
throw new Error(
|
|
203
|
+
`connection "${providerRef}": no sign-in is declared for this OAuth home — the connector's connection module declares one with declareConnectionSignIn (core connections/connection-sign-in.ts).`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
197
206
|
return cfg;
|
|
198
207
|
}
|
|
199
208
|
|
|
@@ -288,7 +297,7 @@ export function makeOeOauthTokenVault(providers: ReadonlyMap<string, OeOauthProv
|
|
|
288
297
|
* `ensureDcrClient`, so it never discovers, registers or writes a DCR row — the two share no network path.
|
|
289
298
|
*/
|
|
290
299
|
async function ensureClient(providerRef: string): Promise<ClientRecord> {
|
|
291
|
-
const cfg =
|
|
300
|
+
const cfg = providersNow().get(providerRef);
|
|
292
301
|
return cfg !== undefined && isStaticConfidential(cfg) ? staticClient(cfg) : ensureDcrClient(providerRef);
|
|
293
302
|
}
|
|
294
303
|
|
|
@@ -323,7 +332,7 @@ export function makeOeOauthTokenVault(providers: ReadonlyMap<string, OeOauthProv
|
|
|
323
332
|
// `providers.get`, not providerConfig: the discovery path never read the config here before this
|
|
324
333
|
// mode existed, and it must not start failing on a config-absent ref now. The drift check below
|
|
325
334
|
// therefore runs only when there IS a config to compare against.
|
|
326
|
-
const cfg =
|
|
335
|
+
const cfg = providersNow().get(pending.providerRef);
|
|
327
336
|
const startedStatic = pending.mode === OE_OAUTH_MODE_STATIC;
|
|
328
337
|
if (cfg !== undefined && isStaticConfidential(cfg) !== startedStatic) {
|
|
329
338
|
throw new Error(
|
|
@@ -371,7 +380,7 @@ export function makeOeOauthTokenVault(providers: ReadonlyMap<string, OeOauthProv
|
|
|
371
380
|
// from config — which is a read, not a registration, so the "never registers" rule above
|
|
372
381
|
// holds on both paths. `providers.get` rather than `providerConfig`: a purge must still
|
|
373
382
|
// destroy the row for a provider whose config an operator has already removed.
|
|
374
|
-
const cfg =
|
|
383
|
+
const cfg = providersNow().get(providerRef);
|
|
375
384
|
const client: ClientRecord | null = cfg && isStaticConfidential(cfg) ? staticClient(cfg) : await readJson<DcrClientRecord>(dcrRef(providerRef));
|
|
376
385
|
// Prefer the REFRESH token: revoking it kills the whole grant family (RFC 7009 §2.1),
|
|
377
386
|
// whereas revoking an access token kills only that one.
|
|
@@ -451,7 +460,8 @@ export function makeOeOauthTokenVault(providers: ReadonlyMap<string, OeOauthProv
|
|
|
451
460
|
let purged = 0;
|
|
452
461
|
let failures = 0;
|
|
453
462
|
let lastError: unknown;
|
|
454
|
-
|
|
463
|
+
const all = providersNow();
|
|
464
|
+
for (const providerRef of all.keys()) {
|
|
455
465
|
try {
|
|
456
466
|
if (await forget(providerRef, subject)) purged++;
|
|
457
467
|
} catch (e) {
|
|
@@ -459,7 +469,7 @@ export function makeOeOauthTokenVault(providers: ReadonlyMap<string, OeOauthProv
|
|
|
459
469
|
lastError = e;
|
|
460
470
|
}
|
|
461
471
|
}
|
|
462
|
-
if (failures > 0 && purged === 0 && failures ===
|
|
472
|
+
if (failures > 0 && purged === 0 && failures === all.size) throw lastError;
|
|
463
473
|
return purged;
|
|
464
474
|
},
|
|
465
475
|
|
|
@@ -597,7 +607,7 @@ export function makeOeOauthTokenVault(providers: ReadonlyMap<string, OeOauthProv
|
|
|
597
607
|
try {
|
|
598
608
|
// `providers.get`, not providerConfig — see {@link ensureClient}: a discovery refresh
|
|
599
609
|
// must still resolve from a complete DCR record when the config entry is absent.
|
|
600
|
-
const cfg =
|
|
610
|
+
const cfg = providersNow().get(providerRef);
|
|
601
611
|
let tok: OAuthTokenResponse;
|
|
602
612
|
try {
|
|
603
613
|
// The dialect decides the refresh call and nothing else about this block. A static
|
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import type { TokenResult } from "eve/connections";
|
|
5
5
|
import type { UserAuthFlowProvider, UserAuthSubject, UserTokenMintParams, OeOauthCallbackCompleter, CredentialPurgeHook } from "@open-everywhere/core/agent/adapter-api.js";
|
|
6
|
-
import type
|
|
7
|
-
import { makeOeOauthTokenVault, type OeOauthVaultDeps, type OeOauthSubject } from "./token-vault.js";
|
|
6
|
+
import { makeOeOauthTokenVault, type OeOauthProviderSource, type OeOauthVaultDeps, type OeOauthSubject } from "./token-vault.js";
|
|
8
7
|
import { isUserAuthRequiredError } from "./errors.js";
|
|
9
8
|
|
|
10
9
|
/** Normalise the port's UserAuthSubject into the vault's subject shape. */
|
|
@@ -14,7 +13,7 @@ function toVaultSubject(subject: UserAuthSubject): OeOauthSubject {
|
|
|
14
13
|
|
|
15
14
|
/** Build the own-rolled provider plus the callback completer, sharing one token vault. `deps` is the DI seam. */
|
|
16
15
|
export function makeOeOauth(
|
|
17
|
-
providers:
|
|
16
|
+
providers: OeOauthProviderSource,
|
|
18
17
|
deps: OeOauthVaultDeps = {},
|
|
19
18
|
): { readonly flow: UserAuthFlowProvider; readonly completer: OeOauthCallbackCompleter; readonly purge: CredentialPurgeHook } {
|
|
20
19
|
const vault = makeOeOauthTokenVault(providers, deps);
|