impel-cli 0.18.15 → 0.18.17

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/src/apps.js CHANGED
@@ -37,6 +37,17 @@ const APP_DEFINITIONS = {
37
37
  executableNames: ["ChatGPT", "Codex"],
38
38
  },
39
39
  };
40
+ const BUNDLE_ARTIFACT_REMOVE_OPTIONS = Object.freeze({
41
+ recursive: true,
42
+ force: true,
43
+ maxRetries: 5,
44
+ retryDelay: 25,
45
+ });
46
+ const BUNDLE_LOCK_WAIT_MS = 120_000;
47
+ const BUNDLE_LOCK_POLL_MS = 100;
48
+ const BUNDLE_LOCK_SLEEP = new Int32Array(new SharedArrayBuffer(4));
49
+ const MACOS_O_EXLOCK = 0x00000020;
50
+ const portableBundleMutationLocks = new Set();
40
51
 
41
52
  // Vendor updates can change minified renderer contracts without notice. Each
42
53
  // CLI release therefore installs and accepts only the exact app builds tested
@@ -81,17 +92,29 @@ export const PINNED_VENDOR_APPS = Object.freeze({
81
92
  }),
82
93
  }),
83
94
  chatgpt: Object.freeze({
84
- version: "26.721.41059",
85
- codexVersion: "0.146.0-alpha.3.1",
95
+ version: "26.727.40816",
96
+ codexVersion: "0.146.0-alpha.9.2",
86
97
  bundleName: "ChatGPT.app",
98
+ windows: Object.freeze({
99
+ storeProductId: "9PLM9XGG6VKS",
100
+ packageName: "OpenAI.Codex",
101
+ // The Microsoft Store manifest identifies this exact reviewed build.
102
+ // Keep one Windows package version so the manifest contract and local
103
+ // AppX validation cannot silently drift apart again.
104
+ packageVersion: "26.727.6591.0",
105
+ codexVersion: "0.146.0-alpha.9.2",
106
+ publisherId: "2p2nqsd0c76g0",
107
+ executable: "app\\ChatGPT.exe",
108
+ updateManifestUrl: "https://persistent.oaistatic.com/codex-app-prod/windows-store-update.json",
109
+ }),
87
110
  downloads: Object.freeze({
88
111
  arm64: Object.freeze({
89
- url: "https://persistent.oaistatic.com/codex-app-prod/ChatGPT-darwin-arm64-26.721.41059.zip",
90
- sha256: "e2b45056f3d1f8ab90f7f16249bfb5a40d09d0f8099f12ca0d8d7a8fdf9108ce",
112
+ url: "https://persistent.oaistatic.com/codex-app-prod/ChatGPT-darwin-arm64-26.727.40816.zip",
113
+ sha256: "fdbede9b8a28b5bf3bbf1213fa04291724faa6ed2d1d61bb04853bbdfebce219",
91
114
  }),
92
115
  x64: Object.freeze({
93
- url: "https://persistent.oaistatic.com/codex-app-prod/ChatGPT-darwin-x64-26.721.41059.zip",
94
- sha256: "f00b09d9e1f5138f055fa972e5243991b493423c41f5d78951dc2e21a84089f4",
116
+ url: "https://persistent.oaistatic.com/codex-app-prod/ChatGPT-darwin-x64-26.727.40816.zip",
117
+ sha256: "12622d916a3993d61fb52a318dcff7af7449eea5070cdd7e094da29f380e3374",
95
118
  }),
96
119
  }),
97
120
  }),
