impel-cli 0.20.56 → 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.
@@ -0,0 +1,197 @@
1
+ // The async half of the CLI Codex catalog layering (D13): fetch the tenant's
2
+ // live gateway catalog and atomically replace the models.json that
3
+ // ensureImpelCodexProfile pinned, recording provenance in
4
+ // models-manifest.json so the synchronous floor writer and the TTL machinery
5
+ // can tell a synced catalog from the offline floor.
6
+ //
7
+ // Layering contract:
8
+ // - ensureImpelCodexProfile (cliProfiles.js) stays synchronous and offline;
9
+ // it writes the FALLBACK_MODELS floor unless the manifest proves the
10
+ // on-disk catalog is the last synced one for the same bindings.
11
+ // - syncCodexModelsSafe runs AFTER it — at launch (the syncAgentProfilesSafe
12
+ // idiom), from `impel models sync`, from the detached stale-only app
13
+ // refresh, and from the `impel token` heartbeat repair. It never throws
14
+ // and never blocks a launch on the network.
15
+ // - A gateway catalog replaces the floor only when valid and non-empty:
16
+ // fetchGatewayModels validates org/product attribution and fails closed
17
+ // on empty data (isExplicitNoSeatCatalog only relaxes lifecycle
18
+ // reconciliation, not this sync), and gatewayCodexCliCatalog reconstructs
19
+ // every entry from validated fields — raw gateway JSON never flows into
20
+ // models.json (D15).
21
+
22
+ import fs from "node:fs";
23
+
24
+ import { normalizeGatewayUrl, redactSecretText } from "./config.js";
25
+ import { tenantCliProfilePaths } from "./cliProfiles.js";
26
+ import { fetchGatewayModels } from "./gatewayModels.js";
27
+ import { gatewayCodexCliCatalog } from "./modelCatalog.js";
28
+ import {
29
+ catalogDigest,
30
+ codexCatalogIsFresh,
31
+ codexModelsManifestPath,
32
+ CODEX_MODELS_MANIFEST_SCHEMA_VERSION,
33
+ readCodexModelsManifest,
34
+ writeAtomicPrivateFile,
35
+ writeCodexModelsManifest,
36
+ } from "./modelsManifest.js";
37
+ import { normalizeTenantId } from "./tenants.js";
38
+
39
+ /**
40
+ * One catalog sync for one tenant's isolated CLI Codex profile. Throws on
41
+ * failure (callers that must not fail use syncCodexModelsSafe). Returns
42
+ * { synced, skipped?, reason?, catalogPath, models? }.
43
+ */
44
+ export async function syncCodexModels({
45
+ gatewayUrl,
46
+ credential,
47
+ tenantId,
48
+ crossAppModels = false,
49
+ staleOnly = false,
50
+ now = Date.now(),
51
+ fetchModels = fetchGatewayModels,
52
+ }) {
53
+ if (process.env.IMPEL_SKIP_MODEL_SYNC === "1" || process.env.IMPEL_SKIP_MODEL_SYNC === "true") {
54
+ return { synced: false, skipped: true, reason: "disabled" };
55
+ }
56
+ const normalizedTenant = normalizeTenantId(tenantId);
57
+ const normalizedGateway = normalizeGatewayUrl(gatewayUrl);
58
+ const { codexHome, codexCatalog } = tenantCliProfilePaths(normalizedTenant);
59
+ const bindings = {
60
+ tenantId: normalizedTenant,
61
+ gatewayUrl: normalizedGateway,
62
+ crossAppModels,
63
+ };
64
+ if (!fs.existsSync(codexCatalog)) {
65
+ // Never conjure a half-initialized profile just to sync models into it;
66
+ // the profile writer owns directory creation and hardening.
67
+ return { synced: false, skipped: true, reason: "uninitialized", catalogPath: codexCatalog };
68
+ }
69
+ if (staleOnly && codexCatalogIsFresh(codexHome, codexCatalog, { ...bindings, now })) {
70
+ return { synced: false, skipped: true, reason: "fresh", catalogPath: codexCatalog };
71
+ }
72
+
73
+ const fetched = await fetchModels({
74
+ gatewayUrl: normalizedGateway,
75
+ pat: credential,
76
+ tenantId: normalizedTenant,
77
+ });
78
+ const catalog = gatewayCodexCliCatalog(fetched.models, {
79
+ crossAppModels,
80
+ now: new Date(now),
81
+ });
82
+ if (!catalog) {
83
+ // Valid-and-non-empty gate: a tenant with zero projectable Codex-picker
84
+ // models keeps its current catalog (floor or last sync) rather than
85
+ // shipping Codex an empty picker.
86
+ throw new Error("gateway returned no models projectable into the Codex picker");
87
+ }
88
+ const text = `${JSON.stringify(catalog, null, 2)}\n`;
89
+ writeAtomicPrivateFile(codexCatalog, text);
90
+ writeCodexModelsManifest(codexHome, {
91
+ schemaVersion: CODEX_MODELS_MANIFEST_SCHEMA_VERSION,
92
+ syncedAt: new Date(now).toISOString(),
93
+ catalogVersion: fetched.version ?? null,
94
+ digest: catalogDigest(text),
95
+ tenantId: normalizedTenant,
96
+ gatewayUrl: normalizedGateway,
97
+ experiments: { crossAppModels: Boolean(crossAppModels) },
98
+ });
99
+ return {
100
+ synced: true,
101
+ catalogPath: codexCatalog,
102
+ models: catalog.models.length,
103
+ catalogVersion: fetched.version ?? null,
104
+ };
105
+ }
106
+
107
+ /**
108
+ * The launch-safe wrapper (the syncAgentProfilesSafe idiom): never throws,
109
+ * never blocks a launch outcome. A network/catalog failure leaves the last
110
+ * good models.json — floor or previously synced — in place.
111
+ */
112
+ export async function syncCodexModelsSafe(options) {
113
+ try {
114
+ return await syncCodexModels(options);
115
+ } catch (error) {
116
+ const logger = options?.logger || console;
117
+ logger.warn(`impel: model catalog sync failed (${redactSecretText(error?.message || error)}); keeping the last good Codex model catalog.`);
118
+ return {
119
+ synced: false,
120
+ reason: "error",
121
+ error: redactSecretText(error?.message || error),
122
+ };
123
+ }
124
+ }
125
+
126
+ /**
127
+ * The `catalogIsFresh` gate for the TTL machinery (heartbeat repair, detached
128
+ * stale-only refresh): true only when an initialized CLI Codex catalog exists
129
+ * and is NOT provably fresh for these bindings. Uninitialized profiles are
130
+ * never stale — a sync would skip them anyway. Never throws.
131
+ */
132
+ export function cliCodexCatalogStale(tenantId, {
133
+ gatewayUrl,
134
+ crossAppModels = false,
135
+ now = Date.now(),
136
+ } = {}) {
137
+ try {
138
+ const normalizedTenant = normalizeTenantId(tenantId);
139
+ const { codexHome, codexCatalog } = tenantCliProfilePaths(normalizedTenant);
140
+ if (!fs.existsSync(codexCatalog)) return false;
141
+ return !codexCatalogIsFresh(codexHome, codexCatalog, {
142
+ tenantId: normalizedTenant,
143
+ gatewayUrl: normalizeGatewayUrl(gatewayUrl),
144
+ crossAppModels,
145
+ now,
146
+ });
147
+ } catch {
148
+ return false;
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Staleness report for diagnostics (`impel models list`, `impel doctor`).
154
+ * Read-only and offline.
155
+ */
156
+ export function codexCatalogStatus(tenantId, {
157
+ gatewayUrl = null,
158
+ crossAppModels = false,
159
+ now = Date.now(),
160
+ } = {}) {
161
+ const normalizedTenant = normalizeTenantId(tenantId);
162
+ const { codexHome, codexCatalog } = tenantCliProfilePaths(normalizedTenant);
163
+ const manifest = readCodexModelsManifest(codexHome);
164
+ let catalog = null;
165
+ let catalogError = null;
166
+ try {
167
+ catalog = JSON.parse(fs.readFileSync(codexCatalog, "utf8"));
168
+ } catch (error) {
169
+ catalogError = error?.code === "ENOENT" ? "missing" : "unreadable";
170
+ }
171
+ const models = Array.isArray(catalog?.models)
172
+ ? catalog.models.filter((model) => model && typeof model.slug === "string")
173
+ : [];
174
+ const digestMatches = Boolean(manifest && catalog)
175
+ && catalogDigest(fs.readFileSync(codexCatalog, "utf8")) === manifest.digest;
176
+ const syncedAt = manifest ? Date.parse(manifest.syncedAt) : NaN;
177
+ const fresh = gatewayUrl !== null
178
+ ? codexCatalogIsFresh(codexHome, codexCatalog, {
179
+ tenantId: normalizedTenant,
180
+ gatewayUrl,
181
+ crossAppModels,
182
+ now,
183
+ })
184
+ : false;
185
+ return {
186
+ tenantId: normalizedTenant,
187
+ catalogPath: codexCatalog,
188
+ manifestPath: codexModelsManifestPath(codexHome),
189
+ catalogError,
190
+ models,
191
+ source: digestMatches ? "gateway" : "floor",
192
+ manifest,
193
+ syncedAt: Number.isFinite(syncedAt) ? new Date(syncedAt).toISOString() : null,
194
+ ageMs: Number.isFinite(syncedAt) ? Math.max(0, now - syncedAt) : null,
195
+ fresh,
196
+ };
197
+ }
@@ -0,0 +1,132 @@
1
+ // Freshness record for the isolated CLI Codex model catalog (models.json):
2
+ // models-manifest.json, written beside the catalog in the tenant CODEX_HOME.
3
+ //
4
+ // Leaf module (node builtins + other leaf modules only). Both the synchronous
5
+ // profile writer in cliProfiles.js — which must decide floor-vs-keep with
6
+ // zero network I/O — and the async gateway sync in modelSync.js consult the
7
+ // same record, so this logic must sit below both without importing either.
8
+ //
9
+ // The manifest pins the exact catalog bytes the last sync wrote (sha256
10
+ // digest) plus the bindings that make a synced catalog valid for a launch:
11
+ // tenant, gateway origin, and the cross-app experiment state. A digest or
12
+ // binding mismatch means the catalog on disk is NOT the one the last sync
13
+ // produced — the floor writer then reasserts the offline floor, and the TTL
14
+ // machinery treats the catalog as stale regardless of syncedAt.
15
+
16
+ import crypto from "node:crypto";
17
+ import fs from "node:fs";
18
+ import path from "node:path";
19
+
20
+ import { normalizeGatewayUrl } from "./config.js";
21
+ import { renameWithWindowsRetry } from "./windowsFs.js";
22
+
23
+ export const CODEX_MODELS_MANIFEST_BASENAME = "models-manifest.json";
24
+ export const CODEX_MODELS_MANIFEST_SCHEMA_VERSION = 1;
25
+ /** Same 6h cadence as the agent-profile and desktop-app refresh machinery. */
26
+ export const MODEL_SYNC_TTL_MS = 6 * 60 * 60 * 1000;
27
+
28
+ export function codexModelsManifestPath(codexHome) {
29
+ return path.join(codexHome, CODEX_MODELS_MANIFEST_BASENAME);
30
+ }
31
+
32
+ export function catalogDigest(text) {
33
+ return crypto.createHash("sha256").update(text, "utf8").digest("hex");
34
+ }
35
+
36
+ /** Atomic private write (write-temp-rename, 0600), matching the CLI's other config writers. */
37
+ export function writeAtomicPrivateFile(target, contents) {
38
+ fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
39
+ const temporaryPath = `${target}.tmp-${process.pid}`;
40
+ try {
41
+ fs.writeFileSync(temporaryPath, contents, { mode: 0o600 });
42
+ renameWithWindowsRetry(temporaryPath, target);
43
+ try {
44
+ fs.chmodSync(target, 0o600);
45
+ } catch {
46
+ // Best effort on platforms where chmod is unavailable.
47
+ }
48
+ } finally {
49
+ try {
50
+ fs.rmSync(temporaryPath, { force: true });
51
+ } catch {
52
+ // The rename already removed the temporary file in the normal case.
53
+ }
54
+ }
55
+ }
56
+
57
+ /** Parse and shape-validate the manifest; anything invalid reads as absent. */
58
+ export function readCodexModelsManifest(codexHome) {
59
+ let raw;
60
+ try {
61
+ raw = fs.readFileSync(codexModelsManifestPath(codexHome), "utf8");
62
+ } catch {
63
+ return null;
64
+ }
65
+ let value;
66
+ try {
67
+ value = JSON.parse(raw);
68
+ } catch {
69
+ return null;
70
+ }
71
+ const valid = value
72
+ && typeof value === "object"
73
+ && !Array.isArray(value)
74
+ && value.schemaVersion === CODEX_MODELS_MANIFEST_SCHEMA_VERSION
75
+ && typeof value.syncedAt === "string"
76
+ && typeof value.digest === "string"
77
+ && /^[0-9a-f]{64}$/u.test(value.digest)
78
+ && typeof value.tenantId === "string"
79
+ && typeof value.gatewayUrl === "string"
80
+ && value.experiments
81
+ && typeof value.experiments === "object"
82
+ && !Array.isArray(value.experiments)
83
+ && typeof value.experiments.crossAppModels === "boolean";
84
+ return valid ? value : null;
85
+ }
86
+
87
+ export function writeCodexModelsManifest(codexHome, manifest) {
88
+ writeAtomicPrivateFile(
89
+ codexModelsManifestPath(codexHome),
90
+ `${JSON.stringify(manifest, null, 2)}\n`,
91
+ );
92
+ }
93
+
94
+ /**
95
+ * Whether the catalog on disk is exactly the one the last gateway sync wrote
96
+ * for these bindings. Deliberately TTL-free: a stale synced catalog still
97
+ * beats the offline floor (stale-beats-broken), so the synchronous profile
98
+ * writer keeps it; only the TTL gate below decides to refresh it.
99
+ */
100
+ export function syncedCodexCatalogMatches(codexHome, catalogPath, {
101
+ tenantId,
102
+ gatewayUrl,
103
+ crossAppModels = false,
104
+ } = {}) {
105
+ const manifest = readCodexModelsManifest(codexHome);
106
+ if (!manifest) return false;
107
+ if (manifest.tenantId !== tenantId) return false;
108
+ if (normalizeGatewayUrl(manifest.gatewayUrl) !== normalizeGatewayUrl(gatewayUrl)) return false;
109
+ if (manifest.experiments.crossAppModels !== Boolean(crossAppModels)) return false;
110
+ let text;
111
+ try {
112
+ text = fs.readFileSync(catalogPath, "utf8");
113
+ } catch {
114
+ return false;
115
+ }
116
+ return catalogDigest(text) === manifest.digest;
117
+ }
118
+
119
+ /** The TTL gate: an intact synced catalog whose last sync is inside the window. */
120
+ export function codexCatalogIsFresh(codexHome, catalogPath, {
121
+ tenantId,
122
+ gatewayUrl,
123
+ crossAppModels = false,
124
+ now = Date.now(),
125
+ ttlMs = MODEL_SYNC_TTL_MS,
126
+ } = {}) {
127
+ if (!syncedCodexCatalogMatches(codexHome, catalogPath, { tenantId, gatewayUrl, crossAppModels })) {
128
+ return false;
129
+ }
130
+ const syncedAt = Date.parse(readCodexModelsManifest(codexHome).syncedAt);
131
+ return Number.isFinite(syncedAt) && syncedAt <= now && now - syncedAt < ttlMs;
132
+ }
@@ -29,8 +29,9 @@ export async function preparePlatformClis(options = {}, dependencies = {}) {
29
29
  };
30
30
  // Discovery-only probes must not write profiles either.
31
31
  if (options.inspectOnly) return result;
32
- const claudeProfile = (dependencies.ensureClaudeProfile || ensureImpelClaudeProfile)(options.gatewayUrl, options.tenantId);
33
- const codexProfile = (dependencies.ensureCodexProfile || ensureImpelCodexProfile)(options.gatewayUrl, options.tenantId);
32
+ const crossAppModels = options.crossAppModels === true;
33
+ const claudeProfile = (dependencies.ensureClaudeProfile || ensureImpelClaudeProfile)(options.gatewayUrl, options.tenantId, { crossAppModels });
34
+ const codexProfile = (dependencies.ensureCodexProfile || ensureImpelCodexProfile)(options.gatewayUrl, options.tenantId, { crossAppModels });
34
35
  return {
35
36
  ...result,
36
37
  profiles: { claude: claudeProfile.configDir, codex: codexProfile.codexHome },
@@ -101,7 +101,9 @@ async function prepareTenantCli(config, tenant, io, binaries) {
101
101
  },
102
102
  codex: {
103
103
  supported: providerSupported(config.scopes, PAT_SCOPE_CODEX),
104
- ensure: () => io.ensureCodex(config.gatewayUrl, tenant.id),
104
+ ensure: () => io.ensureCodex(config.gatewayUrl, tenant.id, {
105
+ crossAppModels: crossAppModelsEnabled(config),
106
+ }),
105
107
  root: (profile) => profile.codexHome,
106
108
  env: (profile) => ({
107
109
  CODEX_HOME: profile.codexHome,
@@ -11,7 +11,7 @@ const CONTROL_RE = /[\u0000-\u001F\u007F-\u009F]/u;
11
11
  const SUPPORTED_COMMANDS = new Set([
12
12
  "setup", "auth", "pat", "token", "mcp", "sessions", "claude", "codex",
13
13
  "status", "doctor", "report", "tasks", "tenant", "app", "nuke", "skills",
14
- "agents", "update", "use", "experimental",
14
+ "models", "agents", "update", "use", "experimental",
15
15
  ]);
16
16
 
17
17
  const DEFAULT = Object.freeze({
package/src/updates.js CHANGED
@@ -295,6 +295,16 @@ export function spawnDetachedAppRefresh(tenantId = null) {
295
295
  ]);
296
296
  }
297
297
 
298
+ /** Background CLI Codex model-catalog sync; no-ops inside the TTL. */
299
+ export function spawnDetachedModelSync(tenantId) {
300
+ spawnDetached([
301
+ "models",
302
+ "sync",
303
+ "--stale-only",
304
+ ...(tenantId ? ["--tenant", tenantId] : []),
305
+ ]);
306
+ }
307
+
298
308
  /**
299
309
  * Background telemetry send. Lives here beside the other detached spawns so
300
310
  * `spawnDetached`'s Windows-console handling has exactly one implementation,
@@ -143,6 +143,7 @@ export async function prepareWindowsClis({
143
143
  skipInstall = false,
144
144
  inspectOnly = false,
145
145
  installTools = ["claude", "codex"],
146
+ crossAppModels = false,
146
147
  } = {}, dependencies = {}) {
147
148
  const io = {
148
149
  environment: process.env,
@@ -238,8 +239,8 @@ export async function prepareWindowsClis({
238
239
  ? detectWindowsClis(io.find, io.environment, io.verify)
239
240
  : before;
240
241
  const drifted = detectDriftedWindowsClis(io.find, io.environment, binaries, { versionOf: io.versionOf });
241
- const claudeProfile = io.ensureClaudeProfile(gatewayUrl, tenantId);
242
- const codexProfile = io.ensureCodexProfile(gatewayUrl, tenantId);
242
+ const claudeProfile = io.ensureClaudeProfile(gatewayUrl, tenantId, { crossAppModels });
243
+ const codexProfile = io.ensureCodexProfile(gatewayUrl, tenantId, { crossAppModels });
243
244
 
244
245
  if (binaries.claude) {
245
246
  await io.syncSkills({