impel-cli 0.20.54 → 0.20.56

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/RELEASE_NOTES.md CHANGED
@@ -1,5 +1,52 @@
1
1
  # Release notes
2
2
 
3
+ ## 0.20.56 — Enforce reviewed Windows vendor CLIs, follow the Store pin, harden convergence
4
+
5
+ - Bumps the Windows ChatGPT Store pin to `26.818.2441.0` (embedded Codex
6
+ `0.148.0-alpha.21`, verified from the registered package on the disposable
7
+ Windows runner): the Store stopped fulfilling `26.814.5167.0`, so fresh
8
+ Windows installs failed the exact registered-package check (IMP-590). The
9
+ macOS pin stays held at `26.810.41047` pending a build that passes the
10
+ managed Tasks board launch smoke. Exact package, publisher, executable, and
11
+ embedded-runtime verification is unchanged.
12
+
13
+ - Enforces the exact reviewed Claude Code and Codex CLI releases on Windows,
14
+ matching the macOS contract: a vendor auto-update past the pin no longer
15
+ launches an unreviewed build against the gateway (the drift behind the
16
+ Windows `403 Forbidden` lockouts), and the launcher resolves the pinned
17
+ side-by-side release under `.local/share/claude/versions/` when the shared
18
+ launcher has drifted — including installs whose `HOME` diverges from
19
+ `USERPROFILE`.
20
+ - Disables the vendor background auto-updater in managed Claude CLI profiles
21
+ (`DISABLE_AUTOUPDATER=1`) so a session on the reviewed release cannot move
22
+ the shared launcher past the pin; manual `claude update` still works.
23
+ - Names vendor version drift in setup/update diagnostics ("installed v2.1.237
24
+ does not match the reviewed v2.1.220") instead of the misleading
25
+ "not installed or discoverable".
26
+ - Stops `chmod` failures from aborting Windows tenant convergence: managed
27
+ profile writers treat the FAT-era mode bits as best-effort on Windows, and
28
+ the all-tenant security sweep no longer fails every tenant over one
29
+ tenant's locked file. POSIX keeps failing loudly.
30
+ - Retries the Windows stable-entrypoint and managed-artifact renames through
31
+ the shared antivirus-tolerant retry helper.
32
+ - Carries which tenants failed and why in the auto-filed
33
+ "Tenant convergence failed during `impel update`" report, via a redacted
34
+ on-disk summary the cascaded child leaves for the parent's single report.
35
+ - Sends `X-Impel-Cli-Version` on gateway requests from `impel claude` so a
36
+ production 403 can be correlated to a CLI release.
37
+
38
+ ## 0.20.55 — Route managed CLIs through the signed runner
39
+
40
+ - Launches `impel codex` and `impel claude` through the tenant-agnostic macOS
41
+ managed runner when the corresponding signed descriptor is installed.
42
+ - Binds each launch to the exact logical application ID and leaves descriptor
43
+ signature, trust-anchor, runtime-lock, and payload verification with the
44
+ signed runner before any vendor CLI starts.
45
+ - Prefers the standalone Codex MSI on Windows while retaining the legacy
46
+ ChatGPT-embedded Codex package only as a compatibility fallback.
47
+ - Stops newly managed Codex sessions from reaching retired macOS wrappers that
48
+ injected an invalid `--console` argument into no-argument launches.
49
+
3
50
  ## 0.20.54 — Restore ChatGPT Store compatibility and scope skills per tenant
4
51
 
5
52
  - Restores managed ChatGPT/Codex installation on Windows by pinning Store
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.20.54",
3
+ "version": "0.20.56",
4
4
  "description": "Prepare isolated Claude and Codex workspaces for every accessible Impel tenant",
5
5
  "type": "module",
6
6
  "bin": {
package/src/apps.js CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  isImpelTasksMcpInvocation,
21
21
  } from "./selfInvocation.js";
22
22
  import { crossAppModelsEnabled, redactSecretText } from "./config.js";
23
+ import { chmodWithPlatformPolicy, renameWithWindowsRetry } from "./windowsFs.js";
23
24
  import { ensureClaudeSessionHooks, ensureCodexSessionHooks } from "./sessionHooks.js";
24
25
  import { ADHOC_IDENTITY, codesignIdentityArgs, desiredSigningMode, resolveSigningIdentity } from "./codesign.js";
25
26
  import { applyImpelClaudeSandbox } from "./claudeSandbox.js";
@@ -131,12 +132,15 @@ export const PINNED_VENDOR_APPS = Object.freeze({
131
132
  // against this value. updateManifestUrl below is advertisement, read by
132
133
  // the contract test alone and never by the install path.
133
134
  //
134
- // Those two disagree as of 2026-08-18: the manifest advertises
135
- // 26.814.5517.0 while the Store installs 26.814.5167.0. Pin what actually
136
- // installs a pin matching only the advertisement would fail closed on
137
- // every real machine.
138
- packageVersion: "26.814.5167.0",
139
- codexVersion: "0.148.0-alpha.15",
135
+ // Those two disagree again as of 2026-08-20: the manifest advertises
136
+ // 26.818.2872.0 while the disposable-runner Store install registered
137
+ // 26.818.2441.0 (the Store stopped fulfilling 26.814.5167.0 entirely
138
+ // IMP-590). Pin what actually installs — a pin matching only the
139
+ // advertisement would fail closed on every real machine.
140
+ packageVersion: "26.818.2441.0",
141
+ // Verified on the disposable Windows runner: the registered package's
142
+ // embedded codex.exe --version reports exactly this release.
143
+ codexVersion: "0.148.0-alpha.21",
140
144
  publisherId: "2p2nqsd0c76g0",
141
145
  executable: "app\\ChatGPT.exe",
142
146
  updateManifestUrl: "https://persistent.oaistatic.com/codex-app-prod/windows-store-update.json",
@@ -820,14 +824,14 @@ export function migrateLegacyClaudeAppSessions(userData, homeDir = os.homedir())
820
824
  const sourceStat = fs.statSync(source);
821
825
  if (fs.existsSync(destination) && fs.statSync(destination).mtimeMs >= sourceStat.mtimeMs) continue;
822
826
  fs.mkdirSync(destinationDirectory, { recursive: true, mode: 0o700 });
823
- fs.chmodSync(destinationDirectory, 0o700);
827
+ chmodWithPlatformPolicy(destinationDirectory, 0o700);
824
828
  const temporary = `${destination}.tmp-${process.pid}`;
825
829
  try {
826
830
  fs.copyFileSync(source, temporary);
827
- fs.chmodSync(temporary, 0o600);
828
- fs.renameSync(temporary, destination);
831
+ chmodWithPlatformPolicy(temporary, 0o600);
832
+ renameWithWindowsRetry(temporary, destination);
829
833
  fs.utimesSync(destination, sourceStat.atime, sourceStat.mtime);
830
- fs.chmodSync(destination, 0o600);
834
+ chmodWithPlatformPolicy(destination, 0o600);
831
835
  copied += 1;
832
836
  } finally {
833
837
  fs.rmSync(temporary, { force: true });
@@ -3368,13 +3372,13 @@ function isExplicitNoSeatCatalog(payload) {
3368
3372
  function writeAtomic(target, contents, mode) {
3369
3373
  fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
3370
3374
  if (fs.existsSync(target) && fs.readFileSync(target, "utf8") === contents) {
3371
- fs.chmodSync(target, mode);
3375
+ chmodWithPlatformPolicy(target, mode);
3372
3376
  return;
3373
3377
  }
3374
3378
  const tmp = `${target}.tmp-${process.pid}`;
3375
3379
  fs.writeFileSync(tmp, contents, { mode });
3376
- fs.renameSync(tmp, target);
3377
- fs.chmodSync(target, mode);
3380
+ renameWithWindowsRetry(tmp, target);
3381
+ chmodWithPlatformPolicy(target, mode);
3378
3382
  }
3379
3383
 
3380
3384
  function bundleVersion(bundle) {
@@ -120,6 +120,11 @@ export function ensureImpelClaudeProfile(gatewayUrl, tenantId, { crossAppModels
120
120
  ...(settings.env && typeof settings.env === "object" && !Array.isArray(settings.env)
121
121
  ? settings.env
122
122
  : {}),
123
+ // The launcher is shared machine-wide and this session runs the exact
124
+ // reviewed release; a background self-update from inside it would move
125
+ // that shared launcher past the reviewed pin for every later launch.
126
+ // Manual `claude update` keeps working.
127
+ DISABLE_AUTOUPDATER: "1",
123
128
  ANTHROPIC_BASE_URL: crossAppModels
124
129
  ? impelCrossAppClaudeBaseUrl(gatewayUrl)
125
130
  : impelClaudeBaseUrl(gatewayUrl),
@@ -1,6 +1,8 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
 
4
+ import { chmodWithPlatformPolicy } from "./windowsFs.js";
5
+
4
6
  const PRIVATE_ROOT_FILES = ["auth.json", "config.toml", "models.json"];
5
7
  const MANAGED_COMMENT = "# Impel security policy: keep gateway credentials out of shell state.";
6
8
 
@@ -189,10 +191,10 @@ function assertSafeManagedPath(target, expectedType) {
189
191
  * to decide whether the file contains one. Known credential/config files are
190
192
  * owner-only. This function must never be called for the native ~/.codex home.
191
193
  */
192
- export function secureManagedCodexHome(codexHome) {
194
+ export function secureManagedCodexHome(codexHome, { bestEffortChmod = false } = {}) {
193
195
  const existing = assertSafeManagedPath(codexHome, "directory");
194
196
  if (!existing) fs.mkdirSync(codexHome, { recursive: true, mode: 0o700 });
195
- fs.chmodSync(codexHome, 0o700);
197
+ chmodWithPlatformPolicy(codexHome, 0o700, { bestEffort: bestEffortChmod });
196
198
 
197
199
  const snapshots = path.join(codexHome, "shell_snapshots");
198
200
  const snapshotStat = assertSafeManagedPath(snapshots, "directory");
@@ -201,7 +203,7 @@ export function secureManagedCodexHome(codexHome) {
201
203
  const filePath = path.join(codexHome, fileName);
202
204
  const stat = assertSafeManagedPath(filePath, "file");
203
205
  if (!stat) continue;
204
- fs.chmodSync(filePath, 0o600);
206
+ chmodWithPlatformPolicy(filePath, 0o600, { bestEffort: bestEffortChmod });
205
207
  }
206
208
  }
207
209
 
@@ -214,7 +216,12 @@ function secureExistingManagedHome(root, relativeParts, secured) {
214
216
  if (stat.isSymbolicLink()) throw new Error(`${current} must not be a symbolic link.`);
215
217
  if (!stat.isDirectory()) throw new Error(`${current} must be a directory.`);
216
218
  }
217
- secureManagedCodexHome(current);
219
+ // The sweep opportunistically tightens every managed home, including other
220
+ // tenants'. A chmod refusal on a sibling tenant's file must not abort the
221
+ // active tenant's convergence (observed in the field as an all-tenant
222
+ // failure over one tenant's models.json); symlink/type violations above
223
+ // still fail closed because they indicate tampering, not permissions.
224
+ secureManagedCodexHome(current, { bestEffortChmod: true });
218
225
  secured.push(current);
219
226
  }
220
227
 
@@ -1,6 +1,7 @@
1
1
  // Hidden fresh-build continuation used by `impel update`.
2
2
 
3
3
  import { loadConfig, saveConfig, redactSecretText } from "../config.js";
4
+ import { writeConvergenceSummary } from "../convergenceSummary.js";
4
5
  import { runInstallRecovery } from "../installRecovery/engine.js";
5
6
  import {
6
7
  printReconciliationSummary,
@@ -10,6 +11,7 @@ import {
10
11
  import { applyCliUser, fetchTenants } from "../tenants.js";
11
12
  import { promptText } from "../prompt.js";
12
13
  import { describeCliFailure, preparePlatformClis } from "../platformSetup.js";
14
+ import { brandedEnvironmentName } from "../runtimeBrand.js";
13
15
  import { restoreNativeProfiles } from "./use.js";
14
16
  import { createTenantRecoveryReportTracker } from "./tenantRecoveryReport.js";
15
17
  import { resolveSkillCredential } from "../skillBundle.js";
@@ -58,9 +60,22 @@ export async function cmdConverge(argv = [], overrides = {}) {
58
60
  if (overrides.prepareWindowsClis && !overrides.preparePlatformClis) {
59
61
  io.preparePlatformClis = overrides.prepareWindowsClis;
60
62
  }
63
+ // The summary exists solely for the `impel update` parent's failure report;
64
+ // a directly invoked `_converge` (tests, manual runs) writes nothing.
65
+ const cascade = io.environment?.[brandedEnvironmentName("UPDATE_CASCADE")] === "1";
66
+ const leaveSummary = (outcome) => {
67
+ if (!cascade) return;
68
+ (io.writeConvergenceSummary || writeConvergenceSummary)(outcome);
69
+ };
70
+ const summarize = (sharedFailure) => leaveSummary({
71
+ passed: false,
72
+ sharedFailure,
73
+ failedTenants: [],
74
+ });
61
75
  const config = io.loadConfig();
62
76
  if (!config?.pat) {
63
77
  console.error("impel update: not authenticated; run `impel setup` first.");
78
+ summarize("not authenticated");
64
79
  process.exitCode = 1;
65
80
  return false;
66
81
  }
@@ -72,6 +87,7 @@ export async function cmdConverge(argv = [], overrides = {}) {
72
87
  } catch (error) {
73
88
  if (!RETRYABLE_TENANT_DISCOVERY_ERROR.test(String(error?.message || error))) {
74
89
  console.error(`impel update: tenant discovery failed (${redactSecretText(error?.message || error)})`);
90
+ summarize(`tenant discovery failed: ${error?.message || error}`);
75
91
  process.exitCode = 1;
76
92
  return false;
77
93
  }
@@ -81,6 +97,7 @@ export async function cmdConverge(argv = [], overrides = {}) {
81
97
  listing = await io.fetchTenants(config);
82
98
  } catch (retryError) {
83
99
  console.error(`impel update: tenant discovery failed (${redactSecretText(retryError?.message || retryError)})`);
100
+ summarize(`tenant discovery failed after retry: ${retryError?.message || retryError}`);
84
101
  process.exitCode = 1;
85
102
  return false;
86
103
  }
@@ -346,7 +363,17 @@ export async function cmdConverge(argv = [], overrides = {}) {
346
363
  }
347
364
  printReconciliationSummary(report);
348
365
  console.log(`CLI tenant: ${selected.id} (change with \`impel tenant use <tenant>\`)`);
349
- if (!report.passed || sharedFailure) {
366
+ const passed = report.passed && !sharedFailure;
367
+ // Leave the machine-readable outcome for the `impel update` parent, whose
368
+ // auto-filed report otherwise knows nothing beyond this child's exit status.
369
+ leaveSummary({
370
+ passed,
371
+ sharedFailure,
372
+ failedTenants: report.tenants
373
+ .filter((tenant) => tenant.status === "failed")
374
+ .map((tenant) => ({ tenantId: tenant.tenantId, errors: tenant.errors })),
375
+ });
376
+ if (!passed) {
350
377
  if (sharedFailure) console.error(`Shared update failure: ${sharedFailure}`);
351
378
  console.error("impel update: one or more tenant surfaces remain incomplete.");
352
379
  process.exitCode = 1;
@@ -23,7 +23,7 @@ import { impelClaudeBaseUrl, impelCrossAppClaudeBaseUrl } from "../claudeSetup.j
23
23
  import { parentVerbatimRelayAppendix } from "../verbatimRelay.js";
24
24
  import { withGitEnvironment } from "../skills.js";
25
25
  import { assertProviderScopes, ensureTenantSelection, tenantCredential } from "../tenants.js";
26
- import { maybePrintUpdateNotice } from "../updates.js";
26
+ import { installedVersion, maybePrintUpdateNotice } from "../updates.js";
27
27
  import { nativeSpawnInvocation } from "../nativeProcess.js";
28
28
  import { IMPEL_NATIVE_BENCHMARK_ENV } from "../selfInvocation.js";
29
29
  import { resolveReviewedVendorCliBinary } from "../vendorCliBinaries.js";
@@ -392,7 +392,13 @@ async function assertLiveProviderReadiness({ config, gatewayUrl, credential, ten
392
392
  if (!readiness.reachable) {
393
393
  throw new Error(`could not verify live ${label} availability for tenant "${tenantId}" (${readiness.error || "gateway unreachable"})`);
394
394
  }
395
- if (readiness.rejected) throw new Error(`gateway credential was rejected (HTTP ${readiness.status})`);
395
+ if (readiness.rejected) {
396
+ throw new Error(
397
+ `gateway credential was rejected (HTTP ${readiness.status}). `
398
+ + `Your access to tenant "${tenantId}" may have changed; rerun \`${RUNTIME_BRAND.cli.command} setup\` `
399
+ + "to refresh it, or ask a workspace administrator to confirm your gateway seat.",
400
+ );
401
+ }
396
402
  if (!readiness.healthy) {
397
403
  throw new Error(`could not verify live ${label} availability for tenant "${tenantId}" (${readiness.error || `HTTP ${readiness.status}`})`);
398
404
  }
@@ -408,9 +414,13 @@ function runNativeCli(tool, argv, environment) {
408
414
  const binary = resolveReviewedVendorCliBinary(tool, environment);
409
415
  if (!binary) {
410
416
  const version = PINNED_VENDOR_CLI_VERSIONS[tool];
417
+ const repair = process.platform === "win32"
418
+ ? `${RUNTIME_BRAND.cli.command}.cmd update`
419
+ : `${RUNTIME_BRAND.cli.command} update`;
411
420
  console.error(
412
421
  `impel ${tool}: reviewed ${tool === "claude" ? "Claude Code" : "Codex"} CLI `
413
- + `v${version} is not installed. Run \`impel update\` to repair it.`,
422
+ + `v${version} is not installed (an unreviewed or auto-updated build is not launched `
423
+ + `against the gateway). Run \`${repair}\` to repair it.`,
414
424
  );
415
425
  return Promise.resolve(127);
416
426
  }
@@ -547,6 +557,13 @@ export async function cmdLaunch(tool, argv) {
547
557
  // Impel PATs are bearer tokens, so keep the token process-scoped instead of
548
558
  // writing it into Claude's isolated profile.
549
559
  environment.ANTHROPIC_AUTH_TOKEN = gatewayCredential;
560
+ // Version attribution for gateway logs: a 403 in production is currently
561
+ // impossible to correlate to a CLI/vendor build. Appended so a user's own
562
+ // custom headers survive.
563
+ const versionHeader = `X-Impel-Cli-Version: ${RUNTIME_BRAND.cli.packageName}/${installedVersion() || "unknown"}`;
564
+ environment.ANTHROPIC_CUSTOM_HEADERS = environment.ANTHROPIC_CUSTOM_HEADERS
565
+ ? `${environment.ANTHROPIC_CUSTOM_HEADERS}\n${versionHeader}`
566
+ : versionHeader;
550
567
  } else if (tool === "codex") {
551
568
  const profile = ensureImpelCodexProfile(gatewayUrl, tenantId);
552
569
  agentProfile = {
@@ -11,6 +11,10 @@ import { nativeCommandInvocation } from "../nativeProcess.js";
11
11
  import { withProgress } from "../progress.js";
12
12
  import { runInstallRecovery } from "../installRecovery/engine.js";
13
13
  import { reportInstallFailure } from "../autoReport.js";
14
+ import {
15
+ convergenceSummaryDiagnostics,
16
+ readRecentConvergenceSummary,
17
+ } from "../convergenceSummary.js";
14
18
  import { brandedEnvironmentName, brandedText, RUNTIME_BRAND } from "../runtimeBrand.js";
15
19
  import { IMPEL_CLI_ENTRYPOINT } from "../selfInvocation.js";
16
20
  import {
@@ -512,12 +516,11 @@ export async function cmdUpdate(argv, overrides = {}) {
512
516
  console.error("impel update: tenant convergence failed; rerun `impel update` after addressing the reported issue.");
513
517
  // The child is `impel _converge`, which files no report of its own — and
514
518
  // this parent only sees its exit status, because the child inherits stdio
515
- // so its progress reaches the terminal live. So this report carries what
516
- // the parent does know: that it was an update cascade, and which flags
517
- // shaped the run it failed under. Which tenants failed is visible on the
518
- // user's screen but not in the report; closing that gap needs a channel
519
- // between the two processes, which R2 (one report per invocation) says must
520
- // not simply be a second report from the child.
519
+ // so its progress reaches the terminal live. The child does leave a
520
+ // machine-readable summary on disk, though, so the single report R2
521
+ // allows can carry which tenants failed and why instead of only the
522
+ // flags that shaped the run.
523
+ const summary = (io.readConvergenceSummary || readRecentConvergenceSummary)();
521
524
  await reportUpdateFailure({
522
525
  scope: "shared",
523
526
  platform: io.platform,
@@ -528,6 +531,7 @@ export async function cmdUpdate(argv, overrides = {}) {
528
531
  skipApps: String(Boolean(flags["skip-apps"])),
529
532
  skipClis: String(Boolean(flags["skip-clis"])),
530
533
  cascadedEntrypoint: String(Boolean(cascadeEntrypoint)),
534
+ ...convergenceSummaryDiagnostics(summary),
531
535
  },
532
536
  });
533
537
  process.exitCode = 1;
@@ -0,0 +1,84 @@
1
+ // The `impel update` parent spawns `impel _converge` with inherited stdio and
2
+ // only sees its exit status, and R2 allows one report per invocation — so the
3
+ // auto-filed "Tenant convergence failed" report has carried no tenant detail
4
+ // at all. The child instead leaves a machine-readable summary on disk that the
5
+ // parent folds into the diagnostics of the report it was already filing.
6
+
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+
10
+ import { CONFIG_DIR, redactSecretText } from "./config.js";
11
+ import { renameWithWindowsRetry } from "./windowsFs.js";
12
+
13
+ export const CONVERGENCE_SUMMARY_PATH = path.join(CONFIG_DIR, "reports", ".last-convergence.json");
14
+
15
+ // Wide enough to survive install-recovery sessions between the child's write
16
+ // and the parent's read; narrow enough that yesterday's failure never
17
+ // masquerades as today's.
18
+ export const CONVERGENCE_SUMMARY_FRESHNESS_MS = 15 * 60 * 1_000;
19
+
20
+ const MAX_FAILED_TENANTS = 12;
21
+ const MAX_ERRORS_PER_TENANT = 4;
22
+ const MAX_ERROR_LENGTH = 300;
23
+
24
+ /** Best-effort: losing the summary only costs report detail, never the run. */
25
+ export function writeConvergenceSummary({ passed, sharedFailure = null, failedTenants = [] }, {
26
+ summaryPath = CONVERGENCE_SUMMARY_PATH,
27
+ now = Date.now(),
28
+ } = {}) {
29
+ try {
30
+ const summary = {
31
+ writtenAt: new Date(now).toISOString(),
32
+ passed: Boolean(passed),
33
+ sharedFailure: sharedFailure ? redactSecretText(String(sharedFailure)).slice(0, 2_000) : null,
34
+ failedTenants: failedTenants.slice(0, MAX_FAILED_TENANTS).map((tenant) => ({
35
+ tenantId: String(tenant.tenantId || "").slice(0, 128),
36
+ errors: (tenant.errors || [])
37
+ .slice(0, MAX_ERRORS_PER_TENANT)
38
+ .map((error) => redactSecretText(String(error)).slice(0, MAX_ERROR_LENGTH)),
39
+ })),
40
+ };
41
+ fs.mkdirSync(path.dirname(summaryPath), { recursive: true, mode: 0o700 });
42
+ const temporaryPath = `${summaryPath}.tmp-${process.pid}`;
43
+ fs.writeFileSync(temporaryPath, `${JSON.stringify(summary)}\n`, { mode: 0o600 });
44
+ renameWithWindowsRetry(temporaryPath, summaryPath);
45
+ } catch {
46
+ // Deliberately silent: the summary is diagnostics for a report about a
47
+ // failure that has already been announced on the user's terminal.
48
+ }
49
+ }
50
+
51
+ /** Returns the parsed summary only while it is plausibly from this cascade. */
52
+ export function readRecentConvergenceSummary({
53
+ summaryPath = CONVERGENCE_SUMMARY_PATH,
54
+ now = Date.now(),
55
+ } = {}) {
56
+ try {
57
+ const parsed = JSON.parse(fs.readFileSync(summaryPath, "utf8"));
58
+ const writtenAt = Date.parse(parsed?.writtenAt || "");
59
+ if (!Number.isFinite(writtenAt)) return null;
60
+ if (writtenAt > now + 60_000 || now - writtenAt > CONVERGENCE_SUMMARY_FRESHNESS_MS) return null;
61
+ if (typeof parsed.passed !== "boolean" || !Array.isArray(parsed.failedTenants)) return null;
62
+ return parsed;
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ /** Flatten a summary into the bounded string map the report envelope accepts. */
69
+ export function convergenceSummaryDiagnostics(summary) {
70
+ if (!summary || summary.passed) return {};
71
+ const failed = summary.failedTenants.filter((tenant) => tenant?.tenantId);
72
+ return {
73
+ ...(summary.sharedFailure ? { sharedFailure: summary.sharedFailure } : {}),
74
+ ...(failed.length
75
+ ? {
76
+ failedTenantIds: failed.map((tenant) => tenant.tenantId).join(","),
77
+ failedTenantErrors: failed
78
+ .map((tenant) => `${tenant.tenantId}: ${(tenant.errors || []).join("; ")}`)
79
+ .join(" | ")
80
+ .slice(0, 1_800),
81
+ }
82
+ : {}),
83
+ };
84
+ }
@@ -4,7 +4,12 @@ import path from "node:path";
4
4
  import { spawn } from "node:child_process";
5
5
 
6
6
  const TENANT = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/u;
7
- const APPLICATION_BY_TOOL = Object.freeze({ claude: "claude", codex: "chatgpt" });
7
+ const APPLICATIONS_BY_TOOL = Object.freeze({
8
+ claude: ["claude"],
9
+ codex: ["codex", "chatgpt"],
10
+ });
11
+ const MAC_RUNNER = "/Applications/Impel Managed App Runner.app/Contents/Library/LaunchServices/impel-managed-runner";
12
+ const MAC_DESCRIPTOR_ROOT = "/Library/Application Support/Impel/Managed App Runner/descriptors";
8
13
 
9
14
  function regularFile(file) {
10
15
  try {
@@ -15,7 +20,7 @@ function regularFile(file) {
15
20
  }
16
21
  }
17
22
 
18
- function parseCandidate(manifestPath, launcher, tool, platform, home) {
23
+ function parseCandidate(manifestPath, launcher, applicationId, platform, home) {
19
24
  if (!regularFile(manifestPath) || !regularFile(launcher)) return null;
20
25
  let manifest;
21
26
  try {
@@ -25,7 +30,6 @@ function parseCandidate(manifestPath, launcher, tool, platform, home) {
25
30
  } catch {
26
31
  return null;
27
32
  }
28
- const applicationId = APPLICATION_BY_TOOL[tool];
29
33
  const tenantId = manifest?.tenant?.id;
30
34
  const expectedPlatform = platform === "win32" ? "windows" : "macos";
31
35
  if (
@@ -46,11 +50,12 @@ function parseCandidate(manifestPath, launcher, tool, platform, home) {
46
50
  tenantId,
47
51
  tenantDisplayName: typeof manifest.tenant.displayName === "string" ? manifest.tenant.displayName : tenantId,
48
52
  launcher,
53
+ logicalApplicationId: manifest.logicalApplicationId,
49
54
  manifestPath,
50
55
  };
51
56
  }
52
57
 
53
- function macCandidates(tool, home, applicationsRoot) {
58
+ function legacyMacCandidates(tool, home, applicationsRoot) {
54
59
  let entries;
55
60
  try {
56
61
  entries = fs.readdirSync(applicationsRoot, { withFileTypes: true });
@@ -58,13 +63,14 @@ function macCandidates(tool, home, applicationsRoot) {
58
63
  return [];
59
64
  }
60
65
  const candidates = [];
66
+ const applicationId = tool === "codex" ? "chatgpt" : tool;
61
67
  for (const entry of entries) {
62
68
  if (!entry.isDirectory() || !entry.name.endsWith(".app")) continue;
63
69
  const root = path.join(applicationsRoot, entry.name, "Contents");
64
70
  const candidate = parseCandidate(
65
71
  path.join(root, "Resources", "app-manifest.json"),
66
72
  path.join(root, "MacOS", "impel-apps-launcher"),
67
- tool,
73
+ applicationId,
68
74
  "darwin",
69
75
  home,
70
76
  );
@@ -73,6 +79,78 @@ function macCandidates(tool, home, applicationsRoot) {
73
79
  return candidates;
74
80
  }
75
81
 
82
+ function runnerEnvelopeCandidate(envelopePath, launcher, tool, home) {
83
+ if (!regularFile(envelopePath) || !regularFile(launcher)) return null;
84
+ let envelope;
85
+ try {
86
+ const raw = fs.readFileSync(envelopePath, "utf8");
87
+ if (Buffer.byteLength(raw, "utf8") > 512 * 1024) return null;
88
+ envelope = JSON.parse(raw);
89
+ } catch {
90
+ return null;
91
+ }
92
+ const payload = envelope?.payload;
93
+ const tenantId = payload?.tenant?.id;
94
+ if (
95
+ envelope?.schemaVersion !== 1
96
+ || payload?.kind !== "com.useimpel.managed-application-descriptor"
97
+ || payload?.runtimeContract?.entrypoint !== "engine/main.mjs"
98
+ || payload?.application?.id !== tool
99
+ || payload?.application?.adapter !== tool
100
+ || !TENANT.test(tenantId || "")
101
+ || String(tenantId).includes("..")
102
+ || payload?.logicalApplicationId !== `${tenantId}.${tool}`
103
+ ) return null;
104
+ const credential = path.join(home, ".config", "impel", "apps", "credentials", `${tenantId}.json`);
105
+ if (!regularFile(credential)) return null;
106
+ return {
107
+ tenantId,
108
+ tenantDisplayName: typeof payload.tenant.displayName === "string" ? payload.tenant.displayName : tenantId,
109
+ launcher,
110
+ logicalApplicationId: payload.logicalApplicationId,
111
+ managedRunner: true,
112
+ };
113
+ }
114
+
115
+ function managedRunnerMacCandidates(tool, home, descriptorRoot, runnerLauncher) {
116
+ if (!regularFile(runnerLauncher)) return [];
117
+ let entries;
118
+ try {
119
+ entries = fs.readdirSync(descriptorRoot, { withFileTypes: true });
120
+ } catch {
121
+ return [];
122
+ }
123
+ const candidates = [];
124
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
125
+ if (!entry.isDirectory() || !entry.name.endsWith(`.${tool}`)) continue;
126
+ const directory = path.join(descriptorRoot, entry.name);
127
+ let envelopes;
128
+ try {
129
+ envelopes = fs.readdirSync(directory, { withFileTypes: true });
130
+ } catch {
131
+ continue;
132
+ }
133
+ for (const envelope of envelopes.sort((left, right) => left.name.localeCompare(right.name))) {
134
+ if (!envelope.isFile() || !envelope.name.endsWith(".json")) continue;
135
+ const candidate = runnerEnvelopeCandidate(path.join(directory, envelope.name), runnerLauncher, tool, home);
136
+ if (!candidate || candidate.logicalApplicationId !== entry.name) continue;
137
+ candidates.push(candidate);
138
+ break;
139
+ }
140
+ }
141
+ return candidates;
142
+ }
143
+
144
+ function macCandidates(tool, home, applicationsRoot, descriptorRoot, runnerLauncher) {
145
+ const preferred = managedRunnerMacCandidates(tool, home, descriptorRoot, runnerLauncher);
146
+ const legacy = legacyMacCandidates(tool, home, applicationsRoot);
147
+ const byTenant = new Map();
148
+ for (const candidate of [...preferred, ...legacy]) {
149
+ if (!byTenant.has(candidate.tenantId)) byTenant.set(candidate.tenantId, candidate);
150
+ }
151
+ return [...byTenant.values()];
152
+ }
153
+
76
154
  function windowsCandidates(tool, home, applicationsRoot) {
77
155
  const candidates = [];
78
156
  let tenants;
@@ -83,15 +161,19 @@ function windowsCandidates(tool, home, applicationsRoot) {
83
161
  }
84
162
  for (const tenant of tenants) {
85
163
  if (!tenant.isDirectory() || !TENANT.test(tenant.name) || tenant.name.includes("..")) continue;
86
- const root = path.join(applicationsRoot, tenant.name, APPLICATION_BY_TOOL[tool]);
87
- const candidate = parseCandidate(
88
- path.join(root, "resources", "app-manifest.json"),
89
- path.join(root, "impel-apps-launcher.exe"),
90
- tool,
91
- "win32",
92
- home,
93
- );
94
- if (candidate) candidates.push(candidate);
164
+ for (const applicationId of APPLICATIONS_BY_TOOL[tool]) {
165
+ const root = path.join(applicationsRoot, tenant.name, applicationId);
166
+ const candidate = parseCandidate(
167
+ path.join(root, "resources", "app-manifest.json"),
168
+ path.join(root, "impel-apps-launcher.exe"),
169
+ applicationId,
170
+ "win32",
171
+ home,
172
+ );
173
+ if (!candidate) continue;
174
+ candidates.push(candidate);
175
+ break;
176
+ }
95
177
  }
96
178
  return candidates;
97
179
  }
@@ -102,11 +184,15 @@ export function installedManagedAppCandidates(tool, {
102
184
  applicationsRoot = platform === "win32"
103
185
  ? path.join(process.env.ProgramFiles || "C:\\Program Files", "Impel", "Apps")
104
186
  : "/Applications",
187
+ descriptorRoot = MAC_DESCRIPTOR_ROOT,
188
+ runnerLauncher = MAC_RUNNER,
105
189
  } = {}) {
106
- if (!APPLICATION_BY_TOOL[tool]) return [];
190
+ if (!APPLICATIONS_BY_TOOL[tool]) return [];
107
191
  const candidates = platform === "win32"
108
192
  ? windowsCandidates(tool, home, applicationsRoot)
109
- : platform === "darwin" ? macCandidates(tool, home, applicationsRoot) : [];
193
+ : platform === "darwin"
194
+ ? macCandidates(tool, home, applicationsRoot, descriptorRoot, runnerLauncher)
195
+ : [];
110
196
  return candidates.sort((left, right) => left.tenantId.localeCompare(right.tenantId));
111
197
  }
112
198
 
@@ -170,8 +256,11 @@ export function runManagedAppCli(candidate, tool, argv, {
170
256
  cwd = process.cwd(),
171
257
  spawnProcess = spawn,
172
258
  } = {}) {
259
+ const args = candidate.managedRunner === true
260
+ ? ["--descriptor", candidate.logicalApplicationId, "--managed-cli", tool, "--", ...argv]
261
+ : ["--managed-cli", tool, "--", ...argv];
173
262
  return new Promise((resolve) => {
174
- const child = spawnProcess(candidate.launcher, ["--managed-cli", tool, "--", ...argv], {
263
+ const child = spawnProcess(candidate.launcher, args, {
175
264
  cwd,
176
265
  env: environment,
177
266
  stdio: "inherit",
@@ -191,4 +280,3 @@ export function runManagedAppCli(candidate, tool, argv, {
191
280
  });
192
281
  });
193
282
  }
194
-
@@ -6,4 +6,7 @@
6
6
  // managed ChatGPT profiles.
7
7
  // v43 sends tenant-checked issue handoffs from the embedded board to the main
8
8
  // Impel browser app without expanding the capability-scoped desktop session.
9
- export const CURRENT_CONFIG_VERSION = 43;
9
+ // v44 disables the vendor background auto-updater in managed Claude CLI
10
+ // profiles so a session on the reviewed release cannot move the shared
11
+ // launcher past the pin (the Windows drift behind the gateway lockouts).
12
+ export const CURRENT_CONFIG_VERSION = 44;
@@ -55,6 +55,7 @@ export function describeCliFailure(prepared, {
55
55
  || failure
56
56
  || installation?.succeeded === false,
57
57
  );
58
+ const drift = prepared.drifted?.[tool];
58
59
  const reason = failure?.message || failure?.code
59
60
  || (Number.isInteger(failure?.status)
60
61
  ? `exit ${failure.status}`
@@ -64,9 +65,11 @@ export function describeCliFailure(prepared, {
64
65
  ? "automatic installation was declined"
65
66
  : skipClis
66
67
  ? "installation was skipped by --skip-clis"
67
- : !isTTY && prepared.installCommands?.[tool]
68
- ? `interactive confirmation required; rerun \`${cliExecutable} ${commandName}\` interactively (or use \`--skip-clis\` to leave it uninstalled)`
69
- : "not installed or discoverable");
68
+ : drift
69
+ ? `installed v${drift.version} does not match the reviewed v${drift.pinned} this release launches against the gateway`
70
+ : !isTTY && prepared.installCommands?.[tool]
71
+ ? `interactive confirmation required; rerun \`${cliExecutable} ${commandName}\` interactively (or use \`--skip-clis\` to leave it uninstalled)`
72
+ : "not installed or discoverable");
70
73
  details.push(`${labels[tool] || tool}: ${redactSecretText(reason)}`);
71
74
  const command = installation?.command || prepared.installCommands?.[tool];
72
75
  if (attempted && command) details.push(`manual ${tool} recovery command: ${redactSecretText(command)}`);
@@ -8,6 +8,7 @@ import {
8
8
  } from "./selfInvocation.js";
9
9
  import { redactSecretText } from "./config.js";
10
10
  import { brandedEnvironmentName, RUNTIME_BRAND } from "./runtimeBrand.js";
11
+ import { renameWithWindowsRetry } from "./windowsFs.js";
11
12
 
12
13
  /**
13
14
  * Render the stable Windows entry point (`%LOCALAPPDATA%\Impel\bin\impel-entry.cjs`).
@@ -172,7 +173,7 @@ export function ensureWindowsStableEntrypoint({
172
173
  const temporaryPath = `${target}.tmp-${process.pid}`;
173
174
  try {
174
175
  fs.writeFileSync(temporaryPath, content, { mode: 0o700 });
175
- fs.renameSync(temporaryPath, target);
176
+ renameWithWindowsRetry(temporaryPath, target);
176
177
  } finally {
177
178
  try {
178
179
  fs.rmSync(temporaryPath, { force: true });
@@ -5,6 +5,7 @@ import { spawnSync } from "node:child_process";
5
5
  import {
6
6
  environmentValue,
7
7
  findNativeBinary,
8
+ nativeSpawnInvocation,
8
9
  resolveNativeBinary,
9
10
  } from "./nativeProcess.js";
10
11
  import { PINNED_VENDOR_CLI_VERSIONS } from "./vendorCliVersions.js";
@@ -99,9 +100,100 @@ export function verifyReviewedMacVendorCli(
99
100
  }
100
101
  }
101
102
 
103
+ /** Run `<binary> --version` through the platform-safe spawn and parse it. */
104
+ export function windowsVendorCliVersion(binary, environment = process.env, { run = spawnSync } = {}) {
105
+ try {
106
+ const invocation = nativeSpawnInvocation(binary, ["--version"], environment, "win32");
107
+ const result = run(invocation.command, invocation.args, {
108
+ encoding: "utf8",
109
+ env: environment,
110
+ stdio: ["ignore", "pipe", "pipe"],
111
+ timeout: 15_000,
112
+ windowsHide: true,
113
+ windowsVerbatimArguments: invocation.windowsVerbatimArguments,
114
+ });
115
+ if (result?.status !== 0 || result?.error) return null;
116
+ return parseVendorCliVersion(`${result.stdout || ""}\n${result.stderr || ""}`);
117
+ } catch {
118
+ return null;
119
+ }
120
+ }
121
+
102
122
  /**
103
- * Locate the reviewed macOS vendor CLI. On other platforms this intentionally
104
- * preserves the existing native resolution behavior.
123
+ * Accept only the exact reviewed release on Windows. Windows ships no
124
+ * in-box codesign equivalent for these unsigned-shim installs, so the gate
125
+ * is the exact version contract; explicit binary overrides remain
126
+ * authoritative for development and recovery, as on macOS.
127
+ */
128
+ export function verifyReviewedWindowsVendorCli(
129
+ tool,
130
+ binary,
131
+ environment = process.env,
132
+ {
133
+ run = spawnSync,
134
+ versions = PINNED_VENDOR_CLI_VERSIONS,
135
+ environmentPrefix = "IMPEL",
136
+ } = {},
137
+ ) {
138
+ if (!versions[tool]) return false;
139
+ try {
140
+ const override = vendorOverrideName(tool, environmentPrefix);
141
+ const overriddenBinary = override ? environmentValue(environment, override) : null;
142
+ if (overriddenBinary && path.resolve(overriddenBinary) === path.resolve(binary)) return true;
143
+ return windowsVendorCliVersion(binary, environment, { run }) === versions[tool];
144
+ } catch {
145
+ return false;
146
+ }
147
+ }
148
+
149
+ function reviewedWindowsClaudeFallback(environment, environmentPrefix, accept, { find, realpath, stat }) {
150
+ // The native Windows installer keeps releases side by side under
151
+ // .local/share/claude/versions/<release> and its auto-updater replaces the
152
+ // .local/bin launcher in place. When the launcher drifts past the reviewed
153
+ // release, resolve the exact reviewed release directly instead of failing
154
+ // the launch; every candidate still passes the version verification.
155
+ const candidates = [];
156
+ const drifted = find("claude", environment, "win32", environmentPrefix);
157
+ if (drifted) {
158
+ try {
159
+ candidates.push(path.win32.join(
160
+ path.win32.dirname(realpath(drifted)),
161
+ "..", "share", "claude", "versions", PINNED_VENDOR_CLI_VERSIONS.claude,
162
+ ));
163
+ } catch {
164
+ // An unresolvable launcher only forfeits the derived candidate.
165
+ }
166
+ }
167
+ // USERPROFILE gates the fixed-root candidates: it is only set on real
168
+ // Windows sessions, so cross-platform tests injecting fake finders never
169
+ // fall through to a genuine macOS side-by-side install. HOME is probed too
170
+ // because the vendor updater follows HOME when it diverges from USERPROFILE.
171
+ const userProfile = environmentValue(environment, "USERPROFILE");
172
+ if (userProfile) {
173
+ const homes = [userProfile];
174
+ const home = environmentValue(environment, "HOME");
175
+ if (home && home !== userProfile) homes.push(home);
176
+ for (const root of homes) {
177
+ candidates.push(path.win32.join(root, ".local", "share", "claude", "versions", PINNED_VENDOR_CLI_VERSIONS.claude));
178
+ }
179
+ }
180
+ for (const base of candidates) {
181
+ // The release file is stored bare on every platform, but probe the .exe
182
+ // sibling too in case the installer ever normalizes Windows names.
183
+ for (const candidate of [base, `${base}.exe`]) {
184
+ try {
185
+ if (stat(candidate).isFile() && accept(candidate)) return candidate;
186
+ } catch {
187
+ // A missing side-by-side release keeps resolution fail-closed.
188
+ }
189
+ }
190
+ }
191
+ return null;
192
+ }
193
+
194
+ /**
195
+ * Locate the reviewed vendor CLI on macOS and Windows. Other platforms
196
+ * intentionally preserve the existing native resolution behavior.
105
197
  */
106
198
  export function findReviewedVendorCliBinary(
107
199
  tool,
@@ -110,13 +202,17 @@ export function findReviewedVendorCliBinary(
110
202
  environmentPrefix = "IMPEL",
111
203
  {
112
204
  find = findNativeBinary,
113
- verify = verifyReviewedMacVendorCli,
205
+ verify = null,
114
206
  realpath = fs.realpathSync,
115
207
  stat = fs.statSync,
116
208
  } = {},
117
209
  ) {
118
- if (platform !== "darwin") return find(tool, environment, platform, environmentPrefix);
119
- const accept = (binary) => verify(tool, binary, environment, { environmentPrefix });
210
+ if (platform !== "darwin" && platform !== "win32") {
211
+ return find(tool, environment, platform, environmentPrefix);
212
+ }
213
+ const verifier = verify
214
+ || (platform === "win32" ? verifyReviewedWindowsVendorCli : verifyReviewedMacVendorCli);
215
+ const accept = (binary) => verifier(tool, binary, environment, { environmentPrefix });
120
216
  const reviewed = find(tool, environment, platform, environmentPrefix, accept);
121
217
  if (reviewed || tool !== "claude") return reviewed;
122
218
  // An explicit override remains authoritative even while unusable: callers
@@ -124,6 +220,10 @@ export function findReviewedVendorCliBinary(
124
220
  const override = vendorOverrideName(tool, environmentPrefix);
125
221
  if (override && environmentValue(environment, override)) return null;
126
222
 
223
+ if (platform === "win32") {
224
+ return reviewedWindowsClaudeFallback(environment, environmentPrefix, accept, { find, realpath, stat });
225
+ }
226
+
127
227
  // The standalone Claude installer keeps releases side by side under
128
228
  // versions/<release> and its auto-updater only moves the launcher symlink.
129
229
  // When the symlink drifts past the reviewed release, resolve the exact
@@ -153,9 +253,9 @@ export function findReviewedVendorCliBinary(
153
253
  }
154
254
 
155
255
  /**
156
- * Resolve a launch target. macOS deliberately returns null when only an
157
- * unreviewed or version-drifted binary exists so callers can request repair
158
- * instead of silently launching it.
256
+ * Resolve a launch target. macOS and Windows deliberately return null when
257
+ * only an unreviewed or version-drifted binary exists so callers can request
258
+ * repair instead of silently launching it.
159
259
  */
160
260
  export function resolveReviewedVendorCliBinary(
161
261
  tool,
@@ -163,7 +263,7 @@ export function resolveReviewedVendorCliBinary(
163
263
  platform = process.platform,
164
264
  environmentPrefix = "IMPEL",
165
265
  ) {
166
- if (platform !== "darwin") {
266
+ if (platform !== "darwin" && platform !== "win32") {
167
267
  return resolveNativeBinary(tool, environment, platform, environmentPrefix);
168
268
  }
169
269
  return findReviewedVendorCliBinary(tool, environment, platform, environmentPrefix);
package/src/windowsFs.js CHANGED
@@ -42,3 +42,26 @@ export function renameWithWindowsRetry(from, to, {
42
42
  }
43
43
  }
44
44
  }
45
+
46
+ // chmod on Windows only toggles the FAT-era read-only attribute — it cannot
47
+ // deliver the owner-only guarantee the POSIX callers rely on, and it fails
48
+ // EPERM whenever the file is momentarily held by antivirus or carries an
49
+ // inherited deny ACL (observed in the field: `setup.shared_vendor_clis`
50
+ // aborting a whole tenant convergence over chmod of a managed models.json).
51
+ // On POSIX the mode bits are a real security property, so failures there
52
+ // still throw unless the caller explicitly opts into best-effort.
53
+
54
+ /** chmod that swallows Windows failures; POSIX failures throw unless bestEffort. */
55
+ export function chmodWithPlatformPolicy(target, mode, {
56
+ platform = process.platform,
57
+ chmod = fs.chmodSync,
58
+ bestEffort = false,
59
+ } = {}) {
60
+ try {
61
+ chmod(target, mode);
62
+ return true;
63
+ } catch (error) {
64
+ if (platform === "win32" || bestEffort) return false;
65
+ throw error;
66
+ }
67
+ }
@@ -5,9 +5,13 @@ import { ensureImpelClaudeProfile, ensureImpelCodexProfile } from "./cliProfiles
5
5
  import {
6
6
  findNativeBinary,
7
7
  nativeCommandInvocation,
8
- nativeSpawnInvocation,
9
8
  } from "./nativeProcess.js";
10
9
  import { syncSkillsSafe } from "./skills.js";
10
+ import {
11
+ findReviewedVendorCliBinary,
12
+ verifyReviewedWindowsVendorCli,
13
+ windowsVendorCliVersion,
14
+ } from "./vendorCliBinaries.js";
11
15
  import { PINNED_VENDOR_CLI_VERSIONS } from "./vendorCliVersions.js";
12
16
  import { provisionWindowsGit } from "./windowsGit.js";
13
17
 
@@ -47,37 +51,49 @@ export function windowsCliInstallCommands(tools = Object.keys(WINDOWS_CLI_INSTAL
47
51
  }));
48
52
  }
49
53
 
50
- function verifyWindowsCli(_tool, binary, environment) {
51
- try {
52
- const invocation = nativeSpawnInvocation(binary, ["--version"], environment, "win32");
53
- const result = spawnSync(invocation.command, invocation.args, {
54
- encoding: "utf8",
55
- env: environment,
56
- stdio: ["ignore", "pipe", "pipe"],
57
- timeout: 15_000,
58
- windowsHide: true,
59
- windowsVerbatimArguments: invocation.windowsVerbatimArguments,
60
- });
61
- return result?.status === 0 && !result?.error;
62
- } catch {
63
- return false;
64
- }
54
+ function verifyWindowsCli(tool, binary, environment) {
55
+ // The exact reviewed release, matching the macOS contract: a runnable but
56
+ // version-drifted vendor CLI must count as missing so setup/update repair
57
+ // it, instead of launching an unreviewed build against the gateway
58
+ // (observed in the field: Windows Claude auto-updated past the pin and the
59
+ // gateway rejected the session while macOS stayed on the reviewed release).
60
+ return verifyReviewedWindowsVendorCli(tool, binary, environment);
65
61
  }
66
62
 
67
63
  function detectWindowsClis(find, environment, verify) {
68
- const detect = (tool) => find(
69
- tool,
70
- environment,
71
- "win32",
72
- "IMPEL",
73
- (binary) => verify(tool, binary, environment),
74
- );
64
+ // Detection must share the launcher's reviewed resolution (including the
65
+ // side-by-side fallback after vendor auto-update drift); a raw PATH probe
66
+ // here would classify a drifted-but-launchable install as missing and
67
+ // reinstall it on every setup/update pass.
68
+ const detect = (tool) => findReviewedVendorCliBinary(tool, environment, "win32", "IMPEL", {
69
+ find,
70
+ verify: (candidateTool, binary, candidateEnvironment) =>
71
+ verify(candidateTool, binary, candidateEnvironment),
72
+ });
75
73
  return {
76
74
  claude: detect("claude"),
77
75
  codex: detect("codex"),
78
76
  };
79
77
  }
80
78
 
79
+ /**
80
+ * Pin-agnostic probe for the drift diagnostic: which build is actually on
81
+ * this machine when the reviewed detection came back empty.
82
+ */
83
+ function detectDriftedWindowsClis(find, environment, binaries, { versionOf = windowsVendorCliVersion } = {}) {
84
+ const drifted = {};
85
+ for (const tool of ["claude", "codex"]) {
86
+ if (binaries[tool]) continue;
87
+ const binary = find(tool, environment, "win32", "IMPEL");
88
+ if (!binary) continue;
89
+ const version = versionOf(binary, environment);
90
+ if (version && version !== PINNED_VENDOR_CLI_VERSIONS[tool]) {
91
+ drifted[tool] = { binary, version, pinned: PINNED_VENDOR_CLI_VERSIONS[tool] };
92
+ }
93
+ }
94
+ return drifted;
95
+ }
96
+
81
97
  function installCli(tool, { environment, run }) {
82
98
  const installer = WINDOWS_CLI_INSTALLERS[tool];
83
99
  if (!installer) throw new Error(`unsupported Windows CLI installer: ${tool}`);
@@ -132,6 +148,7 @@ export async function prepareWindowsClis({
132
148
  environment: process.env,
133
149
  find: findNativeBinary,
134
150
  verify: verifyWindowsCli,
151
+ versionOf: windowsVendorCliVersion,
135
152
  run: spawnSync,
136
153
  ensureClaudeProfile: ensureImpelClaudeProfile,
137
154
  ensureCodexProfile: ensureImpelCodexProfile,
@@ -164,6 +181,7 @@ export async function prepareWindowsClis({
164
181
  binaries: before,
165
182
  missingBefore,
166
183
  missingAfter: [...missingBefore],
184
+ drifted: detectDriftedWindowsClis(io.find, io.environment, before, { versionOf: io.versionOf }),
167
185
  installAttempted: false,
168
186
  installSucceeded: null,
169
187
  installFailure: null,
@@ -219,6 +237,7 @@ export async function prepareWindowsClis({
219
237
  const binaries = installAttempted
220
238
  ? detectWindowsClis(io.find, io.environment, io.verify)
221
239
  : before;
240
+ const drifted = detectDriftedWindowsClis(io.find, io.environment, binaries, { versionOf: io.versionOf });
222
241
  const claudeProfile = io.ensureClaudeProfile(gatewayUrl, tenantId);
223
242
  const codexProfile = io.ensureCodexProfile(gatewayUrl, tenantId);
224
243
 
@@ -248,6 +267,7 @@ export async function prepareWindowsClis({
248
267
  binaries,
249
268
  missingBefore,
250
269
  missingAfter: Object.entries(binaries).filter(([, binary]) => !binary).map(([tool]) => tool),
270
+ drifted,
251
271
  installAttempted,
252
272
  installSucceeded,
253
273
  installFailure,