@provos/ironcurtain 0.12.0 → 0.13.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.
Files changed (211) hide show
  1. package/README.md +14 -12
  2. package/dist/cli.js +6 -0
  3. package/dist/cli.js.map +1 -1
  4. package/dist/config/config-command.d.ts +33 -1
  5. package/dist/config/config-command.js +608 -11
  6. package/dist/config/config-command.js.map +1 -1
  7. package/dist/config/openrouter-catalog.d.ts +46 -0
  8. package/dist/config/openrouter-catalog.js +111 -0
  9. package/dist/config/openrouter-catalog.js.map +1 -0
  10. package/dist/config/openrouter-models-fallback.d.ts +15 -0
  11. package/dist/config/openrouter-models-fallback.js +49 -0
  12. package/dist/config/openrouter-models-fallback.js.map +1 -0
  13. package/dist/config/types.d.ts +8 -0
  14. package/dist/config/user-config.d.ts +133 -7
  15. package/dist/config/user-config.js +234 -5
  16. package/dist/config/user-config.js.map +1 -1
  17. package/dist/daemon/daemon-command.js.map +1 -1
  18. package/dist/daemon/ironcurtain-daemon.js +6 -0
  19. package/dist/daemon/ironcurtain-daemon.js.map +1 -1
  20. package/dist/docker/adapters/claude-code.js +109 -1
  21. package/dist/docker/adapters/claude-code.js.map +1 -1
  22. package/dist/docker/adapters/codex.d.ts +10 -1
  23. package/dist/docker/adapters/codex.js +99 -20
  24. package/dist/docker/adapters/codex.js.map +1 -1
  25. package/dist/docker/adapters/goose.js +38 -2
  26. package/dist/docker/adapters/goose.js.map +1 -1
  27. package/dist/docker/agent-adapter.d.ts +29 -5
  28. package/dist/docker/agent-registry.js +1 -1
  29. package/dist/docker/agent-registry.js.map +1 -1
  30. package/dist/docker/apple-container-manager.d.ts +4 -4
  31. package/dist/docker/apple-container-manager.js +44 -22
  32. package/dist/docker/apple-container-manager.js.map +1 -1
  33. package/dist/docker/ca.js +9 -2
  34. package/dist/docker/ca.js.map +1 -1
  35. package/dist/docker/container-lifecycle.js +9 -0
  36. package/dist/docker/container-lifecycle.js.map +1 -1
  37. package/dist/docker/docker-agent-session.d.ts +31 -0
  38. package/dist/docker/docker-agent-session.js +67 -6
  39. package/dist/docker/docker-agent-session.js.map +1 -1
  40. package/dist/docker/docker-infrastructure.d.ts +55 -15
  41. package/dist/docker/docker-infrastructure.js +261 -61
  42. package/dist/docker/docker-infrastructure.js.map +1 -1
  43. package/dist/docker/docker-manager.js +127 -0
  44. package/dist/docker/docker-manager.js.map +1 -1
  45. package/dist/docker/docker-resource-lifecycle.d.ts +71 -0
  46. package/dist/docker/docker-resource-lifecycle.js +454 -0
  47. package/dist/docker/docker-resource-lifecycle.js.map +1 -0
  48. package/dist/docker/gc-command.d.ts +2 -0
  49. package/dist/docker/gc-command.js +37 -0
  50. package/dist/docker/gc-command.js.map +1 -0
  51. package/dist/docker/mitm-proxy.d.ts +6 -4
  52. package/dist/docker/mitm-proxy.js +105 -17
  53. package/dist/docker/mitm-proxy.js.map +1 -1
  54. package/dist/docker/network-topology.d.ts +16 -9
  55. package/dist/docker/network-topology.js +17 -10
  56. package/dist/docker/network-topology.js.map +1 -1
  57. package/dist/docker/openrouter.d.ts +107 -0
  58. package/dist/docker/openrouter.js +236 -0
  59. package/dist/docker/openrouter.js.map +1 -0
  60. package/dist/docker/orientation.js +1 -1
  61. package/dist/docker/orientation.js.map +1 -1
  62. package/dist/docker/platform.d.ts +3 -1
  63. package/dist/docker/platform.js +3 -1
  64. package/dist/docker/platform.js.map +1 -1
  65. package/dist/docker/provider-config.d.ts +6 -0
  66. package/dist/docker/provider-config.js.map +1 -1
  67. package/dist/docker/pty-session.d.ts +7 -0
  68. package/dist/docker/pty-session.js +111 -29
  69. package/dist/docker/pty-session.js.map +1 -1
  70. package/dist/docker/pty-types.d.ts +15 -0
  71. package/dist/docker/pty-types.js +8 -0
  72. package/dist/docker/pty-types.js.map +1 -1
  73. package/dist/docker/sse-extractor.js +55 -5
  74. package/dist/docker/sse-extractor.js.map +1 -1
  75. package/dist/docker/stream-delay.d.ts +86 -0
  76. package/dist/docker/stream-delay.js +218 -0
  77. package/dist/docker/stream-delay.js.map +1 -0
  78. package/dist/docker/token-stream-types.d.ts +15 -0
  79. package/dist/docker/trajectory-reassembler.d.ts +2 -2
  80. package/dist/docker/trajectory-reassembler.js +22 -2
  81. package/dist/docker/trajectory-reassembler.js.map +1 -1
  82. package/dist/docker/trajectory-tap.js +2 -2
  83. package/dist/docker/trajectory-tap.js.map +1 -1
  84. package/dist/docker/types.d.ts +63 -5
  85. package/dist/doctor/checks.d.ts +3 -3
  86. package/dist/doctor/checks.js +28 -11
  87. package/dist/doctor/checks.js.map +1 -1
  88. package/dist/doctor/doctor-command.js +1 -1
  89. package/dist/doctor/doctor-command.js.map +1 -1
  90. package/dist/escalation/escalation-watcher.js +6 -0
  91. package/dist/escalation/escalation-watcher.js.map +1 -1
  92. package/dist/{mux → escalation}/trusted-input.d.ts +7 -4
  93. package/dist/{mux → escalation}/trusted-input.js +8 -5
  94. package/dist/escalation/trusted-input.js.map +1 -0
  95. package/dist/hash.js.map +1 -1
  96. package/dist/index.js +37 -6
  97. package/dist/index.js.map +1 -1
  98. package/dist/mux/mux-app.d.ts +7 -0
  99. package/dist/mux/mux-app.js +17 -16
  100. package/dist/mux/mux-app.js.map +1 -1
  101. package/dist/mux/mux-command.js +8 -3
  102. package/dist/mux/mux-command.js.map +1 -1
  103. package/dist/mux/mux-input-handler.d.ts +26 -2
  104. package/dist/mux/mux-input-handler.js +93 -11
  105. package/dist/mux/mux-input-handler.js.map +1 -1
  106. package/dist/mux/mux-renderer.d.ts +2 -1
  107. package/dist/mux/mux-renderer.js +67 -0
  108. package/dist/mux/mux-renderer.js.map +1 -1
  109. package/dist/mux/provider-profile-snapshot.d.ts +44 -0
  110. package/dist/mux/provider-profile-snapshot.js +56 -0
  111. package/dist/mux/provider-profile-snapshot.js.map +1 -0
  112. package/dist/mux/types.d.ts +3 -2
  113. package/dist/mux/types.js +6 -2
  114. package/dist/mux/types.js.map +1 -1
  115. package/dist/observe/observe-command.js.map +1 -1
  116. package/dist/pipeline/constitution-compiler.js.map +1 -1
  117. package/dist/pipeline/generate-with-repair.js.map +1 -1
  118. package/dist/pipeline/refresh-lists.js.map +1 -1
  119. package/dist/pipeline/scenario-schema-validator.js.map +1 -1
  120. package/dist/{mux → pty}/pty-bridge.d.ts +39 -3
  121. package/dist/{mux → pty}/pty-bridge.js +68 -18
  122. package/dist/pty/pty-bridge.js.map +1 -0
  123. package/dist/pty/resolve-ironcurtain-bin.d.ts +17 -0
  124. package/dist/pty/resolve-ironcurtain-bin.js +22 -0
  125. package/dist/pty/resolve-ironcurtain-bin.js.map +1 -0
  126. package/dist/servers/fetch-server.js +2 -0
  127. package/dist/servers/fetch-server.js.map +1 -1
  128. package/dist/session/index.d.ts +1 -1
  129. package/dist/session/index.js +11 -2
  130. package/dist/session/index.js.map +1 -1
  131. package/dist/session/preflight.d.ts +19 -5
  132. package/dist/session/preflight.js +41 -25
  133. package/dist/session/preflight.js.map +1 -1
  134. package/dist/session/resource-budget-tracker.js +8 -0
  135. package/dist/session/resource-budget-tracker.js.map +1 -1
  136. package/dist/session/session-manager.d.ts +7 -0
  137. package/dist/session/session-manager.js.map +1 -1
  138. package/dist/session/types.d.ts +14 -0
  139. package/dist/signal/signal-bot-daemon.d.ts +1 -0
  140. package/dist/signal/signal-bot-daemon.js +9 -2
  141. package/dist/signal/signal-bot-daemon.js.map +1 -1
  142. package/dist/trusted-process/mcp-error-utils.js +4 -1
  143. package/dist/trusted-process/mcp-error-utils.js.map +1 -1
  144. package/dist/trusted-process/mcp-proxy-server.js +2 -0
  145. package/dist/trusted-process/mcp-proxy-server.js.map +1 -1
  146. package/dist/trusted-process/policy-engine.js.map +1 -1
  147. package/dist/trusted-process/tcp-server-transport.d.ts +7 -0
  148. package/dist/trusted-process/tcp-server-transport.js +48 -10
  149. package/dist/trusted-process/tcp-server-transport.js.map +1 -1
  150. package/dist/utils/preflight-checks.js +1 -3
  151. package/dist/utils/preflight-checks.js.map +1 -1
  152. package/dist/utils/stdio-guard.d.ts +1 -0
  153. package/dist/utils/stdio-guard.js +27 -0
  154. package/dist/utils/stdio-guard.js.map +1 -0
  155. package/dist/web-ui/__tests__/config-dispatch.test.d.ts +13 -0
  156. package/dist/web-ui/__tests__/config-dispatch.test.js +383 -0
  157. package/dist/web-ui/__tests__/config-dispatch.test.js.map +1 -0
  158. package/dist/web-ui/__tests__/json-rpc-dispatch.test.js +6 -0
  159. package/dist/web-ui/__tests__/json-rpc-dispatch.test.js.map +1 -1
  160. package/dist/web-ui/dispatch/config-dispatch.d.ts +15 -0
  161. package/dist/web-ui/dispatch/config-dispatch.js +277 -0
  162. package/dist/web-ui/dispatch/config-dispatch.js.map +1 -0
  163. package/dist/web-ui/dispatch/escalation-dispatch.js +11 -2
  164. package/dist/web-ui/dispatch/escalation-dispatch.js.map +1 -1
  165. package/dist/web-ui/dispatch/pty-dispatch.d.ts +15 -0
  166. package/dist/web-ui/dispatch/pty-dispatch.js +66 -0
  167. package/dist/web-ui/dispatch/pty-dispatch.js.map +1 -0
  168. package/dist/web-ui/dispatch/session-dispatch.js +69 -4
  169. package/dist/web-ui/dispatch/session-dispatch.js.map +1 -1
  170. package/dist/web-ui/dispatch/types.d.ts +12 -0
  171. package/dist/web-ui/dispatch/types.js +38 -1
  172. package/dist/web-ui/dispatch/types.js.map +1 -1
  173. package/dist/web-ui/dispatch/workflow-dispatch.js.map +1 -1
  174. package/dist/web-ui/json-rpc-dispatch.js +3 -0
  175. package/dist/web-ui/json-rpc-dispatch.js.map +1 -1
  176. package/dist/web-ui/pty-session-manager.d.ts +234 -0
  177. package/dist/web-ui/pty-session-manager.js +599 -0
  178. package/dist/web-ui/pty-session-manager.js.map +1 -0
  179. package/dist/web-ui/web-event-bus.d.ts +9 -0
  180. package/dist/web-ui/web-event-bus.js.map +1 -1
  181. package/dist/web-ui/web-session-transport.js.map +1 -1
  182. package/dist/web-ui/web-ui-server.d.ts +8 -0
  183. package/dist/web-ui/web-ui-server.js +25 -0
  184. package/dist/web-ui/web-ui-server.js.map +1 -1
  185. package/dist/web-ui/web-ui-types.d.ts +80 -1
  186. package/dist/web-ui/web-ui-types.js.map +1 -1
  187. package/dist/web-ui-static/assets/index-CCVI4nEK.css +1 -0
  188. package/dist/web-ui-static/assets/index-Dm012gvY.js +114 -0
  189. package/dist/web-ui-static/index.html +2 -2
  190. package/dist/workflow/daemon-gate-commands.js +38 -42
  191. package/dist/workflow/daemon-gate-commands.js.map +1 -1
  192. package/dist/workflow/machine-builder.js.map +1 -1
  193. package/dist/workflow/orchestrator.js +10 -7
  194. package/dist/workflow/orchestrator.js.map +1 -1
  195. package/dist/workflow/shutdown-signals.d.ts +7 -0
  196. package/dist/workflow/shutdown-signals.js +40 -0
  197. package/dist/workflow/shutdown-signals.js.map +1 -0
  198. package/dist/workflow/validate.js.map +1 -1
  199. package/dist/workflow/workflow-command.js +10 -8
  200. package/dist/workflow/workflow-command.js.map +1 -1
  201. package/dist/workflow/workflows/evolve/README.md +66 -0
  202. package/dist/workflow/workflows/vuln-discovery/README.md +54 -0
  203. package/docker/entrypoint-claude-code.sh +22 -0
  204. package/package.json +31 -27
  205. package/src/workflow/workflows/evolve/README.md +66 -0
  206. package/src/workflow/workflows/vuln-discovery/README.md +54 -0
  207. package/dist/mux/pty-bridge.js.map +0 -1
  208. package/dist/mux/trusted-input.js.map +0 -1
  209. package/dist/web-ui-static/assets/index-BD5U6ILh.css +0 -1
  210. package/dist/web-ui-static/assets/index-FMsHWEfK.js +0 -79
  211. package/packages/web-ui/README.md +0 -113
