impel-cli 0.20.45 → 0.20.46-beta.2

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/src/doctor.js CHANGED
@@ -10,6 +10,7 @@ const MAX_ERROR_BODY_BYTES = 64 * 1024;
10
10
  const MAX_OUTPUT_CHARS = 16 * 1024;
11
11
  const MAX_PENDING_SSE_CHARS = 1024 * 1024;
12
12
  const MAX_STREAM_BYTES = 4 * 1024 * 1024;
13
+ const CLAUDE_PROBE_MAX_TOKENS = 1_024;
13
14
  const GATEWAY_REQUEST_ID_RE = /^impel-[a-f0-9]{32}$/u;
14
15
  const POOL_STATES = new Set(["healthy", "exhausted", "rate_limited", "expired", "no_seat"]);
15
16
 
@@ -86,6 +87,12 @@ function consumeSseBlock(provider, block, state, startedAt, now) {
86
87
  if (!state.responseId && typeof event?.message?.id === "string") {
87
88
  state.responseId = event.message.id.slice(0, 512) || null;
88
89
  }
90
+ if (event?.type === "message_delta" && typeof event?.delta?.stop_reason === "string") {
91
+ state.stopReason = event.delta.stop_reason.slice(0, 100);
92
+ if (state.stopReason !== "end_turn") {
93
+ state.error ||= `provider stopped the response with reason ${redactSecretText(state.stopReason)}`;
94
+ }
95
+ }
89
96
  if (event?.type === "message_stop") state.terminalEvent = event.type;
90
97
  if (event?.type === "content_block_delta" && event?.delta?.type === "text_delta") {
91
98
  delta = typeof event.delta.text === "string" ? event.delta.text : "";
@@ -131,7 +138,11 @@ async function streamProbe({ provider, url, bearer, model, tenantId, attempt, ti
131
138
  const body = provider === "claude"
132
139
  ? {
133
140
  model,
134
- max_tokens: 96,
141
+ // Current reasoning-capable Claude models may spend part of this budget
142
+ // before emitting the tiny acknowledgement. Ninety-six tokens made a
143
+ // healthy route look broken when reasoning exhausted the response
144
+ // budget before the first text delta.
145
+ max_tokens: CLAUDE_PROBE_MAX_TOKENS,
135
146
  stream: true,
136
147
  messages: [{ role: "user", content: prompt }],
137
148
  }
@@ -192,6 +203,7 @@ async function streamProbe({ provider, url, bearer, model, tenantId, attempt, ti
192
203
  exactAck: false,
193
204
  outputChars: 0,
194
205
  outputTruncated: false,
206
+ stopReason: null,
195
207
  terminalEvent: null,
196
208
  doneSentinel: false,
197
209
  receivedBytes: 0,
@@ -204,6 +216,7 @@ async function streamProbe({ provider, url, bearer, model, tenantId, attempt, ti
204
216
  responseId: null,
205
217
  ttftMs: null,
206
218
  outputTruncated: false,
219
+ stopReason: null,
207
220
  terminalEvent: null,
208
221
  doneSentinel: false,
209
222
  error: null,
@@ -262,6 +275,7 @@ async function streamProbe({ provider, url, bearer, model, tenantId, attempt, ti
262
275
  exactAck,
263
276
  outputChars: state.answer.length,
264
277
  outputTruncated: state.outputTruncated,
278
+ stopReason: state.stopReason,
265
279
  terminalEvent: state.terminalEvent,
266
280
  doneSentinel: state.doneSentinel,
267
281
  receivedBytes,
@@ -283,6 +297,7 @@ async function streamProbe({ provider, url, bearer, model, tenantId, attempt, ti
283
297
  exactAck: false,
284
298
  outputChars: 0,
285
299
  outputTruncated: false,
300
+ stopReason: null,
286
301
  terminalEvent: null,
287
302
  doneSentinel: false,
288
303
  receivedBytes: 0,
@@ -488,6 +503,7 @@ export async function probeTenant({
488
503
  exactAck: false,
489
504
  outputChars: 0,
490
505
  outputTruncated: false,
506
+ stopReason: null,
491
507
  terminalEvent: null,
492
508
  doneSentinel: false,
493
509
  receivedBytes: 0,
@@ -0,0 +1,61 @@
1
+ // Process exit codes that mean something specific.
2
+ //
3
+ // Three values are already spoken for and must never be reused:
4
+ //
5
+ // 1 — `bin/impel.js`'s catch-all for any escaped throw. A dedicated code
6
+ // that collides with this is indistinguishable from a crash.
7
+ // 78 — `MANAGED_RUNTIME_PREFLIGHT_EXIT_CODE` (EX_CONFIG) in `src/apps.js`.
8
+ // 127 — the shell's "command not found"; a CLI that returns it is claiming
9
+ // it was never invoked.
10
+ //
11
+ // Everything else follows sysexits.h, so an operator reading a bare number
12
+ // gets the right intuition without a lookup.
13
+
14
+ /** Codes an Impel-specific exit code must not reuse. Asserted by tests. */
15
+ export const RESERVED_EXIT_CODES = Object.freeze([0, 1, 78, 127]);
16
+
17
+ /**
18
+ * `impel _telemetry flush` could not deliver its batch (EX_TEMPFAIL): the
19
+ * spool is intact and a later flush retries.
20
+ *
21
+ * Nothing reads this in production — the sender is detached with stdio
22
+ * ignored, and the durable drop notice is the real observability surface. It
23
+ * exists because the send is an external call, and Law 6 wants every failure
24
+ * branch to have a distinct non-zero outcome that a test and a human running
25
+ * the command by hand can both see.
26
+ */
27
+ export const TELEMETRY_FLUSH_FAILED_EXIT_CODE = 75;
28
+
29
+ /**
30
+ * A gated command refused because the feature-flag state could not be
31
+ * established (EX_UNAVAILABLE): no fresh evaluation, no cached one for this
32
+ * account, and no way to reach the control plane.
33
+ *
34
+ * Distinct from the catch-all 1 on purpose. "I could not find out whether you
35
+ * are allowed to run this" and "I crashed" are different states, and only the
36
+ * first one is worth retrying once the machine is back online.
37
+ */
38
+ export const FLAG_STATE_UNKNOWN_EXIT_CODE = 69;
39
+
40
+ /**
41
+ * A gated command refused because the flag is off for this account
42
+ * (EX_NOPERM): the state *was* established, and the answer was no.
43
+ *
44
+ * Separate from `FLAG_STATE_UNKNOWN_EXIT_CODE` because the two say opposite
45
+ * things about retrying. "Not enabled for you" is a settled answer — running it
46
+ * again changes nothing until someone grants access. "Could not find out" is
47
+ * worth retrying the moment the machine is back online. A script that treats
48
+ * both as one number has to guess which it got.
49
+ */
50
+ export const FEATURE_NOT_ENABLED_EXIT_CODE = 77;
51
+
52
+ /**
53
+ * `impel report` could not deliver the bug report and wrote the envelope to
54
+ * disk instead (EX_IOERR): the route was unreachable, absent, or rate-limited.
55
+ *
56
+ * The report is user-initiated, so unlike telemetry someone is watching this
57
+ * exit code. It says "your report was not lost, and it was not sent either" —
58
+ * a state that deserves its own number, because the printed spool path is the
59
+ * next action and 1 would read as a crash that produced nothing.
60
+ */
61
+ export const REPORT_SPOOLED_EXIT_CODE = 74;
@@ -0,0 +1,393 @@
1
+ // Remote feature flags for the CLI: a disk cache in front of
2
+ // `POST /api/cli/flags`, evaluated fail-closed.
3
+ //
4
+ // The only consumer today is the Cursor experiment gate, and that shapes every
5
+ // decision here. A gate exists so an experiment can be turned off remotely for
6
+ // one account; if a machine that cannot reach the control plane guessed a
7
+ // value, the guess would be wrong exactly when the switch is being thrown. So
8
+ // there is no default: `evaluateFlag` either returns an answer it can trace to
9
+ // a real server evaluation — fresh, cached, or stale — or it throws
10
+ // `FlagStateUnknownError` for the command to turn into a distinct exit code.
11
+ //
12
+ // Two timers, mirroring `src/updates.js`: `fetchedAt` decides whether the
13
+ // cached answer is still current (6h), and `lastFailedAt` suppresses re-probing
14
+ // after a failure (1h). Without the second one an offline machine would attempt
15
+ // a fetch on every single invocation of the gated command.
16
+ //
17
+ // The cache records the principal it was fetched for. A read under a different
18
+ // `{orgId, userId}` is a miss rather than a hit: serving the previous org's
19
+ // answers for up to six hours after a tenant or PAT switch would defeat a kill
20
+ // switch precisely when one org is being killed and another is not.
21
+ //
22
+ // Everything written here lives under `CONFIG_DIR`, so `impel nuke` erases it.
23
+
24
+ import fs from "node:fs";
25
+ import os from "node:os";
26
+ import path from "node:path";
27
+
28
+ import {
29
+ CONFIG_DIR,
30
+ loadConfig,
31
+ normalizeGatewayUrl,
32
+ resolveDefaultAppUrl,
33
+ } from "./config.js";
34
+ import { FLAG_STATE_UNKNOWN_EXIT_CODE } from "./exitCodes.js";
35
+ import { fetchHttp1 } from "./http1.js";
36
+ import { TELEMETRY_CONTRACT } from "./posthog.js";
37
+ import { RUNTIME_BRAND } from "./runtimeBrand.js";
38
+ import { managedMarkerPresent } from "./telemetryConsent.js";
39
+ import { renameWithWindowsRetry } from "./windowsFs.js";
40
+
41
+ /** The route this module posts to. */
42
+ export const FLAGS_ENDPOINT_PATH = "/api/cli/flags";
43
+
44
+ /** Beside `update-check.json`, and erased by the same `impel nuke` sweep. */
45
+ export const FLAG_CACHE_PATH = path.join(CONFIG_DIR, "flags.json");
46
+
47
+ /** How long an evaluation stays authoritative. The repo's cache default. */
48
+ export const FLAG_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
49
+
50
+ /**
51
+ * How long a failed check suppresses the next attempt. Shorter than the TTL:
52
+ * a failure should heal within the session, but must not turn an offline
53
+ * machine into one request per gated command.
54
+ */
55
+ export const FLAG_FETCH_BACKOFF_MS = 60 * 60 * 1000;
56
+
57
+ /**
58
+ * The flag the managed-Cursor experiment is gated on.
59
+ *
60
+ * Declared as a literal rather than read out of `TELEMETRY_CONTRACT` by index;
61
+ * `test/feature-flags.test.js` pins it against `featureFlagKeys` so the two
62
+ * cannot drift while keeping the reference here readable.
63
+ */
64
+ export const CURSOR_EXPERIMENT_FLAG = "cli-cursor-experiment";
65
+
66
+ /** Long enough for a cold control-plane response, short enough to be a gate. */
67
+ const REQUEST_TIMEOUT_MS = 10_000;
68
+
69
+ /** Flag keys are bounded by the server schema; anything longer is not ours. */
70
+ const MAX_FLAG_KEY_LENGTH = 128;
71
+
72
+ /**
73
+ * The flag state could not be established, and no cached answer applies.
74
+ *
75
+ * Thrown rather than returned so a caller cannot accidentally treat it as a
76
+ * value. Commands catch it and set `exitCode` on `process.exitCode`: the
77
+ * `experimental` throw path always exits 1, so an escaping throw would be
78
+ * indistinguishable from a crash (see `src/exitCodes.js`).
79
+ */
80
+ export class FlagStateUnknownError extends Error {
81
+ constructor(message, reason) {
82
+ super(message);
83
+ this.name = "FlagStateUnknownError";
84
+ /** Machine-readable cause; each maps to its own message. */
85
+ this.reason = reason;
86
+ this.exitCode = FLAG_STATE_UNKNOWN_EXIT_CODE;
87
+ }
88
+ }
89
+
90
+ /** Internal: a fetch that did not produce a usable evaluation. */
91
+ class FlagFetchError extends Error {
92
+ constructor(message, reason) {
93
+ super(message);
94
+ this.name = "FlagFetchError";
95
+ this.reason = reason;
96
+ }
97
+ }
98
+
99
+ /* -------------------------------------------------------------------------- */
100
+ /* Cache */
101
+ /* -------------------------------------------------------------------------- */
102
+
103
+ export function readFlagCache() {
104
+ try {
105
+ const cache = JSON.parse(fs.readFileSync(FLAG_CACHE_PATH, "utf8"));
106
+ return cache && typeof cache === "object" && !Array.isArray(cache) ? cache : null;
107
+ } catch {
108
+ return null;
109
+ }
110
+ }
111
+
112
+ /** Merge-and-replace, matching `writeUpdateCache`'s tmp+rename discipline. */
113
+ export function writeFlagCache(patch) {
114
+ const next = { ...(readFlagCache() || {}), ...patch };
115
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
116
+ const temporaryPath = `${FLAG_CACHE_PATH}.tmp-${process.pid}`;
117
+ try {
118
+ fs.writeFileSync(temporaryPath, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
119
+ renameWithWindowsRetry(temporaryPath, FLAG_CACHE_PATH);
120
+ } finally {
121
+ try {
122
+ fs.rmSync(temporaryPath, { force: true });
123
+ } catch {
124
+ // Cleanup must never mask the write/rename outcome.
125
+ }
126
+ }
127
+ return next;
128
+ }
129
+
130
+ /**
131
+ * Who the flags were evaluated for.
132
+ *
133
+ * `userId` comes from `config.user`, which U10 persists from the tenants
134
+ * response. Before that field exists it is `null` on both the write and the
135
+ * read, so the comparison stays consistent — an install that has never seen a
136
+ * `user` is one principal, not a permanent mismatch.
137
+ */
138
+ function flagPrincipal(config) {
139
+ return {
140
+ orgId: typeof config?.tenantId === "string" ? config.tenantId : null,
141
+ userId: typeof config?.user?.id === "string" ? config.user.id : null,
142
+ };
143
+ }
144
+
145
+ function samePrincipal(cache, principal) {
146
+ return (cache?.orgId ?? null) === principal.orgId
147
+ && (cache?.userId ?? null) === principal.userId;
148
+ }
149
+
150
+ /** The cached evaluation, or null when there is none *for this principal*. */
151
+ function cachedFlags(cache, principal) {
152
+ if (!cache || !samePrincipal(cache, principal)) return null;
153
+ const flags = cache.flags;
154
+ return flags && typeof flags === "object" && !Array.isArray(flags) ? flags : null;
155
+ }
156
+
157
+ function withinTtl(cache, now) {
158
+ return Number.isFinite(cache?.fetchedAt) && now - cache.fetchedAt < FLAG_CACHE_TTL_MS;
159
+ }
160
+
161
+ function backoffRemainingMs(cache, now) {
162
+ if (!Number.isFinite(cache?.lastFailedAt)) return 0;
163
+ return Math.max(0, cache.lastFailedAt + FLAG_FETCH_BACKOFF_MS - now);
164
+ }
165
+
166
+ /* -------------------------------------------------------------------------- */
167
+ /* Evaluation */
168
+ /* -------------------------------------------------------------------------- */
169
+
170
+ /**
171
+ * Read one flag out of an evaluation.
172
+ *
173
+ * A key the evaluation does not mention resolves to `enabled: false`. That is
174
+ * not a fabricated default: the server answered, and an answer that does not
175
+ * name the flag is an answer that the flag is not on for this principal. The
176
+ * fabricated case — no answer at all — throws instead. `known` lets a caller
177
+ * that cares tell the two apart.
178
+ */
179
+ function resolveFlag(name, flags, source) {
180
+ const flag = flags?.[name];
181
+ return {
182
+ name,
183
+ enabled: flag?.enabled === true,
184
+ variant: typeof flag?.variant === "string" ? flag.variant : null,
185
+ payload: flag && Object.hasOwn(flag, "payload") ? flag.payload : null,
186
+ known: Boolean(flag),
187
+ source,
188
+ };
189
+ }
190
+
191
+ /**
192
+ * Validate a response into the shape the cache stores, or return null.
193
+ *
194
+ * One malformed flag rejects the whole response rather than being skipped: a
195
+ * partially-read evaluation is exactly the "some flags computed, some not"
196
+ * state the route already refuses to send (`flags_unavailable`), and a kill
197
+ * switch dropped by a lenient parser reads as "not killed".
198
+ */
199
+ function normalizeFlagResponse(payload) {
200
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
201
+ const { flags } = payload;
202
+ if (!flags || typeof flags !== "object" || Array.isArray(flags)) return null;
203
+ const keys = Object.keys(flags);
204
+ if (keys.length > TELEMETRY_CONTRACT.limits.maxFlagsPerResponse) return null;
205
+
206
+ const normalized = {};
207
+ for (const key of keys) {
208
+ if (key.length === 0 || key.length > MAX_FLAG_KEY_LENGTH) return null;
209
+ const value = flags[key];
210
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
211
+ if (typeof value.enabled !== "boolean") return null;
212
+ normalized[key] = { enabled: value.enabled };
213
+ if (typeof value.variant === "string" && value.variant.length > 0) {
214
+ normalized[key].variant = value.variant;
215
+ }
216
+ if (value.payload !== undefined) normalized[key].payload = value.payload;
217
+ }
218
+ return {
219
+ flags: normalized,
220
+ evaluatedAt: typeof payload.evaluatedAt === "string" ? payload.evaluatedAt : null,
221
+ };
222
+ }
223
+
224
+ async function jsonBody(response) {
225
+ try {
226
+ return await response.json();
227
+ } catch {
228
+ return null;
229
+ }
230
+ }
231
+
232
+ /**
233
+ * One evaluation request.
234
+ *
235
+ * Server-supplied text is never echoed — only status codes and our own
236
+ * sentences — so no message can carry back something that needs redacting.
237
+ */
238
+ async function fetchFlags({ config, fetchImpl, timeoutMs }) {
239
+ const appUrl = normalizeGatewayUrl(config.appUrl || resolveDefaultAppUrl());
240
+ const controller = new AbortController();
241
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
242
+ let response;
243
+ try {
244
+ response = await fetchImpl(new URL(FLAGS_ENDPOINT_PATH, appUrl), {
245
+ method: "POST",
246
+ headers: {
247
+ accept: "application/json",
248
+ authorization: `Bearer ${config.pat}`,
249
+ "content-type": "application/json",
250
+ },
251
+ body: "{}",
252
+ signal: controller.signal,
253
+ });
254
+ } catch {
255
+ throw new FlagFetchError(
256
+ `could not reach ${appUrl} to check feature flags; reconnect and try again`,
257
+ "unreachable",
258
+ );
259
+ } finally {
260
+ clearTimeout(timeout);
261
+ }
262
+
263
+ if (!response.ok) {
264
+ // R16: a `next` deployment that predates the route. Named separately from
265
+ // every other failure because the remedy is a deployment, not a retry.
266
+ if (response.status === 404) {
267
+ throw new FlagFetchError(
268
+ `${appUrl} does not serve feature flags yet; update the ${RUNTIME_BRAND.product.displayName} control plane and try again`,
269
+ "absent_route",
270
+ );
271
+ }
272
+ const body = await jsonBody(response);
273
+ if (body?.code === "flags_unavailable") {
274
+ throw new FlagFetchError(
275
+ `${appUrl} cannot evaluate feature flags right now; try again in a few minutes`,
276
+ "unavailable",
277
+ );
278
+ }
279
+ throw new FlagFetchError(
280
+ `${appUrl} refused the feature-flag check (HTTP ${response.status}); try again later`,
281
+ "rejected",
282
+ );
283
+ }
284
+
285
+ const normalized = normalizeFlagResponse(await jsonBody(response));
286
+ if (!normalized) {
287
+ throw new FlagFetchError(
288
+ `${appUrl} returned a feature-flag response this CLI cannot read; update the CLI and try again`,
289
+ "malformed",
290
+ );
291
+ }
292
+ return normalized;
293
+ }
294
+
295
+ /**
296
+ * Why this process may not make the request, or null when it may.
297
+ *
298
+ * The managed-surface denial is the P1-8 egress boundary and is checked first:
299
+ * a managed vendor app profile may reach the gateway and nothing else, so a
300
+ * flag check from inside one presents as an unexpected call from a profile
301
+ * holding app credentials. Unlike analytics capture this is not a consent
302
+ * question — a kill switch that honors an opt-out is not a kill switch — but
303
+ * the context guard applies to every egress path without exception.
304
+ */
305
+ function fetchBlocked({ config, env, homeDir, cache, now }) {
306
+ if (managedMarkerPresent(env, homeDir)) {
307
+ return {
308
+ reason: "managed_surface",
309
+ message: "feature flags cannot be checked from inside a managed Impel app profile; run this from a terminal",
310
+ };
311
+ }
312
+ if (!config?.pat) {
313
+ return {
314
+ reason: "unauthenticated",
315
+ message: `not authenticated; run \`${RUNTIME_BRAND.cli.command} setup\` before using a gated feature`,
316
+ };
317
+ }
318
+ const remaining = backoffRemainingMs(cache, now);
319
+ if (remaining > 0) {
320
+ const minutes = Math.max(1, Math.ceil(remaining / 60_000));
321
+ return {
322
+ reason: "backoff",
323
+ message: `a recent feature-flag check failed and no cached answer applies to this account; try again in ${minutes} minute${minutes === 1 ? "" : "s"}`,
324
+ };
325
+ }
326
+ return null;
327
+ }
328
+
329
+ /**
330
+ * Resolve one flag for the current principal.
331
+ *
332
+ * The state machine, in the order the branches are taken:
333
+ *
334
+ * fresh cache -> use it, no request
335
+ * blocked (managed/no PAT/backoff) -> stale cache if there is one, else refuse
336
+ * fetch succeeds -> store and use
337
+ * fetch fails, cache exists -> stale value, failure stamped
338
+ * fetch fails, no cache -> refuse with that failure's own message
339
+ *
340
+ * "Cache" throughout means a cache written for *this* `{orgId, userId}`; one
341
+ * written for another principal is not a cache miss that degrades to stale, it
342
+ * is a miss that refuses.
343
+ */
344
+ export async function evaluateFlag(name, options = {}) {
345
+ const {
346
+ config = loadConfig(),
347
+ fetchImpl = fetchHttp1,
348
+ env = process.env,
349
+ homeDir = os.homedir(),
350
+ now = Date.now(),
351
+ timeoutMs = REQUEST_TIMEOUT_MS,
352
+ } = options;
353
+
354
+ const principal = flagPrincipal(config);
355
+ const cache = readFlagCache();
356
+ const cached = cachedFlags(cache, principal);
357
+ if (cached && withinTtl(cache, now)) return resolveFlag(name, cached, "cache");
358
+
359
+ const blocked = fetchBlocked({ config, env, homeDir, cache, now });
360
+ if (blocked) {
361
+ if (cached) return resolveFlag(name, cached, "stale");
362
+ throw new FlagStateUnknownError(blocked.message, blocked.reason);
363
+ }
364
+
365
+ let evaluation;
366
+ try {
367
+ evaluation = await fetchFlags({ config, fetchImpl, timeoutMs });
368
+ } catch (error) {
369
+ // Stamp the failure even when a stale answer covers this call: the point of
370
+ // the second timer is that the *next* invocation does not re-probe either.
371
+ try {
372
+ writeFlagCache({ lastFailedAt: now });
373
+ } catch {
374
+ // The stamp is a request-rate limiter, not required state.
375
+ }
376
+ if (cached) return resolveFlag(name, cached, "stale");
377
+ throw new FlagStateUnknownError(error.message, error.reason || "unreachable");
378
+ }
379
+
380
+ try {
381
+ writeFlagCache({
382
+ ...principal,
383
+ flags: evaluation.flags,
384
+ evaluatedAt: evaluation.evaluatedAt,
385
+ fetchedAt: now,
386
+ lastFailedAt: 0,
387
+ });
388
+ } catch {
389
+ // An unwritable config dir costs a cache, not an answer: this evaluation is
390
+ // authoritative and is returned either way.
391
+ }
392
+ return resolveFlag(name, evaluation.flags, "network");
393
+ }
@@ -1,4 +1,6 @@
1
1
  // Fleet generation shared by managed-profile writers and upstream clients.
2
2
  // Keep this isolated from apps.js so latency-sensitive transports do not load
3
3
  // desktop bundle machinery just to identify their managed config contract.
4
- export const CURRENT_CONFIG_VERSION = 37;
4
+ // v38 replaces the generated desktop MCP Apps host with the authenticated web
5
+ // board and removes the desktop-only Codex renderer flag.
6
+ export const CURRENT_CONFIG_VERSION = 38;