dsh-keys-manager 0.2.3 → 0.2.4
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/CHANGELOG.md +8 -0
- package/lib/client/format.d.ts +11 -1
- package/lib/client/styles.d.ts +1 -1
- package/lib/client/types.d.ts +14 -2
- package/lib/client.js +194 -22
- package/lib/core.d.ts +7 -8
- package/lib/core.js +7 -0
- package/lib/core.js.map +1 -1
- package/lib/domain.d.ts +15 -0
- package/lib/index.d.ts +63 -2
- package/lib/index.js +312 -60
- package/lib/index.js.map +1 -1
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +2 -2
package/lib/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AccountAttemptView, KeyTestResult, StateView } from './domain.js';
|
|
1
|
+
import type { AccountAttemptView, KeyTestResult, StateView, TransportModelsView } from './domain.js';
|
|
2
2
|
import type { CredentialsLike, GenerateOptionsLike, HostContext, KeySelection, RequestErrorAction, AgentRequestPayload, RequestErrorPayload, SettingsLike, StreamChunkLike } from './host-types.js';
|
|
3
3
|
import { KeysManagerStore } from './store.js';
|
|
4
4
|
export declare const name = "keys-manager";
|
|
@@ -718,6 +718,35 @@ export declare class KeysManagerService {
|
|
|
718
718
|
removeTransportAccount(route: string): Promise<StateView>;
|
|
719
719
|
/** The route's removal epoch; absent means never removed. */
|
|
720
720
|
private transportGenerationOf;
|
|
721
|
+
/**
|
|
722
|
+
* Remote: read one transport route's catalog and configured allow-list.
|
|
723
|
+
*
|
|
724
|
+
* @remarks
|
|
725
|
+
* The catalog comes from the transports package, because the route's
|
|
726
|
+
* `listModels` answers only the allow-list. An empty allow-list means "all
|
|
727
|
+
* models", so an unreadable settings store must not answer `[]` — a save
|
|
728
|
+
* would then clear a narrowed selection — and it rejects instead.
|
|
729
|
+
*/
|
|
730
|
+
transportModels(route: string): Promise<TransportModelsView>;
|
|
731
|
+
/**
|
|
732
|
+
* Remote: replace one transport route's model allow-list.
|
|
733
|
+
*
|
|
734
|
+
* Writes only the route's own `providers.<route>.models` path through the
|
|
735
|
+
* live settings revision; an empty list restores the full catalog.
|
|
736
|
+
*/
|
|
737
|
+
saveTransportModels(route: string, models: string[]): Promise<TransportModelsView>;
|
|
738
|
+
/** Refuse a route the transports package does not register. */
|
|
739
|
+
private assertTransportRoute;
|
|
740
|
+
/**
|
|
741
|
+
* One transport route's configured model ids, read from the live settings.
|
|
742
|
+
*
|
|
743
|
+
* @remarks
|
|
744
|
+
* Rejects when the descriptor is unreadable or this plugin's namespace is
|
|
745
|
+
* not mounted: a registered route always owns that namespace, so answering
|
|
746
|
+
* "no models configured" would report the all-models state for a store the
|
|
747
|
+
* host simply could not read.
|
|
748
|
+
*/
|
|
749
|
+
private transportModelIds;
|
|
721
750
|
/**
|
|
722
751
|
* Drop a grant a withdrawn sign-in wrote after its account was removed.
|
|
723
752
|
*
|
|
@@ -1013,9 +1042,30 @@ export declare class KeysManagerService {
|
|
|
1013
1042
|
* flush; see {@link compensateActivationFlush} for that compensation.
|
|
1014
1043
|
*/
|
|
1015
1044
|
activateAccount(provider: string, accountId: string): Promise<StateView>;
|
|
1045
|
+
/**
|
|
1046
|
+
* The row's own grant payload, recovered from the live projection when the
|
|
1047
|
+
* plugin-scope record is gone. The projection IS the route's credential, so a
|
|
1048
|
+
* matching payload is written back (compare-at-absent) and used immediately.
|
|
1049
|
+
*
|
|
1050
|
+
* `recoveredFromProjection` marks a payload the ownership-verified projection
|
|
1051
|
+
* supplied when the repair write did not persist it: the ref stays absent or
|
|
1052
|
+
* unusable, so a caller that re-reads it must treat the payload as the live
|
|
1053
|
+
* credential instead of a concurrent writer's record. That case also returns
|
|
1054
|
+
* the projection record it validated: a caller that compares before writing
|
|
1055
|
+
* must use this snapshot as its baseline, or the compare would accept a
|
|
1056
|
+
* rotation that landed after the payload was read. It never marks a
|
|
1057
|
+
* projection whose row has been detached or repointed since the snapshot —
|
|
1058
|
+
* that recovery is refused, because the projection then belongs to a
|
|
1059
|
+
* generation this row no longer names. For the same reason a repair the
|
|
1060
|
+
* updater declined (row gone, retiring, or repointed) resolves `undefined`:
|
|
1061
|
+
* the callers' own "missing or unusable" failure is the truthful answer, not
|
|
1062
|
+
* the payload of a generation this row has left behind.
|
|
1063
|
+
*/
|
|
1064
|
+
private recoverGrantPayload;
|
|
1016
1065
|
/**
|
|
1017
1066
|
* The slow half of an activation, outside the queue: read the row's grant
|
|
1018
|
-
* generation
|
|
1067
|
+
* generation (recovered from the live projection when its record is gone),
|
|
1068
|
+
* validate it, and capture the projection the route serves now.
|
|
1019
1069
|
*/
|
|
1020
1070
|
private prepareActivation;
|
|
1021
1071
|
/** Whether the projection still holds the value this activation read. */
|
|
@@ -1196,6 +1246,17 @@ export declare class KeysManagerService {
|
|
|
1196
1246
|
private testRouteProfile;
|
|
1197
1247
|
/** One request pinned to the row's API-key literal. */
|
|
1198
1248
|
private runApiKeyRowTest;
|
|
1249
|
+
/**
|
|
1250
|
+
* Whether this test may still borrow the route, or the result it must report
|
|
1251
|
+
* instead: the window is live, shutdown has not begun, and the row still
|
|
1252
|
+
* exists, is not retiring, and still names the `grantRef` the test captured.
|
|
1253
|
+
*
|
|
1254
|
+
* Read before the borrow and again after every await that precedes it. The
|
|
1255
|
+
* grant recovery is one: it outlives the isolation deadline on a slow seam,
|
|
1256
|
+
* and a window the watchdog already released no longer compensates its late
|
|
1257
|
+
* borrow (that branch is guarded by `abandonedWindows.get(provider) === window`).
|
|
1258
|
+
*/
|
|
1259
|
+
private borrowBarrier;
|
|
1199
1260
|
/** One request with the row's grant temporarily projected as the route's credential. */
|
|
1200
1261
|
private runOAuthRowTest;
|
|
1201
1262
|
/** The refusal an abandoned test reports instead of a stale success. */
|
package/lib/index.js
CHANGED
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
17
17
|
import { createHash, randomBytes } from 'node:crypto';
|
|
18
|
-
import {
|
|
18
|
+
import { CURSOR_ROUTE, cursorCatalogModels } from 'dsh-llm-omp-transports';
|
|
19
|
+
import { KEY_LIMIT, TEST_SUCCESS_FINISH_KINDS, accountRef, applyFailureCooldown, bindingKey, classifyOAuthFailure, isSelectable, markKeySuccess, markKeyUsed, normalizeSettings, patchSettings, pickHealthyExcluding, pickRoundRobin, shouldFailover, statusOfKey, statusOfProvider, validateKeyInput, validateKeyPatch, } from './core.js';
|
|
19
20
|
import { EXTRA_PROVIDER_MAP, EXTRA_PROVIDERS } from './providers.js';
|
|
20
21
|
import { KeysManagerStore, ensureStateFile, statePath } from './store.js';
|
|
21
22
|
export const name = 'keys-manager';
|
|
@@ -255,6 +256,11 @@ const INVOCATIONS = [
|
|
|
255
256
|
endpoint('beginTransportLogin', ['provider']),
|
|
256
257
|
endpoint('activateTransportAccount', ['provider']),
|
|
257
258
|
endpoint('removeTransportAccount', ['provider']),
|
|
259
|
+
// The transport card's model picker: the catalog lives in the transports
|
|
260
|
+
// package (listModels answers only the allow-list), and the selection is
|
|
261
|
+
// written to the route's own settings path.
|
|
262
|
+
endpoint('transportModels', ['provider']),
|
|
263
|
+
endpoint('saveTransportModels', ['provider', 'models']),
|
|
258
264
|
];
|
|
259
265
|
/** Read a nested value by path. */
|
|
260
266
|
function getPath(value, path) {
|
|
@@ -517,6 +523,15 @@ function transportAccountOf(flow, record, now) {
|
|
|
517
523
|
function transportMethodOf(flow) {
|
|
518
524
|
return flow.methods[0]?.id;
|
|
519
525
|
}
|
|
526
|
+
/**
|
|
527
|
+
* The catalog a transport route advertises to its model picker.
|
|
528
|
+
*
|
|
529
|
+
* Only the Cursor route ships a catalog projection today; a route without one
|
|
530
|
+
* reports no models rather than borrowing another route's catalog.
|
|
531
|
+
*/
|
|
532
|
+
function transportCatalogOf(route) {
|
|
533
|
+
return route === CURSOR_ROUTE ? cursorCatalogModels() : [];
|
|
534
|
+
}
|
|
520
535
|
/** Env names that are a single API-key credential, not an OAuth/federation token. */
|
|
521
536
|
function isKeyishEnv(name) {
|
|
522
537
|
if (!/(?:API_KEY|_TOKEN)$/.test(name))
|
|
@@ -1068,9 +1083,10 @@ export class KeysManagerService {
|
|
|
1068
1083
|
// serves the route ahead of any broker reference exactly like an
|
|
1069
1084
|
// unowned grant, so it is classified as foreign below (PR review
|
|
1070
1085
|
// rounds 4 and 13).
|
|
1071
|
-
const
|
|
1072
|
-
?
|
|
1086
|
+
const projection = record.kind === 'grant' && isOAuthGrantPayload(record.payload)
|
|
1087
|
+
? record.payload
|
|
1073
1088
|
: undefined;
|
|
1089
|
+
const owner = projection === undefined ? undefined : matchOwningAccount(provider, accounts, projection);
|
|
1074
1090
|
// A newer activation/login claimed the owner while this read was in
|
|
1075
1091
|
// flight: the stale read is discarded, never written over it. A
|
|
1076
1092
|
// remount replaced the whole store: its classification is gone too.
|
|
@@ -2105,6 +2121,85 @@ export class KeysManagerService {
|
|
|
2105
2121
|
transportGenerationOf(route) {
|
|
2106
2122
|
return this.transportRemovals.get(route) ?? 0;
|
|
2107
2123
|
}
|
|
2124
|
+
/**
|
|
2125
|
+
* Remote: read one transport route's catalog and configured allow-list.
|
|
2126
|
+
*
|
|
2127
|
+
* @remarks
|
|
2128
|
+
* The catalog comes from the transports package, because the route's
|
|
2129
|
+
* `listModels` answers only the allow-list. An empty allow-list means "all
|
|
2130
|
+
* models", so an unreadable settings store must not answer `[]` — a save
|
|
2131
|
+
* would then clear a narrowed selection — and it rejects instead.
|
|
2132
|
+
*/
|
|
2133
|
+
async transportModels(route) {
|
|
2134
|
+
this.assertTransportRoute(route);
|
|
2135
|
+
const configured = this.transportModelIds(route);
|
|
2136
|
+
const catalog = transportCatalogOf(route);
|
|
2137
|
+
// A settings profile can outlive a catalog id; the picker renders the
|
|
2138
|
+
// catalog, so an unknown id would count toward {n} of {total} while
|
|
2139
|
+
// matching no checkbox and failing every save.
|
|
2140
|
+
const known = new Set(catalog.map(row => row.id));
|
|
2141
|
+
return { catalog, models: configured.filter(id => known.has(id)) };
|
|
2142
|
+
}
|
|
2143
|
+
/**
|
|
2144
|
+
* Remote: replace one transport route's model allow-list.
|
|
2145
|
+
*
|
|
2146
|
+
* Writes only the route's own `providers.<route>.models` path through the
|
|
2147
|
+
* live settings revision; an empty list restores the full catalog.
|
|
2148
|
+
*/
|
|
2149
|
+
async saveTransportModels(route, models) {
|
|
2150
|
+
this.assertTransportRoute(route);
|
|
2151
|
+
if (!Array.isArray(models) || models.some(id => typeof id !== 'string')) {
|
|
2152
|
+
throw new TypeError('models must be a list of model ids');
|
|
2153
|
+
}
|
|
2154
|
+
const catalog = transportCatalogOf(route);
|
|
2155
|
+
if (catalog.length === 0)
|
|
2156
|
+
throw new Error(`Provider "${route}" offers no model list`);
|
|
2157
|
+
const known = new Map(catalog.map(row => [row.id, row.name]));
|
|
2158
|
+
const selected = [...new Set(models)];
|
|
2159
|
+
const unknown = selected.find(id => !known.has(id));
|
|
2160
|
+
if (unknown !== undefined)
|
|
2161
|
+
throw new Error(`Provider "${route}" has no model "${unknown}"`);
|
|
2162
|
+
await this.mutateSettings(TRANSPORT_CREDENTIAL_SCOPE, [
|
|
2163
|
+
{
|
|
2164
|
+
op: 'set',
|
|
2165
|
+
path: ['providers', route, 'models'],
|
|
2166
|
+
value: selected.map(id => ({ id, name: known.get(id) })),
|
|
2167
|
+
},
|
|
2168
|
+
]);
|
|
2169
|
+
return this.transportModels(route);
|
|
2170
|
+
}
|
|
2171
|
+
/** Refuse a route the transports package does not register. */
|
|
2172
|
+
assertTransportRoute(route) {
|
|
2173
|
+
if (typeof route !== 'string' || route.length === 0)
|
|
2174
|
+
throw new TypeError('provider is required');
|
|
2175
|
+
if (this.transportFlowFor(route) === undefined)
|
|
2176
|
+
throw new Error(`Provider "${route}" offers no transport account`);
|
|
2177
|
+
}
|
|
2178
|
+
/**
|
|
2179
|
+
* One transport route's configured model ids, read from the live settings.
|
|
2180
|
+
*
|
|
2181
|
+
* @remarks
|
|
2182
|
+
* Rejects when the descriptor is unreadable or this plugin's namespace is
|
|
2183
|
+
* not mounted: a registered route always owns that namespace, so answering
|
|
2184
|
+
* "no models configured" would report the all-models state for a store the
|
|
2185
|
+
* host simply could not read.
|
|
2186
|
+
*/
|
|
2187
|
+
transportModelIds(route) {
|
|
2188
|
+
const descriptor = this.describeSettings();
|
|
2189
|
+
if (!descriptor.readable)
|
|
2190
|
+
throw new Error('Cannot read settings: the settings store is unavailable');
|
|
2191
|
+
const namespace = this.namespace(descriptor, TRANSPORT_CREDENTIAL_SCOPE);
|
|
2192
|
+
if (namespace === undefined) {
|
|
2193
|
+
throw new Error(`Settings namespace "${TRANSPORT_CREDENTIAL_SCOPE}" is not mounted`);
|
|
2194
|
+
}
|
|
2195
|
+
const value = getPath(namespace.value, ['providers', route, 'models']);
|
|
2196
|
+
if (!Array.isArray(value))
|
|
2197
|
+
return [];
|
|
2198
|
+
return value.flatMap(row => {
|
|
2199
|
+
const id = row !== null && typeof row === 'object' ? row.id : undefined;
|
|
2200
|
+
return typeof id === 'string' ? [id] : [];
|
|
2201
|
+
});
|
|
2202
|
+
}
|
|
2108
2203
|
/**
|
|
2109
2204
|
* Drop a grant a withdrawn sign-in wrote after its account was removed.
|
|
2110
2205
|
*
|
|
@@ -2636,11 +2731,20 @@ export class KeysManagerService {
|
|
|
2636
2731
|
return;
|
|
2637
2732
|
}
|
|
2638
2733
|
if (harvest.wroteRef) {
|
|
2639
|
-
|
|
2640
|
-
|
|
2734
|
+
// A live row still names this ref: a same-ref re-authentication writes
|
|
2735
|
+
// over the record its own pre-existing row depends on, and this
|
|
2736
|
+
// compensation must not leave that row dangling (same guard as the
|
|
2737
|
+
// rotation cleanup, R5-MF1).
|
|
2738
|
+
if (this.grantRefServed(attempt.provider, harvest.grantRef)) {
|
|
2739
|
+
this.warn(`harvest compensation kept ${harvest.grantRef} for ${attempt.provider}: an account row still references it`);
|
|
2641
2740
|
}
|
|
2642
|
-
|
|
2643
|
-
|
|
2741
|
+
else {
|
|
2742
|
+
try {
|
|
2743
|
+
await records.deleteRecord(harvest.grantRef);
|
|
2744
|
+
}
|
|
2745
|
+
catch (error) {
|
|
2746
|
+
this.warn(`harvest compensation could not delete ${harvest.grantRef}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2747
|
+
}
|
|
2644
2748
|
}
|
|
2645
2749
|
}
|
|
2646
2750
|
await this.revertHarvestProjection(attempt, harvest);
|
|
@@ -3392,9 +3496,16 @@ export class KeysManagerService {
|
|
|
3392
3496
|
// changes. A same-ref rotation that lands while the activation waits in
|
|
3393
3497
|
// the queue is the uncovered case.
|
|
3394
3498
|
const source = await records.readRecord(prepared.grantRef);
|
|
3395
|
-
|
|
3499
|
+
const sourceMatches = source !== undefined
|
|
3500
|
+
&& source.kind === 'grant'
|
|
3501
|
+
&& JSON.stringify(source.payload) === prepared.written;
|
|
3502
|
+
// The preparation can carry a payload its own repair write did not
|
|
3503
|
+
// persist: the ownership-verified projection is then the live credential,
|
|
3504
|
+
// and an absent or unusable ref is that state — not a concurrent writer.
|
|
3505
|
+
const sourceAbsentOrUnusable = source === undefined
|
|
3396
3506
|
|| source.kind !== 'grant'
|
|
3397
|
-
||
|
|
3507
|
+
|| !isOAuthGrantPayload(source.payload);
|
|
3508
|
+
if (!sourceMatches && !(prepared.recoveredFromProjection && sourceAbsentOrUnusable)) {
|
|
3398
3509
|
throw new Error(`The grant payload for "${accountId}" on ${provider} could not be projected; activate the account again`);
|
|
3399
3510
|
}
|
|
3400
3511
|
}
|
|
@@ -3562,19 +3673,123 @@ export class KeysManagerService {
|
|
|
3562
3673
|
throw error;
|
|
3563
3674
|
}
|
|
3564
3675
|
}
|
|
3676
|
+
/**
|
|
3677
|
+
* The row's own grant payload, recovered from the live projection when the
|
|
3678
|
+
* plugin-scope record is gone. The projection IS the route's credential, so a
|
|
3679
|
+
* matching payload is written back (compare-at-absent) and used immediately.
|
|
3680
|
+
*
|
|
3681
|
+
* `recoveredFromProjection` marks a payload the ownership-verified projection
|
|
3682
|
+
* supplied when the repair write did not persist it: the ref stays absent or
|
|
3683
|
+
* unusable, so a caller that re-reads it must treat the payload as the live
|
|
3684
|
+
* credential instead of a concurrent writer's record. That case also returns
|
|
3685
|
+
* the projection record it validated: a caller that compares before writing
|
|
3686
|
+
* must use this snapshot as its baseline, or the compare would accept a
|
|
3687
|
+
* rotation that landed after the payload was read. It never marks a
|
|
3688
|
+
* projection whose row has been detached or repointed since the snapshot —
|
|
3689
|
+
* that recovery is refused, because the projection then belongs to a
|
|
3690
|
+
* generation this row no longer names. For the same reason a repair the
|
|
3691
|
+
* updater declined (row gone, retiring, or repointed) resolves `undefined`:
|
|
3692
|
+
* the callers' own "missing or unusable" failure is the truthful answer, not
|
|
3693
|
+
* the payload of a generation this row has left behind.
|
|
3694
|
+
*/
|
|
3695
|
+
async recoverGrantPayload(provider, row, records) {
|
|
3696
|
+
const grantRef = row.oauth.grantRef;
|
|
3697
|
+
const stored = await records.readRecord(grantRef);
|
|
3698
|
+
if (stored !== undefined && stored.kind === 'grant' && isOAuthGrantPayload(stored.payload)) {
|
|
3699
|
+
return { payload: stored.payload, recoveredFromProjection: false };
|
|
3700
|
+
}
|
|
3701
|
+
const projectionKey = `${OAUTH_GRANT_RECORD_SCOPE}/${provider}`;
|
|
3702
|
+
// The seam can hand back its own live record and this repair awaits before
|
|
3703
|
+
// it writes: snapshot it once so the ownership check, the write and the
|
|
3704
|
+
// returned baseline all see the payload that was verified, even if the
|
|
3705
|
+
// source is updated in place meanwhile.
|
|
3706
|
+
const projection = structuredClone(await records.readRecord(projectionKey));
|
|
3707
|
+
if (projection === undefined || projection.kind !== 'grant' || !isOAuthGrantPayload(projection.payload))
|
|
3708
|
+
return undefined;
|
|
3709
|
+
// Only a projection this row owns is adopted; recovering a foreign grant
|
|
3710
|
+
// would attach another account's credential to this row.
|
|
3711
|
+
const oauthRows = this.store.provider(provider)?.keys.filter(candidate => candidate.kind === 'oauth' && candidate.oauth !== null) ?? [];
|
|
3712
|
+
if (matchOwningAccount(provider, oauthRows, projection.payload)?.id !== row.id)
|
|
3713
|
+
return undefined;
|
|
3714
|
+
// `row` is a snapshot taken before the reads: a removal or a re-sign-in can
|
|
3715
|
+
// have detached or repointed it since, and the repair must not land on a
|
|
3716
|
+
// ref this row no longer names.
|
|
3717
|
+
if (oauthRows.find(candidate => candidate.id === row.id)?.oauth?.grantRef !== grantRef) {
|
|
3718
|
+
// The row no longer names this ref, so the projection that matched it
|
|
3719
|
+
// belongs to a previous generation: refusing leaves the caller's own
|
|
3720
|
+
// "missing or unusable" failure in place instead of reviving it.
|
|
3721
|
+
return undefined;
|
|
3722
|
+
}
|
|
3723
|
+
// A declined updater is NOT "the ref is occupied by an unusable record":
|
|
3724
|
+
// it means nothing was written for this row, so the projection read above
|
|
3725
|
+
// is a previous generation's payload and must not be served.
|
|
3726
|
+
let declined = false;
|
|
3727
|
+
try {
|
|
3728
|
+
// Compare-at-absent: an occupied ref returns `current`, which the seam
|
|
3729
|
+
// keeps, so a concurrent login's or rotation's record wins the ref.
|
|
3730
|
+
const liveNow = await records.modifyRecord(grantRef, current => {
|
|
3731
|
+
if (current !== undefined)
|
|
3732
|
+
return Promise.resolve(current);
|
|
3733
|
+
// Runs under the seam's per-key lock, so this is the state the write
|
|
3734
|
+
// commits against: a removal that deleted the ref and marked (or
|
|
3735
|
+
// spliced) the row since the checks above must not be undone by
|
|
3736
|
+
// re-creating the credential it deleted.
|
|
3737
|
+
const liveRow = this.store.provider(provider)?.keys.find(candidate => candidate.id === row.id);
|
|
3738
|
+
if (liveRow === undefined
|
|
3739
|
+
|| this.retiringAccounts.has(liveRow)
|
|
3740
|
+
|| liveRow.kind !== 'oauth'
|
|
3741
|
+
|| liveRow.oauth === null
|
|
3742
|
+
|| liveRow.oauth.grantRef !== grantRef) {
|
|
3743
|
+
declined = true;
|
|
3744
|
+
return Promise.resolve(undefined);
|
|
3745
|
+
}
|
|
3746
|
+
return Promise.resolve({ kind: 'grant', payload: structuredClone(projection.payload) });
|
|
3747
|
+
});
|
|
3748
|
+
// No post-write compensation (PR #37 I-000233): it would prove what THIS
|
|
3749
|
+
// call wrote, not that the record it deleted was still ours — a same-ref
|
|
3750
|
+
// sign-in can commit a fresh record before attaching its row, and the
|
|
3751
|
+
// seam has no compare-and-delete (`deleteRecord(key)` takes no expected
|
|
3752
|
+
// payload). It is redundant: the removal path owns the ref it retires.
|
|
3753
|
+
// `removeOAuthGrant` marks the row in `retiringAccounts` BEFORE its first
|
|
3754
|
+
// awaited delete, and this updater revalidates synchronously under the
|
|
3755
|
+
// seam's per-key lock, so a removal that started first makes the update
|
|
3756
|
+
// decline, and one that starts after this write deletes
|
|
3757
|
+
// `row.oauth.grantRef` — the very ref this updater just proved the row
|
|
3758
|
+
// names — itself.
|
|
3759
|
+
if (declined)
|
|
3760
|
+
return undefined;
|
|
3761
|
+
// The ref may have been taken while this read was parked: the record's
|
|
3762
|
+
// payload is the credential the caller must borrow now, not the
|
|
3763
|
+
// projection this repair declined to write over it.
|
|
3764
|
+
if (liveNow !== undefined && liveNow.kind === 'grant' && isOAuthGrantPayload(liveNow.payload)) {
|
|
3765
|
+
return { payload: liveNow.payload, recoveredFromProjection: false };
|
|
3766
|
+
}
|
|
3767
|
+
}
|
|
3768
|
+
catch (error) {
|
|
3769
|
+
// Best-effort repair: the projection still served this read.
|
|
3770
|
+
this.warn(`could not restore ${grantRef} for ${provider}: ${error instanceof Error ? error.message : String(error)}`);
|
|
3771
|
+
}
|
|
3772
|
+
return { payload: projection.payload, recoveredFromProjection: true, projection };
|
|
3773
|
+
}
|
|
3565
3774
|
/**
|
|
3566
3775
|
* The slow half of an activation, outside the queue: read the row's grant
|
|
3567
|
-
* generation
|
|
3776
|
+
* generation (recovered from the live projection when its record is gone),
|
|
3777
|
+
* validate it, and capture the projection the route serves now.
|
|
3568
3778
|
*/
|
|
3569
3779
|
async prepareActivation(provider, row, records) {
|
|
3570
3780
|
const grantRef = row.oauth.grantRef;
|
|
3571
|
-
const
|
|
3572
|
-
if (
|
|
3781
|
+
const recovered = await this.recoverGrantPayload(provider, row, records);
|
|
3782
|
+
if (recovered === undefined) {
|
|
3573
3783
|
throw new Error(`The grant payload at ${grantRef} is missing or unusable; sign in to this account again`);
|
|
3574
3784
|
}
|
|
3575
3785
|
const projectionKey = `${OAUTH_GRANT_RECORD_SCOPE}/${provider}`;
|
|
3576
|
-
|
|
3577
|
-
|
|
3786
|
+
// A projection-only recovery already read and ownership-verified the route.
|
|
3787
|
+
// Re-reading it here would open a gap: a same-ref rotation landing in it
|
|
3788
|
+
// leaves the payload from the first read and a baseline from the second, so
|
|
3789
|
+
// the compare would pass and write the older credential. The record the
|
|
3790
|
+
// payload came from is the only truthful baseline for it.
|
|
3791
|
+
const live = recovered.projection ?? await records.readRecord(projectionKey);
|
|
3792
|
+
const payload = structuredClone(recovered.payload);
|
|
3578
3793
|
// F5: the restored record decides its owner, so the classification is
|
|
3579
3794
|
// derived from the baseline payload through the shared matcher — the same
|
|
3580
3795
|
// rule the reconciler applies — rather than from an owner id captured
|
|
@@ -3587,6 +3802,7 @@ export class KeysManagerService {
|
|
|
3587
3802
|
grantRef,
|
|
3588
3803
|
payload,
|
|
3589
3804
|
written: JSON.stringify(payload),
|
|
3805
|
+
recoveredFromProjection: recovered.recoveredFromProjection,
|
|
3590
3806
|
baseline: live === undefined ? { state: 'absent' } : { state: 'present', record: structuredClone(live) },
|
|
3591
3807
|
// Captured with the baseline, before the turn claims the route: the
|
|
3592
3808
|
// compensation has to put back the classification that matches the
|
|
@@ -4452,11 +4668,68 @@ export class KeysManagerService {
|
|
|
4452
4668
|
test: true,
|
|
4453
4669
|
});
|
|
4454
4670
|
}
|
|
4671
|
+
/**
|
|
4672
|
+
* Whether this test may still borrow the route, or the result it must report
|
|
4673
|
+
* instead: the window is live, shutdown has not begun, and the row still
|
|
4674
|
+
* exists, is not retiring, and still names the `grantRef` the test captured.
|
|
4675
|
+
*
|
|
4676
|
+
* Read before the borrow and again after every await that precedes it. The
|
|
4677
|
+
* grant recovery is one: it outlives the isolation deadline on a slow seam,
|
|
4678
|
+
* and a window the watchdog already released no longer compensates its late
|
|
4679
|
+
* borrow (that branch is guarded by `abandonedWindows.get(provider) === window`).
|
|
4680
|
+
*/
|
|
4681
|
+
borrowBarrier(window, provider, row, grantRef) {
|
|
4682
|
+
if (window.cleaned) {
|
|
4683
|
+
// QC fix wave 2: the watchdog abandoned this window while an await was in
|
|
4684
|
+
// flight. Starting (or resuming) the borrow now would land it after the
|
|
4685
|
+
// cleanup that already ran, so the test stops here.
|
|
4686
|
+
return this.abandonedTestResult();
|
|
4687
|
+
}
|
|
4688
|
+
if (this.shutdownRequested) {
|
|
4689
|
+
// PR review round 30: shutdown abandons the chain parked here, so the
|
|
4690
|
+
// borrow write must not land after `close()`. Round 34: the marker
|
|
4691
|
+
// is up from shutdown's entry, so the pre-close restoration is covered
|
|
4692
|
+
// too — it does not borrow, and this test must not either.
|
|
4693
|
+
return this.shutdownEntryResult();
|
|
4694
|
+
}
|
|
4695
|
+
// PR review round 12: the reads and the recovery above are awaits, so a
|
|
4696
|
+
// removal can have deleted the provider or this row while they were in
|
|
4697
|
+
// flight (a test that registered and one that did not are covered: the
|
|
4698
|
+
// marker refuses the latter, this re-check the former). Never write the
|
|
4699
|
+
// borrow — and never re-project the removed account's grant — on that state.
|
|
4700
|
+
// PR review round 15: the existence check alone still holds while a removal
|
|
4701
|
+
// is deleting credentials (the row is spliced only after its grant delete),
|
|
4702
|
+
// so the row's retirement mark is part of the assertion. The provider-wide
|
|
4703
|
+
// marker is deliberately not read here: a removal that began while this
|
|
4704
|
+
// test is registered waits for it in {@link settleTestBeforeRemoval} and is
|
|
4705
|
+
// the designed handshake, while the row mark is only set by a removal that
|
|
4706
|
+
// already passed that check.
|
|
4707
|
+
const live = this.store.provider(provider);
|
|
4708
|
+
const removing = this.retiringAccounts.has(row);
|
|
4709
|
+
const liveRow = live?.keys.find(candidate => candidate.id === row.id);
|
|
4710
|
+
if (removing || liveRow === undefined) {
|
|
4711
|
+
return removing
|
|
4712
|
+
? { ok: false, latencyMs: 0, error: `Provider "${provider}" is being removed; the test did not borrow its credential` }
|
|
4713
|
+
: { ok: false, latencyMs: 0, error: `Key "${row.id}" was removed from ${provider} before its test could borrow it` };
|
|
4714
|
+
}
|
|
4715
|
+
// PR #37 review (I-000234): a re-sign-in repoints the row instead of
|
|
4716
|
+
// removing it. The recovery read then belongs to the generation the row no
|
|
4717
|
+
// longer names, so borrowing it would test the previous credential and
|
|
4718
|
+
// report the outcome against this row.
|
|
4719
|
+
if (liveRow.oauth === null || liveRow.oauth.grantRef !== grantRef) {
|
|
4720
|
+
return { ok: false, latencyMs: 0, error: `Key "${row.id}" was repointed on ${provider} before its test could borrow it` };
|
|
4721
|
+
}
|
|
4722
|
+
return undefined;
|
|
4723
|
+
}
|
|
4455
4724
|
/** One request with the row's grant temporarily projected as the route's credential. */
|
|
4456
4725
|
async runOAuthRowTest(provider, row, model, window) {
|
|
4457
4726
|
const grant = row.oauth;
|
|
4458
4727
|
if (grant === null)
|
|
4459
4728
|
return { ok: false, latencyMs: 0, error: `Key "${row.id}" is not an OAuth account` };
|
|
4729
|
+
// Captured here, not read from the row later: a re-sign-in repoints the
|
|
4730
|
+
// row's generation in place, so the live object would answer with the NEW
|
|
4731
|
+
// ref and the borrow would look current.
|
|
4732
|
+
const grantRef = grant.grantRef;
|
|
4460
4733
|
const records = this.credentialRecords();
|
|
4461
4734
|
if (records === undefined)
|
|
4462
4735
|
return { ok: false, latencyMs: 0, error: 'The credential store does not support records' };
|
|
@@ -4466,23 +4739,6 @@ export class KeysManagerService {
|
|
|
4466
4739
|
// grant chain, so a rotation echo cannot observe or overwrite the
|
|
4467
4740
|
// temporary projection between them.
|
|
4468
4741
|
await this.scheduleGrantReconciliation(provider, async () => {
|
|
4469
|
-
const stored = await records.readRecord(grant.grantRef);
|
|
4470
|
-
if (window.cleaned) {
|
|
4471
|
-
// QC fix wave 2: the watchdog abandoned this window while the read was
|
|
4472
|
-
// in flight. Starting (or resuming) the borrow now would land it after
|
|
4473
|
-
// the cleanup that already ran, so the test stops here.
|
|
4474
|
-
result = this.abandonedTestResult();
|
|
4475
|
-
return;
|
|
4476
|
-
}
|
|
4477
|
-
if (stored === undefined || stored.kind !== 'grant' || !isOAuthGrantPayload(stored.payload)) {
|
|
4478
|
-
result = {
|
|
4479
|
-
ok: false,
|
|
4480
|
-
latencyMs: 0,
|
|
4481
|
-
error: `The grant payload at ${grant.grantRef} is missing or unusable; sign in to this account again`,
|
|
4482
|
-
};
|
|
4483
|
-
return;
|
|
4484
|
-
}
|
|
4485
|
-
const borrowed = structuredClone(stored.payload);
|
|
4486
4742
|
const captured = await records.readRecord(projectionKey);
|
|
4487
4743
|
// PR review round 14: the snapshot and its comparison key are frozen
|
|
4488
4744
|
// here. The seam hands back a live record, so a writer mutating that
|
|
@@ -4490,36 +4746,30 @@ export class KeysManagerService {
|
|
|
4490
4746
|
// the updater's `current` and make the borrow look unchanged.
|
|
4491
4747
|
const previous = captured === undefined ? undefined : structuredClone(captured);
|
|
4492
4748
|
const previousKey = previous === undefined ? undefined : JSON.stringify(previous);
|
|
4493
|
-
|
|
4494
|
-
|
|
4749
|
+
const blocked = this.borrowBarrier(window, provider, row, grantRef);
|
|
4750
|
+
if (blocked !== undefined) {
|
|
4751
|
+
result = blocked;
|
|
4495
4752
|
return;
|
|
4496
4753
|
}
|
|
4497
|
-
|
|
4498
|
-
|
|
4499
|
-
|
|
4500
|
-
|
|
4501
|
-
|
|
4502
|
-
|
|
4754
|
+
// The recovery writes a grant record, so it runs only once this test is
|
|
4755
|
+
// known to borrow: an abandoned or retiring test must not restore one.
|
|
4756
|
+
const recovered = await this.recoverGrantPayload(provider, row, records);
|
|
4757
|
+
if (recovered === undefined) {
|
|
4758
|
+
result = {
|
|
4759
|
+
ok: false,
|
|
4760
|
+
latencyMs: 0,
|
|
4761
|
+
error: `The grant payload at ${grantRef} is missing or unusable; sign in to this account again`,
|
|
4762
|
+
};
|
|
4503
4763
|
return;
|
|
4504
4764
|
}
|
|
4505
|
-
|
|
4506
|
-
//
|
|
4507
|
-
//
|
|
4508
|
-
//
|
|
4509
|
-
//
|
|
4510
|
-
|
|
4511
|
-
|
|
4512
|
-
|
|
4513
|
-
// The provider-wide marker is deliberately not read here: a removal that
|
|
4514
|
-
// began while this test is registered waits for it in
|
|
4515
|
-
// {@link settleTestBeforeRemoval} and is the designed handshake, while
|
|
4516
|
-
// the row mark is only set by a removal that already passed that check.
|
|
4517
|
-
const live = this.store.provider(provider);
|
|
4518
|
-
const removing = this.retiringAccounts.has(row);
|
|
4519
|
-
if (removing || live === undefined || !live.keys.some(candidate => candidate.id === row.id)) {
|
|
4520
|
-
result = removing
|
|
4521
|
-
? { ok: false, latencyMs: 0, error: `Provider "${provider}" is being removed; the test did not borrow its credential` }
|
|
4522
|
-
: { ok: false, latencyMs: 0, error: `Key "${row.id}" was removed from ${provider} before its test could borrow it` };
|
|
4765
|
+
const borrowed = structuredClone(recovered.payload);
|
|
4766
|
+
// PR #37 review (must-fix): the recovery is an untimed seam round trip
|
|
4767
|
+
// and can outlive the isolation deadline, after which the window's
|
|
4768
|
+
// cleanup has already run and released the window. Registering facts or
|
|
4769
|
+
// borrowing now would land them with no owner: re-run the barrier first.
|
|
4770
|
+
const lateBlocked = this.borrowBarrier(window, provider, row, grantRef);
|
|
4771
|
+
if (lateBlocked !== undefined) {
|
|
4772
|
+
result = lateBlocked;
|
|
4523
4773
|
return;
|
|
4524
4774
|
}
|
|
4525
4775
|
// QC fix wave 2: record the borrow before the write, so an abandoned
|
|
@@ -5217,11 +5467,13 @@ export class KeysManagerService {
|
|
|
5217
5467
|
return { ok: false, error: failure?.message ?? failure?.code ?? 'The provider reported a failure' };
|
|
5218
5468
|
}
|
|
5219
5469
|
// PR review SF3 + round 8: only a real completion is a pass. The ping
|
|
5220
|
-
// asks for one token, so `
|
|
5470
|
+
// asks for one token, so `max-tokens` is the expected completion; a
|
|
5221
5471
|
// finish chunk with NO reason, `aborted`, or any kind this broker
|
|
5222
5472
|
// does not know means the provider did not answer, so reporting a
|
|
5223
|
-
// pass would claim a credential it never served.
|
|
5224
|
-
|
|
5473
|
+
// pass would claim a credential it never served. The accepted kinds
|
|
5474
|
+
// are the installed `@deepseek-ai/dsh-llm` union (`FinishReasonMap`),
|
|
5475
|
+
// shared with the tests so the two cannot drift again.
|
|
5476
|
+
if (kind !== undefined && TEST_SUCCESS_FINISH_KINDS.includes(kind)) {
|
|
5225
5477
|
// PR review round 42: a successful ping only proves the route
|
|
5226
5478
|
// answered. An api-key row test must also prove the pinned literal
|
|
5227
5479
|
// was the credential that served it — a takeover during the stream
|