@@ -9,8 +9,9 @@ import * as p from '@clack/prompts';
9
9
  import { readFileSync } from 'node:fs';
10
10
  import { resolve, dirname } from 'node:path';
11
11
  import { fileURLToPath } from 'node:url';
12
- import { loadUserConfig, saveUserConfig, validateModelId, ESCALATION_TIMEOUT_MIN, ESCALATION_TIMEOUT_MAX, WEB_SEARCH_PROVIDERS, WEB_SEARCH_PROVIDER_LABELS, WEB_SEARCH_PROVIDER_URLS, GOOSE_PROVIDERS, DOCKER_AGENTS, SESSION_MODES, CONTAINER_RUNTIMES, } from './user-config.js';
12
+ import { loadUserConfig, saveUserConfig, validateModelId, ESCALATION_TIMEOUT_MIN, ESCALATION_TIMEOUT_MAX, WEB_SEARCH_PROVIDERS, WEB_SEARCH_PROVIDER_LABELS, WEB_SEARCH_PROVIDER_URLS, GOOSE_PROVIDERS, DOCKER_AGENTS, SESSION_MODES, CONTAINER_RUNTIMES, NATIVE_PROFILE_NAME, DEFAULT_GLM_SLUG, maskApiKey, cloneProviderPreference, } from './user-config.js';
13
13
  import { getUserConfigPath } from './paths.js';