@@ -107,6 +130,10 @@ const MAX_REASONING_LEVEL = { effort: "max", description: "Maximum reasoning dep
107
130
  const ULTRA_REASONING_LEVEL = { effort: "ultra", description: "Maximum reasoning with automatic task delegation" };
108
131
  const FAST_SERVICE_TIER = { id: "priority", name: "Fast", description: "1.5x speed, increased usage" };
109
132
  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;
133
+ const CHATGPT_THREAD_START_PROVIDER = /serviceName:([A-Za-z_$][\w$]*)\.serviceName\?\?this\.options\.defaultServiceName,threadSource:\1\.threadSource===void 0\?`user`:\1\.threadSource/gu;
134
+ const CHATGPT_THREAD_FORK_PROVIDER = /let ([A-Za-z_$][\w$]*)=\{\.\.\.([A-Za-z_$][\w$]*),threadSource:\2\.threadSource===void 0\?`user`:\2\.threadSource\}/gu;
135
+ const CHATGPT_THREAD_RESUME_PROVIDER = /modelProvider:null,cwd:([A-Za-z_$][\w$]*)\?\.cwd\?\?null,approvalPolicy:null,sandbox:null,config:\1\?\.config\?\?null,developerInstructions:\1\?\.developerInstructions\?\?void 0/gu;
136
+ const CHATGPT_STRUCTURED_PROVIDER_FALLBACK = /allowProviderModelFallback:!0/gu;
110
137
  const CHATGPT_RESOURCE_ROOT = "linked_vendor_resources_with_patched_asar";
111
138
  const CLAUDE_PLAN_USAGE_GUARD = "if(!PSe()){cz();return}if(!Ge().hasOrgPolicyBackend())return;if((r=ar())";
112
139
  const IMPEL_CLAUDE_PLAN_USAGE_GUARD = `if(!PSe()){cz();return}${"".padEnd("if(!Ge().hasOrgPolicyBackend())return;".length, " ")}if((r=ar())`;
@@ -236,7 +263,10 @@ export function managedAppIdentity(target, tenantId = null, tenantName = null) {
236
263
  // and the tenant manifest so unsupported hosts keep Chat and Code usable.
237
264
  // 25: remove the unproven Windows Cowork capability gate from beta.0 profiles
238
265
  // and restore the reviewed always-enabled managed policy.
239
- export const CURRENT_CONFIG_VERSION = 25;
266
+ // 26: remember the managed provider defaults and move legacy GPT-5.5 profiles
267
+ // onto GPT-5.6 Sol so ChatGPT's compact Work picker does not fall back to its
268
+ // unsupported-selection "Reset to default" treatment.
269
+ export const CURRENT_CONFIG_VERSION = 26;
240
270
 
241
271
  // Identifies the bundle-BUILDING logic — the asar patches, plist rewrites,
242
272
  // helper rebranding, and signing. A vendored bundle is rebuilt only when this
@@ -245,7 +275,7 @@ export const CURRENT_CONFIG_VERSION = 25;
245
275
  // — which is what made every `impel update` re-trigger macOS permission
246
276
  // prompts. Bump this ONLY when a code change alters the bytes of a built
247
277
  // bundle; leave it alone for changes that don't touch bundle contents.
248
- export const BUNDLE_BUILD_FINGERPRINT = "bundle-2026-07-25.1";
278
+ export const BUNDLE_BUILD_FINGERPRINT = "bundle-2026-08-01.1";
249
279
 
250
280
  /** Parse the tenant's install manifest, or null when absent/corrupt. */
251
281
  export function readTenantManifest(homeDir = os.homedir(), tenantId = null) {
@@ -323,7 +353,11 @@ export function bundleIsCurrent(status, {
323
353
  compatibility.vendorSignaturesPreserved === true
324
354
  && compatibility.resourceRoot === CHATGPT_RESOURCE_ROOT
325
355
  && compatibility.patches?.fastModeForGatewayAuth > 0
326
- && compatibility.patches?.tenantDisplayNamePreload === 1
356
+ && compatibility.patches?.threadStartProviderOverride === 1
357
+ && compatibility.patches?.threadForkProviderOverride === 1
358
+ && compatibility.patches?.threadResumeProviderOverride === 1
359
+ && compatibility.patches?.structuredProviderFallbackDisabled === 2
360
+ && compatibility.patches?.tenantDisplayNamePreload === 2
327
361
  )),
328
362
  );
329
363
  }
@@ -679,7 +713,7 @@ export function migrateLegacyClaudeAppSessions(userData, homeDir = os.homedir())
679
713
  return copied;
680
714
  }
681
715
 
