impel-cli 0.7.1 → 0.7.3

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/README.md CHANGED
@@ -184,7 +184,7 @@ impel auth --pat impel_pat_xxxxxxxxxxxx --gateway https://gateway.useimpel.com -
184
184
  One command brings everything current, in dependency order: the CLI itself
185
185
  (`npm install -g impel-cli@latest` from npm, with no GitHub credentials),
186
186
  then — re-executing the freshly installed build — `impel app update all`
187
- (close running apps, update the vendor apps, rebuild the vendored Impel apps)
187
+ (close running apps, install the CLI-pinned vendor builds, rebuild the vendored Impel apps)
188
188
  and `impel skills sync all` across every managed profile.
189
189
 
190
190
  Update discovery compares the installed package version with npm's public
@@ -240,7 +240,9 @@ On macOS, desktop app state is isolated under
240
240
  `~/.config/impel/apps/tenants/<org>/`. The app bundle in `~/Applications` is a
241
241
  launcher for the currently opened tenant; `impel app open` refreshes that
242
242
  launcher, the tenant-scoped bearer, model catalog, and specialist MCP arguments
243
- before starting a new isolated instance. Windows Claude Desktop keeps each
243
+ before starting a new isolated instance. Switching tenants never deletes local
244
+ history or projects; switch back to the original tenant to reopen that tenant's
245
+ preserved profile. Windows Claude Desktop keeps each
244
246
  tenant under `%LOCALAPPDATA%\Claude-3p\Impel\<org>` and launches the unchanged
245
247
  signed vendor executable with that profile selected. Windows ChatGPT keeps its
246
248
  Codex and browser state under the same tenant-scoped Impel apps root used on
@@ -585,12 +587,14 @@ configuration:
585
587
  `~/.config/impel/apps/claude`. A narrowly version-checked compatibility patch
586
588
  connects Claude's native plan-usage meter to the authenticated aggregate
587
589
  Claude subscription pool exposed by the Impel gateway.
588
- - Impel ChatGPT is an APFS-cloned vendored copy of the official app. Its wrapper
589
- sets `CODEX_HOME`, passes a separate Chromium `--user-data-dir`, and writes its
590
- provider config and model catalog below `~/.config/impel/apps/chatgpt`. It also
591
- supplies a process-scoped, Codex-compatible wrapper around the Impel PAT so
592
- native `/status` requests can read the organization's aggregated subscription
593
- pool from the gateway. Its generated Codex config uses the same snapshot and
590
+ - Impel ChatGPT wraps an APFS-cloned official app while preserving the nested
591
+ OpenAI-signed bundle and executable. Its outer resource root contains the
592
+ narrowly patched renderer ASAR used at runtime. The wrapper sets `CODEX_HOME`,
593
+ passes a separate Chromium `--user-data-dir`, and writes its provider config
594
+ and model catalog below `~/.config/impel/apps/chatgpt`. It also supplies a
595
+ process-scoped, Codex-compatible wrapper around the Impel PAT so native
596
+ `/status` requests can read the organization's aggregated subscription pool
597
+ from the gateway. Its generated Codex config uses the same snapshot and
594
598
  subprocess-environment hardening as `impel codex`; install, update, and every
595
599
  direct app launch also remove older snapshots for the selected profile, while
596
600
  app install/update/open sweeps inactive tenant and legacy Impel app profiles.
@@ -598,22 +602,25 @@ configuration:
598
602
  normal vendor app's browser/profile data.
599
603
 
600
604
  The official Claude and ChatGPT apps remain separate installations and keep
601
- their normal accounts, updater, profiles, and bundle identities. The installer
602
- uses an existing official app when found. If one is missing and
603
- Homebrew is available, it installs the corresponding cask into
604
- `~/Applications`. `impel app update` upgrades Homebrew-managed vendor apps,
605
- rebuilds the isolated apps, reapplies version-checked compatibility patches,
606
- rewrites versioned configs, and fetches the PAT-scoped model catalog from
607
- `<gateway>/v1/models`. If a vendor update changes a required renderer contract,
608
- the update fails without replacing the working Impel copy. If the model endpoint
609
- is temporarily unavailable, the installer retains functionality using the CLI's
610
- bundled catalog.
605
+ their normal accounts, updater, profiles, and bundle identities. Each CLI
606
+ release pins exact, tested vendor builds (v0.7.2 pins Claude `1.20186.9` and
607
+ ChatGPT `26.707.72221`) and their archive SHA-256 digests. The installer reuses
608
+ an existing official app only when its version exactly matches; otherwise it
609
+ downloads the pinned signed archive into Impel's managed vendor cache under
610
+ `~/.config/impel/vendor`, verifies its digest, bundle version, layout, and code
611
+ signature, and only then rebuilds the isolated app. It never clones a moving
612
+ Homebrew or vendor “latest” release, and it never downgrades or replaces the
613
+ user's normal vendor app. A future app version requires a new tested CLI release
614
+ and pin update. Config refreshes do not download or replace apps. If the model
615
+ endpoint is temporarily unavailable, the installer retains functionality using
616
+ the CLI's bundled catalog.
611
617
 
612
618
  For ChatGPT/Codex, the gateway controls which models and capabilities are
613
619
  enabled while the installer merges those entries with the vendor app's native
614
620
  catalog. The Impel copy relaxes the vendor renderer's ChatGPT-account-only Fast
