impel-cli 0.13.1 → 0.14.0
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 +38 -3
- package/package.json +4 -1
- package/src/apps.js +173 -4
- package/src/commands/apps.js +12 -0
- package/src/gateway/index.js +454 -0
- package/src/nativeProcess.js +27 -11
- package/src/windowsApps.js +8 -2
package/README.md
CHANGED
|
@@ -25,6 +25,26 @@ of the protected Store directory and launches it with tenant-specific Codex and
|
|
|
25
25
|
browser profiles. Each approach keeps the user's normal app profile and
|
|
26
26
|
signed-in account untouched.
|
|
27
27
|
|
|
28
|
+
## Gateway-only vendor package
|
|
29
|
+
|
|
30
|
+
White-labelled gateway launchers import the deliberately narrow
|
|
31
|
+
`impel-cli/gateway` subpath. It exports only `createGatewayCli`; the resulting
|
|
32
|
+
CLI accepts `setup`, `auth`, `claude`, `codex`, `status`, `token`, help, and
|
|
33
|
+
version. Tasks, tenants, PAT minting, apps, agents, skills, MCP, updates, and
|
|
34
|
+
other Impel control-plane commands are not part of this package surface.
|
|
35
|
+
|
|
36
|
+
```js
|
|
37
|
+
import { createGatewayCli } from "impel-cli/gateway";
|
|
38
|
+
|
|
39
|
+
const cli = createGatewayCli({ brand, entrypoint, version });
|
|
40
|
+
const exitCode = await cli.main(process.argv.slice(2));
|
|
41
|
+
if (exitCode) process.exitCode = exitCode;
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The caller supplies a validated branding document, its own executable path,
|
|
45
|
+
and its package version. Gateway profile behavior and the command allowlist
|
|
46
|
+
remain in `impel-cli`, so vendor CLIs do not copy or fork those implementations.
|
|
47
|
+
|
|
28
48
|
## Start here
|
|
29
49
|
|
|
30
50
|
You do not need GitHub access to install Impel. You need an Impel account and a
|
|
@@ -642,7 +662,7 @@ Invocation uses the clients' native agent behavior:
|
|
|
642
662
|
|
|
643
663
|
```text
|
|
644
664
|
# Claude Code / Claude Desktop Code tab: guaranteed explicit selection
|
|
645
|
-
@
|
|
665
|
+
@research-agent investigate the dependency change
|
|
646
666
|
|
|
647
667
|
# Claude blocking CLI
|
|
648
668
|
claude --agent research-agent -p "investigate the dependency change"
|
|
@@ -715,8 +735,11 @@ Their visible bundle names use the organization display name, for example
|
|
|
715
735
|
a stable, unique bundle identifier and gateway configuration:
|
|
716
736
|
|
|
717
737
|
- Impel Claude is an APFS-cloned vendored copy of the official app with its own
|
|
718
|
-
bundle and helper identities. Its LaunchServices environment sets the
|
|
719
|
-
vendor-supported `CLAUDE_USER_DATA_DIR` and
|
|
738
|
+
bundle and helper identities. Its LaunchServices environment sets both the
|
|
739
|
+
vendor-supported `CLAUDE_USER_DATA_DIR` and Claude Code's
|
|
740
|
+
`CLAUDE_CONFIG_DIR` to the tenant-private profile. This isolates browser
|
|
741
|
+
state, sessions, plugins, MCP servers, skills, and native `@` agents from
|
|
742
|
+
`~/.claude`, while the app writes the 3P gateway
|
|
720
743
|
`configLibrary` below `~/.config/impel/apps/tenants/<org>/claude`. A new
|
|
721
744
|
isolated profile defaults to the Code app while preserving any app selection
|
|
722
745
|
the user makes afterward. A narrowly version-checked compatibility patch
|
|
@@ -818,6 +841,18 @@ available: `IMPEL_CODESIGN_ADHOC=1` forces ad-hoc signing, and
|
|
|
818
841
|
`IMPEL_CODESIGN_IDENTITY=<name>` signs with a specific existing identity (e.g. a
|
|
819
842
|
real `Developer ID Application: …`).
|
|
820
843
|
|
|
844
|
+
Older Claude profiles already encrypt browser data with the macOS login
|
|
845
|
+
Keychain item named `Claude Safe Storage`. Impel keeps that namespace immutable
|
|
846
|
+
so an update never makes existing cookies or storage unreadable. Because each
|
|
847
|
+
tenant app has a distinct bundle identity, macOS asks once before that app may
|
|
848
|
+
use the legacy item: enter the Mac login password and choose **Always Allow**.
|
|
849
|
+
Choosing **Allow** grants access for only that launch and therefore prompts
|
|
850
|
+
again next time. The stable Impel signature keeps a successful Always Allow
|
|
851
|
+
grant valid across later app updates. Clean profiles instead start with a
|
|
852
|
+
persisted tenant-specific Safe Storage namespace, so they never request access
|
|
853
|
+
to the shared vendor item. Impel does not weaken or silently rewrite Keychain
|
|
854
|
+
access lists.
|
|
855
|
+
|
|
821
856
|
The Claude 3P config necessarily contains the PAT because that app accepts a
|
|
822
857
|
gateway API key rather than a token-helper command. It is stored in the same
|
|
823
858
|
owner-only Impel config tree as the primary credential. ChatGPT/Codex uses a
|
package/package.json
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "impel-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Configure Claude Code and Codex CLI to talk to Impel's gateway, authenticated by an Impel Personal Access Token",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"impel": "bin/impel.js"
|
|
8
8
|
},
|
|
9
|
+
"exports": {
|
|
10
|
+
"./gateway": "./src/gateway/index.js"
|
|
11
|
+
},
|
|
9
12
|
"files": [
|
|
10
13
|
"bin",
|
|
11
14
|
"src",
|
package/src/apps.js
CHANGED
|
@@ -83,6 +83,8 @@ const CLAUDE_PLAN_USAGE_GUARD = "if(!Ze().hasOrgPolicyBackend())return;if((r=fr(
|
|
|
83
83
|
const IMPEL_CLAUDE_PLAN_USAGE_GUARD = `${"if(!wze())return;".padEnd("if(!Ze().hasOrgPolicyBackend())return;".length, " ")}if((r=fr())`;
|
|
84
84
|
const CLAUDE_PLAN_USAGE_REQUEST = 'P.net.fetch(`${ht()}/api/organizations/${o}/usage`,{signal:AbortSignal.timeout(mcr)})';
|
|
85
85
|
const IMPEL_CLAUDE_PLAN_USAGE_REQUEST = 'P.net.fetch("https://gateway.useimpel.com/ui/a/u",{headers:{key:dR(Oe()).apiKey}})'.padEnd(CLAUDE_PLAN_USAGE_REQUEST.length, " ");
|
|
86
|
+
const LEGACY_CLAUDE_SAFE_STORAGE_NAME = "Claude";
|
|
87
|
+
const CLAUDE_SAFE_STORAGE_METADATA = "safe-storage.json";
|
|
86
88
|
|
|
87
89
|
export const FALLBACK_MODELS = [
|
|
88
90
|
{ id: "claude-opus-4-8", provider: "claude", display_name: "Claude Opus 4.8", family: "opus", family_default: true, default: true, context_window: 200000 },
|
|
@@ -152,7 +154,7 @@ export const CURRENT_CONFIG_VERSION = 10;
|
|
|
152
154
|
// — which is what made every `impel update` re-trigger macOS permission
|
|
153
155
|
// prompts. Bump this ONLY when a code change alters the bytes of a built
|
|
154
156
|
// bundle; leave it alone for changes that don't touch bundle contents.
|
|
155
|
-
export const BUNDLE_BUILD_FINGERPRINT = "bundle-2026-07-16.
|
|
157
|
+
export const BUNDLE_BUILD_FINGERPRINT = "bundle-2026-07-16.3";
|
|
156
158
|
|
|
157
159
|
/** Parse the tenant's install manifest, or null when absent/corrupt. */
|
|
158
160
|
export function readTenantManifest(homeDir = os.homedir(), tenantId = null) {
|
|
@@ -352,6 +354,130 @@ export function appPaths(homeDir = os.homedir(), tenantId = null, {
|
|
|
352
354
|
};
|
|
353
355
|
}
|
|
354
356
|
|
|
357
|
+
function readClaudeSafeStorageMetadata(paths) {
|
|
358
|
+
const metadataPath = path.join(paths.claude.root, CLAUDE_SAFE_STORAGE_METADATA);
|
|
359
|
+
try {
|
|
360
|
+
const metadata = JSON.parse(fs.readFileSync(metadataPath, "utf8"));
|
|
361
|
+
if (
|
|
362
|
+
metadata?.schemaVersion !== 1
|
|
363
|
+
|| typeof metadata.appName !== "string"
|
|
364
|
+
|| !metadata.appName.trim()
|
|
365
|
+
|| metadata.appName.length > 256
|
|
366
|
+
) {
|
|
367
|
+
throw new Error("invalid contents");
|
|
368
|
+
}
|
|
369
|
+
return metadata;
|
|
370
|
+
} catch (error) {
|
|
371
|
+
if (error?.code === "ENOENT") return null;
|
|
372
|
+
throw new Error(`Claude Safe Storage metadata is invalid: ${metadataPath}`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Electron keys macOS Safe Storage by app.name. Existing profiles already have
|
|
378
|
+
* encrypted data under `Claude Safe Storage`, so changing their name would make
|
|
379
|
+
* that data unreadable. New profiles can safely start with an immutable,
|
|
380
|
+
* tenant-specific namespace and never touch the vendor/shared Keychain item.
|
|
381
|
+
*/
|
|
382
|
+
export function ensureClaudeSafeStorageName(paths, tenantId = null) {
|
|
383
|
+
const existing = readClaudeSafeStorageMetadata(paths);
|
|
384
|
+
if (existing) return existing.appName;
|
|
385
|
+
const hasExistingProfile = fs.existsSync(paths.claude.userData)
|
|
386
|
+
&& fs.readdirSync(paths.claude.userData).length > 0;
|
|
387
|
+
const appName = hasExistingProfile
|
|
388
|
+
? LEGACY_CLAUDE_SAFE_STORAGE_NAME
|
|
389
|
+
: `Impel Claude${tenantId ? ` [${normalizeTenantId(tenantId)}]` : ""}`;
|
|
390
|
+
writeAtomic(path.join(paths.claude.root, CLAUDE_SAFE_STORAGE_METADATA), `${JSON.stringify({
|
|
391
|
+
schemaVersion: 1,
|
|
392
|
+
appName,
|
|
393
|
+
mode: hasExistingProfile ? "legacy" : "tenant",
|
|
394
|
+
}, null, 2)}\n`, 0o600);
|
|
395
|
+
return appName;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const CLAUDE_SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
|
|
399
|
+
|
|
400
|
+
function managedClaudeDesktopSessionIds(userData) {
|
|
401
|
+
const root = path.join(userData, "claude-code-sessions");
|
|
402
|
+
if (!fs.existsSync(root) || fs.lstatSync(root).isSymbolicLink()) return new Set();
|
|
403
|
+
const ids = new Set();
|
|
404
|
+
const pending = [root];
|
|
405
|
+
while (pending.length > 0) {
|
|
406
|
+
const directory = pending.pop();
|
|
407
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
408
|
+
const candidate = path.join(directory, entry.name);
|
|
409
|
+
if (entry.isSymbolicLink()) continue;
|
|
410
|
+
if (entry.isDirectory()) {
|
|
411
|
+
pending.push(candidate);
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
|
415
|
+
try {
|
|
416
|
+
const metadata = JSON.parse(fs.readFileSync(candidate, "utf8"));
|
|
417
|
+
if (CLAUDE_SESSION_ID_RE.test(metadata?.cliSessionId || "")) ids.add(metadata.cliSessionId);
|
|
418
|
+
} catch {
|
|
419
|
+
// A corrupt historical session remains in place; other sessions can
|
|
420
|
+
// still be migrated safely.
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return ids;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Before v0.13.2 the desktop bundle isolated Electron state but omitted
|
|
429
|
+
* CLAUDE_CONFIG_DIR, so its embedded Claude Code transcripts fell through to
|
|
430
|
+
* ~/.claude. Copy only transcripts referenced by this app profile's own
|
|
431
|
+
* session metadata into the private config root. Native history is never
|
|
432
|
+
* removed or rewritten, and unrelated session IDs are ignored.
|
|
433
|
+
*/
|
|
434
|
+
export function migrateLegacyClaudeAppSessions(userData, homeDir = os.homedir()) {
|
|
435
|
+
const sessionIds = managedClaudeDesktopSessionIds(userData);
|
|
436
|
+
if (sessionIds.size === 0) return 0;
|
|
437
|
+
const nativeProjects = path.join(homeDir, ".claude", "projects");
|
|
438
|
+
const privateProjects = path.join(userData, "projects");
|
|
439
|
+
if (!fs.existsSync(nativeProjects) || fs.lstatSync(nativeProjects).isSymbolicLink()) return 0;
|
|
440
|
+
if (fs.existsSync(privateProjects) && fs.lstatSync(privateProjects).isSymbolicLink()) {
|
|
441
|
+
throw new Error(`refusing to migrate Claude sessions into symlinked profile path ${privateProjects}`);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
let copied = 0;
|
|
445
|
+
for (const project of fs.readdirSync(nativeProjects, { withFileTypes: true })) {
|
|
446
|
+
if (!project.isDirectory() || project.isSymbolicLink()) continue;
|
|
447
|
+
const sourceDirectory = path.join(nativeProjects, project.name);
|
|
448
|
+
const destinationDirectory = path.join(privateProjects, project.name);
|
|
449
|
+
if (fs.existsSync(destinationDirectory) && fs.lstatSync(destinationDirectory).isSymbolicLink()) {
|
|
450
|
+
throw new Error(`refusing to migrate Claude sessions into symlinked project path ${destinationDirectory}`);
|
|
451
|
+
}
|
|
452
|
+
for (const entry of fs.readdirSync(sourceDirectory, { withFileTypes: true })) {
|
|
453
|
+
if (!entry.isFile() || entry.isSymbolicLink() || !entry.name.endsWith(".jsonl")) continue;
|
|
454
|
+
const sessionId = entry.name.slice(0, -".jsonl".length);
|
|
455
|
+
if (!sessionIds.has(sessionId)) continue;
|
|
456
|
+
const source = path.join(sourceDirectory, entry.name);
|
|
457
|
+
const destination = path.join(destinationDirectory, entry.name);
|
|
458
|
+
if (fs.existsSync(destination) && fs.lstatSync(destination).isSymbolicLink()) {
|
|
459
|
+
throw new Error(`refusing to overwrite symlinked Claude session transcript ${destination}`);
|
|
460
|
+
}
|
|
461
|
+
const sourceStat = fs.statSync(source);
|
|
462
|
+
if (fs.existsSync(destination) && fs.statSync(destination).mtimeMs >= sourceStat.mtimeMs) continue;
|
|
463
|
+
fs.mkdirSync(destinationDirectory, { recursive: true, mode: 0o700 });
|
|
464
|
+
fs.chmodSync(destinationDirectory, 0o700);
|
|
465
|
+
const temporary = `${destination}.tmp-${process.pid}`;
|
|
466
|
+
try {
|
|
467
|
+
fs.copyFileSync(source, temporary);
|
|
468
|
+
fs.chmodSync(temporary, 0o600);
|
|
469
|
+
fs.renameSync(temporary, destination);
|
|
470
|
+
fs.utimesSync(destination, sourceStat.atime, sourceStat.mtime);
|
|
471
|
+
fs.chmodSync(destination, 0o600);
|
|
472
|
+
copied += 1;
|
|
473
|
+
} finally {
|
|
474
|
+
fs.rmSync(temporary, { force: true });
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return copied;
|
|
479
|
+
}
|
|
480
|
+
|
|
355
481
|
export async function fetchGatewayModels(config, fetchImpl = fetch) {
|
|
356
482
|
const controller = new AbortController();
|
|
357
483
|
const timeout = setTimeout(() => controller.abort(), 20000);
|
|
@@ -395,6 +521,9 @@ export function installManagedAppFiles({
|
|
|
395
521
|
claudeUserData,
|
|
396
522
|
tenantName: config.tenantName,
|
|
397
523
|
});
|
|
524
|
+
const claudeSafeStorageName = targets.includes("claude")
|
|
525
|
+
? ensureClaudeSafeStorageName(paths, config.tenantId)
|
|
526
|
+
: null;
|
|
398
527
|
if (targets.includes("chatgpt")) secureAllManagedCodexHomes({ appsRoot: paths.root });
|
|
399
528
|
fs.mkdirSync(paths.tenantRoot, { recursive: true, mode: 0o700 });
|
|
400
529
|
if (writeTokenHelperFile) writeTokenHelper(paths.tokenHelper, config.tenantId);
|
|
@@ -409,6 +538,7 @@ export function installManagedAppFiles({
|
|
|
409
538
|
const installed = [];
|
|
410
539
|
for (const target of targets) {
|
|
411
540
|
if (target === "claude") {
|
|
541
|
+
migrateLegacyClaudeAppSessions(paths.claude.userData, homeDir);
|
|
412
542
|
writeClaudeDefaultApp(paths);
|
|
413
543
|
writeClaudeConfig(paths, config, models);
|
|
414
544
|
}
|
|
@@ -421,14 +551,23 @@ export function installManagedAppFiles({
|
|
|
421
551
|
assertPinnedVendorAppVersion(target, resolvedVendorPaths[target]);
|
|
422
552
|
}
|
|
423
553
|
if (target === "chatgpt") writeVendoredChatGPTBundle(paths, resolvedVendorPaths.chatgpt, config.gatewayUrl);
|
|
424
|
-
else writeVendoredClaudeBundle(
|
|
554
|
+
else writeVendoredClaudeBundle(
|
|
555
|
+
paths,
|
|
556
|
+
resolvedVendorPaths.claude,
|
|
557
|
+
config.gatewayUrl,
|
|
558
|
+
claudeSafeStorageName,
|
|
559
|
+
);
|
|
425
560
|
if (config.tenantId) {
|
|
426
561
|
// A successful tenant-specific rebuild supersedes the old global
|
|
427
562
|
// launcher. Other tenant launchers have distinct names and survive.
|
|
428
563
|
fs.rmSync(appPaths(homeDir)[target].launcher, { recursive: true, force: true });
|
|
429
564
|
}
|
|
430
565
|
}
|
|
431
|
-
installed.push({
|
|
566
|
+
installed.push({
|
|
567
|
+
target,
|
|
568
|
+
launcher: paths[target].launcher,
|
|
569
|
+
...(target === "claude" ? { safeStorageName: claudeSafeStorageName } : {}),
|
|
570
|
+
});
|
|
432
571
|
}
|
|
433
572
|
|
|
434
573
|
writeAtomic(path.join(paths.tenantRoot, "manifest.json"), JSON.stringify({
|
|
@@ -992,7 +1131,7 @@ exec ${node} ${cli} token${tenantArgs}
|
|
|
992
1131
|
writeAtomic(target, script, 0o700);
|
|
993
1132
|
}
|
|
994
1133
|
|
|
995
|
-
function writeVendoredClaudeBundle(paths, vendorPath, gatewayUrl) {
|
|
1134
|
+
function writeVendoredClaudeBundle(paths, vendorPath, gatewayUrl, safeStorageName) {
|
|
996
1135
|
if (!vendorPath) throw new Error("Claude vendor app is unavailable");
|
|
997
1136
|
const executableName = APP_DEFINITIONS.claude.executableNames.find((name) => (
|
|
998
1137
|
fs.existsSync(path.join(vendorPath, "Contents", "MacOS", name))
|
|
@@ -1026,6 +1165,7 @@ function writeVendoredClaudeBundle(paths, vendorPath, gatewayUrl) {
|
|
|
1026
1165
|
);
|
|
1027
1166
|
planUsageRequestPatchCount = patchClaudePlanUsageRequest(asarPath);
|
|
1028
1167
|
}
|
|
1168
|
+
const safeStorageNamePatchCount = patchClaudeSafeStorageName(asarPath);
|
|
1029
1169
|
const newAsarHash = asarHeaderHash(asarPath);
|
|
1030
1170
|
|
|
1031
1171
|
updateBundleIdentity(plistPath, {
|
|
@@ -1035,7 +1175,15 @@ function writeVendoredClaudeBundle(paths, vendorPath, gatewayUrl) {
|
|
|
1035
1175
|
name: paths.claude.displayName,
|
|
1036
1176
|
});
|
|
1037
1177
|
updateAsarIntegrityPlist(plistPath, oldAsarHash, newAsarHash);
|
|
1178
|
+
// Claude Desktop separates its Electron profile from Claude Code's config
|
|
1179
|
+
// root. Keep both on the tenant-private path: CLAUDE_USER_DATA_DIR isolates
|
|
1180
|
+
// browser/app state, while CLAUDE_CONFIG_DIR controls sessions, plugins,
|
|
1181
|
+
// MCP servers, skills, and native `@` agents used by the embedded runtime.
|
|
1182
|
+
// Without the latter, the app silently falls back to ~/.claude and can
|
|
1183
|
+
// expose an unrelated tenant's agents or secret-bearing MCP configuration.
|
|
1038
1184
|
setPlistEnvironmentString(plistPath, "CLAUDE_USER_DATA_DIR", paths.claude.userData);
|
|
1185
|
+
setPlistEnvironmentString(plistPath, "CLAUDE_CONFIG_DIR", paths.claude.userData);
|
|
1186
|
+
setPlistEnvironmentString(plistPath, "IMPEL_CN", safeStorageName);
|
|
1039
1187
|
rebrandElectronHelpers(staging, {
|
|
1040
1188
|
fromName: "Claude",
|
|
1041
1189
|
toName: paths.claude.displayName,
|
|
@@ -1054,10 +1202,13 @@ function writeVendoredClaudeBundle(paths, vendorPath, gatewayUrl) {
|
|
|
1054
1202
|
bundleIdentifier: paths.claude.bundleIdentifier,
|
|
1055
1203
|
displayName: paths.claude.displayName,
|
|
1056
1204
|
profileRoot: paths.claude.userData,
|
|
1205
|
+
configRoot: paths.claude.userData,
|
|
1206
|
+
safeStorageName,
|
|
1057
1207
|
launchMode: "LSEnvironment",
|
|
1058
1208
|
patches: {
|
|
1059
1209
|
aggregatePlanUsageGuard: planUsageGuardPatchCount,
|
|
1060
1210
|
aggregatePlanUsageRequest: planUsageRequestPatchCount,
|
|
1211
|
+
safeStorageName: safeStorageNamePatchCount,
|
|
1061
1212
|
},
|
|
1062
1213
|
asarHeaderSha256: newAsarHash,
|
|
1063
1214
|
}, null, 2) + "\n", 0o644);
|
|
@@ -1239,6 +1390,24 @@ function patchClaudePlanUsageRequest(asarPath) {
|
|
|
1239
1390
|
return applyFixedWidthAsarPatches(asarPath, archive, patches, "Claude plan-usage gateway request");
|
|
1240
1391
|
}
|
|
1241
1392
|
|
|
1393
|
+
function patchClaudeSafeStorageName(asarPath) {
|
|
1394
|
+
const archive = fs.readFileSync(asarPath);
|
|
1395
|
+
const source = archive.toString("latin1");
|
|
1396
|
+
const startupPattern = /([A-Za-z_$][\w$]*)\.app\.isPackaged\|\|\1\.app\.setName\("Claude"\)/gu;
|
|
1397
|
+
const patches = [...source.matchAll(startupPattern)].map((match) => {
|
|
1398
|
+
const original = match[0];
|
|
1399
|
+
const replacement = `${match[1]}.app.setName(process.env.IMPEL_CN)`;
|
|
1400
|
+
if (replacement.length > original.length) {
|
|
1401
|
+
throw new Error("Claude Safe Storage app-name replacement no longer fits the vendor ASAR contract");
|
|
1402
|
+
}
|
|
1403
|
+
return { offset: match.index, original, replacement: replacement.padEnd(original.length, " ") };
|
|
1404
|
+
});
|
|
1405
|
+
if (patches.length !== 1) {
|
|
1406
|
+
throw new Error(`Claude Safe Storage app-name patch expected 1 match, found ${patches.length}`);
|
|
1407
|
+
}
|
|
1408
|
+
return applyFixedWidthAsarPatches(asarPath, archive, patches, "Claude Safe Storage app name");
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1242
1411
|
function patchFastModeAuthGate(asarPath) {
|
|
1243
1412
|
const archive = fs.readFileSync(asarPath);
|
|
1244
1413
|
const source = archive.toString("latin1");
|
package/src/commands/apps.js
CHANGED
|
@@ -56,6 +56,8 @@ import {
|
|
|
56
56
|
windowsClaudeUserData,
|
|
57
57
|
} from "../windowsApps.js";
|
|
58
58
|
|
|
59
|
+
const CLAUDE_KEYCHAIN_NOTICE = "Claude Keychain: enter your Mac login password and choose Always Allow on the first prompt for this tenant; Allow is temporary.";
|
|
60
|
+
|
|
59
61
|
// Each isolated desktop app maps to a client CLI + the env override that points
|
|
60
62
|
// that CLI at the app's private profile, so skill syncing lands in the app's
|
|
61
63
|
// installation rather than a global one.
|
|
@@ -626,6 +628,11 @@ export async function cmdApps(argv, overrides = {}) {
|
|
|
626
628
|
const verb = action === "install" ? "Installed" : "Updated";
|
|
627
629
|
io.log(`${verb} ${item.launcher}${rebuilt.has(item.target) ? "" : " (bundle already current)"}`);
|
|
628
630
|
}
|
|
631
|
+
if (installed.some((item) => (
|
|
632
|
+
item.target === "claude"
|
|
633
|
+
&& rebuilt.has(item.target)
|
|
634
|
+
&& item.safeStorageName === "Claude"
|
|
635
|
+
))) io.log(CLAUDE_KEYCHAIN_NOTICE);
|
|
629
636
|
console.log(`Models: ${catalog.models.length} from ${catalog.source}. Normal ~/.claude and ~/.codex profiles were not changed.`);
|
|
630
637
|
|
|
631
638
|
// Best-effort: sync the Bifrost shared skills into each installed isolated app
|
|
@@ -745,6 +752,11 @@ export async function provisionAndOpenManagedApps({
|
|
|
745
752
|
io.log(`Installed and configured ${item.launcher} for tenant ${config.tenantId}.`);
|
|
746
753
|
}
|
|
747
754
|
}
|
|
755
|
+
if (installed.some((item) => (
|
|
756
|
+
item.target === "claude"
|
|
757
|
+
&& staleBundleTargets.includes(item.target)
|
|
758
|
+
&& item.safeStorageName === "Claude"
|
|
759
|
+
))) io.log(CLAUDE_KEYCHAIN_NOTICE);
|
|
748
760
|
|
|
749
761
|
// Match explicit install setup without penalizing repeat opens: this helper
|
|
750
762
|
// only runs when the zero-network fast path has detected drift or first use.
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import readline from "node:readline";
|
|
6
|
+
|
|
7
|
+
import { nativeCommandInvocation } from "../nativeProcess.js";
|
|
8
|
+
|
|
9
|
+
const SAFE_ID = /^[a-z][a-z0-9-]{2,31}$/u;
|
|
10
|
+
const SAFE_NAMESPACE = /^[a-z][a-z0-9_-]{1,31}$/u;
|
|
11
|
+
const SAFE_PREFIX = /^[a-z][a-z0-9_]{2,31}_$/u;
|
|
12
|
+
const SAFE_ENVIRONMENT_PREFIX = /^[A-Z][A-Z0-9_]{1,31}$/u;
|
|
13
|
+
const SAFE_ROUTE = /^\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*$/u;
|
|
14
|
+
const ANSI_ESCAPE_RE = /\u001B(?:\][^\u0007\u001B]*(?:\u0007|\u001B\\)|\[[0-?]*[ -/]*[@-~]|[@-_])/gu;
|
|
15
|
+
const CONTROL_TEST_RE = /[\u0000-\u001F\u007F-\u009F]/u;
|
|
16
|
+
const CONTROL_RE = /[\u0000-\u001F\u007F-\u009F]/gu;
|
|
17
|
+
|
|
18
|
+
function validateText(name, value, max = 128) {
|
|
19
|
+
if (typeof value !== "string" || value.trim() !== value || !value || value.length > max || CONTROL_TEST_RE.test(value)) {
|
|
20
|
+
throw new Error(`gateway CLI ${name} is invalid`);
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function validateRoute(name, value) {
|
|
26
|
+
if (typeof value !== "string" || !SAFE_ROUTE.test(value) || value.includes("//")) {
|
|
27
|
+
throw new Error(`gateway CLI ${name} is invalid`);
|
|
28
|
+
}
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function validateBrand(input) {
|
|
33
|
+
if (!input || Array.isArray(input) || typeof input !== "object" || input.schemaVersion !== 1) {
|
|
34
|
+
throw new Error("gateway CLI brand schemaVersion must be 1");
|
|
35
|
+
}
|
|
36
|
+
const productID = String(input.product?.id || "");
|
|
37
|
+
const command = String(input.cli?.command || "");
|
|
38
|
+
const configNamespace = String(input.cli?.configNamespace || "");
|
|
39
|
+
const providerID = String(input.cli?.providerId || "");
|
|
40
|
+
const managedMarker = String(input.cli?.managedMarker || "");
|
|
41
|
+
const environmentPrefix = String(input.cli?.environmentPrefix || command.toUpperCase().replace(/-/gu, "_"));
|
|
42
|
+
const patPrefix = String(input.auth?.patPrefix || "");
|
|
43
|
+
if (!SAFE_ID.test(productID)) throw new Error("gateway CLI product.id is invalid");
|
|
44
|
+
if (!SAFE_ID.test(command)) throw new Error("gateway CLI command is invalid");
|
|
45
|
+
if (!SAFE_NAMESPACE.test(configNamespace)) throw new Error("gateway CLI configNamespace is invalid");
|
|
46
|
+
if (!SAFE_NAMESPACE.test(providerID)) throw new Error("gateway CLI providerId is invalid");
|
|
47
|
+
if (!SAFE_NAMESPACE.test(managedMarker)) throw new Error("gateway CLI managedMarker is invalid");
|
|
48
|
+
if (!SAFE_ENVIRONMENT_PREFIX.test(environmentPrefix)) throw new Error("gateway CLI environmentPrefix is invalid");
|
|
49
|
+
if (!SAFE_PREFIX.test(patPrefix)) throw new Error("gateway CLI PAT prefix is invalid");
|
|
50
|
+
|
|
51
|
+
const defaultOrigin = input.gateway?.defaultOrigin == null ? null : normalizeGatewayUrl(input.gateway.defaultOrigin);
|
|
52
|
+
return Object.freeze({
|
|
53
|
+
schemaVersion: 1,
|
|
54
|
+
product: Object.freeze({ id: productID, displayName: validateText("product.displayName", input.product?.displayName) }),
|
|
55
|
+
cli: Object.freeze({ command, configNamespace, providerId: providerID, managedMarker, environmentPrefix }),
|
|
56
|
+
auth: Object.freeze({ patPrefix }),
|
|
57
|
+
gateway: Object.freeze({
|
|
58
|
+
defaultOrigin,
|
|
59
|
+
healthPath: validateRoute("healthPath", input.gateway?.healthPath),
|
|
60
|
+
modelsPath: validateRoute("modelsPath", input.gateway?.modelsPath),
|
|
61
|
+
anthropicBasePath: validateRoute("anthropicBasePath", input.gateway?.anthropicBasePath),
|
|
62
|
+
codexCliBasePath: validateRoute("codexCliBasePath", input.gateway?.codexCliBasePath),
|
|
63
|
+
}),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parseFlags(argv, spec = {}) {
|
|
68
|
+
const flags = {};
|
|
69
|
+
const positionals = [];
|
|
70
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
71
|
+
const argument = argv[index];
|
|
72
|
+
if (argument === "--") {
|
|
73
|
+
positionals.push(...argv.slice(index + 1));
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
if (!argument.startsWith("--")) {
|
|
77
|
+
positionals.push(argument);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const equalsIndex = argument.indexOf("=");
|
|
81
|
+
const name = argument.slice(2, equalsIndex === -1 ? undefined : equalsIndex);
|
|
82
|
+
if (!Object.hasOwn(spec, name)) throw new Error(`unknown flag --${name}`);
|
|
83
|
+
let value;
|
|
84
|
+
if (equalsIndex !== -1) {
|
|
85
|
+
value = argument.slice(equalsIndex + 1);
|
|
86
|
+
} else if (spec[name].type === "boolean") {
|
|
87
|
+
value = true;
|
|
88
|
+
} else {
|
|
89
|
+
value = argv[index + 1];
|
|
90
|
+
index += 1;
|
|
91
|
+
}
|
|
92
|
+
if (spec[name].type === "string" && (value === undefined || value === "")) {
|
|
93
|
+
throw new Error(`--${name} requires a value`);
|
|
94
|
+
}
|
|
95
|
+
flags[name] = value;
|
|
96
|
+
}
|
|
97
|
+
return { flags, positionals };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function normalizeGatewayUrl(value) {
|
|
101
|
+
const normalized = String(value || "").trim().replace(/\/+$/u, "");
|
|
102
|
+
if (!normalized) throw new Error("a gateway URL is required");
|
|
103
|
+
const parsed = new URL(normalized);
|
|
104
|
+
const localHTTP = parsed.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname);
|
|
105
|
+
if (parsed.protocol !== "https:" && !localHTTP) {
|
|
106
|
+
throw new Error("gateway URL must use HTTPS (HTTP is allowed only for local testing)");
|
|
107
|
+
}
|
|
108
|
+
if (parsed.username || parsed.password || parsed.search || parsed.hash) {
|
|
109
|
+
throw new Error("gateway URL must not contain credentials, query, or fragment");
|
|
110
|
+
}
|
|
111
|
+
return parsed.toString().replace(/\/+$/u, "");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function joinRoute(origin, route) {
|
|
115
|
+
return `${origin}${route.startsWith("/") ? route : `/${route}`}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Create a deliberately small, branded gateway launcher. This is the only
|
|
120
|
+
* public library surface intended for white-labelled vendor CLIs.
|
|
121
|
+
*/
|
|
122
|
+
export function createGatewayCli(options) {
|
|
123
|
+
const brand = validateBrand(options?.brand);
|
|
124
|
+
const entrypoint = String(options?.entrypoint || "");
|
|
125
|
+
const version = String(options?.version || "");
|
|
126
|
+
if (!entrypoint || !path.isAbsolute(entrypoint)) throw new Error("gateway CLI entrypoint must be an absolute path");
|
|
127
|
+
if (!/^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?$/u.test(version)) throw new Error("gateway CLI version is invalid");
|
|
128
|
+
|
|
129
|
+
const environment = options.environment || process.env;
|
|
130
|
+
const input = options.input || process.stdin;
|
|
131
|
+
const output = options.output || process.stdout;
|
|
132
|
+
const environmentName = (suffix) => `${brand.cli.environmentPrefix}_${suffix}`;
|
|
133
|
+
const configDirectory = environment[environmentName("CONFIG_DIR")] || path.join(os.homedir(), ".config", brand.cli.configNamespace);
|
|
134
|
+
const configPath = path.join(configDirectory, "config.json");
|
|
135
|
+
const profileRoot = path.join(configDirectory, "cli");
|
|
136
|
+
const gatewayRoutes = Object.freeze({
|
|
137
|
+
health(origin) { return joinRoute(origin, brand.gateway.healthPath); },
|
|
138
|
+
models(origin) { return joinRoute(origin, brand.gateway.modelsPath); },
|
|
139
|
+
anthropic(origin) { return joinRoute(origin, brand.gateway.anthropicBasePath); },
|
|
140
|
+
codex(origin) { return joinRoute(origin, brand.gateway.codexCliBasePath); },
|
|
141
|
+
});
|
|
142
|
+
const print = (line = "") => output.write(`${line}\n`);
|
|
143
|
+
|
|
144
|
+
function ensurePrivateDirectory(directory) {
|
|
145
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
146
|
+
const stat = fs.lstatSync(directory);
|
|
147
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`${directory} must be a real private directory`);
|
|
148
|
+
try { fs.chmodSync(directory, 0o700); } catch {}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function writePrivateFile(filePath, contents) {
|
|
152
|
+
ensurePrivateDirectory(path.dirname(filePath));
|
|
153
|
+
const temporary = `${filePath}.tmp-${process.pid}`;
|
|
154
|
+
try {
|
|
155
|
+
fs.writeFileSync(temporary, contents, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
156
|
+
fs.renameSync(temporary, filePath);
|
|
157
|
+
try { fs.chmodSync(filePath, 0o600); } catch {}
|
|
158
|
+
} finally {
|
|
159
|
+
try { fs.rmSync(temporary, { force: true }); } catch {}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function loadConfig() {
|
|
164
|
+
let raw;
|
|
165
|
+
try { raw = fs.readFileSync(configPath, "utf8"); }
|
|
166
|
+
catch (error) { if (error?.code === "ENOENT") return null; throw error; }
|
|
167
|
+
let value;
|
|
168
|
+
try { value = JSON.parse(raw); }
|
|
169
|
+
catch { throw new Error(`${configPath} is not valid JSON; fix or remove it`); }
|
|
170
|
+
if (!value || Array.isArray(value) || typeof value !== "object") throw new Error(`${configPath} must contain a JSON object`);
|
|
171
|
+
if (value.gatewayUrl) value.gatewayUrl = normalizeGatewayUrl(value.gatewayUrl);
|
|
172
|
+
return value;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function saveConfig(config) {
|
|
176
|
+
writePrivateFile(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function maskSecret(value) {
|
|
180
|
+
const secret = String(value || "");
|
|
181
|
+
if (!secret) return "(none)";
|
|
182
|
+
if (secret.length <= 12) return "*".repeat(secret.length);
|
|
183
|
+
return `${secret.slice(0, brand.auth.patPrefix.length + 4)}...${secret.slice(-4)}`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function redactSecretText(value) {
|
|
187
|
+
const escapedPrefix = brand.auth.patPrefix.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
188
|
+
const tokenPattern = new RegExp(`${escapedPrefix}[A-Za-z0-9_-]+(?:\\.[A-Za-z0-9_-]+)?`, "gu");
|
|
189
|
+
return String(value ?? "")
|
|
190
|
+
.replace(tokenPattern, "[REDACTED GATEWAY CREDENTIAL]")
|
|
191
|
+
.replace(ANSI_ESCAPE_RE, "")
|
|
192
|
+
.replace(CONTROL_RE, " ");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function resolveGateway(existing) {
|
|
196
|
+
const value = environment[environmentName("GATEWAY_URL")] || existing?.gatewayUrl || brand.gateway.defaultOrigin;
|
|
197
|
+
if (!value) {
|
|
198
|
+
throw new Error(`a gateway URL is required (pass --gateway or set ${environmentName("GATEWAY_URL")})`);
|
|
199
|
+
}
|
|
200
|
+
return normalizeGatewayUrl(value);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function promptSecret(question) {
|
|
204
|
+
return new Promise((resolve) => {
|
|
205
|
+
const rl = readline.createInterface({ input, output, terminal: input.isTTY });
|
|
206
|
+
if (input.isTTY) {
|
|
207
|
+
const originalWrite = rl._writeToOutput?.bind(rl);
|
|
208
|
+
if (originalWrite) {
|
|
209
|
+
rl._writeToOutput = (text) => originalWrite(text === question ? text : "*".repeat(text.length));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
rl.question(question, (answer) => {
|
|
213
|
+
rl.close();
|
|
214
|
+
output.write("\n");
|
|
215
|
+
resolve(answer.trim());
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function resolveAuthConfig(argv) {
|
|
221
|
+
const { flags } = parseFlags(argv, { gateway: { type: "string" } });
|
|
222
|
+
const existing = loadConfig();
|
|
223
|
+
const suppliedPAT = environment[environmentName("PAT")];
|
|
224
|
+
const pat = String(suppliedPAT || await promptSecret(`${brand.product.displayName} Personal Access Token: `)).trim();
|
|
225
|
+
if (!pat.startsWith(brand.auth.patPrefix) || /[\u0000-\u0020\u007f]/u.test(pat)) {
|
|
226
|
+
throw new Error(`PAT must start with ${brand.auth.patPrefix} and contain no whitespace`);
|
|
227
|
+
}
|
|
228
|
+
const gatewayUrl = flags.gateway ? normalizeGatewayUrl(flags.gateway) : resolveGateway(existing);
|
|
229
|
+
return { schemaVersion: 1, pat, gatewayUrl, updatedAt: new Date().toISOString() };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function persistAuthConfig(config, { quiet = false } = {}) {
|
|
233
|
+
saveConfig(config);
|
|
234
|
+
if (!quiet) {
|
|
235
|
+
print(`Stored ${brand.product.displayName} credentials in the private ${brand.cli.command} config.`);
|
|
236
|
+
print(` gateway: ${config.gatewayUrl}`);
|
|
237
|
+
print(` PAT: ${maskSecret(config.pat)}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function checkGateway(gatewayUrl, pat) {
|
|
242
|
+
const requestOptions = { signal: AbortSignal.timeout(5000) };
|
|
243
|
+
const health = await fetch(gatewayRoutes.health(gatewayUrl), requestOptions);
|
|
244
|
+
if (!health.ok) throw new Error(`gateway health returned HTTP ${health.status}`);
|
|
245
|
+
const models = await fetch(gatewayRoutes.models(gatewayUrl), {
|
|
246
|
+
...requestOptions,
|
|
247
|
+
headers: { authorization: `Bearer ${pat}` },
|
|
248
|
+
});
|
|
249
|
+
if (!models.ok) throw new Error(`gateway authentication returned HTTP ${models.status}`);
|
|
250
|
+
const body = await models.json();
|
|
251
|
+
return { modelCount: Array.isArray(body?.data) ? body.data.length : null };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function readJsonObject(filePath) {
|
|
255
|
+
if (!fs.existsSync(filePath)) return {};
|
|
256
|
+
try {
|
|
257
|
+
const value = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
258
|
+
if (!value || Array.isArray(value) || typeof value !== "object") throw new Error();
|
|
259
|
+
return value;
|
|
260
|
+
} catch { throw new Error(`${filePath} must contain a JSON object`); }
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function cliInvocation(args = []) {
|
|
264
|
+
return { command: process.execPath, args: [entrypoint, ...args] };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function ensureClaudeProfile(gatewayUrl) {
|
|
268
|
+
const configDir = path.join(profileRoot, "claude");
|
|
269
|
+
const settingsPath = path.join(configDir, "settings.json");
|
|
270
|
+
const settings = readJsonObject(settingsPath);
|
|
271
|
+
settings.env = {
|
|
272
|
+
...(settings.env && typeof settings.env === "object" && !Array.isArray(settings.env) ? settings.env : {}),
|
|
273
|
+
ANTHROPIC_BASE_URL: gatewayRoutes.anthropic(gatewayUrl),
|
|
274
|
+
};
|
|
275
|
+
delete settings.env.ANTHROPIC_API_KEY;
|
|
276
|
+
delete settings.env.ANTHROPIC_AUTH_TOKEN;
|
|
277
|
+
writePrivateFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
278
|
+
return { configDir, settingsPath };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const startMark = `# >>> ${brand.cli.managedMarker} profile >>>`;
|
|
282
|
+
const endMark = `# <<< ${brand.cli.managedMarker} profile <<<`;
|
|
283
|
+
|
|
284
|
+
function stripManagedBlock(text, filePath) {
|
|
285
|
+
const start = text.indexOf(startMark);
|
|
286
|
+
if (start === -1) return text;
|
|
287
|
+
const end = text.indexOf(endMark, start);
|
|
288
|
+
if (end === -1) throw new Error(`${filePath} has an incomplete managed block`);
|
|
289
|
+
return text.slice(0, start) + text.slice(end + endMark.length);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function codexManagedBlock(gatewayUrl) {
|
|
293
|
+
const auth = cliInvocation(["token"]);
|
|
294
|
+
return [
|
|
295
|
+
startMark,
|
|
296
|
+
`# Generated by the ${brand.product.displayName} CLI. Settings outside this block are preserved.`,
|
|
297
|
+
`[model_providers.${brand.cli.providerId}]`,
|
|
298
|
+
`name = ${JSON.stringify(`${brand.product.displayName} Gateway`)}`,
|
|
299
|
+
`base_url = ${JSON.stringify(gatewayRoutes.codex(gatewayUrl))}`,
|
|
300
|
+
'wire_api = "responses"',
|
|
301
|
+
"",
|
|
302
|
+
`[model_providers.${brand.cli.providerId}.auth]`,
|
|
303
|
+
`command = ${JSON.stringify(auth.command)}`,
|
|
304
|
+
`args = [${auth.args.map((argument) => JSON.stringify(argument)).join(", ")}]`,
|
|
305
|
+
"timeout_ms = 5000",
|
|
306
|
+
"refresh_interval_ms = 300000",
|
|
307
|
+
"",
|
|
308
|
+
"[features]",
|
|
309
|
+
"shell_snapshot = false",
|
|
310
|
+
endMark,
|
|
311
|
+
].join("\n");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function ensureCodexProfile(gatewayUrl) {
|
|
315
|
+
const codexHome = path.join(profileRoot, "codex");
|
|
316
|
+
const filePath = path.join(codexHome, "config.toml");
|
|
317
|
+
const original = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : "";
|
|
318
|
+
const outside = stripManagedBlock(original, filePath).trim();
|
|
319
|
+
const provider = brand.cli.providerId.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
320
|
+
if (new RegExp(`^\\s*\\[model_providers\\.${provider}(?:\\.|\\])`, "mu").test(outside)) {
|
|
321
|
+
throw new Error(`${filePath} contains a ${brand.cli.providerId} provider outside the managed block`);
|
|
322
|
+
}
|
|
323
|
+
if (/^\s*\[features\]\s*$/mu.test(outside) || /^\s*features\.shell_snapshot\s*=/mu.test(outside)) {
|
|
324
|
+
throw new Error(`${filePath} defines features outside the managed block; move them into another isolated profile`);
|
|
325
|
+
}
|
|
326
|
+
const withoutProvider = outside.replace(/^\s*model_provider\s*=.*$/mu, "").trim();
|
|
327
|
+
const next = [`model_provider = ${JSON.stringify(brand.cli.providerId)}`, codexManagedBlock(gatewayUrl), withoutProvider]
|
|
328
|
+
.filter(Boolean).join("\n\n").concat("\n");
|
|
329
|
+
writePrivateFile(filePath, next);
|
|
330
|
+
return { codexHome, filePath };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function cmdAuth(argv, { quiet = false } = {}) {
|
|
334
|
+
const config = await resolveAuthConfig(argv);
|
|
335
|
+
persistAuthConfig(config, { quiet });
|
|
336
|
+
return 0;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function cmdSetup(argv) {
|
|
340
|
+
const config = await resolveAuthConfig(argv);
|
|
341
|
+
const result = await checkGateway(config.gatewayUrl, config.pat);
|
|
342
|
+
ensureClaudeProfile(config.gatewayUrl);
|
|
343
|
+
ensureCodexProfile(config.gatewayUrl);
|
|
344
|
+
persistAuthConfig(config, { quiet: true });
|
|
345
|
+
print(`${brand.product.displayName} gateway setup is ready.`);
|
|
346
|
+
print(` gateway: ${config.gatewayUrl}`);
|
|
347
|
+
if (result.modelCount !== null) print(` models: ${result.modelCount}`);
|
|
348
|
+
print(` launch: ${brand.cli.command} claude | ${brand.cli.command} codex`);
|
|
349
|
+
return 0;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function cmdStatus() {
|
|
353
|
+
const config = loadConfig();
|
|
354
|
+
if (!config?.pat || !config?.gatewayUrl) throw new Error(`not configured; run \`${brand.cli.command} setup\``);
|
|
355
|
+
print(`Gateway: ${config.gatewayUrl}`);
|
|
356
|
+
print(`PAT: ${maskSecret(config.pat)}`);
|
|
357
|
+
print(`Profiles: ${profileRoot}`);
|
|
358
|
+
try {
|
|
359
|
+
const result = await checkGateway(config.gatewayUrl, config.pat);
|
|
360
|
+
print(`Status: ready${result.modelCount === null ? "" : ` (${result.modelCount} models)`}`);
|
|
361
|
+
} catch (error) {
|
|
362
|
+
throw new Error(redactSecretText(error?.message || error));
|
|
363
|
+
}
|
|
364
|
+
return 0;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function cmdToken() {
|
|
368
|
+
const config = loadConfig();
|
|
369
|
+
if (!config?.pat) throw new Error(`not authenticated; run \`${brand.cli.command} setup\` or \`${brand.cli.command} auth\``);
|
|
370
|
+
output.write(`${config.pat}\n`);
|
|
371
|
+
return 0;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function cleanLaunchEnvironment() {
|
|
375
|
+
const childEnvironment = { ...environment };
|
|
376
|
+
for (const name of [
|
|
377
|
+
environmentName("PAT"),
|
|
378
|
+
"ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL", "ANTHROPIC_CUSTOM_HEADERS",
|
|
379
|
+
"OPENAI_API_KEY", "OPENAI_BASE_URL", "CODEX_API_KEY",
|
|
380
|
+
]) delete childEnvironment[name];
|
|
381
|
+
return childEnvironment;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function runNative(tool, argv, childEnvironment) {
|
|
385
|
+
return new Promise((resolve, reject) => {
|
|
386
|
+
const invocation = nativeCommandInvocation(
|
|
387
|
+
tool,
|
|
388
|
+
argv,
|
|
389
|
+
childEnvironment,
|
|
390
|
+
process.platform,
|
|
391
|
+
brand.cli.environmentPrefix,
|
|
392
|
+
);
|
|
393
|
+
const child = spawn(invocation.command, invocation.args, {
|
|
394
|
+
env: childEnvironment,
|
|
395
|
+
stdio: "inherit",
|
|
396
|
+
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
|
|
397
|
+
});
|
|
398
|
+
child.once("error", reject);
|
|
399
|
+
child.once("exit", (code, signal) => resolve(code ?? (signal ? 1 : 0)));
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function cmdLaunch(tool, argv) {
|
|
404
|
+
const config = loadConfig();
|
|
405
|
+
if (!config?.pat || !config?.gatewayUrl) throw new Error(`not configured; run \`${brand.cli.command} setup\``);
|
|
406
|
+
const childEnvironment = cleanLaunchEnvironment();
|
|
407
|
+
if (tool === "claude") {
|
|
408
|
+
const profile = ensureClaudeProfile(config.gatewayUrl);
|
|
409
|
+
childEnvironment.CLAUDE_CONFIG_DIR = profile.configDir;
|
|
410
|
+
childEnvironment.ANTHROPIC_BASE_URL = gatewayRoutes.anthropic(config.gatewayUrl);
|
|
411
|
+
childEnvironment.ANTHROPIC_AUTH_TOKEN = config.pat;
|
|
412
|
+
} else if (tool === "codex") {
|
|
413
|
+
const profile = ensureCodexProfile(config.gatewayUrl);
|
|
414
|
+
childEnvironment.CODEX_HOME = profile.codexHome;
|
|
415
|
+
} else {
|
|
416
|
+
throw new Error(`unsupported launcher ${tool}`);
|
|
417
|
+
}
|
|
418
|
+
return runNative(tool, argv, childEnvironment);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const help = `${brand.cli.command} — ${brand.product.displayName} gateway CLI
|
|
422
|
+
|
|
423
|
+
${brand.cli.command} setup --gateway <url> Configure, verify, and create isolated profiles
|
|
424
|
+
${brand.cli.command} auth --gateway <url> Store the PAT and gateway URL privately
|
|
425
|
+
${brand.cli.command} claude [args...] Launch isolated Claude Code
|
|
426
|
+
${brand.cli.command} codex [args...] Launch isolated Codex
|
|
427
|
+
${brand.cli.command} status Verify gateway authentication and model catalog
|
|
428
|
+
${brand.cli.command} token Auth-helper output; prints only the PAT
|
|
429
|
+
${brand.cli.command} help
|
|
430
|
+
${brand.cli.command} --version
|
|
431
|
+
|
|
432
|
+
Environment:
|
|
433
|
+
${environmentName("PAT")} PAT for non-interactive setup/auth; keep it out of command arguments
|
|
434
|
+
${environmentName("GATEWAY_URL")} Default gateway URL
|
|
435
|
+
${environmentName("CLAUDE_BIN")} Optional Claude executable override
|
|
436
|
+
${environmentName("CODEX_BIN")} Optional Codex executable override
|
|
437
|
+
`;
|
|
438
|
+
|
|
439
|
+
async function main(argv) {
|
|
440
|
+
const [command, ...rest] = argv;
|
|
441
|
+
switch (command) {
|
|
442
|
+
case undefined: case "help": case "--help": case "-h": print(help); return 0;
|
|
443
|
+
case "--version": case "-v": print(version); return 0;
|
|
444
|
+
case "setup": return cmdSetup(rest);
|
|
445
|
+
case "auth": return cmdAuth(rest);
|
|
446
|
+
case "token": return cmdToken();
|
|
447
|
+
case "status": return cmdStatus();
|
|
448
|
+
case "claude": case "codex": return cmdLaunch(command, rest);
|
|
449
|
+
default: throw new Error(`unknown command ${JSON.stringify(command)}; run \`${brand.cli.command} help\``);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return Object.freeze({ main });
|
|
454
|
+
}
|
package/src/nativeProcess.js
CHANGED
|
@@ -51,10 +51,10 @@ function binaryCandidates(directory, tool, environment, platform) {
|
|
|
51
51
|
return windowsPathExtensions(environment).map((extension) => `${base}${extension}`);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
function overrideName(tool) {
|
|
55
|
-
if (tool === "claude") return
|
|
56
|
-
if (tool === "codex") return
|
|
57
|
-
if (tool === "npm") return
|
|
54
|
+
function overrideName(tool, environmentPrefix = "IMPEL") {
|
|
55
|
+
if (tool === "claude") return `${environmentPrefix}_CLAUDE_BIN`;
|
|
56
|
+
if (tool === "codex") return `${environmentPrefix}_CODEX_BIN`;
|
|
57
|
+
if (tool === "npm") return `${environmentPrefix}_NPM_BIN`;
|
|
58
58
|
return null;
|
|
59
59
|
}
|
|
60
60
|
|
|
@@ -116,8 +116,13 @@ function commonCandidates(tool, environment, platform) {
|
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
/** Resolve a real executable/shim if one is installed, otherwise return null. */
|
|
119
|
-
export function findNativeBinary(
|
|
120
|
-
|
|
119
|
+
export function findNativeBinary(
|
|
120
|
+
tool,
|
|
121
|
+
environment = process.env,
|
|
122
|
+
platform = process.platform,
|
|
123
|
+
environmentPrefix = "IMPEL",
|
|
124
|
+
) {
|
|
125
|
+
const override = overrideName(tool, environmentPrefix);
|
|
121
126
|
const overriddenBinary = override ? environmentValue(environment, override) : null;
|
|
122
127
|
if (overriddenBinary) return isExecutable(overriddenBinary, platform) ? overriddenBinary : null;
|
|
123
128
|
|
|
@@ -136,10 +141,15 @@ export function findNativeBinary(tool, environment = process.env, platform = pro
|
|
|
136
141
|
}
|
|
137
142
|
|
|
138
143
|
/** Resolve an executable for launch, preserving explicit overrides and useful ENOENT errors. */
|
|
139
|
-
export function resolveNativeBinary(
|
|
140
|
-
|
|
144
|
+
export function resolveNativeBinary(
|
|
145
|
+
tool,
|
|
146
|
+
environment = process.env,
|
|
147
|
+
platform = process.platform,
|
|
148
|
+
environmentPrefix = "IMPEL",
|
|
149
|
+
) {
|
|
150
|
+
const override = overrideName(tool, environmentPrefix);
|
|
141
151
|
const overriddenBinary = override ? environmentValue(environment, override) : null;
|
|
142
|
-
return overriddenBinary || findNativeBinary(tool, environment, platform) || tool;
|
|
152
|
+
return overriddenBinary || findNativeBinary(tool, environment, platform, environmentPrefix) || tool;
|
|
143
153
|
}
|
|
144
154
|
|
|
145
155
|
// Windows cannot execute npm's .cmd/.bat shims directly. This is the escaping
|
|
@@ -186,7 +196,13 @@ export function nativeSpawnInvocation(binary, argv, environment = process.env, p
|
|
|
186
196
|
}
|
|
187
197
|
|
|
188
198
|
/** Resolve a PATH command and produce a spawn-safe invocation for this platform. */
|
|
189
|
-
export function nativeCommandInvocation(
|
|
190
|
-
|
|
199
|
+
export function nativeCommandInvocation(
|
|
200
|
+
tool,
|
|
201
|
+
argv,
|
|
202
|
+
environment = process.env,
|
|
203
|
+
platform = process.platform,
|
|
204
|
+
environmentPrefix = "IMPEL",
|
|
205
|
+
) {
|
|
206
|
+
const binary = resolveNativeBinary(tool, environment, platform, environmentPrefix);
|
|
191
207
|
return { binary, ...nativeSpawnInvocation(binary, argv, environment, platform) };
|
|
192
208
|
}
|
package/src/windowsApps.js
CHANGED
|
@@ -329,7 +329,7 @@ export function removeManagedWindowsChatGPTApp(appsRoot) {
|
|
|
329
329
|
fs.rmSync(windowsChatGPTCacheRoot(appsRoot), { recursive: true, force: true });
|
|
330
330
|
}
|
|
331
331
|
|
|
332
|
-
/** Launch the signed vendor app with
|
|
332
|
+
/** Launch the signed vendor app with all mutable Claude state redirected to Impel. */
|
|
333
333
|
export function launchWindowsClaudeApp(binary, userData, dependencies = {}) {
|
|
334
334
|
const io = {
|
|
335
335
|
environment: process.env,
|
|
@@ -341,11 +341,17 @@ export function launchWindowsClaudeApp(binary, userData, dependencies = {}) {
|
|
|
341
341
|
const environment = { ...io.environment };
|
|
342
342
|
// These developer-only overrides require a signed CDP token and must never
|
|
343
343
|
// bleed from an unrelated native Claude session into the managed app.
|
|
344
|
-
const managedKeys = new Set([
|
|
344
|
+
const managedKeys = new Set([
|
|
345
|
+
"CLAUDE_USER_DATA_DIR",
|
|
346
|
+
"CLAUDE_CONFIG_DIR",
|
|
347
|
+
"CLAUDE_CDP_AUTH",
|
|
348
|
+
"CLAUDE_AI_URL",
|
|
349
|
+
]);
|
|
345
350
|
for (const key of Object.keys(environment)) {
|
|
346
351
|
if (managedKeys.has(key.toUpperCase())) delete environment[key];
|
|
347
352
|
}
|
|
348
353
|
environment.CLAUDE_USER_DATA_DIR = userData;
|
|
354
|
+
environment.CLAUDE_CONFIG_DIR = userData;
|
|
349
355
|
|
|
350
356
|
return new Promise((resolve, reject) => {
|
|
351
357
|
const child = io.spawnProcess(binary, [], {
|