impel-cli 0.16.1 → 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 +35 -7
- package/package.json +1 -1
- package/src/apps.js +195 -11
- package/src/claudeSandbox.js +80 -0
- package/src/claudeSetup.js +13 -0
- package/src/cliProfiles.js +2 -0
- package/src/commands/apps.js +2 -1
- package/src/commands/use.js +7 -0
- package/src/gateway/index.js +2 -0
package/README.md
CHANGED
|
@@ -551,19 +551,40 @@ impel codex exec --json "review the current diff"
|
|
|
551
551
|
switch their native profiles in place; the isolated launchers are the safer
|
|
552
552
|
default when personal and Impel sessions should coexist.
|
|
553
553
|
|
|
554
|
+
Every Impel-managed Claude configuration uses the same strict sandbox defaults:
|
|
555
|
+
the isolated `impel claude` profile, each tenant's Claude Desktop Code profile,
|
|
556
|
+
the native profile while `impel use gateway claude` is active, and branded
|
|
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.
|
|
562
|
+
|
|
554
563
|
### `impel use gateway [claude|codex|all]` (alias `impel on`)
|
|
555
564
|
|
|
556
565
|
Turns **on** gateway mode for the given tool (default `all`). Requires `impel auth`
|
|
557
566
|
first.
|
|
558
567
|
|
|
559
568
|
**Claude Code** — merges into `~/.claude/settings.json` (creating it if needed,
|
|
560
|
-
leaving
|
|
569
|
+
leaving permissions, hooks, other env vars, and custom sandbox filesystem/network
|
|
570
|
+
rules untouched). Gateway mode enables Claude's strict auto-allow sandbox by
|
|
571
|
+
default: Bash runs inside the OS sandbox without routine prompts, the
|
|
572
|
+
`dangerouslyDisableSandbox` escape hatch is disabled, and supported platforms
|
|
573
|
+
fail closed if isolation cannot initialize. Native Windows keeps startup
|
|
574
|
+
available because Claude's sandbox is supported only on macOS, Linux, and WSL2.
|
|
561
575
|
|
|
562
576
|
```json
|
|
563
577
|
{
|
|
564
578
|
"apiKeyHelper": "impel token",
|
|
565
579
|
"env": {
|
|
566
580
|
"ANTHROPIC_BASE_URL": "https://gateway.useimpel.com/anthropic"
|
|
581
|
+
},
|
|
582
|
+
"sandbox": {
|
|
583
|
+
"enabled": true,
|
|
584
|
+
"failIfUnavailable": true,
|
|
585
|
+
"autoAllowBashIfSandboxed": true,
|
|
586
|
+
"allowUnsandboxedCommands": false,
|
|
587
|
+
"excludedCommands": []
|
|
567
588
|
}
|
|
568
589
|
}
|
|
569
590
|
```
|
|
@@ -597,9 +618,9 @@ refresh_interval_ms = 300000
|
|
|
597
618
|
```
|
|
598
619
|
|
|
599
620
|
Before overwriting anything, `impel` **backs up** what was there (any prior
|
|
600
|
-
`apiKeyHelper
|
|
601
|
-
value) into `~/.config/impel/config.json`, so `impel use account`
|
|
602
|
-
exactly. If `config.toml` already has a `[model_providers.impel]` table that
|
|
621
|
+
`apiKeyHelper`, `ANTHROPIC_BASE_URL`, Claude sandbox policy, and the prior Codex
|
|
622
|
+
`model_provider` value) into `~/.config/impel/config.json`, so `impel use account`
|
|
623
|
+
can restore it exactly. If `config.toml` already has a `[model_providers.impel]` table that
|
|
603
624
|
wasn't written by this tool, `impel` refuses to touch the file and tells you to
|
|
604
625
|
resolve it manually rather than risk emitting invalid/duplicate TOML.
|
|
605
626
|
|
|
@@ -908,7 +929,7 @@ impel status # see where each tool stands right now
|
|
|
908
929
|
```
|
|
909
930
|
|
|
910
931
|
How the clean revert is guaranteed: `impel on` records the pre-existing values
|
|
911
|
-
(your own `apiKeyHelper` / `ANTHROPIC_BASE_URL
|
|
932
|
+
(your own `apiKeyHelper` / `ANTHROPIC_BASE_URL` / Claude sandbox policy, and Codex's prior
|
|
912
933
|
`model_provider`) into `~/.config/impel/config.json` **before** overwriting them,
|
|
913
934
|
and only ever captures a real *account-mode* value (re-running `impel on` never
|
|
914
935
|
clobbers the backup with Impel's own values). `impel off` removes only keys that
|
|
@@ -916,8 +937,9 @@ still point at Impel and restores those backups — so a full `on` → `off`
|
|
|
916
937
|
round-trip leaves `settings.json` and `config.toml` byte-for-byte back to where
|
|
917
938
|
they started, with all your unrelated keys and tables intact.
|
|
918
939
|
|
|
919
|
-
If you'd rather revert by hand: delete `apiKeyHelper
|
|
920
|
-
`ANTHROPIC_BASE_URL
|
|
940
|
+
If you'd rather revert by hand: delete `apiKeyHelper`, the Impel
|
|
941
|
+
`ANTHROPIC_BASE_URL`, and the Impel-managed sandbox keys from
|
|
942
|
+
`~/.claude/settings.json`; delete the block between the
|
|
921
943
|
`# >>> impel-cli managed block ... >>>` / `# <<< impel-cli managed block <<<`
|
|
922
944
|
markers in `~/.codex/config.toml` and reset `model_provider`; and
|
|
923
945
|
`rm -rf ~/.config/impel` to forget the PAT + gateway entirely.
|
|
@@ -969,6 +991,12 @@ OpenAI Responses front door for SDK/Eve traffic and may normalize the body
|
|
|
969
991
|
- https://developers.openai.com/codex/auth
|
|
970
992
|
- https://developers.openai.com/codex/ide
|
|
971
993
|
|
|
994
|
+
## Sources consulted for the Claude sandbox config
|
|
995
|
+
|
|
996
|
+
- https://code.claude.com/docs/en/settings#sandbox-settings
|
|
997
|
+
- https://code.claude.com/docs/en/sandboxing#configure-the-sandbox-for-your-organization
|
|
998
|
+
- https://github.com/anthropics/claude-code/blob/v2.1.205/examples/settings/settings-bash-sandbox.json
|
|
999
|
+
|
|
972
1000
|
## Sources consulted for concurrent tenant apps
|
|
973
1001
|
|
|
974
1002
|
- Electron v43.1.0 release: https://github.com/electron/electron/releases/tag/v43.1.0
|
package/package.json
CHANGED
package/src/apps.js
CHANGED
|
@@ -13,6 +13,7 @@ import { normalizeTenantId } from "./tenants.js";
|
|
|
13
13
|
import { impelCliInvocation } from "./selfInvocation.js";
|
|
14
14
|
import { crossAppModelsEnabled, redactSecretText } from "./config.js";
|
|
15
15
|
import { ADHOC_IDENTITY, codesignIdentityArgs, desiredSigningMode, resolveSigningIdentity } from "./codesign.js";
|
|
16
|
+
import { applyImpelClaudeSandbox } from "./claudeSandbox.js";
|
|
16
17
|
|
|
17
18
|
export const CLAUDE_CONFIG_ID = "1ced0000-0000-4000-8000-000000000001";
|
|
18
19
|
const CHATGPT_CONFIG_START = "# >>> impel app managed gateway >>>";
|
|
@@ -162,7 +163,7 @@ export function managedAppIdentity(target, tenantId = null, tenantName = null) {
|
|
|
162
163
|
|
|
163
164
|
// Bump when the written config/manifest schema changes; a mismatch forces the
|
|
164
165
|
// slow open path (and thus a full config rewrite) after a CLI update.
|
|
165
|
-
export const CURRENT_CONFIG_VERSION =
|
|
166
|
+
export const CURRENT_CONFIG_VERSION = 14;
|
|
166
167
|
|
|
167
168
|
// Identifies the bundle-BUILDING logic — the asar patches, plist rewrites,
|
|
168
169
|
// helper rebranding, and signing. A vendored bundle is rebuilt only when this
|
|
@@ -171,7 +172,7 @@ export const CURRENT_CONFIG_VERSION = 11;
|
|
|
171
172
|
// — which is what made every `impel update` re-trigger macOS permission
|
|
172
173
|
// prompts. Bump this ONLY when a code change alters the bytes of a built
|
|
173
174
|
// bundle; leave it alone for changes that don't touch bundle contents.
|
|
174
|
-
export const BUNDLE_BUILD_FINGERPRINT = "bundle-2026-07-
|
|
175
|
+
export const BUNDLE_BUILD_FINGERPRINT = "bundle-2026-07-19.1";
|
|
175
176
|
|
|
176
177
|
/** Parse the tenant's install manifest, or null when absent/corrupt. */
|
|
177
178
|
export function readTenantManifest(homeDir = os.homedir(), tenantId = null) {
|
|
@@ -206,8 +207,24 @@ export function readBundleCompatibility(launcher) {
|
|
|
206
207
|
* closed, so a routine CLI update that doesn't change bundle bytes must leave
|
|
207
208
|
* the installed bundles — and their macOS permission grants — untouched.
|
|
208
209
|
*/
|
|
209
|
-
export function bundleIsCurrent(status, {
|
|
210
|
+
export function bundleIsCurrent(status, {
|
|
211
|
+
signingMode = desiredSigningMode(),
|
|
212
|
+
platform = process.platform,
|
|
213
|
+
osRelease = os.release(),
|
|
214
|
+
} = {}) {
|
|
210
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
|
+
}
|
|
211
228
|
const compatibility = readBundleCompatibility(status.launcher);
|
|
212
229
|
return Boolean(
|
|
213
230
|
compatibility
|
|
@@ -237,6 +254,28 @@ export function bundleIsCurrent(status, { signingMode = desiredSigningMode() } =
|
|
|
237
254
|
);
|
|
238
255
|
}
|
|
239
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
|
+
|
|
240
279
|
function managedClaudeRendererMatchesCompatibility(status, compatibility) {
|
|
241
280
|
try {
|
|
242
281
|
const renderer = compatibility.rendererAssets?.agentMentions;
|
|
@@ -576,6 +615,7 @@ export function installManagedAppFiles({
|
|
|
576
615
|
if (target === "claude") {
|
|
577
616
|
migrateLegacyClaudeAppSessions(paths.claude.userData, homeDir);
|
|
578
617
|
writeClaudeDefaultApp(paths);
|
|
618
|
+
writeClaudeCodeSettings(paths);
|
|
579
619
|
writeClaudeConfig(paths, config, models);
|
|
580
620
|
}
|
|
581
621
|
else writeChatGPTConfig(paths, config, models, vendorCodexModels, chatgptInvocations);
|
|
@@ -851,6 +891,17 @@ function writeClaudeConfig(paths, config, models) {
|
|
|
851
891
|
inferenceGatewayAuthScheme: "bearer",
|
|
852
892
|
modelDiscoveryEnabled: false,
|
|
853
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,
|
|
854
905
|
};
|
|
855
906
|
writeAtomic(path.join(dir, `${CLAUDE_CONFIG_ID}.json`), JSON.stringify(body, null, 2) + "\n", 0o600);
|
|
856
907
|
writeAtomic(path.join(dir, "_meta.json"), JSON.stringify({
|
|
@@ -859,6 +910,23 @@ function writeClaudeConfig(paths, config, models) {
|
|
|
859
910
|
}, null, 2) + "\n", 0o600);
|
|
860
911
|
}
|
|
861
912
|
|
|
913
|
+
function writeClaudeCodeSettings(paths) {
|
|
914
|
+
const settingsPath = path.join(paths.claude.userData, "settings.json");
|
|
915
|
+
let settings = {};
|
|
916
|
+
if (fs.existsSync(settingsPath)) {
|
|
917
|
+
try {
|
|
918
|
+
settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
|
|
919
|
+
} catch {
|
|
920
|
+
throw new Error(`Claude Code settings are not valid JSON: ${settingsPath}`);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
if (!settings || typeof settings !== "object" || Array.isArray(settings)) {
|
|
924
|
+
throw new Error(`Claude Code settings must contain a JSON object: ${settingsPath}`);
|
|
925
|
+
}
|
|
926
|
+
applyImpelClaudeSandbox(settings);
|
|
927
|
+
writeAtomic(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, 0o600);
|
|
928
|
+
}
|
|
929
|
+
|
|
862
930
|
function writeClaudeDefaultApp(paths) {
|
|
863
931
|
const configPath = path.join(paths.claude.userData, "claude_desktop_config.json");
|
|
864
932
|
let current = {};
|
|
@@ -876,14 +944,29 @@ function writeClaudeDefaultApp(paths) {
|
|
|
876
944
|
if (!preferences || typeof preferences !== "object" || Array.isArray(preferences)) {
|
|
877
945
|
throw new Error(`Claude desktop preferences must contain a JSON object: ${configPath}`);
|
|
878
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";
|
|
879
955
|
// Claude persists the selected top-level app as `preferences.sidebarMode`.
|
|
880
956
|
// Seed Code for a new isolated profile, then leave subsequent user choices
|
|
881
957
|
// alone during install, update, and background config refreshes.
|
|
882
|
-
|
|
883
|
-
|
|
958
|
+
const needsSidebarMode = !Object.hasOwn(preferences, "sidebarMode");
|
|
959
|
+
if (!needsDeploymentMode && !needsSidebarMode) return;
|
|
960
|
+
const next = {
|
|
884
961
|
...current,
|
|
885
|
-
|
|
886
|
-
|
|
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);
|
|
887
970
|
}
|
|
888
971
|
|
|
889
972
|
function writeChatGPTConfig(paths, config, models, vendorCodexModels, invocations = null) {
|
|
@@ -1367,16 +1450,28 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl) {
|
|
|
1367
1450
|
const runtimeAsarPath = path.join(staging, "Contents", "Resources", "app.asar");
|
|
1368
1451
|
const fastModePatchCount = patchFastModeAuthGate(runtimeAsarPath);
|
|
1369
1452
|
const asarHash = asarHeaderHash(runtimeAsarPath);
|
|
1453
|
+
const launchHost = ensureChatGPTLaunchHost();
|
|
1370
1454
|
writeAtomic(
|
|
1371
1455
|
path.join(staging, "Contents", "Info.plist"),
|
|
1372
|
-
vendoredChatGPTWrapperPlist(vendorPath, asarHash, paths.chatgpt),
|
|
1456
|
+
vendoredChatGPTWrapperPlist(vendorPath, asarHash, paths.chatgpt, launchHost ? "launch-host" : "launch"),
|
|
1373
1457
|
0o644,
|
|
1374
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).
|
|
1375
1464
|
writeAtomic(
|
|
1376
|
-
path.join(macos, "launch"),
|
|
1465
|
+
path.join(launchHost ? path.join(staging, "Contents", "Resources") : macos, "launch"),
|
|
1377
1466
|
vendoredChatGPTLauncher(paths, vendorBundleName, executableName, gatewayUrl),
|
|
1378
1467
|
0o755,
|
|
1379
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
|
+
}
|
|
1380
1475
|
writeAtomic(
|
|
1381
1476
|
path.join(staging, "Contents", "Resources", "impel-chatgpt-preload.cjs"),
|
|
1382
1477
|
vendoredChatGPTDisplayNamePreload(),
|
|
@@ -1760,12 +1855,101 @@ function rebrandElectronHelpers(bundle, { fromName, toName, fromIdentifier, toId
|
|
|
1760
1855
|
}
|
|
1761
1856
|
}
|
|
1762
1857
|
|
|
1763
|
-
|
|
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") {
|
|
1764
1948
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
1765
1949
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
1766
1950
|
<plist version="1.0"><dict>
|
|
1767
1951
|
<key>CFBundleDisplayName</key><string>${xmlEscape(identity.displayName)}</string>
|
|
1768
|
-
<key>CFBundleExecutable</key><string
|
|
1952
|
+
<key>CFBundleExecutable</key><string>${xmlEscape(executableName)}</string>
|
|
1769
1953
|
<key>CFBundleIconFile</key><string>electron.icns</string>
|
|
1770
1954
|
<key>CFBundleIdentifier</key><string>${xmlEscape(identity.bundleIdentifier)}</string>
|
|
1771
1955
|
<key>CFBundleName</key><string>${xmlEscape(identity.displayName)}</string>
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
const MANAGED_SANDBOX_KEYS = Object.freeze([
|
|
2
|
+
"enabled",
|
|
3
|
+
"failIfUnavailable",
|
|
4
|
+
"autoAllowBashIfSandboxed",
|
|
5
|
+
"allowUnsandboxedCommands",
|
|
6
|
+
"excludedCommands",
|
|
7
|
+
]);
|
|
8
|
+
|
|
9
|
+
function isJsonObject(value) {
|
|
10
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function sameJsonValue(left, right) {
|
|
14
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Claude Code's strict, auto-allow sandbox profile.
|
|
19
|
+
*
|
|
20
|
+
* Native Windows does not implement Claude's sandbox. Keep it enabled so the
|
|
21
|
+
* same profile becomes effective in WSL, but do not make native Windows fail
|
|
22
|
+
* at startup solely because the platform cannot initialize it.
|
|
23
|
+
*/
|
|
24
|
+
export function impelClaudeSandboxPolicy(platform = process.platform) {
|
|
25
|
+
return {
|
|
26
|
+
enabled: true,
|
|
27
|
+
failIfUnavailable: platform !== "win32",
|
|
28
|
+
autoAllowBashIfSandboxed: true,
|
|
29
|
+
allowUnsandboxedCommands: false,
|
|
30
|
+
excludedCommands: [],
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Apply the managed sandbox keys while preserving unrelated sandbox config. */
|
|
35
|
+
export function applyImpelClaudeSandbox(settings, platform = process.platform) {
|
|
36
|
+
const current = isJsonObject(settings.sandbox) ? settings.sandbox : {};
|
|
37
|
+
settings.sandbox = {
|
|
38
|
+
...current,
|
|
39
|
+
...impelClaudeSandboxPolicy(platform),
|
|
40
|
+
};
|
|
41
|
+
return settings.sandbox;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Capture the exact pre-Impel value for reversible native-profile switching. */
|
|
45
|
+
export function captureClaudeSandbox(settings) {
|
|
46
|
+
return Object.hasOwn(settings, "sandbox")
|
|
47
|
+
? { present: true, value: settings.sandbox }
|
|
48
|
+
: { present: false };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Restore only managed keys that still hold Impel's values. User changes made
|
|
53
|
+
* while gateway mode was active, plus unrelated filesystem/network keys, win.
|
|
54
|
+
*/
|
|
55
|
+
export function restoreClaudeSandbox(settings, snapshot, platform = process.platform) {
|
|
56
|
+
if (!isJsonObject(settings.sandbox)) return false;
|
|
57
|
+
const policy = impelClaudeSandboxPolicy(platform);
|
|
58
|
+
const prior = snapshot?.present === true && isJsonObject(snapshot.value)
|
|
59
|
+
? snapshot.value
|
|
60
|
+
: null;
|
|
61
|
+
const sandbox = { ...settings.sandbox };
|
|
62
|
+
let changed = false;
|
|
63
|
+
|
|
64
|
+
for (const key of MANAGED_SANDBOX_KEYS) {
|
|
65
|
+
if (!Object.hasOwn(sandbox, key) || !sameJsonValue(sandbox[key], policy[key])) continue;
|
|
66
|
+
if (prior && Object.hasOwn(prior, key)) sandbox[key] = prior[key];
|
|
67
|
+
else delete sandbox[key];
|
|
68
|
+
changed = true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!changed) return false;
|
|
72
|
+
if (Object.keys(sandbox).length > 0 || (snapshot?.present === true && prior)) {
|
|
73
|
+
settings.sandbox = sandbox;
|
|
74
|
+
} else if (snapshot?.present === true) {
|
|
75
|
+
settings.sandbox = snapshot.value;
|
|
76
|
+
} else {
|
|
77
|
+
delete settings.sandbox;
|
|
78
|
+
}
|
|
79
|
+
return true;
|
|
80
|
+
}
|
package/src/claudeSetup.js
CHANGED
|
@@ -10,6 +10,11 @@ import os from "node:os";
|
|
|
10
10
|
import path from "node:path";
|
|
11
11
|
|
|
12
12
|
import { IMPEL_MANAGED_MCP_ENV, impelMcpInvocation } from "./selfInvocation.js";
|
|
13
|
+
import {
|
|
14
|
+
applyImpelClaudeSandbox,
|
|
15
|
+
captureClaudeSandbox,
|
|
16
|
+
restoreClaudeSandbox,
|
|
17
|
+
} from "./claudeSandbox.js";
|
|
13
18
|
|
|
14
19
|
export const CLAUDE_DIR = path.join(os.homedir(), ".claude");
|
|
15
20
|
export const CLAUDE_SETTINGS_PATH = path.join(CLAUDE_DIR, "settings.json");
|
|
@@ -123,12 +128,14 @@ export function applyClaudeGateway(gatewayUrl) {
|
|
|
123
128
|
|
|
124
129
|
const priorApiKeyHelper = settings.apiKeyHelper;
|
|
125
130
|
const priorBaseUrl = settings.env?.ANTHROPIC_BASE_URL;
|
|
131
|
+
const priorSandbox = captureClaudeSandbox(settings);
|
|
126
132
|
|
|
127
133
|
settings.apiKeyHelper = IMPEL_API_KEY_HELPER;
|
|
128
134
|
settings.env = {
|
|
129
135
|
...(settings.env || {}),
|
|
130
136
|
ANTHROPIC_BASE_URL: impelClaudeBaseUrl(gatewayUrl),
|
|
131
137
|
};
|
|
138
|
+
const sandbox = applyImpelClaudeSandbox(settings);
|
|
132
139
|
|
|
133
140
|
writeSettings(settings);
|
|
134
141
|
userConfig.mcpServers = {
|
|
@@ -143,6 +150,8 @@ export function applyClaudeGateway(gatewayUrl) {
|
|
|
143
150
|
baseUrl: settings.env.ANTHROPIC_BASE_URL,
|
|
144
151
|
priorApiKeyHelper,
|
|
145
152
|
priorBaseUrl,
|
|
153
|
+
priorSandbox,
|
|
154
|
+
sandbox,
|
|
146
155
|
};
|
|
147
156
|
}
|
|
148
157
|
|
|
@@ -179,6 +188,9 @@ export function revertClaudeGateway(gatewayUrl, backup = {}) {
|
|
|
179
188
|
changed = true;
|
|
180
189
|
}
|
|
181
190
|
|
|
191
|
+
const restoredSandbox = restoreClaudeSandbox(settings, backup.sandbox);
|
|
192
|
+
if (restoredSandbox) changed = true;
|
|
193
|
+
|
|
182
194
|
if (changed) writeSettings(settings);
|
|
183
195
|
|
|
184
196
|
if (isImpelMcpServer(userConfig.mcpServers?.[IMPEL_MCP_SERVER_NAME])) {
|
|
@@ -201,6 +213,7 @@ export function revertClaudeGateway(gatewayUrl, backup = {}) {
|
|
|
201
213
|
removedHelper,
|
|
202
214
|
removedBaseUrl,
|
|
203
215
|
removedMcpServer,
|
|
216
|
+
restoredSandbox,
|
|
204
217
|
restoredApiKeyHelper: removedHelper && backup.apiKeyHelper != null ? backup.apiKeyHelper : null,
|
|
205
218
|
restoredBaseUrl: removedBaseUrl && backup.ANTHROPIC_BASE_URL != null ? backup.ANTHROPIC_BASE_URL : null,
|
|
206
219
|
};
|
package/src/cliProfiles.js
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
import { CONFIG_DIR } from "./config.js";
|
|
12
12
|
import { normalizeTenantId } from "./tenants.js";
|
|
13
13
|
import { impelCliInvocation, impelMcpInvocation } from "./selfInvocation.js";
|
|
14
|
+
import { applyImpelClaudeSandbox } from "./claudeSandbox.js";
|
|
14
15
|
|
|
15
16
|
export const IMPEL_CLI_PROFILES_DIR = path.join(CONFIG_DIR, "cli");
|
|
16
17
|
|
|
@@ -118,6 +119,7 @@ export function ensureImpelClaudeProfile(gatewayUrl, tenantId, { crossAppModels
|
|
|
118
119
|
delete managedEnvironment.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY;
|
|
119
120
|
}
|
|
120
121
|
settings.env = managedEnvironment;
|
|
122
|
+
applyImpelClaudeSandbox(settings);
|
|
121
123
|
userConfig.mcpServers = {
|
|
122
124
|
...(userConfig.mcpServers &&
|
|
123
125
|
typeof userConfig.mcpServers === "object" &&
|
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 {
|
package/src/commands/use.js
CHANGED
|
@@ -76,9 +76,13 @@ async function useGateway({ target, app }) {
|
|
|
76
76
|
if (!isImpelClaudeBaseUrl(result.priorBaseUrl, gatewayUrl)) {
|
|
77
77
|
backup.ANTHROPIC_BASE_URL = result.priorBaseUrl ?? null;
|
|
78
78
|
}
|
|
79
|
+
if (!Object.hasOwn(backup, "sandbox")) {
|
|
80
|
+
backup.sandbox = result.priorSandbox;
|
|
81
|
+
}
|
|
79
82
|
console.log(`Claude Code -> GATEWAY (${result.path})`);
|
|
80
83
|
console.log(` apiKeyHelper = "${result.apiKeyHelper}"`);
|
|
81
84
|
console.log(` env.ANTHROPIC_BASE_URL = "${result.baseUrl}"`);
|
|
85
|
+
console.log(` sandbox = strict auto-allow (${result.sandbox.failIfUnavailable ? "fail closed" : "platform fallback"})`);
|
|
82
86
|
console.log(` MCP server = "impel" (direct Impel CLI invocation)`);
|
|
83
87
|
}
|
|
84
88
|
|
|
@@ -167,6 +171,9 @@ function useAccount({ target, app }) {
|
|
|
167
171
|
if (result.removedMcpServer) {
|
|
168
172
|
console.log(` removed Impel MCP server`);
|
|
169
173
|
}
|
|
174
|
+
if (result.restoredSandbox) {
|
|
175
|
+
console.log(` restored prior Claude sandbox settings`);
|
|
176
|
+
}
|
|
170
177
|
}
|
|
171
178
|
if (config?.backups) delete config.backups.claude;
|
|
172
179
|
}
|
package/src/gateway/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import path from "node:path";
|
|
|
5
5
|
import readline from "node:readline";
|
|
6
6
|
|
|
7
7
|
import { nativeCommandInvocation } from "../nativeProcess.js";
|
|
8
|
+
import { applyImpelClaudeSandbox } from "../claudeSandbox.js";
|
|
8
9
|
|
|
9
10
|
const SAFE_ID = /^[a-z][a-z0-9-]{2,31}$/u;
|
|
10
11
|
const SAFE_NAMESPACE = /^[a-z][a-z0-9_-]{1,31}$/u;
|
|
@@ -301,6 +302,7 @@ export function createGatewayCli(options) {
|
|
|
301
302
|
};
|
|
302
303
|
delete settings.env.ANTHROPIC_API_KEY;
|
|
303
304
|
delete settings.env.ANTHROPIC_AUTH_TOKEN;
|
|
305
|
+
applyImpelClaudeSandbox(settings);
|
|
304
306
|
writePrivateFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
305
307
|
return { configDir, settingsPath };
|
|
306
308
|
}
|