impel-cli 0.17.14 → 0.17.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.17.14",
3
+ "version": "0.17.15",
4
4
  "description": "Prepare isolated Claude and Codex workspaces for every accessible Impel tenant",
5
5
  "type": "module",
6
6
  "bin": {
package/src/apps.js CHANGED
@@ -1355,6 +1355,29 @@ function mergeManagedChatGPTToml(current, managed) {
1355
1355
  return `${managed}\n${remainder ? `\n${remainder.replace(/\s*$/u, "")}\n` : ""}`;
1356
1356
  }
1357
1357
 
1358
+ /**
1359
+ * True when a managed ChatGPT profile exists but its config no longer routes
1360
+ * inference through the Impel gateway. The Codex desktop app rewrites
1361
+ * config.toml with its own settings writer (model picker, project trust,
1362
+ * migrations) and can drop the managed top-level `model_provider` key; without
1363
+ * it the built-in ChatGPT provider derives its inference endpoint from
1364
+ * chatgpt.com and sends the Impel token there, which chatgpt.com rejects with
1365
+ * 403 "Unknown personal access token". Stale-only refreshes treat this drift
1366
+ * as staleness so the profile heals immediately instead of waiting out the
1367
+ * manifest TTL.
1368
+ */
1369
+ export function managedChatGPTConfigDrifted(paths) {
1370
+ let current;
1371
+ try {
1372
+ current = fs.readFileSync(path.join(paths.chatgpt.codexHome, "config.toml"), "utf8");
1373
+ } catch {
1374
+ return false; // No managed profile; install/update owns creating one.
1375
+ }
1376
+ return !current.includes(CHATGPT_CONFIG_START)
1377
+ || readTopLevelTomlString(current, "model_provider") !== "impel"
1378
+ || !readTopLevelTomlString(current, "chatgpt_base_url");
1379
+ }
1380
+
1358
1381
  function readTopLevelTomlString(toml, key) {
1359
1382
  const topLevel = toml.split(/^\s*\[/mu, 1)[0];
1360
1383
  const match = topLevel.match(new RegExp(`^\\s*${escapeRegex(key)}\\s*=\\s*("(?:[^"\\\\]|\\\\.)*")\\s*$`, "mu"));
@@ -32,6 +32,7 @@ import {
32
32
  ensureVendorApp,
33
33
  fetchGatewayModels,
34
34
  installManagedAppFiles,
35
+ managedChatGPTConfigDrifted,
35
36
  managedLauncherName,
36
37
  normalizeAppTarget,
37
38
  quitBlockingApps,
@@ -662,7 +663,7 @@ export async function cmdWindowsApps(argv, overrides = {}) {
662
663
  claudeUserData: io.claudeUserData(io.environment, staleTenantId),
663
664
  tenantName: staleTenantId === stored.tenantId ? stored.tenantName : null,
664
665
  });
665
- if (manifestIsFresh(stalePaths, stored)) return true;
666
+ if (manifestIsFresh(stalePaths, stored) && !managedChatGPTConfigDrifted(stalePaths)) return true;
666
667
  }
667
668
  }
668
669
  const config = await io.selectedConfig(targets, flags.tenant || null);
@@ -1241,7 +1242,7 @@ async function refreshApps(targets, { staleOnly = false, tenantId = null } = {},
1241
1242
  ? stored.tenantName || manifest?.tenantName
1242
1243
  : manifest?.tenantName,
1243
1244
  });
1244
- if (staleOnly && manifestIsFresh(paths, stored)) return;
1245
+ if (staleOnly && manifestIsFresh(paths, stored) && !managedChatGPTConfigDrifted(paths)) return;
1245
1246
 
1246
1247
  let config;
1247
1248
  try {
@@ -1,6 +1,8 @@
1
1
  import { spawn } from "node:child_process";
2
+ import path from "node:path";
2
3
 
3
4
  import { parseFlags } from "../args.js";
5
+ import { appPaths, managedChatGPTConfigDrifted } from "../apps.js";
4
6
  import {
5
7
  clearSessionFlushLock,
6
8
  collectSessionHook,
@@ -13,6 +15,7 @@ import {
13
15
  } from "../sessionCollector.js";
14
16
  import { loadConfig, redactSecretText } from "../config.js";
15
17
  import { impelCliInvocation } from "../selfInvocation.js";
18
+ import { spawnDetachedAppRefresh } from "../updates.js";
16
19
 
17
20
  const SPEC = {
18
21
  provider: { type: "string" },
@@ -22,6 +25,31 @@ const SPEC = {
22
25
  "impel-managed-session-hook-v1": { type: "boolean" },
23
26
  };
24
27
 
28
+ /**
29
+ * Repair a managed ChatGPT profile the desktop app just rewrote out from under
30
+ * the gateway. Hooks are the only Impel code guaranteed to run while the app
31
+ * is broken (the app stops calling the provider token helper once the managed
32
+ * `model_provider` key is gone), so each codex hook event checks for drift and
33
+ * kicks one detached stale-only refresh — which the drift-aware staleness gate
34
+ * turns into a real repair. The heartbeat lock keeps repeated hook events from
35
+ * stacking refresh children while one is already running.
36
+ */
37
+ export function maybeRepairManagedCodexApp(tenantId, {
38
+ drifted = managedChatGPTConfigDrifted,
39
+ lockIsFresh = sessionFlushLockIsFresh,
40
+ touchLock = touchSessionFlushLock,
41
+ spawnRefresh = spawnDetachedAppRefresh,
42
+ } = {}) {
43
+ if (!tenantId) return false;
44
+ const paths = appPaths(undefined, tenantId);
45
+ if (!drifted(paths)) return false;
46
+ const lock = path.join(paths.tenantRoot, "app-repair-heartbeat");
47
+ if (lockIsFresh(lock, 60_000)) return false;
48
+ touchLock(lock);
49
+ spawnRefresh(tenantId);
50
+ return true;
51
+ }
52
+
25
53
  function startDetachedFlush({ provider, tenant, session }) {
26
54
  const invocation = impelCliInvocation([
27
55
  "sessions",
@@ -65,6 +93,7 @@ export async function cmdSessions(argv) {
65
93
  config,
66
94
  flush: false,
67
95
  });
96
+ if (flags.provider === "codex") maybeRepairManagedCodexApp(flags.tenant);
68
97
  if (config && (config.tenantId === flags.tenant || process.env.IMPEL_SESSIONS_DEV_ORG_ID)) {
69
98
  // Hooks fire on every session event; only spawn a flush child when no
70
99
  // live one is already polling this session's outbox (heartbeat lock).