impel-cli 0.13.1 → 0.13.2

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 CHANGED
@@ -642,7 +642,7 @@ Invocation uses the clients' native agent behavior:
642
642
 
643
643
  ```text
644
644
  # Claude Code / Claude Desktop Code tab: guaranteed explicit selection
645
- @agent-research-agent investigate the dependency change
645
+ @research-agent investigate the dependency change
646
646
 
647
647
  # Claude blocking CLI
648
648
  claude --agent research-agent -p "investigate the dependency change"
@@ -715,8 +715,11 @@ Their visible bundle names use the organization display name, for example
715
715
  a stable, unique bundle identifier and gateway configuration:
716
716
 
717
717
  - 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 writes the 3P gateway
718
+ bundle and helper identities. Its LaunchServices environment sets both the
719
+ vendor-supported `CLAUDE_USER_DATA_DIR` and Claude Code's
720
+ `CLAUDE_CONFIG_DIR` to the tenant-private profile. This isolates browser
721
+ state, sessions, plugins, MCP servers, skills, and native `@` agents from
722
+ `~/.claude`, while the app writes the 3P gateway
720
723
  `configLibrary` below `~/.config/impel/apps/tenants/<org>/claude`. A new
721
724
  isolated profile defaults to the Code app while preserving any app selection
722
725
  the user makes afterward. A narrowly version-checked compatibility patch
@@ -818,6 +821,18 @@ available: `IMPEL_CODESIGN_ADHOC=1` forces ad-hoc signing, and
818
821
  `IMPEL_CODESIGN_IDENTITY=<name>` signs with a specific existing identity (e.g. a
819
822
  real `Developer ID Application: …`).
820
823
 
824
+ Older Claude profiles already encrypt browser data with the macOS login
825
+ Keychain item named `Claude Safe Storage`. Impel keeps that namespace immutable
826
+ so an update never makes existing cookies or storage unreadable. Because each
827
+ tenant app has a distinct bundle identity, macOS asks once before that app may
828
+ use the legacy item: enter the Mac login password and choose **Always Allow**.
829
+ Choosing **Allow** grants access for only that launch and therefore prompts
830
+ again next time. The stable Impel signature keeps a successful Always Allow
831
+ grant valid across later app updates. Clean profiles instead start with a
832
+ persisted tenant-specific Safe Storage namespace, so they never request access
833
+ to the shared vendor item. Impel does not weaken or silently rewrite Keychain
834
+ access lists.
835
+
821
836
  The Claude 3P config necessarily contains the PAT because that app accepts a
822
837
  gateway API key rather than a token-helper command. It is stored in the same
823
838
  owner-only Impel config tree as the primary credential. ChatGPT/Codex uses a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.13.1",
3
+ "version": "0.13.2",
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": {
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.2";
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(paths, resolvedVendorPaths.claude, config.gatewayUrl);
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({ target, launcher: paths[target].launcher });
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");
@@ -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.
@@ -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 only its profile root redirected to Impel. */
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(["CLAUDE_USER_DATA_DIR", "CLAUDE_CDP_AUTH", "CLAUDE_AI_URL"]);
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, [], {