sneakoscope 10.7.1 → 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 (43) hide show
  1. package/README.md +1 -1
  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 +44 -12
  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/codex-app/menubar/constants.js +0 -1
  12. package/dist/core/codex-lb/combined-catalog/persistence.js +4 -0
  13. package/dist/core/codex-lb/combined-catalog/storage.js +4 -1
  14. package/dist/core/codex-lb/desktop-bridge/http-forward.js +48 -8
  15. package/dist/core/codex-lb/desktop-bridge/models-endpoint.js +62 -0
  16. package/dist/core/codex-lb/desktop-bridge/provider-handoff.js +13 -4
  17. package/dist/core/codex-lb/desktop-bridge/provider-request-compat.js +89 -0
  18. package/dist/core/codex-lb/desktop-bridge/rejection-log.js +61 -0
  19. package/dist/core/codex-lb/desktop-bridge/responses-websocket.js +52 -4
  20. package/dist/core/codex-lb/desktop-bridge/server.js +5 -0
  21. package/dist/core/codex-lb/desktop-bridge-migration/historical-config.js +2 -6
  22. package/dist/core/codex-lb/desktop-bridge-migration.js +14 -2
  23. package/dist/core/codex-lb/desktop-controller-v3/catalog.js +114 -5
  24. package/dist/core/codex-lb/desktop-controller-v3/model-refresh.js +140 -0
  25. package/dist/core/codex-lb/desktop-controller-v3/openrouter-only-commands.js +5 -5
  26. package/dist/core/codex-lb/desktop-controller-v3/provider-commands.js +14 -2
  27. package/dist/core/codex-lb/desktop-controller-v3/status.js +2 -3
  28. package/dist/core/codex-lb/desktop-controller-v3.js +3 -1
  29. package/dist/core/codex-lb/desktop-service.js +41 -4
  30. package/dist/core/codex-lb/model-refresh-after.js +16 -0
  31. package/dist/core/commands/basic-cli.js +5 -0
  32. package/dist/core/commands/menubar-command.js +5 -1
  33. package/dist/core/doctor/desktop-bridge-catalog-repair.js +20 -17
  34. package/dist/core/subagents/model-tiers.js +19 -3
  35. package/dist/core/update/update-migration-state/desktop-bridge-catalog-repair-stage.js +4 -3
  36. package/dist/core/version.js +1 -1
  37. package/dist/native/sks-menubar/Sources/ControlCenterWindowController.swift +0 -1
  38. package/dist/native/sks-menubar/Sources/OverviewSummary.swift +32 -0
  39. package/dist/native/sks-menubar/Sources/OverviewViewController.swift +15 -5
  40. package/dist/native/sks-menubar/Sources/SidebarItem.swift +0 -2
  41. package/dist/native/sks-menubar/Sources/UpdatesViewController.swift +3 -0
  42. package/package.json +1 -1
  43. package/dist/native/sks-menubar/Sources/RemoteCodingViewController.swift +0 -49
@@ -2,6 +2,58 @@ import { PACKAGE_VERSION } from '../../version.js';
2
2
  export const DESKTOP_BRIDGE_LOG_SCHEMA = 'sks.desktop-bridge-log.v2';
3
3
  export const REJECTION_LOG_BURST = 5;
4
4
  export const REJECTION_LOG_SUMMARY_INTERVAL_MS = 60_000;