14
+ import { listOpenrouterModels, catalogEnforces, } from './openrouter-catalog.js';
14
15
  const __dirname = dirname(fileURLToPath(import.meta.url));
15
16
  /** Known model options for selection prompts. */
16
17
  const KNOWN_MODELS = [
@@ -57,17 +58,97 @@ export function formatCost(n) {
57
58
  return 'disabled';
58
59
  return `$${n.toFixed(2)}`;
59
60
  }
60
- export function maskApiKey(key) {
61
- if (!key)
62
- return 'none';
63
- if (key.length <= 6)
64
- return '***';
65
- return key.slice(0, 3) + '...' + key.slice(-3);
66
- }
67
61
  function formatModelShort(id) {
68
62
  const known = KNOWN_MODELS.find((m) => m.value === id);
69
63
  return known ? known.label : id;
70
64
  }
65
+ /**
66
+ * The current editor view of the `profiles` record, in input shape (openrouter
67
+ * profiles carry the fields the editor mutates). A pending `profiles` record,
68
+ * when present, is already the whole record (read-modify-write) and replaces the
69
+ * resolved view; otherwise the resolved profiles are converted to input shape.
70
+ * The implicit `native` profile is intentionally excluded — it is never
71
+ * persisted and is always shown separately as read-only.
72
+ */
73
+ function currentProfiles(resolved, pending) {
74
+ if (pending.modelProviders?.profiles) {
75
+ return { ...pending.modelProviders.profiles };
76
+ }
77
+ const view = {};
78
+ for (const [name, profile] of Object.entries(resolved.modelProviders.profiles)) {
79
+ if (name === NATIVE_PROFILE_NAME)
80
+ continue;
81
+ view[name] = resolvedProfileToInput(profile);
82
+ }
83
+ return view;
84
+ }
85
+ /** Converts a resolved profile back to its input shape (drops resolution-only defaults where empty). */
86
+ function resolvedProfileToInput(profile) {
87
+ if (profile.type === 'native')
88
+ return { type: 'native' };
89
+ const input = { type: 'openrouter' };
90
+ if (profile.apiKey)
91
+ input.apiKey = profile.apiKey;
92
+ // A default-tracking profile OMITS `modelMap` so an edit round-trip re-persists
93
+ // the omission rather than pinning today's materialized DEFAULT_MODEL_MAP.
94
+ if (!profile.usesDefaultMap)
95
+ input.modelMap = profile.modelMap.map((r) => ({ match: r.match, model: r.model }));
96
+ const perAgent = {};
97
+ for (const agent of DOCKER_AGENTS) {
98
+ const slug = profile.perAgent[agent];
99
+ if (slug)
100
+ perAgent[agent] = slug;
101
+ }
102
+ if (Object.keys(perAgent).length > 0)
103
+ input.perAgent = perAgent;
104
+ if (profile.providerPreference) {
105
+ input.providerPreference = cloneProviderPreference(profile.providerPreference);
106
+ }
107
+ input.sessionAffinity = profile.sessionAffinity;
108
+ return input;
109
+ }
110
+ /** The current default profile name (pending override wins over the resolved value). */
111
+ function currentDefault(resolved, pending) {
112
+ return pending.modelProviders?.default ?? resolved.modelProviders.default;
113
+ }
114
+ /**
115
+ * F10 — re-point a dangling `default` to `native`. Returns `NATIVE_PROFILE_NAME`
116
+ * when the current default no longer names a profile in `remainingNames` (and is
117
+ * not itself `native`), otherwise returns the current default unchanged. Pure so
118
+ * the delete-repoints-default invariant is unit-testable without the interactive
119
+ * flow. Persisting a `default` that names a missing profile would make the next
120
+ * `loadUserConfig` a HARD error (the Zod `.refine`), so callers must apply this
121
+ * in the same pending write that deletes a profile.
122
+ */
123
+ export function repointDefaultAfterDelete(currentDefaultName, remainingNames) {
124
+ if (currentDefaultName === NATIVE_PROFILE_NAME)
125
+ return currentDefaultName;
126
+ return remainingNames.includes(currentDefaultName) ? currentDefaultName : NATIVE_PROFILE_NAME;
127
+ }
128
+ /** Compact one-line summary of an openrouter profile for list/hint rendering. */
129
+ function summarizeOpenrouterProfile(profile) {
130
+ const parts = [];
131
+ const map = profile.modelMap;
132
+ if (map && map.length === 0) {
133
+ parts.push('per-agent only');
134
+ }
135
+ else if (map && map.length > 0) {
136
+ parts.push(map.length === 1 ? `-> ${map[0].model}` : `${map.length} map rules`);
137
+ }
138
+ else {
139
+ parts.push('default map');
140
+ }
141
+ parts.push(`key: ${maskApiKey(profile.apiKey)}`);
142
+ return parts.join(', ');
143
+ }
144
+ /** Compact rendering of a modelMap for diffs: `*sonnet*->z-ai/glm-5.2; *opus*->…`. */
145
+ function formatModelMap(map) {
146
+ if (!map)
147
+ return 'default';
148
+ if (map.length === 0)
149
+ return 'per-agent only (empty map)';
150
+ return map.map((r) => `${r.match}->${r.model}`).join('; ');
151
+ }
71
152
  export function computeDiff(resolved, pending) {
72
153
  const diffs = [];
73
154
  const topLevelKeys = [
@@ -142,8 +223,77 @@ export function computeDiff(resolved, pending) {
142
223
  }
143
224
  }
144
225
  }
226
+ diffModelProviders(resolved, pending, diffs);
145
227
  return diffs;
146
228
  }
