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 +47 -0
- package/package.json +1 -1
- package/src/apps.js +17 -13
- package/src/cliProfiles.js +5 -0
- package/src/codexSecurity.js +11 -4
- package/src/commands/converge.js +28 -1
- package/src/commands/launch.js +20 -3
- package/src/commands/update.js +10 -6
- package/src/convergenceSummary.js +84 -0
- package/src/managedApps.js +106 -18
- package/src/managedProfileVersion.js +4 -1
- package/src/platformSetup.js +6 -3
- package/src/stableEntrypoint.js +2 -1
- package/src/vendorCliBinaries.js +109 -9
- package/src/windowsFs.js +23 -0
- package/src/windowsSetup.js +43 -23
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
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-
|
|
135
|
-
// 26.
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
827
|
+
chmodWithPlatformPolicy(destinationDirectory, 0o700);
|
|
824
828
|
const temporary = `${destination}.tmp-${process.pid}`;
|
|
825
829
|
try {
|
|
826
830
|
fs.copyFileSync(source, temporary);
|
|
827
|
-
|
|
828
|
-
|
|
831
|
+
chmodWithPlatformPolicy(temporary, 0o600);
|
|
832
|
+
renameWithWindowsRetry(temporary, destination);
|
|
829
833
|
fs.utimesSync(destination, sourceStat.atime, sourceStat.mtime);
|
|
830
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3377
|
-
|
|
3380
|
+
renameWithWindowsRetry(tmp, target);
|
|
3381
|
+
chmodWithPlatformPolicy(target, mode);
|
|
3378
3382
|
}
|
|
3379
3383
|
|
|
3380
3384
|
function bundleVersion(bundle) {
|
package/src/cliProfiles.js
CHANGED
|
@@ -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),
|
package/src/codexSecurity.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/commands/converge.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/src/commands/launch.js
CHANGED
|
@@ -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)
|
|
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
|
|
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 = {
|
package/src/commands/update.js
CHANGED
|
@@ -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.
|
|
516
|
-
//
|
|
517
|
-
//
|
|
518
|
-
//
|
|
519
|
-
|
|
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
|
+
}
|
package/src/managedApps.js
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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 (!
|
|
190
|
+
if (!APPLICATIONS_BY_TOOL[tool]) return [];
|
|
107
191
|
const candidates = platform === "win32"
|
|
108
192
|
? windowsCandidates(tool, home, applicationsRoot)
|
|
109
|
-
: platform === "darwin"
|
|
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,
|
|
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
|
-
|
|
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;
|
package/src/platformSetup.js
CHANGED
|
@@ -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
|
-
:
|
|
68
|
-
? `
|
|
69
|
-
:
|
|
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)}`);
|
package/src/stableEntrypoint.js
CHANGED
|
@@ -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
|
-
|
|
176
|
+
renameWithWindowsRetry(temporaryPath, target);
|
|
176
177
|
} finally {
|
|
177
178
|
try {
|
|
178
179
|
fs.rmSync(temporaryPath, { force: true });
|
package/src/vendorCliBinaries.js
CHANGED
|
@@ -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
|
-
*
|
|
104
|
-
*
|
|
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 =
|
|
205
|
+
verify = null,
|
|
114
206
|
realpath = fs.realpathSync,
|
|
115
207
|
stat = fs.statSync,
|
|
116
208
|
} = {},
|
|
117
209
|
) {
|
|
118
|
-
if (platform !== "darwin"
|
|
119
|
-
|
|
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
|
|
157
|
-
* unreviewed or version-drifted binary exists so callers can request
|
|
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
|
+
}
|
package/src/windowsSetup.js
CHANGED
|
@@ -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(
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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,
|