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/commands/doctor.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { parseFlags } from "../args.js";
|
|
2
|
-
import { loadConfig, normalizeGatewayUrl, resolveDefaultGateway } from "../config.js";
|
|
2
|
+
import { crossAppModelsEnabled, loadConfig, normalizeGatewayUrl, resolveDefaultGateway } from "../config.js";
|
|
3
3
|
import {
|
|
4
4
|
DEFAULT_TTFT_BUDGET_MS,
|
|
5
5
|
DOCTOR_PROVIDERS,
|
|
6
6
|
probeTenant,
|
|
7
7
|
recordSucceeded,
|
|
8
8
|
} from "../doctor.js";
|
|
9
|
+
import { codexCatalogStatus } from "../modelSync.js";
|
|
9
10
|
import {
|
|
10
11
|
assertProviderScopes,
|
|
11
12
|
fetchTenants,
|
|
@@ -85,12 +86,43 @@ function doctorGatewayUrl(value) {
|
|
|
85
86
|
return parsed.origin;
|
|
86
87
|
}
|
|
87
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Local, offline staleness of the isolated `impel codex` model catalog
|
|
91
|
+
* (models.json + models-manifest.json). Reported beside the live gateway
|
|
92
|
+
* catalog probe so a device stuck on the offline floor or a months-old sync
|
|
93
|
+
* is visible from the same diagnosis surface that proves the gateway works.
|
|
94
|
+
*/
|
|
95
|
+
function cliModelCatalogReport(tenantId, { gatewayUrl, crossAppModels }) {
|
|
96
|
+
try {
|
|
97
|
+
const status = codexCatalogStatus(tenantId, { gatewayUrl, crossAppModels });
|
|
98
|
+
return {
|
|
99
|
+
state: status.catalogError ?? (status.source === "floor" ? "floor" : "synced"),
|
|
100
|
+
syncedAt: status.syncedAt,
|
|
101
|
+
catalogVersion: status.manifest?.catalogVersion ?? null,
|
|
102
|
+
fresh: status.fresh,
|
|
103
|
+
models: status.models.length,
|
|
104
|
+
};
|
|
105
|
+
} catch (error) {
|
|
106
|
+
return { state: "error", error: String(error?.message || error), fresh: false, models: 0 };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function cliModelCatalogLine(local) {
|
|
111
|
+
if (local.state === "missing") return "not initialized (run `impel codex` once)";
|
|
112
|
+
if (local.state === "floor") return "offline floor (never synced; run `impel models sync`)";
|
|
113
|
+
if (local.state === "synced") {
|
|
114
|
+
return `gateway sync ${local.syncedAt ?? "unknown"} (${local.fresh ? "fresh" : "stale"}, ${local.models} models)`;
|
|
115
|
+
}
|
|
116
|
+
return `${local.state}${local.error ? ` (${local.error})` : ""}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
88
119
|
function printHuman(report) {
|
|
89
120
|
for (const tenant of report.tenants) {
|
|
90
121
|
console.log(`Tenant: ${tenant.tenantId}`);
|
|
91
122
|
console.log(`Access: ${productAccessLabel(tenant.productAccess)}`);
|
|
92
123
|
console.log(`Catalog: ${tenant.catalog.status ?? "network error"} (${tenant.catalog.models} ready models, ${tenant.catalog.durationMs ?? "?"}ms)`);
|
|
93
124
|
if (tenant.catalog.error) console.log(` ERROR ${tenant.catalog.error}`);
|
|
125
|
+
if (tenant.cliModelCatalog) console.log(`CLI models: ${cliModelCatalogLine(tenant.cliModelCatalog)}`);
|
|
94
126
|
for (const record of tenant.records) {
|
|
95
127
|
const ok = recordSucceeded(record);
|
|
96
128
|
const latency = record.ttftMs === null ? "TTFT n/a" : `TTFT ${record.ttftMs}ms, total ${record.totalMs}ms`;
|
|
@@ -194,7 +226,7 @@ export async function cmdDoctor(argv) {
|
|
|
194
226
|
|
|
195
227
|
const tenantReports = [];
|
|
196
228
|
for (const tenant of tenants) {
|
|
197
|
-
|
|
229
|
+
const tenantReport = await probeTenant({
|
|
198
230
|
config: doctorConfig,
|
|
199
231
|
tenantId: tenant.id,
|
|
200
232
|
productAccess: listing.productAccess,
|
|
@@ -203,7 +235,13 @@ export async function cmdDoctor(argv) {
|
|
|
203
235
|
timeoutMs,
|
|
204
236
|
ttftBudgets,
|
|
205
237
|
strictLatency: Boolean(flags["strict-latency"]),
|
|
206
|
-
})
|
|
238
|
+
});
|
|
239
|
+
// Offline and advisory: catalog staleness never flips the probe verdict.
|
|
240
|
+
tenantReport.cliModelCatalog = cliModelCatalogReport(tenant.id, {
|
|
241
|
+
gatewayUrl,
|
|
242
|
+
crossAppModels: crossAppModelsEnabled(config),
|
|
243
|
+
});
|
|
244
|
+
tenantReports.push(tenantReport);
|
|
207
245
|
}
|
|
208
246
|
const report = {
|
|
209
247
|
generatedAt: new Date().toISOString(),
|
package/src/commands/launch.js
CHANGED
|
@@ -20,10 +20,11 @@ import {
|
|
|
20
20
|
saveConfig,
|
|
21
21
|
} from "../config.js";
|
|
22
22
|
import { impelClaudeBaseUrl, impelCrossAppClaudeBaseUrl } from "../claudeSetup.js";
|
|
23
|
+
import { syncCodexModelsSafe } from "../modelSync.js";
|
|
23
24
|
import { parentVerbatimRelayAppendix } from "../verbatimRelay.js";
|
|
24
25
|
import { withGitEnvironment } from "../skills.js";
|
|
25
26
|
import { assertProviderScopes, ensureTenantSelection, tenantCredential } from "../tenants.js";
|
|
26
|
-
import { maybePrintUpdateNotice } from "../updates.js";
|
|
27
|
+
import { installedVersion, maybePrintUpdateNotice } from "../updates.js";
|
|
27
28
|
import { nativeSpawnInvocation } from "../nativeProcess.js";
|
|
28
29
|
import { IMPEL_NATIVE_BENCHMARK_ENV } from "../selfInvocation.js";
|
|
29
30
|
import { resolveReviewedVendorCliBinary } from "../vendorCliBinaries.js";
|
|
@@ -392,7 +393,13 @@ async function assertLiveProviderReadiness({ config, gatewayUrl, credential, ten
|
|
|
392
393
|
if (!readiness.reachable) {
|
|
393
394
|
throw new Error(`could not verify live ${label} availability for tenant "${tenantId}" (${readiness.error || "gateway unreachable"})`);
|
|
394
395
|
}
|
|
395
|
-
if (readiness.rejected)
|
|
396
|
+
if (readiness.rejected) {
|
|
397
|
+
throw new Error(
|
|
398
|
+
`gateway credential was rejected (HTTP ${readiness.status}). `
|
|
399
|
+
+ `Your access to tenant "${tenantId}" may have changed; rerun \`${RUNTIME_BRAND.cli.command} setup\` `
|
|
400
|
+
+ "to refresh it, or ask a workspace administrator to confirm your gateway seat.",
|
|
401
|
+
);
|
|
402
|
+
}
|
|
396
403
|
if (!readiness.healthy) {
|
|
397
404
|
throw new Error(`could not verify live ${label} availability for tenant "${tenantId}" (${readiness.error || `HTTP ${readiness.status}`})`);
|
|
398
405
|
}
|
|
@@ -408,9 +415,13 @@ function runNativeCli(tool, argv, environment) {
|
|
|
408
415
|
const binary = resolveReviewedVendorCliBinary(tool, environment);
|
|
409
416
|
if (!binary) {
|
|
410
417
|
const version = PINNED_VENDOR_CLI_VERSIONS[tool];
|
|
418
|
+
const repair = process.platform === "win32"
|
|
419
|
+
? `${RUNTIME_BRAND.cli.command}.cmd update`
|
|
420
|
+
: `${RUNTIME_BRAND.cli.command} update`;
|
|
411
421
|
console.error(
|
|
412
422
|
`impel ${tool}: reviewed ${tool === "claude" ? "Claude Code" : "Codex"} CLI `
|
|
413
|
-
+ `v${version} is not installed
|
|
423
|
+
+ `v${version} is not installed (an unreviewed or auto-updated build is not launched `
|
|
424
|
+
+ `against the gateway). Run \`${repair}\` to repair it.`,
|
|
414
425
|
);
|
|
415
426
|
return Promise.resolve(127);
|
|
416
427
|
}
|
|
@@ -491,7 +502,7 @@ export async function cmdLaunch(tool, argv) {
|
|
|
491
502
|
if (RUNTIME_BRAND.cli.packageName === "impel-cli") maybePrintUpdateNotice();
|
|
492
503
|
|
|
493
504
|
const gatewayUrl = normalizeGatewayUrl(config.gatewayUrl || resolveDefaultGateway());
|
|
494
|
-
const crossAppModels =
|
|
505
|
+
const crossAppModels = crossAppModelsEnabled(config);
|
|
495
506
|
let tenantId;
|
|
496
507
|
try {
|
|
497
508
|
// PAT scopes are immutable. Refresh legacy configs once, then use the
|
|
@@ -547,8 +558,15 @@ export async function cmdLaunch(tool, argv) {
|
|
|
547
558
|
// Impel PATs are bearer tokens, so keep the token process-scoped instead of
|
|
548
559
|
// writing it into Claude's isolated profile.
|
|
549
560
|
environment.ANTHROPIC_AUTH_TOKEN = gatewayCredential;
|
|
561
|
+
// Version attribution for gateway logs: a 403 in production is currently
|
|
562
|
+
// impossible to correlate to a CLI/vendor build. Appended so a user's own
|
|
563
|
+
// custom headers survive.
|
|
564
|
+
const versionHeader = `X-Impel-Cli-Version: ${RUNTIME_BRAND.cli.packageName}/${installedVersion() || "unknown"}`;
|
|
565
|
+
environment.ANTHROPIC_CUSTOM_HEADERS = environment.ANTHROPIC_CUSTOM_HEADERS
|
|
566
|
+
? `${environment.ANTHROPIC_CUSTOM_HEADERS}\n${versionHeader}`
|
|
567
|
+
: versionHeader;
|
|
550
568
|
} else if (tool === "codex") {
|
|
551
|
-
const profile = ensureImpelCodexProfile(gatewayUrl, tenantId);
|
|
569
|
+
const profile = ensureImpelCodexProfile(gatewayUrl, tenantId, { crossAppModels });
|
|
552
570
|
agentProfile = {
|
|
553
571
|
client: "codex",
|
|
554
572
|
root: profile.codexHome,
|
|
@@ -558,6 +576,19 @@ export async function cmdLaunch(tool, argv) {
|
|
|
558
576
|
deleteEnvironmentKeys(environment, CODEX_DIRECT_AUTH_ENV);
|
|
559
577
|
environment.CODEX_HOME = profile.codexHome;
|
|
560
578
|
environment[CODEX_GATEWAY_TOKEN_ENV] = gatewayCredential;
|
|
579
|
+
// Launch layering (D13): the synchronous writer above guaranteed a valid
|
|
580
|
+
// catalog (offline floor, or the intact last sync). Refresh it from the
|
|
581
|
+
// gateway at most every six hours; a network/catalog failure keeps the
|
|
582
|
+
// last good file and never blocks this launch. Codex reads
|
|
583
|
+
// model_catalog_json at startup, so a fresh sync lands next launch.
|
|
584
|
+
await syncCodexModelsSafe({
|
|
585
|
+
gatewayUrl,
|
|
586
|
+
credential: gatewayCredential,
|
|
587
|
+
tenantId,
|
|
588
|
+
crossAppModels,
|
|
589
|
+
staleOnly: true,
|
|
590
|
+
logger: agentSyncLogger(nativeArgv),
|
|
591
|
+
});
|
|
561
592
|
} else {
|
|
562
593
|
throw new Error(`unsupported CLI launcher: ${tool}`);
|
|
563
594
|
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// `impel models sync|list` — the explicit surface over the CLI Codex model
|
|
2
|
+
// catalog layering (D13). `sync` fetches the tenant's gateway catalog and
|
|
3
|
+
// rewrites the isolated `impel codex` models.json (with provenance in
|
|
4
|
+
// models-manifest.json); `list` is near-free diagnostics over the same files:
|
|
5
|
+
// what the picker will show next launch, where it came from (gateway sync or
|
|
6
|
+
// the offline floor), and how stale it is. Launches never depend on this
|
|
7
|
+
// command — the same sync runs launch-layered, TTL-gated, and from the token
|
|
8
|
+
// heartbeat; this is the direct repair/inspection path.
|
|
9
|
+
|
|
10
|
+
import { parseFlags } from "../args.js";
|
|
11
|
+
import {
|
|
12
|
+
crossAppModelsEnabled,
|
|
13
|
+
loadConfig,
|
|
14
|
+
normalizeGatewayUrl,
|
|
15
|
+
redactSecretText,
|
|
16
|
+
resolveDefaultGateway,
|
|
17
|
+
} from "../config.js";
|
|
18
|
+
import { codexCatalogStatus, syncCodexModels } from "../modelSync.js";
|
|
19
|
+
import { MODEL_SYNC_TTL_MS } from "../modelsManifest.js";
|
|
20
|
+
import { ensureTenantSelection, normalizeTenantId, tenantCredential } from "../tenants.js";
|
|
21
|
+
import { RUNTIME_BRAND } from "../runtimeBrand.js";
|
|
22
|
+
|
|
23
|
+
const HELP = `impel models - inspect or refresh the isolated Codex model catalog
|
|
24
|
+
|
|
25
|
+
Usage:
|
|
26
|
+
impel models list [--tenant <org>] [--json]
|
|
27
|
+
impel models sync [--tenant <org>] [--stale-only]
|
|
28
|
+
|
|
29
|
+
list shows the catalog \`impel codex\` will load next launch: each model, and
|
|
30
|
+
whether the file is the last gateway sync (with its age) or the built-in
|
|
31
|
+
offline floor. sync fetches the tenant's live gateway catalog and rewrites the
|
|
32
|
+
catalog atomically; --stale-only exits without fetching while the last sync is
|
|
33
|
+
inside its ${MODEL_SYNC_TTL_MS / (60 * 60 * 1000)}h window. Launches never block on this: they layer the same
|
|
34
|
+
sync after the offline floor write and keep the last good catalog on failure.
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
function formatAge(ageMs) {
|
|
38
|
+
const minutes = Math.floor(ageMs / 60_000);
|
|
39
|
+
if (minutes < 1) return "just now";
|
|
40
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
41
|
+
const hours = Math.floor(minutes / 60);
|
|
42
|
+
if (hours < 48) return `${hours}h ago`;
|
|
43
|
+
return `${Math.floor(hours / 24)}d ago`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function describeCatalogStatus(status) {
|
|
47
|
+
if (status.catalogError === "missing") {
|
|
48
|
+
return `not initialized (run \`${RUNTIME_BRAND.cli.command} setup\` or \`${RUNTIME_BRAND.cli.command} codex\` first)`;
|
|
49
|
+
}
|
|
50
|
+
if (status.catalogError) return "unreadable (rerun the launcher to regenerate it)";
|
|
51
|
+
if (status.source === "floor") {
|
|
52
|
+
return `offline floor (never synced; run \`${RUNTIME_BRAND.cli.command} models sync\`)`;
|
|
53
|
+
}
|
|
54
|
+
const age = status.ageMs === null ? "unknown age" : formatAge(status.ageMs);
|
|
55
|
+
const version = status.manifest?.catalogVersion == null ? "" : `, catalog v${status.manifest.catalogVersion}`;
|
|
56
|
+
return `gateway sync ${age}${version}, ${status.fresh ? "fresh" : "stale"}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function cmdModels(argv, overrides = {}) {
|
|
60
|
+
const [action, ...rest] = argv;
|
|
61
|
+
if (action === undefined || ["help", "--help", "-h"].includes(action)) {
|
|
62
|
+
console.log(HELP);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (!["sync", "list"].includes(action)) {
|
|
66
|
+
console.error(`impel models: unknown action "${action}". Use \`sync\` or \`list\`.`);
|
|
67
|
+
process.exitCode = 1;
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const { flags, positionals } = parseFlags(rest, {
|
|
71
|
+
tenant: { type: "string" },
|
|
72
|
+
"stale-only": { type: "boolean" },
|
|
73
|
+
json: { type: "boolean" },
|
|
74
|
+
});
|
|
75
|
+
if (positionals.length > 0) {
|
|
76
|
+
console.error(`impel models: unexpected argument "${positionals[0]}"`);
|
|
77
|
+
process.exitCode = 1;
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const io = {
|
|
82
|
+
loadConfig,
|
|
83
|
+
ensureTenantSelection,
|
|
84
|
+
syncModels: syncCodexModels,
|
|
85
|
+
catalogStatus: codexCatalogStatus,
|
|
86
|
+
log: (message) => console.log(message),
|
|
87
|
+
...overrides,
|
|
88
|
+
};
|
|
89
|
+
const config = io.loadConfig();
|
|
90
|
+
if (!config?.pat) {
|
|
91
|
+
console.error(`impel models: not authenticated. Run \`${RUNTIME_BRAND.cli.command} setup\` (or \`${RUNTIME_BRAND.cli.command} auth\`) first.`);
|
|
92
|
+
process.exitCode = 1;
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const tenantId = flags.tenant
|
|
98
|
+
? normalizeTenantId(flags.tenant)
|
|
99
|
+
: (await io.ensureTenantSelection(config)).tenantId;
|
|
100
|
+
const gatewayUrl = normalizeGatewayUrl(config.gatewayUrl || resolveDefaultGateway());
|
|
101
|
+
const crossAppModels = crossAppModelsEnabled(config);
|
|
102
|
+
|
|
103
|
+
if (action === "sync") {
|
|
104
|
+
const result = await io.syncModels({
|
|
105
|
+
gatewayUrl,
|
|
106
|
+
credential: tenantCredential(config.pat, tenantId),
|
|
107
|
+
tenantId,
|
|
108
|
+
crossAppModels,
|
|
109
|
+
staleOnly: Boolean(flags["stale-only"]),
|
|
110
|
+
});
|
|
111
|
+
if (result.skipped && result.reason === "uninitialized") {
|
|
112
|
+
console.error(
|
|
113
|
+
`impel models: the isolated Codex profile for tenant "${tenantId}" is not initialized. `
|
|
114
|
+
+ `Run \`${RUNTIME_BRAND.cli.command} setup\` (or \`${RUNTIME_BRAND.cli.command} codex\`) first.`,
|
|
115
|
+
);
|
|
116
|
+
process.exitCode = 1;
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (result.skipped) {
|
|
120
|
+
io.log(`Models: catalog for tenant ${tenantId} is fresh; nothing to sync.`);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
io.log(
|
|
124
|
+
`Models: synced ${result.models} model${result.models === 1 ? "" : "s"} for tenant ${tenantId}`
|
|
125
|
+
+ `${result.catalogVersion == null ? "" : ` (catalog v${result.catalogVersion})`}. `
|
|
126
|
+
+ "The picker updates on the next launch.",
|
|
127
|
+
);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const status = io.catalogStatus(tenantId, { gatewayUrl, crossAppModels });
|
|
132
|
+
if (flags.json) {
|
|
133
|
+
io.log(JSON.stringify({
|
|
134
|
+
tenantId: status.tenantId,
|
|
135
|
+
catalogPath: status.catalogPath,
|
|
136
|
+
source: status.catalogError ? null : status.source,
|
|
137
|
+
catalogError: status.catalogError,
|
|
138
|
+
syncedAt: status.syncedAt,
|
|
139
|
+
catalogVersion: status.manifest?.catalogVersion ?? null,
|
|
140
|
+
fresh: status.fresh,
|
|
141
|
+
crossAppModels,
|
|
142
|
+
models: status.models.map((model) => model.slug),
|
|
143
|
+
}, null, 2));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
io.log(`Tenant: ${tenantId}`);
|
|
147
|
+
io.log(`Catalog: ${status.catalogPath}`);
|
|
148
|
+
io.log(`Status: ${describeCatalogStatus(status)}`);
|
|
149
|
+
if (crossAppModels) io.log("Experiments: cross-app models enabled");
|
|
150
|
+
if (status.models.length > 0) {
|
|
151
|
+
io.log(`Models (${status.models.length}):`);
|
|
152
|
+
for (const model of status.models) {
|
|
153
|
+
io.log(` ${model.slug}${model.display_name && model.display_name !== model.slug ? ` — ${model.display_name}` : ""}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
} catch (error) {
|
|
157
|
+
console.error(`impel models: ${redactSecretText(error?.message || error)}`);
|
|
158
|
+
process.exitCode = 1;
|
|
159
|
+
}
|
|
160
|
+
}
|
package/src/commands/sessions.js
CHANGED
|
@@ -14,9 +14,17 @@ import {
|
|
|
14
14
|
sessionOutboxStatus,
|
|
15
15
|
touchSessionFlushLock,
|
|
16
16
|
} from "../sessionCollector.js";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
crossAppModelsEnabled,
|
|
19
|
+
loadConfig,
|
|
20
|
+
normalizeGatewayUrl,
|
|
21
|
+
redactSecretText,
|
|
22
|
+
resolveDefaultGateway,
|
|
23
|
+
} from "../config.js";
|
|
24
|
+
import { tenantCliProfilePaths } from "../cliProfiles.js";
|
|
25
|
+
import { cliCodexCatalogStale } from "../modelSync.js";
|
|
18
26
|
import { impelCliInvocation } from "../selfInvocation.js";
|
|
19
|
-
import { spawnDetachedAppRefresh } from "../updates.js";
|
|
27
|
+
import { spawnDetachedAppRefresh, spawnDetachedModelSync } from "../updates.js";
|
|
20
28
|
import { brandedEnvironmentName, RUNTIME_BRAND } from "../runtimeBrand.js";
|
|
21
29
|
|
|
22
30
|
const MANAGED_SESSION_HOOK_FLAG = `${RUNTIME_BRAND.cli.command}-managed-session-hook-v1`;
|
|
@@ -71,6 +79,45 @@ export function maybeRepairManagedApps(tenantId, {
|
|
|
71
79
|
return true;
|
|
72
80
|
}
|
|
73
81
|
|
|
82
|
+
/**
|
|
83
|
+
* The model-catalog half of the token-helper heartbeat: when the isolated CLI
|
|
84
|
+
* Codex catalog for this tenant exists but is past its sync TTL (or its
|
|
85
|
+
* manifest no longer matches the file), kick one detached
|
|
86
|
+
* `models sync --stale-only`. Same shape as maybeRepairManagedApps — the
|
|
87
|
+
* vendor apps and Codex's auth command invoke `impel token` every ~5 minutes,
|
|
88
|
+
* so this is the channel that keeps catalogs converging on machines where
|
|
89
|
+
* nobody relaunches. The heartbeat lock stops repeated token calls from
|
|
90
|
+
* stacking sync children while the gateway is unreachable.
|
|
91
|
+
*/
|
|
92
|
+
export function maybeSyncCliModels(tenantId, {
|
|
93
|
+
config = null,
|
|
94
|
+
catalogStale = cliCodexCatalogStale,
|
|
95
|
+
lockIsFresh = sessionFlushLockIsFresh,
|
|
96
|
+
touchLock = touchSessionFlushLock,
|
|
97
|
+
spawnModelSync = spawnDetachedModelSync,
|
|
98
|
+
} = {}) {
|
|
99
|
+
if (!tenantId) return false;
|
|
100
|
+
let stored = config;
|
|
101
|
+
if (stored === null) {
|
|
102
|
+
try {
|
|
103
|
+
stored = loadConfig();
|
|
104
|
+
} catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (!stored?.pat) return false;
|
|
109
|
+
const gatewayUrl = normalizeGatewayUrl(stored.gatewayUrl || resolveDefaultGateway());
|
|
110
|
+
if (!catalogStale(tenantId, {
|
|
111
|
+
gatewayUrl,
|
|
112
|
+
crossAppModels: crossAppModelsEnabled(stored),
|
|
113
|
+
})) return false;
|
|
114
|
+
const lock = path.join(tenantCliProfilePaths(tenantId).codexHome, "models-sync-heartbeat");
|
|
115
|
+
if (lockIsFresh(lock, 60_000)) return false;
|
|
116
|
+
touchLock(lock);
|
|
117
|
+
spawnModelSync(tenantId);
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
|
|
74
121
|
/**
|
|
75
122
|
* The codex session-hook entry point: same repair, scoped to ChatGPT/Codex
|
|
76
123
|
* drift exactly as before the helper was generalized (the codex hook fires on
|
|
@@ -127,7 +174,13 @@ export async function cmdSessions(argv) {
|
|
|
127
174
|
config,
|
|
128
175
|
flush: false,
|
|
129
176
|
});
|
|
130
|
-
if (flags.provider === "codex")
|
|
177
|
+
if (flags.provider === "codex") {
|
|
178
|
+
maybeRepairManagedCodexApp(flags.tenant);
|
|
179
|
+
// Same guaranteed-to-run channel, second artifact: keep the isolated
|
|
180
|
+
// CLI Codex model catalog converging from codex session events too
|
|
181
|
+
// (TTL + heartbeat-locked, detached `models sync --stale-only`).
|
|
182
|
+
maybeSyncCliModels(flags.tenant);
|
|
183
|
+
}
|
|
131
184
|
// The Claude mirror of the codex repair above: Claude Desktop rewrites
|
|
132
185
|
// its co-owned profile files too, and its session hooks are the only
|
|
133
186
|
// Impel code guaranteed to still run afterward.
|
package/src/commands/setup.js
CHANGED
|
@@ -344,6 +344,7 @@ export async function cmdSetup(argv, overrides = {}) {
|
|
|
344
344
|
tenantId: selected.id,
|
|
345
345
|
platform: io.platform,
|
|
346
346
|
skipInstall: true,
|
|
347
|
+
crossAppModels: config.experimental?.crossAppModels === true,
|
|
347
348
|
});
|
|
348
349
|
let prepared = inspected;
|
|
349
350
|
if (inspected.missingAfter.length && !inspectOnly && !flags["skip-clis"]) {
|
|
@@ -376,6 +377,7 @@ export async function cmdSetup(argv, overrides = {}) {
|
|
|
376
377
|
platform: io.platform,
|
|
377
378
|
skipInstall: false,
|
|
378
379
|
installTools,
|
|
380
|
+
crossAppModels: config.experimental?.crossAppModels === true,
|
|
379
381
|
});
|
|
380
382
|
}
|
|
381
383
|
}
|
package/src/commands/token.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { loadConfig } from "../config.js";
|
|
2
2
|
import { parseFlags } from "../args.js";
|
|
3
3
|
import { ensureTenantSelection, normalizeTenantId, tenantCredential } from "../tenants.js";
|
|
4
|
-
import { maybeRepairManagedApps } from "./sessions.js";
|
|
4
|
+
import { maybeRepairManagedApps, maybeSyncCliModels } from "./sessions.js";
|
|
5
5
|
import { RUNTIME_BRAND } from "../runtimeBrand.js";
|
|
6
6
|
|
|
7
7
|
// This is the `apiKeyHelper` / auth-command contract: stdout (and only
|
|
@@ -12,6 +12,7 @@ export async function cmdToken(argv = [], overrides = {}) {
|
|
|
12
12
|
loadConfig,
|
|
13
13
|
ensureTenantSelection,
|
|
14
14
|
repairManagedApps: maybeRepairManagedApps,
|
|
15
|
+
syncCliModels: maybeSyncCliModels,
|
|
15
16
|
...overrides,
|
|
16
17
|
};
|
|
17
18
|
const { flags } = parseFlags(argv, { tenant: { type: "string" } });
|
|
@@ -39,6 +40,15 @@ export async function cmdToken(argv = [], overrides = {}) {
|
|
|
39
40
|
} catch {
|
|
40
41
|
// Opportunistic only; the token contract is already fulfilled.
|
|
41
42
|
}
|
|
43
|
+
// Same heartbeat, second surface: Codex invokes this helper every ~5
|
|
44
|
+
// minutes, so it is also where a stale isolated-CLI model catalog gets
|
|
45
|
+
// its detached `models sync --stale-only` (D13). Identical contract:
|
|
46
|
+
// after the token bytes, never stdout, never the exit code.
|
|
47
|
+
try {
|
|
48
|
+
io.syncCliModels(tenantId, { config });
|
|
49
|
+
} catch {
|
|
50
|
+
// Opportunistic only; the token contract is already fulfilled.
|
|
51
|
+
}
|
|
42
52
|
} catch (error) {
|
|
43
53
|
process.stderr.write(`${RUNTIME_BRAND.cli.command}: ${error.message}\n`);
|
|
44
54
|
process.exitCode = 1;
|
package/src/commands/update.js
CHANGED
|
@@ -11,6 +11,10 @@ import { nativeCommandInvocation } from "../nativeProcess.js";
|
|
|
11
11
|
import { withProgress } from "../progress.js";
|
|
12
12
|
import { runInstallRecovery } from "../installRecovery/engine.js";
|
|
13
13
|
import { reportInstallFailure } from "../autoReport.js";
|
|
14
|
+
import {
|
|
15
|
+
convergenceSummaryDiagnostics,
|
|
16
|
+
readRecentConvergenceSummary,
|
|
17
|
+
} from "../convergenceSummary.js";
|
|
14
18
|
import { brandedEnvironmentName, brandedText, RUNTIME_BRAND } from "../runtimeBrand.js";
|
|
15
19
|
import { IMPEL_CLI_ENTRYPOINT } from "../selfInvocation.js";
|
|
16
20
|
import {
|
|
@@ -512,12 +516,11 @@ export async function cmdUpdate(argv, overrides = {}) {
|
|
|
512
516
|
console.error("impel update: tenant convergence failed; rerun `impel update` after addressing the reported issue.");
|
|
513
517
|
// The child is `impel _converge`, which files no report of its own — and
|
|
514
518
|
// this parent only sees its exit status, because the child inherits stdio
|
|
515
|
-
// so its progress reaches the terminal live.
|
|
516
|
-
//
|
|
517
|
-
//
|
|
518
|
-
//
|
|
519
|
-
|
|
520
|
-
// not simply be a second report from the child.
|
|
519
|
+
// so its progress reaches the terminal live. The child does leave a
|
|
520
|
+
// machine-readable summary on disk, though, so the single report R2
|
|
521
|
+
// allows can carry which tenants failed and why instead of only the
|
|
522
|
+
// flags that shaped the run.
|
|
523
|
+
const summary = (io.readConvergenceSummary || readRecentConvergenceSummary)();
|
|
521
524
|
await reportUpdateFailure({
|
|
522
525
|
scope: "shared",
|
|
523
526
|
platform: io.platform,
|
|
@@ -528,6 +531,7 @@ export async function cmdUpdate(argv, overrides = {}) {
|
|
|
528
531
|
skipApps: String(Boolean(flags["skip-apps"])),
|
|
529
532
|
skipClis: String(Boolean(flags["skip-clis"])),
|
|
530
533
|
cascadedEntrypoint: String(Boolean(cascadeEntrypoint)),
|
|
534
|
+
...convergenceSummaryDiagnostics(summary),
|
|
531
535
|
},
|
|
532
536
|
});
|
|
533
537
|
process.exitCode = 1;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// The `impel update` parent spawns `impel _converge` with inherited stdio and
|
|
2
|
+
// only sees its exit status, and R2 allows one report per invocation — so the
|
|
3
|
+
// auto-filed "Tenant convergence failed" report has carried no tenant detail
|
|
4
|
+
// at all. The child instead leaves a machine-readable summary on disk that the
|
|
5
|
+
// parent folds into the diagnostics of the report it was already filing.
|
|
6
|
+
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
|
|
10
|
+
import { CONFIG_DIR, redactSecretText } from "./config.js";
|
|
11
|
+
import { renameWithWindowsRetry } from "./windowsFs.js";
|
|
12
|
+
|
|
13
|
+
export const CONVERGENCE_SUMMARY_PATH = path.join(CONFIG_DIR, "reports", ".last-convergence.json");
|
|
14
|
+
|
|
15
|
+
// Wide enough to survive install-recovery sessions between the child's write
|
|
16
|
+
// and the parent's read; narrow enough that yesterday's failure never
|
|
17
|
+
// masquerades as today's.
|
|
18
|
+
export const CONVERGENCE_SUMMARY_FRESHNESS_MS = 15 * 60 * 1_000;
|
|
19
|
+
|
|
20
|
+
const MAX_FAILED_TENANTS = 12;
|
|
21
|
+
const MAX_ERRORS_PER_TENANT = 4;
|
|
22
|
+
const MAX_ERROR_LENGTH = 300;
|
|
23
|
+
|
|
24
|
+
/** Best-effort: losing the summary only costs report detail, never the run. */
|
|
25
|
+
export function writeConvergenceSummary({ passed, sharedFailure = null, failedTenants = [] }, {
|
|
26
|
+
summaryPath = CONVERGENCE_SUMMARY_PATH,
|
|
27
|
+
now = Date.now(),
|
|
28
|
+
} = {}) {
|
|
29
|
+
try {
|
|
30
|
+
const summary = {
|
|
31
|
+
writtenAt: new Date(now).toISOString(),
|
|
32
|
+
passed: Boolean(passed),
|
|
33
|
+
sharedFailure: sharedFailure ? redactSecretText(String(sharedFailure)).slice(0, 2_000) : null,
|
|
34
|
+
failedTenants: failedTenants.slice(0, MAX_FAILED_TENANTS).map((tenant) => ({
|
|
35
|
+
tenantId: String(tenant.tenantId || "").slice(0, 128),
|
|
36
|
+
errors: (tenant.errors || [])
|
|
37
|
+
.slice(0, MAX_ERRORS_PER_TENANT)
|
|
38
|
+
.map((error) => redactSecretText(String(error)).slice(0, MAX_ERROR_LENGTH)),
|
|
39
|
+
})),
|
|
40
|
+
};
|
|
41
|
+
fs.mkdirSync(path.dirname(summaryPath), { recursive: true, mode: 0o700 });
|
|
42
|
+
const temporaryPath = `${summaryPath}.tmp-${process.pid}`;
|
|
43
|
+
fs.writeFileSync(temporaryPath, `${JSON.stringify(summary)}\n`, { mode: 0o600 });
|
|
44
|
+
renameWithWindowsRetry(temporaryPath, summaryPath);
|
|
45
|
+
} catch {
|
|
46
|
+
// Deliberately silent: the summary is diagnostics for a report about a
|
|
47
|
+
// failure that has already been announced on the user's terminal.
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Returns the parsed summary only while it is plausibly from this cascade. */
|
|
52
|
+
export function readRecentConvergenceSummary({
|
|
53
|
+
summaryPath = CONVERGENCE_SUMMARY_PATH,
|
|
54
|
+
now = Date.now(),
|
|
55
|
+
} = {}) {
|
|
56
|
+
try {
|
|
57
|
+
const parsed = JSON.parse(fs.readFileSync(summaryPath, "utf8"));
|
|
58
|
+
const writtenAt = Date.parse(parsed?.writtenAt || "");
|
|
59
|
+
if (!Number.isFinite(writtenAt)) return null;
|
|
60
|
+
if (writtenAt > now + 60_000 || now - writtenAt > CONVERGENCE_SUMMARY_FRESHNESS_MS) return null;
|
|
61
|
+
if (typeof parsed.passed !== "boolean" || !Array.isArray(parsed.failedTenants)) return null;
|
|
62
|
+
return parsed;
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Flatten a summary into the bounded string map the report envelope accepts. */
|
|
69
|
+
export function convergenceSummaryDiagnostics(summary) {
|
|
70
|
+
if (!summary || summary.passed) return {};
|
|
71
|
+
const failed = summary.failedTenants.filter((tenant) => tenant?.tenantId);
|
|
72
|
+
return {
|
|
73
|
+
...(summary.sharedFailure ? { sharedFailure: summary.sharedFailure } : {}),
|
|
74
|
+
...(failed.length
|
|
75
|
+
? {
|
|
76
|
+
failedTenantIds: failed.map((tenant) => tenant.tenantId).join(","),
|
|
77
|
+
failedTenantErrors: failed
|
|
78
|
+
.map((tenant) => `${tenant.tenantId}: ${(tenant.errors || []).join("; ")}`)
|
|
79
|
+
.join(" | ")
|
|
80
|
+
.slice(0, 1_800),
|
|
81
|
+
}
|
|
82
|
+
: {}),
|
|
83
|
+
};
|
|
84
|
+
}
|
package/src/extension/index.js
CHANGED
|
@@ -12,6 +12,7 @@ const ALIASES = Object.freeze({
|
|
|
12
12
|
tenants: "tenant",
|
|
13
13
|
org: "tenant",
|
|
14
14
|
skill: "skills",
|
|
15
|
+
model: "models",
|
|
15
16
|
agent: "agents",
|
|
16
17
|
upgrade: "update",
|
|
17
18
|
on: "use",
|
|
@@ -39,6 +40,7 @@ function help(version) {
|
|
|
39
40
|
if (enabled.has("mcp")) lines.push(` ${command} mcp Run the authenticated MCP transport`);
|
|
40
41
|
if (enabled.has("sessions")) lines.push(` ${command} sessions ... Run managed session lifecycle hooks`);
|
|
41
42
|
if (enabled.has("skills")) lines.push(` ${command} skills sync [...] Sync gateway skills into managed clients`);
|
|
43
|
+
if (enabled.has("models")) lines.push(` ${command} models list|sync Inspect or refresh the isolated Codex model catalog`);
|
|
42
44
|
if (enabled.has("agents")) lines.push(` ${command} agents sync [...] Sync tenant agents into managed clients`);
|
|
43
45
|
if (enabled.has("status")) lines.push(` ${command} status Show local readiness`);
|
|
44
46
|
if (enabled.has("doctor")) lines.push(` ${command} doctor [...] Run gateway and provider diagnostics`);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// The authenticated tenant model catalog fetch (GET /v1/models), shared by
|
|
2
|
+
// the desktop app writers in apps.js and the CLI catalog sync in
|
|
3
|
+
// modelSync.js. Leaf module (no imports): the model-catalog side of the CLI
|
|
4
|
+
// must be loadable without pulling in the desktop bundle machinery, so this
|
|
5
|
+
// cannot live in apps.js. apps.js re-exports it for existing importers.
|
|
6
|
+
|
|
7
|
+
function isGatewayModel(model) {
|
|
8
|
+
return model && typeof model.id === "string" && (model.provider === "claude" || model.provider === "codex");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A tenant whose subscription genuinely has no Claude/Codex seats returns an
|
|
13
|
+
* explicitly marked empty catalog. Only lifecycle reconciliation may treat
|
|
14
|
+
* that as valid emptiness (allowEmpty) — every other caller keeps failing
|
|
15
|
+
* closed so a gateway outage can never masquerade as "no models".
|
|
16
|
+
*/
|
|
17
|
+
function isExplicitNoSeatCatalog(payload) {
|
|
18
|
+
const statuses = payload?.provider_status;
|
|
19
|
+
return Boolean(statuses
|
|
20
|
+
&& typeof statuses === "object"
|
|
21
|
+
&& !Array.isArray(statuses)
|
|
22
|
+
&& ["claude", "codex"].every((provider) => (
|
|
23
|
+
statuses[provider]?.state === "no_seat"
|
|
24
|
+
&& statuses[provider]?.routable === false
|
|
25
|
+
)));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function fetchGatewayModels(config, fetchImpl = fetch, { allowEmpty = false } = {}) {
|
|
29
|
+
const controller = new AbortController();
|
|
30
|
+
const timeout = setTimeout(() => controller.abort(), 20000);
|
|
31
|
+
try {
|
|
32
|
+
const response = await fetchImpl(`${config.gatewayUrl}/v1/models`, {
|
|
33
|
+
headers: { authorization: `Bearer ${config.pat}` },
|
|
34
|
+
signal: controller.signal,
|
|
35
|
+
});
|
|
36
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
37
|
+
const payload = await response.json();
|
|
38
|
+
if (config.tenantId && payload?.org_id !== config.tenantId) {
|
|
39
|
+
throw new Error("gateway model catalog tenant did not match the selected tenant");
|
|
40
|
+
}
|
|
41
|
+
if (config.productAccess && payload?.product_access !== config.productAccess) {
|
|
42
|
+
throw new Error("gateway model catalog product access did not match the live entitlement");
|
|
43
|
+
}
|
|
44
|
+
if (!Array.isArray(payload?.data)) throw new Error("response has no model data array");
|
|
45
|
+
// The envelope is additive at schema version 3: overlay-aware gateways
|
|
46
|
+
// also send entitlement_available and org_defaults beside the filtered
|
|
47
|
+
// data[], whose per-model default/family_default flags are already
|
|
48
|
+
// rewritten to the tenant's effective defaults. Clients trust the served
|
|
49
|
+
// flags as-is and tolerate unknown envelope fields.
|
|
50
|
+
const models = payload.data.filter(isGatewayModel);
|
|
51
|
+
if (models.length === 0 && !(allowEmpty && isExplicitNoSeatCatalog(payload))) {
|
|
52
|
+
throw new Error("gateway returned no supported models");
|
|
53
|
+
}
|
|
54
|
+
return { models, source: "gateway", version: payload.version ?? null };
|
|
55
|
+
} finally {
|
|
56
|
+
clearTimeout(timeout);
|
|
57
|
+
}
|
|
58
|
+
}
|