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 +89 -0
- package/package.json +1 -1
- package/src/apps.js +23 -117
- package/src/cli.js +6 -0
- package/src/cliProfiles.js +33 -6
- package/src/codexSecurity.js +11 -4
- package/src/commands/apps.js +43 -0
- package/src/commands/converge.js +30 -1
- package/src/commands/doctor.js +41 -3
- package/src/commands/launch.js +36 -5
- package/src/commands/models.js +160 -0
- package/src/commands/sessions.js +56 -3
- package/src/commands/setup.js +2 -0
- package/src/commands/token.js +11 -1
- package/src/commands/update.js +10 -6
- package/src/convergenceSummary.js +84 -0
- package/src/extension/index.js +2 -0
- package/src/gatewayModels.js +58 -0
- package/src/macSetup.js +3 -2
- package/src/managedProfileVersion.js +9 -1
- package/src/modelCatalog.js +109 -1
- package/src/modelSync.js +197 -0
- package/src/modelsManifest.js +132 -0
- package/src/platformSetup.js +9 -5
- package/src/provisioning.js +3 -1
- package/src/runtimeBrand.js +1 -1
- package/src/stableEntrypoint.js +2 -1
- package/src/updates.js +10 -0
- package/src/vendorCliBinaries.js +109 -9
- package/src/windowsFs.js +23 -0
- package/src/windowsSetup.js +46 -25
package/src/macSetup.js
CHANGED
|
@@ -178,6 +178,7 @@ export async function prepareMacClis({
|
|
|
178
178
|
skipInstall = false,
|
|
179
179
|
inspectOnly = false,
|
|
180
180
|
installTools = ["claude", "codex"],
|
|
181
|
+
crossAppModels = false,
|
|
181
182
|
} = {}, dependencies = {}) {
|
|
182
183
|
const io = {
|
|
183
184
|
environment: process.env,
|
|
@@ -244,8 +245,8 @@ export async function prepareMacClis({
|
|
|
244
245
|
const binaries = installAttempted
|
|
245
246
|
? detectMacClis(io.find, io.environment, io.verify)
|
|
246
247
|
: before;
|
|
247
|
-
const claudeProfile = io.ensureClaudeProfile(gatewayUrl, tenantId);
|
|
248
|
-
const codexProfile = io.ensureCodexProfile(gatewayUrl, tenantId);
|
|
248
|
+
const claudeProfile = io.ensureClaudeProfile(gatewayUrl, tenantId, { crossAppModels });
|
|
249
|
+
const codexProfile = io.ensureCodexProfile(gatewayUrl, tenantId, { crossAppModels });
|
|
249
250
|
if (binaries.claude) {
|
|
250
251
|
await io.syncSkills({
|
|
251
252
|
client: "claude",
|
|
@@ -6,4 +6,12 @@
|
|
|
6
6
|
// managed ChatGPT profiles.
|
|
7
7
|
// v43 sends tenant-checked issue handoffs from the embedded board to the main
|
|
8
8
|
// Impel browser app without expanding the capability-scoped desktop session.
|
|
9
|
-
|
|
9
|
+
// v44 disables the vendor background auto-updater in managed Claude CLI
|
|
10
|
+
// profiles so a session on the reviewed release cannot move the shared
|
|
11
|
+
// launcher past the pin (the Windows drift behind the gateway lockouts).
|
|
12
|
+
// v45 layers `impel models sync` over the isolated CLI Codex catalog: the
|
|
13
|
+
// profile writer keeps a manifest-verified synced catalog instead of always
|
|
14
|
+
// reasserting the FALLBACK_MODELS floor, records provenance in
|
|
15
|
+
// models-manifest.json, and (with cross-app models enabled) routes the CLI
|
|
16
|
+
// provider through the experimental OpenAI-compatible gateway path.
|
|
17
|
+
export const CURRENT_CONFIG_VERSION = 45;
|
package/src/modelCatalog.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// tenant profiles in cliProfiles.js can consume it without an import cycle —
|
|
5
5
|
// the 0.20.39 regression happened precisely because the CLI carried its own
|
|
6
6
|
// hand-frozen copy of this data.
|
|
7
|
+
import { redactSecretText } from "./config.js";
|
|
7
8
|
import { RUNTIME_BRAND } from "./runtimeBrand.js";
|
|
8
9
|
|
|
9
10
|
const STANDARD_REASONING_LEVELS = [
|
|
@@ -65,6 +66,7 @@ export function codexCatalogEntry(model, index, vendorModel) {
|
|
|
65
66
|
multi_agent_version: null,
|
|
66
67
|
};
|
|
67
68
|
const entry = vendorModel ? { ...generated, ...vendorModel } : generated;
|
|
69
|
+
const serviceTiers = Array.isArray(model.service_tiers) ? model.service_tiers : (entry.service_tiers || []);
|
|
68
70
|
return {
|
|
69
71
|
...entry,
|
|
70
72
|
slug: model.id,
|
|
@@ -74,7 +76,16 @@ export function codexCatalogEntry(model, index, vendorModel) {
|
|
|
74
76
|
supported_reasoning_levels: Array.isArray(model.supported_reasoning_levels)
|
|
75
77
|
? model.supported_reasoning_levels
|
|
76
78
|
: entry.supported_reasoning_levels,
|
|
77
|
-
service_tiers:
|
|
79
|
+
service_tiers: serviceTiers,
|
|
80
|
+
// The vendor catalog always pairs the priority ("Fast") service tier with
|
|
81
|
+
// additional_speed_tiers: ["fast"], and the pinned Codex runtime
|
|
82
|
+
// serializes both to the renderer. Reconstruct the pair from the final
|
|
83
|
+
// service tiers (vendor enrichment may still carry its own value) so the
|
|
84
|
+
// Fast surface matches vendor shape — the impel-apps writer has always
|
|
85
|
+
// done this and the two projections must not drift (D15).
|
|
86
|
+
additional_speed_tiers: Array.isArray(vendorModel?.additional_speed_tiers)
|
|
87
|
+
? vendorModel.additional_speed_tiers
|
|
88
|
+
: (serviceTiers.some((tier) => tier?.id === "priority") ? ["fast"] : []),
|
|
78
89
|
visibility: "list",
|
|
79
90
|
supported_in_api: true,
|
|
80
91
|
priority: Number.isInteger(model.priority) ? model.priority : (entry.priority || index + 1),
|
|
@@ -86,6 +97,76 @@ export function codexCatalogEntry(model, index, vendorModel) {
|
|
|
86
97
|
};
|
|
87
98
|
}
|
|
88
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Project a gateway Claude model into the Codex picker. Fail-closed: every
|
|
102
|
+
* capability field must arrive verified in client_capabilities.codex, or the
|
|
103
|
+
* model is omitted (never guessed). Lives in this leaf (not apps.js) so the
|
|
104
|
+
* CLI catalog sync and the desktop writers share one projection; apps.js
|
|
105
|
+
* re-exports it for existing importers.
|
|
106
|
+
*/
|
|
107
|
+
export function foreignClaudeCodexCatalogEntry(model, index) {
|
|
108
|
+
const capability = model.client_capabilities?.codex;
|
|
109
|
+
const levels = capability?.supported_reasoning_levels;
|
|
110
|
+
const tiers = capability?.service_tiers;
|
|
111
|
+
const modalities = capability?.input_modalities;
|
|
112
|
+
const valid = capability && typeof capability === "object"
|
|
113
|
+
&& Number.isInteger(capability.context_window) && capability.context_window > 0
|
|
114
|
+
&& Number.isInteger(capability.max_context_window) && capability.max_context_window >= capability.context_window
|
|
115
|
+
&& Array.isArray(levels) && levels.every((level) => (
|
|
116
|
+
level && typeof level.effort === "string" && typeof level.description === "string"
|
|
117
|
+
))
|
|
118
|
+
&& Array.isArray(tiers) && tiers.every((tier) => (
|
|
119
|
+
tier && typeof tier.id === "string" && typeof tier.name === "string" && typeof tier.description === "string"
|
|
120
|
+
))
|
|
121
|
+
&& Array.isArray(modalities) && modalities.every((value) => typeof value === "string")
|
|
122
|
+
&& ["supports_parallel_tool_calls", "supports_reasoning_summaries", "supports_verbosity", "supports_search_tool", "supports_image_detail_original"]
|
|
123
|
+
.every((key) => typeof capability[key] === "boolean")
|
|
124
|
+
&& (capability.default_reasoning_level == null
|
|
125
|
+
|| capability.default_reasoning_level === ""
|
|
126
|
+
|| levels.some((level) => level.effort === capability.default_reasoning_level))
|
|
127
|
+
&& (capability.apply_patch_tool_type == null || typeof capability.apply_patch_tool_type === "string")
|
|
128
|
+
&& (capability.web_search_tool_type == null || typeof capability.web_search_tool_type === "string");
|
|
129
|
+
if (!valid) {
|
|
130
|
+
console.warn(`impel: omitting model ${redactSecretText(model.id)} from Impel ChatGPT: missing verified Codex capabilities`);
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
slug: model.id,
|
|
135
|
+
display_name: model.display_name || model.id,
|
|
136
|
+
description: model.description || `Claude model available through the Impel gateway (${model.id}).`,
|
|
137
|
+
...(capability.default_reasoning_level ? { default_reasoning_level: capability.default_reasoning_level } : {}),
|
|
138
|
+
supported_reasoning_levels: levels,
|
|
139
|
+
service_tiers: tiers,
|
|
140
|
+
// Same vendor-shape pairing rule as codexCatalogEntry: a priority tier
|
|
141
|
+
// always travels with the "fast" speed tier.
|
|
142
|
+
additional_speed_tiers: tiers.some((tier) => tier?.id === "priority") ? ["fast"] : [],
|
|
143
|
+
shell_type: "shell_command",
|
|
144
|
+
visibility: "list",
|
|
145
|
+
supported_in_api: true,
|
|
146
|
+
priority: Number.isInteger(model.priority) ? model.priority : index + 1,
|
|
147
|
+
base_instructions: "You are an AI coding agent. Follow repository instructions, collaborate with the user, make scoped changes, and verify your work.",
|
|
148
|
+
include_skills_usage_instructions: false,
|
|
149
|
+
supports_reasoning_summaries: capability.supports_reasoning_summaries,
|
|
150
|
+
support_verbosity: capability.supports_verbosity,
|
|
151
|
+
...(capability.supports_reasoning_summaries ? { default_reasoning_summary: "none" } : {}),
|
|
152
|
+
...(capability.supports_verbosity ? { default_verbosity: "low" } : {}),
|
|
153
|
+
...(capability.apply_patch_tool_type ? { apply_patch_tool_type: capability.apply_patch_tool_type } : {}),
|
|
154
|
+
...(capability.web_search_tool_type ? { web_search_tool_type: capability.web_search_tool_type } : {}),
|
|
155
|
+
truncation_policy: { mode: "tokens", limit: 10000 },
|
|
156
|
+
context_window: capability.context_window,
|
|
157
|
+
max_context_window: capability.max_context_window,
|
|
158
|
+
effective_context_window_percent: 95,
|
|
159
|
+
experimental_supported_tools: [],
|
|
160
|
+
input_modalities: modalities,
|
|
161
|
+
supports_parallel_tool_calls: capability.supports_parallel_tool_calls,
|
|
162
|
+
supports_image_detail_original: capability.supports_image_detail_original,
|
|
163
|
+
supports_search_tool: capability.supports_search_tool,
|
|
164
|
+
use_responses_lite: false,
|
|
165
|
+
tool_mode: null,
|
|
166
|
+
multi_agent_version: null,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
89
170
|
/**
|
|
90
171
|
* The managed CLI Codex catalog, projected from the same model registry the
|
|
91
172
|
* desktop app uses so reasoning efforts, service tiers, and tool modes can
|
|
@@ -101,3 +182,30 @@ export function managedCodexCliCatalog(now = new Date()) {
|
|
|
101
182
|
.map((model, index) => codexCatalogEntry(model, index)),
|
|
102
183
|
};
|
|
103
184
|
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The same catalog document, projected from live gateway models instead of
|
|
188
|
+
* the FALLBACK_MODELS floor (`impel models sync`). The gateway payload never
|
|
189
|
+
* flows through: every entry is reconstructed by codexCatalogEntry /
|
|
190
|
+
* foreignClaudeCodexCatalogEntry from validated fields, exactly like the
|
|
191
|
+
* desktop writer (D15). Claude models join the picker only when crossAppModels
|
|
192
|
+
* is enabled AND each carries verified client_capabilities.codex (fail-closed
|
|
193
|
+
* in the foreign projection). Returns null when nothing projects — an empty
|
|
194
|
+
* catalog must never replace the floor.
|
|
195
|
+
*/
|
|
196
|
+
export function gatewayCodexCliCatalog(models, { crossAppModels = false, now = new Date() } = {}) {
|
|
197
|
+
const ordered = crossAppModels
|
|
198
|
+
? [...models.filter((model) => model.provider === "codex"), ...models.filter((model) => model.provider === "claude")]
|
|
199
|
+
: models.filter((model) => model.provider === "codex");
|
|
200
|
+
const codexModels = ordered
|
|
201
|
+
.map((model, index) => model.provider === "codex"
|
|
202
|
+
? codexCatalogEntry(model, index)
|
|
203
|
+
: foreignClaudeCodexCatalogEntry(model, index))
|
|
204
|
+
.filter(Boolean);
|
|
205
|
+
if (codexModels.length === 0) return null;
|
|
206
|
+
return {
|
|
207
|
+
fetched_at: now.toISOString(),
|
|
208
|
+
client_version: `${RUNTIME_BRAND.cli.command}-managed-2`,
|
|
209
|
+
models: codexModels,
|
|
210
|
+
};
|
|
211
|
+
}
|
package/src/modelSync.js
ADDED
|
@@ -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
|
+
}
|
package/src/platformSetup.js
CHANGED
|
@@ -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
|
|
33
|
-
const
|
|
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 },
|
|
@@ -55,6 +56,7 @@ export function describeCliFailure(prepared, {
|
|
|
55
56
|
|| failure
|
|
56
57
|
|| installation?.succeeded === false,
|
|
57
58
|
);
|
|
59
|
+
const drift = prepared.drifted?.[tool];
|
|
58
60
|
const reason = failure?.message || failure?.code
|
|
59
61
|
|| (Number.isInteger(failure?.status)
|
|
60
62
|
? `exit ${failure.status}`
|
|
@@ -64,9 +66,11 @@ export function describeCliFailure(prepared, {
|
|
|
64
66
|
? "automatic installation was declined"
|
|
65
67
|
: skipClis
|
|
66
68
|
? "installation was skipped by --skip-clis"
|
|
67
|
-
:
|
|
68
|
-
? `
|
|
69
|
-
:
|
|
69
|
+
: drift
|
|
70
|
+
? `installed v${drift.version} does not match the reviewed v${drift.pinned} this release launches against the gateway`
|
|
71
|
+
: !isTTY && prepared.installCommands?.[tool]
|
|
72
|
+
? `interactive confirmation required; rerun \`${cliExecutable} ${commandName}\` interactively (or use \`--skip-clis\` to leave it uninstalled)`
|
|
73
|
+
: "not installed or discoverable");
|
|
70
74
|
details.push(`${labels[tool] || tool}: ${redactSecretText(reason)}`);
|
|
71
75
|
const command = installation?.command || prepared.installCommands?.[tool];
|
|
72
76
|
if (attempted && command) details.push(`manual ${tool} recovery command: ${redactSecretText(command)}`);
|
package/src/provisioning.js
CHANGED
|
@@ -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,
|
package/src/runtimeBrand.js
CHANGED
|
@@ -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/stableEntrypoint.js
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
} from "./selfInvocation.js";
|
|
9
9
|
import { redactSecretText } from "./config.js";
|
|
10
10
|
import { brandedEnvironmentName, RUNTIME_BRAND } from "./runtimeBrand.js";
|
|
11
|
+
import { renameWithWindowsRetry } from "./windowsFs.js";
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Render the stable Windows entry point (`%LOCALAPPDATA%\Impel\bin\impel-entry.cjs`).
|
|
@@ -172,7 +173,7 @@ export function ensureWindowsStableEntrypoint({
|
|
|
172
173
|
const temporaryPath = `${target}.tmp-${process.pid}`;
|
|
173
174
|
try {
|
|
174
175
|
fs.writeFileSync(temporaryPath, content, { mode: 0o700 });
|
|
175
|
-
|
|
176
|
+
renameWithWindowsRetry(temporaryPath, target);
|
|
176
177
|
} finally {
|
|
177
178
|
try {
|
|
178
179
|
fs.rmSync(temporaryPath, { force: true });
|
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,
|