229
+ /**
230
+ * Dedicated `modelProviders` diff branch (not the generic nestedSections loop,
231
+ * which compares by reference and would spuriously diff freshly-materialized
232
+ * profile objects — m14). Deep-compares each profile field via JSON.stringify so
233
+ * a no-op read-modify-write yields an EMPTY diff; masks every `apiKey`; renders
234
+ * modelMap rows compactly.
235
+ */
236
+ function diffModelProviders(resolved, pending, diffs) {
237
+ const pendingMp = pending.modelProviders;
238
+ if (!pendingMp)
239
+ return;
240
+ // default selector
241
+ const resolvedDefault = resolved.modelProviders.default;
242
+ if (pendingMp.default !== undefined && pendingMp.default !== resolvedDefault) {
243
+ diffs.push(['modelProviders.default', { from: resolvedDefault, to: pendingMp.default }]);
244
+ }
245
+ if (!pendingMp.profiles)
246
+ return;
247
+ const resolvedProfiles = resolved.modelProviders.profiles;
248
+ const pendingProfiles = pendingMp.profiles;
249
+ // Added / changed profiles.
250
+ for (const [name, pendingProfile] of Object.entries(pendingProfiles)) {
251
+ if (name === NATIVE_PROFILE_NAME)
252
+ continue;
253
+ const before = name in resolvedProfiles ? resolvedProfileToInput(resolvedProfiles[name]) : undefined;
254
+ diffOneProfile(name, before, pendingProfile, diffs);
255
+ }
256
+ // Removed profiles (present in resolved, absent in the whole-record pending write).
257
+ for (const name of Object.keys(resolvedProfiles)) {
258
+ if (name === NATIVE_PROFILE_NAME)
259
+ continue;
260
+ if (!(name in pendingProfiles)) {
261
+ diffs.push([`modelProviders.profiles.${name}`, { from: 'configured', to: 'removed' }]);
262
+ }
263
+ }
264
+ }
265
+ /** Field-by-field diff of one profile, masking apiKey and rendering modelMap compactly. */
266
+ function diffOneProfile(name, before, after, diffs) {
267
+ const prefix = `modelProviders.profiles.${name}`;
268
+ if (!before) {
269
+ diffs.push([prefix, { from: 'none', to: `added (${after.type})` }]);
270
+ return;
271
+ }
272
+ if (before.type !== after.type) {
273
+ diffs.push([`${prefix}.type`, { from: before.type, to: after.type }]);
274
+ return;
275
+ }
276
+ if (before.type === 'native' || after.type === 'native')
277
+ return;
278
+ if (before.apiKey !== after.apiKey) {
279
+ diffs.push([`${prefix}.apiKey`, { from: maskApiKey(before.apiKey), to: maskApiKey(after.apiKey) }]);
280
+ }
281
+ if (JSON.stringify(before.modelMap) !== JSON.stringify(after.modelMap)) {
282
+ diffs.push([`${prefix}.modelMap`, { from: formatModelMap(before.modelMap), to: formatModelMap(after.modelMap) }]);
283
+ }
284
+ if (JSON.stringify(before.perAgent) !== JSON.stringify(after.perAgent)) {
285
+ diffs.push([`${prefix}.perAgent`, { from: before.perAgent ?? {}, to: after.perAgent ?? {} }]);
286
+ }
287
+ if (JSON.stringify(before.providerPreference) !== JSON.stringify(after.providerPreference)) {
288
+ diffs.push([
289
+ `${prefix}.providerPreference`,
290
+ { from: before.providerPreference ?? 'default', to: after.providerPreference ?? 'default' },
291
+ ]);
292
+ }
293
+ if (before.sessionAffinity !== after.sessionAffinity) {
294
+ diffs.push([`${prefix}.sessionAffinity`, { from: before.sessionAffinity, to: after.sessionAffinity }]);
295
+ }
296
+ }
147
297
  function formatDiffValue(key, value) {
148
298
  if (value === null)
149
299
  return 'disabled';
@@ -157,7 +307,11 @@ function formatDiffValue(key, value) {
157
307
  return formatSeconds(value);
158
308
  if (key.includes('Cost'))
159
309
  return formatCost(value);
160
- return String(value);
310
+ if (typeof value === 'string')
311
+ return value;
312
+ if (typeof value === 'number')
313
+ return String(value);
314
+ return JSON.stringify(value);
161
315
  }
162
316
  // ─── Model prompt ────────────────────────────────────────────
163
317
  async function promptModelId(message, current) {
@@ -648,6 +802,437 @@ async function handleWebSearch(resolved, pending) {
648
802
  };
649
803
  }
650
804
  }
