impel-cli 0.7.0 → 0.7.2

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
@@ -25,78 +25,91 @@ of the protected Store directory and launches it with tenant-specific Codex and
25
25
  browser profiles. Each approach keeps the user's normal app profile and
26
26
  signed-in account untouched.
27
27
 
28
- ## Install
28
+ ## Start here
29
29
 
30
- Requires Node.js 18 or newer. The same command works on macOS, Linux, and
31
- Windows (PowerShell):
30
+ You do not need GitHub access to install Impel. You need an Impel account and a
31
+ computer with Node.js installed. Node.js includes the small installer tool
32
+ called `npm` that installs Impel for you.
32
33
 
33
- ```sh
34
- npm install --global impel-cli
35
- ```
34
+ ### 1. Install Node.js and npm
36
35
 
37
- The installable package is intentionally public so Impel users do not need
38
- access to the private GitHub repository. It contains only this dependency-free
39
- CLI—no credentials, service secrets, or customer data. Installing it grants no
40
- Impel access: every protected operation still requires an Impel PAT and the
41
- gateway/control plane re-checks the user's live product access, scopes, and
42
- tenant membership.
36
+ If `node --version` and `npm --version` already print version numbers, skip to
37
+ the next step.
43
38
 
44
- Releases are published from the private repository with npm Trusted Publishing:
45
- the registry accepts a short-lived OIDC credential only from the pinned release
46
- workflow, so no long-lived npm token is stored in GitHub. npm also verifies the
47
- published tarball's advertised SHA-512 integrity during installation.
39
+ - **Mac or Windows:** visit [nodejs.org/download](https://nodejs.org/en/download),
40
+ choose the **LTS** installer, and accept the default installer options.
41
+ - **Linux:** follow Node's [official package-manager instructions](https://nodejs.org/en/download/package-manager)
42
+ for your distribution.
48
43
 
49
- Maintainers can exercise the Windows-specific paths from a Linux Docker engine
50
- with the real Windows Node binary and Wine's `cmd.exe` compatibility layer:
44
+ After installing Node.js, close and reopen Terminal (Mac/Linux) or PowerShell
45
+ (Windows), then check that it worked:
51
46
 
52
47
  ```sh
53
- docker build --platform linux/amd64 -f test/docker/windows-wine.Dockerfile -t impel-cli-windows-test .
54
- docker run --rm --platform linux/amd64 impel-cli-windows-test
48
+ node --version
49
+ npm --version
55
50
  ```
56
51
 
57
- The repository CI additionally runs the complete suite on `windows-latest`;
58
- that native job owns the hostile percent/caret npm-shim case that Wine does not
59
- faithfully emulate.
52
+ Both commands should print a version number. Impel needs Node.js 18 or newer.
53
+
54
+ ### 2. Install Impel
60
55
 
61
- ## Quickstart
56
+ In Terminal or PowerShell, run:
62
57
 
63
58
  ```sh
64
- impel setup # token → tenant → platform clients → verify
65
- impel claude # launch Claude Code with an isolated Impel profile
66
- impel codex # launch Codex with an isolated Impel profile
67
- impel update # later: update the CLI, apps, and skills in one command
59
+ npm install --global impel-cli
68
60
  ```
69
61
 
70
- `impel setup` walks through everything below in one command (create your PAT at
71
- `https://www.useimpel.com/settings/gateway`, paste it at the secure prompt, and
72
- the wizard does the rest). On Windows it installs either missing official vendor
73
- CLI from npm, prepares both tenant-isolated CLI profiles, installs the signed
74
- Claude and ChatGPT desktop apps with separate tenant profiles, and syncs skills.
75
- On macOS it installs the isolated desktop apps. The individual commands remain
76
- for piecemeal use:
62
+ This downloads the public Impel command-line app. It does **not** give anyone
63
+ access to your Impel account, workspace, or data.
64
+
65
+ ### 3. Connect your Impel account
66
+
67
+ 1. Open [Impel gateway settings](https://www.useimpel.com/settings/gateway).
68
+ 2. Create a Personal Access Token and copy it somewhere safe.
69
+ 3. Back in Terminal or PowerShell, run:
70
+
71
+ ```sh
72
+ impel setup
73
+ ```
74
+
75
+ 4. Paste the token when asked. The input is hidden, and Impel stores it only in
76
+ your local account configuration.
77
+ 5. Choose your organization when prompted and let the setup finish.
78
+
79
+ Your token is the key to your account. Never paste it into a chat, ticket, or
80
+ shared document.
81
+
82
+ ### 4. Start working
77
83
 
78
84
  ```sh
79
- impel auth # paste your PAT when prompted
80
- impel tenant list # see every organization available to this user
81
- impel tenant use impel # select the tenant for accounts, specialists, and history
82
- impel claude # launch Claude Code with an isolated Impel profile
83
- impel codex # launch Codex with an isolated Impel profile
84
- impel status # isolated-launcher readiness, native mode, and reachability
85
- impel doctor # make one synthetic request to each provider and measure routing/latency
86
-
87
- impel app install # install both isolated desktop app profiles
88
- impel app update # update vendor apps, configs, and supported models
89
- impel app open claude # open Claude Desktop with its isolated Impel profile
90
- impel app open codex # open ChatGPT/Codex Desktop with its isolated Impel profile
91
-
92
- # The normal `claude` and `codex` commands keep using your personal profiles.
85
+ impel claude # open an isolated Impel Claude Code session
86
+ impel codex # open an isolated Impel Codex session
87
+ impel status # check your connection and configuration
88
+ impel update # install the latest Impel CLI later
93
89
  ```
94
90
 
95
- Fresh `impel claude` profiles start on **Claude Opus 4.8 with 1M context** and
96
- also register **Claude Fable 5** in `/model`. You can choose a different
97
- persistent default there, or override one session with `--model`.
91
+ `impel setup` keeps your normal Claude and Codex profiles separate. On Mac and
92
+ Windows it also prepares the supported isolated desktop experiences. On Windows,
93
+ it installs missing official vendor command-line tools when needed. On Linux,
94
+ it prepares the isolated command-line workflow.
95
+
96
+ ### If something does not work
97
+
98
+ - **`npm` or `node` is not recognized:** install Node.js from the link above,
99
+ then close and reopen Terminal or PowerShell.
100
+ - **`impel` is not recognized:** close and reopen the terminal, then run
101
+ `npm install --global impel-cli` again.
102
+ - **Your token is rejected:** create a fresh token in Impel gateway settings and
103
+ run `impel setup` again.
104
+ - **You need a different organization:** run `impel tenant list`, then
105
+ `impel tenant use <organization>`.
106
+
107
+ The npm package is public so Impel users do not need access to the private
108
+ GitHub repository. The package contains no account credentials or service
109
+ secrets; the Impel gateway and control plane still enforce your token, product
110
+ access, scopes, and organization membership for every protected action.
98
111
 
99
- ## Commands
112
+ ## Command reference
100
113
 
101
114
  ```
102
115
  impel setup [--pat <pat>] [--tenant <org>] [--skip-apps] [--skip-clis]
@@ -171,7 +184,7 @@ impel auth --pat impel_pat_xxxxxxxxxxxx --gateway https://gateway.useimpel.com -
171
184
  One command brings everything current, in dependency order: the CLI itself
172
185
  (`npm install -g impel-cli@latest` from npm, with no GitHub credentials),
173
186
  then — re-executing the freshly installed build — `impel app update all`
174
- (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)
175
188
  and `impel skills sync all` across every managed profile.
176
189
 
177
190
  Update discovery compares the installed package version with npm's public
@@ -572,12 +585,14 @@ configuration:
572
585
  `~/.config/impel/apps/claude`. A narrowly version-checked compatibility patch
573
586
  connects Claude's native plan-usage meter to the authenticated aggregate
574
587
  Claude subscription pool exposed by the Impel gateway.
575
- - Impel ChatGPT is an APFS-cloned vendored copy of the official app. Its wrapper
576
- sets `CODEX_HOME`, passes a separate Chromium `--user-data-dir`, and writes its
577
- provider config and model catalog below `~/.config/impel/apps/chatgpt`. It also
578
- supplies a process-scoped, Codex-compatible wrapper around the Impel PAT so
579
- native `/status` requests can read the organization's aggregated subscription
580
- pool from the gateway. Its generated Codex config uses the same snapshot and
588
+ - Impel ChatGPT wraps an APFS-cloned official app while preserving the nested
589
+ OpenAI-signed bundle and executable. Its outer resource root contains the
590
+ narrowly patched renderer ASAR used at runtime. The wrapper sets `CODEX_HOME`,
591
+ passes a separate Chromium `--user-data-dir`, and writes its provider config
592
+ and model catalog below `~/.config/impel/apps/chatgpt`. It also supplies a
593
+ process-scoped, Codex-compatible wrapper around the Impel PAT so native
594
+ `/status` requests can read the organization's aggregated subscription pool
595
+ from the gateway. Its generated Codex config uses the same snapshot and
581
596
  subprocess-environment hardening as `impel codex`; install, update, and every
582
597
  direct app launch also remove older snapshots for the selected profile, while
583
598
  app install/update/open sweeps inactive tenant and legacy Impel app profiles.
@@ -585,22 +600,25 @@ configuration:
585
600
  normal vendor app's browser/profile data.
586
601
 
587
602
  The official Claude and ChatGPT apps remain separate installations and keep
588
- their normal accounts, updater, profiles, and bundle identities. The installer
589
- uses an existing official app when found. If one is missing and
590
- Homebrew is available, it installs the corresponding cask into
591
- `~/Applications`. `impel app update` upgrades Homebrew-managed vendor apps,
592
- rebuilds the isolated apps, reapplies version-checked compatibility patches,
593
- rewrites versioned configs, and fetches the PAT-scoped model catalog from
594
- `<gateway>/v1/models`. If a vendor update changes a required renderer contract,
595
- the update fails without replacing the working Impel copy. If the model endpoint
596
- is temporarily unavailable, the installer retains functionality using the CLI's
597
- bundled catalog.
603
+ their normal accounts, updater, profiles, and bundle identities. Each CLI
604
+ release pins exact, tested vendor builds (v0.7.2 pins Claude `1.20186.9` and
605
+ ChatGPT `26.707.72221`) and their archive SHA-256 digests. The installer reuses
606
+ an existing official app only when its version exactly matches; otherwise it
607
+ downloads the pinned signed archive into Impel's managed vendor cache under
608
+ `~/.config/impel/vendor`, verifies its digest, bundle version, layout, and code
609
+ signature, and only then rebuilds the isolated app. It never clones a moving
610
+ Homebrew or vendor “latest” release, and it never downgrades or replaces the
611
+ user's normal vendor app. A future app version requires a new tested CLI release
612
+ and pin update. Config refreshes do not download or replace apps. If the model
613
+ endpoint is temporarily unavailable, the installer retains functionality using
614
+ the CLI's bundled catalog.
598
615
 
599
616
  For ChatGPT/Codex, the gateway controls which models and capabilities are
600
617
  enabled while the installer merges those entries with the vendor app's native
601
618
  catalog. The Impel copy relaxes the vendor renderer's ChatGPT-account-only Fast
602
- gate because gateway auth supplies the entitlement instead. The copy is then
603
- ad-hoc signed locally. This preserves model-specific instructions and exposes
619
+ gate because gateway auth supplies the entitlement instead. The outer wrapper
620
+ is then ad-hoc signed locally without replacing the nested OpenAI signing
621
+ identity. This preserves model-specific instructions and exposes
604
622
  the same reasoning-effort and service-tier controls as the normal app, including
605
623
  Ultra where supported and Fast only on models that advertise the `priority`
606
624
  tier. Inference uses an explicit non-WebSocket Impel model provider so Codex
@@ -609,8 +627,10 @@ Impel PAT. Config refreshes replace only the Impel-managed gateway block; plugin
609
627
  desktop preferences, projects, and other settings written by the isolated app
610
628
  are kept.
611
629
 
612
- Both vendored copies are ad-hoc signed locally after their bundle identities and
613
- launch wrappers are installed. The Claude 3P config necessarily contains the PAT because that app accepts a
630
+ Impel Claude and the outer Impel ChatGPT wrapper are ad-hoc signed locally after
631
+ their bundle identities and launch wrappers are installed. The nested ChatGPT
632
+ bundle retains OpenAI's signature. The Claude 3P config necessarily contains
633
+ the PAT because that app accepts a
614
634
  gateway API key rather than a token-helper command. It is stored in the same
615
635
  owner-only Impel config tree as the primary credential. ChatGPT/Codex uses a
616
636
  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.0",
3
+ "version": "0.7.2",
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,37 @@ export function bundleIsCurrent(status) {
110
144
  compatibility
111
145
  && compatibility.cliVersion === CLI_VERSION
112
146
  && compatibility.vendorVersion === status.vendorVersion
147
+ && managedBundleMatchesCompatibility(status, compatibility)
113
148
  && (status.target !== "chatgpt" || (
114
149
  compatibility.vendorSignaturesPreserved === true
115
- && compatibility.resourceRoot === "linked_vendor_resources"
150
+ && compatibility.resourceRoot === CHATGPT_RESOURCE_ROOT
151
+ && compatibility.patches?.fastModeForGatewayAuth > 0
116
152
  )),
117
153
  );
118
154
  }
119
155
 
156
+ function managedBundleMatchesCompatibility(status, compatibility) {
157
+ try {
158
+ const managedVendorBundle = status.target === "claude"
159
+ ? status.launcher
160
+ : APP_DEFINITIONS.chatgpt.names
161
+ .map((name) => path.join(status.launcher, "Contents", "Resources", name))
162
+ .find((candidate) => fs.existsSync(candidate));
163
+ const runtimeAsar = path.join(status.launcher, "Contents", "Resources", "app.asar");
164
+ return Boolean(
165
+ managedVendorBundle
166
+ && bundleVersion(managedVendorBundle) === compatibility.vendorVersion
167
+ && compatibility.asarHeaderSha256
168
+ && asarHeaderHash(runtimeAsar) === compatibility.asarHeaderSha256
169
+ );
170
+ } catch {
171
+ return false;
172
+ }
173
+ }
174
+
120
175
  // 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.
176
+ // Cloning/replacing a bundle under a running instance produces broken copies
177
+ // and codesign failures.
123
178
 
124
179
  /** osascript application names whose bundles install/update will touch. */
125
180
  export function blockingAppNames(targets) {
@@ -269,6 +324,9 @@ export function installManagedAppFiles({
269
324
  // token helper, catalog, and manifest only. Bundle swaps require the app
270
325
  // to be closed (see quitBlockingApps), so those paths never attempt one.
271
326
  if (bundleTargets.has(target)) {
327
+ if (resolvedVendorPaths[target] && isVendableVendorApp(target, resolvedVendorPaths[target])) {
328
+ assertPinnedVendorAppVersion(target, resolvedVendorPaths[target]);
329
+ }
272
330
  if (target === "chatgpt") writeVendoredChatGPTBundle(paths, resolvedVendorPaths.chatgpt, config.gatewayUrl);
273
331
  else writeVendoredClaudeBundle(paths, resolvedVendorPaths.claude, config.gatewayUrl);
274
332
  }
@@ -289,8 +347,11 @@ export function installManagedAppFiles({
289
347
  return installed;
290
348
  }
291
349
 
292
- function vendorSearchRoots(homeDir) {
293
- return ["/Applications", path.join(homeDir, "Applications")];
350
+ function vendorSearchRoots(homeDir, target) {
351
+ const roots = ["/Applications", path.join(homeDir, "Applications")];
352
+ const pin = PINNED_VENDOR_APPS[target];
353
+ if (pin) roots.push(path.join(homeDir, ".config", "impel", "vendor", target, pin.version));
354
+ return roots;
294
355
  }
295
356
 
296
357
  export function readBundleIdentifier(appPath) {
@@ -320,19 +381,26 @@ export function isVendableVendorApp(target, appPath) {
320
381
  );
321
382
  }
322
383
 
323
- export function findVendorApp(target, homeDir = os.homedir(), { roots = vendorSearchRoots(homeDir) } = {}) {
384
+ export function findVendorApp(target, homeDir = os.homedir(), {
385
+ roots = vendorSearchRoots(homeDir, target),
386
+ version = PINNED_VENDOR_APPS[target]?.version,
387
+ } = {}) {
324
388
  const definition = APP_DEFINITIONS[target];
325
389
  for (const base of roots) {
326
390
  for (const name of definition.names) {
327
391
  const candidate = path.join(base, name);
328
- if (fs.existsSync(candidate) && isVendableVendorApp(target, candidate)) return candidate;
392
+ if (
393
+ fs.existsSync(candidate)
394
+ && isVendableVendorApp(target, candidate)
395
+ && (!version || bundleVersion(candidate) === version)
396
+ ) return candidate;
329
397
  }
330
398
  }
331
399
  return null;
332
400
  }
333
401
 
334
402
  /** 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) } = {}) {
403
+ export function rejectedVendorCandidates(target, homeDir = os.homedir(), { roots = vendorSearchRoots(homeDir, target) } = {}) {
336
404
  const definition = APP_DEFINITIONS[target];
337
405
  const rejected = [];
338
406
  for (const base of roots) {
@@ -346,33 +414,103 @@ export function rejectedVendorCandidates(target, homeDir = os.homedir(), { roots
346
414
  return rejected;
347
415
  }
348
416
 
349
- export function ensureVendorApp(target, { homeDir = os.homedir(), update = false } = {}) {
417
+ function unpinnedVendorCandidates(target, homeDir = os.homedir(), { roots = vendorSearchRoots(homeDir, target) } = {}) {
350
418
  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" };
419
+ const expectedVersion = PINNED_VENDOR_APPS[target].version;
420
+ const candidates = [];
421
+ for (const base of roots) {
422
+ for (const name of definition.names) {
423
+ const candidate = path.join(base, name);
424
+ if (fs.existsSync(candidate) && isVendableVendorApp(target, candidate)) {
425
+ const version = bundleVersion(candidate);
426
+ if (version !== expectedVersion) candidates.push({ path: candidate, version });
427
+ }
428
+ }
429
+ }
430
+ return candidates;
431
+ }
432
+
433
+ export function assertPinnedVendorAppVersion(target, appPath) {
434
+ const expected = PINNED_VENDOR_APPS[target]?.version;
435
+ const actual = bundleVersion(appPath);
436
+ if (!expected || actual !== expected) {
437
+ throw new Error(
438
+ `${APP_DEFINITIONS[target]?.label || target} vendor version ${actual || "unknown"} is unsupported; impel-cli v${CLI_VERSION} requires the verified ${expected || "pinned"} build`,
439
+ );
440
+ }
441
+ return actual;
442
+ }
443
+
444
+ function installPinnedVendorApp(target, homeDir) {
445
+ const pin = PINNED_VENDOR_APPS[target];
446
+ const download = pin?.downloads?.[process.arch];
447
+ if (!pin || !download) throw new Error(`${target}: no verified vendor download is available for ${process.arch}`);
448
+
449
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), `impel-${target}-vendor-`));
450
+ const archive = path.join(tempRoot, `${target}.zip`);
451
+ const extractedRoot = path.join(tempRoot, "extracted");
452
+ try {
453
+ console.log(`${target}: downloading verified vendor app ${pin.version}…`);
454
+ const fetched = spawnSync(
455
+ "/usr/bin/curl",
456
+ ["--fail", "--location", "--progress-bar", "--retry", "2", "--output", archive, download.url],
457
+ { stdio: ["ignore", "inherit", "inherit"] },
458
+ );
459
+ if (fetched.status !== 0 || fetched.error) {
460
+ throw new Error(`${target}: failed to download verified vendor app ${pin.version}`);
461
+ }
462
+ const actualHash = sha256(fs.readFileSync(archive));
463
+ if (actualHash !== download.sha256) {
464
+ throw new Error(`${target}: vendor archive integrity check failed for ${pin.version}`);
465
+ }
466
+
467
+ fs.mkdirSync(extractedRoot, { recursive: true });
468
+ const extracted = spawnSync("/usr/bin/ditto", ["-x", "-k", archive, extractedRoot], { encoding: "utf8" });
469
+ if (extracted.status !== 0 || extracted.error) {
470
+ throw new Error(`${target}: failed to extract verified vendor app ${pin.version}`);
471
+ }
472
+ const extractedApp = path.join(extractedRoot, pin.bundleName);
473
+ if (!isVendableVendorApp(target, extractedApp)) {
474
+ throw new Error(`${target}: verified vendor archive ${pin.version} has an unsupported app layout`);
475
+ }
476
+ assertPinnedVendorAppVersion(target, extractedApp);
477
+ const verified = spawnSync("/usr/bin/codesign", ["--verify", "--deep", "--strict", extractedApp], { encoding: "utf8" });
478
+ if (verified.status !== 0 || verified.error) {
479
+ throw new Error(`${target}: vendor signature verification failed for ${pin.version}`);
360
480
  }
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" };
481
+
482
+ const destination = path.join(
483
+ homeDir, ".config", "impel", "vendor", target, pin.version, pin.bundleName,
484
+ );
485
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
486
+ replaceDirectory(destination, extractedApp);
487
+ return destination;
488
+ } finally {
489
+ fs.rmSync(tempRoot, { recursive: true, force: true });
363
490
  }
491
+ }
492
+
493
+ export function ensureVendorApp(target, { homeDir = os.homedir() } = {}) {
494
+ const definition = APP_DEFINITIONS[target];
495
+ const pin = PINNED_VENDOR_APPS[target];
496
+ const current = findVendorApp(target, homeDir);
497
+ if (current) return { path: current, action: "existing", note: `verified ${pin.version}` };
364
498
 
365
499
  // 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.
500
+ // (com.openai.chat) is NOT the Electron app and cannot be vendored.
368
501
  for (const candidate of rejectedVendorCandidates(target, homeDir)) {
369
502
  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`,
503
+ `${target}: ignoring ${candidate.path} (${candidate.bundleId || "unknown bundle id"}) — not a vendorable ${definition.label.replace("Impel ", "")} desktop app`,
371
504
  );
372
505
  }
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" };
506
+ for (const candidate of unpinnedVendorCandidates(target, homeDir)) {
507
+ console.log(
508
+ `${target}: ignoring ${candidate.path} (${candidate.version || "unknown version"}) this CLI is pinned to verified version ${pin.version}`,
509
+ );
510
+ }
511
+
512
+ const installed = installPinnedVendorApp(target, homeDir);
513
+ return { path: installed, action: "installed", note: `verified ${pin.version}` };
376
514
  }
