impel-cli 0.20.55 → 0.20.57

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/RELEASE_NOTES.md CHANGED
@@ -1,5 +1,94 @@
1
1
  # Release notes
2
2
 
3
+ ## 0.20.57 — Live tenant model catalogs for `impel codex` (`impel models sync`)
4
+
5
+ - Adds `impel models sync|list`: the isolated `impel codex` Codex picker now
6
+ follows the tenant's live gateway catalog (`GET /v1/models`) instead of
7
+ being frozen at CLI release time. `sync` rewrites the tenant `models.json`
8
+ atomically (with `--stale-only` TTL gating); `list` shows the picker
9
+ contents, whether they came from a gateway sync or the built-in offline
10
+ floor, and how stale they are.
11
+ - Launches never block on the network: `ensureImpelCodexProfile` stays
12
+ synchronous and keeps writing the built-in catalog as the offline floor,
13
+ while an async, never-throwing `syncCodexModelsSafe` layers the gateway
14
+ catalog after it — at launch, from the detached 6h stale-only app refresh,
15
+ from codex session hooks, and from the `impel token` heartbeat Codex calls
16
+ every ~5 minutes. A valid synced catalog survives relaunches
17
+ (stale-beats-broken); a failed sync keeps the last good file.
18
+ - Records catalog provenance in `models-manifest.json`
19
+ (`{schemaVersion, syncedAt, catalogVersion, digest}` plus tenant/gateway/
20
+ experiment bindings): tampered bytes or changed bindings fall back to the
21
+ floor, and `impel doctor` reports CLI catalog staleness per tenant.
22
+ - Cross-app models (hidden experiment) now reach the isolated CLI: with
23
+ `experimental.crossAppModels` enabled, `impel codex` routes through the
24
+ experimental OpenAI-compatible gateway path and projects tenant Claude
25
+ models into the Codex picker — fail-closed on each model's verified
26
+ `client_capabilities.codex`, exactly like the desktop app.
27
+ - Moves the Claude→Codex projection (`foreignClaudeCodexCatalogEntry`) and
28
+ the gateway catalog fetch (`fetchGatewayModels`) into leaf modules shared
29
+ by the CLI and desktop writers (re-exported from `src/apps.js`), so the
30
+ two surfaces can never fork the projection again. Gateway JSON is still
31
+ reconstructed field-by-field, never written through.
32
+ - Bumps `CURRENT_CONFIG_VERSION` to 45 so existing installs take the slow
33
+ rewrite path once.
34
+ - Release mechanics: the released-fixture corpus for 0.20.57 was re-captured
35
+ on the final release tree (after the projection-convergence merge added
36
+ `additional_speed_tiers` to the shared Codex projection) and is
37
+ byte-identical to the earlier capture — vendor model catalogs
38
+ (`models.json`), the only artifacts that projection change touches, are
39
+ excluded from the corpus by design (offline determinism), so no corpus
40
+ bytes moved.
41
+ - Skipped the green requirement on the `Pinned ChatGPT/Codex Store install`
42
+ vendor-contract job because the Microsoft Store now fulfills
43
+ `OpenAI.Codex 26.818.5229.0` while the reviewed pin tracks `26.818.2441.0`
44
+ (upstream Store drift; the pin bump is tracked as its own follow-up). The
45
+ `Pinned Claude MSIX install and replacement` job is required green on the
46
+ release SHA as usual; the release gate is bypassed only via the documented
47
+ `workflow_dispatch` + `skip_release_gate` escape hatch with a written
48
+ reason naming that drift, after manually verifying every other required
49
+ check green on the tagged SHA.
50
+ - Skipped the prerelease soak (testing-protocol §4.2): 0.20.57 ships stable
51
+ without a `next`-channel soak.
52
+ - Local `pnpm test` on the release machine shows two known environmental
53
+ failures (launch.test.js / managed-apps.test.js, caused by this machine's
54
+ MSC-enrolled managed apps); they reproduce on clean `main` and all six CI
55
+ `Node {18,24}` legs run the same suite green.
56
+
57
+ ## 0.20.56 — Enforce reviewed Windows vendor CLIs, follow the Store pin, harden convergence
58
+
59
+ - Bumps the Windows ChatGPT Store pin to `26.818.2441.0` (embedded Codex
60
+ `0.148.0-alpha.21`, verified from the registered package on the disposable
61
+ Windows runner): the Store stopped fulfilling `26.814.5167.0`, so fresh
62
+ Windows installs failed the exact registered-package check (IMP-590). The
63
+ macOS pin stays held at `26.810.41047` pending a build that passes the
64
+ managed Tasks board launch smoke. Exact package, publisher, executable, and
65
+ embedded-runtime verification is unchanged.
66
+
67
+ - Enforces the exact reviewed Claude Code and Codex CLI releases on Windows,
68
+ matching the macOS contract: a vendor auto-update past the pin no longer
69
+ launches an unreviewed build against the gateway (the drift behind the
70
+ Windows `403 Forbidden` lockouts), and the launcher resolves the pinned
71
+ side-by-side release under `.local/share/claude/versions/` when the shared
72
+ launcher has drifted — including installs whose `HOME` diverges from
73
+ `USERPROFILE`.
74
+ - Disables the vendor background auto-updater in managed Claude CLI profiles
75
+ (`DISABLE_AUTOUPDATER=1`) so a session on the reviewed release cannot move
76
+ the shared launcher past the pin; manual `claude update` still works.
77
+ - Names vendor version drift in setup/update diagnostics ("installed v2.1.237
78
+ does not match the reviewed v2.1.220") instead of the misleading
79
+ "not installed or discoverable".
80
+ - Stops `chmod` failures from aborting Windows tenant convergence: managed
81
+ profile writers treat the FAT-era mode bits as best-effort on Windows, and
82
+ the all-tenant security sweep no longer fails every tenant over one
83
+ tenant's locked file. POSIX keeps failing loudly.
84
+ - Retries the Windows stable-entrypoint and managed-artifact renames through
85
+ the shared antivirus-tolerant retry helper.
86
+ - Carries which tenants failed and why in the auto-filed
87
+ "Tenant convergence failed during `impel update`" report, via a redacted
88
+ on-disk summary the cascaded child leaves for the parent's single report.
89
+ - Sends `X-Impel-Cli-Version` on gateway requests from `impel claude` so a
90
+ production 403 can be correlated to a CLI release.
91
+
3
92
  ## 0.20.55 — Route managed CLIs through the signed runner
4
93
 
5
94
  - Launches `impel codex` and `impel claude` through the tenant-agnostic macOS
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.20.55",
3
+ "version": "0.20.57",
4
4
  "description": "Prepare isolated Claude and Codex workspaces for every accessible Impel tenant",
5
5
  "type": "module",
6
6
  "bin": {
package/src/apps.js CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  secureManagedCodexHome,
12
12
  } from "./codexSecurity.js";
13
13
  import { normalizeTenantId } from "./tenants.js";
14
- import { codexCatalogEntry } from "./modelCatalog.js";
14
+ import { codexCatalogEntry, foreignClaudeCodexCatalogEntry } from "./modelCatalog.js";
15
15
  import {
16
16
  IMPEL_CLI_ENTRYPOINT,
17
17
  IMPEL_TASKS_MCP_SERVER_NAME,
@@ -20,6 +20,7 @@ import {
20
20
  isImpelTasksMcpInvocation,
21
21
  } from "./selfInvocation.js";
22
22
  import { crossAppModelsEnabled, redactSecretText } from "./config.js";
23
+ import { chmodWithPlatformPolicy, renameWithWindowsRetry } from "./windowsFs.js";
23
24
  import { ensureClaudeSessionHooks, ensureCodexSessionHooks } from "./sessionHooks.js";
24
25
  import { ADHOC_IDENTITY, codesignIdentityArgs, desiredSigningMode, resolveSigningIdentity } from "./codesign.js";
25
26
  import { applyImpelClaudeSandbox } from "./claudeSandbox.js";
@@ -131,12 +132,15 @@ export const PINNED_VENDOR_APPS = Object.freeze({
131
132
  // against this value. updateManifestUrl below is advertisement, read by
132
133
  // the contract test alone and never by the install path.
133
134
  //
134
- // Those two disagree as of 2026-08-18: the manifest advertises
135
- // 26.814.5517.0 while the Store installs 26.814.5167.0. Pin what actually
136
- // installs a pin matching only the advertisement would fail closed on
137
- // every real machine.
138
- packageVersion: "26.814.5167.0",
139
- codexVersion: "0.148.0-alpha.15",
135
+ // Those two disagree again as of 2026-08-20: the manifest advertises
136
+ // 26.818.2872.0 while the disposable-runner Store install registered
137
+ // 26.818.2441.0 (the Store stopped fulfilling 26.814.5167.0 entirely
138
+ // IMP-590). Pin what actually installs — a pin matching only the
139
+ // advertisement would fail closed on every real machine.
140
+ packageVersion: "26.818.2441.0",
141
+ // Verified on the disposable Windows runner: the registered package's
142
+ // embedded codex.exe --version reports exactly this release.
143
+ codexVersion: "0.148.0-alpha.21",
140
144
  publisherId: "2p2nqsd0c76g0",
141
145
  executable: "app\\ChatGPT.exe",
142
146
  updateManifestUrl: "https://persistent.oaistatic.com/codex-app-prod/windows-store-update.json",
@@ -230,7 +234,11 @@ const LEGACY_TRAPPED_CLAUDE_NAME = new RegExp(`^${escapedDisplayPrefix} Claude(
230
234
  // modelCatalog.js (a leaf module) so cliProfiles.js can consume them
231
235
  // without importing this module's launch-command cycle. Re-exported here
232
236
  // for the many existing consumers.
233
- export { FALLBACK_MODELS, managedCodexCliCatalog } from "./modelCatalog.js";
237
+ // Re-exported from their leaf homes so existing importers keep working;
238
+ // modelCatalog.js and gatewayModels.js must never import from apps.js (the
239
+ // leaf constraint behind the 0.20.39 fork regression).
240
+ export { FALLBACK_MODELS, foreignClaudeCodexCatalogEntry, managedCodexCliCatalog } from "./modelCatalog.js";
241
+ export { fetchGatewayModels } from "./gatewayModels.js";
234
242
 
235
243
  export function normalizeAppTarget(value) {
236
244
  if (value == null || value === "all") return ["claude", "chatgpt"];
@@ -820,14 +828,14 @@ export function migrateLegacyClaudeAppSessions(userData, homeDir = os.homedir())
820
828
  const sourceStat = fs.statSync(source);
821
829
  if (fs.existsSync(destination) && fs.statSync(destination).mtimeMs >= sourceStat.mtimeMs) continue;
822
830
  fs.mkdirSync(destinationDirectory, { recursive: true, mode: 0o700 });
823
- fs.chmodSync(destinationDirectory, 0o700);
831
+ chmodWithPlatformPolicy(destinationDirectory, 0o700);
824
832
  const temporary = `${destination}.tmp-${process.pid}`;
825
833
  try {
826
834
  fs.copyFileSync(source, temporary);
827
- fs.chmodSync(temporary, 0o600);
828
- fs.renameSync(temporary, destination);
835
+ chmodWithPlatformPolicy(temporary, 0o600);
836
+ renameWithWindowsRetry(temporary, destination);
829
837
  fs.utimesSync(destination, sourceStat.atime, sourceStat.mtime);
830
- fs.chmodSync(destination, 0o600);
838
+ chmodWithPlatformPolicy(destination, 0o600);
831
839
  copied += 1;
832
840
  } finally {
833
841
  fs.rmSync(temporary, { force: true });
@@ -837,33 +845,6 @@ export function migrateLegacyClaudeAppSessions(userData, homeDir = os.homedir())
837
845
  return copied;
838
846
  }
839
847
 
840
- export async function fetchGatewayModels(config, fetchImpl = fetch, { allowEmpty = false } = {}) {
841
- const controller = new AbortController();
842
- const timeout = setTimeout(() => controller.abort(), 20000);
843
- try {
844
- const response = await fetchImpl(`${config.gatewayUrl}/v1/models`, {
845
- headers: { authorization: `Bearer ${config.pat}` },
846
- signal: controller.signal,
847
- });
848
- if (!response.ok) throw new Error(`HTTP ${response.status}`);
849
- const payload = await response.json();
850
- if (config.tenantId && payload?.org_id !== config.tenantId) {
851
- throw new Error("gateway model catalog tenant did not match the selected tenant");
852
- }
853
- if (config.productAccess && payload?.product_access !== config.productAccess) {
854
- throw new Error("gateway model catalog product access did not match the live entitlement");
855
- }
856
- if (!Array.isArray(payload?.data)) throw new Error("response has no model data array");
857
- const models = payload.data.filter(isGatewayModel);
858
- if (models.length === 0 && !(allowEmpty && isExplicitNoSeatCatalog(payload))) {
859
- throw new Error("gateway returned no supported models");
860
- }
861
- return { models, source: "gateway", version: payload.version ?? null };
862
- } finally {
863
- clearTimeout(timeout);
864
- }
865
- }
866
-
867
848
  export function installManagedAppFiles({
868
849
  config,
869
850
  targets,
@@ -1551,66 +1532,6 @@ export function projectClaudeDesktopModels(models) {
1551
1532
  });
1552
1533
  }
1553
1534
 
1554
- export function foreignClaudeCodexCatalogEntry(model, index) {
1555
- const capability = model.client_capabilities?.codex;
1556
- const levels = capability?.supported_reasoning_levels;
1557
- const tiers = capability?.service_tiers;
1558
- const modalities = capability?.input_modalities;
1559
- const valid = capability && typeof capability === "object"
1560
- && Number.isInteger(capability.context_window) && capability.context_window > 0
1561
- && Number.isInteger(capability.max_context_window) && capability.max_context_window >= capability.context_window
1562
- && Array.isArray(levels) && levels.every((level) => (
1563
- level && typeof level.effort === "string" && typeof level.description === "string"
1564
- ))
1565
- && Array.isArray(tiers) && tiers.every((tier) => (
1566
- tier && typeof tier.id === "string" && typeof tier.name === "string" && typeof tier.description === "string"
1567
- ))
1568
- && Array.isArray(modalities) && modalities.every((value) => typeof value === "string")
1569
- && ["supports_parallel_tool_calls", "supports_reasoning_summaries", "supports_verbosity", "supports_search_tool", "supports_image_detail_original"]
1570
- .every((key) => typeof capability[key] === "boolean")
1571
- && (capability.default_reasoning_level == null
1572
- || capability.default_reasoning_level === ""
1573
- || levels.some((level) => level.effort === capability.default_reasoning_level))
1574
- && (capability.apply_patch_tool_type == null || typeof capability.apply_patch_tool_type === "string")
1575
- && (capability.web_search_tool_type == null || typeof capability.web_search_tool_type === "string");
1576
- if (!valid) {
1577
- console.warn(`impel: omitting model ${redactSecretText(model.id)} from Impel ChatGPT: missing verified Codex capabilities`);
1578
- return null;
1579
- }
1580
- return {
1581
- slug: model.id,
1582
- display_name: model.display_name || model.id,
1583
- description: model.description || `Claude model available through the Impel gateway (${model.id}).`,
1584
- ...(capability.default_reasoning_level ? { default_reasoning_level: capability.default_reasoning_level } : {}),
1585
- supported_reasoning_levels: levels,
1586
- service_tiers: tiers,
1587
- shell_type: "shell_command",
1588
- visibility: "list",
1589
- supported_in_api: true,
1590
- priority: Number.isInteger(model.priority) ? model.priority : index + 1,
1591
- base_instructions: "You are an AI coding agent. Follow repository instructions, collaborate with the user, make scoped changes, and verify your work.",
1592
- include_skills_usage_instructions: false,
1593
- supports_reasoning_summaries: capability.supports_reasoning_summaries,
1594
- support_verbosity: capability.supports_verbosity,
1595
- ...(capability.supports_reasoning_summaries ? { default_reasoning_summary: "none" } : {}),
1596
- ...(capability.supports_verbosity ? { default_verbosity: "low" } : {}),
1597
- ...(capability.apply_patch_tool_type ? { apply_patch_tool_type: capability.apply_patch_tool_type } : {}),
1598
- ...(capability.web_search_tool_type ? { web_search_tool_type: capability.web_search_tool_type } : {}),
1599
- truncation_policy: { mode: "tokens", limit: 10000 },
1600
- context_window: capability.context_window,
1601
- max_context_window: capability.max_context_window,
1602
- effective_context_window_percent: 95,
1603
- experimental_supported_tools: [],
1604
- input_modalities: modalities,
1605
- supports_parallel_tool_calls: capability.supports_parallel_tool_calls,
1606
- supports_image_detail_original: capability.supports_image_detail_original,
1607
- supports_search_tool: capability.supports_search_tool,
1608
- use_responses_lite: false,
1609
- tool_mode: null,
1610
- multi_agent_version: null,
1611
- };
1612
- }
1613
-
1614
1535
  function readVendorCodexModels(vendorPath) {
1615
1536
  const binary = vendorPath && path.join(vendorPath, "Contents", "Resources", "codex");
1616
1537
  if (!binary || !fs.existsSync(binary)) return new Map();
@@ -3350,31 +3271,16 @@ export function replaceDirectory(destination, staging, options = {}) {
3350
3271
  ));
3351
3272
  }
3352
3273
 
3353
- function isGatewayModel(model) {
3354
- return model && typeof model.id === "string" && (model.provider === "claude" || model.provider === "codex");
3355
- }
3356
-
3357
- function isExplicitNoSeatCatalog(payload) {
3358
- const statuses = payload?.provider_status;
3359
- return Boolean(statuses
3360
- && typeof statuses === "object"
3361
- && !Array.isArray(statuses)
3362
- && ["claude", "codex"].every((provider) => (
3363
- statuses[provider]?.state === "no_seat"
3364
- && statuses[provider]?.routable === false
3365
- )));
3366
- }
3367
-
3368
3274
  function writeAtomic(target, contents, mode) {
3369
3275
  fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
3370
3276
  if (fs.existsSync(target) && fs.readFileSync(target, "utf8") === contents) {
3371
- fs.chmodSync(target, mode);
3277
+ chmodWithPlatformPolicy(target, mode);
3372
3278
  return;
3373
3279
  }
3374
3280
  const tmp = `${target}.tmp-${process.pid}`;
3375
3281
  fs.writeFileSync(tmp, contents, { mode });
3376
- fs.renameSync(tmp, target);
3377
- fs.chmodSync(target, mode);
3282
+ renameWithWindowsRetry(tmp, target);
3283
+ chmodWithPlatformPolicy(target, mode);
3378
3284
  }
3379
3285
 
3380
3286
  function bundleVersion(bundle) {
package/src/cli.js CHANGED
@@ -11,6 +11,7 @@ import { cmdApps } from "./commands/apps.js";
11
11
  import { cmdMcp } from "./commands/mcp.js";
12
12
  import { cmdLaunch } from "./commands/launch.js";
13
13
  import { cmdSkills } from "./commands/skills.js";
14
+ import { cmdModels } from "./commands/models.js";
14
15
  import { cmdAgents } from "./commands/agents.js";
15
16
  import { cmdTenant } from "./commands/tenant.js";
16
17
  import { cmdDoctor } from "./commands/doctor.js";
@@ -68,6 +69,7 @@ Account:
68
69
  Diagnostics:
69
70
  impel status Authentication, current tenant, and local readiness
70
71
  impel doctor [--tenant <org>|--all-tenants] Synthetic provider, routing, and latency checks
72
+ impel models list|sync Inspect or refresh the isolated Codex model catalog
71
73
  impel report --message "<text>" Send a bug report; you approve the exact payload
72
74
 
73
75
  Reset:
@@ -183,6 +185,10 @@ async function dispatch(cmd, rest) {
183
185
  case "skill":
184
186
  return cmdSkills(rest);
185
187
 
188
+ case "models":
189
+ case "model":
190
+ return cmdModels(rest);
191
+
186
192
  case "agents":
187
193
  case "agent":
188
194
  return cmdAgents(rest);
@@ -22,6 +22,7 @@ import { renameWithWindowsRetry } from "./windowsFs.js";
22
22
  import { ensureClaudeSessionHooks, ensureCodexSessionHooks } from "./sessionHooks.js";
23
23
  import { RUNTIME_BRAND } from "./runtimeBrand.js";
24
24
  import { managedCodexCliCatalog } from "./modelCatalog.js";
25
+ import { codexModelsManifestPath, syncedCodexCatalogMatches } from "./modelsManifest.js";
25
26
 
26
27
  export const IMPEL_CLI_PROFILES_DIR = path.join(CONFIG_DIR, "cli");
27
28
  export const CODEX_GATEWAY_TOKEN_ENV = `${RUNTIME_BRAND.cli.providerId
@@ -120,6 +121,11 @@ export function ensureImpelClaudeProfile(gatewayUrl, tenantId, { crossAppModels
120
121
  ...(settings.env && typeof settings.env === "object" && !Array.isArray(settings.env)
121
122
  ? settings.env
122
123
  : {}),
124
+ // The launcher is shared machine-wide and this session runs the exact
125
+ // reviewed release; a background self-update from inside it would move
126
+ // that shared launcher past the reviewed pin for every later launch.
127
+ // Manual `claude update` keeps working.
128
+ DISABLE_AUTOUPDATER: "1",
123
129
  ANTHROPIC_BASE_URL: crossAppModels
124
130
  ? impelCrossAppClaudeBaseUrl(gatewayUrl)
125
131
  : impelClaudeBaseUrl(gatewayUrl),
@@ -195,14 +201,19 @@ function splitTomlPreamble(text) {
195
201
  return { preamble: text.slice(0, table.index), rest: text.slice(table.index) };
196
202
  }
197
203
 
198
- function codexManagedBlock(gatewayUrl, tenantId) {
204
+ function codexManagedBlock(gatewayUrl, tenantId, { crossAppModels = false } = {}) {
199
205
  const providerId = RUNTIME_BRAND.cli.providerId;
200
206
  const lines = [
201
207
  CODEX_START_MARK,
202
208
  `# Generated for \`${RUNTIME_BRAND.cli.command} codex\`. Other profile settings outside this block are preserved.`,
203
209
  `[model_providers.${providerId}]`,
204
210
  `name = ${JSON.stringify(`${RUNTIME_BRAND.product.displayName} Gateway`)}`,
205
- `base_url = ${JSON.stringify(impelCodexBaseUrl(gatewayUrl))}`,
211
+ // The experimental OpenAI-compatible route can dispatch both provider
212
+ // families (the desktop ChatGPT profile's cross-app path); the default
213
+ // route is the byte-preserving Codex passthrough.
214
+ `base_url = ${JSON.stringify(crossAppModels
215
+ ? `${gatewayUrl}/experimental/openai/v1`
216
+ : impelCodexBaseUrl(gatewayUrl))}`,
206
217
  'wire_api = "responses"',
207
218
  `env_key = ${JSON.stringify(CODEX_GATEWAY_TOKEN_ENV)}`,
208
219
  ];
@@ -228,7 +239,7 @@ function codexManagedBlock(gatewayUrl, tenantId) {
228
239
  return lines.join("\n");
229
240
  }
230
241
 
231
- export function ensureImpelCodexProfile(gatewayUrl, tenantId) {
242
+ export function ensureImpelCodexProfile(gatewayUrl, tenantId, { crossAppModels = false } = {}) {
232
243
  secureAllManagedCodexHomes({ cliRoot: IMPEL_CLI_PROFILES_DIR });
233
244
  const { codexHome, codexCatalog } = tenantCliProfilePaths(tenantId);
234
245
  secureManagedCodexHome(codexHome);
@@ -264,14 +275,30 @@ export function ensureImpelCodexProfile(gatewayUrl, tenantId) {
264
275
  : `${nextPreamble.trimEnd()}\n${catalogLine}\n`;
265
276
  nextPreamble = nextPreamble.replace(/^bypass_hook_trust[ \t]*=[ \t]*(?:true|false)[ \t]*\n?/gm, "");
266
277
  const restText = rest.trim();
267
- const next = [nextPreamble.trimEnd(), codexManagedBlock(gatewayUrl, tenantId), restText]
278
+ const next = [nextPreamble.trimEnd(), codexManagedBlock(gatewayUrl, tenantId, { crossAppModels }), restText]
268
279
  .filter(Boolean)
269
280
  .join("\n\n")
270
281
  .concat("\n");
271
282
 
272
283
  writePrivateFile(configPath, hardenManagedCodexToml(next, configPath));
273
- writePrivateFile(codexCatalog, `${JSON.stringify(managedCodexCliCatalog(), null, 2)}\n`);
284
+ // Launch layering (D13), synchronous half: the FALLBACK_MODELS projection
285
+ // is the offline floor. A catalog that models-manifest.json proves is
286
+ // exactly what the last gateway sync wrote for THESE bindings (tenant,
287
+ // gateway, cross-app state) is kept even when its TTL has lapsed —
288
+ // stale-beats-broken — and syncCodexModelsSafe refreshes it afterwards
289
+ // without ever blocking this writer on the network. Anything else (first
290
+ // run, tampered bytes, changed bindings) is reset to the floor, and the
291
+ // stale manifest is removed so the TTL machinery sees the floor as stale.
292
+ const keepSyncedCatalog = syncedCodexCatalogMatches(codexHome, codexCatalog, {
293
+ tenantId: normalizeTenantId(tenantId),
294
+ gatewayUrl,
295
+ crossAppModels,
296
+ });
297
+ if (!keepSyncedCatalog) {
298
+ writePrivateFile(codexCatalog, `${JSON.stringify(managedCodexCliCatalog(), null, 2)}\n`);
299
+ fs.rmSync(codexModelsManifestPath(codexHome), { force: true });
300
+ }
274
301
  if (RUNTIME_BRAND.features.sessions) ensureCodexSessionHooks(codexHome, tenantId, "codex_cli");
275
302
  secureManagedCodexHome(codexHome);
276
- return { codexHome, configPath, catalogPath: codexCatalog };
303
+ return { codexHome, configPath, catalogPath: codexCatalog, syncedCatalog: keepSyncedCatalog };
277
304
  }
@@ -1,6 +1,8 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
 
4
+ import { chmodWithPlatformPolicy } from "./windowsFs.js";
5
+
4
6
  const PRIVATE_ROOT_FILES = ["auth.json", "config.toml", "models.json"];
5
7
  const MANAGED_COMMENT = "# Impel security policy: keep gateway credentials out of shell state.";
6
8
 
@@ -189,10 +191,10 @@ function assertSafeManagedPath(target, expectedType) {
189
191
  * to decide whether the file contains one. Known credential/config files are
190
192
  * owner-only. This function must never be called for the native ~/.codex home.
191
193
  */
192
- export function secureManagedCodexHome(codexHome) {
194
+ export function secureManagedCodexHome(codexHome, { bestEffortChmod = false } = {}) {
193
195
  const existing = assertSafeManagedPath(codexHome, "directory");
194
196
  if (!existing) fs.mkdirSync(codexHome, { recursive: true, mode: 0o700 });
195
- fs.chmodSync(codexHome, 0o700);
197
+ chmodWithPlatformPolicy(codexHome, 0o700, { bestEffort: bestEffortChmod });
196
198
 
197
199
  const snapshots = path.join(codexHome, "shell_snapshots");
198
200
  const snapshotStat = assertSafeManagedPath(snapshots, "directory");
@@ -201,7 +203,7 @@ export function secureManagedCodexHome(codexHome) {
201
203
  const filePath = path.join(codexHome, fileName);
202
204
  const stat = assertSafeManagedPath(filePath, "file");
203
205
  if (!stat) continue;
204
- fs.chmodSync(filePath, 0o600);
206
+ chmodWithPlatformPolicy(filePath, 0o600, { bestEffort: bestEffortChmod });
205
207
  }
206
208
  }
207
209
 
@@ -214,7 +216,12 @@ function secureExistingManagedHome(root, relativeParts, secured) {
214
216
  if (stat.isSymbolicLink()) throw new Error(`${current} must not be a symbolic link.`);
215
217
  if (!stat.isDirectory()) throw new Error(`${current} must be a directory.`);
216
218
  }
217
- secureManagedCodexHome(current);
219
+ // The sweep opportunistically tightens every managed home, including other
220
+ // tenants'. A chmod refusal on a sibling tenant's file must not abort the
221
+ // active tenant's convergence (observed in the field as an all-tenant
222
+ // failure over one tenant's models.json); symlink/type violations above
223
+ // still fail closed because they indicate tampering, not permissions.
224
+ secureManagedCodexHome(current, { bestEffortChmod: true });
218
225
  secured.push(current);
219
226
  }
220
227
 
@@ -45,6 +45,7 @@ import {
45
45
  import { resolveSkillCredential } from "../skillBundle.js";
46
46
  import { resolveSkillsGateway, syncSkillsSafe } from "../skills.js";
47
47
  import { syncAgentProfilesSafe } from "../agents.js";
48
+ import { syncCodexModelsSafe } from "../modelSync.js";
48
49
  import { secureManagedCodexHome } from "../codexSecurity.js";
49
50
  import { impelCliInvocation } from "../selfInvocation.js";
50
51
  import { createProgressLogger, withProgress } from "../progress.js";
@@ -749,6 +750,28 @@ export async function cmdWindowsApps(argv, overrides = {}) {
749
750
 
750
751
  try {
751
752
  if (action === "open" && RUNTIME_BRAND.cli.packageName === "impel-cli") maybePrintUpdateNotice();
753
+ // Windows mirror of refreshApps' CLI-catalog leg: the detached
754
+ // `app refresh --stale-only` slot also converges the isolated
755
+ // `impel codex` model catalog (D13). Runs before the desktop manifest
756
+ // fast-path below because the CLI catalog has its own TTL manifest.
757
+ if (action === "refresh") {
758
+ const stored = loadConfig();
759
+ if (stored?.pat) {
760
+ const cliTenantId = flags.tenant
761
+ ? normalizeTenantId(flags.tenant)
762
+ : stored.tenantId || null;
763
+ if (cliTenantId) {
764
+ await (overrides.syncCliModels || syncCodexModelsSafe)({
765
+ gatewayUrl: normalizeGatewayUrl(stored.gatewayUrl || resolveDefaultGateway()),
766
+ credential: tenantCredential(stored.pat, cliTenantId),
767
+ tenantId: cliTenantId,
768
+ crossAppModels: crossAppModelsEnabled(stored),
769
+ staleOnly: Boolean(flags["stale-only"]),
770
+ logger: { warn: () => {} },
771
+ });
772
+ }
773
+ }
774
+ }
752
775
  // Background token-helper refreshes pass --stale-only; honor the manifest
753
776
  // TTL here like the darwin refresh path does, so every vendor token call
754
777
  // does not become a full catalog fetch + profile rewrite (and its child
@@ -1355,11 +1378,31 @@ async function refreshApps(targets, { staleOnly = false, tenantId = null } = {},
1355
1378
  installFiles: installManagedAppFiles,
1356
1379
  syncSkills: syncSkillsSafe,
1357
1380
  syncAgents: syncAgentProfilesSafe,
1381
+ syncCliModels: syncCodexModelsSafe,
1358
1382
  secureCodexHome: secureManagedCodexHome,
1359
1383
  ...overrides,
1360
1384
  };
1361
1385
  const stored = io.loadStoredConfig();
1362
1386
  if (!stored?.pat) return;
1387
+ // The isolated `impel codex` catalog rides the same detached refresh slot
1388
+ // (D13). Independent of the desktop legs below: a CLI-only machine has no
1389
+ // installed app bundles yet still needs its Codex picker converging.
1390
+ // syncCodexModelsSafe skips uninitialized profiles, honors the 6h manifest
1391
+ // TTL under --stale-only, never throws, and keeps the last good catalog on
1392
+ // failure.
1393
+ {
1394
+ const cliTenantId = tenantId ? normalizeTenantId(tenantId) : stored.tenantId || null;
1395
+ if (cliTenantId) {
1396
+ await io.syncCliModels({
1397
+ gatewayUrl: normalizeGatewayUrl(stored.gatewayUrl || resolveDefaultGateway()),
1398
+ credential: tenantCredential(stored.pat, cliTenantId),
1399
+ tenantId: cliTenantId,
1400
+ crossAppModels: crossAppModelsEnabled(stored),
1401
+ staleOnly,
1402
+ logger: { warn: () => {} },
1403
+ });
1404
+ }
1405
+ }
1363
1406
  const requestedTenantId = tenantId
1364
1407
  ? normalizeTenantId(tenantId)
1365
1408
  : stored.tenantId || null;
@@ -1,6 +1,7 @@
1
1
  // Hidden fresh-build continuation used by `impel update`.
2
2
 
3
3
  import { loadConfig, saveConfig, redactSecretText } from "../config.js";
4
+ import { writeConvergenceSummary } from "../convergenceSummary.js";
4
5
  import { runInstallRecovery } from "../installRecovery/engine.js";
5
6
  import {
6
7
  printReconciliationSummary,
@@ -10,6 +11,7 @@ import {
10
11
  import { applyCliUser, fetchTenants } from "../tenants.js";
11
12
  import { promptText } from "../prompt.js";
12
13
  import { describeCliFailure, preparePlatformClis } from "../platformSetup.js";
14
+ import { brandedEnvironmentName } from "../runtimeBrand.js";
13
15
  import { restoreNativeProfiles } from "./use.js";
14
16
  import { createTenantRecoveryReportTracker } from "./tenantRecoveryReport.js";
15
17
  import { resolveSkillCredential } from "../skillBundle.js";
@@ -58,9 +60,22 @@ export async function cmdConverge(argv = [], overrides = {}) {
58
60
  if (overrides.prepareWindowsClis && !overrides.preparePlatformClis) {
59
61
  io.preparePlatformClis = overrides.prepareWindowsClis;
60
62
  }
63
+ // The summary exists solely for the `impel update` parent's failure report;
64
+ // a directly invoked `_converge` (tests, manual runs) writes nothing.
65
+ const cascade = io.environment?.[brandedEnvironmentName("UPDATE_CASCADE")] === "1";
66
+ const leaveSummary = (outcome) => {
67
+ if (!cascade) return;
68
+ (io.writeConvergenceSummary || writeConvergenceSummary)(outcome);
69
+ };
70
+ const summarize = (sharedFailure) => leaveSummary({
71
+ passed: false,
72
+ sharedFailure,
73
+ failedTenants: [],
74
+ });
61
75
  const config = io.loadConfig();
62
76
  if (!config?.pat) {
63
77
  console.error("impel update: not authenticated; run `impel setup` first.");
78
+ summarize("not authenticated");
64
79
  process.exitCode = 1;
65
80
  return false;
66
81
  }
@@ -72,6 +87,7 @@ export async function cmdConverge(argv = [], overrides = {}) {
72
87
  } catch (error) {
73
88
  if (!RETRYABLE_TENANT_DISCOVERY_ERROR.test(String(error?.message || error))) {
74
89
  console.error(`impel update: tenant discovery failed (${redactSecretText(error?.message || error)})`);
90
+ summarize(`tenant discovery failed: ${error?.message || error}`);
75
91
  process.exitCode = 1;
76
92
  return false;
77
93
  }
@@ -81,6 +97,7 @@ export async function cmdConverge(argv = [], overrides = {}) {
81
97
  listing = await io.fetchTenants(config);
82
98
  } catch (retryError) {
83
99
  console.error(`impel update: tenant discovery failed (${redactSecretText(retryError?.message || retryError)})`);
100
+ summarize(`tenant discovery failed after retry: ${retryError?.message || retryError}`);
84
101
  process.exitCode = 1;
85
102
  return false;
86
103
  }
@@ -156,6 +173,7 @@ export async function cmdConverge(argv = [], overrides = {}) {
156
173
  credential: resolveSkillCredential({ pat: config.pat, tenantId: selected.id }),
157
174
  platform: io.platform,
158
175
  skipInstall: true,
176
+ crossAppModels: config.experimental?.crossAppModels === true,
159
177
  });
160
178
  let prepared = inspected;
161
179
  if (inspected.missingAfter.length && !inspectOnly && !skipClis) {
@@ -189,6 +207,7 @@ export async function cmdConverge(argv = [], overrides = {}) {
189
207
  platform: io.platform,
190
208
  skipInstall: false,
191
209
  installTools,
210
+ crossAppModels: config.experimental?.crossAppModels === true,
192
211
  });
193
212
  }
194
213
  }
@@ -346,7 +365,17 @@ export async function cmdConverge(argv = [], overrides = {}) {
346
365
  }
347
366
  printReconciliationSummary(report);
348
367
  console.log(`CLI tenant: ${selected.id} (change with \`impel tenant use <tenant>\`)`);
349
- if (!report.passed || sharedFailure) {
368
+ const passed = report.passed && !sharedFailure;
369
+ // Leave the machine-readable outcome for the `impel update` parent, whose
370
+ // auto-filed report otherwise knows nothing beyond this child's exit status.
371
+ leaveSummary({
372
+ passed,
373
+ sharedFailure,
374
+ failedTenants: report.tenants
375
+ .filter((tenant) => tenant.status === "failed")
376
+ .map((tenant) => ({ tenantId: tenant.tenantId, errors: tenant.errors })),
377
+ });
378
+ if (!passed) {
350
379
  if (sharedFailure) console.error(`Shared update failure: ${sharedFailure}`);
351
380
  console.error("impel update: one or more tenant surfaces remain incomplete.");
352
381
  process.exitCode = 1;