805
+ // ─── Model Providers ─────────────────────────────────────────
806
+ /** Returns a shallow copy of `obj` without the given key (avoids `delete obj[dynamicKey]`). */
807
+ function omitKey(obj, key) {
808
+ const copy = { ...obj };
809
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- key is intentionally dynamic here
810
+ delete copy[key];
811
+ return copy;
812
+ }
813
+ /**
814
+ * Writes the whole `profiles` record + `default` into pending (read-modify-write,
815
+ * required by the shallow `deepMergeConfig`). Callers always pass the complete
816
+ * view so no unmentioned profile is dropped unintentionally.
817
+ */
818
+ function commitModelProviders(pending, profiles, defaultName) {
819
+ pending.modelProviders = { default: defaultName, profiles };
820
+ }
821
+ async function handleModelProviders(resolved, pending) {
822
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- interactive loop exited via return
823
+ while (true) {
824
+ const profiles = currentProfiles(resolved, pending);
825
+ const defaultName = currentDefault(resolved, pending);
826
+ const options = [
827
+ {
828
+ value: `profile:${NATIVE_PROFILE_NAME}`,
829
+ label: NATIVE_PROFILE_NAME,
830
+ hint: defaultName === NATIVE_PROFILE_NAME ? 'native routing (default, not editable)' : 'native routing',
831
+ },
832
+ ];
833
+ for (const [name, profile] of Object.entries(profiles)) {
834
+ const summary = profile.type === 'openrouter' ? summarizeOpenrouterProfile(profile) : 'native';
835
+ options.push({
836
+ value: `profile:${name}`,
837
+ label: name,
838
+ hint: `${profile.type}, ${summary}${name === defaultName ? ' (default)' : ''}`,
839
+ });
840
+ }
841
+ options.push({ value: 'add', label: 'Add profile...' });
842
+ options.push({ value: 'default', label: 'Set default', hint: defaultName });
843
+ options.push({ value: 'back', label: 'Back' });
844
+ const action = await p.select({ message: 'Model Providers', options });
845
+ if (isCancelled(action) || action === 'back')
846
+ return;
847
+ if (action === 'add') {
848
+ await addProfile(resolved, pending);
849
+ }
850
+ else if (action === 'default') {
851
+ await setDefaultProfile(resolved, pending);
852
+ }
853
+ else if (typeof action === 'string' && action.startsWith('profile:')) {
854
+ const name = action.slice('profile:'.length);
855
+ if (name === NATIVE_PROFILE_NAME) {
856
+ p.note('The native profile uses today’s canonical routing and cannot be edited or deleted.', 'native');
857
+ continue;
858
+ }
859
+ await editProfile(resolved, pending, name);
860
+ }
861
+ }
862
+ }
863
+ async function addProfile(resolved, pending) {
864
+ const profiles = currentProfiles(resolved, pending);
865
+ const name = await p.text({
866
+ message: 'Profile name:',
867
+ placeholder: 'e.g. glm-5.2',
868
+ validate: (val) => {
869
+ if (!val || val.trim() === '')
870
+ return 'Name is required';
871
+ if (val === NATIVE_PROFILE_NAME)
872
+ return `"${NATIVE_PROFILE_NAME}" is a reserved profile name.`;
873
+ if (val in profiles)
874
+ return `Profile "${val}" already exists.`;
875
+ return undefined;
876
+ },
877
+ });
878
+ if (isCancelled(name))
879
+ return;
880
+ // v1 supports only the 'openrouter' type; native is implicit and never user-defined.
881
+ p.note('OpenRouter routes Docker agents through openrouter.ai with a bound model map + key.\n' +
882
+ 'Paste an sk-or-v1-... key; defaults map *sonnet*/*opus*/*haiku* -> ' +
883
+ `${DEFAULT_GLM_SLUG} with a soft z-ai cache pin.`, 'openrouter');
884
+ const apiKey = await p.text({
885
+ message: 'OpenRouter API key (sk-or-v1-...):',
886
+ placeholder: 'set via OPENROUTER_API_KEY env, or leave blank',
887
+ validate: () => undefined,
888
+ });
889
+ if (isCancelled(apiKey))
890
+ return;
891
+ const profile = { type: 'openrouter' };
892
+ if (apiKey)
893
+ profile.apiKey = apiKey;
894
+ profiles[name] = profile;
895
+ commitModelProviders(pending, profiles, currentDefault(resolved, pending));
896
+ }
897
+ async function setDefaultProfile(resolved, pending) {
898
+ const profiles = currentProfiles(resolved, pending);
899
+ const currentName = currentDefault(resolved, pending);
900
+ const options = [
901
+ {
902
+ value: NATIVE_PROFILE_NAME,
903
+ label: NATIVE_PROFILE_NAME,
904
+ hint: currentName === NATIVE_PROFILE_NAME ? '(current)' : undefined,
905
+ },
906
+ ...Object.keys(profiles).map((name) => ({
907
+ value: name,
908
+ label: name,
909
+ hint: name === currentName ? '(current)' : undefined,
910
+ })),
911
+ ];
912
+ const selected = await p.select({
913
+ message: 'Default profile (applies when no per-session choice is made):',
914
+ options,
915
+ });
916
+ if (isCancelled(selected))
917
+ return;
918
+ const name = selected;
919
+ if (name !== currentName) {
920
+ commitModelProviders(pending, profiles, name);
921
+ }
922
+ }
923
+ async function editProfile(resolved, pending, name) {
924
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- interactive loop exited via return
925
+ while (true) {
926
+ const profiles = currentProfiles(resolved, pending);
927
+ if (!(name in profiles))
928
+ return;
929
+ const profile = profiles[name];
930
+ if (profile.type !== 'openrouter')
931
+ return;
932
+ const field = await p.select({
933
+ message: `Profile: ${name}`,
934
+ options: [
935
+ { value: 'apiKey', label: 'API key', hint: maskApiKey(profile.apiKey) },
936
+ { value: 'modelMap', label: 'Model map (glob -> slug)', hint: formatModelMap(profile.modelMap) },
937
+ { value: 'perAgent', label: 'Per-agent model overrides', hint: perAgentSummary(profile) },
938
+ {
939
+ value: 'providerPreference',
940
+ label: 'Provider preference (cache pinning)',
941
+ hint: providerPreferenceSummary(profile.providerPreference),
942
+ },
943
+ {
944
+ value: 'sessionAffinity',
945
+ label: 'Session affinity (GLM cache)',
946
+ hint: (profile.sessionAffinity ?? true) ? 'on' : 'off',
947
+ },
948
+ { value: 'delete', label: 'Delete profile' },
949
+ { value: 'back', label: 'Back' },
950
+ ],
951
+ });
952
+ if (isCancelled(field) || field === 'back')
953
+ return;
954
+ if (field === 'delete') {
955
+ const confirmed = await p.confirm({ message: `Delete profile "${name}"?`, initialValue: false });
956
+ if (isCancelled(confirmed) || !confirmed)
957
+ continue;
958
+ const remaining = omitKey(profiles, name);
959
+ // F10: never persist a `default` that names the just-deleted profile.
960
+ const nextDefault = repointDefaultAfterDelete(currentDefault(resolved, pending), Object.keys(remaining));
961
+ commitModelProviders(pending, remaining, nextDefault);
962
+ return;
963
+ }
964
+ const updated = await editProfileField(field, profile);
965
+ if (updated) {
966
+ profiles[name] = updated;
967
+ commitModelProviders(pending, profiles, currentDefault(resolved, pending));
968
+ }
969
+ }
970
+ }
971
+ /** Edits one field of an openrouter profile, returning the updated profile or undefined (no change). */
972
+ async function editProfileField(field, profile) {
973
+ if (field === 'apiKey') {
974
+ const apiKey = await p.text({
975
+ message: 'OpenRouter API key (sk-or-v1-...):',
976
+ placeholder: profile.apiKey ? '(keep current)' : 'leave blank to use OPENROUTER_API_KEY env',
977
+ validate: () => undefined,
978
+ });
979
+ if (isCancelled(apiKey))
980
+ return undefined;
981
+ const next = { ...profile };
982
+ if (apiKey)
983
+ next.apiKey = apiKey;
984
+ else
985
+ delete next.apiKey;
986
+ return next;
987
+ }
988
+ if (field === 'modelMap')
989
+ return editModelMap(profile);
990
+ if (field === 'perAgent')
991
+ return editPerAgent(profile);
992
+ if (field === 'providerPreference')
993
+ return editProviderPreference(profile);
994
+ if (field === 'sessionAffinity') {
995
+ const enabled = await p.confirm({
996
+ message: 'Inject a stable session_id for GLM cache affinity?',
997
+ initialValue: profile.sessionAffinity ?? true,
998
+ });
999
+ if (isCancelled(enabled))
1000
+ return undefined;
1001
+ return { ...profile, sessionAffinity: enabled };
1002
+ }
1003
+ return undefined;
1004
+ }
1005
+ /**
1006
+ * Builds the option list for a model-slug autocomplete from the resolved catalog.
1007
+ *
1008
+ * - Every catalog slug becomes a `{ value, label }` option.
1009
+ * - `allowNone` prepends a `(none)` sentinel (value `''`) FIRST — the only way a
1010
+ * selection-only prompt can clear a per-agent override.
1011
+ * - A non-empty `current` absent from the catalog is appended as a grandfather
1012
+ * option, so editing a legacy/delisted slug never traps the user.
1013
+ *
1014
+ * There is intentionally NO refresh sentinel: `ironcurtain config` is short-lived
1015
+ * and fetches the catalog once, so re-running it IS the refresh path.
1016
+ */
1017
+ export function buildSlugOptions(catalog, current, opts) {
1018
+ const options = catalog.models.map((slug) => ({ value: slug, label: slug }));
1019
+ if (opts.allowNone) {
1020
+ options.unshift({ value: '', label: '(none — use model map)' });
1021
+ }
1022
+ if (current !== '' && !catalog.models.includes(current)) {
1023
+ options.push({ value: current, label: `${current} (current, unverified)` });
1024
+ }
1025
+ return options;
1026
+ }
1027
+ /** Autocomplete (hard-block) for an authoritative catalog; free text for the bundled floor. */
1028
+ export function slugPromptMode(source) {
1029
+ return catalogEnforces(source) ? 'autocomplete' : 'freetext';
1030
+ }
1031
+ /**
1032
+ * Prompts for one OpenRouter model slug. Fetches the catalog lazily (module-cached)
1033
+ * so apiKey / provider-preference-only edits never pay the network cost.
1034
+ *
1035
+ * Authoritative catalog (`live`/`cache`): a selection-only `autocomplete` whose
1036
+ * options ARE the allowlist — a structural hard-block — with `validate` as
1037
+ * belt-and-suspenders. For an UNSET field the `(none)` sentinel is first, so a
1038
+ * no-op Enter keeps it unset — clack auto-preselects the first option and collapses
1039
+ * a falsy `initialValue` to undefined; a non-empty `current` is passed as
1040
+ * `initialValue` so editing an existing slug starts on it.
1041
+ *
1042
+ * Bundled floor (offline): the list is known-incomplete, so free-text entry with a
1043
+ * one-line warning — the value is accepted as-is.
1044
+ *
1045
+ * Returns the chosen slug (`''` clears when `allowNone`), or `undefined` on cancel.
1046
+ */
1047
+ async function promptSlug(message, opts) {
1048
+ const { current, allowNone } = opts;
1049
+ const catalog = await listOpenrouterModels();
1050
+ if (slugPromptMode(catalog.source) === 'autocomplete') {
1051
+ // Allowlist = catalog slugs, plus `current` (grandfather a legacy/delisted slug)
1052
+ // and '' ONLY when the field is clearable (per-agent). A required field
1053
+ // (map-row model, allowNone:false) never admits an empty submit.
1054
+ const allowlist = new Set([...catalog.models, ...(current ? [current] : []), ...(allowNone ? [''] : [])]);
1055
+ const picked = await p.autocomplete({
1056
+ message,
1057
+ options: buildSlugOptions(catalog, current, { allowNone }),
1058
+ initialValue: current || '',
1059
+ initialUserInput: current || undefined,
1060
+ maxItems: 8,
1061
+ validate: (v) => (typeof v === 'string' && !allowlist.has(v) ? `Unknown model: ${v}` : undefined),
1062
+ });
1063
+ if (isCancelled(picked))
1064
+ return undefined;
1065
+ return picked;
1066
+ }
1067
+ p.note("Model catalog unavailable — slug can't be verified; entered value accepted as-is.");
1068
+ const typed = await p.text({
1069
+ message,
1070
+ placeholder: current || DEFAULT_GLM_SLUG,
1071
+ validate: allowNone ? () => undefined : (val) => (!val || !val.trim() ? 'Target slug is required' : undefined),
1072
+ });
1073
+ if (isCancelled(typed))
1074
+ return undefined;
1075
+ // Trim: a free-typed slug is persisted as-is, and " " would pass the schema's
1076
+ // min(1) yet break routing (and defeat "blank to clear" for per-agent). The web
1077
+ // path already trims (editableToDto / validateSlugs); keep the CLI consistent.
1078
+ return typed.trim();
1079
+ }
1080
+ async function editModelMap(profile) {
1081
+ const rows = [...(profile.modelMap ?? [])];
1082
+ p.note('Ordered glob -> slug rules; first match wins (matched against the requested model id).\n' +
1083
+ 'An EMPTY map means "per-agent-only mode": no glob mapping, rely on per-agent overrides.\n' +
1084
+ 'Leaving the map unset uses the built-in defaults (*sonnet*/*opus*/*haiku* -> ' +
1085
+ `${DEFAULT_GLM_SLUG}).`, 'Model map');
1086
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- interactive loop exited via return
1087
+ while (true) {
1088
+ const options = rows.map((row, i) => ({
1089
+ value: `row:${i}`,
1090
+ label: `${row.match} -> ${row.model}`,
1091
+ hint: 'select to remove',
1092
+ }));
1093
+ options.push({ value: 'add', label: 'Add rule...' });
1094
+ options.push({ value: 'done', label: 'Done', hint: rows.length === 0 ? 'empty = per-agent-only mode' : undefined });
1095
+ const action = await p.select({ message: `Model map (${rows.length} rule(s))`, options });
1096
+ if (isCancelled(action) || action === 'done')
1097
+ break;
1098
+ if (action === 'add') {
1099
+ const match = await p.text({
1100
+ message: 'Glob to match (e.g. *sonnet*):',
1101
+ validate: (val) => (!val ? 'Match glob is required' : undefined),
1102
+ });
1103
+ if (isCancelled(match))
1104
+ continue;
1105
+ const model = await promptSlug('Target OpenRouter slug (e.g. z-ai/glm-5.2):', {
1106
+ current: '',
1107
+ allowNone: false,
1108
+ });
1109
+ if (model === undefined)
1110
+ continue;
1111
+ rows.push({ match: match, model });
1112
+ }
1113
+ else if (typeof action === 'string' && action.startsWith('row:')) {
1114
+ const index = Number(action.slice('row:'.length));
1115
+ rows.splice(index, 1);
1116
+ }
1117
+ }
1118
+ return { ...profile, modelMap: rows };
1119
+ }
1120
+ async function editPerAgent(profile) {
1121
+ let perAgent = { ...(profile.perAgent ?? {}) };
1122
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- interactive loop exited via return
1123
+ while (true) {
1124
+ const options = DOCKER_AGENTS.map((agent) => ({
1125
+ value: agent,
1126
+ label: DOCKER_AGENT_LABELS[agent],
1127
+ hint: perAgent[agent] ?? 'none (use model map)',
1128
+ }));
1129
+ const selected = await p.select({
1130
+ message: 'Per-agent model overrides (wins over the model map)',
1131
+ options: [...options, { value: 'back', label: 'Back', hint: '' }],
1132
+ });
1133
+ if (isCancelled(selected) || selected === 'back')
1134
+ break;
1135
+ const agent = selected;
1136
+ const slug = await promptSlug(`${DOCKER_AGENT_LABELS[agent]} model slug (blank to clear):`, {
1137
+ current: perAgent[agent] ?? '',
1138
+ allowNone: true,
1139
+ });
1140
+ if (slug === undefined)
1141
+ continue;
1142
+ if (slug)
1143
+ perAgent[agent] = slug;
1144
+ else
1145
+ perAgent = omitKey(perAgent, agent);
1146
+ }
1147
+ const next = { ...profile };
1148
+ if (Object.keys(perAgent).length > 0)
1149
+ next.perAgent = perAgent;
1150
+ else
1151
+ delete next.perAgent;
1152
+ return next;
1153
+ }
1154
+ async function editProviderPreference(profile) {
1155
+ const pref = { ...(profile.providerPreference ?? {}) };
1156
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- interactive loop exited via return
1157
+ while (true) {
1158
+ const field = await p.select({
1159
+ message: 'Provider preference (leave unset for the default soft z-ai pin on z-ai/* slugs)',
1160
+ options: [
1161
+ { value: 'order', label: 'order (soft pin)', hint: pref.order?.join(', ') ?? 'none' },
1162
+ { value: 'only', label: 'only (strict pin)', hint: pref.only?.join(', ') ?? 'none' },
1163
+ {
1164
+ value: 'allowFallbacks',
1165
+ label: 'allowFallbacks',
1166
+ hint: pref.allowFallbacks === undefined ? 'default (true)' : pref.allowFallbacks ? 'true' : 'false',
1167
+ },
1168
+ { value: 'clear', label: 'Clear (revert to default pin)' },
1169
+ { value: 'back', label: 'Back' },
1170
+ ],
1171
+ });
1172
+ if (isCancelled(field) || field === 'back')
1173
+ break;
1174
+ if (field === 'clear') {
1175
+ return { ...profile, providerPreference: undefined };
1176
+ }
1177
+ if (field === 'order' || field === 'only') {
1178
+ const input = await p.text({
1179
+ message: `${field} — comma-separated provider slugs (e.g. z-ai), blank to clear:`,
1180
+ placeholder: pref[field]?.join(', ') ?? 'z-ai',
1181
+ validate: () => undefined,
1182
+ });
1183
+ if (isCancelled(input))
1184
+ continue;
1185
+ const list = input
1186
+ .split(',')
1187
+ .map((s) => s.trim())
1188
+ .filter((s) => s.length > 0);
1189
+ const next = list.length > 0 ? list : undefined;
1190
+ if (field === 'order')
1191
+ pref.order = next;
1192
+ else
1193
+ pref.only = next;
1194
+ }
1195
+ else if (field === 'allowFallbacks') {
1196
+ const enabled = await p.confirm({
1197
+ message: 'Allow OpenRouter to fall back to other providers?',
1198
+ initialValue: pref.allowFallbacks ?? true,
1199
+ });
1200
+ if (isCancelled(enabled))
1201
+ continue;
1202
+ pref.allowFallbacks = enabled;
1203
+ }
1204
+ }
1205
+ const cleaned = {};
1206
+ if (pref.order !== undefined)
1207
+ cleaned.order = pref.order;
1208
+ if (pref.only !== undefined)
1209
+ cleaned.only = pref.only;
1210
+ if (pref.allowFallbacks !== undefined)
1211
+ cleaned.allowFallbacks = pref.allowFallbacks;
1212
+ const hasAny = Object.keys(cleaned).length > 0;
1213
+ return { ...profile, providerPreference: hasAny ? cleaned : undefined };
1214
+ }
1215
+ function perAgentSummary(profile) {
1216
+ const perAgent = profile.perAgent;
1217
+ if (!perAgent)
1218
+ return 'none';
1219
+ const entries = Object.entries(perAgent).filter(([, v]) => v);
1220
+ if (entries.length === 0)
1221
+ return 'none';
1222
+ return entries.map(([agent, slug]) => `${agent}=${slug}`).join(', ');
1223
+ }
1224
+ function providerPreferenceSummary(pref) {
1225
+ if (!pref)
1226
+ return 'default (soft z-ai pin)';
1227
+ const parts = [];
1228
+ if (pref.only)
1229
+ parts.push(`only: ${pref.only.join(', ')}`);
1230
+ if (pref.order)
1231
+ parts.push(`order: ${pref.order.join(', ')}`);
1232
+ if (pref.allowFallbacks !== undefined)
1233
+ parts.push(`fallbacks: ${pref.allowFallbacks ? 'on' : 'off'}`);
1234
+ return parts.length > 0 ? parts.join(', ') : 'default';
1235
+ }
651
1236
  // ─── Server Credentials ──────────────────────────────────────