615
- gate because gateway auth supplies the entitlement instead. The copy is then
616
- ad-hoc signed locally. This preserves model-specific instructions and exposes
621
+ gate because gateway auth supplies the entitlement instead. The outer wrapper
622
+ is then ad-hoc signed locally without replacing the nested OpenAI signing
623
+ identity. This preserves model-specific instructions and exposes
617
624
  the same reasoning-effort and service-tier controls as the normal app, including
618
625
  Ultra where supported and Fast only on models that advertise the `priority`
619
626
  tier. Inference uses an explicit non-WebSocket Impel model provider so Codex
@@ -622,8 +629,10 @@ Impel PAT. Config refreshes replace only the Impel-managed gateway block; plugin
622
629
  desktop preferences, projects, and other settings written by the isolated app
623
630
  are kept.
624
631
 
625
- Both vendored copies are ad-hoc signed locally after their bundle identities and
626
- launch wrappers are installed. The Claude 3P config necessarily contains the PAT because that app accepts a
632
+ Impel Claude and the outer Impel ChatGPT wrapper are ad-hoc signed locally after
633
+ their bundle identities and launch wrappers are installed. The nested ChatGPT
634
+ bundle retains OpenAI's signature. The Claude 3P config necessarily contains
635
+ the PAT because that app accepts a
627
636
  gateway API key rather than a token-helper command. It is stored in the same
628
637
  owner-only Impel config tree as the primary credential. ChatGPT/Codex uses a
629
638
  command-backed helper and does not copy the PAT into TOML. The installable app
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
4
4
  "description": "Configure Claude Code and Codex CLI to talk to Impel's gateway, authenticated by an Impel Personal Access Token",
5
5
  "type": "module",