5
+ const SHAPE_TOKEN = /^[A-Za-z0-9_.[\]-]{1,64}$/;
6
+ const MAX_SHAPE_KEYS = 64;
7
+ const MAX_SHAPE_INCLUDE = 16;
8
+ const MAX_SHAPE_INPUT_TYPES = 32;
9
+ function shapeToken(value) {
10
+ return typeof value === 'string' && SHAPE_TOKEN.test(value) ? value : '<other>';
11
+ }
12
+ export function desktopBridgeRequestShape(payload) {
13
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload))
14
+ return null;
15
+ const request = payload;
16
+ const inputTypes = {};
17
+ if (Array.isArray(request.input)) {
18
+ for (const item of request.input) {
19
+ const type = item && typeof item === 'object' && !Array.isArray(item) ? shapeToken(item.type) : '<other>';
20
+ if (inputTypes[type] === undefined && Object.keys(inputTypes).length >= MAX_SHAPE_INPUT_TYPES)
21
+ continue;
22
+ inputTypes[type] = (inputTypes[type] ?? 0) + 1;
23
+ }
24
+ }
25
+ else if (typeof request.input === 'string')
26
+ inputTypes['<text>'] = 1;
27
+ return {
28
+ keys: Object.keys(request).map(shapeToken).sort().slice(0, MAX_SHAPE_KEYS),
29
+ include: Array.isArray(request.include) ? request.include.slice(0, MAX_SHAPE_INCLUDE).map(shapeToken) : null,
30
+ input_types: inputTypes,
31
+ };
32
+ }
33
+ function safeRequestShape(shape) {
34
+ if (!shape || !Array.isArray(shape.keys))
35
+ return null;
36
+ const inputTypes = {};
37
+ for (const [type, count] of Object.entries(shape.input_types ?? {}).slice(0, MAX_SHAPE_INPUT_TYPES)) {
38
+ if (Number.isSafeInteger(count) && count > 0)
39
+ inputTypes[shapeToken(type)] = count;
40
+ }
41
+ return {
42
+ keys: shape.keys.slice(0, MAX_SHAPE_KEYS).map(shapeToken),
43
+ include: Array.isArray(shape.include) ? shape.include.slice(0, MAX_SHAPE_INCLUDE).map(shapeToken) : null,
44
+ input_types: inputTypes,
45
+ };
46
+ }
47
+ function safeUpstreamId(value) {
48
+ const text = String(value ?? '').trim();
49
+ return /^[A-Za-z0-9_.:-]{1,64}$/.test(text) ? text : null;
50
+ }
51
+ export function safeUpstreamParam(value) {
52
+ if (typeof value !== 'string')
53
+ return null;
54
+ const text = value.slice(0, 64);
55
+ return /^[A-Za-z0-9_.[\]-]+$/.test(text) ? text : null;
56
+ }
5
57
  function safeCatalogId(value) {
6
58
  const text = String(value ?? '').replace(/[\r\n\0]/g, '').trim().slice(0, 128);
7
59
  return text.length > 0 ? text : null;
@@ -69,6 +121,10 @@ export function createDesktopBridgeRejectionLogger(options = {}) {
69
121
  const method = safeMethod(event.method);
70
122
  const providerId = safeCatalogId(event.provider_id);
71
123
  const publicModel = safeCatalogId(event.public_model);
124
+ const upstreamCode = safeUpstreamId(event.upstream_code);
125
+ const upstreamType = safeUpstreamId(event.upstream_type);
126
+ const upstreamParam = safeUpstreamParam(event.upstream_param);
127
+ const requestShape = safeRequestShape(event.request_shape);
72
128
  emit({
73
129
  event: 'sks.desktop_bridge.rejected',
74
130
  code,
@@ -81,6 +137,11 @@ export function createDesktopBridgeRejectionLogger(options = {}) {
81
137
  ...(Number.isSafeInteger(event.max_request_body_bytes) && event.max_request_body_bytes > 0 ? { max_request_body_bytes: event.max_request_body_bytes } : {}),
82
138
  ...(Number.isSafeInteger(event.request_body_bytes) && event.request_body_bytes >= 0 ? { request_body_bytes: event.request_body_bytes } : {}),
83
139
  ...(event.request_body_stage === 'encoded' || event.request_body_stage === 'decoded' ? { request_body_stage: event.request_body_stage } : {}),
140
+ ...(Number.isInteger(event.upstream_status) && event.upstream_status >= 100 && event.upstream_status <= 599 ? { upstream_status: event.upstream_status } : {}),
141
+ ...(upstreamCode ? { upstream_code: upstreamCode } : {}),
142
+ ...(upstreamType ? { upstream_type: upstreamType } : {}),
143
+ ...(upstreamParam ? { upstream_param: upstreamParam } : {}),
144
+ ...(requestShape ? { request_shape: requestShape } : {}),
84
145
  });
85
146
  };
86
147
  }
@@ -3,14 +3,16 @@ import tls from 'node:tls';
3
3
  import WebSocket, { WebSocketServer } from 'ws';
4
4
  import { BRIDGE_OFFICIAL_ROUTE_ID, BRIDGE_REMOTE_COMPACTION } from '../bridge-contracts.js';
5
5
  import { buildOfficialPassthroughWebSocketHeaders, buildProviderWebSocketHeaders } from './header-policy.js';
6
- import { createDesktopBridgeRejectionLogger } from './rejection-log.js';
6
+ import { createDesktopBridgeRejectionLogger, desktopBridgeRequestShape } from './rejection-log.js';
7
+ import { adaptRequestForRoute, logRequestAdaptation, providerExtraInclude } from './provider-request-compat.js';
7
8
  import { assertDesktopBridgeRouteContext, ensureDesktopBridgeRemoteTarget, isUnreachableUpstreamError, refreshDesktopBridgeRemoteTarget, resolveAndBindDesktopBridgeRouteContext, resolveCodexSessionIdentity, resolveDesktopBridgeTarget, safeBridgeErrorCode, threadRouteMove } from './security.js';
8
9
  import { admitCodexLbTurnState } from './codex-lb-turn-state.js';
9
10
  import { ProviderHandoffRefusedError, assertProviderHandoffAllowed, providerHandoffRefusedEvent, stripProviderBoundItems, } from './provider-handoff.js';
10
11
  import { DEFAULT_DESKTOP_BRIDGE_MAX_REQUEST_BODY_BYTES, DesktopBridgeError } from './types.js';
11
- import { buildCompactionSummaryRequest, compactionCreatedEvent, compactionFailedEvent, compactionResponseEvents, expandBridgeCompactionItems, newCompactionResponseId, parseProviderSummary, requestsRemoteCompaction, ResponsesContinuationChain, summaryFailure, summaryWithinBounds, } from './compaction-adapter.js';
12
+ import { buildCompactionSummaryRequest, compactionCreatedEvent, compactionFailedEvent, compactionResponseEvents, expandBridgeCompactionItems, newCompactionResponseId, parseProviderSummary, requestsRemoteCompaction, ResponsesContinuationChain, safeUpstreamErrorId, summaryFailure, summaryWithinBounds, } from './compaction-adapter.js';
12
13
  import { requestProviderCompactionSummary } from './http-forward.js';
13
14
  import { modelCapacityAdvisedFrame } from './capacity-retry-advice.js';
15
+ import { desktopBridgeModelsEtag, frameWithModelsEtag } from './models-endpoint.js';
14
16
  const MAX_PENDING_MESSAGES = 256;
15
17
  const CLOSE_GRACE_MS = 1_000;
16
18
  const INITIAL_CREATE_TIMEOUT_MS = 60 * 60_000;
@@ -22,6 +24,25 @@ export function isResponsesWebSocketRequest(req) {
22
24
  function bytes(data) {
23
25
  return Buffer.isBuffer(data) ? data : Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data);
24
26
  }
27
+ function isRecord(value) {
28
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
29
+ }
30
+ function upstreamFailureEvent(frame) {
31
+ if (!frame.includes('"response.failed"') && !frame.includes('"type":"error"') && !frame.includes('"type": "error"'))
32
+ return null;
33
+ let event;
34
+ try {
35
+ event = JSON.parse(frame.toString('utf8'));
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ if (!isRecord(event) || (event.type !== 'error' && event.type !== 'response.failed'))
41
+ return null;
42
+ const error = event.type === 'error' ? event.error : isRecord(event.response) ? event.response.error : null;
43
+ const record = isRecord(error) ? error : {};
44
+ return { status: typeof event.status === 'number' ? event.status : undefined, code: record.code, type: record.type, param: record.param };
45
+ }
25
46
  function withLineage(identity, next) {
26
47
  return {
27
48
  ...identity,
@@ -34,6 +55,7 @@ export function forwardResponsesWebSocket(req, socket, head, config) {
34
55
  const upgradeIdentity = resolveCodexSessionIdentity(req.headers);
35
56
  const maxBytes = config.maxRequestBodyBytes ?? DEFAULT_DESKTOP_BRIDGE_MAX_REQUEST_BODY_BYTES;
36
57
  const server = new WebSocketServer({ noServer: true, clientTracking: false, perMessageDeflate: false, maxPayload: maxBytes });
58
+ const modelsEtag = config.modelsCatalog ? desktopBridgeModelsEtag(config.modelsCatalog.generation) : null;
37
59
  try {
38
60
  server.handleUpgrade(req, socket, head, (client) => {
39
61
  bridge(client);
@@ -52,6 +74,7 @@ export function forwardResponsesWebSocket(req, socket, head, config) {
52
74
  let boundCredentialGeneration = null;
53
75
  let boundCredentialFingerprint = null;
54
76
  let routedModel = null;
77
+ let routedShape = null;
55
78
  let stopped = false;
56
79
  let processing = false;
57
80
  let pendingBytes = 0;
@@ -150,12 +173,19 @@ export function forwardResponsesWebSocket(req, socket, head, config) {
150
173
  let rewritten = expandBridgeCompactionItems(create);
151
174
  if (route.session_pin?.handoff_from && stripProviderBoundItems(create, Boolean(move)))
152
175
  rewritten = true;
176
+ const adapted = adaptRequestForRoute(create, route, providerExtraInclude(config, route));
177
+ if (adapted.length) {
178
+ rewritten = true;
179
+ logRequestAdaptation(config, route, adapted, 'websocket');
180
+ }
153
181
  if (BRIDGE_REMOTE_COMPACTION[route.provider_id] === 'bridge_summary' && requestsRemoteCompaction(create)) {
154
182
  message.compaction = create;
155
183
  return;
156
184
  }
157
- if (route.upstream_model !== model || rewritten)
185
+ if (route.upstream_model !== model || rewritten) {
158
186
  message.data = Buffer.from(JSON.stringify(create));
187
+ message.binary = false;
188
+ }
159
189
  message.routed = create;
160
190
  }
161
191
  function refuseMove(message, error) {
@@ -181,7 +211,9 @@ export function forwardResponsesWebSocket(req, socket, head, config) {
181
211
  send(compactionFailedEvent(responseId, route.public_model, 'bridge_compaction_history_unavailable'));
182
212
  return;
183
213
  }
184
- const body = Buffer.from(JSON.stringify(buildCompactionSummaryRequest({ ...create, input }, route.upstream_model)));
214
+ const summaryRequest = buildCompactionSummaryRequest({ ...create, input }, route.upstream_model);
215
+ adaptRequestForRoute(summaryRequest, route, providerExtraInclude(config, route));
216
+ const body = Buffer.from(JSON.stringify(summaryRequest));
185
217
  let accepted = false;
186
218
  try {
187
219
  const call = await requestProviderCompactionSummary(config, { provider, credential, clientHeaders: req.headers, requestUrl: req.url || '/' }, body, () => {
@@ -282,6 +314,20 @@ export function forwardResponsesWebSocket(req, socket, head, config) {
282
314
  frame = advised;
283
315
  logRejection({ code: 'bridge_upstream_model_capacity_retry_advised', transport: 'websocket', provider_id: route.provider_id, public_model: routedModel ?? route.public_model, ...(req.url ? { url: req.url } : {}) });
284
316
  }
317
+ const advertised = modelsEtag && !binary ? frameWithModelsEtag(frame, modelsEtag) : null;
318
+ if (advertised)
319
+ frame = advertised;
320
+ const failure = binary ? null : upstreamFailureEvent(frame);
321
+ if (failure) {
322
+ logRejection({
323
+ code: 'bridge_upstream_request_rejected', transport: 'websocket',
324
+ ...(failure.status === undefined ? {} : { status: failure.status, upstream_status: failure.status }),
325
+ provider_id: route.provider_id, public_model: routedModel ?? route.public_model,
326
+ upstream_code: safeUpstreamErrorId(failure.code), upstream_type: safeUpstreamErrorId(failure.type),
327
+ upstream_param: typeof failure.param === 'string' ? failure.param : null,
328
+ request_shape: routedShape, ...(req.url ? { url: req.url } : {}),
329
+ });
330
+ }
285
331
  if (summarizes)
286
332
  chain.received(frame);
287
333
  try {
@@ -350,6 +396,8 @@ export function forwardResponsesWebSocket(req, socket, head, config) {
350
396
  await answerCompaction(message.compaction);
351
397
  else {
352
398
  relay(upstream, message.data, message.binary);
399
+ if (message.routed)
400
+ routedShape = desktopBridgeRequestShape(message.routed);
353
401
  if (message.routed && bound && BRIDGE_REMOTE_COMPACTION[bound.provider_id] === 'bridge_summary')
354
402
  chain.sent(message.routed, message.data);
355
403
  }
@@ -11,6 +11,7 @@ import { ProviderHandoffRefusedError, providerHandoffRefusalBody } from './provi
11
11
  import { PACKAGE_VERSION } from '../../version.js';
12
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';
13
13
  import { handleDesktopBridgeImagegen } from './imagegen-endpoint.js';
14
+ import { answerDesktopBridgeModels, isDesktopBridgeModelsRequest } from './models-endpoint.js';
14
15
  import { forwardWebSocket, safeEndUpgradeSocket } from './websocket-forward.js';
15
16
  function authenticateDesktopBridgeClient(req, input) {
16
17
  const raw = String(req.url || '/');
@@ -270,6 +271,10 @@ export async function startPreparedDesktopBridge(input, options = {}) {
270
271
  return;
271
272
  }
272
273
  assertAllowedPath(authenticated.pathname, input.allowedPathPrefixes);
274
+ if (input.modelsCatalog && isDesktopBridgeModelsRequest(req, authenticated.pathname)) {
275
+ answerDesktopBridgeModels(req, res, input.modelsCatalog);
276
+ return;
277
+ }
273
278
  const prepared = await prepareDesktopBridgeRequest(req, input);
274
279
  await forwardHttp(req, res, input, prepared, `${desktopBridgeListenOrigin(input)}${authenticated.clientBasePath}`);
275
280
  }
@@ -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,5 +1,5 @@
1
1
  import { readText, writeJsonAtomic } from '../../fsx.js';
2
- import { repairManagedCodexLbClientSelection } from '../../../cli/install-helpers-codex-lb-config.js';
2
+ import { codexReadsBridgeModels, hasUserOwnedModelCatalog, repairManagedCodexLbClientSelection } from '../../../cli/install-helpers-codex-lb-config.js';
3
3
  import { safeWriteCodexConfigToml } from '../../codex-runtime/codex-desktop-config-policy.js';
4
4
  import { readTopLevelTomlString } from '../../codex-app/codex-model-catalog.js';
5
5
  import { codexRestartBlockers, maybeRestartRunningCodexApp } from '../../codex-app/codex-app-restart-policy.js';
@@ -18,6 +18,7 @@ import { sha256Stable } from '../route-index.js';
18
18
  import { bridgeBaseUrl, commandResult, controllerEnv, controllerPaths, nowIso, providerCode, providerRegistrySnapshot, resolveRawCredentials, resolveValidatedCredentials, safeCode, serializedSettings, stringArray, timeoutMs, unique } from './shared.js';
19
19
  import { desktopBridgeStatusV3 } from './status.js';
20
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';
21
22
  export async function syncCatalog(options) {
22
23
  const result = await syncCatalogInternal(options);
23
24
  const status = await desktopBridgeStatusV3(options);
@@ -36,10 +37,11 @@ async function repairCodexLbClientSelection(configPath) {
36
37
  }
37
38
  export async function syncCatalogInternal(options, behavior = {}) {
38
39
  const paths = controllerPaths(options);
39
- const repair = await repairCodexLbClientSelection(paths.configPath);
40
+ const repair = behavior.configWrites === false ? null : await repairCodexLbClientSelection(paths.configPath);
40
41
  if (repair)
41
42
  return blockedSync(inspectHistoricalDesktopBridgeIntent(''), [repair]);
42
- const historicalIntent = inspectHistoricalDesktopBridgeIntent(await readText(paths.configPath, ''));
43
+ const configText = await readText(paths.configPath, '');
44
+ const historicalIntent = inspectHistoricalDesktopBridgeIntent(configText);
43
45
  if (historicalIntent.blockers.length > 0)
44
46
  return blockedSync(historicalIntent, historicalIntent.blockers);
45
47
  const rawCredentials = await resolveRawCredentials(options, paths);
@@ -68,6 +70,10 @@ export async function syncCatalogInternal(options, behavior = {}) {
68
70
  ? openRouterOnlyExposure(selection, openRouterOnly, await readText(paths.configPath, ''))
69
71
  : selection;
70
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
+ }
71
77
  if (!selectionState.stored)
72
78
  await writeBridgeModelSelection(paths.home, selection).catch(() => undefined);
73
79
  await writeJsonAtomic(bridgeAvailableModelsPath(paths.home), {
@@ -139,6 +145,8 @@ export async function syncCatalogInternal(options, behavior = {}) {
139
145
  receiptDir: paths.receiptDir,
140
146
  bridgeBaseUrl: managedBridgeBaseUrl,
141
147
  combinedCatalogPath: staging.catalog_path,
148
+ bindModelCatalog: !codexReadsBridgeModels(configText) && !hasUserOwnedModelCatalog(configText),
149
+ configWrite: behavior.configWrites === false ? 'forbid' : 'allow',
142
150
  newCatalogGeneration: build.catalog.generation,
143
151
  metadataUpdates: [
144
152
  {
@@ -316,6 +324,20 @@ function emptyProviderCatalog(providerId, state, checkedAt, blockers) {
316
324
  warnings: []
317
325
  };
318
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
+ }
319
341
  function validationFailureState(blockers) {
320
342
  return blockers.some((blocker) => /(?:401|403|auth|rejected|invalid_key|unauthorized)/i.test(blocker))
321
343
  ? 'rejected' : 'unavailable';
@@ -362,12 +384,14 @@ export async function selectExposedModels(publicIds, options) {
362
384
  updated_at: nowIso(options),
363
385
  openrouter: { mode: 'selected', public_ids: requested }
364
386
  });
365
- const sync = await syncCatalogInternal(options);
387
+ const sync = await syncAsRefresh(options, 'models.select');
366
388
  const status = await desktopBridgeStatusV3(options);
367
389
  const activation = sync.activation && typeof sync.activation === 'object'
368
390
  ? sync.activation
369
391
  : {};
370
- 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);
371
395
  const restart = sync.ok === true
372
396
  ? await maybeRestartRunningCodexApp({
373
397
  env: controllerEnv(options),
@@ -387,3 +411,88 @@ function seedSelectionFromConfiguredModel(selection, configText) {
387
411
  return selection;
388
412
  return { ...selection, openrouter: { mode: 'selected', public_ids: [configured] } };
389
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
+ }
@@ -0,0 +1,140 @@
1
+ import http from 'node:http';
2
+ import path from 'node:path';
3
+ import { readText, writeJsonAtomic } from '../../fsx.js';
4
+ import { safeWriteCodexConfigToml } from '../../codex-runtime/codex-desktop-config-policy.js';
5
+ import { readTopLevelTomlString } from '../../codex-app/codex-model-catalog.js';
6
+ import { codexReadsBridgeModels, hasUserOwnedModelCatalog, setDesktopBridgeManagedModelCatalog } from '../../../cli/install-helpers-codex-lb-config.js';
7
+ import { captureCodexAuthSnapshot } from '../desktop-auth-invariant.js';
8
+ import { desktopBridgeModelsEtag } from '../desktop-bridge/models-endpoint.js';
9
+ export const MODEL_REFRESH_STATE_SCHEMA = 'sks.bridge-model-refresh-state.v1';
10
+ export const MODEL_REFRESH_DEBOUNCE_MS = 60_000;
11
+ export const MODEL_REFRESH_RECOVERY_COMMAND = 'sks bridge models refresh';
12
+ export function modelRefreshStatePath(home) {
13
+ return path.join(path.resolve(home), '.codex', 'sks', 'sks-bridge-model-refresh.json');
14
+ }
15
+ export async function readModelRefreshState(home) {
16
+ try {
17
+ const parsed = JSON.parse(await readText(modelRefreshStatePath(home), ''));
18
+ return parsed?.schema === MODEL_REFRESH_STATE_SCHEMA && typeof parsed.refreshed_at === 'string'
19
+ && typeof parsed.provider_registry_generation === 'string' && typeof parsed.generation === 'string'
20
+ && Number.isSafeInteger(parsed.total)
21
+ ? parsed
22
+ : null;
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
28
+ export async function writeModelRefreshState(home, state) {
29
+ await writeJsonAtomic(modelRefreshStatePath(home), { schema: MODEL_REFRESH_STATE_SCHEMA, ...state }, { mode: 0o600 });
30
+ }
31
+ export function refreshedRecently(state, registryGeneration, nowMs) {
32
+ if (!state || state.provider_registry_generation !== registryGeneration)
33
+ return false;
34
+ const age = nowMs - Date.parse(state.refreshed_at);
35
+ return Number.isFinite(age) && age >= 0 && age < MODEL_REFRESH_DEBOUNCE_MS;
36
+ }
37
+ export function listedModelSlugs(catalog) {
38
+ if (!catalog)
39
+ return [];
40
+ return [...new Set(catalog.models
41
+ .filter((model) => !/^hid(?:e|den)$/i.test(String(model.visibility || 'list')))
42
+ .map((model) => String(model.slug || ''))
43
+ .filter(Boolean))].sort();
44
+ }
45
+ export function modelListDiff(before, after) {
46
+ const was = new Set(before);
47
+ const is = new Set(after);
48
+ return { added: after.filter((slug) => !was.has(slug)), removed: before.filter((slug) => !is.has(slug)) };
49
+ }
50
+ export function modelRefreshLine(input) {
51
+ const line = `Model list refreshed: ${input.total} models (+${input.added} −${input.removed}) generation ${input.generation.slice(0, 8)}`;
52
+ return input.restartCodex ? `${line}. Restart Codex to see the new model list.` : line;
53
+ }
54
+ export async function bridgeServesModels(baseUrl, catalog, options = {}) {
55
+ const expected = desktopBridgeModelsEtag(catalog.generation);
56
+ let failure = 'bridge_models_self_check_unreachable';
57
+ for (let attempt = 0; attempt < (options.attempts ?? 3); attempt += 1) {
58
+ if (attempt > 0)
59
+ await new Promise((resolve) => setTimeout(resolve, 500));
60
+ failure = await new Promise((resolve) => {
61
+ let url;
62
+ try {
63
+ url = new URL(`${baseUrl.replace(/\/+$/, '')}/models`);
64
+ }
65
+ catch {
66
+ resolve('bridge_models_self_check_url_invalid');
67
+ return;
68
+ }
69
+ if (url.protocol !== 'http:' || !['127.0.0.1', '[::1]', 'localhost'].includes(url.hostname)) {
70
+ resolve('bridge_models_self_check_not_loopback');
71
+ return;
72
+ }
73
+ const request = http.get(url, { timeout: options.timeoutMs ?? 3_000 }, (response) => {
74
+ const chunks = [];
75
+ response.on('data', (chunk) => chunks.push(chunk));
76
+ response.once('end', () => {
77
+ if (response.statusCode !== 200) {
78
+ resolve(`bridge_models_self_check_status_${response.statusCode}`);
79
+ return;
80
+ }
81
+ if (response.headers.etag !== expected) {
82
+ resolve('bridge_models_self_check_generation_mismatch');
83
+ return;
84
+ }
85
+ try {
86
+ const models = JSON.parse(Buffer.concat(chunks).toString('utf8')).models;
87
+ resolve(Array.isArray(models) && models.length === catalog.models.length ? null : 'bridge_models_self_check_rows_mismatch');
88
+ }
89
+ catch {
90
+ resolve('bridge_models_self_check_body_invalid');
91
+ }
92
+ });
93
+ response.once('error', () => resolve('bridge_models_self_check_unreachable'));
94
+ });
95
+ request.once('timeout', () => request.destroy());
96
+ request.once('error', () => resolve('bridge_models_self_check_unreachable'));
97
+ });
98
+ if (failure === null)
99
+ return null;
100
+ }
101
+ return failure;
102
+ }
103
+ export async function alignCodexModelSource(input) {
104
+ const current = await readText(input.configPath, '');
105
+ if (hasUserOwnedModelCatalog(current)) {
106
+ return { source: 'user_model_catalog_json', changed: false, warnings: ['codex_model_list_pinned_by_user_model_catalog_json'] };
107
+ }
108
+ const baseUrl = readTopLevelTomlString(current, 'openai_base_url');
109
+ if (!baseUrl || (!codexReadsBridgeModels(current) && !readTopLevelTomlString(current, 'model_catalog_json'))) {
110
+ return { source: 'not_managed', changed: false, warnings: [] };
111
+ }
112
+ const auth = await captureCodexAuthSnapshot({ home: input.home, authPath: input.authPath }).catch(() => null);
113
+ const warnings = [];
114
+ let wantBridge = auth?.mode === 'chatgpt_oauth';
115
+ if (wantBridge) {
116
+ const failure = await input.selfCheck(baseUrl, input.catalog);
117
+ if (failure) {
118
+ warnings.push(failure);
119
+ wantBridge = false;
120
+ }
121
+ }
122
+ if (!wantBridge && !input.catalogPath) {
123
+ return { source: codexReadsBridgeModels(current) ? 'bridge_models' : 'model_catalog_json', changed: false, warnings: [...warnings, 'codex_model_catalog_path_unavailable'] };
124
+ }
125
+ const next = setDesktopBridgeManagedModelCatalog(current, wantBridge ? null : input.catalogPath);
126
+ const source = wantBridge ? 'bridge_models' : 'model_catalog_json';
127
+ if (next === current)
128
+ return { source, changed: false, warnings };
129
+ if (!input.allowConfigWrites) {
130
+ return {
131
+ source: codexReadsBridgeModels(current) ? 'bridge_models' : 'model_catalog_json',
132
+ changed: false,
133
+ warnings: [...warnings, `codex_model_source_change_deferred:${source}`]
134
+ };
135
+ }
136
+ const write = await safeWriteCodexConfigToml(input.configPath, current, next, 'desktop-bridge-model-source', { verifyUnchangedBeforeWrite: true });
137
+ if (!write.ok)
138
+ return { source: codexReadsBridgeModels(current) ? 'bridge_models' : 'model_catalog_json', changed: false, warnings: [...warnings, `codex_model_source_write_refused:${write.status}`] };
139
+ return { source, changed: true, warnings };
140
+ }
@@ -9,7 +9,7 @@ import { readActiveCombinedBridgeCatalog } from '../combined-catalog.js';
9
9
  import { MAX_SELECTED_OPENROUTER_MODELS, bridgeAvailableModelsPath, readBridgeModelSelectionState, writeBridgeModelSelection } from '../combined-catalog/model-selection.js';
10
10
  import { ensureUserReadOnlyListRole } from '../../subagents/read-only-list-role.js';
11
11
  import { canonicalizeBridgeModelId } from '../route-index.js';
12
- import { syncCatalogInternal } from './catalog.js';
12
+ import { syncAsRefresh } from './catalog.js';
13
13
  import { hasOpenRouterRoute, openRouterOnlyStoreLocation } from './openrouter-only-catalog.js';
14
14
  import { applyOpenRouterOnlyMainModel, openRouterOnlyMainModelTarget, restoreOpenRouterOnlyMainModel } from './openrouter-only-main-model.js';
15
15
  import { openRouterOnlyProviderBlocker, openRouterOnlyStatusFromCore } from './openrouter-only-status.js';
@@ -116,10 +116,10 @@ async function enableOpenRouterOnly(noRestart, options) {
116
116
  if (entries.length === 0) {
117
117
  return finish('openrouter-only.set', false, ['openrouter_only_no_openrouter_models'], {}, options);
118
118
  }
119
- const sync = await syncCatalogInternal({
119
+ const sync = await syncAsRefresh({
120
120
  ...options,
121
121
  settings: { ...(options.settings || {}), auth_priority_enabled: false }
122
- }, {
122
+ }, 'openrouter-only.set', {
123
123
  openRouterOnly: { ...before, enabled: true, subagent_models: entries },
124
124
  onActivated: async () => {
125
125
  await requireRoutableEntry(core.paths, { ...before, enabled: true, subagent_models: entries });
@@ -204,7 +204,7 @@ export async function turnOffOpenRouterOnly(options, behavior = {}) {
204
204
  return { ok: true, changed: false, catalog_sync: null, main_model: null, blockers: [] };
205
205
  }
206
206
  const off = { ...before, enabled: false };
207
- const sync = (commit) => syncCatalogInternal(options, {
207
+ const sync = (commit) => syncAsRefresh(options, 'openrouter-only.off', {
208
208
  ...(behavior.restartService === undefined ? {} : { restartService: behavior.restartService }),
209
209
  openRouterOnly: off,
210
210
  ...(commit ? { onActivated: () => writeOpenRouterOnlyState({ enabled: false }, location) } : {})
@@ -319,7 +319,7 @@ export async function setSubagentModels(raw, noRestart, options, expectedProfile
319
319
  }, options);
320
320
  }
321
321
  const exposedBefore = await exposedModelIds(paths);
322
- const sync = await syncCatalogInternal(options, {
322
+ const sync = await syncAsRefresh(options, 'subagent-models.set', {
323
323
  openRouterOnly: { ...before, subagent_models: entries },
324
324
  onActivated: async () => {
325
325
  if (entries.length > 0)