377
515
 
378
516
  export function appStatus(targets, homeDir = os.homedir(), tenantId = null) {
@@ -749,12 +887,7 @@ function writeVendoredClaudeBundle(paths, vendorPath, gatewayUrl) {
749
887
  IMPEL_CLAUDE_PLAN_USAGE_GUARD,
750
888
  "Claude plan-usage eligibility guard",
751
889
  );
752
- planUsageRequestPatchCount = patchFixedWidthAsarString(
753
- asarPath,
754
- CLAUDE_PLAN_USAGE_REQUEST,
755
- IMPEL_CLAUDE_PLAN_USAGE_REQUEST,
756
- "Claude plan-usage gateway request",
757
- );
890
+ planUsageRequestPatchCount = patchClaudePlanUsageRequest(asarPath);
758
891
  }
759
892
  const newAsarHash = asarHeaderHash(asarPath);
760
893
 
@@ -825,22 +958,27 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl) {
825
958
  if (!fs.existsSync(asarPath) || !fs.existsSync(plistPath)) {
826
959
  const bundleId = readBundleIdentifier(vendorPath);
827
960
  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"
961
+ ? " — this is OpenAI's non-Electron ChatGPT app, not the supported desktop build; re-run `impel app install chatgpt`"
829
962
  : bundleId
830
963
  ? ` (bundle id ${bundleId})`
831
964
  : "";
832
965
  throw new Error(`unsupported ChatGPT/Codex bundle layout in ${vendorPath}${hint}`);
833
966
  }
834
967
 
835
- const asarHash = asarHeaderHash(asarPath);
836
-
837
968
  const macos = path.join(staging, "Contents", "MacOS");
838
- writeAtomic(path.join(staging, "Contents", "Info.plist"), vendoredChatGPTWrapperPlist(vendorPath), 0o644);
839
969
  const vendorIcon = path.join(vendorBundle, "Contents", "Resources", "electron.icns");
840
970
  if (fs.existsSync(vendorIcon)) {
841
971
  fs.copyFileSync(vendorIcon, path.join(staging, "Contents", "Resources", "electron.icns"));
842
972
  }
843
973
  linkVendoredChatGPTResources(staging, vendorBundleName);
974
+ const runtimeAsarPath = path.join(staging, "Contents", "Resources", "app.asar");
975
+ const fastModePatchCount = patchFastModeAuthGate(runtimeAsarPath);
976
+ const asarHash = asarHeaderHash(runtimeAsarPath);
977
+ writeAtomic(
978
+ path.join(staging, "Contents", "Info.plist"),
979
+ vendoredChatGPTWrapperPlist(vendorPath, asarHash),
980
+ 0o644,
981
+ );
844
982
  writeAtomic(
845
983
  path.join(macos, "launch"),
846
984
  vendoredChatGPTLauncher(paths, vendorBundleName, executableName, gatewayUrl),
@@ -851,9 +989,9 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl) {
851
989
  cliVersion: CLI_VERSION,
852
990
  vendorVersion: bundleVersion(vendorPath),
853
991
  sourceBundle: vendorPath,
854
- patches: { fastModeForGatewayAuth: 0, desktopAPIForGatewayAuth: 0 },
992
+ patches: { fastModeForGatewayAuth: fastModePatchCount, desktopAPIForGatewayAuth: 0 },
855
993
  vendorSignaturesPreserved: true,
856
- resourceRoot: "linked_vendor_resources",
994
+ resourceRoot: CHATGPT_RESOURCE_ROOT,
857
995
  asarHeaderSha256: asarHash,
858
996
  }, null, 2) + "\n", 0o644);
