@sentropic/llm-mesh 0.33.0 → 0.34.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/enrollment/muse-code.d.ts +9 -0
- package/dist/enrollment/muse-code.d.ts.map +1 -1
- package/dist/enrollment/muse-code.js +22 -1
- package/dist/enrollment/muse-code.js.map +1 -1
- package/dist/node/claude-code-cli-runner.d.ts +56 -0
- package/dist/node/claude-code-cli-runner.d.ts.map +1 -0
- package/dist/node/claude-code-cli-runner.js +590 -0
- package/dist/node/claude-code-cli-runner.js.map +1 -0
- package/dist/node/claude-code-cli-wire.d.ts +55 -0
- package/dist/node/claude-code-cli-wire.d.ts.map +1 -0
- package/dist/node/claude-code-cli-wire.js +144 -0
- package/dist/node/claude-code-cli-wire.js.map +1 -0
- package/dist/node/index.d.ts +5 -0
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +3 -0
- package/dist/node/index.js.map +1 -1
- package/dist/service/custodian-refresh.d.ts +52 -0
- package/dist/service/custodian-refresh.d.ts.map +1 -0
- package/dist/service/custodian-refresh.js +51 -0
- package/dist/service/custodian-refresh.js.map +1 -0
- package/dist/service/facade.d.ts +18 -0
- package/dist/service/facade.d.ts.map +1 -1
- package/dist/service/facade.js +15 -1
- package/dist/service/facade.js.map +1 -1
- package/dist/service/local-account-transport-service.d.ts +88 -2
- package/dist/service/local-account-transport-service.d.ts.map +1 -1
- package/dist/service/local-account-transport-service.js +382 -10
- package/dist/service/local-account-transport-service.js.map +1 -1
- package/dist/service/reauth-alert.d.ts +29 -0
- package/dist/service/reauth-alert.d.ts.map +1 -0
- package/dist/service/reauth-alert.js +2 -0
- package/dist/service/reauth-alert.js.map +1 -0
- package/dist/transport/claude-code-runtime-client.d.ts +24 -0
- package/dist/transport/claude-code-runtime-client.d.ts.map +1 -1
- package/dist/transport/claude-code-runtime-client.js +66 -3
- package/dist/transport/claude-code-runtime-client.js.map +1 -1
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"local-account-transport-service.d.ts","sourceRoot":"","sources":["../../src/service/local-account-transport-service.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"local-account-transport-service.d.ts","sourceRoot":"","sources":["../../src/service/local-account-transport-service.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,4BAA4B,EAE5B,KAAK,uBAAuB,EAC5B,KAAK,4BAA4B,EACjC,KAAK,2BAA2B,EACjC,MAAM,0BAA0B,CAAC;AAClC,OAAO,KAAK,EACV,aAAa,EAGb,oBAAoB,EACpB,kBAAkB,EAClB,iBAAiB,EAGjB,oBAAoB,EACrB,MAAM,4BAA4B,CAAC;AAIpC,OAAO,KAAK,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAElE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AAS1C,OAAO,EAGL,KAAK,YAAY,EAElB,MAAM,8BAA8B,CAAC;AACtC,OAAO,EAEL,KAAK,gBAAgB,EACtB,MAAM,kCAAkC,CAAC;AAE1C,OAAO,KAAK,EACV,oBAAoB,EAErB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAIL,KAAK,sBAAsB,EAC3B,KAAK,sBAAsB,EAC5B,MAAM,wBAAwB,CAAC;AAChC,OAAO,KAAK,EAAuC,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAE9F,MAAM,WAAW,mCAAmC;IAClD,+DAA+D;IAC/D,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,4EAA4E;IAC5E,WAAW,CAAC,EAAE,eAAe,CAAC;IAC9B,0EAA0E;IAC1E,aAAa,CAAC,EAAE,sBAAsB,CAAC;CACxC;AAyBD,MAAM,MAAM,2BAA2B,GACnC,YAAY,GACZ,gBAAgB,GAChB,kBAAkB,GAClB,gBAAgB,GAChB,gBAAgB,CAAC;AAErB;;;;;;GAMG;AACH,qBAAa,oBAAqB,SAAQ,4BAA4B;;CAIrE;AA0BD,0FAA0F;AAC1F,qBAAa,0BAA2B,SAAQ,KAAK;IACnD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;gBAEV,MAAM,EAAE,2BAA2B,EAAE,MAAM,EAAE,MAAM;CAKhE;AAED,0FAA0F;AAC1F,MAAM,WAAW,gBAAgB;IAC/B,OAAO,EAAE,CAAC,CAAC;IACX,MAAM,EAAE,YAAY,CAAC;IACrB,UAAU,EAAE,gBAAgB,GAAG,IAAI,CAAC;IACpC,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,YAAY,CAAC;IACrB,UAAU,EAAE,gBAAgB,GAAG,IAAI,CAAC;CACrC;AAwCD,qBAAa,4BAA4B;IA+BrC,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,cAAc;IAC/B,OAAO,CAAC,QAAQ,CAAC,0BAA0B,CAAC;IAjC9C,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,eAAe,CAAuC;IAC9E,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAsC;IAClE,OAAO,CAAC,QAAQ,CAAC,WAAW,CAA8C;IAC1E,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAA6B;IAChE,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAkD;IAClF,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAoC;IACjE,OAAO,CAAC,QAAQ,CAAC,yBAAyB,CAA6B;IACvE,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAA6B;IAChE,iFAAiF;IACjF,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAA6B;IACjE;;;;OAIG;IACH,OAAO,CAAC,QAAQ,CAAC,oBAAoB,CAA6B;IAClE,OAAO,CAAC,QAAQ,CAAC,qBAAqB,CAAqB;IAC3D,OAAO,CAAC,qBAAqB,CAAoC;IACjE,OAAO,CAAC,QAAQ,CAAC,cAAc,CAA8C;IAC7E,OAAO,CAAC,oBAAoB,CAAK;IACjC,2EAA2E;IAC3E,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAqB;IACnD,0FAA0F;IAC1F,OAAO,CAAC,QAAQ,CAAC,qBAAqB,CAAqB;IAC3D,OAAO,CAAC,cAAc,CAA8C;IACpE,OAAO,CAAC,qBAAqB,CAAS;IACtC,OAAO,CAAC,uBAAuB,CAAK;IACpC,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAsC;gBAGlD,OAAO,EAAE,cAAc,EACvB,SAAS,EAAE,GAAG,CAAC,MAAM,EAAE,kBAAkB,CAAC,EAC1C,cAAc,EAAE,cAAc,EAC9B,0BAA0B,CAAC,oBAAQ,EACpD,OAAO,CAAC,EAAE,mCAAmC;IAM/C,OAAO,CAAC,KAAK;IAKP,MAAM,CAAC,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE,oBAAoB,GAAG,OAAO,CAAC,iBAAiB,CAAC;IAmBnF,wBAAwB,CAAC,YAAY,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAC/D,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,oBAAoB,CAAC;IAyBjD,8BAA8B,CAAC,cAAc,EAAE,MAAM,EACzD,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,oBAAoB,CAAC;IAcjD,eAAe,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,oBAAoB,CAAC;IAiEpE,iBAAiB,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,oBAAoB,CAAC;IA6DtE,kBAAkB,CACtB,YAAY,EAAE,MAAM,EACpB,IAAI,EAAE,MAAM,EACZ,aAAa,EAAE,MAAM,GACpB,OAAO,CAAC,oBAAoB,CAAC;IAyD1B,wBAAwB,CAC5B,MAAM,EAAE,MAAM,EACd,aAAa,EAAE,MAAM,GACpB,OAAO,CAAC,oBAAoB,CAAC;IA8D1B,wBAAwB,CAC5B,YAAY,EAAE,MAAM,EACpB,aAAa,EAAE,MAAM,EACrB,WAAW,SAAK,GACf,OAAO,CAAC,oBAAoB,CAAC;IAoEhC;;;;;;;;OAQG;IACG,gBAAgB,CAAC,KAAK,EAAE;QAC5B,MAAM,EAAE,OAAO,CAAC;QAChB,UAAU,EAAE,MAAM,CAAC;QACnB,UAAU,CAAC,EAAE,OAAO,CAAC;KACtB,GAAG,OAAO,CAAC;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,OAAO,CAAA;KAAE,CAAC;IA+H5F;;;;;;;;OAQG;IACG,gBAAgB,CAAC,KAAK,EAAE;QAC5B,SAAS,EAAE,MAAM,CAAC;QAClB,UAAU,EAAE,MAAM,CAAC;KACpB,GAAG,OAAO,CAAC,UAAU,CAAC;IAwEvB,+EAA+E;IAC/E,OAAO,CAAC,sBAAsB;IAuB9B,OAAO,CAAC,aAAa;YAIP,eAAe;YAcf,cAAc;YAoBd,2BAA2B;YAS3B,cAAc;YAoBd,uBAAuB;IAuD/B,MAAM,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAW3C,YAAY,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,aAAa,EAAE,CAAC;IA0BnE,aAAa,CACjB,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,IAAI,CAAA;KAAE,CAAC;IAuChD,eAAe,CACb,OAAO,EAAE,uBAAuB,EAChC,aAAa,SAAW,EACxB,iBAAiB,CAAC,EAAE,MAAM,GACzB,IAAI;IAsBD,OAAO,CAAC,KAAK,EAAE,4BAA4B,GAAG,OAAO,CAAC,2BAA2B,CAAC;IA2ElF,OAAO,CAAC,WAAW,EAAE,2BAA2B,GAAG,OAAO,CAAC,IAAI,CAAC;IAKtE,oBAAoB,CAAC,OAAO,EAAE,IAAI,CAAC,OAAO,EAAE,UAAU,GAAG,QAAQ,CAAC,GAAG,oBAAoB;YAsB3E,oBAAoB;IAoDlC,OAAO,CAAC,mBAAmB;IAM3B;;;;;;OAMG;IACH,OAAO,CAAC,cAAc;YAOR,iBAAiB;IAgC/B,OAAO,CAAC,YAAY;IAgDpB,OAAO,CAAC,oBAAoB;YAOd,kBAAkB;YAIlB,YAAY;YAiGZ,iBAAiB;YA6CjB,iBAAiB;YAajB,mBAAmB;IA8BjC,OAAO,CAAC,iBAAiB;IAYzB,OAAO,CAAC,iBAAiB;IAMzB,OAAO,CAAC,UAAU;IAIlB,OAAO,CAAC,aAAa;YAIP,cAAc;YAiBd,iBAAiB;YAgBjB,uBAAuB;YAgBvB,oBAAoB;YAkBpB,gBAAgB;YAMhB,qBAAqB;YAOrB,6BAA6B;YAK7B,2BAA2B;IAqBzC,OAAO,CAAC,oBAAoB;YAMd,yBAAyB;YAazB,gBAAgB;YAmBhB,wBAAwB;YAkFxB,kBAAkB;IAkBhC,sEAAsE;IACtE,OAAO,CAAC,sBAAsB;IAuB9B;;;;OAIG;YACW,eAAe;IAiB7B;;;;;OAKG;YACW,0BAA0B;IAexC;;;;;;;;;;;;;;;;;;OAkBG;IACG,wBAAwB,IAAI,OAAO,CAAC,sBAAsB,CAAC;IAkIjE;;;;;;;;;;;;;OAaG;IACH,kBAAkB,CAChB,UAAU,EAAE,MAAM,EAClB,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,sBAAsB,KAAK,IAAI,EACnD,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,GACjC,MAAM,IAAI;IA4Cb,iBAAiB,IAAI,IAAI;CAO1B"}
|
|
@@ -1,10 +1,45 @@
|
|
|
1
1
|
import { AccountTransportAcquireError, InMemoryAccountTransportCoordinator, } from '../account-transports.js';
|
|
2
2
|
import { MUSE_DIRECT_BILLING_TYPE } from '../enrollment/muse.js';
|
|
3
3
|
import { ClaudeRefreshPreparationError } from '../enrollment/claude-code.js';
|
|
4
|
+
import { MusePostRotationError } from '../enrollment/muse-code.js';
|
|
4
5
|
import { listModelProfilesByProvider } from '../catalog.js';
|
|
5
6
|
import { isDeviceFlowSeatAccount, isExternalSeatAccount, isExternalSeatUnavailable, markExternalSeatAuthority, readExternalSeatAuthority, throwExternalSeatUnavailable, } from './external-seat-authority.js';
|
|
6
7
|
import { classifyTransition, validateSeatBundle, } from '../enrollment/seat-bundle.js';
|
|
7
8
|
import { validateSeatOnboarding, } from '../enrollment/seat-onboarding.js';
|
|
9
|
+
import { sanitizeCustodianRefreshPolicy, shouldRefreshProactively, } from './custodian-refresh.js';
|
|
10
|
+
/**
|
|
11
|
+
* A refresh failure that must NOT flip the account: the grant was provably
|
|
12
|
+
* not consumed (an explicit 429/5xx token-endpoint response, or a typed local
|
|
13
|
+
* preparation failure before any provider request), so a retry is safe.
|
|
14
|
+
* Extends {@link AccountTransportAcquireError} so `acquire()` callers keep
|
|
15
|
+
* their handling unchanged.
|
|
16
|
+
*/
|
|
17
|
+
export class RefreshDeferredError extends AccountTransportAcquireError {
|
|
18
|
+
constructor() {
|
|
19
|
+
super('Token refresh deferred; retry later', 'no_active_account');
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
// An explicit revocation marker always wins: the grant is dead, retrying is
|
|
23
|
+
// pointless and rotating again could harm the grant family.
|
|
24
|
+
const DEFINITIVE_REFRESH_FAILURE = /invalid_grant|invalidated|revoked|deleted|disabled|unauthorized_client|invalid or missing refresh token/i;
|
|
25
|
+
// Only an explicit token-endpoint error response proves the grant was NOT
|
|
26
|
+
// consumed: HTTP 429/5xx with a response, in either provider shape (the
|
|
27
|
+
// `(status)` form or Claude's `HTTP <status>` form). Transport signals with
|
|
28
|
+
// no response (network, timeout, reset, abort, `fetch failed`) prove nothing:
|
|
29
|
+
// the server may have rotated the grant before the socket died. They stay
|
|
30
|
+
// fail-closed toward reauth, as do post-rotation failures (typed
|
|
31
|
+
// `MusePostRotationError`) and any other 4xx or unknown shape.
|
|
32
|
+
const EXPLICIT_RETRYABLE_STATUS = /\(\s*(429|5\d\d)\s*\)|\bHTTP\s+(429|5\d\d)\b|\b429\b/i;
|
|
33
|
+
function classifyRefreshFailure(error) {
|
|
34
|
+
if (error instanceof MusePostRotationError)
|
|
35
|
+
return 'reauth';
|
|
36
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
37
|
+
if (DEFINITIVE_REFRESH_FAILURE.test(message))
|
|
38
|
+
return 'reauth';
|
|
39
|
+
if (EXPLICIT_RETRYABLE_STATUS.test(message))
|
|
40
|
+
return 'retry';
|
|
41
|
+
return 'reauth';
|
|
42
|
+
}
|
|
8
43
|
/** Typed refusal for exportSeatBundle; the code is `seat-export-unavailable:<reason>`. */
|
|
9
44
|
export class SeatExportUnavailableError extends Error {
|
|
10
45
|
code;
|
|
@@ -56,16 +91,34 @@ export class LocalAccountTransportService {
|
|
|
56
91
|
claimedOwnerScopes = new Map();
|
|
57
92
|
/** enrollmentId → providerId, so poll completion reaches the owning provider. */
|
|
58
93
|
enrollmentProviders = new Map();
|
|
94
|
+
/**
|
|
95
|
+
* Last credential issuance per account (enrollment, restore, or refresh).
|
|
96
|
+
* The proactive window is derived from this issuance age, never from the
|
|
97
|
+
* enrollment age: a refreshed credential restarts its own TTL.
|
|
98
|
+
*/
|
|
99
|
+
credentialIssuedAtMs = new Map();
|
|
59
100
|
pendingClaudeAccounts = new Set();
|
|
60
101
|
claudePersistenceTail = Promise.resolve();
|
|
61
102
|
claudeSessions = new Map();
|
|
62
103
|
routeAttemptSequence = 0;
|
|
63
|
-
|
|
104
|
+
/** Account ids already alerted for their current reauth_required epoch. */
|
|
105
|
+
alertedReauth = new Set();
|
|
106
|
+
/** Account ids already warned about an upcoming no-grant expiry (once per enrollment). */
|
|
107
|
+
warnedExpiringNoGrant = new Set();
|
|
108
|
+
custodianTimer = null;
|
|
109
|
+
custodianTickInFlight = false;
|
|
110
|
+
custodianLoopGeneration = 0;
|
|
111
|
+
serviceOptions;
|
|
112
|
+
constructor(keyring, providers, configResolver, legacyAccountOwnerScopeRef, options) {
|
|
64
113
|
this.keyring = keyring;
|
|
65
114
|
this.providers = providers;
|
|
66
115
|
this.configResolver = configResolver;
|
|
67
116
|
this.legacyAccountOwnerScopeRef = legacyAccountOwnerScopeRef;
|
|
68
117
|
this.coordinator = new InMemoryAccountTransportCoordinator();
|
|
118
|
+
this.serviceOptions = options ?? {};
|
|
119
|
+
}
|
|
120
|
+
nowMs() {
|
|
121
|
+
return this.serviceOptions.now?.() ?? Date.now();
|
|
69
122
|
}
|
|
70
123
|
// ── Enrollment (called via facade by h2a CLI) ──────────────────────────
|
|
71
124
|
async enroll(providerId, input) {
|
|
@@ -766,6 +819,7 @@ export class LocalAccountTransportService {
|
|
|
766
819
|
this.coordinator.removeAccount(accountId);
|
|
767
820
|
this.accountsMap.delete(accountId);
|
|
768
821
|
this.credentialVersions.delete(accountId);
|
|
822
|
+
this.credentialIssuedAtMs.delete(accountId);
|
|
769
823
|
this.accountRemovalBarrierRefs.delete(accountId);
|
|
770
824
|
await this.keyring.deleteSecret(`sentropic-llm-mesh:${accountId}:envelope`);
|
|
771
825
|
await this.keyring.deleteSecret(`sentropic-llm-mesh:${accountId}:public`);
|
|
@@ -777,6 +831,15 @@ export class LocalAccountTransportService {
|
|
|
777
831
|
const executableAccount = this.coordinator.addAccount(account);
|
|
778
832
|
this.accountsMap.set(account.accountId, executableAccount);
|
|
779
833
|
this.credentialVersions.set(account.accountId, configVersion);
|
|
834
|
+
// A (re-)enrollment opens a fresh alert epoch: the owner has just
|
|
835
|
+
// re-authenticated, so the next outage must alert again. The
|
|
836
|
+
// pre-expiry warning window reopens as well.
|
|
837
|
+
this.alertedReauth.delete(account.accountId);
|
|
838
|
+
this.warnedExpiringNoGrant.delete(account.accountId);
|
|
839
|
+
const enrolledAtMs = account.enrollmentCompletedAt
|
|
840
|
+
? Date.parse(account.enrollmentCompletedAt)
|
|
841
|
+
: Number.NaN;
|
|
842
|
+
this.credentialIssuedAtMs.set(account.accountId, Number.isFinite(enrolledAtMs) ? enrolledAtMs : this.nowMs());
|
|
780
843
|
if (removalBarrierRef) {
|
|
781
844
|
this.accountRemovalBarrierRefs.set(account.accountId, removalBarrierRef);
|
|
782
845
|
}
|
|
@@ -804,7 +867,7 @@ export class LocalAccountTransportService {
|
|
|
804
867
|
throw new AccountTransportAcquireError(`Account ${acquiredAccountId} has been removed`, 'no_active_account');
|
|
805
868
|
}
|
|
806
869
|
const account = this.accountsMap.get(acquisition.material.accountId ?? '');
|
|
807
|
-
const nowMs = typeof input.now === 'number' ? input.now :
|
|
870
|
+
const nowMs = typeof input.now === 'number' ? input.now : this.nowMs();
|
|
808
871
|
// Check if access token is expired
|
|
809
872
|
// L1 external policy: custodied seats never refresh here and never
|
|
810
873
|
// serve on unknown expiry. Expired or dateless access becomes
|
|
@@ -905,7 +968,7 @@ export class LocalAccountTransportService {
|
|
|
905
968
|
const parsedExpiry = typeof rawExpiry === 'string' && rawExpiry
|
|
906
969
|
? Date.parse(rawExpiry)
|
|
907
970
|
: Number.NaN;
|
|
908
|
-
const expiredAt = Number.isFinite(parsedExpiry) && parsedExpiry <=
|
|
971
|
+
const expiredAt = Number.isFinite(parsedExpiry) && parsedExpiry <= this.nowMs()
|
|
909
972
|
? new Date(parsedExpiry).toISOString()
|
|
910
973
|
: undefined;
|
|
911
974
|
diagnostics.push({
|
|
@@ -924,7 +987,7 @@ export class LocalAccountTransportService {
|
|
|
924
987
|
if (!account.expiresAt)
|
|
925
988
|
return false;
|
|
926
989
|
const expiresAtMs = Date.parse(account.expiresAt);
|
|
927
|
-
return Number.isFinite(expiresAtMs) && expiresAtMs <=
|
|
990
|
+
return Number.isFinite(expiresAtMs) && expiresAtMs <= this.nowMs();
|
|
928
991
|
}
|
|
929
992
|
/**
|
|
930
993
|
* True when the account cannot recover on its own: its status already
|
|
@@ -1068,7 +1131,7 @@ export class LocalAccountTransportService {
|
|
|
1068
1131
|
if (!account || refreshed.accountId !== input.accountId
|
|
1069
1132
|
|| !refreshed.accessToken?.trim() || /[\r\n]/.test(refreshed.accessToken)
|
|
1070
1133
|
|| !Number.isFinite(Date.parse(refreshed.expiresAt))
|
|
1071
|
-
|| Date.parse(refreshed.expiresAt) <=
|
|
1134
|
+
|| Date.parse(refreshed.expiresAt) <= this.nowMs()
|
|
1072
1135
|
|| (providerId === 'claude-code' && refreshed.authClientConfigVersion !== input.credentialVersion)
|
|
1073
1136
|
|| (refreshed.refreshToken !== undefined && (!refreshed.refreshToken.trim()
|
|
1074
1137
|
|| /[\r\n]/.test(refreshed.refreshToken))))
|
|
@@ -1088,6 +1151,8 @@ export class LocalAccountTransportService {
|
|
|
1088
1151
|
throw new Error('Account removed');
|
|
1089
1152
|
Object.assign(account, updated);
|
|
1090
1153
|
this.credentialVersions.set(input.accountId, refreshed.authClientConfigVersion);
|
|
1154
|
+
// The refreshed credential restarts its own TTL from this issuance.
|
|
1155
|
+
this.credentialIssuedAtMs.set(input.accountId, this.nowMs());
|
|
1091
1156
|
return { ...refreshed, refreshToken: updated.refreshToken ?? undefined };
|
|
1092
1157
|
}
|
|
1093
1158
|
catch (error) {
|
|
@@ -1095,12 +1160,20 @@ export class LocalAccountTransportService {
|
|
|
1095
1160
|
|| await this.isAccountRemoved(input.accountId).catch(() => false)) {
|
|
1096
1161
|
throw new AccountTransportAcquireError(`Account ${input.accountId} has been removed`, 'no_active_account');
|
|
1097
1162
|
}
|
|
1098
|
-
|
|
1163
|
+
const failure = error instanceof ClaudeRefreshPreparationError
|
|
1164
|
+
? 'retry'
|
|
1165
|
+
: classifyRefreshFailure(error);
|
|
1166
|
+
if ((!requestMayHaveBeenSent || failure === 'retry')
|
|
1099
1167
|
&& account?.status !== 'reauth_required') {
|
|
1100
|
-
//
|
|
1168
|
+
// The grant was provably not consumed: either no provider request
|
|
1169
|
+
// went out yet (a fence write can commit and then throw), or the
|
|
1170
|
+
// token endpoint answered with an explicit 429/5xx. Restore the
|
|
1171
|
+
// pre-request fence and defer without flipping. Every other
|
|
1172
|
+
// failure (timeout, reset, mint/persistence after a possible
|
|
1173
|
+
// rotation) falls through to the fail-closed flip below.
|
|
1101
1174
|
if (account)
|
|
1102
1175
|
await this.persistAccountState(account, true).catch(() => { });
|
|
1103
|
-
throw new
|
|
1176
|
+
throw new RefreshDeferredError();
|
|
1104
1177
|
}
|
|
1105
1178
|
await this.markReauthRequired(input.accountId).catch(() => { });
|
|
1106
1179
|
throw new AccountTransportAcquireError('Token refresh failed; reauthentication required', 'no_active_account');
|
|
@@ -1351,6 +1424,7 @@ export class LocalAccountTransportService {
|
|
|
1351
1424
|
this.coordinator.removeAccount(accountId);
|
|
1352
1425
|
this.accountsMap.delete(accountId);
|
|
1353
1426
|
this.credentialVersions.delete(accountId);
|
|
1427
|
+
this.credentialIssuedAtMs.delete(accountId);
|
|
1354
1428
|
this.accountRemovalBarrierRefs.delete(accountId);
|
|
1355
1429
|
}
|
|
1356
1430
|
const rawIndex = await this.keyring.getSecret(LocalAccountTransportService.accountIndexKey);
|
|
@@ -1398,6 +1472,15 @@ export class LocalAccountTransportService {
|
|
|
1398
1472
|
refreshToken: envelope.refreshToken,
|
|
1399
1473
|
expiresAt: envelope.expiresAt,
|
|
1400
1474
|
}, envelope.authClientConfigVersion, publicRecord.removalBarrierRef);
|
|
1475
|
+
// A restore must not reuse the enrollment age as the TTL origin: the
|
|
1476
|
+
// persisted `updatedAt` is the newest issuance-safe bound the keyring
|
|
1477
|
+
// holds (it only ever moves the window later, never earlier).
|
|
1478
|
+
const restoredIssuedAtMs = typeof publicRecord.updatedAt === 'string'
|
|
1479
|
+
? Date.parse(publicRecord.updatedAt)
|
|
1480
|
+
: Number.NaN;
|
|
1481
|
+
if (Number.isFinite(restoredIssuedAtMs)) {
|
|
1482
|
+
this.credentialIssuedAtMs.set(accountId, restoredIssuedAtMs);
|
|
1483
|
+
}
|
|
1401
1484
|
if (ownerClaim && ownerClaim.ownerScopeRef === restoredAccount.ownerScopeRef) {
|
|
1402
1485
|
this.claimedOwnerScopes.set(accountId, ownerClaim.ownerScopeRef);
|
|
1403
1486
|
}
|
|
@@ -1410,11 +1493,300 @@ export class LocalAccountTransportService {
|
|
|
1410
1493
|
}
|
|
1411
1494
|
}
|
|
1412
1495
|
}
|
|
1413
|
-
async markReauthRequired(accountId) {
|
|
1496
|
+
async markReauthRequired(accountId, reason = 'refresh-failed') {
|
|
1414
1497
|
const account = this.accountsMap.get(accountId);
|
|
1415
1498
|
if (account) {
|
|
1416
1499
|
account.status = 'reauth_required';
|
|
1417
|
-
|
|
1500
|
+
// The in-memory flip is the source of truth; a keyring outage must
|
|
1501
|
+
// not swallow the owner alert.
|
|
1502
|
+
try {
|
|
1503
|
+
await this.persistAccountState(account);
|
|
1504
|
+
}
|
|
1505
|
+
catch {
|
|
1506
|
+
// The persist retries on the next tick; the alert goes out now.
|
|
1507
|
+
}
|
|
1508
|
+
await this.emitReauthAlert(account, reason);
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
/** Secret-free custody event for an account (diagnostic ref only). */
|
|
1512
|
+
buildCustodyAlertEvent(account, reason) {
|
|
1513
|
+
const rawExpiry = account.expiresAt;
|
|
1514
|
+
const parsedExpiry = typeof rawExpiry === 'string' && rawExpiry
|
|
1515
|
+
? Date.parse(rawExpiry)
|
|
1516
|
+
: Number.NaN;
|
|
1517
|
+
return {
|
|
1518
|
+
at: new Date(this.nowMs()).toISOString(),
|
|
1519
|
+
ownerScopeRef: account.ownerScopeRef ?? '',
|
|
1520
|
+
transportProviderId: String(account.transportProviderId),
|
|
1521
|
+
accountRef: this.diagnosticAccountRef(account.accountId),
|
|
1522
|
+
...(Number.isFinite(parsedExpiry) && parsedExpiry <= this.nowMs()
|
|
1523
|
+
? { expiredAt: new Date(parsedExpiry).toISOString() }
|
|
1524
|
+
: {}),
|
|
1525
|
+
...(reason === 'expiring-no-grant' && Number.isFinite(parsedExpiry) && parsedExpiry > this.nowMs()
|
|
1526
|
+
? { expiresAt: new Date(parsedExpiry).toISOString() }
|
|
1527
|
+
: {}),
|
|
1528
|
+
reason,
|
|
1529
|
+
};
|
|
1530
|
+
}
|
|
1531
|
+
/**
|
|
1532
|
+
* Emit the D4 reauth alert once per reauth_required epoch. The event is
|
|
1533
|
+
* secret-free (diagnostic account ref only); a throwing sink never breaks
|
|
1534
|
+
* the custodian. A fresh `active` status clears the epoch.
|
|
1535
|
+
*/
|
|
1536
|
+
async emitReauthAlert(account, reason) {
|
|
1537
|
+
if (this.alertedReauth.has(account.accountId))
|
|
1538
|
+
return;
|
|
1539
|
+
this.alertedReauth.add(account.accountId);
|
|
1540
|
+
const sink = this.serviceOptions.reauthAlert;
|
|
1541
|
+
if (!sink)
|
|
1542
|
+
return;
|
|
1543
|
+
try {
|
|
1544
|
+
await sink.notifyReauthRequired(this.buildCustodyAlertEvent(account, reason));
|
|
1545
|
+
}
|
|
1546
|
+
catch {
|
|
1547
|
+
// Alert delivery never breaks custody; the status persists and the
|
|
1548
|
+
// next cycle retries the alert after clearing a stale epoch entry.
|
|
1549
|
+
this.alertedReauth.delete(account.accountId);
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
/**
|
|
1553
|
+
* Warn once per enrollment that a grant-less credential is about to
|
|
1554
|
+
* expire. Re-enrollment is the only rotation path, so the warning opens
|
|
1555
|
+
* the plannable window the expiry flip alone cannot give. It uses its own
|
|
1556
|
+
* epoch: the later `expired-no-grant` flip must still alert.
|
|
1557
|
+
*/
|
|
1558
|
+
async emitExpiringNoGrantWarning(account) {
|
|
1559
|
+
if (this.warnedExpiringNoGrant.has(account.accountId))
|
|
1560
|
+
return;
|
|
1561
|
+
this.warnedExpiringNoGrant.add(account.accountId);
|
|
1562
|
+
const sink = this.serviceOptions.reauthAlert;
|
|
1563
|
+
if (!sink)
|
|
1564
|
+
return;
|
|
1565
|
+
try {
|
|
1566
|
+
await sink.notifyReauthRequired(this.buildCustodyAlertEvent(account, 'expiring-no-grant'));
|
|
1567
|
+
}
|
|
1568
|
+
catch {
|
|
1569
|
+
// A throwing sink retries on the next cycle, like the reauth alert.
|
|
1570
|
+
this.warnedExpiringNoGrant.delete(account.accountId);
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
/**
|
|
1574
|
+
* Proactive custodian cycle (Lot 1): refresh every mesh-managed account
|
|
1575
|
+
* inside its skew window before expiry, flip `reauth_required` early on
|
|
1576
|
+
* definitive refresh failure and alert (D4). Only provably unconsumed
|
|
1577
|
+
* failures defer without flipping: explicit 429/5xx token-endpoint
|
|
1578
|
+
* responses and typed local preparation failures. Timeouts, resets,
|
|
1579
|
+
* mint/persistence failures after a possible rotation, and unknown shapes
|
|
1580
|
+
* flip fail-closed: the grant may already be consumed.
|
|
1581
|
+
*
|
|
1582
|
+
* Externally custodied seats never refresh here and are skipped; an
|
|
1583
|
+
* unknown or elapsed expiry reads reauth-required (it awaits the next
|
|
1584
|
+
* import) without flipping a status the custodian never owns. A
|
|
1585
|
+
* CLI-imported Muse credential carries no refresh grant yet IS renewable
|
|
1586
|
+
* (the mesh re-reads the CLI store), so it follows the grant path. Static
|
|
1587
|
+
* keys with no refresh grant (Vibe long-lived keys, Muse direct keys)
|
|
1588
|
+
* are skipped: their rotation path is re-enrollment, not refresh. They
|
|
1589
|
+
* warn once inside the refresh window (`expiring-no-grant`, with the
|
|
1590
|
+
* upcoming expiry in the report) and flip on expiry.
|
|
1591
|
+
*/
|
|
1592
|
+
async runCustodianRefreshCycle() {
|
|
1593
|
+
await this.restorePersistedAccounts();
|
|
1594
|
+
const now = this.nowMs();
|
|
1595
|
+
const policy = sanitizeCustodianRefreshPolicy(this.serviceOptions.refreshPolicy);
|
|
1596
|
+
const accounts = [];
|
|
1597
|
+
let refreshed = 0;
|
|
1598
|
+
for (const account of [...this.accountsMap.values()]) {
|
|
1599
|
+
const base = {
|
|
1600
|
+
accountRef: this.diagnosticAccountRef(account.accountId),
|
|
1601
|
+
transportProviderId: String(account.transportProviderId),
|
|
1602
|
+
ownerScopeRef: account.ownerScopeRef ?? '',
|
|
1603
|
+
};
|
|
1604
|
+
const expiredAt = typeof account.expiresAt === 'string' && account.expiresAt
|
|
1605
|
+
? Date.parse(account.expiresAt)
|
|
1606
|
+
: Number.NaN;
|
|
1607
|
+
const expired = Number.isFinite(expiredAt) && expiredAt <= now;
|
|
1608
|
+
if (isExternalSeatAccount(account)) {
|
|
1609
|
+
// Mirrors the route directory: an expired external projection reads
|
|
1610
|
+
// reauth-required (it awaits the next import), without flipping a
|
|
1611
|
+
// status the custodian never owns or alerting the owner. An unknown
|
|
1612
|
+
// expiry is not infinite validity: it reads reauth-required too.
|
|
1613
|
+
const unknownExpiry = !Number.isFinite(expiredAt);
|
|
1614
|
+
accounts.push({ ...base, action: 'skipped-external',
|
|
1615
|
+
readiness: expired || unknownExpiry ? 'reauth-required' : 'ready',
|
|
1616
|
+
...(expired ? { expiredAt: new Date(expiredAt).toISOString() } : {}) });
|
|
1617
|
+
continue;
|
|
1618
|
+
}
|
|
1619
|
+
if (account.status === 'reauth_required') {
|
|
1620
|
+
const reason = !account.refreshToken && expired
|
|
1621
|
+
? 'expired-no-grant'
|
|
1622
|
+
: 'refresh-failed';
|
|
1623
|
+
await this.emitReauthAlert(account, reason);
|
|
1624
|
+
accounts.push({ ...base, action: 'already-reauth', readiness: 'reauth-required',
|
|
1625
|
+
...(expired ? { expiredAt: new Date(expiredAt).toISOString() } : {}) });
|
|
1626
|
+
continue;
|
|
1627
|
+
}
|
|
1628
|
+
// A CLI-imported Muse credential carries no refresh grant, yet it IS
|
|
1629
|
+
// renewable: mesh refresh re-reads the CLI store and re-mints (same
|
|
1630
|
+
// wire as the acquire path). Only direct-billed Muse keys
|
|
1631
|
+
// (`auth_type: 'api_key'`, no CLI store) are truly grant-less.
|
|
1632
|
+
const metadata = account.metadata;
|
|
1633
|
+
const isMuseDirectKey = metadata?.['auth_type'] === 'api_key';
|
|
1634
|
+
const museCliRefreshable = account.transportProviderId === 'muse'
|
|
1635
|
+
&& !isMuseDirectKey && !isDeviceFlowSeatAccount(account);
|
|
1636
|
+
if (!account.refreshToken?.trim() && !museCliRefreshable) {
|
|
1637
|
+
// No renewable grant: an expired credential cannot recover on its
|
|
1638
|
+
// own, so it flips to reauth_required and alerts (planned
|
|
1639
|
+
// re-enrollment). An unexpired one stays ready and warns once inside
|
|
1640
|
+
// the refresh window, with the upcoming expiry exposed for planning.
|
|
1641
|
+
if (expired) {
|
|
1642
|
+
await this.markReauthRequired(account.accountId, 'expired-no-grant');
|
|
1643
|
+
accounts.push({ ...base, action: 'expired-no-grant', readiness: 'reauth-required',
|
|
1644
|
+
expiredAt: new Date(expiredAt).toISOString() });
|
|
1645
|
+
continue;
|
|
1646
|
+
}
|
|
1647
|
+
if (!Number.isFinite(expiredAt)) {
|
|
1648
|
+
// Grant-less and dateless: nothing can schedule or heal this
|
|
1649
|
+
// credential; it reads not-ready until re-enrollment records one.
|
|
1650
|
+
accounts.push({ ...base, action: 'skipped-no-expiry', readiness: 'reauth-required' });
|
|
1651
|
+
continue;
|
|
1652
|
+
}
|
|
1653
|
+
const upcomingExpiry = new Date(expiredAt).toISOString();
|
|
1654
|
+
accounts.push({ ...base, action: 'skipped-no-grant', readiness: 'ready',
|
|
1655
|
+
expiresAt: upcomingExpiry });
|
|
1656
|
+
const issuedAtMs = this.credentialIssuedAtMs.get(account.accountId);
|
|
1657
|
+
if (shouldRefreshProactively({ expiresAtMs: expiredAt,
|
|
1658
|
+
...(issuedAtMs !== undefined && Number.isFinite(issuedAtMs) ? { issuedAtMs } : {}),
|
|
1659
|
+
nowMs: now }, policy)) {
|
|
1660
|
+
await this.emitExpiringNoGrantWarning(account);
|
|
1661
|
+
}
|
|
1662
|
+
continue;
|
|
1663
|
+
}
|
|
1664
|
+
if (!Number.isFinite(expiredAt)) {
|
|
1665
|
+
// The custodian cannot schedule a renewal without an expiry; the
|
|
1666
|
+
// account reads not-ready until a successful acquire-time refresh
|
|
1667
|
+
// records one. No flip: routing still serves the live credential.
|
|
1668
|
+
accounts.push({ ...base, action: 'skipped-no-expiry', readiness: 'reauth-required' });
|
|
1669
|
+
continue;
|
|
1670
|
+
}
|
|
1671
|
+
const issuedAtMs = this.credentialIssuedAtMs.get(account.accountId);
|
|
1672
|
+
if (!shouldRefreshProactively({ expiresAtMs: expiredAt,
|
|
1673
|
+
...(issuedAtMs !== undefined && Number.isFinite(issuedAtMs) ? { issuedAtMs } : {}), nowMs: now }, policy)) {
|
|
1674
|
+
accounts.push({ ...base, action: 'skipped-healthy', readiness: 'ready' });
|
|
1675
|
+
continue;
|
|
1676
|
+
}
|
|
1677
|
+
if (this.refreshInFlight.has(account.accountId)) {
|
|
1678
|
+
// A refresh is already running for this account; report its pending
|
|
1679
|
+
// state instead of stacking another one or claiming it is healthy.
|
|
1680
|
+
accounts.push({ ...base, action: 'refresh-in-flight', readiness: 'ready' });
|
|
1681
|
+
continue;
|
|
1682
|
+
}
|
|
1683
|
+
try {
|
|
1684
|
+
const version = this.credentialVersions.get(account.accountId) ?? 'v1.0.0';
|
|
1685
|
+
await this.refreshToken({
|
|
1686
|
+
accountId: account.accountId,
|
|
1687
|
+
refreshToken: account.refreshToken ?? undefined,
|
|
1688
|
+
credentialVersion: version,
|
|
1689
|
+
});
|
|
1690
|
+
this.alertedReauth.delete(account.accountId);
|
|
1691
|
+
this.warnedExpiringNoGrant.delete(account.accountId);
|
|
1692
|
+
refreshed += 1;
|
|
1693
|
+
accounts.push({ ...base, action: 'refreshed', readiness: 'ready' });
|
|
1694
|
+
}
|
|
1695
|
+
catch (error) {
|
|
1696
|
+
// A deferred failure never flipped the status and never alerted: the
|
|
1697
|
+
// grant is still live, the next tick retries.
|
|
1698
|
+
if (error instanceof RefreshDeferredError) {
|
|
1699
|
+
accounts.push({ ...base, action: 'refresh-deferred-retry', readiness: 'ready',
|
|
1700
|
+
...(expired ? { expiredAt: new Date(expiredAt).toISOString() } : {}) });
|
|
1701
|
+
continue;
|
|
1702
|
+
}
|
|
1703
|
+
// refreshToken() already flipped the status and alerted; ensure the
|
|
1704
|
+
// alert epoch is recorded even when the persist raced. The widened
|
|
1705
|
+
// read defeats the early-continue narrowing above, by design.
|
|
1706
|
+
const statusAfterRefresh = account.status;
|
|
1707
|
+
if (statusAfterRefresh === 'reauth_required' && !this.alertedReauth.has(account.accountId)) {
|
|
1708
|
+
await this.emitReauthAlert(account, 'refresh-failed');
|
|
1709
|
+
}
|
|
1710
|
+
accounts.push({ ...base, action: 'refresh-failed-reauth', readiness: 'reauth-required',
|
|
1711
|
+
...(expired ? { expiredAt: new Date(expiredAt).toISOString() } : {}) });
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
return {
|
|
1715
|
+
at: new Date(now).toISOString(),
|
|
1716
|
+
checked: accounts.length,
|
|
1717
|
+
refreshed,
|
|
1718
|
+
reauthRequired: accounts.filter((entry) => entry.readiness === 'reauth-required').length,
|
|
1719
|
+
accounts,
|
|
1720
|
+
};
|
|
1721
|
+
}
|
|
1722
|
+
/**
|
|
1723
|
+
* Start the custodian loop: an immediate cycle, then one every `intervalMs`
|
|
1724
|
+
* with a small random stagger (two replicas started together must not fire
|
|
1725
|
+
* in lockstep on single-use refresh grants). Ticks never overlap; the timer
|
|
1726
|
+
* is unref'd so it cannot hold the process open.
|
|
1727
|
+
*
|
|
1728
|
+
* The host MUST run exactly one loop per owner scope: this library holds no
|
|
1729
|
+
* distributed lock, so two live loops would race on rotating grants
|
|
1730
|
+
* (multi-replica custody belongs to the Lot 2 host). Cycle and
|
|
1731
|
+
* report-listener failures surface through `onError`; without it a dead
|
|
1732
|
+
* keyring would tick silently.
|
|
1733
|
+
*
|
|
1734
|
+
* Returns a stop function; `stopCustodianLoop()` stops it as well.
|
|
1735
|
+
*/
|
|
1736
|
+
startCustodianLoop(intervalMs, onReport, onError) {
|
|
1737
|
+
this.stopCustodianLoop();
|
|
1738
|
+
const everyMs = Number.isFinite(intervalMs) && intervalMs > 0
|
|
1739
|
+
? Math.max(1_000, Math.floor(intervalMs))
|
|
1740
|
+
: 60_000;
|
|
1741
|
+
const generation = (this.custodianLoopGeneration += 1);
|
|
1742
|
+
const live = () => generation === this.custodianLoopGeneration;
|
|
1743
|
+
const tick = async () => {
|
|
1744
|
+
if (this.custodianTickInFlight)
|
|
1745
|
+
return;
|
|
1746
|
+
this.custodianTickInFlight = true;
|
|
1747
|
+
try {
|
|
1748
|
+
const report = await this.runCustodianRefreshCycle();
|
|
1749
|
+
try {
|
|
1750
|
+
onReport?.(report);
|
|
1751
|
+
}
|
|
1752
|
+
catch (error) {
|
|
1753
|
+
onError?.(error);
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
catch (error) {
|
|
1757
|
+
// A failed cycle never kills the loop; the next tick retries.
|
|
1758
|
+
onError?.(error);
|
|
1759
|
+
}
|
|
1760
|
+
finally {
|
|
1761
|
+
this.custodianTickInFlight = false;
|
|
1762
|
+
}
|
|
1763
|
+
};
|
|
1764
|
+
const staggerMs = () => everyMs + Math.floor(Math.random() * Math.min(everyMs * 0.1, 30_000));
|
|
1765
|
+
const schedule = (delayMs) => {
|
|
1766
|
+
const timer = setTimeout(() => {
|
|
1767
|
+
if (!live())
|
|
1768
|
+
return;
|
|
1769
|
+
void tick().finally(() => {
|
|
1770
|
+
if (live())
|
|
1771
|
+
schedule(staggerMs());
|
|
1772
|
+
});
|
|
1773
|
+
}, delayMs);
|
|
1774
|
+
timer.unref?.();
|
|
1775
|
+
this.custodianTimer = timer;
|
|
1776
|
+
};
|
|
1777
|
+
// Immediate first cycle: readiness is reported at boot, not after one
|
|
1778
|
+
// idle interval.
|
|
1779
|
+
void tick().finally(() => {
|
|
1780
|
+
if (live())
|
|
1781
|
+
schedule(staggerMs());
|
|
1782
|
+
});
|
|
1783
|
+
return () => this.stopCustodianLoop();
|
|
1784
|
+
}
|
|
1785
|
+
stopCustodianLoop() {
|
|
1786
|
+
this.custodianLoopGeneration += 1;
|
|
1787
|
+
if (this.custodianTimer) {
|
|
1788
|
+
clearTimeout(this.custodianTimer);
|
|
1789
|
+
this.custodianTimer = null;
|
|
1418
1790
|
}
|
|
1419
1791
|
}
|
|
1420
1792
|
}
|