@runuai/host 0.9.113 → 0.9.115

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.
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Token broker core (ADR-135, docs/token-broker.md).
3
+ *
4
+ * One cycle: list the credentials a storage adapter holds, pick the lane for
5
+ * each, decide whether a refresh is due (expiry lead, cadence, backoff,
6
+ * terminal state, or forced by the reactive path), perform at most one
7
+ * refresh per credential, and persist the outcome with the broker-owned
8
+ * `RefreshMeta`. The same function runs inside the host process (local
9
+ * storage) and inside the broker Lambda (Parameter Store storage). It never
10
+ * logs material; the audit line is name, lane, outcome, timing.
11
+ */
12
+
13
+ import { TOKEN_LANES } from "./token-lanes/registry";
14
+ import {
15
+ emptyRefreshMeta,
16
+ type CredentialFiles,
17
+ type CredentialHeader,
18
+ type LaneHttp,
19
+ type LaneHttpResponse,
20
+ type RefreshMeta,
21
+ type TokenLane,
22
+ } from "./token-lanes/types";
23
+
24
+ export interface BrokerCredential {
25
+ /** Storage-specific identity (a parameter name, or a local dir path). */
26
+ name: string;
27
+ header: CredentialHeader;
28
+ files: CredentialFiles;
29
+ refresh: RefreshMeta | null;
30
+ }
31
+
32
+ export interface BrokerStorage {
33
+ list(): Promise<BrokerCredential[]>;
34
+ /** Persist new material and/or new meta. `files` is null when only the
35
+ * meta changed (a failed attempt, a terminal answer). */
36
+ save(credential: BrokerCredential, files: CredentialFiles | null, refresh: RefreshMeta): Promise<void>;
37
+ }
38
+
39
+ export interface BrokerCycleOptions {
40
+ storage: BrokerStorage;
41
+ lanes?: readonly TokenLane[];
42
+ http?: LaneHttp;
43
+ now?: () => number;
44
+ /** Credential names to refresh regardless of schedule (reactive path). */
45
+ force?: ReadonlySet<string>;
46
+ /** Audit sink; one line per decision. Never carries material. */
47
+ log?: (line: string) => void;
48
+ /** Reactive dedupe: skip a forced refresh when the last successful one is
49
+ * younger than this (a burst from many tasks). */
50
+ forceDedupeMs?: number;
51
+ }
52
+
53
+ export interface BrokerCycleSummary {
54
+ checked: number;
55
+ due: number;
56
+ refreshed: number;
57
+ failed: number;
58
+ terminal: number;
59
+ }
60
+
61
+ /** Retry spacing after failed attempts: 5 min, 30 min, 2 h, then every 6 h. */
62
+ export function backoffMs(attempts: number): number {
63
+ if (attempts <= 0) return 0;
64
+ if (attempts === 1) return 5 * 60_000;
65
+ if (attempts === 2) return 30 * 60_000;
66
+ if (attempts === 3) return 2 * 60 * 60_000;
67
+ return 6 * 60 * 60_000;
68
+ }
69
+
70
+ export function isRefreshDue(
71
+ lane: TokenLane,
72
+ files: CredentialFiles,
73
+ meta: RefreshMeta | null,
74
+ now: number,
75
+ forced = false,
76
+ ): boolean {
77
+ if (meta?.needsSignIn) return false;
78
+ if (forced) return true;
79
+ if (meta && meta.attempts > 0 && meta.lastAttemptAt !== null) {
80
+ if (now - meta.lastAttemptAt < backoffMs(meta.attempts)) return false;
81
+ }
82
+ const expiresAt = lane.expiresAt(files) ?? meta?.expiresAt ?? null;
83
+ if (expiresAt !== null && lane.schedule.leadMs !== undefined) {
84
+ return expiresAt - now <= lane.schedule.leadMs;
85
+ }
86
+ if (lane.schedule.everyMs !== undefined) {
87
+ return now - (meta?.refreshedAt ?? 0) >= lane.schedule.everyMs;
88
+ }
89
+ return false;
90
+ }
91
+
92
+ /**
93
+ * An http client that refuses every URL outside `allowed`. Built per lane so
94
+ * a poisoned credential value can never steer the broker elsewhere.
95
+ */
96
+ export function createLaneHttp(
97
+ allowed: readonly string[],
98
+ fetchImpl: typeof fetch = fetch,
99
+ timeoutMs = 20_000,
100
+ ): LaneHttp {
101
+ const guard = (url: string): void => {
102
+ if (!allowed.includes(url)) throw new Error(`lane may not call ${url}`);
103
+ };
104
+ const send = async (url: string, headers: Record<string, string>, body: string): Promise<LaneHttpResponse> => {
105
+ guard(url);
106
+ const controller = new AbortController();
107
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
108
+ try {
109
+ const res = await fetchImpl(url, {
110
+ method: "POST",
111
+ headers: { accept: "application/json", ...headers },
112
+ body,
113
+ signal: controller.signal,
114
+ redirect: "error",
115
+ });
116
+ let json: unknown = null;
117
+ try {
118
+ json = await res.json();
119
+ } catch {
120
+ json = null;
121
+ }
122
+ return { status: res.status, json };
123
+ } finally {
124
+ clearTimeout(timer);
125
+ }
126
+ };
127
+ return {
128
+ postJson: (url, body) =>
129
+ send(url, { "content-type": "application/json" }, JSON.stringify(body)),
130
+ postForm: (url, form) =>
131
+ send(
132
+ url,
133
+ { "content-type": "application/x-www-form-urlencoded" },
134
+ new URLSearchParams(form).toString(),
135
+ ),
136
+ };
137
+ }
138
+
139
+ export async function runBrokerCycle(opts: BrokerCycleOptions): Promise<BrokerCycleSummary> {
140
+ const lanes = opts.lanes ?? TOKEN_LANES;
141
+ const now = opts.now ?? Date.now;
142
+ const log = opts.log ?? (() => undefined);
143
+ const forceDedupeMs = opts.forceDedupeMs ?? 60_000;
144
+ const summary: BrokerCycleSummary = { checked: 0, due: 0, refreshed: 0, failed: 0, terminal: 0 };
145
+ const credentials = await opts.storage.list();
146
+ for (const credential of credentials) {
147
+ const lane = lanes.find((l) => l.matches(credential.header, credential.files)) ?? null;
148
+ if (!lane) continue;
149
+ summary.checked += 1;
150
+ const at = now();
151
+ const meta = credential.refresh?.lane === lane.id ? credential.refresh : null;
152
+ let forced = opts.force?.has(credential.name) === true;
153
+ if (forced && meta?.refreshedAt !== null && meta?.refreshedAt !== undefined && at - meta.refreshedAt < forceDedupeMs) {
154
+ log(`skip ${credential.name} lane=${lane.id} reason=just-refreshed`);
155
+ forced = false;
156
+ if (!isRefreshDue(lane, credential.files, meta, at, false)) continue;
157
+ }
158
+ if (!isRefreshDue(lane, credential.files, meta, at, forced)) continue;
159
+ summary.due += 1;
160
+ const http = opts.http ?? createLaneHttp(lane.endpoints);
161
+ const started = at;
162
+ const outcome = await lane.refresh(credential.files, http, at);
163
+ const finished = now();
164
+ const base = meta ?? emptyRefreshMeta(lane.id);
165
+ if (outcome.ok) {
166
+ summary.refreshed += 1;
167
+ await opts.storage.save(credential, outcome.files, {
168
+ ...base,
169
+ expiresAt: outcome.expiresAt ?? lane.expiresAt(outcome.files),
170
+ refreshedAt: finished,
171
+ attempts: 0,
172
+ lastAttemptAt: finished,
173
+ lastError: null,
174
+ needsSignIn: false,
175
+ });
176
+ log(
177
+ `refreshed ${credential.name} lane=${lane.id} ms=${finished - started} expiresAt=${
178
+ outcome.expiresAt ? new Date(outcome.expiresAt).toISOString() : "unknown"
179
+ }`,
180
+ );
181
+ continue;
182
+ }
183
+ if (outcome.terminal) {
184
+ summary.terminal += 1;
185
+ await opts.storage.save(credential, null, {
186
+ ...base,
187
+ attempts: base.attempts + 1,
188
+ lastAttemptAt: finished,
189
+ lastError: outcome.reason,
190
+ needsSignIn: true,
191
+ });
192
+ log(`terminal ${credential.name} lane=${lane.id} reason=${outcome.reason} → needs sign-in`);
193
+ continue;
194
+ }
195
+ summary.failed += 1;
196
+ await opts.storage.save(credential, null, {
197
+ ...base,
198
+ attempts: base.attempts + 1,
199
+ lastAttemptAt: finished,
200
+ lastError: outcome.reason,
201
+ });
202
+ log(
203
+ `failed ${credential.name} lane=${lane.id} attempt=${base.attempts + 1} reason=${outcome.reason} retryIn=${Math.round(backoffMs(base.attempts + 1) / 60_000)}m`,
204
+ );
205
+ }
206
+ return summary;
207
+ }
208
+
209
+ /** The copy a task may receive for a credential, or the files unchanged when
210
+ * no lane owns them (static credentials). */
211
+ export function brokeredFilesFor(
212
+ header: CredentialHeader,
213
+ files: CredentialFiles,
214
+ lanes: readonly TokenLane[] = TOKEN_LANES,
215
+ ): CredentialFiles {
216
+ const lane = lanes.find((l) => l.matches(header, files));
217
+ return lane ? lane.brokeredCopy(files) : files;
218
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Lane `openai-oauth` — Codex and opencode ChatGPT sign-ins (`auth.json`,
3
+ * `auth_mode: chatgpt`, `tokens: { access_token (JWT ~10 days), refresh_token
4
+ * (single-use, rotating), id_token, account_id }`).
5
+ *
6
+ * Refresh is `grant_type=refresh_token` against OpenAI's OIDC token endpoint
7
+ * with Codex's public client (no secret) — the same call `codex login` makes.
8
+ * Facts and the 2026-07-28 incident: docs/token-broker.md, PR #45.
9
+ */
10
+
11
+ import {
12
+ BROKER_REFRESH_PLACEHOLDER,
13
+ isTerminalOAuthError,
14
+ jwtExpiryMs,
15
+ parseObjectFile,
16
+ type CredentialFiles,
17
+ type CredentialHeader,
18
+ type LaneHttp,
19
+ type RefreshOutcome,
20
+ type TokenLane,
21
+ } from "./types";
22
+
23
+ export const OPENAI_TOKEN_ENDPOINT = "https://auth.openai.com/oauth/token";
24
+ export const CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
25
+ const DAY_MS = 24 * 60 * 60 * 1000;
26
+
27
+ function tokensOf(files: CredentialFiles): {
28
+ doc: Record<string, unknown>;
29
+ tokens: Record<string, unknown>;
30
+ } | null {
31
+ const doc = parseObjectFile(files["auth.json"]);
32
+ if (!doc) return null;
33
+ const tokens = doc.tokens;
34
+ if (!tokens || typeof tokens !== "object" || Array.isArray(tokens)) return null;
35
+ return { doc, tokens: tokens as Record<string, unknown> };
36
+ }
37
+
38
+ export const openaiOAuthLane: TokenLane = {
39
+ id: "openai-oauth",
40
+ endpoints: [OPENAI_TOKEN_ENDPOINT],
41
+ schedule: { leadMs: 2 * DAY_MS },
42
+ reactiveSignals: [
43
+ /refresh token was revoked/i,
44
+ /refresh_token_reused/i,
45
+ /invalid_grant/i,
46
+ /log out and sign in again/i,
47
+ ],
48
+
49
+ matches(header: CredentialHeader, files: CredentialFiles): boolean {
50
+ if (header.authKind !== "config-dir") return false;
51
+ if (header.kind !== "codex" && header.kind !== "opencode") return false;
52
+ const t = tokensOf(files);
53
+ // API-key mode has no tokens block; nothing rotates.
54
+ return t !== null && typeof t.tokens.refresh_token === "string";
55
+ },
56
+
57
+ expiresAt(files: CredentialFiles): number | null {
58
+ const t = tokensOf(files);
59
+ return t ? jwtExpiryMs(t.tokens.access_token) : null;
60
+ },
61
+
62
+ brokeredCopy(files: CredentialFiles): CredentialFiles {
63
+ const t = tokensOf(files);
64
+ if (!t || typeof t.tokens.refresh_token !== "string") return files;
65
+ if (t.tokens.refresh_token === BROKER_REFRESH_PLACEHOLDER) return files;
66
+ t.tokens.refresh_token = BROKER_REFRESH_PLACEHOLDER;
67
+ return { ...files, "auth.json": `${JSON.stringify(t.doc, null, 2)}\n` };
68
+ },
69
+
70
+ async refresh(files: CredentialFiles, http: LaneHttp, now: number): Promise<RefreshOutcome> {
71
+ const t = tokensOf(files);
72
+ const current = t?.tokens.refresh_token;
73
+ if (!t || typeof current !== "string" || current === BROKER_REFRESH_PLACEHOLDER) {
74
+ return { ok: false, terminal: true, reason: "no refresh token to use" };
75
+ }
76
+ let res;
77
+ try {
78
+ res = await http.postJson(OPENAI_TOKEN_ENDPOINT, {
79
+ client_id: CODEX_CLIENT_ID,
80
+ grant_type: "refresh_token",
81
+ refresh_token: current,
82
+ scope: "openid profile email",
83
+ });
84
+ } catch (err) {
85
+ return {
86
+ ok: false,
87
+ terminal: false,
88
+ reason: `request failed: ${err instanceof Error ? err.message : String(err)}`,
89
+ };
90
+ }
91
+ if (res.status < 200 || res.status >= 300) {
92
+ return {
93
+ ok: false,
94
+ terminal: isTerminalOAuthError(res.status, res.json),
95
+ reason: `HTTP ${res.status}`,
96
+ };
97
+ }
98
+ const body =
99
+ res.json && typeof res.json === "object" && !Array.isArray(res.json)
100
+ ? (res.json as Record<string, unknown>)
101
+ : null;
102
+ const access = body?.access_token;
103
+ const refresh = body?.refresh_token;
104
+ // Require BOTH before touching the credential — a partial answer must not
105
+ // half-rotate it.
106
+ if (typeof access !== "string" || typeof refresh !== "string") {
107
+ return { ok: false, terminal: false, reason: "response missing access_token/refresh_token" };
108
+ }
109
+ t.tokens.access_token = access;
110
+ t.tokens.refresh_token = refresh;
111
+ if (typeof body?.id_token === "string") t.tokens.id_token = body.id_token;
112
+ t.doc.last_refresh = new Date(now).toISOString();
113
+ return {
114
+ ok: true,
115
+ files: { ...files, "auth.json": `${JSON.stringify(t.doc, null, 2)}\n` },
116
+ expiresAt: jwtExpiryMs(access),
117
+ };
118
+ },
119
+ };
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The lane registry (ADR-135). Add a provider here and nowhere else; the
3
+ * host loop, the brokered-copy staging, the reactive matcher, and the broker
4
+ * Lambda all read this list.
5
+ */
6
+
7
+ import { openaiOAuthLane } from "./openai-oauth";
8
+ import { xaiOAuthLane } from "./xai-oauth";
9
+ import type { CredentialFiles, CredentialHeader, TokenLane } from "./types";
10
+
11
+ export const TOKEN_LANES: readonly TokenLane[] = [openaiOAuthLane, xaiOAuthLane];
12
+
13
+ /** The lane that owns a credential, or null for static credentials. */
14
+ export function laneFor(
15
+ header: CredentialHeader,
16
+ files: CredentialFiles,
17
+ lanes: readonly TokenLane[] = TOKEN_LANES,
18
+ ): TokenLane | null {
19
+ for (const lane of lanes) {
20
+ if (lane.matches(header, files)) return lane;
21
+ }
22
+ return null;
23
+ }
24
+
25
+ export function laneById(id: string, lanes: readonly TokenLane[] = TOKEN_LANES): TokenLane | null {
26
+ return lanes.find((lane) => lane.id === id) ?? null;
27
+ }
28
+
29
+ /** Lanes that can own credentials of an engine kind (for reactive matching
30
+ * before the material is known). */
31
+ export function lanesForKind(kind: string, lanes: readonly TokenLane[] = TOKEN_LANES): TokenLane[] {
32
+ return lanes.filter((lane) =>
33
+ lane.matches({ kind, authKind: "config-dir", id: "probe", scope: "host" }, PROBE_FILES[kind] ?? {}),
34
+ );
35
+ }
36
+
37
+ // Minimal material per kind that makes `matches` answer on kind alone.
38
+ const PROBE_FILES: Record<string, CredentialFiles> = {
39
+ codex: { "auth.json": JSON.stringify({ tokens: { refresh_token: "x" } }) },
40
+ opencode: { "auth.json": JSON.stringify({ tokens: { refresh_token: "x" } }) },
41
+ grok: { "auth.json": JSON.stringify({ "https://auth.x.ai::c": { refresh_token: "x" } }) },
42
+ };
43
+
44
+ export { BROKER_REFRESH_PLACEHOLDER } from "./types";
45
+ export type { CredentialFiles, CredentialHeader, TokenLane } from "./types";
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Token lanes (ADR-135, docs/token-broker.md).
3
+ *
4
+ * A lane is everything the broker needs to know about ONE kind of rotating
5
+ * credential: which stored credentials it owns, the only endpoints it may
6
+ * talk to, how to read expiry, when to refresh, how to perform one refresh,
7
+ * what a task is allowed to receive (the brokered copy), and which engine
8
+ * error strings mean "the access token just died". The host loop and the
9
+ * broker Lambda load the same registry; a new provider is a new lane file.
10
+ *
11
+ * Lanes never log material. Everything they return is written by the broker
12
+ * core; nothing here touches storage or the network directly.
13
+ */
14
+
15
+ /** Identity of a stored credential (the engine-account envelope fields). */
16
+ export interface CredentialHeader {
17
+ kind: string;
18
+ authKind: "env" | "config-dir";
19
+ id: string;
20
+ scope: string;
21
+ }
22
+
23
+ /** A config-dir credential's files by basename (today: `auth.json`). */
24
+ export type CredentialFiles = Record<string, string>;
25
+
26
+ /** The broker-owned block stored beside the credential (`refresh` in the
27
+ * store value, `uai-broker.json` beside a local dir). */
28
+ export interface RefreshMeta {
29
+ lane: string;
30
+ expiresAt: number | null;
31
+ refreshedAt: number | null;
32
+ attempts: number;
33
+ lastAttemptAt: number | null;
34
+ lastError: string | null;
35
+ /** Terminal: the provider said the refresh token is dead. The broker stops;
36
+ * the Engines page says "Sign in again"; the wizard replaces the credential. */
37
+ needsSignIn: boolean;
38
+ }
39
+
40
+ export type RefreshOutcome =
41
+ | { ok: true; files: CredentialFiles; expiresAt: number | null }
42
+ | { ok: false; terminal: true; reason: string }
43
+ | { ok: false; terminal: false; reason: string };
44
+
45
+ export interface LaneHttpResponse {
46
+ status: number;
47
+ /** Parsed JSON body, or null when the body was not JSON. */
48
+ json: unknown;
49
+ }
50
+
51
+ /** The only network a lane gets. The broker core builds it with the lane's
52
+ * endpoint allowlist; any other URL throws before a byte is sent. */
53
+ export interface LaneHttp {
54
+ postJson(url: string, body: Record<string, unknown>): Promise<LaneHttpResponse>;
55
+ postForm(url: string, form: Record<string, string>): Promise<LaneHttpResponse>;
56
+ }
57
+
58
+ export interface LaneSchedule {
59
+ /** Refresh this long before the known expiry. */
60
+ leadMs?: number;
61
+ /** Refresh on this cadence when expiry cannot be read. */
62
+ everyMs?: number;
63
+ }
64
+
65
+ export interface TokenLane {
66
+ /** Stable id, stored in `RefreshMeta.lane`. */
67
+ readonly id: string;
68
+ /** Whether this lane owns a credential with this header and material. */
69
+ matches(header: CredentialHeader, files: CredentialFiles): boolean;
70
+ /** Every URL the lane may POST to. Enforced by the broker's http client. */
71
+ readonly endpoints: readonly string[];
72
+ /** When the access token expires (ms epoch), or null when unreadable. */
73
+ expiresAt(files: CredentialFiles): number | null;
74
+ readonly schedule: LaneSchedule;
75
+ /** Perform ONE refresh. Never writes; the core persists the outcome. */
76
+ refresh(files: CredentialFiles, http: LaneHttp, now: number): Promise<RefreshOutcome>;
77
+ /** The copy a task receives: access token kept, refresh token replaced.
78
+ * Returns the input unchanged when there is nothing rotating in it. */
79
+ brokeredCopy(files: CredentialFiles): CredentialFiles;
80
+ /** Engine output that means the access token is dead now (reactive path). */
81
+ readonly reactiveSignals: readonly RegExp[];
82
+ }
83
+
84
+ /**
85
+ * What replaces a refresh token in a task's copy. A non-token string: a
86
+ * container that tries to rotate gets a benign `invalid_grant`, never a
87
+ * family revocation. Codex requires the FIELD to exist (deleting it fails with
88
+ * `missing field refresh_token`; validated 2026-07-28), hence replace, not
89
+ * remove.
90
+ */
91
+ export const BROKER_REFRESH_PLACEHOLDER = "uai-broker-managed-no-refresh";
92
+
93
+ export function emptyRefreshMeta(lane: string): RefreshMeta {
94
+ return {
95
+ lane,
96
+ expiresAt: null,
97
+ refreshedAt: null,
98
+ attempts: 0,
99
+ lastAttemptAt: null,
100
+ lastError: null,
101
+ needsSignIn: false,
102
+ };
103
+ }
104
+
105
+ /** Parse a stored `refresh` block; null when absent or malformed. */
106
+ export function refreshMetaFrom(value: unknown): RefreshMeta | null {
107
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
108
+ const o = value as Record<string, unknown>;
109
+ if (typeof o.lane !== "string") return null;
110
+ const numOrNull = (v: unknown): number | null =>
111
+ typeof v === "number" && Number.isFinite(v) ? v : null;
112
+ return {
113
+ lane: o.lane,
114
+ expiresAt: numOrNull(o.expiresAt),
115
+ refreshedAt: numOrNull(o.refreshedAt),
116
+ attempts: typeof o.attempts === "number" && o.attempts >= 0 ? Math.floor(o.attempts) : 0,
117
+ lastAttemptAt: numOrNull(o.lastAttemptAt),
118
+ lastError: typeof o.lastError === "string" ? o.lastError : null,
119
+ needsSignIn: o.needsSignIn === true,
120
+ };
121
+ }
122
+
123
+ /** `exp` (ms epoch) of a JWT access token, or null when it is not one. */
124
+ export function jwtExpiryMs(token: unknown): number | null {
125
+ if (typeof token !== "string") return null;
126
+ const parts = token.split(".");
127
+ if (parts.length < 2 || !parts[1]) return null;
128
+ try {
129
+ const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as {
130
+ exp?: unknown;
131
+ };
132
+ return typeof payload.exp === "number" && Number.isFinite(payload.exp)
133
+ ? payload.exp * 1000
134
+ : null;
135
+ } catch {
136
+ return null;
137
+ }
138
+ }
139
+
140
+ /** Parse a JSON object file; null for anything that is not a plain object. */
141
+ export function parseObjectFile(text: string | undefined): Record<string, unknown> | null {
142
+ if (typeof text !== "string") return null;
143
+ try {
144
+ const parsed: unknown = JSON.parse(text);
145
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
146
+ return parsed as Record<string, unknown>;
147
+ } catch {
148
+ return null;
149
+ }
150
+ }
151
+
152
+ /** Provider answers that mean the refresh token itself is dead. */
153
+ export function isTerminalOAuthError(status: number, json: unknown): boolean {
154
+ const error =
155
+ json && typeof json === "object" && !Array.isArray(json)
156
+ ? (json as Record<string, unknown>).error
157
+ : undefined;
158
+ const text = typeof error === "string" ? error : "";
159
+ return (
160
+ /invalid_grant|refresh_token_reused|revoked|unauthorized_client|invalid_client/i.test(text) ||
161
+ ((status === 400 || status === 401 || status === 403) && text.length > 0)
162
+ );
163
+ }