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/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
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-
|
|
135
|
-
// 26.
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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
|
-
|
|
831
|
+
chmodWithPlatformPolicy(destinationDirectory, 0o700);
|
|
824
832
|
const temporary = `${destination}.tmp-${process.pid}`;
|
|
825
833
|
try {
|
|
826
834
|
fs.copyFileSync(source, temporary);
|
|
827
|
-
|
|
828
|
-
|
|
835
|
+
chmodWithPlatformPolicy(temporary, 0o600);
|
|
836
|
+
renameWithWindowsRetry(temporary, destination);
|
|
829
837
|
fs.utimesSync(destination, sourceStat.atime, sourceStat.mtime);
|
|
830
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3377
|
-
|
|
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);
|
package/src/cliProfiles.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/codexSecurity.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/commands/apps.js
CHANGED
|
@@ -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;
|
package/src/commands/converge.js
CHANGED
|
@@ -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
|
-
|
|
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;
|