@vymalo/opencode-lightbridge 0.16.0 → 0.17.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 +47 -24
- package/dist/config.d.ts +67 -4
- package/dist/config.js +95 -3
- package/dist/config.js.map +1 -1
- package/dist/lib.d.ts +3 -2
- package/dist/lib.js +3 -2
- package/dist/lib.js.map +1 -1
- package/dist/migration.d.ts +31 -0
- package/dist/migration.js +91 -0
- package/dist/migration.js.map +1 -0
- package/dist/opencode.js +148 -7
- package/dist/opencode.js.map +1 -1
- package/dist/plugin.d.ts +77 -23
- package/dist/plugin.js +157 -33
- package/dist/plugin.js.map +1 -1
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -2,21 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@vymalo/opencode-lightbridge)
|
|
4
4
|
|
|
5
|
-
**
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
**The all-in-one plugin.** One credential drives everything: provider registration + model
|
|
6
|
+
discovery ([`register`](https://github.com/ADORSYS-GIS/lightbridge-opencode-toolbeit/blob/main/docs/adr/0017-lightbridge-all-in-one.md) —
|
|
7
|
+
"everything `@vymalo/opencode-oauth2` does"), the LLM gateway bearer (`gateway`), and the OTEL
|
|
8
|
+
export credential (`otel`) — see [ADR-0012](https://github.com/ADORSYS-GIS/lightbridge-opencode-toolbeit/blob/main/docs/adr/0012-single-auth-across-gateway-and-otel.md)
|
|
9
|
+
and [ADR-0017](https://github.com/ADORSYS-GIS/lightbridge-opencode-toolbeit/blob/main/docs/adr/0017-lightbridge-all-in-one.md).
|
|
10
|
+
Log in once as yourself; every module that needs a credential rides the same login — and since
|
|
11
|
+
ADR-0017, that login is shared with `@vymalo/opencode-oauth2` too, when both target the same IdP.
|
|
9
12
|
|
|
10
13
|
Part of the [OpenCode Toolbelt](https://github.com/ADORSYS-GIS/lightbridge-opencode-toolbeit).
|
|
11
14
|
|
|
12
|
-
## Why this instead of `opencode-repo-auth` + `opencode-otel`
|
|
15
|
+
## Why this instead of `opencode-oauth2` + `opencode-repo-auth` + `opencode-otel`
|
|
13
16
|
|
|
14
|
-
Running
|
|
15
|
-
namespace —
|
|
16
|
-
composes `@vymalo/opencode-
|
|
17
|
-
`@vymalo/opencode-core
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
Running the standalone plugins works, but each holds its **own** credential in its **own** cache
|
|
18
|
+
namespace — up to three logins, three token stores, no relationship between them.
|
|
19
|
+
`@vymalo/opencode-lightbridge` composes `@vymalo/opencode-provider-sync` (the model-sync engine,
|
|
20
|
+
for `register`), `@vymalo/opencode-auth-core` (the OAuth/token-exchange primitive), and
|
|
21
|
+
`@vymalo/opencode-core-otel` (the OTel engine) over **one shared credential**, so the same token
|
|
22
|
+
that registers your provider is the one the gateway injects and OTEL presents to the collector. No
|
|
23
|
+
forked engine logic — this package composes its sibling libraries.
|
|
20
24
|
|
|
21
25
|
## Install
|
|
22
26
|
|
|
@@ -37,11 +41,14 @@ npm install @vymalo/opencode-lightbridge
|
|
|
37
41
|
"authFlow": "device_code"
|
|
38
42
|
},
|
|
39
43
|
"gateway": {
|
|
40
|
-
"
|
|
41
|
-
"
|
|
44
|
+
"providers": ["gateway"],
|
|
45
|
+
"exchange": false
|
|
42
46
|
},
|
|
43
47
|
"otel": {
|
|
44
48
|
"endpoint": "http://localhost:4318"
|
|
49
|
+
},
|
|
50
|
+
"register": {
|
|
51
|
+
"baseURL": "https://gateway.example.com/v1"
|
|
45
52
|
}
|
|
46
53
|
}]
|
|
47
54
|
]
|
|
@@ -49,17 +56,30 @@ npm install @vymalo/opencode-lightbridge
|
|
|
49
56
|
```
|
|
50
57
|
|
|
51
58
|
- **`auth`** (required) — the one IdP login, an `AuthServerConfigInput` (same shape as
|
|
52
|
-
`opencode-oauth2`/`opencode-repo-auth`'s per-provider auth block).
|
|
53
|
-
|
|
54
|
-
|
|
59
|
+
`opencode-oauth2`/`opencode-repo-auth`'s per-provider auth block). `auth.id` also doubles as the
|
|
60
|
+
OpenCode provider id `register` uses and the shared cache identity (see below).
|
|
61
|
+
- **`register`** (optional, [ADR-0017](https://github.com/ADORSYS-GIS/lightbridge-opencode-toolbeit/blob/main/docs/adr/0017-lightbridge-all-in-one.md)) —
|
|
62
|
+
register `auth`'s IdP as an OpenCode provider and keep its models in sync, via the same
|
|
63
|
+
`@vymalo/opencode-provider-sync` engine `opencode-oauth2` uses. Requires `register.baseURL`.
|
|
64
|
+
- **`gateway`** (optional) — the OpenCode provider ids to inject a bearer on, per request (plus an
|
|
65
|
+
optional `gateway.exchange`/`projectId`).
|
|
66
|
+
- **`gateway.exchange`** (optional, default `false`) — `false`: use the IdP access token directly.
|
|
67
|
+
`true`: RFC 8693-exchange it for a project-scoped token first (ADR-0012's original behaviour).
|
|
55
68
|
- **`otel`** (optional) — the same `OtelPluginOptions` shape as `@vymalo/opencode-otel`
|
|
56
69
|
(`endpoint`, `exporters`, `serviceName`, …), minus `tokenCommand`/`tokenHeader`/`tokenPrefix`: the
|
|
57
70
|
shared runtime supersedes that seam entirely.
|
|
58
|
-
- **`projectId`** (optional, top-level or under `gateway`) —
|
|
59
|
-
exchange
|
|
60
|
-
explicit top-level `projectId` wins
|
|
71
|
+
- **`projectId`** (optional, top-level or under `gateway`) — only meaningful when
|
|
72
|
+
`gateway.exchange: true`. **Fully optional**: omit it and the exchange sends no `project_id`, so
|
|
73
|
+
the backend mints a token for your **default project**. An explicit top-level `projectId` wins
|
|
74
|
+
over `gateway.projectId`.
|
|
75
|
+
|
|
76
|
+
Omitting `gateway`, `otel` and `register` is a valid, inert config — the plugin logs and no-ops.
|
|
61
77
|
|
|
62
|
-
|
|
78
|
+
**One login, shared with oauth2.** Configure the same `id`/`issuer`/`clientId` as an
|
|
79
|
+
`@vymalo/opencode-oauth2` server entry and logging in through either plugin makes the token
|
|
80
|
+
available to both — no second device-code/browser flow. An existing pre-ADR-0017 install's cached
|
|
81
|
+
login is migrated automatically on first use; no re-login is forced. See
|
|
82
|
+
[`docs/lightbridge.md`](https://github.com/ADORSYS-GIS/lightbridge-opencode-toolbeit/blob/main/docs/lightbridge.md#one-login-shared-cache-with-oauth2-adr-0017).
|
|
63
83
|
|
|
64
84
|
## Scope: gateway + OTEL, not MCP
|
|
65
85
|
|
|
@@ -73,12 +93,15 @@ for the full reasoning.
|
|
|
73
93
|
## Full reference
|
|
74
94
|
|
|
75
95
|
- [`docs/lightbridge.md`](https://github.com/ADORSYS-GIS/lightbridge-opencode-toolbeit/blob/main/docs/lightbridge.md)
|
|
76
|
-
— the one
|
|
96
|
+
— the all-in-one design, full config reference, migration notes.
|
|
77
97
|
- [ADR-0012](https://github.com/ADORSYS-GIS/lightbridge-opencode-toolbeit/blob/main/docs/adr/0012-single-auth-across-gateway-and-otel.md)
|
|
78
98
|
— why one runtime, why MCP is excluded, the alternatives considered.
|
|
79
|
-
- [
|
|
80
|
-
and
|
|
81
|
-
|
|
99
|
+
- [ADR-0017](https://github.com/ADORSYS-GIS/lightbridge-opencode-toolbeit/blob/main/docs/adr/0017-lightbridge-all-in-one.md)
|
|
100
|
+
— `register`, the shared cache with oauth2, and the exchange becoming opt-in.
|
|
101
|
+
- [`docs/repo-auth.md`](https://github.com/ADORSYS-GIS/lightbridge-opencode-toolbeit/blob/main/docs/repo-auth.md),
|
|
102
|
+
[`docs/otel.md`](https://github.com/ADORSYS-GIS/lightbridge-opencode-toolbeit/blob/main/docs/otel.md) and
|
|
103
|
+
[`docs/architecture.md`](https://github.com/ADORSYS-GIS/lightbridge-opencode-toolbeit/blob/main/docs/architecture.md)
|
|
104
|
+
(oauth2) — the standalone plugins this package composes; useful for the config shapes it reuses.
|
|
82
105
|
|
|
83
106
|
## License
|
|
84
107
|
|
package/dist/config.d.ts
CHANGED
|
@@ -7,9 +7,49 @@ export interface LightbridgeGatewayOptions {
|
|
|
7
7
|
/**
|
|
8
8
|
* Optional project id for the shared RFC 8693 exchange (ADR-0012). When
|
|
9
9
|
* omitted, the exchange sends no `project_id` and the backend mints a token
|
|
10
|
-
* for the caller's **default project**.
|
|
10
|
+
* for the caller's **default project**. Only meaningful when `exchange` is
|
|
11
|
+
* `true` — ignored otherwise (there is no exchange call to attach it to).
|
|
11
12
|
*/
|
|
12
13
|
projectId?: string;
|
|
14
|
+
/**
|
|
15
|
+
* Whether to perform the RFC 8693 token exchange (ADR-0012) before using
|
|
16
|
+
* the result as the gateway bearer. Defaults to `false` (ADR-0017 amends
|
|
17
|
+
* ADR-0012): most IdP setups hand out an access token that is already
|
|
18
|
+
* scoped for the gateway, and an unconditional exchange fails outright for
|
|
19
|
+
* a client that has no `token-exchange` grant registered (`unauthorized_client`).
|
|
20
|
+
*
|
|
21
|
+
* - `false` (default): the IdP access token from `auth` is used directly
|
|
22
|
+
* as `Authorization: Bearer <token>` — no second network call, no extra
|
|
23
|
+
* grant required.
|
|
24
|
+
* - `true`: today's ADR-0012 behaviour, unchanged — the human token is
|
|
25
|
+
* exchanged for a project-scoped token (optionally carrying `project_id`)
|
|
26
|
+
* and THAT is used as the bearer.
|
|
27
|
+
*/
|
|
28
|
+
exchange?: boolean;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The `register` opt-in (ADR-0017): register `auth`'s IdP as an OpenCode
|
|
32
|
+
* provider and keep its model list in sync via the SAME
|
|
33
|
+
* `@vymalo/opencode-provider-sync` engine `@vymalo/opencode-oauth2` uses —
|
|
34
|
+
* "everything oauth2 does", now available from lightbridge too. Independent
|
|
35
|
+
* of `gateway` (header injection) and `otel`: a config can register a
|
|
36
|
+
* provider without ever using it as the gateway bearer target, though the
|
|
37
|
+
* common case sets `gateway.providers` to the same id.
|
|
38
|
+
*/
|
|
39
|
+
export interface LightbridgeRegisterOptions {
|
|
40
|
+
/** Base URL of the OpenAI-compatible (or Responses) inference endpoint. */
|
|
41
|
+
baseURL: string;
|
|
42
|
+
/** Display name for the registered OpenCode provider. Defaults to `auth.id`. */
|
|
43
|
+
name?: string;
|
|
44
|
+
/** Raw model id -> display name overrides, applied at discovery time. */
|
|
45
|
+
nameOverrides?: Record<string, string>;
|
|
46
|
+
/** Minutes between model-discovery syncs. Defaults to 60 (matches oauth2). */
|
|
47
|
+
syncIntervalMinutes?: number;
|
|
48
|
+
/**
|
|
49
|
+
* Route inference through the OpenAI Responses API instead of Chat
|
|
50
|
+
* Completions — see `@vymalo/opencode-provider-sync`'s `resolveProviderNpm`.
|
|
51
|
+
*/
|
|
52
|
+
responseApi?: boolean;
|
|
13
53
|
}
|
|
14
54
|
/**
|
|
15
55
|
* Plugin options as written under the `opencode-lightbridge` entry of
|
|
@@ -26,9 +66,12 @@ export interface LightbridgeOptions {
|
|
|
26
66
|
* Optional project id for the shared project-scoped token exchange. Fully
|
|
27
67
|
* optional: when omitted, the exchange sends no `project_id` and the backend
|
|
28
68
|
* mints a token for the caller's **default project**. An explicit `projectId`
|
|
29
|
-
* here wins over `gateway.projectId` when both are set.
|
|
69
|
+
* here wins over `gateway.projectId` when both are set. Only meaningful
|
|
70
|
+
* when `gateway.exchange` is `true`.
|
|
30
71
|
*/
|
|
31
72
|
projectId?: string;
|
|
73
|
+
/** NEW (ADR-0017): register `auth`'s IdP as an OpenCode provider + sync its models. */
|
|
74
|
+
register?: LightbridgeRegisterOptions;
|
|
32
75
|
}
|
|
33
76
|
/** `LightbridgeOptions` after parsing: `auth` fully validated + defaulted. */
|
|
34
77
|
export interface ParsedLightbridgeOptions {
|
|
@@ -37,6 +80,7 @@ export interface ParsedLightbridgeOptions {
|
|
|
37
80
|
otel?: OtelPluginOptions;
|
|
38
81
|
/** Resolved project id (`projectId` ?? `gateway.projectId`), if either was set. */
|
|
39
82
|
projectId?: string;
|
|
83
|
+
register?: LightbridgeRegisterOptions;
|
|
40
84
|
}
|
|
41
85
|
/**
|
|
42
86
|
* Parse + validate the plugin's `pluginOptions` (2nd factory arg). `auth` is
|
|
@@ -49,7 +93,26 @@ export interface ParsedLightbridgeOptions {
|
|
|
49
93
|
export declare function parseLightbridgeOptions(raw: unknown): ParsedLightbridgeOptions;
|
|
50
94
|
/**
|
|
51
95
|
* Whether the parsed config has a module (gateway or otel) that needs the
|
|
52
|
-
* shared token — project-scoped when `projectId` is set
|
|
53
|
-
* token. Determines whether the one shared
|
|
96
|
+
* shared token — project-scoped when `projectId` is set (and `gateway.exchange`
|
|
97
|
+
* is `true`), else the raw IdP token. Determines whether the one shared
|
|
98
|
+
* runtime is built. `register` doesn't gate this: it drives a separate
|
|
99
|
+
* `ProviderModelSyncEngine`, not `LightbridgeRuntime`.
|
|
54
100
|
*/
|
|
55
101
|
export declare function needsProjectToken(options: ParsedLightbridgeOptions): boolean;
|
|
102
|
+
export interface OpenCodeConfigLike {
|
|
103
|
+
provider?: unknown;
|
|
104
|
+
pluginConfig?: unknown;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Provider ids managed by `@vymalo/opencode-oauth2` via its
|
|
108
|
+
* `pluginConfig.oauth2ModelSync.servers` channel.
|
|
109
|
+
*/
|
|
110
|
+
export declare function oauth2ManagedProviderIds(config: OpenCodeConfigLike): Set<string>;
|
|
111
|
+
/**
|
|
112
|
+
* Whether `providerId` is (also) managed by `@vymalo/opencode-oauth2` —
|
|
113
|
+
* either via that provider's own `options.oauth2` / `options.oauth2ModelSync`
|
|
114
|
+
* block, or via the `pluginConfig.oauth2ModelSync.servers` channel. Used by
|
|
115
|
+
* lightbridge's `register` module to skip registering/owning a provider
|
|
116
|
+
* oauth2 already claims (logged at `debug` — see `opencode.ts`).
|
|
117
|
+
*/
|
|
118
|
+
export declare function hasOAuth2Conflict(config: OpenCodeConfigLike, providerId: string): boolean;
|
package/dist/config.js
CHANGED
|
@@ -14,6 +14,15 @@ function asProviderList(value, path) {
|
|
|
14
14
|
}
|
|
15
15
|
return value.map((entry, index) => asNonEmptyString(entry, `${path}[${index}]`));
|
|
16
16
|
}
|
|
17
|
+
function asBoolean(value, path) {
|
|
18
|
+
if (value === undefined || value === null) {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
if (typeof value !== "boolean") {
|
|
22
|
+
throw new Error(`${path} must be a boolean (received ${JSON.stringify(value)})`);
|
|
23
|
+
}
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
17
26
|
function parseGateway(raw, path) {
|
|
18
27
|
if (raw === undefined || raw === null) {
|
|
19
28
|
return undefined;
|
|
@@ -22,11 +31,44 @@ function parseGateway(raw, path) {
|
|
|
22
31
|
throw new Error(`${path} must be an object with \`providers\` (and an optional \`projectId\`)`);
|
|
23
32
|
}
|
|
24
33
|
const projectId = raw.projectId === undefined || raw.projectId === null ? undefined : asNonEmptyString(raw.projectId, `${path}.projectId`);
|
|
34
|
+
// Opt-in (ADR-0017, amends ADR-0012): defaults to `false` — see
|
|
35
|
+
// `LightbridgeGatewayOptions.exchange`'s doc comment for the rationale.
|
|
36
|
+
const exchange = asBoolean(raw.exchange, `${path}.exchange`) ?? false;
|
|
25
37
|
return {
|
|
26
38
|
providers: asProviderList(raw.providers, `${path}.providers`),
|
|
39
|
+
exchange,
|
|
27
40
|
...projectId ? { projectId } : {}
|
|
28
41
|
};
|
|
29
42
|
}
|
|
43
|
+
function asStringMap(value, path) {
|
|
44
|
+
if (value === undefined || value === null) {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
if (!isRecord(value)) {
|
|
48
|
+
throw new Error(`${path} must be an object mapping model id -> display name`);
|
|
49
|
+
}
|
|
50
|
+
const normalized = {};
|
|
51
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
52
|
+
normalized[key] = asNonEmptyString(entry, `${path}.${key}`);
|
|
53
|
+
}
|
|
54
|
+
return normalized;
|
|
55
|
+
}
|
|
56
|
+
function parseRegister(raw, path) {
|
|
57
|
+
if (raw === undefined || raw === null) {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
if (!isRecord(raw)) {
|
|
61
|
+
throw new Error(`${path} must be an object with at least \`baseURL\``);
|
|
62
|
+
}
|
|
63
|
+
const syncIntervalMinutes = typeof raw.syncIntervalMinutes === "number" && Number.isFinite(raw.syncIntervalMinutes) && raw.syncIntervalMinutes > 0 ? raw.syncIntervalMinutes : undefined;
|
|
64
|
+
return {
|
|
65
|
+
baseURL: asNonEmptyString(raw.baseURL, `${path}.baseURL`),
|
|
66
|
+
name: raw.name === undefined || raw.name === null ? undefined : asNonEmptyString(raw.name, `${path}.name`),
|
|
67
|
+
nameOverrides: asStringMap(raw.nameOverrides, `${path}.nameOverrides`),
|
|
68
|
+
syncIntervalMinutes,
|
|
69
|
+
responseApi: asBoolean(raw.responseApi, `${path}.responseApi`)
|
|
70
|
+
};
|
|
71
|
+
}
|
|
30
72
|
function parseOtel(raw, path) {
|
|
31
73
|
if (raw === undefined || raw === null) {
|
|
32
74
|
return undefined;
|
|
@@ -57,22 +99,72 @@ export function parseLightbridgeOptions(raw) {
|
|
|
57
99
|
const auth = validateAuthConfig(raw.auth);
|
|
58
100
|
const gateway = parseGateway(raw.gateway, "lightbridge.gateway");
|
|
59
101
|
const otel = parseOtel(raw.otel, "lightbridge.otel");
|
|
102
|
+
const register = parseRegister(raw.register, "lightbridge.register");
|
|
60
103
|
const explicitProjectId = typeof raw.projectId === "string" && raw.projectId.trim().length > 0 ? raw.projectId.trim() : undefined;
|
|
61
104
|
const projectId = explicitProjectId ?? gateway?.projectId;
|
|
62
105
|
return {
|
|
63
106
|
auth,
|
|
64
107
|
gateway,
|
|
65
108
|
otel,
|
|
66
|
-
projectId
|
|
109
|
+
projectId,
|
|
110
|
+
register
|
|
67
111
|
};
|
|
68
112
|
}
|
|
69
113
|
/**
|
|
70
114
|
* Whether the parsed config has a module (gateway or otel) that needs the
|
|
71
|
-
* shared token — project-scoped when `projectId` is set
|
|
72
|
-
* token. Determines whether the one shared
|
|
115
|
+
* shared token — project-scoped when `projectId` is set (and `gateway.exchange`
|
|
116
|
+
* is `true`), else the raw IdP token. Determines whether the one shared
|
|
117
|
+
* runtime is built. `register` doesn't gate this: it drives a separate
|
|
118
|
+
* `ProviderModelSyncEngine`, not `LightbridgeRuntime`.
|
|
73
119
|
*/
|
|
74
120
|
export function needsProjectToken(options) {
|
|
75
121
|
return Boolean(options.gateway) || Boolean(options.otel);
|
|
76
122
|
}
|
|
123
|
+
// ---- oauth2 provider-id collision guard (ADR-0017 requirement 4) ----------
|
|
124
|
+
//
|
|
125
|
+
// If a developer configures BOTH `@vymalo/opencode-oauth2` and lightbridge's
|
|
126
|
+
// `register` block against the SAME OpenCode provider id, they must not both
|
|
127
|
+
// register/own it — mirrors `@vymalo/opencode-repo-auth`'s
|
|
128
|
+
// `hasOAuth2Conflict`/`oauth2ManagedProviderIds` (which defers to oauth2 the
|
|
129
|
+
// same way). Kept as a lightbridge-local copy rather than a shared import:
|
|
130
|
+
// each consumer's own config-key literals are intentionally NOT centralized
|
|
131
|
+
// (see `@vymalo/opencode-provider-sync`'s `opencode-helpers.ts` module doc).
|
|
132
|
+
const OAUTH2_OPTION_KEYS = ["oauth2", "oauth2ModelSync"];
|
|
133
|
+
/**
|
|
134
|
+
* Provider ids managed by `@vymalo/opencode-oauth2` via its
|
|
135
|
+
* `pluginConfig.oauth2ModelSync.servers` channel.
|
|
136
|
+
*/
|
|
137
|
+
export function oauth2ManagedProviderIds(config) {
|
|
138
|
+
const ids = new Set();
|
|
139
|
+
const pluginConfig = isRecord(config.pluginConfig) ? config.pluginConfig : undefined;
|
|
140
|
+
const oauth2ModelSync = isRecord(pluginConfig?.oauth2ModelSync) ? pluginConfig?.oauth2ModelSync : undefined;
|
|
141
|
+
const servers = oauth2ModelSync?.servers;
|
|
142
|
+
if (Array.isArray(servers)) {
|
|
143
|
+
for (const raw of servers) {
|
|
144
|
+
const entry = isRecord(raw) ? raw : undefined;
|
|
145
|
+
const id = typeof entry?.id === "string" ? entry.id : undefined;
|
|
146
|
+
if (id) {
|
|
147
|
+
ids.add(id);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return ids;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Whether `providerId` is (also) managed by `@vymalo/opencode-oauth2` —
|
|
155
|
+
* either via that provider's own `options.oauth2` / `options.oauth2ModelSync`
|
|
156
|
+
* block, or via the `pluginConfig.oauth2ModelSync.servers` channel. Used by
|
|
157
|
+
* lightbridge's `register` module to skip registering/owning a provider
|
|
158
|
+
* oauth2 already claims (logged at `debug` — see `opencode.ts`).
|
|
159
|
+
*/
|
|
160
|
+
export function hasOAuth2Conflict(config, providerId) {
|
|
161
|
+
const providers = isRecord(config.provider) ? config.provider : undefined;
|
|
162
|
+
const providerEntry = providers ? providers[providerId] : undefined;
|
|
163
|
+
const providerOptions = isRecord(providerEntry) && isRecord(providerEntry.options) ? providerEntry.options : undefined;
|
|
164
|
+
if (providerOptions && OAUTH2_OPTION_KEYS.some((key) => isRecord(providerOptions[key]))) {
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
return oauth2ManagedProviderIds(config).has(providerId);
|
|
168
|
+
}
|
|
77
169
|
|
|
78
170
|
//# sourceMappingURL=config.js.map
|
package/dist/config.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"mappings":"AAAA,SACE,0BAGK;
|
|
1
|
+
{"mappings":"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","names":[],"sources":["../src/config.ts"],"version":3,"file":"config.js","sourceRoot":""}
|
package/dist/lib.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { createLightbridgePlugin, LightbridgePlugin, type LightbridgePluginFactoryOptions } from "./opencode.js";
|
|
2
|
-
export { LightbridgeRuntime, LIGHTBRIDGE_IDENTITY, DEFAULT_CACHE_NAMESPACE, DEFAULT_PROJECT_KEY, lightbridgeCacheDir, type LightbridgeRuntimeFactory, type LightbridgeRuntimeLike, type LightbridgeRuntimeOptions } from "./plugin.js";
|
|
3
|
-
export {
|
|
2
|
+
export { LightbridgeRuntime, LIGHTBRIDGE_IDENTITY, DEFAULT_CACHE_NAMESPACE, DEFAULT_PROJECT_KEY, ROOT_CACHE_NAMESPACE, ROOT_CACHE_SEGMENT, lightbridgeCacheDir, rootCacheDir, type LightbridgeRuntimeFactory, type LightbridgeRuntimeLike, type LightbridgeRuntimeOptions } from "./plugin.js";
|
|
3
|
+
export { hasOAuth2Conflict, needsProjectToken, oauth2ManagedProviderIds, parseLightbridgeOptions, type LightbridgeGatewayOptions, type LightbridgeOptions, type LightbridgeRegisterOptions, type OpenCodeConfigLike, type ParsedLightbridgeOptions } from "./config.js";
|
|
4
|
+
export { migrateRootTokenIfNeeded } from "./migration.js";
|
package/dist/lib.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { createLightbridgePlugin, LightbridgePlugin } from "./opencode.js";
|
|
2
|
-
export { LightbridgeRuntime, LIGHTBRIDGE_IDENTITY, DEFAULT_CACHE_NAMESPACE, DEFAULT_PROJECT_KEY, lightbridgeCacheDir } from "./plugin.js";
|
|
3
|
-
export {
|
|
2
|
+
export { LightbridgeRuntime, LIGHTBRIDGE_IDENTITY, DEFAULT_CACHE_NAMESPACE, DEFAULT_PROJECT_KEY, ROOT_CACHE_NAMESPACE, ROOT_CACHE_SEGMENT, lightbridgeCacheDir, rootCacheDir } from "./plugin.js";
|
|
3
|
+
export { hasOAuth2Conflict, needsProjectToken, oauth2ManagedProviderIds, parseLightbridgeOptions } from "./config.js";
|
|
4
|
+
export { migrateRootTokenIfNeeded } from "./migration.js";
|
|
4
5
|
|
|
5
6
|
//# sourceMappingURL=lib.js.map
|
package/dist/lib.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"mappings":"AAAA,SACE,yBACA,yBAEK;AAEP,SACE,oBACA,sBACA,yBACA,qBACA,
|
|
1
|
+
{"mappings":"AAAA,SACE,yBACA,yBAEK;AAEP,SACE,oBACA,sBACA,yBACA,qBACA,sBACA,oBACA,qBACA,oBAIK;AAEP,SACE,mBACA,mBACA,0BACA,+BAMK;AAEP,SAAS,gCAAgC","names":[],"sources":["../src/lib.ts"],"version":3,"file":"lib.js","sourceRoot":""}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type Logger } from "@vymalo/opencode-auth-core/lib";
|
|
2
|
+
/**
|
|
3
|
+
* One-time migration read-path (ADR-0017, mandatory — not optional polish).
|
|
4
|
+
*
|
|
5
|
+
* v0.17 moves lightbridge's human root token from its own bespoke file
|
|
6
|
+
* (`<cacheRoot>/opencode-lightbridge/lightbridge.json`, a bare `TokenSet`) to
|
|
7
|
+
* the SAME fused `CachedServerState` file `@vymalo/opencode-oauth2` uses
|
|
8
|
+
* (`<cacheRoot>/<sharedSegment>/<sharedNamespace>/<serverId>.json`) so the two
|
|
9
|
+
* plugins can share one login (ADR-0017 §"one login, shared cache"). An
|
|
10
|
+
* install that already has a valid old-format token must NOT be forced
|
|
11
|
+
* through a fresh device-code/browser login just because the file moved.
|
|
12
|
+
*
|
|
13
|
+
* Behaviour:
|
|
14
|
+
* - If the NEW location already has a persisted state (with or without a
|
|
15
|
+
* token), this is a no-op — never clobbers a fresher state (e.g. one
|
|
16
|
+
* oauth2 has already been writing to, with real discovered models).
|
|
17
|
+
* - Else, read the OLD location. If it holds a structurally usable token
|
|
18
|
+
* (`accessToken` + `tokenType` present — possibly expired; an expired
|
|
19
|
+
* token with a refresh token is still worth adopting, since `TokenRuntime`
|
|
20
|
+
* will refresh it transparently), write it into the NEW location as a
|
|
21
|
+
* fresh `CachedServerState` (empty `models`/`rawModels` — nothing to carry
|
|
22
|
+
* over, since the old shape never tracked models).
|
|
23
|
+
* - Else (no old file, or it's empty/malformed) this is a no-op; the caller
|
|
24
|
+
* proceeds to a normal login.
|
|
25
|
+
*
|
|
26
|
+
* The old file is left in place (non-destructive) — this function only ever
|
|
27
|
+
* ADDS the new file, never deletes/mutates the old one. Safe to call more
|
|
28
|
+
* than once: the "new location already has a state" check makes every call
|
|
29
|
+
* after the first a cheap no-op.
|
|
30
|
+
*/
|
|
31
|
+
export declare function migrateRootTokenIfNeeded(cacheRoot: string, sharedSegment: string, sharedNamespace: string, serverId: string, logger: Logger): Promise<void>;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { FileCacheStore as LegacyFileCacheStore } from "@vymalo/opencode-auth-core/lib";
|
|
3
|
+
import { FileCacheStore as SharedFileCacheStore } from "@vymalo/opencode-provider-sync/lib";
|
|
4
|
+
/**
|
|
5
|
+
* The pre-ADR-0017 on-disk location of lightbridge's human root token:
|
|
6
|
+
* `<cacheRoot>/opencode-lightbridge/lightbridge.json`, a bare `TokenSet`
|
|
7
|
+
* (see the OLD `LIGHTBRIDGE_IDENTITY`/`DEFAULT_CACHE_NAMESPACE` in `plugin.ts`,
|
|
8
|
+
* which are still used verbatim for the exchanged project-token store — only
|
|
9
|
+
* the ROOT token relocated).
|
|
10
|
+
*/
|
|
11
|
+
const OLD_ROOT_CACHE_NAMESPACE = "opencode-lightbridge";
|
|
12
|
+
const OLD_ROOT_KEY = "lightbridge";
|
|
13
|
+
function hasUsableTokenShape(value) {
|
|
14
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
const candidate = value;
|
|
18
|
+
return typeof candidate.accessToken === "string" && candidate.accessToken.length > 0 && typeof candidate.tokenType === "string" && candidate.tokenType.length > 0;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* One-time migration read-path (ADR-0017, mandatory — not optional polish).
|
|
22
|
+
*
|
|
23
|
+
* v0.17 moves lightbridge's human root token from its own bespoke file
|
|
24
|
+
* (`<cacheRoot>/opencode-lightbridge/lightbridge.json`, a bare `TokenSet`) to
|
|
25
|
+
* the SAME fused `CachedServerState` file `@vymalo/opencode-oauth2` uses
|
|
26
|
+
* (`<cacheRoot>/<sharedSegment>/<sharedNamespace>/<serverId>.json`) so the two
|
|
27
|
+
* plugins can share one login (ADR-0017 §"one login, shared cache"). An
|
|
28
|
+
* install that already has a valid old-format token must NOT be forced
|
|
29
|
+
* through a fresh device-code/browser login just because the file moved.
|
|
30
|
+
*
|
|
31
|
+
* Behaviour:
|
|
32
|
+
* - If the NEW location already has a persisted state (with or without a
|
|
33
|
+
* token), this is a no-op — never clobbers a fresher state (e.g. one
|
|
34
|
+
* oauth2 has already been writing to, with real discovered models).
|
|
35
|
+
* - Else, read the OLD location. If it holds a structurally usable token
|
|
36
|
+
* (`accessToken` + `tokenType` present — possibly expired; an expired
|
|
37
|
+
* token with a refresh token is still worth adopting, since `TokenRuntime`
|
|
38
|
+
* will refresh it transparently), write it into the NEW location as a
|
|
39
|
+
* fresh `CachedServerState` (empty `models`/`rawModels` — nothing to carry
|
|
40
|
+
* over, since the old shape never tracked models).
|
|
41
|
+
* - Else (no old file, or it's empty/malformed) this is a no-op; the caller
|
|
42
|
+
* proceeds to a normal login.
|
|
43
|
+
*
|
|
44
|
+
* The old file is left in place (non-destructive) — this function only ever
|
|
45
|
+
* ADDS the new file, never deletes/mutates the old one. Safe to call more
|
|
46
|
+
* than once: the "new location already has a state" check makes every call
|
|
47
|
+
* after the first a cheap no-op.
|
|
48
|
+
*/
|
|
49
|
+
export async function migrateRootTokenIfNeeded(cacheRoot, sharedSegment, sharedNamespace, serverId, logger) {
|
|
50
|
+
const sharedStore = new SharedFileCacheStore(join(cacheRoot, sharedSegment, sharedNamespace), logger);
|
|
51
|
+
let alreadyMigrated;
|
|
52
|
+
try {
|
|
53
|
+
alreadyMigrated = await sharedStore.loadServerState(serverId);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
logger.debug("lightbridge_root_migration_check_failed", { error: error instanceof Error ? error.message : String(error) });
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (alreadyMigrated) {
|
|
59
|
+
// New location already has SOME state (with or without a token) — either
|
|
60
|
+
// a previous migration ran, or oauth2/register already populated it.
|
|
61
|
+
// Never overwrite it from the old file.
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const legacyStore = new LegacyFileCacheStore(join(cacheRoot, OLD_ROOT_CACHE_NAMESPACE), logger);
|
|
65
|
+
let legacyToken;
|
|
66
|
+
try {
|
|
67
|
+
legacyToken = await legacyStore.load(OLD_ROOT_KEY);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
logger.debug("lightbridge_root_migration_legacy_read_failed", { error: error instanceof Error ? error.message : String(error) });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (!hasUsableTokenShape(legacyToken)) {
|
|
73
|
+
logger.trace("lightbridge_root_migration_nothing_to_adopt", { serverId });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const migrated = {
|
|
77
|
+
serverId,
|
|
78
|
+
updatedAt: Date.now(),
|
|
79
|
+
models: [],
|
|
80
|
+
rawModels: [],
|
|
81
|
+
token: legacyToken
|
|
82
|
+
};
|
|
83
|
+
await sharedStore.ensureReady();
|
|
84
|
+
await sharedStore.saveServerState(migrated);
|
|
85
|
+
logger.info("lightbridge_root_migration_adopted", {
|
|
86
|
+
serverId,
|
|
87
|
+
hadRefreshToken: Boolean(legacyToken.refreshToken)
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
//# sourceMappingURL=migration.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"mappings":"AAAA,SAAS,YAAY;AAErB,SACE,kBAAkB,4BAGb;AACP,SACE,kBAAkB,4BAEb;;;;;;;;AASP,MAAM,2BAA2B;AACjC,MAAM,eAAe;AAErB,SAAS,oBAAoB,OAAmC;CAC9D,IAAI,CAAC,SAAS,OAAO,UAAU,YAAY,MAAM,QAAQ,KAAK,GAAG;EAC/D,OAAO;CACT;CACA,MAAM,YAAY;CAClB,OACE,OAAO,UAAU,gBAAgB,YACjC,UAAU,YAAY,SAAS,KAC/B,OAAO,UAAU,cAAc,YAC/B,UAAU,UAAU,SAAS;AAEjC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+BA,OAAO,eAAe,yBACpB,WACA,eACA,iBACA,UACA,QACe;CACf,MAAM,cAAc,IAAI,qBACtB,KAAK,WAAW,eAAe,eAAe,GAC9C,MACF;CAEA,IAAI;CACJ,IAAI;EACF,kBAAkB,MAAM,YAAY,gBAAgB,QAAQ;CAC9D,SAAS,OAAO;EACd,OAAO,MAAM,2CAA2C,EACtD,OAAO,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,EAC9D,CAAC;EACD;CACF;CACA,IAAI,iBAAiB;;;;EAInB;CACF;CAEA,MAAM,cAAc,IAAI,qBAAqB,KAAK,WAAW,wBAAwB,GAAG,MAAM;CAC9F,IAAI;CACJ,IAAI;EACF,cAAc,MAAM,YAAY,KAAe,YAAY;CAC7D,SAAS,OAAO;EACd,OAAO,MAAM,iDAAiD,EAC5D,OAAO,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,EAC9D,CAAC;EACD;CACF;CAEA,IAAI,CAAC,oBAAoB,WAAW,GAAG;EACrC,OAAO,MAAM,+CAA+C,EAAE,SAAS,CAAC;EACxE;CACF;CAEA,MAAM,WAA8B;EAClC;EACA,WAAW,KAAK,IAAI;EACpB,QAAQ,CAAC;EACT,WAAW,CAAC;EACZ,OAAO;CACT;CAEA,MAAM,YAAY,YAAY;CAC9B,MAAM,YAAY,gBAAgB,QAAQ;CAC1C,OAAO,KAAK,sCAAsC;EAChD;EACA,iBAAiB,QAAQ,YAAY,YAAY;CACnD,CAAC;AACH","names":[],"sources":["../src/migration.ts"],"version":3,"file":"migration.js","sourceRoot":""}
|
package/dist/opencode.js
CHANGED
|
@@ -1,12 +1,23 @@
|
|
|
1
1
|
import { hostname } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
2
3
|
import { createJsonConsoleLogger, DEFAULT_LOG_LEVEL, LOG_LEVEL_PRIORITY } from "@vymalo/opencode-auth-core/lib";
|
|
3
4
|
import { buildResource, createProviders, deferredAttribute, describeError, fromOpenCodeLogLevel, installTracePropagation, readVcsInfo, resolveOtelConfig, TelemetryRecorder } from "@vymalo/opencode-core-otel";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
5
|
+
import { applyResponsesApiOptions, mergeDiscoveredModels, ProviderModelSyncEngine, resolveProviderNpm } from "@vymalo/opencode-provider-sync/lib";
|
|
6
|
+
import { hasOAuth2Conflict, needsProjectToken, parseLightbridgeOptions } from "./config.js";
|
|
7
|
+
import { LightbridgeRuntime, ROOT_CACHE_NAMESPACE, ROOT_CACHE_SEGMENT } from "./plugin.js";
|
|
6
8
|
const PLUGIN_SERVICE_NAME = "opencode-lightbridge-plugin";
|
|
7
9
|
/**
|
|
8
10
|
* Pipe plugin logs through OpenCode's `client.app.log`, JSON console as
|
|
9
11
|
* fallback. Mirrors every other plugin in the suite.
|
|
12
|
+
*
|
|
13
|
+
* This plugin mutates the session (gateway bearer injection, OTEL export), so
|
|
14
|
+
* it might seem like a warn/error printed to the terminal would be actionable
|
|
15
|
+
* developer feedback. It is not: no plugin in this suite may write to the
|
|
16
|
+
* user's TUI (ADR-0014, superseding ADR-0013's narrower per-plugin scope).
|
|
17
|
+
* Every record still reaches `client.app.log` at its true level, so the
|
|
18
|
+
* diagnostic is always persisted to OpenCode's own log — just not on the
|
|
19
|
+
* developer's screen. Set `VYMALO_PLUGIN_CONSOLE_LOG=1` (`consoleAll` below)
|
|
20
|
+
* to opt back into the console mirror for every level.
|
|
10
21
|
*/
|
|
11
22
|
function createOpenCodeLogger(client, getMinLevel) {
|
|
12
23
|
const fallback = createJsonConsoleLogger("debug");
|
|
@@ -15,7 +26,7 @@ function createOpenCodeLogger(client, getMinLevel) {
|
|
|
15
26
|
if (LOG_LEVEL_PRIORITY[level] < LOG_LEVEL_PRIORITY[getMinLevel()]) {
|
|
16
27
|
return;
|
|
17
28
|
}
|
|
18
|
-
if (consoleAll
|
|
29
|
+
if (consoleAll) {
|
|
19
30
|
fallback[level](event, fields);
|
|
20
31
|
}
|
|
21
32
|
const hostLevel = level === "trace" ? "debug" : level;
|
|
@@ -72,8 +83,15 @@ function registerExitHandlers(providers, logger, deferred) {
|
|
|
72
83
|
* async factory over the SHARED runtime, superseding otel's standalone
|
|
73
84
|
* `tokenCommand` path entirely (ADR-0012 — one credential, not two seams).
|
|
74
85
|
* Never throws — the OTLP exporters require that of every `headers()` call —
|
|
75
|
-
* so an exchange failure
|
|
76
|
-
*
|
|
86
|
+
* so an exchange failure resolves `headers()` to `undefined` rather than `{}`.
|
|
87
|
+
* That distinction is load-bearing (ADR-0015): `{}` would still let the export
|
|
88
|
+
* fire with no `Authorization` header, which the collector then rejects at the
|
|
89
|
+
* network — the exact bug this fixes. `undefined` tells the shared gate in
|
|
90
|
+
* `@vymalo/opencode-core-otel` (`withFailureLogging`) to skip the export
|
|
91
|
+
* before any request goes out, so a logged-out session goes quiet instead of
|
|
92
|
+
* hammering the collector with 401s. Because `headers()` is called fresh on
|
|
93
|
+
* every export attempt, a later successful login resumes exporting on the
|
|
94
|
+
* next flush with nothing to restart.
|
|
77
95
|
*/
|
|
78
96
|
function createRuntimeTokenSource(runtime) {
|
|
79
97
|
return {
|
|
@@ -82,7 +100,7 @@ function createRuntimeTokenSource(runtime) {
|
|
|
82
100
|
const token = await runtime.getProjectToken({ interactive: false });
|
|
83
101
|
return { Authorization: `${token.tokenType || "Bearer"} ${token.accessToken}` };
|
|
84
102
|
} catch {
|
|
85
|
-
return
|
|
103
|
+
return undefined;
|
|
86
104
|
}
|
|
87
105
|
},
|
|
88
106
|
// v1: no-op. The project token is short-lived and `getProjectToken`
|
|
@@ -114,6 +132,117 @@ function createGatewayChatHeaders(providers, runtime, logger) {
|
|
|
114
132
|
};
|
|
115
133
|
}
|
|
116
134
|
/**
|
|
135
|
+
* Translate `auth` (already-validated `AuthServerConfig`) + `register` into
|
|
136
|
+
* the ONE `ProviderServerConfig` the shared `ProviderModelSyncEngine` needs
|
|
137
|
+
* (ADR-0017 requirement 1 — "everything oauth2 does"). `auth.id` is the
|
|
138
|
+
* provider id AND the shared cache identity (see `plugin.ts`'s
|
|
139
|
+
* `ROOT_CACHE_SEGMENT`/`ROOT_CACHE_NAMESPACE`).
|
|
140
|
+
*/
|
|
141
|
+
function buildRegisterServerConfig(auth, register) {
|
|
142
|
+
return {
|
|
143
|
+
id: auth.id,
|
|
144
|
+
name: register.name ?? auth.id,
|
|
145
|
+
issuer: auth.issuer,
|
|
146
|
+
baseURL: register.baseURL,
|
|
147
|
+
clientId: auth.clientId,
|
|
148
|
+
clientSecret: auth.clientSecret,
|
|
149
|
+
scopes: auth.scopes,
|
|
150
|
+
syncIntervalMinutes: register.syncIntervalMinutes ?? 60,
|
|
151
|
+
nameOverrides: register.nameOverrides ?? {},
|
|
152
|
+
authorizationEndpoint: auth.authorizationEndpoint,
|
|
153
|
+
tokenEndpoint: auth.tokenEndpoint,
|
|
154
|
+
deviceAuthorizationEndpoint: auth.deviceAuthorizationEndpoint,
|
|
155
|
+
jwksUri: auth.jwksUri,
|
|
156
|
+
redirectPort: auth.redirectPort,
|
|
157
|
+
authFlow: auth.authFlow,
|
|
158
|
+
pkce: auth.pkce,
|
|
159
|
+
subjectTokenSource: auth.subjectTokenSource,
|
|
160
|
+
tokenExchangeAudience: auth.tokenExchangeAudience
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* The `register` module (ADR-0017 requirement 1): register `auth`'s IdP as an
|
|
165
|
+
* OpenCode provider and keep `config.provider[<auth.id>].models` in sync via
|
|
166
|
+
* the SAME `ProviderModelSyncEngine` `@vymalo/opencode-oauth2` uses — built
|
|
167
|
+
* ONCE (register's config is fixed at plugin-construction time, unlike
|
|
168
|
+
* oauth2's, which is re-derived from the host config on every `config` hook),
|
|
169
|
+
* then merged into the host config on every `config` hook call so a later
|
|
170
|
+
* OpenCode config pass still sees the discovered models.
|
|
171
|
+
*
|
|
172
|
+
* Guard (requirement 4a): if `auth.id` is ALSO managed by
|
|
173
|
+
* `@vymalo/opencode-oauth2` in this same host config, lightbridge must not
|
|
174
|
+
* also register/own it — skip entirely (never construct the engine, never
|
|
175
|
+
* touch `config.provider[id]`), logged at `debug` (ADR-0014: nothing reaches
|
|
176
|
+
* the terminal), and fail safe (the gateway/OTEL bearer still works via
|
|
177
|
+
* `LightbridgeRuntime`'s own lightweight, read/refresh-only participation in
|
|
178
|
+
* the same shared cache file). The double-scheduling guard (requirement 4b —
|
|
179
|
+
* two engine instances in one process ticking the same cache file) is
|
|
180
|
+
* enforced generically inside `ProviderModelSyncEngine.start()` itself.
|
|
181
|
+
*/
|
|
182
|
+
async function applyRegisterModule(hostConfig, register, auth, state, logger, factoryOptions) {
|
|
183
|
+
const providerId = auth.id;
|
|
184
|
+
if (!state.engine && hasOAuth2Conflict(hostConfig, providerId)) {
|
|
185
|
+
if (!state.skippedForConflict) {
|
|
186
|
+
state.skippedForConflict = true;
|
|
187
|
+
logger.debug("lightbridge_register_skipped_oauth2_conflict", { providerId });
|
|
188
|
+
}
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (!state.engine) {
|
|
192
|
+
state.engine = new ProviderModelSyncEngine({
|
|
193
|
+
servers: [buildRegisterServerConfig(auth, register)],
|
|
194
|
+
cacheNamespace: ROOT_CACHE_NAMESPACE
|
|
195
|
+
}, {
|
|
196
|
+
logger,
|
|
197
|
+
fetchImpl: factoryOptions.fetchImpl,
|
|
198
|
+
onAuthorizationUrl: factoryOptions.onAuthorizationUrl,
|
|
199
|
+
cacheDir: factoryOptions.cacheDir ? join(factoryOptions.cacheDir, ROOT_CACHE_SEGMENT, ROOT_CACHE_NAMESPACE) : undefined,
|
|
200
|
+
cacheNamespaceSegment: ROOT_CACHE_SEGMENT,
|
|
201
|
+
serviceLabel: "opencode-lightbridge"
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
if (!state.started) {
|
|
205
|
+
await state.engine.initialize();
|
|
206
|
+
await state.engine.start({ warmup: true });
|
|
207
|
+
state.started = true;
|
|
208
|
+
}
|
|
209
|
+
const providers = hostConfig.provider ??= {};
|
|
210
|
+
const providerConfig = providers[providerId] ??= {};
|
|
211
|
+
const providerOptions = providerConfig.options ?? {};
|
|
212
|
+
providerConfig.npm = resolveProviderNpm(register.responseApi);
|
|
213
|
+
providerConfig.name = providerConfig.name ?? register.name ?? providerId;
|
|
214
|
+
providerConfig.options = applyResponsesApiOptions({
|
|
215
|
+
...providerOptions,
|
|
216
|
+
baseURL: register.baseURL
|
|
217
|
+
}, register.responseApi, providerId, logger);
|
|
218
|
+
const models = state.engine.getServerModels(providerId);
|
|
219
|
+
if (models.length > 0) {
|
|
220
|
+
mergeDiscoveredModels(providerConfig, models);
|
|
221
|
+
}
|
|
222
|
+
logger.trace("lightbridge_register_config_applied", {
|
|
223
|
+
providerId,
|
|
224
|
+
modelCount: models.length
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* `otel.tokenCommand`/`tokenHeader`/`tokenPrefix` are accepted by
|
|
229
|
+
* `parseLightbridgeOptions` (they are just `OtelPluginOptions` fields) but
|
|
230
|
+
* never take effect here: `buildOtelModule` always injects the shared
|
|
231
|
+
* runtime-backed `TokenSource` whenever a runtime exists, and that injected
|
|
232
|
+
* source unconditionally wins over `config.tokenCommand` in
|
|
233
|
+
* `createProviders` (see `providers.ts`). Silently accepting and ignoring a
|
|
234
|
+
* credential-helper config is exactly the kind of no-op ADR-0015 calls out, so
|
|
235
|
+
* this logs once at `debug` rather than staying quiet — `debug` because it's
|
|
236
|
+
* an informational no-op, not a warning-worthy misconfiguration, and no
|
|
237
|
+
* plugin in this suite prints to the terminal by default anyway (ADR-0014).
|
|
238
|
+
*/
|
|
239
|
+
function warnIfTokenCommandConfigIgnored(otel, env, logger) {
|
|
240
|
+
const explicit = otel.tokenCommand !== undefined || otel.tokenHeader !== undefined || otel.tokenPrefix !== undefined || Boolean(env.OPENCODE_OTEL_TOKEN_COMMAND) || Boolean(env.OPENCODE_OTEL_TOKEN_HEADER) || Boolean(env.OPENCODE_OTEL_TOKEN_PREFIX);
|
|
241
|
+
if (explicit) {
|
|
242
|
+
logger.debug("lightbridge_otel_token_command_ignored", { reason: "otel.tokenCommand/tokenHeader/tokenPrefix are ignored — the shared lightbridge auth token always supersedes them" });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
117
246
|
* Build the OTEL module: resource, providers (with the runtime-backed
|
|
118
247
|
* `TokenSource` when a shared runtime is available), recorder and exit
|
|
119
248
|
* handlers. Mirrors `@vymalo/opencode-otel`'s `createOtelPlugin` orchestration
|
|
@@ -235,9 +364,15 @@ export function createLightbridgePlugin(factoryOptions = {}) {
|
|
|
235
364
|
fetchImpl: factoryOptions.fetchImpl,
|
|
236
365
|
onAuthorizationUrl: factoryOptions.onAuthorizationUrl,
|
|
237
366
|
cacheDir: factoryOptions.cacheDir,
|
|
238
|
-
tokenExpirySkewMs: factoryOptions.tokenExpirySkewMs
|
|
367
|
+
tokenExpirySkewMs: factoryOptions.tokenExpirySkewMs,
|
|
368
|
+
exchange: parsed.gateway?.exchange ?? false
|
|
239
369
|
}) : undefined;
|
|
240
370
|
const hooks = {};
|
|
371
|
+
// ---- register module (ADR-0017 requirement 1) ----
|
|
372
|
+
const registerState = {
|
|
373
|
+
started: false,
|
|
374
|
+
skippedForConflict: false
|
|
375
|
+
};
|
|
241
376
|
// ---- gateway module ----
|
|
242
377
|
// `sharedRuntime` is always defined here: it is built whenever `gateway`
|
|
243
378
|
// or `otel` is configured (`wantsProjectToken`).
|
|
@@ -249,6 +384,7 @@ export function createLightbridgePlugin(factoryOptions = {}) {
|
|
|
249
384
|
if (parsed.otel) {
|
|
250
385
|
const otelConfig = resolveOtelConfig(parsed.otel, factoryOptions.env ?? process.env);
|
|
251
386
|
if (otelConfig.active) {
|
|
387
|
+
warnIfTokenCommandConfigIgnored(parsed.otel, factoryOptions.env ?? process.env, logger);
|
|
252
388
|
otel = await buildOtelModule(input, otelConfig, logger, factoryOptions, sharedRuntime);
|
|
253
389
|
registerOtelHooks(hooks, otel);
|
|
254
390
|
} else {
|
|
@@ -260,6 +396,9 @@ export function createLightbridgePlugin(factoryOptions = {}) {
|
|
|
260
396
|
}
|
|
261
397
|
hooks.config = async (hostConfig) => {
|
|
262
398
|
currentLogLevel = fromOpenCodeLogLevel(hostConfig.logLevel) ?? DEFAULT_LOG_LEVEL;
|
|
399
|
+
if (parsed.register) {
|
|
400
|
+
await applyRegisterModule(hostConfig, parsed.register, parsed.auth, registerState, logger, factoryOptions);
|
|
401
|
+
}
|
|
263
402
|
if (otel?.config.propagateTraceContext) {
|
|
264
403
|
const activeOtel = otel;
|
|
265
404
|
const wrapped = installTracePropagation(hostConfig, {
|
|
@@ -271,7 +410,9 @@ export function createLightbridgePlugin(factoryOptions = {}) {
|
|
|
271
410
|
};
|
|
272
411
|
logger.info("lightbridge_plugin_ready", {
|
|
273
412
|
gateway: Boolean(hooks["chat.headers"]),
|
|
413
|
+
exchange: parsed.gateway?.exchange ?? false,
|
|
274
414
|
otel: Boolean(otel),
|
|
415
|
+
register: Boolean(parsed.register),
|
|
275
416
|
projectId: parsed.projectId ?? (wantsProjectToken ? "(default)" : undefined)
|
|
276
417
|
});
|
|
277
418
|
return hooks;
|
package/dist/opencode.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"mappings":"AAAA,SAAS,gBAAgB;
|
|
1
|
+
{"mappings":"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","names":[],"sources":["../src/opencode.ts"],"version":3,"file":"opencode.js","sourceRoot":""}
|
package/dist/plugin.d.ts
CHANGED
|
@@ -1,12 +1,28 @@
|
|
|
1
1
|
import { type AuthServerConfig, type Logger, type TokenSet } from "@vymalo/opencode-auth-core/lib";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
3
|
+
* Legacy identity for the exchanged project-token store (UNCHANGED by
|
|
4
|
+
* ADR-0017 — see `migration.ts`'s module doc for why only the ROOT token
|
|
5
|
+
* relocated). Kept as a constant, not `auth.id`, because the exchanged-token
|
|
6
|
+
* cache key has always been derived from this literal, independent of the
|
|
7
|
+
* configured IdP's `id`.
|
|
5
8
|
*/
|
|
6
9
|
export declare const LIGHTBRIDGE_IDENTITY = "lightbridge";
|
|
7
|
-
/** Own cache namespace — separate from oauth2/repo-auth/otel's
|
|
10
|
+
/** Own cache namespace for the exchanged-token store — separate from oauth2/repo-auth/otel's. */
|
|
8
11
|
export declare const DEFAULT_CACHE_NAMESPACE = "opencode-lightbridge";
|
|
9
12
|
/**
|
|
13
|
+
* ADR-0017: the human ROOT token now lives in the SAME cache file
|
|
14
|
+
* `@vymalo/opencode-oauth2` uses for a server of this id —
|
|
15
|
+
* `<cacheRoot>/opencode-oauth2/opencode-oauth2-model-sync/<id>.json`, a fused
|
|
16
|
+
* `CachedServerState` (token + discovered models). This is what lets a
|
|
17
|
+
* developer who configures the same IdP (`auth.id` == an oauth2 `servers[].id`,
|
|
18
|
+
* same issuer/clientId) log in ONCE and have both plugins read/write the same
|
|
19
|
+
* on-disk state. The literals are pinned here (not imported from
|
|
20
|
+
* `@vymalo/opencode-oauth2`, which lightbridge does not depend on) — see
|
|
21
|
+
* `docs/adr/0017-lightbridge-all-in-one.md` for the shared-contract rationale.
|
|
22
|
+
*/
|
|
23
|
+
export declare const ROOT_CACHE_SEGMENT = "opencode-oauth2";
|
|
24
|
+
export declare const ROOT_CACHE_NAMESPACE = "opencode-oauth2-model-sync";
|
|
25
|
+
/**
|
|
10
26
|
* Cache key for the default-project token — used when no `projectId` is
|
|
11
27
|
* configured, so the exchange sends no `project_id` and the backend mints a
|
|
12
28
|
* token for the caller's default project (ADR-0012). Distinct from any real
|
|
@@ -17,9 +33,16 @@ export interface LightbridgeRuntimeOptions {
|
|
|
17
33
|
logger?: Logger;
|
|
18
34
|
fetchImpl?: typeof fetch;
|
|
19
35
|
onAuthorizationUrl?: (url: string) => Promise<void> | void;
|
|
20
|
-
/** Override the cache
|
|
36
|
+
/** Override the cache ROOT (defaults to the OS cache dir convention) — both
|
|
37
|
+
* the shared root-token store and the exchange-only store are joined from
|
|
38
|
+
* this. Test seam; production callers omit it. */
|
|
21
39
|
cacheDir?: string;
|
|
22
40
|
tokenExpirySkewMs?: number;
|
|
41
|
+
/**
|
|
42
|
+
* Whether to perform the RFC 8693 exchange (ADR-0012) rather than use the
|
|
43
|
+
* IdP access token directly as the bearer. Defaults to `false` (ADR-0017).
|
|
44
|
+
*/
|
|
45
|
+
exchange?: boolean;
|
|
23
46
|
}
|
|
24
47
|
/**
|
|
25
48
|
* The minimal surface `opencode.ts` needs from the shared runtime — narrow on
|
|
@@ -38,50 +61,81 @@ export interface LightbridgeRuntimeLike {
|
|
|
38
61
|
*/
|
|
39
62
|
export type LightbridgeRuntimeFactory = (auth: AuthServerConfig, projectId: string | undefined, options: LightbridgeRuntimeOptions) => LightbridgeRuntimeLike;
|
|
40
63
|
/**
|
|
41
|
-
* The ONE shared
|
|
42
|
-
* single human login, reused as the subject token for a
|
|
43
|
-
* 8693 exchange
|
|
44
|
-
*
|
|
45
|
-
* the
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
64
|
+
* The ONE shared runtime the umbrella plugin builds (ADR-0012, amended by
|
|
65
|
+
* ADR-0017): a single human login, reused as the subject token for a
|
|
66
|
+
* project-scoped RFC 8693 exchange (when `gateway.exchange` is `true`) or
|
|
67
|
+
* used directly as the gateway/OTEL bearer (the new default) — consumed by
|
|
68
|
+
* BOTH the gateway (`chat.headers`) and OTEL (`TokenSource.headers()`).
|
|
69
|
+
*
|
|
70
|
+
* The human root token itself is no longer lightbridge-exclusive storage: it
|
|
71
|
+
* lives in the SAME cache file `@vymalo/opencode-oauth2` writes for a server
|
|
72
|
+
* of this id (`ROOT_CACHE_SEGMENT`/`ROOT_CACHE_NAMESPACE`), so configuring
|
|
73
|
+
* both plugins against the same IdP (`auth.id` matching an oauth2
|
|
74
|
+
* `servers[].id`, same issuer/clientId) means logging in once. An upgrading
|
|
75
|
+
* install's pre-existing root token is migrated in automatically — see
|
|
76
|
+
* `migration.ts`.
|
|
49
77
|
*/
|
|
50
78
|
export declare class LightbridgeRuntime implements LightbridgeRuntimeLike {
|
|
51
79
|
private readonly projectId;
|
|
52
|
-
private readonly
|
|
80
|
+
private readonly rootRuntime;
|
|
81
|
+
private readonly sharedStore;
|
|
82
|
+
private readonly exchangeRuntime;
|
|
53
83
|
private readonly logger;
|
|
54
84
|
private readonly tokenExpirySkewMs;
|
|
85
|
+
private readonly exchange;
|
|
86
|
+
private readonly cacheRoot;
|
|
87
|
+
private readonly serverId;
|
|
55
88
|
/** Token-cache key: the project id, or `DEFAULT_PROJECT_KEY` when unset. */
|
|
56
89
|
private readonly projectKey;
|
|
57
90
|
private inFlightExchange?;
|
|
91
|
+
private migration?;
|
|
58
92
|
constructor(auth: AuthServerConfig, projectId: string | undefined, options?: LightbridgeRuntimeOptions);
|
|
93
|
+
/**
|
|
94
|
+
* One-time (per instance) migration of a pre-ADR-0017 root token into the
|
|
95
|
+
* shared cache file — see `migration.ts`. Idempotent and cheap on every
|
|
96
|
+
* call after the first (the underlying function also short-circuits once
|
|
97
|
+
* the new location has ANY state).
|
|
98
|
+
*/
|
|
99
|
+
private ensureMigrated;
|
|
59
100
|
/** Non-network read of the cached project token, if any. */
|
|
60
101
|
getCachedProjectToken(): Promise<TokenSet | undefined>;
|
|
61
102
|
/**
|
|
62
|
-
* Resolve a usable
|
|
63
|
-
* `chat.headers`, OTEL `TokenSource.headers()`)
|
|
103
|
+
* Resolve a usable gateway/OTEL bearer, shared by every caller (gateway
|
|
104
|
+
* `chat.headers`, OTEL `TokenSource.headers()`).
|
|
105
|
+
*
|
|
106
|
+
* `gateway.exchange: false` (default, ADR-0017): the IdP access token IS
|
|
107
|
+
* the bearer — just `ensure()` the shared root token (refresh-only unless
|
|
108
|
+
* `interactive`).
|
|
109
|
+
*
|
|
110
|
+
* `gateway.exchange: true` (ADR-0012, unchanged):
|
|
64
111
|
*
|
|
65
|
-
* cached usable? ──yes──▶ return it
|
|
112
|
+
* cached exchanged token usable? ──yes──▶ return it
|
|
66
113
|
* │ no
|
|
67
114
|
* ▼
|
|
68
|
-
* ensure human root (refresh-only unless `interactive`)
|
|
115
|
+
* ensure shared human root (refresh-only unless `interactive`)
|
|
69
116
|
* ▼
|
|
70
117
|
* exchangeTo(projectKey, humanToken, projectId ? { project_id } : {})
|
|
71
118
|
* ▼ (no projectId → backend picks the default project)
|
|
72
119
|
* cache + return
|
|
73
120
|
*
|
|
74
|
-
* Concurrent callers
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* which is expected to fail closed (no header / empty `headers()`).
|
|
121
|
+
* Concurrent callers share the in-flight exchange in either mode. Never
|
|
122
|
+
* invents a token: a failure propagates to the caller, which is expected to
|
|
123
|
+
* fail closed (no header / empty `headers()`).
|
|
78
124
|
*/
|
|
79
125
|
getProjectToken(options?: {
|
|
80
126
|
interactive?: boolean;
|
|
81
127
|
}): Promise<TokenSet>;
|
|
82
128
|
private performExchange;
|
|
83
|
-
/**
|
|
129
|
+
/**
|
|
130
|
+
* Drop the cached tokens for this identity: the exchanged project token
|
|
131
|
+
* entirely (its own exclusive store), and the shared root token's `token`
|
|
132
|
+
* field only — NOT the whole shared cache file, which may also hold
|
|
133
|
+
* `@vymalo/opencode-oauth2`'s discovered models for this server id and must
|
|
134
|
+
* survive a lightbridge-side logout.
|
|
135
|
+
*/
|
|
84
136
|
reset(): Promise<void>;
|
|
85
137
|
}
|
|
86
|
-
/** Absolute path to the lightbridge cache directory
|
|
138
|
+
/** Absolute path to the lightbridge-exclusive (exchanged-token) cache directory. */
|
|
87
139
|
export declare function lightbridgeCacheDir(cacheRoot: string): string;
|
|
140
|
+
/** Absolute path to the SHARED root-token cache directory (ADR-0017). */
|
|
141
|
+
export declare function rootCacheDir(cacheRoot: string): string;
|
package/dist/plugin.js
CHANGED
|
@@ -1,13 +1,31 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import { createJsonConsoleLogger, DEFAULT_TOKEN_EXPIRY_SKEW_MS, resolveCacheRoot, TokenRuntime } from "@vymalo/opencode-auth-core/lib";
|
|
3
|
+
import { FileCacheStore as SharedFileCacheStore } from "@vymalo/opencode-provider-sync/lib";
|
|
4
|
+
import { migrateRootTokenIfNeeded } from "./migration.js";
|
|
3
5
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
+
* Legacy identity for the exchanged project-token store (UNCHANGED by
|
|
7
|
+
* ADR-0017 — see `migration.ts`'s module doc for why only the ROOT token
|
|
8
|
+
* relocated). Kept as a constant, not `auth.id`, because the exchanged-token
|
|
9
|
+
* cache key has always been derived from this literal, independent of the
|
|
10
|
+
* configured IdP's `id`.
|
|
6
11
|
*/
|
|
7
12
|
export const LIGHTBRIDGE_IDENTITY = "lightbridge";
|
|
8
|
-
/** Own cache namespace — separate from oauth2/repo-auth/otel's
|
|
13
|
+
/** Own cache namespace for the exchanged-token store — separate from oauth2/repo-auth/otel's. */
|
|
9
14
|
export const DEFAULT_CACHE_NAMESPACE = "opencode-lightbridge";
|
|
10
15
|
/**
|
|
16
|
+
* ADR-0017: the human ROOT token now lives in the SAME cache file
|
|
17
|
+
* `@vymalo/opencode-oauth2` uses for a server of this id —
|
|
18
|
+
* `<cacheRoot>/opencode-oauth2/opencode-oauth2-model-sync/<id>.json`, a fused
|
|
19
|
+
* `CachedServerState` (token + discovered models). This is what lets a
|
|
20
|
+
* developer who configures the same IdP (`auth.id` == an oauth2 `servers[].id`,
|
|
21
|
+
* same issuer/clientId) log in ONCE and have both plugins read/write the same
|
|
22
|
+
* on-disk state. The literals are pinned here (not imported from
|
|
23
|
+
* `@vymalo/opencode-oauth2`, which lightbridge does not depend on) — see
|
|
24
|
+
* `docs/adr/0017-lightbridge-all-in-one.md` for the shared-contract rationale.
|
|
25
|
+
*/
|
|
26
|
+
export const ROOT_CACHE_SEGMENT = "opencode-oauth2";
|
|
27
|
+
export const ROOT_CACHE_NAMESPACE = "opencode-oauth2-model-sync";
|
|
28
|
+
/**
|
|
11
29
|
* Cache key for the default-project token — used when no `projectId` is
|
|
12
30
|
* configured, so the exchange sends no `project_id` and the backend mints a
|
|
13
31
|
* token for the caller's default project (ADR-0012). Distinct from any real
|
|
@@ -15,10 +33,9 @@ export const DEFAULT_CACHE_NAMESPACE = "opencode-lightbridge";
|
|
|
15
33
|
*/
|
|
16
34
|
export const DEFAULT_PROJECT_KEY = "__default__";
|
|
17
35
|
/**
|
|
18
|
-
* Whether a cached project token is usable.
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* non-expiring.
|
|
36
|
+
* Whether a cached project token is usable. The project token carries no
|
|
37
|
+
* refresh token, so an undefined `expiresAt` must be treated as expired
|
|
38
|
+
* (re-exchange), never as non-expiring.
|
|
22
39
|
*/
|
|
23
40
|
function isProjectTokenUsable(token, skewMs) {
|
|
24
41
|
if (!token?.accessToken) {
|
|
@@ -30,59 +47,148 @@ function isProjectTokenUsable(token, skewMs) {
|
|
|
30
47
|
return Date.now() + skewMs < token.expiresAt;
|
|
31
48
|
}
|
|
32
49
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
50
|
+
* Build the ONE shared root `TokenRuntime` for `serverId`, persisting through
|
|
51
|
+
* the SAME fused `CachedServerState` shape+location `ProviderModelSyncEngine`
|
|
52
|
+
* uses (see `ROOT_CACHE_SEGMENT`/`ROOT_CACHE_NAMESPACE` above) — mirroring the
|
|
53
|
+
* override pattern in `@vymalo/opencode-provider-sync`'s
|
|
54
|
+
* `ProviderModelSyncEngine.buildTokenRuntime` (extract `.token`, preserve
|
|
55
|
+
* `.models`/`.rawModels` on write) but WITHOUT running that engine's
|
|
56
|
+
* scheduler/model-discovery — this is a read/refresh-only participant in the
|
|
57
|
+
* shared cache file, not a second poller (see ADR-0017's double-scheduling
|
|
58
|
+
* guard, enforced inside the engine itself for the `register` path).
|
|
59
|
+
*
|
|
60
|
+
* Unlike `ProviderModelSyncEngine`, this performs no cross-process
|
|
61
|
+
* "adopt-if-newer" reconciliation — every `getCached()` call here reads disk
|
|
62
|
+
* fresh and takes it as-is. That is deliberately simpler (no in-memory
|
|
63
|
+
* snapshot to reconcile against) and still safe: a momentarily unreadable/
|
|
64
|
+
* missing file degrades to "no cached token" (a safe, if suboptimal,
|
|
65
|
+
* fallback), and refreshes are still coordinated via `TokenRuntime`'s own
|
|
66
|
+
* cross-process file lock.
|
|
67
|
+
*/
|
|
68
|
+
function buildSharedRootTokenRuntime(serverId, auth, sharedStore, options) {
|
|
69
|
+
return new TokenRuntime(serverId, auth, {
|
|
70
|
+
logger: options.logger,
|
|
71
|
+
fetchImpl: options.fetchImpl,
|
|
72
|
+
onAuthorizationUrl: options.onAuthorizationUrl,
|
|
73
|
+
tokenExpirySkewMs: options.tokenExpirySkewMs,
|
|
74
|
+
serviceLabel: "opencode-lightbridge",
|
|
75
|
+
getCached: async () => {
|
|
76
|
+
const state = await sharedStore.loadServerState(serverId);
|
|
77
|
+
return state?.token;
|
|
78
|
+
},
|
|
79
|
+
setCached: async (token) => {
|
|
80
|
+
const existing = await sharedStore.loadServerState(serverId);
|
|
81
|
+
const next = {
|
|
82
|
+
serverId,
|
|
83
|
+
updatedAt: Date.now(),
|
|
84
|
+
lastSyncAt: existing?.lastSyncAt,
|
|
85
|
+
models: existing?.models ?? [],
|
|
86
|
+
rawModels: existing?.rawModels ?? [],
|
|
87
|
+
token
|
|
88
|
+
};
|
|
89
|
+
await sharedStore.saveServerState(next);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The ONE shared runtime the umbrella plugin builds (ADR-0012, amended by
|
|
95
|
+
* ADR-0017): a single human login, reused as the subject token for a
|
|
96
|
+
* project-scoped RFC 8693 exchange (when `gateway.exchange` is `true`) or
|
|
97
|
+
* used directly as the gateway/OTEL bearer (the new default) — consumed by
|
|
98
|
+
* BOTH the gateway (`chat.headers`) and OTEL (`TokenSource.headers()`).
|
|
99
|
+
*
|
|
100
|
+
* The human root token itself is no longer lightbridge-exclusive storage: it
|
|
101
|
+
* lives in the SAME cache file `@vymalo/opencode-oauth2` writes for a server
|
|
102
|
+
* of this id (`ROOT_CACHE_SEGMENT`/`ROOT_CACHE_NAMESPACE`), so configuring
|
|
103
|
+
* both plugins against the same IdP (`auth.id` matching an oauth2
|
|
104
|
+
* `servers[].id`, same issuer/clientId) means logging in once. An upgrading
|
|
105
|
+
* install's pre-existing root token is migrated in automatically — see
|
|
106
|
+
* `migration.ts`.
|
|
41
107
|
*/
|
|
42
108
|
export class LightbridgeRuntime {
|
|
43
109
|
projectId;
|
|
44
|
-
|
|
110
|
+
rootRuntime;
|
|
111
|
+
sharedStore;
|
|
112
|
+
exchangeRuntime;
|
|
45
113
|
logger;
|
|
46
114
|
tokenExpirySkewMs;
|
|
115
|
+
exchange;
|
|
116
|
+
cacheRoot;
|
|
117
|
+
serverId;
|
|
47
118
|
/** Token-cache key: the project id, or `DEFAULT_PROJECT_KEY` when unset. */
|
|
48
119
|
projectKey;
|
|
49
120
|
inFlightExchange;
|
|
121
|
+
migration;
|
|
50
122
|
constructor(auth, projectId, options = {}) {
|
|
51
123
|
this.projectId = projectId;
|
|
124
|
+
this.serverId = auth.id;
|
|
52
125
|
this.projectKey = projectId ?? DEFAULT_PROJECT_KEY;
|
|
53
126
|
this.logger = options.logger ?? createJsonConsoleLogger("info");
|
|
127
|
+
this.exchange = options.exchange ?? false;
|
|
128
|
+
this.cacheRoot = options.cacheDir ?? resolveCacheRoot();
|
|
54
129
|
this.tokenExpirySkewMs = typeof options.tokenExpirySkewMs === "number" && Number.isFinite(options.tokenExpirySkewMs) && options.tokenExpirySkewMs > 0 ? options.tokenExpirySkewMs : DEFAULT_TOKEN_EXPIRY_SKEW_MS;
|
|
55
|
-
this.
|
|
130
|
+
this.sharedStore = new SharedFileCacheStore(join(this.cacheRoot, ROOT_CACHE_SEGMENT, ROOT_CACHE_NAMESPACE), this.logger);
|
|
131
|
+
this.rootRuntime = buildSharedRootTokenRuntime(this.serverId, auth, this.sharedStore, options);
|
|
132
|
+
// The exchange-only runtime keeps its OWN dedicated, lightbridge-exclusive
|
|
133
|
+
// store (unchanged location/shape) — there is no oauth2 equivalent for a
|
|
134
|
+
// project-scoped exchanged token, so nothing to share here.
|
|
135
|
+
this.exchangeRuntime = new TokenRuntime(LIGHTBRIDGE_IDENTITY, auth, {
|
|
56
136
|
logger: this.logger,
|
|
57
137
|
fetchImpl: options.fetchImpl,
|
|
58
138
|
onAuthorizationUrl: options.onAuthorizationUrl,
|
|
59
|
-
cacheDir:
|
|
60
|
-
tokenExpirySkewMs: this.tokenExpirySkewMs
|
|
139
|
+
cacheDir: join(this.cacheRoot, DEFAULT_CACHE_NAMESPACE),
|
|
140
|
+
tokenExpirySkewMs: this.tokenExpirySkewMs,
|
|
141
|
+
serviceLabel: "opencode-lightbridge"
|
|
61
142
|
});
|
|
62
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* One-time (per instance) migration of a pre-ADR-0017 root token into the
|
|
146
|
+
* shared cache file — see `migration.ts`. Idempotent and cheap on every
|
|
147
|
+
* call after the first (the underlying function also short-circuits once
|
|
148
|
+
* the new location has ANY state).
|
|
149
|
+
*/
|
|
150
|
+
ensureMigrated() {
|
|
151
|
+
if (!this.migration) {
|
|
152
|
+
this.migration = migrateRootTokenIfNeeded(this.cacheRoot, ROOT_CACHE_SEGMENT, ROOT_CACHE_NAMESPACE, this.serverId, this.logger).catch((error) => {
|
|
153
|
+
// Never fatal: worst case is a fresh login, same as a first-ever
|
|
154
|
+
// install. Swallow so a migration hiccup can't break normal auth.
|
|
155
|
+
this.logger.debug("lightbridge_root_migration_error", { error: error instanceof Error ? error.message : String(error) });
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
return this.migration;
|
|
159
|
+
}
|
|
63
160
|
/** Non-network read of the cached project token, if any. */
|
|
64
161
|
async getCachedProjectToken() {
|
|
65
|
-
return this.
|
|
162
|
+
return this.exchangeRuntime.getExchangedByKey(this.projectKey);
|
|
66
163
|
}
|
|
67
164
|
/**
|
|
68
|
-
* Resolve a usable
|
|
69
|
-
* `chat.headers`, OTEL `TokenSource.headers()`)
|
|
165
|
+
* Resolve a usable gateway/OTEL bearer, shared by every caller (gateway
|
|
166
|
+
* `chat.headers`, OTEL `TokenSource.headers()`).
|
|
167
|
+
*
|
|
168
|
+
* `gateway.exchange: false` (default, ADR-0017): the IdP access token IS
|
|
169
|
+
* the bearer — just `ensure()` the shared root token (refresh-only unless
|
|
170
|
+
* `interactive`).
|
|
171
|
+
*
|
|
172
|
+
* `gateway.exchange: true` (ADR-0012, unchanged):
|
|
70
173
|
*
|
|
71
|
-
* cached usable? ──yes──▶ return it
|
|
174
|
+
* cached exchanged token usable? ──yes──▶ return it
|
|
72
175
|
* │ no
|
|
73
176
|
* ▼
|
|
74
|
-
* ensure human root (refresh-only unless `interactive`)
|
|
177
|
+
* ensure shared human root (refresh-only unless `interactive`)
|
|
75
178
|
* ▼
|
|
76
179
|
* exchangeTo(projectKey, humanToken, projectId ? { project_id } : {})
|
|
77
180
|
* ▼ (no projectId → backend picks the default project)
|
|
78
181
|
* cache + return
|
|
79
182
|
*
|
|
80
|
-
* Concurrent callers
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
* which is expected to fail closed (no header / empty `headers()`).
|
|
183
|
+
* Concurrent callers share the in-flight exchange in either mode. Never
|
|
184
|
+
* invents a token: a failure propagates to the caller, which is expected to
|
|
185
|
+
* fail closed (no header / empty `headers()`).
|
|
84
186
|
*/
|
|
85
187
|
async getProjectToken(options = {}) {
|
|
188
|
+
await this.ensureMigrated();
|
|
189
|
+
if (!this.exchange) {
|
|
190
|
+
return this.rootRuntime.ensure({ interactive: options.interactive });
|
|
191
|
+
}
|
|
86
192
|
const cached = await this.getCachedProjectToken();
|
|
87
193
|
if (isProjectTokenUsable(cached, this.tokenExpirySkewMs)) {
|
|
88
194
|
this.logger.trace("lightbridge_exchange_cache_hit", { projectId: this.projectId });
|
|
@@ -103,12 +209,12 @@ export class LightbridgeRuntime {
|
|
|
103
209
|
}
|
|
104
210
|
}
|
|
105
211
|
async performExchange(options) {
|
|
106
|
-
const human = await this.
|
|
212
|
+
const human = await this.rootRuntime.ensure({ interactive: options.interactive });
|
|
107
213
|
this.logger.info("lightbridge_exchange_started", { projectId: this.projectId ?? "(default)" });
|
|
108
214
|
try {
|
|
109
215
|
// No `projectId` → send no `project_id` param; the backend mints a token
|
|
110
216
|
// for the caller's default project (ADR-0012).
|
|
111
|
-
const exchanged = await this.
|
|
217
|
+
const exchanged = await this.exchangeRuntime.exchangeTo(this.projectKey, human.accessToken, this.projectId ? { project_id: this.projectId } : {});
|
|
112
218
|
this.logger.info("lightbridge_exchange_success", { projectId: this.projectId ?? "(default)" });
|
|
113
219
|
return exchanged;
|
|
114
220
|
} catch (error) {
|
|
@@ -119,14 +225,32 @@ export class LightbridgeRuntime {
|
|
|
119
225
|
throw error;
|
|
120
226
|
}
|
|
121
227
|
}
|
|
122
|
-
/**
|
|
228
|
+
/**
|
|
229
|
+
* Drop the cached tokens for this identity: the exchanged project token
|
|
230
|
+
* entirely (its own exclusive store), and the shared root token's `token`
|
|
231
|
+
* field only — NOT the whole shared cache file, which may also hold
|
|
232
|
+
* `@vymalo/opencode-oauth2`'s discovered models for this server id and must
|
|
233
|
+
* survive a lightbridge-side logout.
|
|
234
|
+
*/
|
|
123
235
|
async reset() {
|
|
124
|
-
await this.
|
|
236
|
+
await this.exchangeRuntime.reset();
|
|
237
|
+
const existing = await this.sharedStore.loadServerState(this.serverId);
|
|
238
|
+
if (existing?.token) {
|
|
239
|
+
await this.sharedStore.saveServerState({
|
|
240
|
+
...existing,
|
|
241
|
+
updatedAt: Date.now(),
|
|
242
|
+
token: undefined
|
|
243
|
+
});
|
|
244
|
+
}
|
|
125
245
|
}
|
|
126
246
|
}
|
|
127
|
-
/** Absolute path to the lightbridge cache directory
|
|
247
|
+
/** Absolute path to the lightbridge-exclusive (exchanged-token) cache directory. */
|
|
128
248
|
export function lightbridgeCacheDir(cacheRoot) {
|
|
129
249
|
return join(cacheRoot, DEFAULT_CACHE_NAMESPACE);
|
|
130
250
|
}
|
|
251
|
+
/** Absolute path to the SHARED root-token cache directory (ADR-0017). */
|
|
252
|
+
export function rootCacheDir(cacheRoot) {
|
|
253
|
+
return join(cacheRoot, ROOT_CACHE_SEGMENT, ROOT_CACHE_NAMESPACE);
|
|
254
|
+
}
|
|
131
255
|
|
|
132
256
|
//# sourceMappingURL=plugin.js.map
|
package/dist/plugin.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"mappings":"AAAA,SAAS,YAAY;AAErB,SACE,yBACA,8BACA,kBACA,oBAIK
|
|
1
|
+
{"mappings":"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","names":[],"sources":["../src/plugin.ts"],"version":3,"file":"plugin.js","sourceRoot":""}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vymalo/opencode-lightbridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Umbrella OpenCode plugin for ADR-0012: one shared credential (TokenRuntime) drives both the LLM gateway bearer and OTEL export, so a developer authenticates once and every egress rides the same project-scoped token.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "vymalo contributors",
|
|
@@ -53,8 +53,9 @@
|
|
|
53
53
|
"@opencode-ai/plugin": "1.15.10",
|
|
54
54
|
"@opentelemetry/api": "^1.9.1",
|
|
55
55
|
"@opentelemetry/api-logs": "^0.221.0",
|
|
56
|
-
"@vymalo/opencode-core
|
|
57
|
-
"@vymalo/opencode-
|
|
56
|
+
"@vymalo/opencode-auth-core": "0.17.0",
|
|
57
|
+
"@vymalo/opencode-core-otel": "0.17.0",
|
|
58
|
+
"@vymalo/opencode-provider-sync": "0.17.0"
|
|
58
59
|
},
|
|
59
60
|
"devDependencies": {
|
|
60
61
|
"@opentelemetry/sdk-logs": "^0.221.0",
|