sneakoscope 10.7.0 → 10.7.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.
Files changed (66) hide show
  1. package/README.md +27 -5
  2. package/crates/sks-core/Cargo.lock +1 -1
  3. package/crates/sks-core/Cargo.toml +1 -1
  4. package/dist/cli/command-manifest-lite.js +1 -1
  5. package/dist/cli/install-helpers-codex-lb-config.js +55 -20
  6. package/dist/commands/bridge.js +14 -1
  7. package/dist/commands/codex.js +5 -1
  8. package/dist/commands/doctor-console.js +2 -0
  9. package/dist/commands/doctor.js +8 -9
  10. package/dist/config/skills-manifest.json +1 -1
  11. package/dist/core/agents/agent-role-config.js +6 -2
  12. package/dist/core/agents/sks-model-pin-hygiene.js +150 -0
  13. package/dist/core/codex/codex-config-guard.js +18 -13
  14. package/dist/core/codex-app/codex-app-ui-state-snapshot.js +7 -1
  15. package/dist/core/codex-app/menubar/constants.js +0 -1
  16. package/dist/core/codex-lb/bridge-contracts.js +5 -0
  17. package/dist/core/codex-lb/combined-catalog/builder.js +4 -2
  18. package/dist/core/codex-lb/combined-catalog/latest-generation.js +7 -34
  19. package/dist/core/codex-lb/combined-catalog/normalize.js +3 -1
  20. package/dist/core/codex-lb/combined-catalog/persistence.js +4 -0
  21. package/dist/core/codex-lb/combined-catalog/storage.js +4 -1
  22. package/dist/core/codex-lb/desktop-bridge/codex-lb-turn-state.js +35 -0
  23. package/dist/core/codex-lb/desktop-bridge/compaction-adapter.js +3 -0
  24. package/dist/core/codex-lb/desktop-bridge/header-policy.js +10 -4
  25. package/dist/core/codex-lb/desktop-bridge/http-forward.js +138 -36
  26. package/dist/core/codex-lb/desktop-bridge/models-endpoint.js +62 -0
  27. package/dist/core/codex-lb/desktop-bridge/provider-handoff.js +88 -0
  28. package/dist/core/codex-lb/desktop-bridge/provider-request-compat.js +89 -0
  29. package/dist/core/codex-lb/desktop-bridge/rejection-log.js +61 -0
  30. package/dist/core/codex-lb/desktop-bridge/responses-websocket.js +108 -16
  31. package/dist/core/codex-lb/desktop-bridge/security.js +101 -55
  32. package/dist/core/codex-lb/desktop-bridge/server.js +15 -2
  33. package/dist/core/codex-lb/desktop-bridge/types.js +3 -0
  34. package/dist/core/codex-lb/desktop-bridge/websocket-forward.js +3 -0
  35. package/dist/core/codex-lb/desktop-bridge-migration/historical-config.js +2 -6
  36. package/dist/core/codex-lb/desktop-bridge-migration.js +14 -2
  37. package/dist/core/codex-lb/desktop-controller-v3/catalog.js +125 -3
  38. package/dist/core/codex-lb/desktop-controller-v3/model-refresh.js +140 -0
  39. package/dist/core/codex-lb/desktop-controller-v3/openrouter-only-commands.js +5 -5
  40. package/dist/core/codex-lb/desktop-controller-v3/provider-commands.js +14 -11
  41. package/dist/core/codex-lb/desktop-controller-v3/status.js +2 -3
  42. package/dist/core/codex-lb/desktop-controller-v3.js +3 -1
  43. package/dist/core/codex-lb/desktop-service.js +51 -17
  44. package/dist/core/codex-lb/model-refresh-after.js +16 -0
  45. package/dist/core/codex-lb/request-route-resolver.js +1 -1
  46. package/dist/core/codex-runtime/codex-desktop-config-policy.js +0 -1
  47. package/dist/core/commands/basic-cli.js +5 -0
  48. package/dist/core/commands/menubar-command.js +5 -1
  49. package/dist/core/doctor/desktop-bridge-catalog-repair.js +20 -17
  50. package/dist/core/hooks-runtime/jev-spawn-routing.js +1 -5
  51. package/dist/core/hooks-runtime/parent-orchestration-gate.js +6 -8
  52. package/dist/core/hooks-runtime.js +13 -9
  53. package/dist/core/init/skills.js +22 -3
  54. package/dist/core/provider/provider-context.js +4 -1
  55. package/dist/core/search/context.js +2 -2
  56. package/dist/core/subagents/model-tiers.js +19 -3
  57. package/dist/core/update/update-migration-state/desktop-bridge-catalog-repair-stage.js +4 -3
  58. package/dist/core/update/update-migration-state.js +10 -40
  59. package/dist/core/version.js +1 -1
  60. package/dist/native/sks-menubar/Sources/ControlCenterWindowController.swift +0 -1
  61. package/dist/native/sks-menubar/Sources/OverviewSummary.swift +32 -0
  62. package/dist/native/sks-menubar/Sources/OverviewViewController.swift +15 -5
  63. package/dist/native/sks-menubar/Sources/SidebarItem.swift +0 -2
  64. package/dist/native/sks-menubar/Sources/UpdatesViewController.swift +3 -0
  65. package/package.json +2 -2
  66. package/dist/native/sks-menubar/Sources/RemoteCodingViewController.swift +0 -49