859
997
 
@@ -870,11 +1008,17 @@ function linkVendoredChatGPTResources(bundle, vendorBundleName) {
870
1008
  const vendorResources = path.join(outerResources, vendorBundleName, "Contents", "Resources");
871
1009
  for (const entry of fs.readdirSync(vendorResources, { withFileTypes: true })) {
872
1010
  // 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.
1011
+ // resource is exposed at the outer Electron resource root, leaving the
1012
+ // OpenAI-signed nested bundle untouched. app.asar is the sole copied file
1013
+ // because the runtime needs the narrowly scoped compatibility patch below.
875
1014
  if (entry.name === "electron.icns") continue;
876
1015
  const target = path.join(vendorBundleName, "Contents", "Resources", entry.name);
877
- fs.symlinkSync(target, path.join(outerResources, entry.name));
1016
+ const destination = path.join(outerResources, entry.name);
1017
+ // Electron resolves app.asar from the outer wrapper's resource root. Keep
1018
+ // the nested OpenAI bundle byte-for-byte signed, but use a patched copy at
1019
+ // runtime so gateway-authenticated profiles retain the native Fast control.
1020
+ if (entry.name === "app.asar") fs.copyFileSync(path.join(vendorResources, entry.name), destination);
1021
+ else fs.symlinkSync(target, destination);
878
1022
  }
879
1023
  }