682
- export async function fetchGatewayModels(config, fetchImpl = fetch) {
716
+ export async function fetchGatewayModels(config, fetchImpl = fetch, { allowEmpty = false } = {}) {
683
717
  const controller = new AbortController();
684
718
  const timeout = setTimeout(() => controller.abort(), 20000);
685
719
  try {
@@ -697,7 +731,9 @@ export async function fetchGatewayModels(config, fetchImpl = fetch) {
697
731
  }
698
732
  if (!Array.isArray(payload?.data)) throw new Error("response has no model data array");
699
733
  const models = payload.data.filter(isGatewayModel);
700
- if (models.length === 0) throw new Error("gateway returned no supported models");
734
+ if (models.length === 0 && !(allowEmpty && isExplicitNoSeatCatalog(payload))) {
735
+ throw new Error("gateway returned no supported models");
736
+ }
701
737
  return { models, source: "gateway", version: payload.version ?? null };
702
738
  } finally {
703
739
  clearTimeout(timeout);
@@ -733,6 +769,7 @@ export function installManagedAppFiles({
733
769
  target,
734
770
  vendorPaths[target] || findVendorApp(target, homeDir),
735
771
  ]));
772
+ const previousManifest = readTenantManifest(homeDir, config.tenantId);
736
773
  const vendorCodexModels = targets.includes("chatgpt")
737
774
  ? readVendorCodexModels(resolvedVendorPaths.chatgpt)
738
775
  : new Map();
@@ -783,6 +820,7 @@ export function installManagedAppFiles({
783
820
  vendorCodexModels,
784
821
  chatgptInvocations,
785
822
  generatedAt,
823
+ previousManifest,
786
824
  );
787
825
  if (RUNTIME_BRAND.features.sessions) ensureCodexSessionHooks(paths.chatgpt.codexHome, config.tenantId, "codex_desktop");
788
826
  }
@@ -794,7 +832,7 @@ export function installManagedAppFiles({
794
832
  }
795
833
 
796
834
  writeAtomic(path.join(paths.tenantRoot, "manifest.json"), JSON.stringify({
797
- schemaVersion: 6,
835
+ schemaVersion: 7,
798
836
  configVersion: CURRENT_CONFIG_VERSION,
799
837
  modelCatalogVersion: 3,
800
838
  gatewayUrl: config.gatewayUrl,
@@ -807,6 +845,10 @@ export function installManagedAppFiles({
807
845
  models,
808
846
  config,
809
847
  ),
848
+ modelDefaults: {
849
+ claude: models.find((model) => model.provider === "claude" && model.default)?.id ?? null,
850
+ chatgpt: models.find((model) => model.provider === "codex" && model.default)?.id ?? null,
851
+ },
810
852
  models: models.map((model) => model.id),
811
853
  updatedAt: generatedAt,
812
854
  }, null, 2) + "\n", 0o600);
@@ -960,7 +1002,6 @@ function installPinnedVendorApp(target, homeDir) {
960
1002
  homeDir, ".config", RUNTIME_BRAND.cli.configNamespace, "vendor", target, pin.version, pin.bundleName,
961
1003
  );
962
1004
  fs.mkdirSync(path.dirname(destination), { recursive: true });
963
- sweepStaleBundleArtifacts(path.dirname(destination), path.basename(destination));
964
1005
  replaceDirectory(destination, extractedApp);
965
1006
  return destination;
966
1007
  } finally {
@@ -1141,6 +1182,7 @@ function writeChatGPTConfig(
1141
1182
  vendorCodexModels,
1142
1183
  invocations = null,
1143
1184
  generatedAt = new Date().toISOString(),
1185
+ previousManifest = null,
1144
1186
  ) {
1145
1187
  const experimental = crossAppModelsEnabled(config);
1146
1188
  const orderedModels = experimental
@@ -1156,8 +1198,28 @@ function writeChatGPTConfig(
1156
1198
  const currentToml = fs.existsSync(configPath) ? fs.readFileSync(configPath, "utf8") : "";
1157
1199
  const configuredModel = readTopLevelTomlString(currentToml, "model");
1158
1200
  const defaultModelID = orderedModels.find((model) => model.provider === "codex" && model.default)?.id;
1159
- const selectedModel = codexModels.find((model) => model.slug === configuredModel)
1160
- || codexModels.find((model) => model.slug === defaultModelID)
1201
+ const defaultModel = codexModels.find((model) => model.slug === defaultModelID);
1202
+ const previousDefaultModelID = previousManifest?.modelDefaults?.chatgpt;
1203
+ const followsPreviousManagedDefault = (
1204
+ typeof previousDefaultModelID === "string"
1205
+ && configuredModel === previousDefaultModelID
1206
+ && configuredModel !== defaultModelID
1207
+ );
1208
+ // Profiles written before the default-model field existed cannot distinguish
1209
+ // an old managed default from an explicit user choice. GPT-5.5 is the one
1210
+ // observed legacy default that ChatGPT 26.727 renders as an unsupported
1211
+ // compact-picker selection; migrate it once, while preserving every other
1212
+ // explicit model choice.
1213
+ const hasLegacyCompactPickerDefault = (
1214
+ previousDefaultModelID == null
1215
+ && Number(previousManifest?.configVersion ?? 0) < CURRENT_CONFIG_VERSION
1216
+ && configuredModel === "gpt-5.5"
1217
+ && defaultModelID === "gpt-5.6-sol"
1218
+ );
1219
+ const selectedModel = (followsPreviousManagedDefault || hasLegacyCompactPickerDefault
1220
+ ? defaultModel
1221
+ : codexModels.find((model) => model.slug === configuredModel))
1222
+ || defaultModel
1161
1223
  || codexModels[0];
1162
1224
  if (!selectedModel) throw new Error("no Codex models are available for Impel ChatGPT");
1163
1225
  const configuredEffort = readTopLevelTomlString(currentToml, "model_reasoning_effort");
@@ -1517,6 +1579,12 @@ exec "$NODE" "$CLI" token${tenantArgs}
1517
1579
  }
1518
1580
 
1519
1581
  function writeVendoredClaudeBundle(paths, vendorPath, gatewayUrl, safeStorageName) {
1582
+ return withBundleMutationLock(paths.claude.launcher, () => (
1583
+ writeVendoredClaudeBundleLocked(paths, vendorPath, gatewayUrl, safeStorageName)
1584
+ ));
1585
+ }
1586
+
1587
+ function writeVendoredClaudeBundleLocked(paths, vendorPath, gatewayUrl, safeStorageName) {
1520
1588
  if (!vendorPath) throw new Error("Claude vendor app is unavailable");
1521
1589
  if (!isVendableVendorApp("claude", vendorPath)) {
1522
1590
  const bundleId = readBundleIdentifier(vendorPath);
@@ -1530,9 +1598,8 @@ function writeVendoredClaudeBundle(paths, vendorPath, gatewayUrl, safeStorageNam
1530
1598
  if (!executableName) throw new Error(`Claude executable is missing from ${vendorPath}`);
1531
1599
 
1532
1600
  const bundle = paths.claude.launcher;
1533
- const staging = `${bundle}.tmp-${process.pid}`;
1534
1601
  sweepStaleBundleArtifacts(path.dirname(bundle), path.basename(bundle));
1535
- fs.rmSync(staging, { recursive: true, force: true });
1602
+ const staging = uniqueBundleArtifactPath(bundle, "tmp");
1536
1603
  cloneAppBundle(vendorPath, staging);
1537
1604
  try {
1538
1605
  const plistPath = path.join(staging, "Contents", "Info.plist");
@@ -1614,9 +1681,9 @@ function writeVendoredClaudeBundle(paths, vendorPath, gatewayUrl, safeStorageNam
1614
1681
  }, null, 2) + "\n", 0o644);
1615
1682
 
1616
1683
  signVendoredApp(staging, vendorExecutable, "Claude", signingIdentity);
1617
- replaceDirectory(bundle, staging);
1684
+ replaceDirectoryUnlocked(bundle, staging);
1618
1685
  } catch (error) {
1619
- fs.rmSync(staging, { recursive: true, force: true });
1686
+ removeBundleArtifact(staging);
1620
1687
  throw error;
1621
1688
  }
1622
1689
  }
@@ -1630,6 +1697,12 @@ function claudeUsageCompatibilityEnabled(gatewayUrl) {
1630
1697
  }
1631
1698
 
1632
1699
  function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl, homeDir) {
1700
+ return withBundleMutationLock(paths.chatgpt.launcher, () => (
1701
+ writeVendoredChatGPTBundleLocked(paths, vendorPath, gatewayUrl, homeDir)
1702
+ ));
1703
+ }
1704
+
1705
+ function writeVendoredChatGPTBundleLocked(paths, vendorPath, gatewayUrl, homeDir) {
1633
1706
  if (!vendorPath) throw new Error("ChatGPT/Codex vendor app is unavailable");
1634
1707
  const definition = APP_DEFINITIONS.chatgpt;
1635
1708
  const executableName = definition.executableNames.find((name) => (
@@ -1638,11 +1711,10 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl, homeDir) {
1638
1711
  if (!executableName) throw new Error(`ChatGPT/Codex executable is missing from ${vendorPath}`);
1639
1712
 
1640
1713
  const bundle = paths.chatgpt.launcher;
1641
- const staging = `${bundle}.tmp-${process.pid}`;
1714
+ sweepStaleBundleArtifacts(path.dirname(bundle), path.basename(bundle));
1715
+ const staging = uniqueBundleArtifactPath(bundle, "tmp");
1642
1716
  const vendorBundleName = path.basename(vendorPath);
1643
1717
  const vendorBundle = path.join(staging, "Contents", "Resources", vendorBundleName);
1644
- sweepStaleBundleArtifacts(path.dirname(bundle), path.basename(bundle));
1645
- fs.rmSync(staging, { recursive: true, force: true });
1646
1718
  cloneAppBundle(vendorPath, vendorBundle);
1647
1719
  try {
1648
1720
  const asarPath = path.join(vendorBundle, "Contents", "Resources", "app.asar");
@@ -1665,6 +1737,8 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl, homeDir) {
1665
1737
  linkVendoredChatGPTResources(staging, vendorBundleName);
1666
1738
  const runtimeAsarPath = path.join(staging, "Contents", "Resources", "app.asar");
1667
1739
  const fastModePatchCount = patchFastModeAuthGate(runtimeAsarPath);
1740
+ const threadProviderPatches = patchChatGPTThreadRouting(runtimeAsarPath);
1741
+ const structuredFallbackPatchCount = patchChatGPTStructuredProviderFallback(runtimeAsarPath);
1668
1742
  const asarHash = asarHeaderHash(runtimeAsarPath);
1669
1743
  const vendorExecutable = path.join(vendorBundle, "Contents", "MacOS", executableName);
1670
1744
  const signingIdentity = resolveVendoredAppSigningIdentity(vendorExecutable);
@@ -1709,8 +1783,12 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl, homeDir) {
1709
1783
  browserData: paths.chatgpt.browserData,
1710
1784
  patches: {
1711
1785
  fastModeForGatewayAuth: fastModePatchCount,
1786
+ threadStartProviderOverride: threadProviderPatches.start,
1787
+ threadForkProviderOverride: threadProviderPatches.fork,
1788
+ threadResumeProviderOverride: threadProviderPatches.resume,
1789
+ structuredProviderFallbackDisabled: structuredFallbackPatchCount,
1712
1790
  desktopAPIForGatewayAuth: 0,
1713
- tenantDisplayNamePreload: 1,
1791
+ tenantDisplayNamePreload: 2,
1714
1792
  },
1715
1793
  vendorSignaturesPreserved: true,
1716
1794
  resourceRoot: CHATGPT_RESOURCE_ROOT,
@@ -1718,9 +1796,9 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl, homeDir) {
1718
1796
  }, null, 2) + "\n", 0o644);
1719
1797
 
1720
1798
  signVendoredApp(staging, vendorExecutable, "ChatGPT", signingIdentity);
1721
- replaceDirectory(bundle, staging);
1799
+ replaceDirectoryUnlocked(bundle, staging);
1722
1800
  } catch (error) {
1723
- fs.rmSync(staging, { recursive: true, force: true });
1801
+ removeBundleArtifact(staging);
1724
1802
  throw error;
1725
1803
  }
1726
1804
  }
@@ -1931,6 +2009,99 @@ function patchFastModeAuthGate(asarPath) {
1931
2009
  return applyFixedWidthAsarPatches(asarPath, archive, patches, "ChatGPT/Codex Fast-mode eligibility contract");
1932
2010
  }
1933
2011
 
2012
+ /**
2013
+ * Force every desktop app-server thread request onto the tenant's configured
2014
+ * Impel provider. The pinned renderer passes null for internal metadata turns,
2015
+ * which otherwise lets those turns escape to native OpenAI authentication.
2016
+ */
2017
+ function patchChatGPTThreadRouting(asarPath) {
2018
+ const archive = fs.readFileSync(asarPath);
2019
+ const source = archive.toString("latin1");
2020
+ const provider = JSON.stringify(RUNTIME_BRAND.cli.providerId);
2021
+ const contracts = [
2022
+ {
2023
+ name: "start",
2024
+ pattern: CHATGPT_THREAD_START_PROVIDER,
2025
+ replacement: (match) => (
2026
+ `modelProvider:${provider},serviceName:${match[1]}.serviceName??this.options.defaultServiceName,`
2027
+ + `threadSource:${match[1]}.threadSource??\`user\``
2028
+ ),
2029
+ },
2030
+ {
2031
+ name: "fork",
2032
+ pattern: CHATGPT_THREAD_FORK_PROVIDER,
2033
+ replacement: (match) => (
2034
+ `let ${match[1]}={...${match[2]},modelProvider:${provider},`
2035
+ + `threadSource:${match[2]}.threadSource??\`user\`}`
2036
+ ),
2037
+ },
2038
+ {
2039
+ name: "resume",
2040
+ pattern: CHATGPT_THREAD_RESUME_PROVIDER,
2041
+ replacement: (match) => (
2042
+ `modelProvider:${provider},cwd:${match[1]}?.cwd??null,approvalPolicy:null,sandbox:null,`
2043
+ + `config:${match[1]}?.config??null,developerInstructions:${match[1]}?.developerInstructions`
2044
+ ),
2045
+ },
2046
+ ];
2047
+ const counts = {};
2048
+ const patches = [];
2049
+ for (const contract of contracts) {
2050
+ const matches = [...source.matchAll(contract.pattern)];
2051
+ if (matches.length !== 1) {
2052
+ throw new Error(
2053
+ `ChatGPT/Codex thread-${contract.name} provider patch expected 1 match, found ${matches.length}`,
2054
+ );
2055
+ }
2056
+ const match = matches[0];
2057
+ const replacement = contract.replacement(match);
2058
+ if (replacement.length > match[0].length) {
2059
+ throw new Error(
2060
+ `ChatGPT/Codex thread-${contract.name} provider replacement no longer fits the vendor ASAR contract`,
2061
+ );
2062
+ }
2063
+ patches.push({
2064
+ offset: match.index,
2065
+ original: match[0],
2066
+ replacement: replacement.padEnd(match[0].length, " "),
2067
+ });
2068
+ counts[contract.name] = 1;
2069
+ }
2070
+ applyFixedWidthAsarPatches(
2071
+ asarPath,
2072
+ archive,
2073
+ patches,
2074
+ "ChatGPT/Codex thread-provider routing contract",
2075
+ );
2076
+ return counts;
2077
+ }
2078
+
2079
+ /**
2080
+ * Structured title, description, and classification turns opt into falling
2081
+ * back to another provider in the pinned renderer. Keep those turns fail
2082
+ * closed on Impel by changing the two reviewed boolean literals in place.
2083
+ */
2084
+ function patchChatGPTStructuredProviderFallback(asarPath) {
2085
+ const archive = fs.readFileSync(asarPath);
2086
+ const source = archive.toString("latin1");
2087
+ const matches = [...source.matchAll(CHATGPT_STRUCTURED_PROVIDER_FALLBACK)];
2088
+ if (matches.length !== 2) {
2089
+ throw new Error(
2090
+ `ChatGPT/Codex structured provider-fallback patch expected 2 matches, found ${matches.length}`,
2091
+ );
2092
+ }
2093
+ return applyFixedWidthAsarPatches(
2094
+ asarPath,
2095
+ archive,
2096
+ matches.map((match) => ({
2097
+ offset: match.index,
2098
+ original: match[0],
2099
+ replacement: "allowProviderModelFallback:!1",
2100
+ })),
2101
+ "ChatGPT/Codex structured provider-fallback contract",
2102
+ );
2103
+ }
2104
+
1934
2105
  function applyFixedWidthAsarPatches(asarPath, archive, patches, label) {
1935
2106
  if (patches.length === 0) {
1936
2107
  throw new Error(`this desktop app version has an unsupported ${label}; update impel-cli before installing it`);
@@ -2213,6 +2384,7 @@ HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
2213
2384
  VENDOR_APP="$HERE/../Resources/"${shellQuote(vendorBundleName)}
2214
2385
  export CODEX_HOME=${shellQuote(paths.chatgpt.codexHome)}
2215
2386
  export CODEX_AUTHAPI_BASE_URL=${shellQuote(codexAccountBaseUrl)}
2387
+ export CODEX_SPARKLE_ENABLED=false
2216
2388
  unset CODEX_ACCESS_TOKEN CODEX_API_KEY OPENAI_API_KEY OPENAI_BASE_URL
2217
2389
  secure_codex_home() {
2218
2390
  [ ! -L "$CODEX_HOME" ] || exit 1
@@ -2236,6 +2408,7 @@ secure_codex_home
2236
2408
  BROWSER_DATA=${shellQuote(paths.chatgpt.browserData)}
2237
2409
  mkdir -p "$BROWSER_DATA"
2238
2410
  export IMPEL_APP_DISPLAY_NAME=${shellQuote(paths.chatgpt.displayName)}
2411
+ export IMPEL_APP_BUNDLE_ID=${shellQuote(paths.chatgpt.bundleIdentifier)}
2239
2412
  PRELOAD="$HERE/../Resources/impel-chatgpt-preload.cjs"
2240
2413
  export NODE_OPTIONS="--require=\\\"$PRELOAD\\\""
2241
2414
  # Electron resolves resources from the Impel wrapper when the nested app is
@@ -2250,13 +2423,14 @@ function vendoredChatGPTDisplayNamePreload() {
2250
2423
  return `"use strict";
2251
2424
 
2252
2425
  const displayName = process.env.IMPEL_APP_DISPLAY_NAME;
2253
- if (process.type === "browser" && displayName) {
2426
+ const bundleIdentifier = process.env.IMPEL_APP_BUNDLE_ID;
2427
+ if (process.type === "browser" && displayName && bundleIdentifier) {
2254
2428
  const Module = require("node:module");
2255
2429
  const path = require("node:path");
2256
2430
  const load = Module._load;
2257
2431
  let objc;
2258
2432
  let appKit;
2259
- let launchServicesNamed = false;
2433
+ let launchServicesIdentified = false;
2260
2434
  let activationPolicyRefreshed = false;
2261
2435
 
2262
2436
  const loadNativeBridge = () => {
@@ -2268,20 +2442,31 @@ if (process.type === "browser" && displayName) {
2268
2442
  return { objc, appKit };
2269
2443
  };
2270
2444
 
2271
- const setLaunchServicesApplicationName = () => {
2272
- if (launchServicesNamed) return;
2445
+ const setLaunchServicesApplicationIdentity = () => {
2446
+ if (launchServicesIdentified) return;
2273
2447
  try {
2274
2448
  const native = loadNativeBridge();
2275
2449
  new native.objc.NobjcLibrary(
2276
2450
  "/System/Library/Frameworks/ApplicationServices.framework/Versions/A/ApplicationServices",
2277
2451
  );
2278
2452
  const title = native.appKit.NSString.stringWithUTF8String$(displayName);
2453
+ const identifier = native.appKit.NSString.stringWithUTF8String$(bundleIdentifier);
2279
2454
  const info = native.appKit.NSMutableDictionary.dictionaryWithDictionary$(
2280
2455
  native.appKit.NSBundle.mainBundle().infoDictionary(),
2281
2456
  );
2282
2457
  for (const key of ["CFBundleName", "CFBundleDisplayName"]) {
2283
2458
  info.setObject$forKey$(title, native.appKit.NSString.stringWithUTF8String$(key));
2284
2459
  }
2460
+ // The signed vendor executable runs from the nested ChatGPT.app, so
2461
+ // LaunchServices initially checks it in as com.openai.codex. When the
2462
+ // native app is also open, Dock groups both processes under ChatGPT even
2463
+ // though LSDisplayName was already tenant-branded. Check the process in
2464
+ // again with the unique outer-wrapper identity to keep each tenant's
2465
+ // Dock item distinct while preserving the vendor executable signature.
2466
+ info.setObject$forKey$(
2467
+ identifier,
2468
+ native.appKit.NSString.stringWithUTF8String$("CFBundleIdentifier"),
2469
+ );
2285
2470
  native.objc.callFunction(
2286
2471
  "_LSSetApplicationLaunchServicesServerConnectionStatus",
2287
2472
  { returns: "v", args: ["Q", "@"] },
@@ -2305,7 +2490,7 @@ if (process.type === "browser" && displayName) {
2305
2490
  title,
2306
2491
  null,
2307
2492
  );
2308
- launchServicesNamed = status === 0;
2493
+ launchServicesIdentified = status === 0;
2309
2494
  } catch {
2310
2495
  // Keep the signed vendor app usable if LaunchServices changes this
2311
2496
  // private-but-established process-title API in a future macOS release.
@@ -2340,7 +2525,7 @@ if (process.type === "browser" && displayName) {
2340
2525
  const setName = loaded.app.setName.bind(loaded.app);
2341
2526
  setName(displayName);
2342
2527
  loaded.app.setName = () => setName(displayName);
2343
- setLaunchServicesApplicationName();
2528
+ setLaunchServicesApplicationIdentity();
2344
2529
  setNativeApplicationMenuTitle();
2345
2530
  const setApplicationMenu = loaded.Menu.setApplicationMenu.bind(loaded.Menu);
2346
2531
  loaded.Menu.setApplicationMenu = (menu) => {
@@ -2351,11 +2536,11 @@ if (process.type === "browser" && displayName) {
2351
2536
  loaded.app.setActivationPolicy("accessory");
2352
2537
  setTimeout(() => {
2353
2538
  loaded.app.setActivationPolicy("regular");
2354
- launchServicesNamed = false;
2355
- setLaunchServicesApplicationName();
2539
+ launchServicesIdentified = false;
2540
+ setLaunchServicesApplicationIdentity();
2356
2541
  setApplicationMenu(menu);
2357
2542
  setNativeApplicationMenuTitle();
2358
- }, 0);
2543
+ }, 100);
2359
2544
  }
2360
2545
  setTimeout(setNativeApplicationMenuTitle, 0);
2361
2546
  return result;
@@ -2523,30 +2708,122 @@ export function sweepStaleBundleArtifacts(dir, baseName) {
2523
2708
  throw error;
2524
2709
  }
2525
2710
  const prefixes = [`${baseName}.tmp-`, `${baseName}.previous-`];
2526
- const removed = entries.filter((entry) => prefixes.some((prefix) => entry.startsWith(prefix)));
2527
- for (const entry of removed) {
2528
- fs.rmSync(path.join(dir, entry), { recursive: true, force: true });
2711
+ const removed = [];
2712
+ for (const entry of entries.filter((candidate) => prefixes.some((prefix) => candidate.startsWith(prefix)))) {
2713
+ if (removeBundleArtifact(path.join(dir, entry))) removed.push(entry);
2529
2714
  }
2530
2715
  return removed;
2531
2716
  }
2532
2717
 
2533
- function replaceDirectory(destination, staging) {
2534
- const backup = `${destination}.previous-${process.pid}`;
2535
- fs.rmSync(backup, { recursive: true, force: true });
2536
- if (fs.existsSync(destination)) fs.renameSync(destination, backup);
2718
+ function uniqueBundleArtifactPath(destination, kind) {
2719
+ return `${destination}.${kind}-${process.pid}-${crypto.randomBytes(6).toString("hex")}`;
2720
+ }
2721
+
2722
+ function removeBundleArtifact(candidate) {
2723
+ try {
2724
+ fs.rmSync(candidate, BUNDLE_ARTIFACT_REMOVE_OPTIONS);
2725
+ return !fs.existsSync(candidate);
2726
+ } catch {
2727
+ return false;
2728
+ }
2729
+ }
2730
+
2731
+ export function withBundleMutationLock(destination, run) {
2732
+ const lockPath = path.join(
2733
+ path.dirname(destination),
2734
+ `.${path.basename(destination)}.impel-install.lock`,
2735
+ );
2736
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
2737
+
2738
+ if (process.platform !== "darwin") {
2739
+ if (portableBundleMutationLocks.has(lockPath)) {
2740
+ throw new Error(`another Impel app update is already replacing ${path.basename(destination)}`);
2741
+ }
2742
+ const descriptor = fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_RDWR, 0o600);
2743
+ portableBundleMutationLocks.add(lockPath);
2744
+ try {
2745
+ return run();
2746
+ } finally {
2747
+ portableBundleMutationLocks.delete(lockPath);
2748
+ fs.closeSync(descriptor);
2749
+ }
2750
+ }
2751
+
2752
+ const deadline = Date.now() + BUNDLE_LOCK_WAIT_MS;
2753
+ let descriptor;
2754
+ while (descriptor === undefined) {
2755
+ try {
2756
+ descriptor = fs.openSync(
2757
+ lockPath,
2758
+ fs.constants.O_CREAT | fs.constants.O_RDWR | fs.constants.O_NONBLOCK | MACOS_O_EXLOCK,
2759
+ 0o600,
2760
+ );
2761
+ } catch (error) {
2762
+ if (!["EAGAIN", "EWOULDBLOCK"].includes(error?.code)) throw error;
2763
+ if (Date.now() >= deadline) {
2764
+ throw new Error(`timed out waiting for another Impel app update to replace ${path.basename(destination)}`);
2765
+ }
2766
+ Atomics.wait(BUNDLE_LOCK_SLEEP, 0, 0, BUNDLE_LOCK_POLL_MS);
2767
+ }
2768
+ }
2769
+ try {
2770
+ return run();
2771
+ } finally {
2772
+ fs.closeSync(descriptor);
2773
+ }
2774
+ }
2775
+
2776
+ function replaceDirectoryUnlocked(destination, staging, {
2777
+ removeArtifact = removeBundleArtifact,
2778
+ } = {}) {
2779
+ const backup = fs.existsSync(destination)
2780
+ ? uniqueBundleArtifactPath(destination, "previous")
2781
+ : null;
2782
+ if (backup) fs.renameSync(destination, backup);
2537
2783
  try {
2538
2784
  fs.renameSync(staging, destination);
2539
- fs.rmSync(backup, { recursive: true, force: true });
2540
2785
  } catch (error) {
2541
- if (!fs.existsSync(destination) && fs.existsSync(backup)) fs.renameSync(backup, destination);
2786
+ if (backup && !fs.existsSync(destination) && fs.existsSync(backup)) {
2787
+ fs.renameSync(backup, destination);
2788
+ }
2542
2789
  throw error;
2543
2790
  }
2791
+
2792
+ // The new bundle is committed once staging has been renamed into place.
2793
+ // Finder, LaunchServices, or a just-closed helper can briefly keep changing
2794
+ // the old tree and make recursive removal end with ENOTEMPTY. Do not turn
2795
+ // that post-commit housekeeping race into a failed tenant update: the next
2796
+ // sweep will retry any backup that remains.
2797
+ if (backup) {
2798
+ try {
2799
+ removeArtifact(backup);
2800
+ } catch {
2801
+ // Cleanup is best-effort after the committed rename.
2802
+ }
2803
+ }
2804
+ }
2805
+
2806
+ export function replaceDirectory(destination, staging, options = {}) {
2807
+ return withBundleMutationLock(destination, () => (
2808
+ replaceDirectoryUnlocked(destination, staging, options)
2809
+ ));
2544
2810
  }
2545
2811
 
2546
2812
  function isGatewayModel(model) {
2547
2813
  return model && typeof model.id === "string" && (model.provider === "claude" || model.provider === "codex");
2548
2814
  }
2549
2815
 
2816
+ function isExplicitNoSeatCatalog(payload) {
2817
+ const statuses = payload?.provider_status;
2818
+ return Boolean(statuses
2819
+ && typeof statuses === "object"
2820
+ && !Array.isArray(statuses)
2821
+ && ["claude", "codex"].every((provider) => (
2822
+ statuses[provider]?.state === "no_seat"
2823
+ && statuses[provider]?.routable === false
2824
+ )));
2825
+ }
2826
+
2550
2827
  function writeAtomic(target, contents, mode) {
2551
2828
  fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
2552
2829
  if (fs.existsSync(target) && fs.readFileSync(target, "utf8") === contents) {