impel-cli 0.16.2 → 0.16.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -2
- package/package.json +1 -1
- package/src/apps.js +176 -11
- package/src/commands/apps.js +2 -1
package/README.md
CHANGED
|
@@ -554,8 +554,11 @@ default when personal and Impel sessions should coexist.
|
|
|
554
554
|
Every Impel-managed Claude configuration uses the same strict sandbox defaults:
|
|
555
555
|
the isolated `impel claude` profile, each tenant's Claude Desktop Code profile,
|
|
556
556
|
the native profile while `impel use gateway claude` is active, and branded
|
|
557
|
-
gateway-only CLI profiles. App config version
|
|
558
|
-
|
|
557
|
+
gateway-only CLI profiles. App config version 14 also pins managed Claude
|
|
558
|
+
Desktop profiles to third-party deployment mode, enables the 3P Chat tab so
|
|
559
|
+
Code's "Back to Claude" route has a valid destination, and removes the unusable
|
|
560
|
+
Claude.ai sign-in path. Existing tenant profiles refresh onto these policies
|
|
561
|
+
after the CLI update.
|
|
559
562
|
|
|
560
563
|
### `impel use gateway [claude|codex|all]` (alias `impel on`)
|
|
561
564
|
|
package/package.json
CHANGED
package/src/apps.js
CHANGED
|
@@ -163,7 +163,7 @@ export function managedAppIdentity(target, tenantId = null, tenantName = null) {
|
|
|
163
163
|
|
|
164
164
|
// Bump when the written config/manifest schema changes; a mismatch forces the
|
|
165
165
|
// slow open path (and thus a full config rewrite) after a CLI update.
|
|
166
|
-
export const CURRENT_CONFIG_VERSION =
|
|
166
|
+
export const CURRENT_CONFIG_VERSION = 14;
|
|
167
167
|
|
|
168
168
|
// Identifies the bundle-BUILDING logic — the asar patches, plist rewrites,
|
|
169
169
|
// helper rebranding, and signing. A vendored bundle is rebuilt only when this
|
|
@@ -172,7 +172,7 @@ export const CURRENT_CONFIG_VERSION = 12;
|
|
|
172
172
|
// — which is what made every `impel update` re-trigger macOS permission
|
|
173
173
|
// prompts. Bump this ONLY when a code change alters the bytes of a built
|
|
174
174
|
// bundle; leave it alone for changes that don't touch bundle contents.
|
|
175
|
-
export const BUNDLE_BUILD_FINGERPRINT = "bundle-2026-07-
|
|
175
|
+
export const BUNDLE_BUILD_FINGERPRINT = "bundle-2026-07-19.1";
|
|
176
176
|
|
|
177
177
|
/** Parse the tenant's install manifest, or null when absent/corrupt. */
|
|
178
178
|
export function readTenantManifest(homeDir = os.homedir(), tenantId = null) {
|
|
@@ -207,8 +207,24 @@ export function readBundleCompatibility(launcher) {
|
|
|
207
207
|
* closed, so a routine CLI update that doesn't change bundle bytes must leave
|
|
208
208
|
* the installed bundles — and their macOS permission grants — untouched.
|
|
209
209
|
*/
|
|
210
|
-
export function bundleIsCurrent(status, {
|
|
210
|
+
export function bundleIsCurrent(status, {
|
|
211
|
+
signingMode = desiredSigningMode(),
|
|
212
|
+
platform = process.platform,
|
|
213
|
+
osRelease = os.release(),
|
|
214
|
+
} = {}) {
|
|
211
215
|
if (!status.launcherInstalled || !status.vendorPath || !status.vendorVersion) return false;
|
|
216
|
+
// macOS 26 (Darwin 25) refuses to launch bundles whose CFBundleExecutable is
|
|
217
|
+
// a script (-10669). A wrapper in that state — either target — is broken
|
|
218
|
+
// regardless of its fingerprint; reporting it stale makes the next
|
|
219
|
+
// install/open rebuild it into a launchable layout (e.g. the ChatGPT
|
|
220
|
+
// launch-host layout once a compiler exists), with no fingerprint bump.
|
|
221
|
+
if (
|
|
222
|
+
platform === "darwin"
|
|
223
|
+
&& Number.parseInt(osRelease, 10) >= 25
|
|
224
|
+
&& !wrapperLaunchesOnTahoe(status)
|
|
225
|
+
) {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
212
228
|
const compatibility = readBundleCompatibility(status.launcher);
|
|
213
229
|
return Boolean(
|
|
214
230
|
compatibility
|
|
@@ -238,6 +254,28 @@ export function bundleIsCurrent(status, { signingMode = desiredSigningMode() } =
|
|
|
238
254
|
);
|
|
239
255
|
}
|
|
240
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Whether a wrapper bundle's main executable is one macOS 26 will launch.
|
|
259
|
+
* The ChatGPT wrapper must be on the launch-host layout: its script fallback
|
|
260
|
+
* is exactly what Tahoe refuses (-10669). The Claude wrapper retains the
|
|
261
|
+
* vendor's Mach-O executable, so its only unlaunchable state is that
|
|
262
|
+
* executable going missing — the Mach-O bytes themselves are not probed
|
|
263
|
+
* because test fixtures legitimately stand in scripts for the vendor binary.
|
|
264
|
+
*/
|
|
265
|
+
function wrapperLaunchesOnTahoe(status) {
|
|
266
|
+
try {
|
|
267
|
+
const plist = fs.readFileSync(path.join(status.launcher, "Contents", "Info.plist"), "utf8");
|
|
268
|
+
const executable = plist.match(/<key>CFBundleExecutable<\/key>\s*<string>([^<]+)<\/string>/u)?.[1];
|
|
269
|
+
if (!executable) return false;
|
|
270
|
+
const file = path.join(status.launcher, "Contents", "MacOS", executable);
|
|
271
|
+
if (!fs.existsSync(file)) return false;
|
|
272
|
+
if (status.target === "chatgpt") return executable === "launch-host" && isMachO(file);
|
|
273
|
+
return true;
|
|
274
|
+
} catch {
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
241
279
|
function managedClaudeRendererMatchesCompatibility(status, compatibility) {
|
|
242
280
|
try {
|
|
243
281
|
const renderer = compatibility.rendererAssets?.agentMentions;
|
|
@@ -853,6 +891,17 @@ function writeClaudeConfig(paths, config, models) {
|
|
|
853
891
|
inferenceGatewayAuthScheme: "bearer",
|
|
854
892
|
modelDiscoveryEnabled: false,
|
|
855
893
|
inferenceModels: claudeModels,
|
|
894
|
+
// 3P builds leave Chat unavailable unless the administrator opts in. The
|
|
895
|
+
// Code install/disabled screen's "Back to Claude" link targets the Chat
|
|
896
|
+
// root; without this key the root router selects Code again and strands
|
|
897
|
+
// gateway users in a loop even though inference is healthy.
|
|
898
|
+
chatTabEnabled: true,
|
|
899
|
+
// Managed flat key (maps to authentication.disableClaudeAiSignIn, vendor
|
|
900
|
+
// 1.3834.0+): hides the Claude.ai sign-in option and keeps the app in 3P
|
|
901
|
+
// mode even when the persisted deploymentMode is missing or "1p". Claude.ai
|
|
902
|
+
// account login is a dead end for gateway profiles anyway — the server
|
|
903
|
+
// rejects it with custom_3p_not_available.
|
|
904
|
+
disableDeploymentModeChooser: true,
|
|
856
905
|
};
|
|
857
906
|
writeAtomic(path.join(dir, `${CLAUDE_CONFIG_ID}.json`), JSON.stringify(body, null, 2) + "\n", 0o600);
|
|
858
907
|
writeAtomic(path.join(dir, "_meta.json"), JSON.stringify({
|
|
@@ -895,14 +944,29 @@ function writeClaudeDefaultApp(paths) {
|
|
|
895
944
|
if (!preferences || typeof preferences !== "object" || Array.isArray(preferences)) {
|
|
896
945
|
throw new Error(`Claude desktop preferences must contain a JSON object: ${configPath}`);
|
|
897
946
|
}
|
|
947
|
+
// The desktop app gates its UI on a persisted top-level `deploymentMode`:
|
|
948
|
+
// until it is "3p", every launch lands on the sign-in / provider-chooser
|
|
949
|
+
// screen — even though the applied configLibrary gateway config loads fine —
|
|
950
|
+
// and Claude.ai email login is server-disabled for 3P profiles
|
|
951
|
+
// (custom_3p_not_available). A managed gateway profile is only ever 3P, so
|
|
952
|
+
// enforce the key like the sandbox policy (heal "1p"/cleared values too,
|
|
953
|
+
// e.g. after an in-app sign-out), not seed-once like a user preference.
|
|
954
|
+
const needsDeploymentMode = current.deploymentMode !== "3p";
|
|
898
955
|
// Claude persists the selected top-level app as `preferences.sidebarMode`.
|
|
899
956
|
// Seed Code for a new isolated profile, then leave subsequent user choices
|
|
900
957
|
// alone during install, update, and background config refreshes.
|
|
901
|
-
|
|
902
|
-
|
|
958
|
+
const needsSidebarMode = !Object.hasOwn(preferences, "sidebarMode");
|
|
959
|
+
if (!needsDeploymentMode && !needsSidebarMode) return;
|
|
960
|
+
const next = {
|
|
903
961
|
...current,
|
|
904
|
-
|
|
905
|
-
|
|
962
|
+
...(needsDeploymentMode ? { deploymentMode: "3p" } : {}),
|
|
963
|
+
preferences: needsSidebarMode ? { ...preferences, sidebarMode: "code" } : preferences,
|
|
964
|
+
};
|
|
965
|
+
// A vendor sign-out writes `awaitingSignIn: true` alongside "1p". Left in
|
|
966
|
+
// place it re-arms the app's quit-time revert — which deletes deploymentMode
|
|
967
|
+
// again — should the gateway config ever fail to load. Drop it when healing.
|
|
968
|
+
if (needsDeploymentMode) delete next.awaitingSignIn;
|
|
969
|
+
writeAtomic(configPath, JSON.stringify(next, null, 2) + "\n", 0o600);
|
|
906
970
|
}
|
|
907
971
|
|
|
908
972
|
function writeChatGPTConfig(paths, config, models, vendorCodexModels, invocations = null) {
|
|
@@ -1386,16 +1450,28 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl) {
|
|
|
1386
1450
|
const runtimeAsarPath = path.join(staging, "Contents", "Resources", "app.asar");
|
|
1387
1451
|
const fastModePatchCount = patchFastModeAuthGate(runtimeAsarPath);
|
|
1388
1452
|
const asarHash = asarHeaderHash(runtimeAsarPath);
|
|
1453
|
+
const launchHost = ensureChatGPTLaunchHost();
|
|
1389
1454
|
writeAtomic(
|
|
1390
1455
|
path.join(staging, "Contents", "Info.plist"),
|
|
1391
|
-
vendoredChatGPTWrapperPlist(vendorPath, asarHash, paths.chatgpt),
|
|
1456
|
+
vendoredChatGPTWrapperPlist(vendorPath, asarHash, paths.chatgpt, launchHost ? "launch-host" : "launch"),
|
|
1392
1457
|
0o644,
|
|
1393
1458
|
);
|
|
1459
|
+
// With the Mach-O launch host as the bundle executable, the launch script
|
|
1460
|
+
// lives in Resources (codesign seals it as a resource; anything else in
|
|
1461
|
+
// Contents/MacOS would need its own code signature). Its $HERE/../Resources
|
|
1462
|
+
// references resolve identically from either directory. Without a compiler
|
|
1463
|
+
// the script stays in MacOS as the executable (pre-Tahoe layout).
|
|
1394
1464
|
writeAtomic(
|
|
1395
|
-
path.join(macos, "launch"),
|
|
1465
|
+
path.join(launchHost ? path.join(staging, "Contents", "Resources") : macos, "launch"),
|
|
1396
1466
|
vendoredChatGPTLauncher(paths, vendorBundleName, executableName, gatewayUrl),
|
|
1397
1467
|
0o755,
|
|
1398
1468
|
);
|
|
1469
|
+
if (launchHost) {
|
|
1470
|
+
fs.mkdirSync(macos, { recursive: true });
|
|
1471
|
+
const hostTarget = path.join(macos, "launch-host");
|
|
1472
|
+
fs.copyFileSync(launchHost, hostTarget);
|
|
1473
|
+
fs.chmodSync(hostTarget, 0o755);
|
|
1474
|
+
}
|
|
1399
1475
|
writeAtomic(
|
|
1400
1476
|
path.join(staging, "Contents", "Resources", "impel-chatgpt-preload.cjs"),
|
|
1401
1477
|
vendoredChatGPTDisplayNamePreload(),
|
|
@@ -1779,12 +1855,101 @@ function rebrandElectronHelpers(bundle, { fromName, toName, fromIdentifier, toId
|
|
|
1779
1855
|
}
|
|
1780
1856
|
}
|
|
1781
1857
|
|
|
1782
|
-
|
|
1858
|
+
// macOS 26 (Tahoe) Launch Services refuses to launch a bundle whose
|
|
1859
|
+
// CFBundleExecutable is a script (_LSOpenURLsWithCompletionHandler error
|
|
1860
|
+
// -10669), so the wrapper's main executable must be a real Mach-O. This tiny
|
|
1861
|
+
// host binary exec()s the launch script in Contents/Resources, preserving the
|
|
1862
|
+
// script-based setup flow and the bundle's designated requirement.
|
|
1863
|
+
const CHATGPT_LAUNCH_HOST_SOURCE = `#include <libgen.h>
|
|
1864
|
+
#include <limits.h>
|
|
1865
|
+
#include <mach-o/dyld.h>
|
|
1866
|
+
#include <stdint.h>
|
|
1867
|
+
#include <stdio.h>
|
|
1868
|
+
#include <stdlib.h>
|
|
1869
|
+
#include <string.h>
|
|
1870
|
+
#include <unistd.h>
|
|
1871
|
+
|
|
1872
|
+
int main(int argc, char *argv[]) {
|
|
1873
|
+
char exe[PATH_MAX];
|
|
1874
|
+
uint32_t size = sizeof(exe);
|
|
1875
|
+
if (_NSGetExecutablePath(exe, &size) != 0) return 1;
|
|
1876
|
+
char resolved[PATH_MAX];
|
|
1877
|
+
if (!realpath(exe, resolved)) return 1;
|
|
1878
|
+
char script[PATH_MAX];
|
|
1879
|
+
if (snprintf(script, sizeof(script), "%s/../Resources/launch", dirname(resolved)) >= (int)sizeof(script)) return 1;
|
|
1880
|
+
char **args = calloc((size_t)argc + 1, sizeof(char *));
|
|
1881
|
+
if (!args) return 1;
|
|
1882
|
+
args[0] = script;
|
|
1883
|
+
for (int i = 1; i < argc; i++) args[i] = argv[i];
|
|
1884
|
+
args[argc] = NULL;
|
|
1885
|
+
execv(script, args);
|
|
1886
|
+
perror("execv");
|
|
1887
|
+
return 1;
|
|
1888
|
+
}
|
|
1889
|
+
`;
|
|
1890
|
+
|
|
1891
|
+
/**
|
|
1892
|
+
* Compile (once) and cache the Mach-O launch host for the ChatGPT wrapper.
|
|
1893
|
+
* Returns the cached binary's path, or null when it cannot be built (no
|
|
1894
|
+
* compiler); callers then fall back to the script-only layout. That fallback
|
|
1895
|
+
* still launches on macOS ≤ 15, but macOS 26 refuses it (-10669), so the
|
|
1896
|
+
* failure is reported loudly there instead of silently installing an app
|
|
1897
|
+
* that will never open.
|
|
1898
|
+
*/
|
|
1899
|
+
export function ensureChatGPTLaunchHost({
|
|
1900
|
+
platform = process.platform,
|
|
1901
|
+
osRelease = os.release(),
|
|
1902
|
+
compiler = "/usr/bin/cc",
|
|
1903
|
+
cacheDir = fileURLToPath(new URL("../cache", import.meta.url)),
|
|
1904
|
+
warn = console.warn,
|
|
1905
|
+
} = {}) {
|
|
1906
|
+
if (platform !== "darwin") return null;
|
|
1907
|
+
const sourceHash = crypto.createHash("sha256").update(CHATGPT_LAUNCH_HOST_SOURCE).digest("hex").slice(0, 12);
|
|
1908
|
+
const hostPath = path.join(cacheDir, `launch-host-${sourceHash}`);
|
|
1909
|
+
const failed = (detail) => {
|
|
1910
|
+
// Darwin 25 == macOS 26, the first release that refuses script-executable
|
|
1911
|
+
// bundles; earlier releases launch the fallback layout fine, so stay quiet.
|
|
1912
|
+
if (Number.parseInt(osRelease, 10) >= 25) {
|
|
1913
|
+
warn(
|
|
1914
|
+
`impel: could not compile the Impel ChatGPT launch helper (${redactSecretText(String(detail || "unknown error"))}); `
|
|
1915
|
+
+ "macOS 26 refuses to open the script-based fallback app. Install the Xcode Command Line Tools "
|
|
1916
|
+
+ "(xcode-select --install), then run `impel app update chatgpt`.",
|
|
1917
|
+
);
|
|
1918
|
+
}
|
|
1919
|
+
return null;
|
|
1920
|
+
};
|
|
1921
|
+
const building = path.join(cacheDir, `.launch-host-${sourceHash}.tmp-${process.pid}`);
|
|
1922
|
+
try {
|
|
1923
|
+
if (fs.existsSync(hostPath) && isMachO(hostPath)) return hostPath;
|
|
1924
|
+
fs.mkdirSync(cacheDir, { recursive: true, mode: 0o700 });
|
|
1925
|
+
const source = path.join(os.tmpdir(), `impel-launch-host-${process.pid}.c`);
|
|
1926
|
+
fs.writeFileSync(source, CHATGPT_LAUNCH_HOST_SOURCE, { mode: 0o600 });
|
|
1927
|
+
// Compile to a process-private path and rename into place: concurrent
|
|
1928
|
+
// installs share this cache, and a reader must never see a partial binary.
|
|
1929
|
+
const built = spawnSync(compiler, ["-O2", "-o", building, source], { encoding: "utf8" });
|
|
1930
|
+
fs.rmSync(source, { force: true });
|
|
1931
|
+
if (built.error || built.status !== 0 || !fs.existsSync(building) || !isMachO(building)) {
|
|
1932
|
+
fs.rmSync(building, { force: true });
|
|
1933
|
+
const detail = built.error?.message
|
|
1934
|
+
|| String(built.stderr || "").trim().split("\n")[0]
|
|
1935
|
+
|| `compiler exit code ${built.status}`;
|
|
1936
|
+
return failed(detail);
|
|
1937
|
+
}
|
|
1938
|
+
fs.chmodSync(building, 0o755);
|
|
1939
|
+
fs.renameSync(building, hostPath);
|
|
1940
|
+
return hostPath;
|
|
1941
|
+
} catch (error) {
|
|
1942
|
+
fs.rmSync(building, { force: true });
|
|
1943
|
+
return failed(error?.message);
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
function vendoredChatGPTWrapperPlist(vendorPath, asarHash, identity, executableName = "launch") {
|
|
1783
1948
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
1784
1949
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
1785
1950
|
<plist version="1.0"><dict>
|
|
1786
1951
|
<key>CFBundleDisplayName</key><string>${xmlEscape(identity.displayName)}</string>
|
|
1787
|
-
<key>CFBundleExecutable</key><string
|
|
1952
|
+
<key>CFBundleExecutable</key><string>${xmlEscape(executableName)}</string>
|
|
1788
1953
|
<key>CFBundleIconFile</key><string>electron.icns</string>
|
|
1789
1954
|
<key>CFBundleIdentifier</key><string>${xmlEscape(identity.bundleIdentifier)}</string>
|
|
1790
1955
|
<key>CFBundleName</key><string>${xmlEscape(identity.displayName)}</string>
|
package/src/commands/apps.js
CHANGED
|
@@ -986,13 +986,14 @@ async function refreshApps(targets, { staleOnly = false, tenantId = null } = {})
|
|
|
986
986
|
}
|
|
987
987
|
}
|
|
988
988
|
|
|
989
|
-
function manifestIsFresh(paths, config, now = Date.now()) {
|
|
989
|
+
export function manifestIsFresh(paths, config, now = Date.now()) {
|
|
990
990
|
try {
|
|
991
991
|
const manifest = JSON.parse(
|
|
992
992
|
fs.readFileSync(path.join(paths.tenantRoot, "manifest.json"), "utf8")
|
|
993
993
|
);
|
|
994
994
|
const updatedAt = Date.parse(manifest?.updatedAt || "");
|
|
995
995
|
return Number.isFinite(updatedAt)
|
|
996
|
+
&& manifest?.configVersion === CURRENT_CONFIG_VERSION
|
|
996
997
|
&& manifest?.experiments?.crossAppModels === crossAppModelsEnabled(config)
|
|
997
998
|
&& now - updatedAt < REFRESH_TTL_MS;
|
|
998
999
|
} catch {
|