@@ -209,14 +209,45 @@ function canonicalPublicModel(value) {
209
209
  export function desktopBridgeOfficialPassthroughEnabled(config) {
210
210
  return Boolean(config.officialPassthrough?.baseUrl);
211
211
  }
212
- function officialRouteContext(publicModel, policy, upstreamModel) {
212
+ function mintSessionPin(sessionId, target, publicModel, policy, previous) {
213
+ const handoffFrom = previous && previous.provider_id !== target.provider_id ? previous.provider_id : previous?.handoff_from;
214
+ return {
215
+ thread_id: sessionId,
216
+ provider_id: target.provider_id,
217
+ public_model: publicModel,
218
+ upstream_model: target.upstream_model,
219
+ catalog_generation: policy.catalog_generation,
220
+ route_policy_generation: policy.policy_generation,
221
+ created_at: new Date().toISOString(),
222
+ ...(handoffFrom ? { handoff_from: handoffFrom } : {}),
223
+ };
224
+ }
225
+ function officialRouteContext(publicModel, policy, upstreamModel, pin = null) {
213
226
  return {
214
227
  provider_id: BRIDGE_OFFICIAL_ROUTE_ID,
215
228
  public_model: publicModel,
216
229
  upstream_model: upstreamModel || publicModel,
217
230
  catalog_generation: policy.catalog_generation,
218
231
  route_policy_generation: policy.policy_generation,
219
- session_pin: null,
232
+ session_pin: pin,
233
+ };
234
+ }
235
+ function pinnedRouteContext(pin, publicModel, policy) {
236
+ const aged = pin.catalog_generation !== policy.catalog_generation
237
+ || pin.route_policy_generation !== policy.policy_generation;
238
+ return {
239
+ provider_id: pin.provider_id,
240
+ public_model: publicModel,
241
+ upstream_model: pin.upstream_model,
242
+ catalog_generation: policy.catalog_generation,
243
+ route_policy_generation: policy.policy_generation,
244
+ session_pin: aged
245
+ ? {
246
+ ...pin,
247
+ catalog_generation: policy.catalog_generation,
248
+ route_policy_generation: policy.policy_generation,
249
+ }
250
+ : pin,
220
251
  };
221
252
  }
222
253
  export function resolveBridgeRequestRoute(request, policy, pins) {
@@ -224,43 +255,22 @@ export function resolveBridgeRequestRoute(request, policy, pins) {
224
255
  const sessionId = request.session_id ? canonicalSessionId(request.session_id) : null;
225
256
  const pin = sessionId ? pins.find((entry) => entry.thread_id === sessionId) || null : null;
226
257
  if (pin && pin.public_model === publicModel) {
258
+ if (pin.provider_id === BRIDGE_OFFICIAL_ROUTE_ID)
259
+ return pinnedRouteContext(pin, publicModel, policy);
227
260
  const bare = policy.model_routes[publicModel];
228
261
  const qualified = policy.model_routes[`${pin.provider_id}:${publicModel}`];
229
262
  const current = bare?.provider_id === pin.provider_id && bare.upstream_model === pin.upstream_model ? bare : qualified;
230
263
  if (!current || current.provider_id !== pin.provider_id || current.upstream_model !== pin.upstream_model) {
231
264
  throw new DesktopBridgeError('session_pin_route_unavailable');
232
265
  }
233
- const aged = pin.catalog_generation !== policy.catalog_generation
234
- || pin.route_policy_generation !== policy.policy_generation;
235
- return {
236
- provider_id: pin.provider_id,
237
- public_model: publicModel,
238
- upstream_model: pin.upstream_model,
239
- catalog_generation: policy.catalog_generation,
240
- route_policy_generation: policy.policy_generation,
241
- session_pin: aged
242
- ? {
243
- ...pin,
244
- catalog_generation: policy.catalog_generation,
245
- route_policy_generation: policy.policy_generation,
246
- }
247
- : pin,
248
- };
266
+ return pinnedRouteContext(pin, publicModel, policy);
249
267
  }
250
268
  const route = policy.model_routes[publicModel];
251
269
  if (!route)
252
270
  throw new DesktopBridgeError('catalog_model_route_missing');
271
+ const nextPin = sessionId ? mintSessionPin(sessionId, route, publicModel, policy, pin) : null;
253
272
  if (route.provider_id === BRIDGE_OFFICIAL_ROUTE_ID)
254
- return officialRouteContext(publicModel, policy, route.upstream_model);
255
- const nextPin = sessionId ? {
256
- thread_id: sessionId,
257
- provider_id: route.provider_id,
258
- public_model: publicModel,
259
- upstream_model: route.upstream_model,
260
- catalog_generation: policy.catalog_generation,
261
- route_policy_generation: policy.policy_generation,
262
- created_at: new Date().toISOString(),
263
- } : null;
273
+ return officialRouteContext(publicModel, policy, route.upstream_model, nextPin);
264
274
  return {
265
275
  provider_id: route.provider_id,
266
276
  public_model: publicModel,
@@ -270,7 +280,7 @@ export function resolveBridgeRequestRoute(request, policy, pins) {
270
280
  session_pin: nextPin,
271
281
  };
272
282
  }
273
- function threadPinProvider(request, config) {
283
+ function threadPin(request, pins) {
274
284
  if (!request.session_id)
275
285
  return null;
276
286
  let threadId;
@@ -280,7 +290,25 @@ function threadPinProvider(request, config) {
280
290
  catch {
281
291
  return null;
282
292
  }
283
- return config.providerSessionPins.find((pin) => pin.thread_id === threadId)?.provider_id ?? null;
293
+ return pins.find((pin) => pin.thread_id === threadId) ?? null;
294
+ }
295
+ function threadPinProvider(request, config) {
296
+ return threadPin(request, config.providerSessionPins)?.provider_id ?? null;
297
+ }
298
+ export function pinnedPublicModel(threadId, config) {
299
+ if (!threadId)
300
+ return null;
301
+ return threadPin({ session_id: threadId }, config.providerSessionPins)?.public_model ?? null;
302
+ }
303
+ const RESPONSES_PATH_PREFIXES = ['/backend-api/codex/responses', '/api/v1/responses', '/v1/responses'];
304
+ export function isResponsesFamilyPath(pathname) {
305
+ return RESPONSES_PATH_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));
306
+ }
307
+ export function threadRouteMove(request, route, config) {
308
+ if (!route.session_pin)
309
+ return null;
310
+ const previous = threadPin(request, config.providerSessionPins);
311
+ return previous && previous.provider_id !== route.provider_id ? { from: previous.provider_id, to: route.provider_id } : null;
284
312
  }
285
313
  export function assertDesktopBridgeRouteContext(request, config) {
286
314
  const resolver = config.resolveRequestRoute || resolveBridgeRequestRoute;
@@ -295,8 +323,16 @@ export function assertDesktopBridgeRouteContext(request, config) {
295
323
  && error.code === 'catalog_model_route_missing') {
296
324
  const model = canonicalizeBridgeModelId(request.public_model) || '';
297
325
  const live = model ? policy.model_routes[model] : undefined;
326
+ const pin = threadPin(request, config.providerSessionPins);
327
+ const responses = isResponsesFamilyPath(request.pathname);
328
+ if (pin && pin.provider_id !== BRIDGE_OFFICIAL_ROUTE_ID && responses) {
329
+ assertOpenRouterOnlyRoute(request, officialRouteContext(model, policy), config);
330
+ throw error;
331
+ }
298
332
  if (!live || live.provider_id === BRIDGE_OFFICIAL_ROUTE_ID) {
299
- route = officialRouteContext(model, policy, live?.upstream_model);
333
+ const sessionId = model && responses && request.session_id ? canonicalSessionId(request.session_id) : null;
334
+ const target = { provider_id: BRIDGE_OFFICIAL_ROUTE_ID, upstream_model: live?.upstream_model || model };
335
+ route = officialRouteContext(model, policy, live?.upstream_model, sessionId ? mintSessionPin(sessionId, target, model, policy, pin) : null);
300
336
  }
301
337
  else
302
338
  throw error;
@@ -315,8 +351,7 @@ export function assertDesktopBridgeRouteContext(request, config) {
315
351
  if (route.catalog_generation !== policy.catalog_generation || route.route_policy_generation !== policy.policy_generation) {
316
352
  throw new DesktopBridgeError('session_pin_route_unavailable');
317
353
  }
318
- if (route.session_pin)
319
- throw new DesktopBridgeError('bridge_session_pin_invalid');
354
+ assertRouteSessionPin(request, route, false);
320
355
  return route;
321
356
  }
322
357
  const existingPin = request.session_id ? config.providerSessionPins.find((pin) => pin.thread_id === request.session_id && pin.public_model === route.public_model
@@ -330,21 +365,7 @@ export function assertDesktopBridgeRouteContext(request, config) {
330
365
  if (route.catalog_generation !== policy.catalog_generation || route.route_policy_generation !== policy.policy_generation) {
331
366
  throw new DesktopBridgeError('session_pin_route_unavailable');
332
367
  }
333
- if (request.session_id) {
334
- const sessionId = canonicalSessionId(request.session_id);
335
- if (!route.session_pin
336
- || route.session_pin.thread_id !== sessionId
337
- || route.session_pin.provider_id !== route.provider_id
338
- || route.session_pin.public_model !== route.public_model
339
- || route.session_pin.upstream_model !== route.upstream_model
340
- || route.session_pin.catalog_generation !== route.catalog_generation
341
- || route.session_pin.route_policy_generation !== route.route_policy_generation) {
342
- throw new DesktopBridgeError('bridge_session_pin_invalid');
343
- }
344
- }
345
- else if (route.session_pin) {
346
- throw new DesktopBridgeError('bridge_session_pin_invalid');
347
- }
368
+ assertRouteSessionPin(request, route, true);
348
369
  const provider = config.providers[route.provider_id];
349
370
  if (!provider || !provider.enabled)
350
371
  throw new DesktopBridgeError('bridge_provider_route_unavailable');
@@ -358,7 +379,28 @@ export function assertDesktopBridgeRouteContext(request, config) {
358
379
  }
359
380
  return route;
360
381
  }
361
- export async function resolveAndBindDesktopBridgeRouteContext(request, config) {
382
+ function assertRouteSessionPin(request, route, required) {
383
+ if (!request.session_id) {
384
+ if (route.session_pin)
385
+ throw new DesktopBridgeError('bridge_session_pin_invalid');
386
+ return;
387
+ }
388
+ if (!route.session_pin) {
389
+ if (required)
390
+ throw new DesktopBridgeError('bridge_session_pin_invalid');
391
+ return;
392
+ }
393
+ const sessionId = canonicalSessionId(request.session_id);
394
+ if (route.session_pin.thread_id !== sessionId
395
+ || route.session_pin.provider_id !== route.provider_id
396
+ || route.session_pin.public_model !== route.public_model
397
+ || route.session_pin.upstream_model !== route.upstream_model
398
+ || route.session_pin.catalog_generation !== route.catalog_generation
399
+ || route.session_pin.route_policy_generation !== route.route_policy_generation) {
400
+ throw new DesktopBridgeError('bridge_session_pin_invalid');
401
+ }
402
+ }
403
+ export async function resolveAndBindDesktopBridgeRouteContext(request, config, beforeBind) {
362
404
  const pinsRead = config.providerSessionPins;
363
405
  let steady = null;
364
406
  try {
@@ -368,13 +410,14 @@ export async function resolveAndBindDesktopBridgeRouteContext(request, config) {
368
410
  steady = null;
369
411
  }
370
412
  const steadyPin = steady?.session_pin;
371
- if (steady && !steadyPin)
372
- return steady;
373
413
  const persisted = steadyPin ? pinsRead.find((entry) => entry.thread_id === steadyPin.thread_id) : undefined;
374
- if (steady && steadyPin && persisted && sameSessionPin(persisted, steadyPin))
414
+ if (steady && (!steadyPin || (persisted && sameSessionPin(persisted, steadyPin)))) {
415
+ beforeBind?.(steady);
375
416
  return steady;
417
+ }
376
418
  return withSessionPinMutation(config, async () => {
377
419
  const route = steady && config.providerSessionPins === pinsRead ? steady : assertDesktopBridgeRouteContext(request, config);
420
+ beforeBind?.(route);
378
421
  const pin = route.session_pin;
379
422
  if (!pin)
380
423
  return route;
@@ -418,7 +461,8 @@ function sameSessionPin(left, right) {
418
461
  && left.public_model === right.public_model
419
462
  && left.upstream_model === right.upstream_model
420
463
  && left.catalog_generation === right.catalog_generation
421
- && left.route_policy_generation === right.route_policy_generation;
464
+ && left.route_policy_generation === right.route_policy_generation
465
+ && left.handoff_from === right.handoff_from;
422
466
  }
423
467
  export function canonicalSessionId(value) {
424
468
  const sessionId = typeof value === 'string' ? value.trim() : '';
@@ -634,15 +678,17 @@ function assertRegistryAndPolicy(config, registry) {
634
678
  }
635
679
  }
636
680
  const pinIds = new Set();
681
+ const pinTargets = [...ids, BRIDGE_OFFICIAL_ROUTE_ID];
637
682
  for (const pin of config.providerSessionPins) {
638
683
  const keys = Object.keys(pin);
639
684
  if (keys.some((key) => ![
640
685
  'thread_id', 'provider_id', 'public_model', 'upstream_model',
641
- 'catalog_generation', 'route_policy_generation', 'created_at',
686
+ 'catalog_generation', 'route_policy_generation', 'created_at', 'handoff_from',
642
687
  ].includes(key))
643
688
  || canonicalSessionId(pin.thread_id) !== pin.thread_id
644
689
  || pinIds.has(pin.thread_id)
645
- || !ids.includes(pin.provider_id)
690
+ || !pinTargets.includes(pin.provider_id)
691
+ || (pin.handoff_from !== undefined && (!pinTargets.includes(pin.handoff_from) || pin.handoff_from === pin.provider_id))
646
692
  || canonicalizeBridgeModelId(pin.public_model) !== pin.public_model
647
693
  || normalizeBridgeUpstreamModelId(pin.upstream_model) !== pin.upstream_model
648
694
  || !pin.catalog_generation
@@ -6,10 +6,12 @@ import { createDesktopBridgeRejectionLogger } from './rejection-log.js';
6
6
  import { OPENROUTER_ONLY_REFUSAL_CODES } from './exclusive-provider-guard.js';
7
7
  import { assertAllowedOrigin, assertAllowedPath, assertLoopbackPeer, assertLoopbackListenHost, assertWebSocketUpgrade, prepareDesktopBridgeConfig, safeBridgeErrorCode, validatePreparedDesktopBridgeConfig, } from './security.js';
8
8
  import { createDesktopBridgePublicState, desktopBridgeListenOrigin, desktopBridgeStatePath, refreshDesktopBridgeState, removeDesktopBridgeStateIfOwned, writeDesktopBridgeState, } from './state.js';
9
- import { DesktopBridgeError, DesktopBridgeRequestBodyTooLargeError } from './types.js';
9
+ import { DesktopBridgeError, DesktopBridgeRequestBodyTooLargeError, isDesktopBridgeRoutingConflict } from './types.js';
10
+ import { ProviderHandoffRefusedError, providerHandoffRefusalBody } from './provider-handoff.js';
10
11
  import { PACKAGE_VERSION } from '../../version.js';
11
12
  import { DESKTOP_BRIDGE_CLIENT_PATH_PREFIX, DESKTOP_BRIDGE_DIAGNOSTIC_HEALTH_PATH, DESKTOP_BRIDGE_DIAGNOSTIC_PATH, DESKTOP_BRIDGE_DIAGNOSTIC_PROTOCOL, DESKTOP_BRIDGE_IMAGEGEN_PATH } from './types.js';
12
13
  import { handleDesktopBridgeImagegen } from './imagegen-endpoint.js';
14
+ import { answerDesktopBridgeModels, isDesktopBridgeModelsRequest } from './models-endpoint.js';
13
15
  import { forwardWebSocket, safeEndUpgradeSocket } from './websocket-forward.js';
14
16
  function authenticateDesktopBridgeClient(req, input) {
15
17
  const raw = String(req.url || '/');
@@ -94,7 +96,9 @@ function rejectionStatus(code) {
94
96
  return 404;
95
97
  if (code === 'bridge_request_capacity_exhausted')
96
98
  return 503;
97
- if (code === 'bridge_session_pin_persist_failed' || code.startsWith('bridge_upstream_'))
99
+ if (isDesktopBridgeRoutingConflict(code))
100
+ return 409;
101
+ if (code.startsWith('bridge_upstream_'))
98
102
  return 502;
99
103
  if (code.includes('origin') || code.includes('peer') || code.includes('loopback') || code.includes('capability'))
100
104
  return 403;
@@ -134,6 +138,11 @@ function writeBridgeRejection(res, error, req) {
134
138
  res.destroy(error instanceof Error ? error : undefined);
135
139
  return;
136
140
  }
141
+ if (error instanceof ProviderHandoffRefusedError) {
142
+ res.writeHead(400, { 'content-type': 'application/json', 'cache-control': 'no-store', connection: 'close' });
143
+ res.end(providerHandoffRefusalBody(error));
144
+ return;
145
+ }
137
146
  const code = rejectedCode;
138
147
  res.writeHead(rejectionStatus(code), {
139
148
  'content-type': 'application/json',
@@ -262,6 +271,10 @@ export async function startPreparedDesktopBridge(input, options = {}) {
262
271
  return;
263
272
  }
264
273
  assertAllowedPath(authenticated.pathname, input.allowedPathPrefixes);
274
+ if (input.modelsCatalog && isDesktopBridgeModelsRequest(req, authenticated.pathname)) {
275
+ answerDesktopBridgeModels(req, res, input.modelsCatalog);
276
+ return;
277
+ }
265
278
  const prepared = await prepareDesktopBridgeRequest(req, input);
266
279
  await forwardHttp(req, res, input, prepared, `${desktopBridgeListenOrigin(input)}${authenticated.clientBasePath}`);
267
280
  }
@@ -22,6 +22,9 @@ export class DesktopBridgeError extends Error {
22
22
  this.code = code;
23
23
  }
24
24
  }
25
+ export function isDesktopBridgeRoutingConflict(code) {
26
+ return code.startsWith('catalog_') || code.startsWith('session_') || code.includes('route_');
27
+ }
25
28
  export const DEFAULT_DESKTOP_BRIDGE_MAX_REQUEST_BODY_BYTES = 128 * 1024 * 1024;
26
29
  export class DesktopBridgeRequestBodyTooLargeError extends DesktopBridgeError {
27
30
  maximumBytes;
@@ -9,6 +9,7 @@ import { OPENROUTER_ONLY_REFUSAL_CODES, openRouterOnlyEnabled } from './exclusiv
9
9
  import { desktopBridgeOfficialPassthroughEnabled, ensureDesktopBridgeRemoteTarget, isUnreachableUpstreamError, refreshDesktopBridgeRemoteTarget, resolveAndBindDesktopBridgeRouteContext, resolveCodexSessionIdentity, resolveDesktopBridgeTarget, safeBridgeErrorCode, singleBridgeHeader, canonicalSessionId } from './security.js';
10
10
  import { desktopBridgeListenOrigin } from './state.js';
11
11
  import { forwardResponsesWebSocket, isResponsesWebSocketRequest } from './responses-websocket.js';
12
+ import { admitCodexLbTurnState } from './codex-lb-turn-state.js';
12
13
  import { DESKTOP_BRIDGE_DIAGNOSTIC_PROTOCOL, DesktopBridgeError, } from './types.js';
13
14
  const MAX_HEAD = 64 * 1024;
14
15
  const REDACTED_RESPONSE_HEADERS = new Set(['authorization', 'cookie', 'proxy-authenticate', 'proxy-authorization', 'set-cookie', 'x-api-key', 'x-codex-lb-api-key']);
@@ -203,6 +204,8 @@ export async function forwardWebSocket(req, client, head, config, authenticatedL
203
204
  const headers = official
204
205
  ? buildOfficialPassthroughWebSocketHeaders(req.headers, target.host)
205
206
  : buildProviderWebSocketHeaders(req.headers, { providerId: provider.provider_id, authTransport: provider.auth_transport, credential: credential }, target.host);
207
+ if (provider?.provider_id === 'codex-lb')
208
+ admitCodexLbTurnState(headers, route.session_pin?.thread_id ?? null, credential.fingerprint);
206
209
  upstream.write([`${req.method || 'GET'} ${target.pathname}${target.search} HTTP/1.1`, ...serializeHeaders(headers), '', ''].join('\r\n'));
207
210
  if (head.length)
208
211
  upstream.write(head);
@@ -1,5 +1,5 @@
1
1
  import path from 'node:path';
2
- import { DESKTOP_BRIDGE_MANAGED_BASE_URL_MARKER, DESKTOP_BRIDGE_MANAGED_MARKER, DESKTOP_BRIDGE_MANAGED_MODEL_CATALOG_MARKER } from '../../../cli/install-helpers-codex-lb-config.js';
2
+ import { DESKTOP_BRIDGE_MANAGED_BASE_URL_MARKER, DESKTOP_BRIDGE_MANAGED_MARKER } from '../../../cli/install-helpers-codex-lb-config.js';
3
3
  import { escapeRegExp } from '../../text/regex.js';
4
4
  import { CANONICAL_OPENROUTER_ENDPOINT, HISTORICAL_DESKTOP_BRIDGE_MARKER, HISTORICAL_DESKTOP_COMPAT_MARKER, HISTORICAL_MODEL_CATALOG_MARKER, HISTORICAL_OAUTH_SELECTION_MARKER, HISTORICAL_OPENAI_ROUTING_MARKER, HISTORICAL_PROVIDER_MODE_MARKER, HISTORICAL_PROVIDER_SELECTION_MARKER, hasTomlTable, hasTopLevelMarker, isHistoricalProviderMode, secretFreeProviderEndpoint, tomlTableBoolean, tomlTableContainsCustomHeader, tomlTableString, topLevelTomlString } from './historical-toml.js';
5
5
  export function inspectHistoricalDesktopBridgeIntent(configText) {
@@ -122,11 +122,7 @@ export function inspectHistoricalDesktopBridgeIntent(configText) {
122
122
  }
123
123
  export function prepareHistoricalConfigForDesktopBridgeWriter(text, state) {
124
124
  const source = String(text || '');
125
- if ([
126
- DESKTOP_BRIDGE_MANAGED_MARKER,
127
- DESKTOP_BRIDGE_MANAGED_BASE_URL_MARKER,
128
- DESKTOP_BRIDGE_MANAGED_MODEL_CATALOG_MARKER
129
- ].every((marker) => hasTopLevelMarker(source, marker)))
125
+ if ([DESKTOP_BRIDGE_MANAGED_MARKER, DESKTOP_BRIDGE_MANAGED_BASE_URL_MARKER].every((marker) => hasTopLevelMarker(source, marker)))
130
126
  return source;
131
127
  const historicalMarkers = historicalManagedMarkers();
132
128
  const hasHistoricalMarker = historicalMarkers.some((marker) => hasTopLevelMarker(source, marker));
@@ -55,7 +55,8 @@ async function migrateDesktopBridgeConfigUnlocked(input) {
55
55
  const migrationInputConfig = prepareHistoricalConfigForDesktopBridgeWriter(currentConfig, historicalState);
56
56
  nextConfig = upsertDesktopBridgeManagedConfig(migrationInputConfig, {
57
57
  bridgeBaseUrl: input.bridgeBaseUrl,
58
- combinedCatalogPath
58
+ combinedCatalogPath,
59
+ bindModelCatalog: input.bindModelCatalog !== false
59
60
  });
60
61
  validateMetadataUpdates(input.metadataUpdates || [], { home, configPath, authPath });
61
62
  }
@@ -74,6 +75,17 @@ async function migrateDesktopBridgeConfigUnlocked(input) {
74
75
  error: message
75
76
  };
76
77
  }
78
+ if (input.configWrite === 'forbid' && nextConfig !== currentConfig) {
79
+ return {
80
+ ...baseResult,
81
+ ok: false,
82
+ status: 'blocked',
83
+ managed_runtime: null,
84
+ receipt_path: null,
85
+ auth_semantic_identity_preserved: true,
86
+ blockers: ['desktop_bridge_config_write_deferred']
87
+ };
88
+ }
77
89
  const metadataUpdates = normalizedMetadataUpdates(input.metadataUpdates || []);
78
90
  const metadataAlreadyCurrent = await Promise.all(metadataUpdates.map(async (update) => {
79
91
  const currentSha = await fileSha256OrMissing(update.path);
@@ -82,7 +94,7 @@ async function migrateDesktopBridgeConfigUnlocked(input) {
82
94
  const hasUnifiedMarkers = [
83
95
  DESKTOP_BRIDGE_MANAGED_MARKER,
84
96
  DESKTOP_BRIDGE_MANAGED_BASE_URL_MARKER,
85
- DESKTOP_BRIDGE_MANAGED_MODEL_CATALOG_MARKER
97
+ ...(input.bindModelCatalog === false ? [] : [DESKTOP_BRIDGE_MANAGED_MODEL_CATALOG_MARKER])
86
98
  ].every((marker) => hasTopLevelMarker(currentConfig, marker));
87
99
  if (nextConfig === currentConfig && hasUnifiedMarkers && metadataAlreadyCurrent.every(Boolean)) {
88
100
  const authAfter = await captureCodexAuthSnapshot({ home, authPath });
@@ -1,4 +1,6 @@
1
1
  import { readText, writeJsonAtomic } from '../../fsx.js';
2
+ import { codexReadsBridgeModels, hasUserOwnedModelCatalog, repairManagedCodexLbClientSelection } from '../../../cli/install-helpers-codex-lb-config.js';
3
+ import { safeWriteCodexConfigToml } from '../../codex-runtime/codex-desktop-config-policy.js';
2
4
  import { readTopLevelTomlString } from '../../codex-app/codex-model-catalog.js';
3
5
  import { codexRestartBlockers, maybeRestartRunningCodexApp } from '../../codex-app/codex-app-restart-policy.js';
4
6
  import { BRIDGE_MODEL_SELECTION_SCHEMA, MAX_SELECTED_OPENROUTER_MODELS, bridgeAvailableModelsPath, pruneSelection, readBridgeModelSelectionState, writeBridgeModelSelection } from '../combined-catalog/model-selection.js';
@@ -16,6 +18,7 @@ import { sha256Stable } from '../route-index.js';
16
18
  import { bridgeBaseUrl, commandResult, controllerEnv, controllerPaths, nowIso, providerCode, providerRegistrySnapshot, resolveRawCredentials, resolveValidatedCredentials, safeCode, serializedSettings, stringArray, timeoutMs, unique } from './shared.js';
17
19
  import { desktopBridgeStatusV3 } from './status.js';
18
20
  import { openRouterOnlyExposure, readControllerOpenRouterOnlyState, withoutCodexLbCatalog } from './openrouter-only-catalog.js';
21
+ import { MODEL_REFRESH_RECOVERY_COMMAND, alignCodexModelSource, bridgeServesModels, listedModelSlugs, modelListDiff, modelRefreshLine, readModelRefreshState, refreshedRecently, writeModelRefreshState } from './model-refresh.js';
19
22
  export async function syncCatalog(options) {
20
23
  const result = await syncCatalogInternal(options);
21
24
  const status = await desktopBridgeStatusV3(options);
@@ -24,9 +27,21 @@ export async function syncCatalog(options) {
24
27
  : {};
25
28
  return commandResult('catalog.sync', result.ok === true, status, { catalog_sync: result }, result.ok === true ? [] : stringArray(activation.blockers), options);
26
29
  }
30
+ async function repairCodexLbClientSelection(configPath) {
31
+ const current = await readText(configPath, '');
32
+ const next = repairManagedCodexLbClientSelection(current);
33
+ if (next === current)
34
+ return null;
35
+ const write = await safeWriteCodexConfigToml(configPath, current, next, 'codex-lb-client-provider-repair', { verifyUnchangedBeforeWrite: true });
36
+ return write.ok ? null : `codex_lb_client_provider_repair_refused:${write.status}`;
37
+ }
27
38
  export async function syncCatalogInternal(options, behavior = {}) {
28
39
  const paths = controllerPaths(options);
29
- const historicalIntent = inspectHistoricalDesktopBridgeIntent(await readText(paths.configPath, ''));
40
+ const repair = behavior.configWrites === false ? null : await repairCodexLbClientSelection(paths.configPath);
41
+ if (repair)
42
+ return blockedSync(inspectHistoricalDesktopBridgeIntent(''), [repair]);
43
+ const configText = await readText(paths.configPath, '');
44
+ const historicalIntent = inspectHistoricalDesktopBridgeIntent(configText);
30
45
  if (historicalIntent.blockers.length > 0)
31
46
  return blockedSync(historicalIntent, historicalIntent.blockers);
32
47
  const rawCredentials = await resolveRawCredentials(options, paths);
@@ -55,6 +70,10 @@ export async function syncCatalogInternal(options, behavior = {}) {
55
70
  ? openRouterOnlyExposure(selection, openRouterOnly, await readText(paths.configPath, ''))
56
71
  : selection;
57
72
  const build = buildCombinedBridgeCatalog(catalogRegistry(registry), { catalogs, created_at: nowIso(options), selection: exposure });
73
+ const lost = providersLostToFailedFetch(await readActiveCombinedBridgeCatalog(paths.catalogPath, paths.routeIndexPath), catalogs, catalogRegistry(registry));
74
+ if (lost.length > 0) {
75
+ return { ...blockedSync(historicalIntent, lost.map((providerId) => `${providerCode(providerId)}_catalog_refresh_failed`)), build, active_generation_preserved: true };
76
+ }
58
77
  if (!selectionState.stored)
59
78
  await writeBridgeModelSelection(paths.home, selection).catch(() => undefined);
60
79
  await writeJsonAtomic(bridgeAvailableModelsPath(paths.home), {
@@ -126,6 +145,8 @@ export async function syncCatalogInternal(options, behavior = {}) {
126
145
  receiptDir: paths.receiptDir,
127
146
  bridgeBaseUrl: managedBridgeBaseUrl,
128
147
  combinedCatalogPath: staging.catalog_path,
148
+ bindModelCatalog: !codexReadsBridgeModels(configText) && !hasUserOwnedModelCatalog(configText),
149
+ configWrite: behavior.configWrites === false ? 'forbid' : 'allow',
129
150
  newCatalogGeneration: build.catalog.generation,
130
151
  metadataUpdates: [
131
152
  {
@@ -303,6 +324,20 @@ function emptyProviderCatalog(providerId, state, checkedAt, blockers) {
303
324
  warnings: []
304
325
  };
305
326
  }
327
+ function providersLostToFailedFetch(active, catalogs, registry) {
328
+ if (!active.ok)
329
+ return [];
330
+ return ['codex-lb', 'openrouter'].filter((providerId) => {
331
+ const blockers = catalogs[providerId].blockers || [];
332
+ return registry.profiles[providerId].enabled
333
+ && catalogs[providerId].state === 'failed'
334
+ && !blockers.some(refusesCredential)
335
+ && active.catalog.models.some((model) => model.provider_id === providerId);
336
+ });
337
+ }
338
+ function refusesCredential(blocker) {
339
+ return /unauthorized|credential_missing|key_missing|invalid_key|_http_40[13]\b/.test(blocker);
340
+ }
306
341
  function validationFailureState(blockers) {
307
342
  return blockers.some((blocker) => /(?:401|403|auth|rejected|invalid_key|unauthorized)/i.test(blocker))
308
343
  ? 'rejected' : 'unavailable';
@@ -349,12 +384,14 @@ export async function selectExposedModels(publicIds, options) {
349
384
  updated_at: nowIso(options),
350
385
  openrouter: { mode: 'selected', public_ids: requested }
351
386
  });
352
- const sync = await syncCatalogInternal(options);
387
+ const sync = await syncAsRefresh(options, 'models.select');
353
388
  const status = await desktopBridgeStatusV3(options);
354
389
  const activation = sync.activation && typeof sync.activation === 'object'
355
390
  ? sync.activation
356
391
  : {};
357
- const changed = before.selection.openrouter.public_ids.join('\0') !== requested.join('\0');
392
+ const refresh = sync.model_refresh;
393
+ const changed = before.selection.openrouter.public_ids.join('\0') !== requested.join('\0')
394
+ && (refresh.codex_model_source !== 'bridge_models' || refresh.restart_codex_required);
358
395
  const restart = sync.ok === true
359
396
  ? await maybeRestartRunningCodexApp({
360
397
  env: controllerEnv(options),
@@ -374,3 +411,88 @@ function seedSelectionFromConfiguredModel(selection, configText) {
374
411
  return selection;
375
412
  return { ...selection, openrouter: { mode: 'selected', public_ids: [configured] } };
376
413
  }
414
+ export async function refreshModelCatalog(options, input) {
415
+ const paths = controllerPaths(options);
416
+ const refreshedAt = nowIso(options);
417
+ const base = {
418
+ schema: 'sks.bridge-model-refresh.v1',
419
+ reason: input.reason,
420
+ refreshed_at: refreshedAt,
421
+ added: [],
422
+ removed: [],
423
+ restart_codex_required: false
424
+ };
425
+ try {
426
+ const registryGeneration = (await resolveBridgeProviderRegistry({
427
+ home: paths.home,
428
+ credentials: await resolveRawCredentials(options, paths)
429
+ })).generation;
430
+ const recent = input.force ? null : await readModelRefreshState(paths.home);
431
+ if (recent && refreshedRecently(recent, registryGeneration, Date.parse(refreshedAt))) {
432
+ return {
433
+ ...base, ok: true, skipped: true, generation: recent.generation, total: recent.total, codex_model_source: null,
434
+ line: `Model list refreshed recently: ${recent.total} models generation ${recent.generation.slice(0, 8)}`,
435
+ warnings: ['model_refresh_skipped_recent'], blockers: [], sync: null
436
+ };
437
+ }
438
+ const before = await readActiveCombinedBridgeCatalog(paths.catalogPath, paths.routeIndexPath);
439
+ const sync = await syncCatalogInternal(options, { ...(input.behavior || {}), configWrites: input.allowConfigWrites });
440
+ if (sync.ok !== true)
441
+ return refreshFailed(base, stringArray(sync.activation?.blockers), sync);
442
+ const build = sync.build;
443
+ const activation = sync.activation;
444
+ const diff = modelListDiff(listedModelSlugs(before.ok ? before.catalog : null), listedModelSlugs(build.catalog));
445
+ const source = await alignCodexModelSource({
446
+ home: paths.home,
447
+ configPath: paths.configPath,
448
+ authPath: paths.authPath,
449
+ catalog: build.catalog,
450
+ catalogPath: typeof activation.catalog_path === 'string' ? activation.catalog_path : null,
451
+ allowConfigWrites: input.allowConfigWrites,
452
+ selfCheck: options.modelsSelfCheckImpl || bridgeServesModels
453
+ });
454
+ const restart = source.changed || (source.source === 'model_catalog_json' && diff.added.length + diff.removed.length > 0);
455
+ const total = listedModelSlugs(build.catalog).length;
456
+ await writeModelRefreshState(paths.home, {
457
+ refreshed_at: refreshedAt,
458
+ provider_registry_generation: registryGeneration,
459
+ generation: build.catalog.generation,
460
+ total
461
+ }).catch(() => undefined);
462
+ return {
463
+ ...base, ok: true, skipped: false, generation: build.catalog.generation, total, added: diff.added, removed: diff.removed,
464
+ codex_model_source: source.source, restart_codex_required: restart,
465
+ line: modelRefreshLine({ total, added: diff.added.length, removed: diff.removed.length, generation: build.catalog.generation, restartCodex: restart }),
466
+ warnings: source.warnings, blockers: [], sync
467
+ };
468
+ }
469
+ catch (error) {
470
+ return refreshFailed(base, [safeCode(error, 'model_refresh_failed')], null);
471
+ }
472
+ }
473
+ function refreshFailed(base, blockers, sync) {
474
+ return {
475
+ ...base, ok: false, skipped: false, generation: null, total: 0, codex_model_source: null,
476
+ line: `Model list refresh failed; the previous list stays active. Run \`${MODEL_REFRESH_RECOVERY_COMMAND}\` to retry.`,
477
+ warnings: [`model_refresh_failed: run \`${MODEL_REFRESH_RECOVERY_COMMAND}\``],
478
+ blockers: unique(blockers.length > 0 ? blockers : ['model_refresh_failed']),
479
+ sync
480
+ };
481
+ }
482
+ export async function refreshModels(request, options) {
483
+ const refresh = await refreshModelCatalog(options, {
484
+ reason: request.reason || 'cli',
485
+ force: request.force !== false,
486
+ allowConfigWrites: request.allow_config_writes !== false
487
+ });
488
+ const { sync, ...model_refresh } = refresh;
489
+ const status = await desktopBridgeStatusV3(options);
490
+ return commandResult('models.refresh', refresh.ok, status, { model_refresh, catalog_sync: sync }, refresh.blockers, options);
491
+ }
492
+ export async function syncAsRefresh(options, reason, behavior = {}) {
493
+ const { sync, ...model_refresh } = await refreshModelCatalog(options, { reason, force: true, allowConfigWrites: true, behavior });
494
+ return {
495
+ ...(sync ?? blockedSync(null, model_refresh.blockers)),
496
+ model_refresh
497
+ };
498
+ }