880
1024
 
@@ -883,17 +1027,88 @@ function patchFixedWidthAsarString(asarPath, original, replacement, label) {
883
1027
  throw new Error(`${label} replacement must preserve the vendor ASAR byte length`);
884
1028
  }
885
1029
  const archive = fs.readFileSync(asarPath);
886
- const metadata = readAsarMetadata(archive);
887
1030
  const source = archive.toString("latin1");
888
- const patchOffsets = [];
1031
+ const patches = [];
889
1032
  for (let offset = source.indexOf(original); offset !== -1; offset = source.indexOf(original, offset + original.length)) {
890
- patchOffsets.push(offset);
1033
+ patches.push({ offset, original, replacement });
1034
+ }
1035
+ return applyFixedWidthAsarPatches(asarPath, archive, patches, label);
1036
+ }
1037
+
1038
+ function patchClaudePlanUsageRequest(asarPath) {
1039
+ const archive = fs.readFileSync(asarPath);
1040
+ const source = archive.toString("latin1");
1041
+ const legacyPatches = [];
1042
+ for (
1043
+ let offset = source.indexOf(CLAUDE_PLAN_USAGE_REQUEST);
1044
+ offset !== -1;
1045
+ offset = source.indexOf(CLAUDE_PLAN_USAGE_REQUEST, offset + CLAUDE_PLAN_USAGE_REQUEST.length)
1046
+ ) {
1047
+ legacyPatches.push({
1048
+ offset,
1049
+ original: CLAUDE_PLAN_USAGE_REQUEST,
1050
+ replacement: IMPEL_CLAUDE_PLAN_USAGE_REQUEST,
1051
+ });
1052
+ }
1053
+ if (legacyPatches.length > 0) {
1054
+ return applyFixedWidthAsarPatches(
1055
+ asarPath, archive, legacyPatches, "Claude plan-usage gateway request",
1056
+ );
1057
+ }
1058
+ const credentialAccessor = source.match(
1059
+ /function ([A-Za-z_$][\w$]*)\(e\)\{var t;return [\s\S]{0,500}?\[custom-3p\] Credentials loaded from managed config/u,
1060
+ )?.[1];
1061
+ if (!credentialAccessor) {
1062
+ return applyFixedWidthAsarPatches(asarPath, archive, [], "Claude plan-usage gateway request");
1063
+ }
1064
+ const configAccessorPattern = new RegExp(
1065
+ `${escapeRegex(credentialAccessor)}\\(([A-Za-z_$][\\w$]*)\\(\\)\\)`,
1066
+ "u",
1067
+ );
1068
+ const configAccessor = source.match(configAccessorPattern)?.[1];
1069
+ if (!configAccessor) {
1070
+ return applyFixedWidthAsarPatches(asarPath, archive, [], "Claude plan-usage gateway request");
891
1071
  }
892
- if (patchOffsets.length === 0) {
1072
+
1073
+ 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;
1074
+ const patches = [...source.matchAll(requestPattern)].map((match) => {
1075
+ const original = match[0];
1076
+ const replacement = `${match[1]}.net.fetch("https://gateway.useimpel.com/ui/a/u",{headers:{key:${credentialAccessor}(${configAccessor}()).apiKey}})`;
1077
+ if (replacement.length > original.length) {
1078
+ throw new Error("Claude plan-usage gateway request replacement no longer fits the vendor ASAR contract");
1079
+ }
1080
+ return { offset: match.index, original, replacement: replacement.padEnd(original.length, " ") };
1081
+ });
1082
+ return applyFixedWidthAsarPatches(asarPath, archive, patches, "Claude plan-usage gateway request");
1083
+ }
1084
+
1085
+ function patchFastModeAuthGate(asarPath) {
1086
+ const archive = fs.readFileSync(asarPath);
1087
+ const source = archive.toString("latin1");
1088
+ const patches = [...source.matchAll(FAST_MODE_AUTH_GATE)].map((match) => {
1089
+ const replacement = `${match[1]}=!${match[3]}`;
1090
+ return {
1091
+ offset: match.index,
1092
+ original: match[0],
1093
+ replacement: replacement.padEnd(match[0].length, " "),
1094
+ };
1095
+ });
1096
+ return applyFixedWidthAsarPatches(asarPath, archive, patches, "ChatGPT/Codex Fast-mode eligibility contract");
1097
+ }
1098
+
1099
+ function applyFixedWidthAsarPatches(asarPath, archive, patches, label) {
1100
+ if (patches.length === 0) {
893
1101
  throw new Error(`this desktop app version has an unsupported ${label}; update impel-cli before installing it`);
894
1102
  }
895
- const patchedArchive = Buffer.from(source.replaceAll(original, replacement), "latin1");
896
- const entries = new Set(patchOffsets.map((offset) => findAsarEntry(metadata.header, metadata.dataOffset, offset)));
1103
+ const metadata = readAsarMetadata(archive);
1104
+ const patchedArchive = Buffer.from(archive);
1105
+ for (const { offset, original, replacement } of patches) {
1106
+ if (Buffer.byteLength(original, "latin1") !== Buffer.byteLength(replacement, "latin1")) {
1107
+ throw new Error(`${label} replacement must preserve the vendor ASAR byte length`);
1108
+ }
1109
+ Buffer.from(replacement, "latin1").copy(patchedArchive, offset);
1110
+ }
1111
+ const entries = new Set(patches.map(({ offset }) => findAsarEntry(metadata.header, metadata.dataOffset, offset)));
897
1112
  for (const entry of entries) updateAsarEntryIntegrity(patchedArchive, metadata.dataOffset, entry);
898
1113
  const header = Buffer.from(JSON.stringify(metadata.header));
899
1114
  if (header.length !== metadata.headerLength) {
@@ -901,7 +1116,7 @@ function patchFixedWidthAsarString(asarPath, original, replacement, label) {
901
1116
  }
902
1117
  header.copy(patchedArchive, 16);
903
1118
  fs.writeFileSync(asarPath, patchedArchive);
904
- return patchOffsets.length;
1119
+ return patches.length;
905
1120
  }
906
1121
 
907
1122
  function cloneAppBundle(source, destination) {
@@ -1039,7 +1254,7 @@ function rebrandElectronHelpers(bundle, { fromName, toName, fromIdentifier, toId
1039
1254
  }
1040
1255
  }
1041
1256
 
1042
- function vendoredChatGPTWrapperPlist(vendorPath) {
1257
+ function vendoredChatGPTWrapperPlist(vendorPath, asarHash) {
1043
1258
  return `<?xml version="1.0" encoding="UTF-8"?>
1044
1259
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1045
1260
  <plist version="1.0"><dict>
@@ -1050,6 +1265,7 @@ function vendoredChatGPTWrapperPlist(vendorPath) {
1050
1265
  <key>CFBundleName</key><string>Impel ChatGPT</string>
1051
1266
  <key>CFBundlePackageType</key><string>APPL</string>
1052
1267
  <key>CFBundleShortVersionString</key><string>${xmlEscape(bundleVersion(vendorPath) || "1")}</string>
1268
+ <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
1269
  <key>LSMinimumSystemVersion</key><string>12.0</string>
1054
1270
  <key>NSHighResolutionCapable</key><true/>
1055
1271
  </dict></plist>
@@ -1191,9 +1407,26 @@ function isMachO(file) {
1191
1407
  }
1192
1408
 
1193
1409
  function asarHeaderHash(file) {
1194
- const archive = fs.readFileSync(file);
1195
- const { headerLength } = readAsarMetadata(archive);
1196
- return sha256(archive.subarray(16, 16 + headerLength));
1410
+ const handle = fs.openSync(file, "r");
1411
+ try {
1412
+ const prefix = Buffer.alloc(16);
1413
+ if (fs.readSync(handle, prefix, 0, prefix.length, 0) !== prefix.length) {
1414
+ throw new Error("desktop app ASAR header is invalid");
1415
+ }
1416
+ const headerLength = prefix.readUInt32LE(12);
1417
+ const dataOffset = 8 + prefix.readUInt32LE(4);
1418
+ const fileSize = fs.fstatSync(handle).size;
1419
+ if (headerLength <= 0 || 16 + headerLength > dataOffset || dataOffset > fileSize) {
1420
+ throw new Error("desktop app ASAR offsets are invalid");
1421
+ }
1422
+ const header = Buffer.alloc(headerLength);
1423
+ if (fs.readSync(handle, header, 0, header.length, 16) !== header.length) {
1424
+ throw new Error("desktop app ASAR header is truncated");
1425
+ }
1426
+ return sha256(header);
1427
+ } finally {
1428
+ fs.closeSync(handle);
1429
+ }
1197
1430
  }
1198
1431
 
1199
1432
  function sha256(contents) {
@@ -1241,11 +1474,6 @@ function bundleVersion(bundle) {
1241
1474
  }
1242
1475
  }
1243
1476
 
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
1477
  function shellQuote(value) {
1250
1478
  return `'${String(value).replace(/'/g, `'"'"'`)}'`;
1251
1479
  }
@@ -352,19 +352,18 @@ export async function cmdApps(argv, overrides = {}) {
352
352
 
353
353
  const config = await selectedAppConfig(targets);
354
354
 
355
- // A running vendor or Impel app makes the cask upgrade / bundle swap fail.
355
+ // A running vendor or Impel app makes the pinned vendor install / bundle swap fail.
356
356
  await quitBlockingApps(targets);
357
357
 
358
358
  const vendorPaths = {};
359
359
  if (!flags["skip-vendor"]) {
360
360
  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 });
361
+ // Reuse or install only the exact vendor build verified by this CLI.
362
+ // Moving "latest" releases are never cloned into a managed bundle.
363
+ const result = ensureVendorApp(target);
365
364
  vendorPaths[target] = result.path;
366
365
  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`);
366
+ if (!result.path) throw new Error(`${target} verified vendor app is unavailable; retry the pinned download`);
368
367
  }
369
368
  }
370
369
 
@@ -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"]);
@@ -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;