652
1237
  /** Loads server names from mcp-servers.json for the credential editor. */
653
1238
  function loadServerNames() {
@@ -760,12 +1345,12 @@ async function handleServerCredentials(resolved, pending) {
760
1345
  // ─── Session Mode ─────────────────────────────────────────────
761
1346
  /** Human-readable labels for session modes. */
762
1347
  const SESSION_MODE_LABELS = {
763
- docker: 'Docker (recommended)',
1348
+ container: 'Container (recommended)',
764
1349
  builtin: 'Builtin (V8 sandbox)',
765
1350
  };
766
1351
  /** Short labels used in hints (no parenthetical). */
767
1352
  const SESSION_MODE_SHORT_LABELS = {
768
- docker: 'Docker',
1353
+ container: 'Container',
769
1354
  builtin: 'Builtin',
770
1355
  };
771
1356
  /** Human-readable labels for container runtime backends. */
@@ -1052,6 +1637,14 @@ function webSearchHint(resolved, pending) {
1052
1637
  const provider = pending.webSearch?.provider ?? resolved.webSearch.provider;
1053
1638
  return provider ? WEB_SEARCH_PROVIDER_LABELS[provider] : 'not configured';
1054
1639
  }
1640
+ function modelProvidersHint(resolved, pending) {
1641
+ const profiles = currentProfiles(resolved, pending);
1642
+ const count = Object.keys(profiles).length;
1643
+ const defaultName = currentDefault(resolved, pending);
1644
+ if (count === 0)
1645
+ return `default: ${defaultName}`;
1646
+ return `${count} profile${count > 1 ? 's' : ''}, default: ${defaultName}`;
1647
+ }
1055
1648
  function serverCredentialsHint(resolved, pending) {
1056
1649
  const creds = { ...resolved.serverCredentials, ...pending.serverCredentials };
1057
1650
  const configured = Object.entries(creds).filter(([, v]) => Object.keys(v).length > 0);
@@ -1114,6 +1707,7 @@ export async function runConfigCommand() {
1114
1707
  { value: 'resources', label: `Resource Limits (${resourceHint(resolved, pending)})` },
1115
1708
  { value: 'compact', label: `Auto-Compact (${autoCompactHint(resolved, pending)})` },
1116
1709
  { value: 'websearch', label: `Web Search (${webSearchHint(resolved, pending)})` },
1710
+ { value: 'modelProviders', label: `Model Providers (${modelProvidersHint(resolved, pending)})` },
1117
1711
  { value: 'credentials', label: `Server Credentials (${serverCredentialsHint(resolved, pending)})` },
1118
1712
  { value: 'memory', label: `Memory (${memoryHint(resolved, pending)})` },
1119
1713
  { value: 'snapshots', label: `Container Snapshots (${snapshotHint(resolved, pending)})` },
@@ -1143,6 +1737,9 @@ export async function runConfigCommand() {
1143
1737
  case 'websearch':
1144
1738
  await handleWebSearch(resolved, pending);
1145
1739
  break;
1740
+ case 'modelProviders':
1741
+ await handleModelProviders(resolved, pending);
1742
+ break;
1146
1743
  case 'credentials':
1147
1744
  await handleServerCredentials(resolved, pending);
1148
1745
  break;