impel-cli 0.17.5 → 0.17.7

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.5",
3
+ "version": "0.17.7",
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
@@ -41,19 +41,26 @@ const APP_DEFINITIONS = {
41
41
  // CLI release therefore installs and accepts only the exact app builds tested
42
42
  // with its compatibility patches. Updating a pin requires a new CLI release.
43
43
  export const PINNED_VENDOR_APPS = Object.freeze({
44
+ // 1.22209.3 (the previous pin) routes every managed 3P gateway profile into
45
+ // the Claude Code marketing page at app://localhost/code/disabled: the
46
+ // desktop Code surface never becomes available, the hardcoded-off
47
+ // claude_code_web gate traps /code/*, and the page's "Back to Claude" link
48
+ // targets "/" so the persisted Code mode loops straight back. 1.20186.9 is
49
+ // the last build verified working end-to-end for gateway profiles. Do not
50
+ // re-upgrade without a verified fix for the Code-surface routing contract.
44
51
  claude: Object.freeze({
45
- version: "1.22209.3",
46
- claudeCodeVersion: "2.1.215",
47
- claudeCodeCommit: "316ce99628e89900bf0b1328fed3b8fec0c0c92d",
52
+ version: "1.20186.9",
53
+ claudeCodeVersion: "2.1.209",
54
+ claudeCodeCommit: "0fe048596fd45e79e99353cbe2c3d1b1ac069568",
48
55
  bundleName: "Claude.app",
49
56
  downloads: Object.freeze({
50
57
  arm64: Object.freeze({
51
- url: "https://downloads.claude.ai/releases/darwin/universal/1.22209.3/Claude-babe11577dfefe3e209c06bd674628d862f0dbae.zip",
52
- sha256: "b110e0fdb7b2601d80dfdd7893f7811de382de84c4586113e7873e28f17680fc",
58
+ url: "https://downloads.claude.ai/releases/darwin/universal/1.20186.9/Claude-69f150a4c9316d5c8cd7b9f130ed583d15c0383e.zip",
59
+ sha256: "9364648c74bd525aa293a28103eb37262a5553de21c7ca907766d7fc40623015",
53
60
  }),
54
61
  x64: Object.freeze({
55
- url: "https://downloads.claude.ai/releases/darwin/universal/1.22209.3/Claude-babe11577dfefe3e209c06bd674628d862f0dbae.zip",
56
- sha256: "b110e0fdb7b2601d80dfdd7893f7811de382de84c4586113e7873e28f17680fc",
62
+ url: "https://downloads.claude.ai/releases/darwin/universal/1.20186.9/Claude-69f150a4c9316d5c8cd7b9f130ed583d15c0383e.zip",
63
+ sha256: "9364648c74bd525aa293a28103eb37262a5553de21c7ca907766d7fc40623015",
57
64
  }),
58
65
  }),
59
66
  }),
@@ -85,22 +92,16 @@ const ULTRA_REASONING_LEVEL = { effort: "ultra", description: "Maximum reasoning
85
92
  const FAST_SERVICE_TIER = { id: "priority", name: "Fast", description: "1.5x speed, increased usage" };
86
93
  const FAST_MODE_AUTH_GATE = /([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)&&!([A-Za-z_$][\w$]*)&&([A-Za-z_$][\w$]*)!=null&&\4\?\.requirements\?\.featureRequirements\?\.fast_mode!==!1/gu;
87
94
  const CHATGPT_RESOURCE_ROOT = "linked_vendor_resources_with_patched_asar";
88
- const CLAUDE_PLAN_USAGE_GUARD = "if(!Vve()){f8();return}if(!et().hasOrgPolicyBackend())return;if((r=yr())";
89
- const IMPEL_CLAUDE_PLAN_USAGE_GUARD = `if(!Vve()){f8();return}${"".padEnd("if(!et().hasOrgPolicyBackend())return;".length, " ")}if((r=yr())`;
90
- const CLAUDE_DRAFT_AGENT_MENTIONS = 'agentMentionsEnabled:"draft"===He&&s';
91
- const IMPEL_CLAUDE_BOUND_AGENT_MENTIONS = 'agentMentionsEnabled:"draft"===He?s:Le?.agent??!1';
95
+ const CLAUDE_PLAN_USAGE_GUARD = "if(!Ze().hasOrgPolicyBackend())return;if((r=fr())";
96
+ const IMPEL_CLAUDE_PLAN_USAGE_GUARD = `${"if(!wze())return;".padEnd("if(!Ze().hasOrgPolicyBackend())return;".length, " ")}if((r=fr())`;
97
+ const CLAUDE_PLAN_USAGE_REQUEST = 'P.net.fetch(`${ht()}/api/organizations/${o}/usage`,{signal:AbortSignal.timeout(mcr)})';
98
+ const IMPEL_CLAUDE_PLAN_USAGE_REQUEST = 'P.net.fetch("https://gateway.useimpel.com/ui/a/u",{headers:{key:dR(Oe()).apiKey}})'.padEnd(CLAUDE_PLAN_USAGE_REQUEST.length, " ");
99
+ const CLAUDE_DRAFT_AGENT_MENTIONS = 'agentMentionsEnabled:"draft"===Be&&s';
100
+ const IMPEL_CLAUDE_BOUND_AGENT_MENTIONS = 'agentMentionsEnabled:"draft"===Be?s:Le?.agent??!1';
92
101
  const CLAUDE_AGENT_MENTION_FILTER = 'return e.filter(e=>"user"===e.source&&(!s||e.name.toLowerCase().includes(s))).slice(0,10)';
93
102
  const IMPEL_CLAUDE_BOUND_AGENT_MENTION_FILTER = 'return e.filter(e=>"user"===e.source&&(!s||e.name.toLowerCase().includes(s))&&("string"!=typeof y||e.name===y)).slice(0,10)';
94
103
  const CLAUDE_AGENT_MENTION_SELECT = 'onSelect:t=>(e(String(t)),Promise.resolve(null))';
95
104
  const IMPEL_CLAUDE_BOUND_AGENT_MENTION_SELECT = 'onSelect:t=>"string"==typeof y?Promise.resolve({chipText:String(t)}):(e(String(t)),Promise.resolve(null))';
96
- // Claude's Code-disabled screens link Back to "/", but the root router honors
97
- // its separately persisted last Code mode and immediately returns to the same
98
- // disabled screen. Route directly to the pinned build's canonical Chat page;
99
- // its normal mode effect then reconciles both renderer and desktop preference.
100
- const CLAUDE_CODE_DISABLED_BACK_LINK = 'function Fi(){return e.jsxs("a",{href:"/",className:"inline-flex items-center text-sm text-text-400 hover:text-text-300 outline-none"';
101
- const IMPEL_CLAUDE_CODE_DISABLED_BACK_LINK = 'function Fi(){return e.jsxs("a",{href:"/chat/new",className:"inline-flex items-center text-sm text-text-400 hover:text-text-300 outline-none"';
102
- const CLAUDE_CODE_NO_SEAT_BACK_LINK = 'b.jsx(D,{variant:"ghost",href:"/",icon:"ArrowLeft",children:b.jsx(A,{defaultMessage:"Back to Claude",id:"e4M0bJ/pZc"})})';
103
- const IMPEL_CLAUDE_CODE_NO_SEAT_BACK_LINK = 'b.jsx(D,{variant:"ghost",href:"/chat/new",icon:"ArrowLeft",children:b.jsx(A,{defaultMessage:"Back to Claude",id:"e4M0bJ/pZc"})})';
104
105
  const LEGACY_CLAUDE_SAFE_STORAGE_NAME = "Claude";
105
106
  const CLAUDE_SAFE_STORAGE_METADATA = "safe-storage.json";
106
107
 
@@ -173,7 +174,7 @@ export function managedAppIdentity(target, tenantId = null, tenantName = null) {
173
174
 
174
175
  // Bump when the written config/manifest schema changes; a mismatch forces the
175
176
  // slow open path (and thus a full config rewrite) after a CLI update.
176
- export const CURRENT_CONFIG_VERSION = 16;
177
+ export const CURRENT_CONFIG_VERSION = 17;
177
178
 
178
179
  // Identifies the bundle-BUILDING logic — the asar patches, plist rewrites,
179
180
  // helper rebranding, and signing. A vendored bundle is rebuilt only when this
@@ -182,7 +183,7 @@ export const CURRENT_CONFIG_VERSION = 16;
182
183
  // — which is what made every `impel update` re-trigger macOS permission
183
184
  // prompts. Bump this ONLY when a code change alters the bytes of a built
184
185
  // bundle; leave it alone for changes that don't touch bundle contents.
185
- export const BUNDLE_BUILD_FINGERPRINT = "bundle-2026-07-20.2";
186
+ export const BUNDLE_BUILD_FINGERPRINT = "bundle-2026-07-21.1";
186
187
 
187
188
  /** Parse the tenant's install manifest, or null when absent/corrupt. */
188
189
  export function readTenantManifest(homeDir = os.homedir(), tenantId = null) {
@@ -253,8 +254,7 @@ export function bundleIsCurrent(status, {
253
254
  compatibility.patches?.followupBoundAgentEnable === 1
254
255
  && compatibility.patches?.followupBoundAgentFilter === 1
255
256
  && compatibility.patches?.followupBoundAgentChip === 1
256
- && compatibility.patches?.codeDisabledBack === 1
257
- && compatibility.patches?.codeNoSeatBack === 1
257
+ && claudePatchSetMatchesCompatibility(compatibility)
258
258
  && managedClaudeRendererMatchesCompatibility(status, compatibility)
259
259
  ))
260
260
  && (status.target !== "chatgpt" || (
@@ -288,14 +288,40 @@ function wrapperLaunchesOnTahoe(status) {
288
288
  }
289
289
  }
290
290
 
291
+ // The exact patch and renderer-asset groups this CLI build writes. A bundle
292
+ // whose compatibility metadata records a DIFFERENT set — even a superset — was
293
+ // built by different patching logic (e.g. a dev working tree that reused the
294
+ // released fingerprint), so it must be reported stale and rebuilt rather than
295
+ // trusted to behave like a reviewed release.
296
+ const CLAUDE_COMPATIBILITY_PATCH_KEYS = Object.freeze([
297
+ "aggregatePlanUsageGuard",
298
+ "aggregatePlanUsageRequest",
299
+ "safeStorageName",
300
+ "followupBoundAgentEnable",
301
+ "followupBoundAgentFilter",
302
+ "followupBoundAgentChip",
303
+ ]);
304
+ const CLAUDE_COMPATIBILITY_RENDERER_GROUPS = Object.freeze(["agentMentions"]);
305
+
306
+ function claudePatchSetMatchesCompatibility(compatibility) {
307
+ try {
308
+ const patchKeys = Object.keys(compatibility.patches ?? {}).sort();
309
+ const rendererGroups = Object.keys(compatibility.rendererAssets ?? {}).sort();
310
+ return (
311
+ patchKeys.length === CLAUDE_COMPATIBILITY_PATCH_KEYS.length
312
+ && [...CLAUDE_COMPATIBILITY_PATCH_KEYS].sort().every((key, index) => patchKeys[index] === key)
313
+ && rendererGroups.length === CLAUDE_COMPATIBILITY_RENDERER_GROUPS.length
314
+ && [...CLAUDE_COMPATIBILITY_RENDERER_GROUPS].sort().every((key, index) => rendererGroups[index] === key)
315
+ );
316
+ } catch {
317
+ return false;
318
+ }
319
+ }
320
+
291
321
  function managedClaudeRendererMatchesCompatibility(status, compatibility) {
292
322
  try {
293
- const groups = [
294
- compatibility.rendererAssets?.agentMentions,
295
- compatibility.rendererAssets?.codeDisabledBack,
296
- ];
297
- if (groups.some((renderers) => !Array.isArray(renderers) || renderers.length === 0)) return false;
298
- const renderers = groups.flat();
323
+ const renderers = compatibility.rendererAssets?.agentMentions;
324
+ if (!Array.isArray(renderers) || renderers.length === 0) return false;
299
325
  const root = path.resolve(status.launcher, "Contents", "Resources", "ion-dist");
300
326
  return renderers.every((renderer) => {
301
327
  if (!renderer?.path || !renderer.sha256 || path.isAbsolute(renderer.path)) return false;
@@ -632,27 +658,13 @@ export function installManagedAppFiles({
632
658
 
633
659
  const installed = [];
634
660
  for (const target of targets) {
635
- if (target === "claude") {
636
- migrateLegacyClaudeAppSessions(paths.claude.userData, homeDir);
637
- writeClaudeDefaultApp(paths);
638
- writeClaudeCodeSettings(paths);
639
- writeClaudeConfig(paths, config, models);
640
- ensureClaudeSessionHooks(paths.claude.userData, config.tenantId, "claude_desktop");
641
- }
642
- else {
643
- writeChatGPTConfig(
644
- paths,
645
- config,
646
- models,
647
- vendorCodexModels,
648
- chatgptInvocations,
649
- generatedAt,
650
- );
651
- ensureCodexSessionHooks(paths.chatgpt.codexHome, config.tenantId, "codex_desktop");
652
- }
653
661
  // Non-bundle targets are the background-refresh / fast-open path: configs,
654
662
  // token helper, catalog, and manifest only. Bundle swaps require the app
655
663
  // to be closed (see quitBlockingApps), so those paths never attempt one.
664
+ // A stale bundle is rebuilt BEFORE its profile config is rewritten: the
665
+ // generated config is only reviewed against the pinned vendor build, so a
666
+ // failed rebuild must leave the old bundle paired with its old config
667
+ // rather than stranding it with a contract written for a different app.
656
668
  if (bundleTargets.has(target)) {
657
669
  if (resolvedVendorPaths[target] && isVendableVendorApp(target, resolvedVendorPaths[target])) {
658
670
  assertPinnedVendorAppVersion(target, resolvedVendorPaths[target]);
@@ -670,6 +682,24 @@ export function installManagedAppFiles({
670
682
  fs.rmSync(appPaths(homeDir)[target].launcher, { recursive: true, force: true });
671
683
  }
672
684
  }
685
+ if (target === "claude") {
686
+ migrateLegacyClaudeAppSessions(paths.claude.userData, homeDir);
687
+ writeClaudeNative3PSelection(paths);
688
+ writeClaudeCodeSettings(paths);
689
+ writeClaudeConfig(paths, config, models);
690
+ ensureClaudeSessionHooks(paths.claude.userData, config.tenantId, "claude_desktop");
691
+ }
692
+ else {
693
+ writeChatGPTConfig(
694
+ paths,
695
+ config,
696
+ models,
697
+ vendorCodexModels,
698
+ chatgptInvocations,
699
+ generatedAt,
700
+ );
701
+ ensureCodexSessionHooks(paths.chatgpt.codexHome, config.tenantId, "codex_desktop");
702
+ }
673
703
  installed.push({
674
704
  target,
675
705
  launcher: paths[target].launcher,
@@ -925,16 +955,16 @@ function writeClaudeConfig(paths, config, models) {
925
955
  inferenceGatewayAuthScheme: "bearer",
926
956
  modelDiscoveryEnabled: false,
927
957
  inferenceModels: claudeModels,
928
- // 3P builds leave Chat unavailable unless the administrator opts in. The
929
- // Code install/disabled screen's "Back to Claude" link targets the Chat
930
- // root; without this key the root router selects Code again and strands
931
- // gateway users in a loop even though inference is healthy.
958
+ // These are Anthropic's native managed 3P surface controls. Cowork and
959
+ // Code default to enabled in the pinned build, but state them explicitly
960
+ // so an older generated profile cannot leave either surface disabled.
961
+ coworkTabEnabled: true,
962
+ isClaudeCodeForDesktopEnabled: true,
963
+ // Chat is the only opt-in surface in the pinned build.
932
964
  chatTabEnabled: true,
933
- // Managed flat key (maps to authentication.disableClaudeAiSignIn, vendor
934
- // 1.3834.0+): hides the Claude.ai sign-in option and keeps the app in 3P
935
- // mode even when the persisted deploymentMode is missing or "1p". Claude.ai
936
- // account login is a dead end for gateway profiles anyway — the server
937
- // rejects it with custom_3p_not_available.
965
+ // Native managed flat key (maps to authentication.disableClaudeAiSignIn):
966
+ // with an inference config present the pinned app selects 3P and hides the
967
+ // inapplicable Claude.ai sign-in option.
938
968
  disableDeploymentModeChooser: true,
939
969
  };
940
970
  writeAtomic(path.join(dir, `${CLAUDE_CONFIG_ID}.json`), JSON.stringify(body, null, 2) + "\n", 0o600);
@@ -961,7 +991,7 @@ function writeClaudeCodeSettings(paths) {
961
991
  writeAtomic(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, 0o600);
962
992
  }
963
993
 
964
- function writeClaudeDefaultApp(paths) {
994
+ function writeClaudeNative3PSelection(paths) {
965
995
  const configPath = path.join(paths.claude.userData, "claude_desktop_config.json");
966
996
  let current = {};
967
997
  if (fs.existsSync(configPath)) {
@@ -974,33 +1004,13 @@ function writeClaudeDefaultApp(paths) {
974
1004
  if (!current || typeof current !== "object" || Array.isArray(current)) {
975
1005
  throw new Error(`Claude desktop config must contain a JSON object: ${configPath}`);
976
1006
  }
977
- const preferences = current.preferences ?? {};
978
- if (!preferences || typeof preferences !== "object" || Array.isArray(preferences)) {
979
- throw new Error(`Claude desktop preferences must contain a JSON object: ${configPath}`);
980
- }
981
- // The desktop app gates its UI on a persisted top-level `deploymentMode`:
982
- // until it is "3p", every launch lands on the sign-in / provider-chooser
983
- // screen even though the applied configLibrary gateway config loads fine —
984
- // and Claude.ai email login is server-disabled for 3P profiles
985
- // (custom_3p_not_available). A managed gateway profile is only ever 3P, so
986
- // enforce the key like the sandbox policy (heal "1p"/cleared values too,
987
- // e.g. after an in-app sign-out), not seed-once like a user preference.
988
- const needsDeploymentMode = current.deploymentMode !== "3p";
989
- // Claude persists the selected top-level app as `preferences.sidebarMode`.
990
- // Seed Code for a new isolated profile, then leave subsequent user choices
991
- // alone during install, update, and background config refreshes.
992
- const needsSidebarMode = !Object.hasOwn(preferences, "sidebarMode");
993
- if (!needsDeploymentMode && !needsSidebarMode) return;
994
- const next = {
995
- ...current,
996
- ...(needsDeploymentMode ? { deploymentMode: "3p" } : {}),
997
- preferences: needsSidebarMode ? { ...preferences, sidebarMode: "code" } : preferences,
998
- };
999
- // A vendor sign-out writes `awaitingSignIn: true` alongside "1p". Left in
1000
- // place it re-arms the app's quit-time revert — which deletes deploymentMode
1001
- // again — should the gateway config ever fail to load. Drop it when healing.
1002
- if (needsDeploymentMode) delete next.awaitingSignIn;
1003
- writeAtomic(configPath, JSON.stringify(next, null, 2) + "\n", 0o600);
1007
+ // Mirror the pinned app's native "select 3P" persistence contract. This
1008
+ // marks the first-run choice complete and clears a pending 1P sign-in while
1009
+ // leaving Claude-owned preferences (including sidebarMode) untouched.
1010
+ if (current.deploymentMode === "3p" && !Object.hasOwn(current, "awaitingSignIn")) return;
1011
+ const next = { ...current, deploymentMode: "3p" };
1012
+ delete next.awaitingSignIn;
1013
+ writeAtomic(configPath, `${JSON.stringify(next, null, 2)}\n`, 0o600);
1004
1014
  }
1005
1015
 
1006
1016
  function writeChatGPTConfig(
@@ -1379,7 +1389,6 @@ function writeVendoredClaudeBundle(paths, vendorPath, gatewayUrl, safeStorageNam
1379
1389
  }
1380
1390
 
1381
1391
  const agentMentionPatches = patchClaudeFollowupAgentMentions(staging);
1382
- const codeDisabledBackPatches = patchClaudeCodeDisabledBackRouting(staging);
1383
1392
  const oldAsarHash = asarHeaderHash(asarPath);
1384
1393
  let planUsageGuardPatchCount = 0;
1385
1394
  let planUsageRequestPatchCount = 0;
@@ -1422,7 +1431,7 @@ function writeVendoredClaudeBundle(paths, vendorPath, gatewayUrl, safeStorageNam
1422
1431
  const vendorExecutable = path.join(macos, executableName);
1423
1432
  const signingIdentity = resolveVendoredAppSigningIdentity(vendorExecutable);
1424
1433
  writeAtomic(path.join(staging, "Contents", "Resources", "impel-compatibility.json"), JSON.stringify({
1425
- schemaVersion: 6,
1434
+ schemaVersion: 7,
1426
1435
  cliVersion: CLI_VERSION,
1427
1436
  bundleFingerprint: BUNDLE_BUILD_FINGERPRINT,
1428
1437
  signingMode: signingModeForIdentity(signingIdentity),
@@ -1441,12 +1450,9 @@ function writeVendoredClaudeBundle(paths, vendorPath, gatewayUrl, safeStorageNam
1441
1450
  followupBoundAgentEnable: agentMentionPatches.enable,
1442
1451
  followupBoundAgentFilter: agentMentionPatches.filter,
1443
1452
  followupBoundAgentChip: agentMentionPatches.chip,
1444
- codeDisabledBack: codeDisabledBackPatches.disabled,
1445
- codeNoSeatBack: codeDisabledBackPatches.noSeat,
1446
1453
  },
1447
1454
  rendererAssets: {
1448
1455
  agentMentions: agentMentionPatches.assets,
1449
- codeDisabledBack: codeDisabledBackPatches.assets,
1450
1456
  },
1451
1457
  asarHeaderSha256: newAsarHash,
1452
1458
  }, null, 2) + "\n", 0o644);
@@ -1612,30 +1618,6 @@ function patchClaudeFollowupAgentMentions(bundle) {
1612
1618
  };
1613
1619
  }
1614
1620
 
1615
- function patchClaudeCodeDisabledBackRouting(bundle) {
1616
- const assets = patchClaudeRendererContracts(bundle, {
1617
- supportLabel: "Code-disabled Back routing",
1618
- patchLabel: "Code-disabled Back routing",
1619
- contracts: [
1620
- {
1621
- key: "disabled",
1622
- original: CLAUDE_CODE_DISABLED_BACK_LINK,
1623
- replacement: IMPEL_CLAUDE_CODE_DISABLED_BACK_LINK,
1624
- },
1625
- {
1626
- key: "noSeat",
1627
- original: CLAUDE_CODE_NO_SEAT_BACK_LINK,
1628
- replacement: IMPEL_CLAUDE_CODE_NO_SEAT_BACK_LINK,
1629
- },
1630
- ],
1631
- });
1632
- return {
1633
- disabled: 1,
1634
- noSeat: 1,
1635
- assets,
1636
- };
1637
- }
1638
-
1639
1621
  function patchClaudeRendererContracts(bundle, {
1640
1622
  supportLabel,
1641
1623
  patchLabel,
@@ -1734,6 +1716,23 @@ function patchFixedWidthAsarString(asarPath, original, replacement, label) {
1734
1716
  function patchClaudePlanUsageRequest(asarPath) {
1735
1717
  const archive = fs.readFileSync(asarPath);
1736
1718
  const source = archive.toString("latin1");
1719
+ const legacyPatches = [];
1720
+ for (
1721
+ let offset = source.indexOf(CLAUDE_PLAN_USAGE_REQUEST);
1722
+ offset !== -1;
1723
+ offset = source.indexOf(CLAUDE_PLAN_USAGE_REQUEST, offset + CLAUDE_PLAN_USAGE_REQUEST.length)
1724
+ ) {
1725
+ legacyPatches.push({
1726
+ offset,
1727
+ original: CLAUDE_PLAN_USAGE_REQUEST,
1728
+ replacement: IMPEL_CLAUDE_PLAN_USAGE_REQUEST,
1729
+ });
1730
+ }
1731
+ if (legacyPatches.length > 0) {
1732
+ return applyFixedWidthAsarPatches(
1733
+ asarPath, archive, legacyPatches, "Claude plan-usage gateway request",
1734
+ );
1735
+ }
1737
1736
  const credentialAccessor = source.match(
1738
1737
  /function ([A-Za-z_$][\w$]*)\(e\)\{var t;return [\s\S]{0,500}?\[custom-3p\] Credentials loaded from managed config/u,
1739
1738
  )?.[1];
@@ -1749,10 +1748,10 @@ function patchClaudePlanUsageRequest(asarPath) {
1749
1748
  return applyFixedWidthAsarPatches(asarPath, archive, [], "Claude plan-usage gateway request");
1750
1749
  }
1751
1750
 
1752
- const requestPattern = /const ([A-Za-z_$][\w$]*)=`\$\{[A-Za-z_$][\w$]*\(\)\}\/api\/organizations\/\$\{[A-Za-z_$][\w$]*\}\/usage\$\{[A-Za-z_$][\w$]*\?"\?skip_spend=1":""\}`,([A-Za-z_$][\w$]*)=await ([A-Za-z_$][\w$]*)\.net\.fetch\(\1,\{signal:AbortSignal\.timeout\([A-Za-z_$][\w$]*\)\}\)/gu;
1751
+ const requestPattern = /([A-Za-z_$][\w$]*)\.net\.fetch\(`\$\{[A-Za-z_$][\w$]*\(\)\}\/api\/organizations\/\$\{[A-Za-z_$][\w$]*\}\/usage`,\{signal:AbortSignal\.timeout\([A-Za-z_$][\w$]*\)\}\)/gu;
1753
1752
  const patches = [...source.matchAll(requestPattern)].map((match) => {
1754
1753
  const original = match[0];
1755
- const replacement = `const ${match[1]}="https://gateway.useimpel.com/ui/a/u",${match[2]}=await ${match[3]}.net.fetch(${match[1]},{headers:{key:${credentialAccessor}(${configAccessor}()).apiKey}})`;
1754
+ const replacement = `${match[1]}.net.fetch("https://gateway.useimpel.com/ui/a/u",{headers:{key:${credentialAccessor}(${configAccessor}()).apiKey}})`;
1756
1755
  if (replacement.length > original.length) {
1757
1756
  throw new Error("Claude plan-usage gateway request replacement no longer fits the vendor ASAR contract");
1758
1757
  }
@@ -772,7 +772,7 @@ export async function cmdApps(argv, overrides = {}) {
772
772
  return refreshApps(targets, {
773
773
  staleOnly: Boolean(flags["stale-only"]),
774
774
  tenantId: flags.tenant || null,
775
- });
775
+ }, overrides);
776
776
  }
777
777
  if (action === "uninstall") {
778
778
  const config = loadConfig();
@@ -1163,8 +1163,20 @@ function claudeConfigIsCurrent(configPath, config, gatewayUrl) {
1163
1163
  // run (config writes only, never a bundle swap, never quits anything) and
1164
1164
  // silent on any failure: it is invoked detached from the apps' token helper,
1165
1165
  // so there is no user to talk to and nothing may leak to stdout.
1166
- async function refreshApps(targets, { staleOnly = false, tenantId = null } = {}) {
1167
- const stored = loadConfig();
1166
+ async function refreshApps(targets, { staleOnly = false, tenantId = null } = {}, overrides = {}) {
1167
+ const io = {
1168
+ loadStoredConfig: loadConfig,
1169
+ selectedConfig: selectedAppConfig,
1170
+ status: appStatus,
1171
+ bundleCurrent: bundleIsCurrent,
1172
+ fetchModels: fetchGatewayModels,
1173
+ installFiles: installManagedAppFiles,
1174
+ syncSkills: syncSkillsSafe,
1175
+ syncAgents: syncAgentProfilesSafe,
1176
+ secureCodexHome: secureManagedCodexHome,
1177
+ ...overrides,
1178
+ };
1179
+ const stored = io.loadStoredConfig();
1168
1180
  if (!stored?.pat) return;
1169
1181
  const requestedTenantId = tenantId
1170
1182
  ? normalizeTenantId(tenantId)
@@ -1181,17 +1193,25 @@ async function refreshApps(targets, { staleOnly = false, tenantId = null } = {})
1181
1193
 
1182
1194
  let config;
1183
1195
  try {
1184
- config = await selectedAppConfig(targets, tenantId ? requestedTenantId : null);
1196
+ config = await io.selectedConfig(targets, tenantId ? requestedTenantId : null);
1185
1197
  } catch {
1186
1198
  return;
1187
1199
  }
1188
- const statuses = appStatus(targets, os.homedir(), config.tenantId, config.tenantName);
1189
- const installedTargets = statuses.filter((status) => status.launcherInstalled);
1200
+ // Refresh only targets whose installed bundle was built by THIS CLI's
1201
+ // bundle logic. The generated config contract is reviewed against the
1202
+ // pinned vendor build, so rewriting configs (and advancing the manifest)
1203
+ // under a stale bundle would strand an old app with a config written for a
1204
+ // different app version. Stale bundles wait for the next foreground
1205
+ // install/open/update, which swaps bundle and config together.
1206
+ const statuses = io.status(targets, os.homedir(), config.tenantId, config.tenantName);
1207
+ const installedTargets = statuses.filter((status) => (
1208
+ status.launcherInstalled && io.bundleCurrent(status)
1209
+ ));
1190
1210
  if (installedTargets.length === 0) return;
1191
1211
 
1192
1212
  let catalog;
1193
1213
  try {
1194
- catalog = await fetchGatewayModels(config);
1214
+ catalog = await io.fetchModels(config);
1195
1215
  } catch {
1196
1216
  return;
1197
1217
  }
@@ -1204,7 +1224,7 @@ async function refreshApps(targets, { staleOnly = false, tenantId = null } = {})
1204
1224
  supportedTargetSet.has(status.target)
1205
1225
  ));
1206
1226
  if (supportedStatuses.length === 0) return;
1207
- installManagedAppFiles({
1227
+ io.installFiles({
1208
1228
  config,
1209
1229
  targets: supportedStatuses.map((status) => status.target),
1210
1230
  models: catalog.models,
@@ -1217,10 +1237,10 @@ async function refreshApps(targets, { staleOnly = false, tenantId = null } = {})
1217
1237
  const gatewayUrl = resolveSkillsGateway(config.gatewayUrl);
1218
1238
  for (const status of supportedStatuses) {
1219
1239
  const { client, env, label } = appSkillTarget(status.target, tenantPaths);
1220
- await syncSkillsSafe({ client, gatewayUrl, env, label });
1221
- if (status.target === "chatgpt") secureManagedCodexHome(tenantPaths.chatgpt.codexHome);
1240
+ await io.syncSkills({ client, gatewayUrl, env, label });
1241
+ if (status.target === "chatgpt") io.secureCodexHome(tenantPaths.chatgpt.codexHome);
1222
1242
  }
1223
- await syncAgentProfilesSafe({
1243
+ await io.syncAgents({
1224
1244
  profiles: supportedStatuses.map((status) => appAgentProfile(status.target, tenantPaths)),
1225
1245
  gatewayUrl: config.gatewayUrl,
1226
1246
  credential: config.pat,