6
6
  "bin": {
package/src/apps.js CHANGED
@@ -21,18 +21,50 @@ const CLI_VERSION = JSON.parse(
21
21
  const APP_DEFINITIONS = {
22
22
  claude: {
23
23
  label: "Impel Claude",
24
- cask: "claude",
25
24
  names: ["Claude.app"],
26
25
  executableNames: ["Claude"],
27
26
  },
28
27
  chatgpt: {
29
28
  label: "Impel ChatGPT",
30
- cask: "codex-app",
31
29
  names: ["ChatGPT.app", "Codex.app"],
32
30
  executableNames: ["ChatGPT", "Codex"],
33
31
  },
34
32
  };
35
33
 
34
+ // Vendor updates can change minified renderer contracts without notice. Each
35
+ // CLI release therefore installs and accepts only the exact app builds tested
36
+ // with its compatibility patches. Updating a pin requires a new CLI release.
37
+ export const PINNED_VENDOR_APPS = Object.freeze({
38
+ claude: Object.freeze({
39
+ version: "1.20186.9",
40
+ bundleName: "Claude.app",
41
+ downloads: Object.freeze({
42
+ arm64: Object.freeze({
43
+ url: "https://downloads.claude.ai/releases/darwin/universal/1.20186.9/Claude-69f150a4c9316d5c8cd7b9f130ed583d15c0383e.zip",
44
+ sha256: "9364648c74bd525aa293a28103eb37262a5553de21c7ca907766d7fc40623015",
45
+ }),
46
+ x64: Object.freeze({
47
+ url: "https://downloads.claude.ai/releases/darwin/universal/1.20186.9/Claude-69f150a4c9316d5c8cd7b9f130ed583d15c0383e.zip",
48
+ sha256: "9364648c74bd525aa293a28103eb37262a5553de21c7ca907766d7fc40623015",
49
+ }),
50
+ }),
51
+ }),
52
+ chatgpt: Object.freeze({
53
+ version: "26.707.72221",
54
+ bundleName: "ChatGPT.app",
55
+ downloads: Object.freeze({
56
+ arm64: Object.freeze({
57
+ url: "https://persistent.oaistatic.com/codex-app-prod/ChatGPT-darwin-arm64-26.707.72221.zip",
58
+ sha256: "d47a2c0dfb07afec21c415fcec672035a2e90410ecb7c6dc60141518fba524e0",
59
+ }),
60
+ x64: Object.freeze({
61
+ url: "https://persistent.oaistatic.com/codex-app-prod/ChatGPT-darwin-x64-26.707.72221.zip",
62
+ sha256: "cadfdc8e1efa809b021bcf4466ae8c115ffdd280f1a467b518eca8ce8f6717fe",
63
+ }),
64
+ }),
65
+ }),
66
+ });
67
+
36
68
  const STANDARD_REASONING_LEVELS = [
37
69
  { effort: "low", description: "Fast responses with lighter reasoning" },
38
70
  { effort: "medium", description: "Balances speed and reasoning depth for everyday tasks" },
@@ -42,6 +74,8 @@ const STANDARD_REASONING_LEVELS = [
42
74
  const MAX_REASONING_LEVEL = { effort: "max", description: "Maximum reasoning depth for the hardest problems" };
43
75
  const ULTRA_REASONING_LEVEL = { effort: "ultra", description: "Maximum reasoning with automatic task delegation" };
44
76
  const FAST_SERVICE_TIER = { id: "priority", name: "Fast", description: "1.5x speed, increased usage" };
77
+ 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;
78
+ const CHATGPT_RESOURCE_ROOT = "linked_vendor_resources_with_patched_asar";
45
79
  const CLAUDE_PLAN_USAGE_GUARD = "if(!Ze().hasOrgPolicyBackend())return;if((r=fr())";
46
80
  const IMPEL_CLAUDE_PLAN_USAGE_GUARD = `${"if(!wze())return;".padEnd("if(!Ze().hasOrgPolicyBackend())return;".length, " ")}if((r=fr())`;
47
81
  const CLAUDE_PLAN_USAGE_REQUEST = 'P.net.fetch(`${ht()}/api/organizations/${o}/usage`,{signal:AbortSignal.timeout(mcr)})';
@@ -110,16 +144,42 @@ export function bundleIsCurrent(status) {
110
144
  compatibility
111
145
  && compatibility.cliVersion === CLI_VERSION
112
146
  && compatibility.vendorVersion === status.vendorVersion
147
+ && compatibility.profileRoot === status.profileRoot
148
+ && (status.target !== "chatgpt" || (
149
+ compatibility.codexHome === status.codexHome
150
+ && compatibility.browserData === status.browserData
151
+ ))
152
+ && managedBundleMatchesCompatibility(status, compatibility)
113
153
  && (status.target !== "chatgpt" || (
114
154
  compatibility.vendorSignaturesPreserved === true
115
- && compatibility.resourceRoot === "linked_vendor_resources"
155
+ && compatibility.resourceRoot === CHATGPT_RESOURCE_ROOT
156
+ && compatibility.patches?.fastModeForGatewayAuth > 0
116
157
  )),
117
158
  );
118
159
  }
119
160
 
161
+ function managedBundleMatchesCompatibility(status, compatibility) {
162
+ try {
163
+ const managedVendorBundle = status.target === "claude"
164
+ ? status.launcher
165
+ : APP_DEFINITIONS.chatgpt.names
166
+ .map((name) => path.join(status.launcher, "Contents", "Resources", name))
167
+ .find((candidate) => fs.existsSync(candidate));
168
+ const runtimeAsar = path.join(status.launcher, "Contents", "Resources", "app.asar");
169
+ return Boolean(
170
+ managedVendorBundle
171
+ && bundleVersion(managedVendorBundle) === compatibility.vendorVersion
172
+ && compatibility.asarHeaderSha256
173
+ && asarHeaderHash(runtimeAsar) === compatibility.asarHeaderSha256
174
+ );
175
+ } catch {
176
+ return false;
177
+ }
178
+ }
179
+
120
180
  // Install/update must not run while the vendor or Impel-managed apps are open:
121
- // `brew` refuses to upgrade a running cask, and cloning/replacing a bundle
122
- // under a running instance produces broken copies and codesign failures.
181
+ // Cloning/replacing a bundle under a running instance produces broken copies
182
+ // and codesign failures.
123
183
 
124
184
  /** osascript application names whose bundles install/update will touch. */
125
185
  export function blockingAppNames(targets) {
@@ -269,6 +329,9 @@ export function installManagedAppFiles({
269
329
  // token helper, catalog, and manifest only. Bundle swaps require the app
270
330
  // to be closed (see quitBlockingApps), so those paths never attempt one.
271
331
  if (bundleTargets.has(target)) {
332
+ if (resolvedVendorPaths[target] && isVendableVendorApp(target, resolvedVendorPaths[target])) {
333
+ assertPinnedVendorAppVersion(target, resolvedVendorPaths[target]);
334
+ }
272
335
  if (target === "chatgpt") writeVendoredChatGPTBundle(paths, resolvedVendorPaths.chatgpt, config.gatewayUrl);
273
336
  else writeVendoredClaudeBundle(paths, resolvedVendorPaths.claude, config.gatewayUrl);
274
337
  }
@@ -289,8 +352,11 @@ export function installManagedAppFiles({
289
352
  return installed;
290
353
  }
291
354
 
292
- function vendorSearchRoots(homeDir) {
293
- return ["/Applications", path.join(homeDir, "Applications")];
355
+ function vendorSearchRoots(homeDir, target) {
356
+ const roots = ["/Applications", path.join(homeDir, "Applications")];
357
+ const pin = PINNED_VENDOR_APPS[target];
358
+ if (pin) roots.push(path.join(homeDir, ".config", "impel", "vendor", target, pin.version));
359
+ return roots;
294
360
  }
295
361
 
296
362
  export function readBundleIdentifier(appPath) {
@@ -320,19 +386,26 @@ export function isVendableVendorApp(target, appPath) {
320
386
  );
321
387
  }
322
388
 
323
- export function findVendorApp(target, homeDir = os.homedir(), { roots = vendorSearchRoots(homeDir) } = {}) {
389
+ export function findVendorApp(target, homeDir = os.homedir(), {
390
+ roots = vendorSearchRoots(homeDir, target),
391
+ version = PINNED_VENDOR_APPS[target]?.version,
392
+ } = {}) {
324
393
  const definition = APP_DEFINITIONS[target];
325
394
  for (const base of roots) {
326
395
  for (const name of definition.names) {
327
396
  const candidate = path.join(base, name);
328
- if (fs.existsSync(candidate) && isVendableVendorApp(target, candidate)) return candidate;
397
+ if (
398
+ fs.existsSync(candidate)
399
+ && isVendableVendorApp(target, candidate)
400
+ && (!version || bundleVersion(candidate) === version)
401
+ ) return candidate;
329
402
  }
330
403
  }
331
404
  return null;
332
405
  }
333
406
 
334
407
  /** Name-matched bundles that are NOT vendable — surfaced so "why was my app ignored?" is answerable. */
335
- export function rejectedVendorCandidates(target, homeDir = os.homedir(), { roots = vendorSearchRoots(homeDir) } = {}) {
408
+ export function rejectedVendorCandidates(target, homeDir = os.homedir(), { roots = vendorSearchRoots(homeDir, target) } = {}) {
336
409
  const definition = APP_DEFINITIONS[target];
337
410
  const rejected = [];
338
411
  for (const base of roots) {
@@ -346,33 +419,103 @@ export function rejectedVendorCandidates(target, homeDir = os.homedir(), { roots
346
419
  return rejected;
347
420
  }
348
421
 
349
- export function ensureVendorApp(target, { homeDir = os.homedir(), update = false } = {}) {
422
+ function unpinnedVendorCandidates(target, homeDir = os.homedir(), { roots = vendorSearchRoots(homeDir, target) } = {}) {
350
423
  const definition = APP_DEFINITIONS[target];
351
- const current = findVendorApp(target, homeDir);
352
- const brew = findCommand("brew");
353
- if (!brew) return { path: current, action: current ? "existing" : "missing", note: "Homebrew is not installed" };
354
-
355
- if (current && !update) return { path: current, action: "existing" };
356
- if (current && update) {
357
- const listed = spawnSync(brew, ["list", "--cask", definition.cask], { stdio: "ignore" });
358
- if (listed.status !== 0) {
359
- return { path: current, action: "existing", note: "vendor app is not Homebrew-managed; its built-in updater remains active" };
424
+ const expectedVersion = PINNED_VENDOR_APPS[target].version;
425
+ const candidates = [];
426
+ for (const base of roots) {
427
+ for (const name of definition.names) {
428
+ const candidate = path.join(base, name);
429
+ if (fs.existsSync(candidate) && isVendableVendorApp(target, candidate)) {
430
+ const version = bundleVersion(candidate);
431
+ if (version !== expectedVersion) candidates.push({ path: candidate, version });
432
+ }
360
433
  }
361
- const upgraded = spawnSync(brew, ["upgrade", "--cask", definition.cask], { stdio: "inherit" });
362
- return { path: findVendorApp(target, homeDir) || current, action: upgraded.status === 0 ? "updated" : "update-failed" };
363
434
  }
435
+ return candidates;
436
+ }
437
+
438
+ export function assertPinnedVendorAppVersion(target, appPath) {
439
+ const expected = PINNED_VENDOR_APPS[target]?.version;
440
+ const actual = bundleVersion(appPath);
441
+ if (!expected || actual !== expected) {
442
+ throw new Error(
443
+ `${APP_DEFINITIONS[target]?.label || target} vendor version ${actual || "unknown"} is unsupported; impel-cli v${CLI_VERSION} requires the verified ${expected || "pinned"} build`,
444
+ );
445
+ }
446
+ return actual;
447
+ }
448
+
449
+ function installPinnedVendorApp(target, homeDir) {
450
+ const pin = PINNED_VENDOR_APPS[target];
451
+ const download = pin?.downloads?.[process.arch];
452
+ if (!pin || !download) throw new Error(`${target}: no verified vendor download is available for ${process.arch}`);
453
+
454
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), `impel-${target}-vendor-`));
455
+ const archive = path.join(tempRoot, `${target}.zip`);
456
+ const extractedRoot = path.join(tempRoot, "extracted");
457
+ try {
458
+ console.log(`${target}: downloading verified vendor app ${pin.version}…`);
459
+ const fetched = spawnSync(
460
+ "/usr/bin/curl",
461
+ ["--fail", "--location", "--progress-bar", "--retry", "2", "--output", archive, download.url],
462
+ { stdio: ["ignore", "inherit", "inherit"] },
463
+ );
464
+ if (fetched.status !== 0 || fetched.error) {
465
+ throw new Error(`${target}: failed to download verified vendor app ${pin.version}`);
466
+ }
467
+ const actualHash = sha256(fs.readFileSync(archive));
468
+ if (actualHash !== download.sha256) {
469
+ throw new Error(`${target}: vendor archive integrity check failed for ${pin.version}`);
470
+ }
471
+
472
+ fs.mkdirSync(extractedRoot, { recursive: true });
473
+ const extracted = spawnSync("/usr/bin/ditto", ["-x", "-k", archive, extractedRoot], { encoding: "utf8" });
474
+ if (extracted.status !== 0 || extracted.error) {
475
+ throw new Error(`${target}: failed to extract verified vendor app ${pin.version}`);
476
+ }
477
+ const extractedApp = path.join(extractedRoot, pin.bundleName);
478
+ if (!isVendableVendorApp(target, extractedApp)) {
479
+ throw new Error(`${target}: verified vendor archive ${pin.version} has an unsupported app layout`);
480
+ }
481
+ assertPinnedVendorAppVersion(target, extractedApp);
482
+ const verified = spawnSync("/usr/bin/codesign", ["--verify", "--deep", "--strict", extractedApp], { encoding: "utf8" });
483
+ if (verified.status !== 0 || verified.error) {
484
+ throw new Error(`${target}: vendor signature verification failed for ${pin.version}`);
485
+ }
486
+
487
+ const destination = path.join(
488
+ homeDir, ".config", "impel", "vendor", target, pin.version, pin.bundleName,
489
+ );
490
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
491
+ replaceDirectory(destination, extractedApp);
492
+ return destination;
493
+ } finally {
494
+ fs.rmSync(tempRoot, { recursive: true, force: true });
495
+ }
496
+ }
497
+
498
+ export function ensureVendorApp(target, { homeDir = os.homedir() } = {}) {
499
+ const definition = APP_DEFINITIONS[target];
500
+ const pin = PINNED_VENDOR_APPS[target];
501
+ const current = findVendorApp(target, homeDir);
502
+ if (current) return { path: current, action: "existing", note: `verified ${pin.version}` };
364
503
 
365
504
  // Name-collision transparency: e.g. OpenAI's native ChatGPT chat app
366
- // (com.openai.chat) at /Applications/ChatGPT.app is NOT the Codex desktop
367
- // app and cannot be vendored — say so instead of silently installing.
505
+ // (com.openai.chat) is NOT the Electron app and cannot be vendored.
368
506
  for (const candidate of rejectedVendorCandidates(target, homeDir)) {
369
507
  console.log(
370
- `${target}: ignoring ${candidate.path} (${candidate.bundleId || "unknown bundle id"}) — not a vendorable ${definition.label.replace("Impel ", "")} desktop app; installing the \`${definition.cask}\` cask instead`,
508
+ `${target}: ignoring ${candidate.path} (${candidate.bundleId || "unknown bundle id"}) — not a vendorable ${definition.label.replace("Impel ", "")} desktop app`,
509
+ );
510
+ }
511
+ for (const candidate of unpinnedVendorCandidates(target, homeDir)) {
512
+ console.log(
513
+ `${target}: ignoring ${candidate.path} (${candidate.version || "unknown version"}) — this CLI is pinned to verified version ${pin.version}`,
371
514
  );
372
515
  }
373
- fs.mkdirSync(path.join(homeDir, "Applications"), { recursive: true });
374
- const installed = spawnSync(brew, ["install", "--cask", `--appdir=${path.join(homeDir, "Applications")}`, definition.cask], { stdio: "inherit" });
375
- return { path: findVendorApp(target, homeDir), action: installed.status === 0 ? "installed" : "install-failed" };
516
+
517
+ const installed = installPinnedVendorApp(target, homeDir);
518
+ return { path: installed, action: "installed", note: `verified ${pin.version}` };
376
519
  }
377
520
 
378
521
  export function appStatus(targets, homeDir = os.homedir(), tenantId = null) {
@@ -386,6 +529,9 @@ export function appStatus(targets, homeDir = os.homedir(), tenantId = null) {
386
529
  launcherInstalled: fs.existsSync(paths[target].launcher),
387
530
  vendorPath,
388
531
  vendorVersion: vendorPath ? bundleVersion(vendorPath) : null,
532
+ profileRoot: target === "claude" ? paths.claude.userData : paths.chatgpt.root,
533
+ codexHome: target === "chatgpt" ? paths.chatgpt.codexHome : null,
534
+ browserData: target === "chatgpt" ? paths.chatgpt.browserData : null,
389
535
  configPath: target === "claude"
390
536
  ? path.join(paths.claude.userData, "configLibrary", `${CLAUDE_CONFIG_ID}.json`)
391
537
  : path.join(paths.chatgpt.codexHome, "config.toml"),
@@ -749,12 +895,7 @@ function writeVendoredClaudeBundle(paths, vendorPath, gatewayUrl) {
749
895
  IMPEL_CLAUDE_PLAN_USAGE_GUARD,
750
896
  "Claude plan-usage eligibility guard",
751
897
  );
752
- planUsageRequestPatchCount = patchFixedWidthAsarString(
753
- asarPath,
754
- CLAUDE_PLAN_USAGE_REQUEST,
755
- IMPEL_CLAUDE_PLAN_USAGE_REQUEST,
756
- "Claude plan-usage gateway request",
757
- );
898
+ planUsageRequestPatchCount = patchClaudePlanUsageRequest(asarPath);
758
899
  }
759
900
  const newAsarHash = asarHeaderHash(asarPath);
760
901
 
@@ -825,22 +966,27 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl) {
825
966
  if (!fs.existsSync(asarPath) || !fs.existsSync(plistPath)) {
826
967
  const bundleId = readBundleIdentifier(vendorPath);
827
968
  const hint = bundleId === "com.openai.chat"
828
- ? " — this is OpenAI's ChatGPT chat app, not the Codex desktop app; install the desktop app with `brew install --cask codex-app` and re-run"
969
+ ? " — this is OpenAI's non-Electron ChatGPT app, not the supported desktop build; re-run `impel app install chatgpt`"
829
970
  : bundleId
830
971
  ? ` (bundle id ${bundleId})`
831
972
  : "";
832
973
  throw new Error(`unsupported ChatGPT/Codex bundle layout in ${vendorPath}${hint}`);
833
974
  }
834
975
 
835
- const asarHash = asarHeaderHash(asarPath);
836
-
837
976
  const macos = path.join(staging, "Contents", "MacOS");
838
- writeAtomic(path.join(staging, "Contents", "Info.plist"), vendoredChatGPTWrapperPlist(vendorPath), 0o644);
839
977
  const vendorIcon = path.join(vendorBundle, "Contents", "Resources", "electron.icns");
840
978
  if (fs.existsSync(vendorIcon)) {
841
979
  fs.copyFileSync(vendorIcon, path.join(staging, "Contents", "Resources", "electron.icns"));
842
980
  }
843
981
  linkVendoredChatGPTResources(staging, vendorBundleName);
982
+ const runtimeAsarPath = path.join(staging, "Contents", "Resources", "app.asar");
983
+ const fastModePatchCount = patchFastModeAuthGate(runtimeAsarPath);
984
+ const asarHash = asarHeaderHash(runtimeAsarPath);
985
+ writeAtomic(
986
+ path.join(staging, "Contents", "Info.plist"),
987
+ vendoredChatGPTWrapperPlist(vendorPath, asarHash),
988
+ 0o644,
989
+ );
844
990
  writeAtomic(
845
991
  path.join(macos, "launch"),
846
992
  vendoredChatGPTLauncher(paths, vendorBundleName, executableName, gatewayUrl),
@@ -851,9 +997,12 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl) {
851
997
  cliVersion: CLI_VERSION,
852
998
  vendorVersion: bundleVersion(vendorPath),
853
999
  sourceBundle: vendorPath,
854
- patches: { fastModeForGatewayAuth: 0, desktopAPIForGatewayAuth: 0 },
1000
+ profileRoot: paths.chatgpt.root,
1001
+ codexHome: paths.chatgpt.codexHome,
1002
+ browserData: paths.chatgpt.browserData,
1003
+ patches: { fastModeForGatewayAuth: fastModePatchCount, desktopAPIForGatewayAuth: 0 },
855
1004
  vendorSignaturesPreserved: true,
856
- resourceRoot: "linked_vendor_resources",
1005
+ resourceRoot: CHATGPT_RESOURCE_ROOT,
857
1006
  asarHeaderSha256: asarHash,
858
1007
  }, null, 2) + "\n", 0o644);
859
1008
 
@@ -870,11 +1019,17 @@ function linkVendoredChatGPTResources(bundle, vendorBundleName) {
870
1019
  const vendorResources = path.join(outerResources, vendorBundleName, "Contents", "Resources");
871
1020
  for (const entry of fs.readdirSync(vendorResources, { withFileTypes: true })) {
872
1021
  // The wrapper owns its icon and compatibility record. Every other vendor
873
- // resource is exposed at the outer Electron resource root through a
874
- // relative link, leaving the OpenAI-signed nested bundle untouched.
1022
+ // resource is exposed at the outer Electron resource root, leaving the
1023
+ // OpenAI-signed nested bundle untouched. app.asar is the sole copied file
1024
+ // because the runtime needs the narrowly scoped compatibility patch below.
875
1025
  if (entry.name === "electron.icns") continue;
876
1026
  const target = path.join(vendorBundleName, "Contents", "Resources", entry.name);
877
- fs.symlinkSync(target, path.join(outerResources, entry.name));
1027
+ const destination = path.join(outerResources, entry.name);
1028
+ // Electron resolves app.asar from the outer wrapper's resource root. Keep
1029
+ // the nested OpenAI bundle byte-for-byte signed, but use a patched copy at
1030
+ // runtime so gateway-authenticated profiles retain the native Fast control.
1031
+ if (entry.name === "app.asar") fs.copyFileSync(path.join(vendorResources, entry.name), destination);
1032
+ else fs.symlinkSync(target, destination);
878
1033
  }
879
1034
  }
880
1035
 
@@ -883,17 +1038,88 @@ function patchFixedWidthAsarString(asarPath, original, replacement, label) {
883
1038
  throw new Error(`${label} replacement must preserve the vendor ASAR byte length`);
884
1039
  }
885
1040
  const archive = fs.readFileSync(asarPath);
886
- const metadata = readAsarMetadata(archive);
887
1041
  const source = archive.toString("latin1");
888
- const patchOffsets = [];
1042
+ const patches = [];
889
1043
  for (let offset = source.indexOf(original); offset !== -1; offset = source.indexOf(original, offset + original.length)) {
890
- patchOffsets.push(offset);
1044
+ patches.push({ offset, original, replacement });
1045
+ }
1046
+ return applyFixedWidthAsarPatches(asarPath, archive, patches, label);
1047
+ }
1048
+
1049
+ function patchClaudePlanUsageRequest(asarPath) {
1050
+ const archive = fs.readFileSync(asarPath);
1051
+ const source = archive.toString("latin1");
1052
+ const legacyPatches = [];
1053
+ for (
1054
+ let offset = source.indexOf(CLAUDE_PLAN_USAGE_REQUEST);
1055
+ offset !== -1;
1056
+ offset = source.indexOf(CLAUDE_PLAN_USAGE_REQUEST, offset + CLAUDE_PLAN_USAGE_REQUEST.length)
1057
+ ) {
1058
+ legacyPatches.push({
1059
+ offset,
1060
+ original: CLAUDE_PLAN_USAGE_REQUEST,
1061
+ replacement: IMPEL_CLAUDE_PLAN_USAGE_REQUEST,
1062
+ });
1063
+ }
1064
+ if (legacyPatches.length > 0) {
1065
+ return applyFixedWidthAsarPatches(
1066
+ asarPath, archive, legacyPatches, "Claude plan-usage gateway request",
1067
+ );
1068
+ }
1069
+ const credentialAccessor = source.match(
1070
+ /function ([A-Za-z_$][\w$]*)\(e\)\{var t;return [\s\S]{0,500}?\[custom-3p\] Credentials loaded from managed config/u,
1071
+ )?.[1];
1072
+ if (!credentialAccessor) {
1073
+ return applyFixedWidthAsarPatches(asarPath, archive, [], "Claude plan-usage gateway request");
891
1074
  }
892
- if (patchOffsets.length === 0) {
1075
+ const configAccessorPattern = new RegExp(
1076
+ `${escapeRegex(credentialAccessor)}\\(([A-Za-z_$][\\w$]*)\\(\\)\\)`,
1077
+ "u",
1078
+ );
1079
+ const configAccessor = source.match(configAccessorPattern)?.[1];
1080
+ if (!configAccessor) {
1081
+ return applyFixedWidthAsarPatches(asarPath, archive, [], "Claude plan-usage gateway request");
1082
+ }
1083
+
1084
+ 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;
1085
+ const patches = [...source.matchAll(requestPattern)].map((match) => {
1086
+ const original = match[0];
1087
+ const replacement = `${match[1]}.net.fetch("https://gateway.useimpel.com/ui/a/u",{headers:{key:${credentialAccessor}(${configAccessor}()).apiKey}})`;
1088
+ if (replacement.length > original.length) {
1089
+ throw new Error("Claude plan-usage gateway request replacement no longer fits the vendor ASAR contract");
1090
+ }
1091
+ return { offset: match.index, original, replacement: replacement.padEnd(original.length, " ") };
1092
+ });
1093
+ return applyFixedWidthAsarPatches(asarPath, archive, patches, "Claude plan-usage gateway request");
1094
+ }
1095
+
1096
+ function patchFastModeAuthGate(asarPath) {
1097
+ const archive = fs.readFileSync(asarPath);
1098
+ const source = archive.toString("latin1");
1099
+ const patches = [...source.matchAll(FAST_MODE_AUTH_GATE)].map((match) => {
1100
+ const replacement = `${match[1]}=!${match[3]}`;
1101
+ return {
1102
+ offset: match.index,
1103
+ original: match[0],
1104
+ replacement: replacement.padEnd(match[0].length, " "),
1105
+ };
1106
+ });
1107
+ return applyFixedWidthAsarPatches(asarPath, archive, patches, "ChatGPT/Codex Fast-mode eligibility contract");
1108
+ }
1109
+
1110
+ function applyFixedWidthAsarPatches(asarPath, archive, patches, label) {
1111
+ if (patches.length === 0) {
893
1112
  throw new Error(`this desktop app version has an unsupported ${label}; update impel-cli before installing it`);
894
1113
  }
895
- const patchedArchive = Buffer.from(source.replaceAll(original, replacement), "latin1");
896
- const entries = new Set(patchOffsets.map((offset) => findAsarEntry(metadata.header, metadata.dataOffset, offset)));
1114
+ const metadata = readAsarMetadata(archive);
1115
+ const patchedArchive = Buffer.from(archive);
1116
+ for (const { offset, original, replacement } of patches) {
1117
+ if (Buffer.byteLength(original, "latin1") !== Buffer.byteLength(replacement, "latin1")) {
1118
+ throw new Error(`${label} replacement must preserve the vendor ASAR byte length`);
1119
+ }
1120
+ Buffer.from(replacement, "latin1").copy(patchedArchive, offset);
1121
+ }
1122
+ const entries = new Set(patches.map(({ offset }) => findAsarEntry(metadata.header, metadata.dataOffset, offset)));
897
1123
  for (const entry of entries) updateAsarEntryIntegrity(patchedArchive, metadata.dataOffset, entry);
898
1124
  const header = Buffer.from(JSON.stringify(metadata.header));
899
1125
  if (header.length !== metadata.headerLength) {
@@ -901,7 +1127,7 @@ function patchFixedWidthAsarString(asarPath, original, replacement, label) {
901
1127
  }
902
1128
  header.copy(patchedArchive, 16);
903
1129
  fs.writeFileSync(asarPath, patchedArchive);
904
- return patchOffsets.length;
1130
+ return patches.length;
905
1131
  }
906
1132
 
907
1133
  function cloneAppBundle(source, destination) {
@@ -1039,7 +1265,7 @@ function rebrandElectronHelpers(bundle, { fromName, toName, fromIdentifier, toId
1039
1265
  }
1040
1266
  }
1041
1267
 
1042
- function vendoredChatGPTWrapperPlist(vendorPath) {
1268
+ function vendoredChatGPTWrapperPlist(vendorPath, asarHash) {
1043
1269
  return `<?xml version="1.0" encoding="UTF-8"?>
1044
1270
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1045
1271
  <plist version="1.0"><dict>
@@ -1050,6 +1276,7 @@ function vendoredChatGPTWrapperPlist(vendorPath) {
1050
1276
  <key>CFBundleName</key><string>Impel ChatGPT</string>
1051
1277
  <key>CFBundlePackageType</key><string>APPL</string>
1052
1278
  <key>CFBundleShortVersionString</key><string>${xmlEscape(bundleVersion(vendorPath) || "1")}</string>
1279
+ <key>ElectronAsarIntegrity</key><dict><key>Resources/app.asar</key><dict><key>algorithm</key><string>SHA256</string><key>hash</key><string>${xmlEscape(asarHash)}</string></dict></dict>
1053
1280
  <key>LSMinimumSystemVersion</key><string>12.0</string>
1054
1281
  <key>NSHighResolutionCapable</key><true/>
1055
1282
  </dict></plist>
@@ -1191,9 +1418,26 @@ function isMachO(file) {
1191
1418
  }
1192
1419
 
1193
1420
  function asarHeaderHash(file) {
1194
- const archive = fs.readFileSync(file);
1195
- const { headerLength } = readAsarMetadata(archive);
1196
- return sha256(archive.subarray(16, 16 + headerLength));
1421
+ const handle = fs.openSync(file, "r");
1422
+ try {
1423
+ const prefix = Buffer.alloc(16);
1424
+ if (fs.readSync(handle, prefix, 0, prefix.length, 0) !== prefix.length) {
1425
+ throw new Error("desktop app ASAR header is invalid");
1426
+ }
1427
+ const headerLength = prefix.readUInt32LE(12);
1428
+ const dataOffset = 8 + prefix.readUInt32LE(4);
1429
+ const fileSize = fs.fstatSync(handle).size;
1430
+ if (headerLength <= 0 || 16 + headerLength > dataOffset || dataOffset > fileSize) {
1431
+ throw new Error("desktop app ASAR offsets are invalid");
1432
+ }
1433
+ const header = Buffer.alloc(headerLength);
1434
+ if (fs.readSync(handle, header, 0, header.length, 16) !== header.length) {
1435
+ throw new Error("desktop app ASAR header is truncated");
1436
+ }
1437
+ return sha256(header);
1438
+ } finally {
1439
+ fs.closeSync(handle);
1440
+ }
1197
1441
  }
1198
1442
 
1199
1443
  function sha256(contents) {
@@ -1241,11 +1485,6 @@ function bundleVersion(bundle) {
1241
1485
  }
1242
1486
  }
1243
1487
 
1244
- function findCommand(command) {
1245
- const result = spawnSync("/usr/bin/which", [command], { encoding: "utf8" });
1246
- return result.status === 0 ? result.stdout.trim() : null;
1247
- }
1248
-
1249
1488
  function shellQuote(value) {
1250
1489
  return `'${String(value).replace(/'/g, `'"'"'`)}'`;
1251
1490
  }
@@ -351,20 +351,20 @@ export async function cmdApps(argv, overrides = {}) {
351
351
  }
352
352
 
353
353
  const config = await selectedAppConfig(targets);
354
+ console.log(`Tenant: ${config.tenantId} (desktop history is isolated per tenant).`);
354
355
 
355
- // A running vendor or Impel app makes the cask upgrade / bundle swap fail.
356
+ // A running vendor or Impel app makes the pinned vendor install / bundle swap fail.
356
357
  await quitBlockingApps(targets);
357
358
 
358
359
  const vendorPaths = {};
359
360
  if (!flags["skip-vendor"]) {
360
361
  for (const target of targets) {
361
- // Always bring the vendor (system) app to the latest version before
362
- // cloning it into the vendored Impel bundle, so install and update both
363
- // start from a current copy. A failed upgrade keeps the existing app.
364
- const result = ensureVendorApp(target, { update: true });
362
+ // Reuse or install only the exact vendor build verified by this CLI.
363
+ // Moving "latest" releases are never cloned into a managed bundle.
364
+ const result = ensureVendorApp(target);
365
365
  vendorPaths[target] = result.path;
366
366
  console.log(`${target}: vendor app ${result.action}${result.note ? ` (${result.note})` : ""}`);
367
- if (!result.path) throw new Error(`${target} vendor app is unavailable; install it manually or fix Homebrew, then re-run`);
367
+ if (!result.path) throw new Error(`${target} verified vendor app is unavailable; retry the pinned download`);
368
368
  }
369
369
  }
370
370
 
@@ -225,7 +225,7 @@ export async function cmdSetup(argv, overrides = {}) {
225
225
 
226
226
  // ── Step 3: apps ─────────────────────────────────────────────────────────
227
227
  // Order inside `impel app install`: close running Claude/ChatGPT apps (a
228
- // running app makes the install fail), bring the vendor apps to the latest
228
+ // running app makes the install fail), bring the vendor apps to the verified
229
229
  // version, then build the vendored Impel bundles from that fresh copy.
230
230
  // Native Claude Code and Codex profiles are never touched.
231
231
  let platformSetupFailed = false;
@@ -301,7 +301,7 @@ export async function cmdSetup(argv, overrides = {}) {
301
301
  );
302
302
  } else {
303
303
  console.log(
304
- "3/4 Installing the Impel desktop apps (running Claude/ChatGPT apps are closed, vendor apps updated first)…"
304
+ "3/4 Installing the Impel desktop apps (running Claude/ChatGPT apps are closed, verified vendor builds installed first)…"
305
305
  );
306
306
  try {
307
307
  await io.installApps(["install", "all"]);
@@ -65,6 +65,7 @@ export async function cmdTenant(argv) {
65
65
  if (!tenantId) throw new Error("tenant use requires an org slug; run `impel tenant list`");
66
66
  const { tenant } = await selectTenant(config, tenantId);
67
67
  console.log(`Tenant -> ${tenant.id} (${tenant.name})`);
68
+ console.log("Local CLI and desktop history in other tenants is preserved and remains available when you switch back.");
68
69
 
69
70
  const launch = flags.launch;
70
71
  if (!launch) {
@@ -196,7 +196,7 @@ export async function cmdUpdate(argv, overrides = {}) {
196
196
  } else {
197
197
  console.log(io.platform === "win32"
198
198
  ? "Apps: updating the signed Claude and ChatGPT vendor apps and isolated profiles…"
199
- : "Apps: updating (running apps are closed, vendor apps updated, bundles rebuilt)…");
199
+ : "Apps: updating (running apps are closed, verified vendor builds installed, bundles rebuilt)…");
200
200
  if (!io.runAppsUpdate()) {
201
201
  console.error("impel update: the app update failed; re-run `impel app update` after fixing the issue.");
202
202
  cascadeFailed = true;