@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.
Files changed (58) hide show
  1. package/README.md +0 -1
  2. package/agent/aws/config.ts +13 -16
  3. package/agent/aws/register.ts +2 -2
  4. package/agent/aws/sso.ts +2 -2
  5. package/agent/browser/register.ts +12 -1
  6. package/agent/directory/config.ts +22 -47
  7. package/agent/model/gateway.ts +26 -1
  8. package/agent/oe-oauth/config.ts +25 -45
  9. package/agent/oe-oauth/register.ts +14 -12
  10. package/agent/oe-oauth/token-vault.ts +19 -9
  11. package/agent/oe-oauth/user-auth-flow.ts +2 -3
  12. package/agent/register-defaults.ts +18 -19
  13. package/agent/sandbox/backend-stop.ts +10 -10
  14. package/dist/agent/aws/config.d.ts +5 -3
  15. package/dist/agent/aws/config.d.ts.map +1 -1
  16. package/dist/agent/aws/config.js +12 -16
  17. package/dist/agent/aws/config.js.map +1 -1
  18. package/dist/agent/aws/register.js +2 -2
  19. package/dist/agent/aws/register.js.map +1 -1
  20. package/dist/agent/aws/sso.js +2 -2
  21. package/dist/agent/aws/sso.js.map +1 -1
  22. package/dist/agent/browser/register.d.ts +8 -0
  23. package/dist/agent/browser/register.d.ts.map +1 -1
  24. package/dist/agent/browser/register.js +10 -1
  25. package/dist/agent/browser/register.js.map +1 -1
  26. package/dist/agent/directory/config.d.ts +7 -4
  27. package/dist/agent/directory/config.d.ts.map +1 -1
  28. package/dist/agent/directory/config.js +17 -41
  29. package/dist/agent/directory/config.js.map +1 -1
  30. package/dist/agent/model/gateway.d.ts +4 -0
  31. package/dist/agent/model/gateway.d.ts.map +1 -1
  32. package/dist/agent/model/gateway.js +24 -2
  33. package/dist/agent/model/gateway.js.map +1 -1
  34. package/dist/agent/oe-oauth/config.d.ts +5 -8
  35. package/dist/agent/oe-oauth/config.d.ts.map +1 -1
  36. package/dist/agent/oe-oauth/config.js +25 -46
  37. package/dist/agent/oe-oauth/config.js.map +1 -1
  38. package/dist/agent/oe-oauth/register.d.ts +4 -6
  39. package/dist/agent/oe-oauth/register.d.ts.map +1 -1
  40. package/dist/agent/oe-oauth/register.js +9 -8
  41. package/dist/agent/oe-oauth/register.js.map +1 -1
  42. package/dist/agent/oe-oauth/token-vault.d.ts +4 -1
  43. package/dist/agent/oe-oauth/token-vault.d.ts.map +1 -1
  44. package/dist/agent/oe-oauth/token-vault.js +13 -10
  45. package/dist/agent/oe-oauth/token-vault.js.map +1 -1
  46. package/dist/agent/oe-oauth/user-auth-flow.d.ts +2 -3
  47. package/dist/agent/oe-oauth/user-auth-flow.d.ts.map +1 -1
  48. package/dist/agent/oe-oauth/user-auth-flow.js.map +1 -1
  49. package/dist/agent/register-defaults.d.ts +7 -3
  50. package/dist/agent/register-defaults.d.ts.map +1 -1
  51. package/dist/agent/register-defaults.js +15 -19
  52. package/dist/agent/register-defaults.js.map +1 -1
  53. package/dist/agent/sandbox/backend-stop.d.ts +5 -6
  54. package/dist/agent/sandbox/backend-stop.d.ts.map +1 -1
  55. package/dist/agent/sandbox/backend-stop.js +9 -9
  56. package/dist/agent/sandbox/backend-stop.js.map +1 -1
  57. package/package.json +3 -9
  58. 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
