subconscious-cli 6.3.1 → 8.0.0

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/bin/cli.js CHANGED
@@ -35,6 +35,7 @@ import {
35
35
  } from './harness-manifest.js';
36
36
  import { headlessPromptIndex } from './headless-args.js';
37
37
  import { resolveModelCatalog } from './models.js';
38
+ import { ensureSystemCa, refuseUnsupportedSystemCa } from './network.js';
38
39
  import {
39
40
  configCommand,
40
41
  DEFAULT_PROFILE,
@@ -44,6 +45,7 @@ import {
44
45
  printConfigHelp,
45
46
  RUNBOOK_DEFAULTS,
46
47
  resolvedModelSetting,
48
+ systemCaEnabledFor,
47
49
  updatePlatformUrlCommand,
48
50
  updateUrlCommand,
49
51
  validateProfileName,
@@ -58,6 +60,23 @@ function isHelpArg(arg) {
58
60
  return arg === 'help' || arg === '-h' || arg === '--help';
59
61
  }
60
62
 
63
+ // Commands that never make a network request.
64
+ const OFFLINE_COMMANDS = new Set([
65
+ '--help',
66
+ '-h',
67
+ 'help',
68
+ 'config',
69
+ 'update-key',
70
+ 'logout',
71
+ 'setup',
72
+ 'settings',
73
+ 'cursor',
74
+ ]);
75
+
76
+ function needsNetwork(args) {
77
+ return !OFFLINE_COMMANDS.has(args[0]) && !isHelpArg(args[1]);
78
+ }
79
+
61
80
  function printHelp() {
62
81
  const agents = agentList()
63
82
  .map(
@@ -122,6 +141,9 @@ ${agents}
122
141
  ${c.dim}$${c.reset} subc codex headless "fix the failing test"
123
142
  ${c.dim}$${c.reset} subc -p staging codex
124
143
 
144
+ ${c.bold}Network${c.reset}
145
+ Behind a proxy or a TLS-inspecting firewall? Run ${c.cyan}subc login help${c.reset}.
146
+
125
147
  ${c.dim}Use subc <command> help for command-specific usage.${c.reset}
126
148
  `);
127
149
  }
@@ -136,6 +158,16 @@ Usage:
136
158
  Authenticate and save an API key to the selected profile.
137
159
  Opens a browser to a one-time login link. If the browser does not open, use the URL printed in the terminal.
138
160
  To sign in without a browser, copy an API key from the dashboard and run subc update-key <your-api-key>.
161
+
162
+ Behind a proxy or firewall:
163
+ If login fails with ENOTFOUND, ECONNREFUSED or a timeout, the network may require a proxy.
164
+ Set HTTPS_PROXY=http://host:port (or http://user:pass@host:port) and retry.
165
+ NO_PROXY=host1,.example.com lists hosts to reach directly, not through the proxy.
166
+ If the error names a certificate (SELF_SIGNED_CERT_IN_CHAIN, UNABLE_TO_VERIFY_LEAF_SIGNATURE),
167
+ the proxy re-signs TLS traffic. Ask IT for its root certificate, then either:
168
+ export NODE_EXTRA_CA_CERTS=/path/to/corporate-root-ca.pem
169
+ or set SYSTEM_CA=on in the profile (subc config edit) to trust the OS certificate store. Needs Node 22.15 or newer.
170
+ If nothing works, subc update-key <your-api-key> saves a key without any network request.
139
171
  `,
140
172
  logout: `
141
173
  Usage:
@@ -369,6 +401,16 @@ async function main() {
369
401
  return;
370
402
  }
371
403
 
404
+ // Trust the OS certificate store before any request when the profile opts in.
405
+ // Node reads --use-system-ca at startup, so this re-execs once. Commands that
406
+ // make no request skip it, so help and config edit still work on old Node.
407
+ if (needsNetwork(args)) {
408
+ const profile = await loadProfile(profileName);
409
+ const systemCaEnabled = systemCaEnabledFor(profile);
410
+ if (refuseUnsupportedSystemCa(systemCaEnabled, profile.path)) return;
411
+ if (systemCaEnabled && (await ensureSystemCa())) return;
412
+ }
413
+
372
414
  const launchTui =
373
415
  !command &&
374
416
  process.stdin.isTTY === true &&
@@ -500,6 +542,7 @@ async function main() {
500
542
  });
501
543
  modelsCommand(catalog.models, {
502
544
  selectedModel,
545
+ entries: catalog.entries,
503
546
  error: catalog.error,
504
547
  source: catalog.source,
505
548
  hasApiKey: Boolean(auth?.key),
package/bin/feedback.js CHANGED
@@ -12,6 +12,7 @@ import { createInterface } from 'node:readline/promises';
12
12
  import { getApiKey, getPlatformUrl } from './auth.js';
13
13
  import { imageContentType, readClipboardImage } from './clipboard-image.js';
14
14
  import { c } from './colors.js';
15
+ import { proxyAwareFetch } from './network.js';
15
16
  import { DEFAULT_PROFILE } from './profiles.js';
16
17
 
17
18
  export const FEEDBACK_API_PATH = '/api/cli/feedback';
@@ -111,7 +112,7 @@ export async function submitFeedback(
111
112
  apiKey,
112
113
  platformUrl,
113
114
  payload,
114
- fetchImpl = fetch,
115
+ fetchImpl = proxyAwareFetch,
115
116
  ) {
116
117
  const res = await fetchImpl(
117
118
  `${platformUrl.replace(/\/$/, '')}${FEEDBACK_API_PATH}`,
@@ -1,8 +1,16 @@
1
1
  import { MODEL_CAPABILITIES } from './agent-data.js';
2
2
 
3
- // The models endpoint supplies IDs, not modality metadata, so capabilities
4
- // are listed by exact model ID: similarly named models inherit nothing.
5
- export function modelSupportsVision(modelId) {
3
+ // Capabilities are listed by exact model ID: similarly named models inherit
4
+ // nothing. The canonical catalog's per-model modality metadata is preferred
5
+ // when a launch carries it; the packaged map is the offline fallback.
6
+ export function modelSupportsVision(modelId, entries = []) {
7
+ const entry = entries.find((candidate) => candidate?.id === modelId);
8
+ if (entry?.modalities) {
9
+ const input = Array.isArray(entry.modalities.input)
10
+ ? entry.modalities.input
11
+ : [];
12
+ return input.includes('image');
13
+ }
6
14
  return (
7
15
  Object.hasOwn(MODEL_CAPABILITIES, modelId) &&
8
16
  MODEL_CAPABILITIES[modelId].vision === true
@@ -10,6 +18,38 @@ export function modelSupportsVision(modelId) {
10
18
  }
11
19
 
12
20
  /** Vision model IDs, newline-separated, for the runbooks' shell lookup. */
13
- export function visionModelList() {
14
- return Object.keys(MODEL_CAPABILITIES).filter(modelSupportsVision).join('\n');
21
+ export function visionModelList(entries = []) {
22
+ const ids = entries.length
23
+ ? entries
24
+ .map((entry) => entry.id)
25
+ .filter((id) => modelSupportsVision(id, entries))
26
+ : Object.keys(MODEL_CAPABILITIES).filter((id) =>
27
+ modelSupportsVision(id, entries),
28
+ );
29
+ return ids.join('\n');
30
+ }
31
+
32
+ /**
33
+ * Canonical catalog entries from the launch env's SUBC_MODEL_METADATA (a
34
+ * JSON array), or an empty list when discovery did not run, was skipped,
35
+ * or wrote malformed JSON.
36
+ */
37
+ export function catalogEntriesFromMetadata(metadataJson) {
38
+ if (typeof metadataJson !== 'string' || !metadataJson.trim()) return [];
39
+ try {
40
+ const entries = JSON.parse(metadataJson);
41
+ return Array.isArray(entries) ? entries : [];
42
+ } catch {
43
+ return [];
44
+ }
45
+ }
46
+
47
+ /**
48
+ * The vision list for a runbook script, from the launch env's catalog
49
+ * metadata with the packaged map as the fallback when discovery did not
50
+ * run or failed.
51
+ */
52
+ export function visionModelListFromMetadata(metadataJson) {
53
+ const entries = catalogEntriesFromMetadata(metadataJson);
54
+ return entries.length ? visionModelList(entries) : visionModelList();
15
55
  }
package/bin/models.js CHANGED
@@ -1,21 +1,24 @@
1
1
  /**
2
2
  * Resolve the model catalog exposed by an OpenAI-compatible gateway.
3
3
  *
4
- * When a profile (or agent) API key is present, prefer the key-scoped
5
- * /v1/models/available list. If that request fails, fall back to the public
6
- * /v1/models fleet catalog. Launches keep working with the packaged registry
7
- * when the gateway is offline or returns nothing usable.
4
+ * A key-scoped GET /v1/models is the canonical source: the gateway filters
5
+ * it by the key's model access and describes each model (lifecycle, limits,
6
+ * modalities, capabilities). Older gateways without the canonical catalog
7
+ * answer with a minimal list, which still names the fleet. Launches keep
8
+ * working with the packaged registry when the gateway is offline or returns
9
+ * nothing usable.
8
10
  */
9
11
 
12
+ import { proxyAwareFetch } from './network.js';
13
+
10
14
  const MODEL_ID_PATTERN = /^[-A-Za-z0-9._:/+]+$/;
11
15
  export const DEFAULT_MODEL_FETCH_TIMEOUT_MS = 3000;
12
- export const AVAILABLE_MODELS_PATH = '/v1/models/available';
13
16
  export const PUBLIC_MODELS_PATH = '/v1/models';
14
17
  export const PUBLIC_CATALOG_FALLBACK_MESSAGE =
15
18
  'Provisioned catalog unavailable; showing the public model list.';
16
19
 
17
20
  export function isLiveModelSource(source) {
18
- return source === 'available' || source === 'public';
21
+ return source === 'catalog' || source === 'public';
19
22
  }
20
23
 
21
24
  export function isAbortError(error) {
@@ -58,6 +61,34 @@ export function normalizeModelIds(modelIds = [], selectedModel) {
58
61
  return models;
59
62
  }
60
63
 
64
+ /**
65
+ * Rich entry fields from a canonical /v1/models response: lifecycle
66
+ * (status, deprecation_date, replacement), limits, modalities, and
67
+ * capabilities. Minimal legacy responses produce entries that carry only
68
+ * the id; every optional field is absent, never guessed.
69
+ */
70
+ export function parseCatalogEntries(data = []) {
71
+ const entries = [];
72
+ for (const model of data) {
73
+ if (model == null || typeof model !== 'object') continue;
74
+ const id = typeof model.id === 'string' ? model.id.trim() : '';
75
+ if (!id || !MODEL_ID_PATTERN.test(id)) continue;
76
+ entries.push({
77
+ id,
78
+ lifecycle: model.lifecycle ?? null,
79
+ limits: model.limits ?? null,
80
+ modalities: model.modalities ?? null,
81
+ capabilities: model.capabilities ?? null,
82
+ });
83
+ }
84
+ return entries;
85
+ }
86
+
87
+ /** True when the response is the canonical catalog, not a minimal list. */
88
+ function isCanonicalResponse(data = []) {
89
+ return data.some((model) => model?.schema_version === '1');
90
+ }
91
+
61
92
  function gatewayOrigin(baseUrl) {
62
93
  if (!baseUrl?.trim()) throw new Error('Gateway URL is not configured');
63
94
  return baseUrl.trim().replace(/\/+$/, '');
@@ -68,12 +99,12 @@ function modelsEndpoint(baseUrl, path) {
68
99
  return `${gatewayOrigin(baseUrl)}${normalizedPath}`;
69
100
  }
70
101
 
71
- export async function fetchGatewayModels({
102
+ async function fetchModelsPayload({
72
103
  baseUrl,
73
104
  apiKey,
74
- path = PUBLIC_MODELS_PATH,
75
- fetchImpl = globalThis.fetch,
76
- timeoutMs = DEFAULT_MODEL_FETCH_TIMEOUT_MS,
105
+ path,
106
+ fetchImpl,
107
+ timeoutMs,
77
108
  signal,
78
109
  }) {
79
110
  if (typeof fetchImpl !== 'function') {
@@ -123,7 +154,7 @@ export async function fetchGatewayModels({
123
154
  throw new Error('Model discovery returned an invalid response');
124
155
  }
125
156
 
126
- return normalizeModelIds(payload.data.map((model) => model?.id));
157
+ return payload.data;
127
158
  };
128
159
  return await Promise.race([discover(), aborted]);
129
160
  } catch (error) {
@@ -138,16 +169,35 @@ export async function fetchGatewayModels({
138
169
  }
139
170
  }
140
171
 
141
- async function fetchPublicModels({ baseUrl, fetchImpl, timeoutMs, signal }) {
142
- const models = await fetchGatewayModels({
172
+ /**
173
+ * Fetch one models listing. Returns the id list, the parsed catalog
174
+ * entries, and whether the gateway answered with the canonical catalog.
175
+ */
176
+ export async function fetchGatewayCatalog({
177
+ baseUrl,
178
+ apiKey,
179
+ path = PUBLIC_MODELS_PATH,
180
+ fetchImpl = proxyAwareFetch,
181
+ timeoutMs = DEFAULT_MODEL_FETCH_TIMEOUT_MS,
182
+ signal,
183
+ }) {
184
+ const data = await fetchModelsPayload({
143
185
  baseUrl,
144
- path: PUBLIC_MODELS_PATH,
186
+ apiKey,
187
+ path,
145
188
  fetchImpl,
146
189
  timeoutMs,
147
190
  signal,
148
191
  });
192
+ const entries = parseCatalogEntries(data);
193
+ const models = normalizeModelIds(entries.map((entry) => entry.id));
149
194
  if (!models.length)
150
195
  throw new Error('Model discovery returned no usable model IDs');
196
+ return { models, entries, canonical: isCanonicalResponse(data) };
197
+ }
198
+
199
+ export async function fetchGatewayModels(args) {
200
+ const { models } = await fetchGatewayCatalog(args);
151
201
  return models;
152
202
  }
153
203
 
@@ -157,14 +207,33 @@ function packagedCatalog(selectedModel, fallbackModels, error) {
157
207
  [selectedModel, ...fallbackModels],
158
208
  selectedModel,
159
209
  ),
210
+ defaultModels: normalizeModelIds(
211
+ [selectedModel, ...fallbackModels],
212
+ selectedModel,
213
+ ),
214
+ entries: [],
160
215
  source: 'packaged',
161
216
  error,
162
217
  };
163
218
  }
164
219
 
165
- function liveCatalog(models, selectedModel, source) {
220
+ /**
221
+ * Assemble the catalog views. `models` is everything the caller can invoke,
222
+ * deprecated models included. `defaultModels` drops models the gateway
223
+ * marked deprecated or retired, so new sessions pick a current model while
224
+ * existing configurations that name a deprecated model keep working.
225
+ */
226
+ function liveCatalog(models, selectedModel, source, entries = []) {
227
+ const normalized = normalizeModelIds(models, selectedModel);
228
+ const entryById = new Map(entries.map((entry) => [entry.id, entry]));
229
+ const defaults = normalized.filter((id) => {
230
+ const status = entryById.get(id)?.lifecycle?.status;
231
+ return status !== 'deprecated' && status !== 'retired';
232
+ });
166
233
  return {
167
- models: normalizeModelIds(models, selectedModel),
234
+ models: normalized,
235
+ defaultModels: defaults.length ? defaults : normalized,
236
+ entries,
168
237
  source,
169
238
  error: null,
170
239
  };
@@ -176,7 +245,7 @@ export async function resolveModelCatalog({
176
245
  selectedModel,
177
246
  fallbackModels = [],
178
247
  pinnedModels,
179
- fetchImpl = globalThis.fetch,
248
+ fetchImpl = proxyAwareFetch,
180
249
  timeoutMs = DEFAULT_MODEL_FETCH_TIMEOUT_MS,
181
250
  signal,
182
251
  }) {
@@ -186,6 +255,8 @@ export async function resolveModelCatalog({
186
255
  if (pinned?.length) {
187
256
  return {
188
257
  models: normalizeModelIds(pinned, selectedModel),
258
+ defaultModels: normalizeModelIds(pinned, selectedModel),
259
+ entries: [],
189
260
  source: 'pinned',
190
261
  error: null,
191
262
  };
@@ -194,41 +265,60 @@ export async function resolveModelCatalog({
194
265
  const key = apiKey?.trim() || '';
195
266
 
196
267
  if (key) {
268
+ let catalogError;
197
269
  try {
198
- const discovered = await fetchGatewayModels({
270
+ // The canonical catalog is key-scoped and access-filtered by the
271
+ // gateway, so no second request is needed when it answers.
272
+ const discovered = await fetchGatewayCatalog({
199
273
  baseUrl,
200
274
  apiKey: key,
201
- path: AVAILABLE_MODELS_PATH,
275
+ path: PUBLIC_MODELS_PATH,
202
276
  fetchImpl,
203
277
  timeoutMs,
204
278
  signal,
205
279
  });
206
- return liveCatalog(discovered, selectedModel, 'available');
207
- } catch (availableError) {
208
- if (isAbortError(availableError) || signal?.aborted) throw availableError;
209
- try {
210
- const discovered = await fetchPublicModels({
211
- baseUrl,
212
- fetchImpl,
213
- timeoutMs,
214
- signal,
215
- });
216
- return liveCatalog(discovered, selectedModel, 'public');
217
- } catch (publicError) {
218
- if (isAbortError(publicError) || signal?.aborted) throw publicError;
219
- return packagedCatalog(selectedModel, fallbackModels, availableError);
220
- }
280
+ // An older gateway answers /v1/models with a minimal public list;
281
+ // those ids still name the fleet, so use them rather than the
282
+ // packaged registry.
283
+ return discovered.canonical
284
+ ? liveCatalog(
285
+ discovered.models,
286
+ selectedModel,
287
+ 'catalog',
288
+ discovered.entries,
289
+ )
290
+ : liveCatalog(discovered.models, selectedModel, 'public');
291
+ } catch (error) {
292
+ if (isAbortError(error) || signal?.aborted) throw error;
293
+ catalogError = error;
294
+ }
295
+
296
+ try {
297
+ const discovered = await fetchGatewayCatalog({
298
+ baseUrl,
299
+ fetchImpl,
300
+ timeoutMs,
301
+ signal,
302
+ });
303
+ return liveCatalog(discovered.models, selectedModel, 'public');
304
+ } catch (publicError) {
305
+ if (isAbortError(publicError) || signal?.aborted) throw publicError;
306
+ return packagedCatalog(
307
+ selectedModel,
308
+ fallbackModels,
309
+ catalogError ?? publicError,
310
+ );
221
311
  }
222
312
  }
223
313
 
224
314
  try {
225
- const discovered = await fetchPublicModels({
315
+ const discovered = await fetchGatewayCatalog({
226
316
  baseUrl,
227
317
  fetchImpl,
228
318
  timeoutMs,
229
319
  signal,
230
320
  });
231
- return liveCatalog(discovered, selectedModel, 'public');
321
+ return liveCatalog(discovered.models, selectedModel, 'public');
232
322
  } catch (error) {
233
323
  if (isAbortError(error) || signal?.aborted) throw error;
234
324
  return packagedCatalog(selectedModel, fallbackModels, error);
Binary file
Binary file
Binary file
Binary file