@@ -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. Config is env-sourced at the adapter boundary, like DATABASE_URL.
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
- /** Optional space-separated OAuth scopes for the link (default "sso:account:access"). */
18
- const OE_AWS_SSO_SCOPES_ENV = "OE_AWS_SSO_SCOPES";
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
- const raw = process.env[OE_AWS_ENABLED_ENV];
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 from env. Region falls back to the general AWS default. */
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 = process.env[OE_AWS_SSO_SCOPES_ENV]?.trim()
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 } : {}),
@@ -1,4 +1,4 @@
1
- // Register the AWS-access layer, called only when the OE_AWS_ENABLED master flag is armed. Flag-off ⇒
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 OE_AWS_ENABLED boot
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
- * `OE_AWS_SSO_SCOPES` must keep `openid` in the list. */
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\" (OE_AWS_SSO_SCOPES). Without it the Identity " +
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
- /** App map: `"<platform>[/<tenant>]:<oktaAppId>[:<field>], …"` — see {@link parseOktaAppMap}. */
15
- const OE_DIRECTORY_OKTA_APPS_ENV = "OE_DIRECTORY_OKTA_APPS";
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
- * Parse the app map: `<platform>[/<tenant>]:<oktaAppId>[:<field>]`, comma-separated. The workspace rung uses
53
- * `/`, matching the principal-key grammar. FAIL-LOUD on a malformed entry — a dropped entry is a silent gap.
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 parseOktaAppMap(raw: string | undefined): readonly OktaAppMapping[] {
56
- const out: OktaAppMapping[] = [];
57
- const seen = new Set<string>();
58
- for (const entry of (raw ?? "").split(",").map((s) => s.trim()).filter(Boolean)) {
59
- const parts = entry.split(":").map((s) => s.trim());
60
- const [head, appId, field] = parts;
61
- if (parts.length < 2 || parts.length > 3 || !head || !appId) {
62
- throw new Error(
63
- `${OE_DIRECTORY_OKTA_APPS_ENV} entry "${entry}" is malformed — expected "<platform>[/<tenant>]:<oktaAppId>" ` +
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(env: NodeJS.ProcessEnv = process.env): OktaDirectoryConfig {
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
  }
@@ -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 = GATEWAY_BASE_URL ? createGateway({ baseURL: GATEWAY_BASE_URL }) : gateway;
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 {
@@ -1,12 +1,13 @@
1
- // The `oe-oauth` master flag and provider config, env-sourced. 🔴 The client SECRET is a REF — a literal in
2
- // the JSON is REFUSED. ⚠️ A map key must equal the credential home's ref, or the click dies late, at DCR.
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
- /** The master arming flag env var — one string this module's readers share. */
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 = `${OE_OAUTH_CONFIG_ENV}["${ref}"]`;
131
- // 🔴 A LITERAL SECRET IS REFUSED, NOT ACCEPTED. See the module header: this block is an env var.
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 this config block — ` +
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 = `${OE_OAUTH_CONFIG_ENV}["${ref}"]`;
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
- function parseJsonConfig(raw: string): Map<string, OeOauthProviderConfig> {
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
- let obj: unknown;
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
- `${OE_OAUTH_CONFIG_ENV}["${ref}"].mode ${JSON.stringify(mode)} is not a known oe-oauth mode — use "${OE_OAUTH_MODE_DISCOVERY}" or "${OE_OAUTH_MODE_STATIC}".`,
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
- `${OE_OAUTH_CONFIG_ENV}["${ref}"] carries "${field}" but declares no mode, so it reads as "${OE_OAUTH_MODE_DISCOVERY}" ` +
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
- * Load the provider config map from env, or `undefined` when no provider is configured (the
233
- * "config-absent" path — combined with {@link oeOauthEnabled}, both must be truthy for the own-rolled
234
- * OAuth layer to register). Explicit OE_OAUTH_CONFIG entries win over the notion shortcut.
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 = new Map<string, OeOauthProviderConfig>();
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. Flag-off ⇒ none registers and the callback route fails closed with a 404.
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, type OeOauthProviderConfig } from "./config.js";
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 {@link loadOeOauthConfig}; `deps` is a test/DI seam. */
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?: ReadonlyMap<string, OeOauthProviderConfig>;
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. Throws when no provider
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 providers = opts.providers ?? loadOeOauthConfig();
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(providers: ReadonlyMap<string, OeOauthProviderConfig>, deps: OeOauthVaultDeps = {}) {
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 = providers.get(providerRef);
196
- if (!cfg) throw new Error(`connection "${providerRef}": no oe-oauth provider config (set OE_OAUTH_CONFIG / OE_OAUTH_NOTION_RESOURCE).`);
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 = providers.get(providerRef);
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 = providers.get(pending.providerRef);
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 = providers.get(providerRef);
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
- for (const providerRef of providers.keys()) {
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 === providers.size) throw lastError;
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 = providers.get(providerRef);
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 { OeOauthProviderConfig } from "./config.js";
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: ReadonlyMap<string, OeOauthProviderConfig>,
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);