impel-cli 0.20.38 → 0.20.39

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/src/agents.js CHANGED
@@ -56,7 +56,7 @@ export const NATIVE_AGENT_RESUME_TOOL = "resume_native_agent_run";
56
56
  export const NATIVE_AGENT_RECOVER_TOOL = "recover_native_agent_runs";
57
57
  export const NATIVE_AGENT_CONTINUATION_SCHEMA = "impel.native-agent-continuation.v1";
58
58
  export const MANAGED_AGENT_MCP_SERVER = "impel_agent";
59
- export const MANAGED_AGENT_MANIFEST_VERSION = 20;
59
+ export const MANAGED_AGENT_MANIFEST_VERSION = 22;
60
60
 
61
61
  // The host model only selects the fixed MCP tool and faithfully returns its
62
62
  // result. Spark minimizes those transport-only turns while the selected Eve
@@ -623,7 +623,7 @@ function claudeAdapterInstructions(tenantId, agent) {
623
623
  `Confirm that the request fits the synchronized capabilities ${JSON.stringify(agent.capabilities)} and none of the exclusions ${JSON.stringify(agent.exclusions)}.${contextRequirement}`,
624
624
  sideEffectInstruction,
625
625
  `Call ${nativeToolName(NATIVE_AGENT_ANSWER_TOOL)} exactly once with question set to the complete assigned task, optional context set to one string containing all supplied context, and contextKeys naming the fields present in that string.`,
626
- `If the bounded answer returns an object whose schema is exactly ${JSON.stringify(NATIVE_AGENT_CONTINUATION_SCHEMA)}, pass that complete object unchanged as the handle to ${nativeToolName(NATIVE_AGENT_RESUME_TOOL)} until terminal. Never change its schema or add handle metadata. Never call answer_native_agent again for this request.`,
626
+ `If the bounded answer returns an object whose schema is exactly ${JSON.stringify(NATIVE_AGENT_CONTINUATION_SCHEMA)}, pass that complete object unchanged as the handle to ${nativeToolName(NATIVE_AGENT_RESUME_TOOL)} until terminal. Never change its schema, add handle metadata, or fabricate a continuation from any other field. If the answer call is cancelled or fails before returning any continuation, call ${nativeToolName(NATIVE_AGENT_ANSWER_TOOL)} again with the same question; once a continuation has been returned, never call it again for this request.`,
627
627
  completionGuidance,
628
628
  ].join(" ");
629
629
  }
@@ -673,7 +673,7 @@ function codexAdapterInstructions(tenantId, agent) {
673
673
  `Confirm that the assigned request fits the cataloged capabilities ${JSON.stringify(agent.capabilities)} and none of the exclusions ${JSON.stringify(agent.exclusions)} before answering.${contextRequirement}`,
674
674
  sideEffectInstruction,
675
675
  `Call ${nativeToolName(NATIVE_AGENT_ANSWER_TOOL)} exactly once with question set to the complete assigned task, optional context set to one string containing all supplied context, and contextKeys naming the fields present in that string.`,
676
- `If the bounded answer returns an object whose schema is exactly ${JSON.stringify(NATIVE_AGENT_CONTINUATION_SCHEMA)}, pass that complete object unchanged as the handle to ${nativeToolName(NATIVE_AGENT_RESUME_TOOL)} until terminal. Never change its schema or add handle metadata. Never call answer_native_agent again for this request.`,
676
+ `If the bounded answer returns an object whose schema is exactly ${JSON.stringify(NATIVE_AGENT_CONTINUATION_SCHEMA)}, pass that complete object unchanged as the handle to ${nativeToolName(NATIVE_AGENT_RESUME_TOOL)} until terminal. Never change its schema, add handle metadata, or fabricate a continuation from any other field. If the answer call is cancelled or fails before returning any continuation, call ${nativeToolName(NATIVE_AGENT_ANSWER_TOOL)} again with the same question; once a continuation has been returned, never call it again for this request.`,
677
677
  completionGuidance,
678
678
  ].join("\n\n");
679
679
  }
@@ -827,6 +827,13 @@ function renderCodexConfiguration({
827
827
  ...(directCodeMode ? [
828
828
  "[features]",
829
829
  "multi_agent = false",
830
+ // Fixed-binding adapters cannot use host plugins. Disable both local and
831
+ // remote plugin paths so profile layering does not load the ordinary
832
+ // tenant Codex plugin catalog or make unrelated catalog requests.
833
+ "plugins = false",
834
+ "remote_plugin = false",
835
+ "plugin_sharing = false",
836
+ "recommended_plugins = false",
830
837
  "",
831
838
  "[features.code_mode]",
832
839
  "enabled = true",
@@ -1333,9 +1340,23 @@ export function syncAgentProfile({
1333
1340
  const priorUsesDiscoveryRoot = Number.isInteger(prior?.version)
1334
1341
  && prior.version >= 2
1335
1342
  && prior.version <= MANAGED_AGENT_MANIFEST_VERSION;
1336
- const priorOwnsCodexProfiles = priorUsesDiscoveryRoot
1337
- && prior?.client === "codex"
1343
+ // Ownership requires the client and tenant to match on every leg; a manifest
1344
+ // from another scope must neither authorize overwrites nor deletions.
1345
+ const priorOwnsManagedFiles = priorUsesDiscoveryRoot
1346
+ && prior?.client === client
1338
1347
  && prior?.tenantId === tenantId;
1348
+ // A colliding artifact whose bytes match the recorded digest of a
1349
+ // non-owning manifest is provably impel-generated (e.g. after a tenant
1350
+ // scope-key drift), so reclaiming it cannot lose operator-authored content.
1351
+ const reclaimable = (digestKey, destination) => {
1352
+ const recorded = prior?.contentDigests?.[digestKey];
1353
+ if (typeof recorded !== "string" || !recorded) return false;
1354
+ try {
1355
+ return contentDigest(fs.readFileSync(destination)) === recorded;
1356
+ } catch {
1357
+ return false;
1358
+ }
1359
+ };
1339
1360
 
1340
1361
  // Native clients discover standalone definitions directly under `agents/`.
1341
1362
  // Preflight every destination before writing so an unmanaged file with the
@@ -1343,12 +1364,13 @@ export function syncAgentProfile({
1343
1364
  for (const agent of rendered) {
1344
1365
  const destination = path.join(agentsDir, agent.fileName);
1345
1366
  if (fs.existsSync(destination)) {
1346
- if (!priorUsesDiscoveryRoot || !priorFiles.has(agent.fileName)) {
1347
- throw new Error(`refusing to overwrite unmanaged native-agent file ${destination}`);
1348
- }
1349
1367
  if (fs.lstatSync(destination).isSymbolicLink()) {
1350
1368
  throw new Error(`refusing to overwrite symlinked native-agent file ${destination}`);
1351
1369
  }
1370
+ if ((!priorOwnsManagedFiles || !priorFiles.has(agent.fileName))
1371
+ && !reclaimable(`agents/${agent.fileName}`, destination)) {
1372
+ throw new Error(`refusing to overwrite unmanaged native-agent file ${destination}`);
1373
+ }
1352
1374
  }
1353
1375
  if (client === "codex" && directProfiles) {
1354
1376
  const profileDestination = path.join(root, agent.profileFileName);
@@ -1356,7 +1378,8 @@ export function syncAgentProfile({
1356
1378
  if (fs.lstatSync(profileDestination).isSymbolicLink()) {
1357
1379
  throw new Error(`refusing to overwrite symlinked Codex profile ${profileDestination}`);
1358
1380
  }
1359
- if (!priorOwnsCodexProfiles || !priorProfiles.has(agent.profileFileName)) {
1381
+ if ((!priorOwnsManagedFiles || !priorProfiles.has(agent.profileFileName))
1382
+ && !reclaimable(agent.profileFileName, profileDestination)) {
1360
1383
  throw new Error(`refusing to overwrite unmanaged Codex profile ${profileDestination}`);
1361
1384
  }
1362
1385
  }
@@ -1370,20 +1393,29 @@ export function syncAgentProfile({
1370
1393
  const currentProfiles = new Set(client === "codex" && directProfiles
1371
1394
  ? rendered.map((agent) => agent.profileFileName)
1372
1395
  : []);
1396
+ // Stale-file cleanup in the shared discovery root: an owning manifest's
1397
+ // listing is authoritative, while a non-owning manifest (another tenant or
1398
+ // a drifted scope) may only remove files it digest-proves impel wrote —
1399
+ // native-profile roots are shared across tenants, so skipping cleanup
1400
+ // entirely would orphan the previous tenant's agents forever, and deleting
1401
+ // unproven listings could destroy another owner's live files. Legacy
1402
+ // nested manifests only ever clean impel's private managed directory below.
1403
+ const staleRemovable = (stale, digestKey, destination) =>
1404
+ typeof stale === "string"
1405
+ && path.basename(stale) === stale
1406
+ && (priorOwnsManagedFiles || reclaimable(digestKey, destination));
1373
1407
  for (const stale of prior?.files || []) {
1374
1408
  if (
1375
- typeof stale === "string"
1376
- && path.basename(stale) === stale
1409
+ staleRemovable(stale, `agents/${stale}`, path.join(agentsDir, stale))
1377
1410
  && !currentFiles.has(stale)
1378
1411
  && (stale.endsWith(".md") || stale.endsWith(".toml"))
1379
1412
  ) {
1380
- fs.rmSync(path.join(priorUsesDiscoveryRoot ? agentsDir : managedDir, stale), { force: true });
1413
+ fs.rmSync(path.join(agentsDir, stale), { force: true });
1381
1414
  }
1382
1415
  }
1383
- for (const stale of priorOwnsCodexProfiles ? prior.profiles || [] : []) {
1416
+ for (const stale of client === "codex" ? prior?.profiles || [] : []) {
1384
1417
  if (
1385
- typeof stale === "string"
1386
- && path.basename(stale) === stale
1418
+ staleRemovable(stale, stale, path.join(root, stale))
1387
1419
  && stale.endsWith(".config.toml")
1388
1420
  && !currentProfiles.has(stale)
1389
1421
  ) {
package/src/apps.js CHANGED
@@ -157,6 +157,27 @@ const CLAUDE_AGENT_MENTION_SELECT = 'onSelect:t=>(e(String(t)),Promise.resolve(n
157
157
  const IMPEL_CLAUDE_BOUND_AGENT_MENTION_SELECT = 'onSelect:t=>"string"==typeof v?Promise.resolve({chipText:String(t)}):(e(String(t)),Promise.resolve(null))';
158
158
  const LEGACY_CLAUDE_SAFE_STORAGE_NAME = "Claude";
159
159
  const CLAUDE_SAFE_STORAGE_METADATA = "safe-storage.json";
160
+ // The pinned Claude desktop build refuses remote debugging unless
161
+ // process.env.CLAUDE_CDP_AUTH carries a token Ed25519-signed by the private
162
+ // half of this baked-in public key: its main process runs
163
+ // `eae(process.argv)&&!_9()&&process.exit(1)` where eae() is true for any
164
+ // --remote-debugging-port/-pipe flag and _9() verifies CLAUDE_CDP_AUTH with
165
+ // crypto.verify(null, `${ms}.${base64(userData)}`, createPublicKey(<key>),
166
+ // sig). The exact PEM literal appears TWICE in the pinned ASAR (the
167
+ // main-process gate and a shared utility chunk whose token verifiers reuse the
168
+ // same anchor); both are the identical CDP-auth trust anchor. Swapping it to
169
+ // the Impel key below lets the CI-isolated launch smoke drive the managed
170
+ // bundle over CDP with a token signed by the matching private key (held only
171
+ // in GitHub Actions secrets), which is required to verify pins by behavior
172
+ // (docs/testing-protocol.md P0-1). This deliberately moves the managed
173
+ // bundle's CDP trust anchor from Anthropic's key to an Impel CI-held key;
174
+ // flagged for maintainer review in the PR. Both keys are Ed25519 SPKI PEM of
175
+ // identical byte length, so the fixed-width ASAR swap preserves layout.
176
+ const CLAUDE_CDP_AUTH_VENDOR_PUBLIC_KEY = "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEApH/vaEiLV0sNY/eS+Ct/IMbMqw8i/vC/cNC84BAbBq8=\n-----END PUBLIC KEY-----";
177
+ const IMPEL_CLAUDE_CDP_AUTH_PUBLIC_KEY = "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAXG1l1V6YxCaH1Dbn27EIkeQjYU4urRx2aPrDwAULUfw=\n-----END PUBLIC KEY-----";
178
+ // The pinned build embeds the anchor in exactly this many chunks. Fail closed
179
+ // (no loose regex, no "best effort") if a vendor update changes the count.
180
+ const CLAUDE_CDP_AUTH_KEY_COPIES = 2;
160
181
  // The pinned Claude renderer decides desktop-vs-web mode SOLELY by matching its
161
182
  // own Electron user-agent against /claude(nest|gov)?\/([^ ]+)/i (functions o_,
162
183
  // x_, i_ in ion-dist). Electron derives that UA product token from app.name as
@@ -290,7 +311,11 @@ export function managedAppIdentity(target, tenantId = null, tenantName = null) {
290
311
  // and install Claude task, notification, and permission lifecycle hooks.
291
312
  // 32: install a persistent tenant-bound Tasks navigation surface in the
292
313
  // managed Claude and ChatGPT/Codex desktop wrappers.
293
- export const CURRENT_CONFIG_VERSION = 32;
314
+ // 33: size the Codex verbatim-relay parent wait to outlast the child's
315
+ // native-agent attachment window so the visible working span is not
316
+ // truncated while the child is still running.
317
+ // 34: isolate fixed-binding Codex profiles from local and remote host plugins.
318
+ export const CURRENT_CONFIG_VERSION = 34;
294
319
 
295
320
  // Identifies the bundle-BUILDING logic — the asar patches, plist rewrites,
296
321
  // helper rebranding, and signing. A vendored bundle is rebuilt only when this
@@ -299,7 +324,7 @@ export const CURRENT_CONFIG_VERSION = 32;
299
324
  // — which is what made every `impel update` re-trigger macOS permission
300
325
  // prompts. Bump this ONLY when a code change alters the bytes of a built
301
326
  // bundle; leave it alone for changes that don't touch bundle contents.
302
- export const BUNDLE_BUILD_FINGERPRINT = "bundle-2026-08-09.4";
327
+ export const BUNDLE_BUILD_FINGERPRINT = "bundle-2026-08-09.5";
303
328
 
304
329
  /** Parse the tenant's install manifest, or null when absent/corrupt. */
305
330
  export function readTenantManifest(homeDir = os.homedir(), tenantId = null) {
@@ -423,6 +448,7 @@ const CLAUDE_COMPATIBILITY_PATCH_KEYS = Object.freeze([
423
448
  "followupBoundAgentFilter",
424
449
  "followupBoundAgentChip",
425
450
  "desktopTasksPreload",
451
+ "cdpAuthKey",
426
452
  ]);
427
453
  const CLAUDE_COMPATIBILITY_RENDERER_GROUPS = Object.freeze(["agentMentions"]);
428
454
 
@@ -1659,6 +1685,187 @@ function removeTopLevelTomlString(toml, key) {
1659
1685
  return topLevel + toml.slice(topLevelEnd);
1660
1686
  }
1661
1687
 
1688
+ /** Read one co-owned JSON artifact; distinguishes "absent" from "corrupt". */
1689
+ function readManagedJsonArtifact(filePath) {
1690
+ let raw;
1691
+ try {
1692
+ raw = fs.readFileSync(filePath, "utf8");
1693
+ } catch {
1694
+ return { present: false, value: null };
1695
+ }
1696
+ try {
1697
+ const value = JSON.parse(raw);
1698
+ return {
1699
+ present: true,
1700
+ value: value && typeof value === "object" && !Array.isArray(value) ? value : null,
1701
+ };
1702
+ } catch {
1703
+ return { present: true, value: null };
1704
+ }
1705
+ }
1706
+
1707
+ // Mirrors the session-hook installer's tenant gate (sessionHooks.js
1708
+ // validTenantId): hooks are only ever written for ids matching this shape, so
1709
+ // the drift check must not demand them for ids the installer skipped.
1710
+ const SESSION_HOOK_TENANT_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
1711
+
1712
+ /** Whether the managed session-hook heal channel survives in settings.json. */
1713
+ function claudeSettingsCarrySessionHooks(settings) {
1714
+ const hooks = settings.hooks;
1715
+ if (!hooks || typeof hooks !== "object" || Array.isArray(hooks)) return false;
1716
+ const marker = `--${RUNTIME_BRAND.cli.command}-managed-session-hook-v1`;
1717
+ return Object.values(hooks).some((groups) => Array.isArray(groups) && groups.some((group) => (
1718
+ Array.isArray(group?.hooks) && group.hooks.some((handler) => (
1719
+ (Array.isArray(handler?.args) && handler.args.includes(marker))
1720
+ || (typeof handler?.command === "string" && handler.command.includes(marker))
1721
+ ))
1722
+ )));
1723
+ }
1724
+
1725
+ /**
1726
+ * True when a managed Claude profile exists but its artifacts no longer carry
1727
+ * the load-bearing managed keys. Claude Desktop and the embedded Claude Code
1728
+ * runtime co-own these files and rewrite them with their own settings writers,
1729
+ * which normalize away optional fields (the persisted Tasks MCP entry loses
1730
+ * `type: "stdio"`), reorder keys, and reformat whitespace. Those cosmetic
1731
+ * rewrites are NOT drift — ownership recognition tolerates them through
1732
+ * isImpelTasksMcpInvocation, and every check parses JSON rather than comparing
1733
+ * bytes — but a lost or overwritten managed key (gateway routing config, the
1734
+ * applied configLibrary selection, the 3P deployment selection, the
1735
+ * tenant-bound Tasks MCP entry, the session-hook heal channel, the managed
1736
+ * sandbox policy) is real drift. Stale-only refreshes treat it as staleness so
1737
+ * the profile heals immediately instead of waiting out the manifest TTL.
1738
+ * The content-based mirror of managedChatGPTConfigDrifted for the Claude
1739
+ * target; like it, this never keys on TTLs or timestamps.
1740
+ */
1741
+ export function managedClaudeConfigDrifted(paths) {
1742
+ let manifest;
1743
+ try {
1744
+ manifest = JSON.parse(fs.readFileSync(path.join(paths.tenantRoot, "manifest.json"), "utf8"));
1745
+ } catch {
1746
+ return false; // No managed install; install/update owns creating one.
1747
+ }
1748
+ if (!Array.isArray(manifest?.targets) || !manifest.targets.includes("claude")) return false;
1749
+ const tenantId = typeof manifest.tenantId === "string" && manifest.tenantId
1750
+ ? manifest.tenantId
1751
+ : null;
1752
+
1753
+ // configLibrary: the gateway inference routing the desktop app applies. A
1754
+ // profile that loses it (or its provider/base-url/api-key wiring) no longer
1755
+ // routes inference through the Impel gateway at all.
1756
+ const configLibrary = readManagedJsonArtifact(
1757
+ path.join(paths.claude.userData, "configLibrary", `${CLAUDE_CONFIG_ID}.json`),
1758
+ );
1759
+ if (
1760
+ !configLibrary.value
1761
+ || configLibrary.value.inferenceProvider !== "gateway"
1762
+ || typeof configLibrary.value.inferenceGatewayBaseUrl !== "string"
1763
+ || !configLibrary.value.inferenceGatewayBaseUrl
1764
+ || typeof configLibrary.value.inferenceGatewayApiKey !== "string"
1765
+ || !configLibrary.value.inferenceGatewayApiKey
1766
+ ) {
1767
+ return true;
1768
+ }
1769
+ const meta = readManagedJsonArtifact(path.join(paths.claude.userData, "configLibrary", "_meta.json"));
1770
+ if (!meta.value || meta.value.appliedId !== CLAUDE_CONFIG_ID) return true;
1771
+
1772
+ // claude_desktop_config.json: the native 3P selection plus the tenant-bound
1773
+ // Tasks MCP entry. Claude Desktop persists this file itself, without the
1774
+ // optional `type` field and with its own key order — that vendor-normalized
1775
+ // form must keep counting as ours (the 0.20.1 repair-refusal regression).
1776
+ const desktop = readManagedJsonArtifact(path.join(paths.claude.userData, "claude_desktop_config.json"));
1777
+ if (!desktop.value || desktop.value.deploymentMode !== "3p") return true;
1778
+ if (RUNTIME_BRAND.features.mcp && tenantId) {
1779
+ const servers = desktop.value.mcpServers;
1780
+ const tasksEntry = servers && typeof servers === "object" && !Array.isArray(servers)
1781
+ ? servers[IMPEL_TASKS_MCP_SERVER_NAME]
1782
+ : undefined;
1783
+ if (!isImpelTasksMcpInvocation(tasksEntry)) return true;
1784
+ }
1785
+
1786
+ // settings.json: the managed sandbox policy and the session-hook heal
1787
+ // channel. The hooks are the only Impel code guaranteed to run while the
1788
+ // profile is broken, so losing them kills the self-repair path itself.
1789
+ const settings = readManagedJsonArtifact(path.join(paths.claude.userData, "settings.json"));
1790
+ if (!settings.value || settings.value.sandbox?.enabled !== false) return true;
1791
+ if (
1792
+ RUNTIME_BRAND.features.sessions
1793
+ && tenantId
1794
+ && SESSION_HOOK_TENANT_RE.test(tenantId)
1795
+ && !claudeSettingsCarrySessionHooks(settings.value)
1796
+ ) {
1797
+ return true;
1798
+ }
1799
+ return false;
1800
+ }
1801
+
1802
+ /** Distinct exit code for a failed managed-runtime preflight (EX_CONFIG). */
1803
+ export const MANAGED_RUNTIME_PREFLIGHT_EXIT_CODE = 78;
1804
+
1805
+ function runtimeRepairInstruction(compatibility) {
1806
+ const command = RUNTIME_BRAND.cli.command;
1807
+ const tenantSource = `${compatibility.codexHome || ""}${compatibility.profileRoot || ""}`;
1808
+ const tenantId = tenantSource.match(/[\\/]tenants[\\/]([^\\/]+)[\\/]/u)?.[1] || null;
1809
+ return `run \`${command} update\` or \`${command} app refresh${tenantId ? ` --tenant ${tenantId}` : ""}\` to repair it`;
1810
+ }
1811
+
1812
+ /**
1813
+ * Runtime-presence preflight for the managed-launch seam: before a managed
1814
+ * launcher is handed to the OS, require the vendored runtime it embeds to
1815
+ * exist at the version this CLI pins (the v0.20.32 exit-127 rule: a pin bump
1816
+ * that outruns the installed runtime must fail with one actionable line and a
1817
+ * distinct exit code, never a bare ENOENT/127 crash). Checks are cheap —
1818
+ * Impel's own build metadata, file existence, and the existing plist
1819
+ * version-read helper; no binary is spawned. Bundles without Impel build
1820
+ * metadata are not judged (the OS reports its own launch failure), so
1821
+ * non-managed behavior is unchanged.
1822
+ */
1823
+ export function managedRuntimePreflight(launcher, { pins = PINNED_VENDOR_APPS } = {}) {
1824
+ const compatibility = readBundleCompatibility(launcher);
1825
+ if (!compatibility) return { ok: true, checked: false };
1826
+ const target = compatibility.codexHome ? "chatgpt" : "claude";
1827
+ const pin = pins[target];
1828
+ if (!pin) return { ok: true, checked: false };
1829
+ const command = RUNTIME_BRAND.cli.command;
1830
+ const repair = runtimeRepairInstruction(compatibility);
1831
+ const fail = (message) => ({ ok: false, checked: true, target, message: `${command} app: ${message}; ${repair}` });
1832
+
1833
+ if (target === "claude") {
1834
+ let executable = null;
1835
+ try {
1836
+ const plist = fs.readFileSync(path.join(launcher, "Contents", "Info.plist"), "utf8");
1837
+ executable = plist.match(/<key>CFBundleExecutable<\/key>\s*<string>([^<]+)<\/string>/u)?.[1] || null;
1838
+ } catch {
1839
+ executable = null;
1840
+ }
1841
+ const binary = executable ? path.join(launcher, "Contents", "MacOS", executable) : null;
1842
+ if (!binary || !fs.existsSync(binary)) {
1843
+ return fail(`managed Claude runtime binary is missing at ${binary || path.join(launcher, "Contents", "MacOS")} (pinned ${pin.version})`);
1844
+ }
1845
+ const found = bundleVersion(launcher);
1846
+ if (found !== pin.version) {
1847
+ return fail(`managed Claude runtime is version ${found || "unknown"} at ${launcher}, but this CLI pins ${pin.version}`);
1848
+ }
1849
+ return { ok: true, checked: true, target };
1850
+ }
1851
+
1852
+ const embedded = APP_DEFINITIONS.chatgpt.names
1853
+ .map((name) => path.join(launcher, "Contents", "Resources", name))
1854
+ .find((candidate) => fs.existsSync(candidate));
1855
+ if (!embedded) {
1856
+ return fail(`managed ChatGPT runtime bundle is missing under ${path.join(launcher, "Contents", "Resources")} (pinned ${pin.version})`);
1857
+ }
1858
+ const codexBinary = path.join(embedded, "Contents", "Resources", "codex");
1859
+ if (!fs.existsSync(codexBinary)) {
1860
+ return fail(`managed Codex runtime binary is missing at ${codexBinary} (pinned codex-cli ${pin.codexVersion})`);
1861
+ }
1862
+ const found = bundleVersion(embedded);
1863
+ if (found !== pin.version) {
1864
+ return fail(`managed ChatGPT runtime is version ${found || "unknown"} at ${embedded}, but this CLI pins ${pin.version} (codex-cli ${pin.codexVersion})`);
1865
+ }
1866
+ return { ok: true, checked: true, target };
1867
+ }
1868
+
1662
1869
  /**
1663
1870
  * Rewrite one tenant's app token helper with the current node/CLI paths.
1664
1871
  * Cheap and Impel-owned, so install recovery can use it to heal helpers whose
@@ -1768,6 +1975,7 @@ function writeVendoredClaudeBundleLocked(paths, vendorPath, gatewayUrl, safeStor
1768
1975
  }
1769
1976
  const safeStorageNamePatchCount = patchClaudeSafeStorageName(asarPath);
1770
1977
  const desktopTasksPreloadPatchCount = patchDesktopTasksPreload(asarPath, "claude");
1978
+ const cdpAuthKeyPatchCount = patchClaudeCdpAuthKey(asarPath);
1771
1979
  const newAsarHash = asarHeaderHash(asarPath);
1772
1980
 
1773
1981
  updateBundleIdentity(plistPath, {
@@ -1824,6 +2032,7 @@ function writeVendoredClaudeBundleLocked(paths, vendorPath, gatewayUrl, safeStor
1824
2032
  followupBoundAgentFilter: agentMentionPatches.filter,
1825
2033
  followupBoundAgentChip: agentMentionPatches.chip,
1826
2034
  desktopTasksPreload: desktopTasksPreloadPatchCount,
2035
+ cdpAuthKey: cdpAuthKeyPatchCount,
1827
2036
  },
1828
2037
  rendererAssets: {
1829
2038
  agentMentions: agentMentionPatches.assets,
@@ -2200,6 +2409,28 @@ function patchClaudeSafeStorageName(asarPath) {
2200
2409
  return applyFixedWidthAsarPatches(asarPath, archive, patches, "Claude Safe Storage app name");
2201
2410
  }
2202
2411
 
2412
+ // Replace the baked-in CDP-auth trust anchor with the Impel key so the
2413
+ // CI-isolated launch smoke can drive the managed bundle over CDP with a token
2414
+ // signed by the matching (Actions-secret-held) private key. Fail closed on any
2415
+ // count other than the exact pinned number of copies, and let the shared
2416
+ // fixed-width helper enforce byte-length parity (both keys are Ed25519 SPKI
2417
+ // PEM of equal length) so the ASAR layout is preserved.
2418
+ function patchClaudeCdpAuthKey(asarPath) {
2419
+ const source = fs.readFileSync(asarPath).toString("latin1");
2420
+ const occurrences = exactTextMatchCount(source, CLAUDE_CDP_AUTH_VENDOR_PUBLIC_KEY);
2421
+ if (occurrences !== CLAUDE_CDP_AUTH_KEY_COPIES) {
2422
+ throw new Error(
2423
+ `Claude CDP-auth trust-anchor patch expected ${CLAUDE_CDP_AUTH_KEY_COPIES} vendor key copies, found ${occurrences}`,
2424
+ );
2425
+ }
2426
+ return patchFixedWidthAsarString(
2427
+ asarPath,
2428
+ CLAUDE_CDP_AUTH_VENDOR_PUBLIC_KEY,
2429
+ IMPEL_CLAUDE_CDP_AUTH_PUBLIC_KEY,
2430
+ "Claude CDP-auth trust anchor",
2431
+ );
2432
+ }
2433
+
2203
2434
  function patchFastModeAuthGate(asarPath) {
2204
2435
  const archive = fs.readFileSync(asarPath);
2205
2436
  const source = archive.toString("latin1");
@@ -32,8 +32,11 @@ import {
32
32
  ensureVendorApp,
33
33
  fetchGatewayModels,
34
34
  installManagedAppFiles,
35
+ MANAGED_RUNTIME_PREFLIGHT_EXIT_CODE,
35
36
  managedChatGPTConfigDrifted,
37
+ managedClaudeConfigDrifted,
36
38
  managedLauncherName,
39
+ managedRuntimePreflight,
37
40
  normalizeAppTarget,
38
41
  quitBlockingApps,
39
42
  readTenantManifest,
@@ -294,7 +297,18 @@ function windowsProcessFailure(result) {
294
297
  export function openManagedLauncher(launcher, {
295
298
  spawn = spawnSync,
296
299
  reportError = console.error,
300
+ preflight = managedRuntimePreflight,
297
301
  } = {}) {
302
+ // Runtime-presence preflight (the v0.20.32 exit-127 rule): never hand a
303
+ // managed bundle whose vendored runtime is missing or off-pin to the OS —
304
+ // that launch dies with an unactionable bare failure. One actionable line
305
+ // and a distinct exit code instead; unmanaged bundles are not judged here.
306
+ const runtime = preflight(launcher);
307
+ if (runtime && runtime.ok === false) {
308
+ reportError(runtime.message);
309
+ process.exitCode = MANAGED_RUNTIME_PREFLIGHT_EXIT_CODE;
310
+ return false;
311
+ }
298
312
  const result = spawn("/usr/bin/open", ["-n", launcher], { encoding: "utf8" });
299
313
  if (!result?.error && result?.status === 0) return true;
300
314
  const detail = result?.error?.message
@@ -740,7 +754,11 @@ export async function cmdWindowsApps(argv, overrides = {}) {
740
754
  claudeUserData: io.claudeUserData(io.environment, staleTenantId),
741
755
  tenantName: staleTenantId === stored.tenantId ? stored.tenantName : null,
742
756
  });
743
- if (manifestIsFresh(stalePaths, stored) && !managedChatGPTConfigDrifted(stalePaths)) return true;
757
+ if (
758
+ manifestIsFresh(stalePaths, stored)
759
+ && !managedChatGPTConfigDrifted(stalePaths)
760
+ && !managedClaudeConfigDrifted(stalePaths)
761
+ ) return true;
744
762
  }
745
763
  }
746
764
  const config = await io.selectedConfig(targets, flags.tenant || null);
@@ -1329,7 +1347,12 @@ async function refreshApps(targets, { staleOnly = false, tenantId = null } = {},
1329
1347
  ? stored.tenantName || manifest?.tenantName
1330
1348
  : manifest?.tenantName,
1331
1349
  });
1332
- if (staleOnly && manifestIsFresh(paths, stored) && !managedChatGPTConfigDrifted(paths)) return;
1350
+ if (
1351
+ staleOnly
1352
+ && manifestIsFresh(paths, stored)
1353
+ && !managedChatGPTConfigDrifted(paths)
1354
+ && !managedClaudeConfigDrifted(paths)
1355
+ ) return;
1333
1356
 
1334
1357
  let config;
1335
1358
  try {
@@ -263,7 +263,7 @@ export function runNativeAgentMcpServer({
263
263
  let value;
264
264
  if (name === NATIVE_AGENT_ANSWER_TOOL) {
265
265
  if (mode !== "answer") throw new Error("this native-agent binding cannot answer directly");
266
- value = await transport.answer(args, { signal: controller.signal });
266
+ value = await transport.answer(args, { signal: controller.signal, onProgress });
267
267
  } else if (name === NATIVE_AGENT_RUN_TOOL) {
268
268
  if (mode === "recovery") throw new Error("retired native-agent bindings cannot start new runs");
269
269
  if (mode === "answer") throw new Error("direct-answer native-agent bindings cannot start durable runs");
@@ -2,7 +2,8 @@ import { spawn } from "node:child_process";
2
2
  import path from "node:path";
3
3
 
4
4
  import { parseFlags } from "../args.js";
5
- import { appPaths, managedChatGPTConfigDrifted } from "../apps.js";
5
+ import { appPaths, managedChatGPTConfigDrifted, managedClaudeConfigDrifted } from "../apps.js";
6
+ import { windowsClaudeUserData } from "../windowsApps.js";
6
7
  import {
7
8
  clearSessionFlushLock,
8
9
  collectSessionHook,
@@ -32,23 +33,36 @@ const SPEC = {
32
33
  [MANAGED_SESSION_HOOK_FLAG]: { type: "boolean" },
33
34
  };
34
35
 
36
+ // The Claude drift check reads the Claude app profile, which lives under
37
+ // %LOCALAPPDATA% on Windows rather than the darwin tenant root.
38
+ function managedAppDriftPaths(tenantId) {
39
+ return appPaths(undefined, tenantId, process.platform === "win32"
40
+ ? { claudeUserData: windowsClaudeUserData(process.env, tenantId) }
41
+ : {});
42
+ }
43
+
44
+ function anyManagedAppConfigDrifted(paths) {
45
+ return managedChatGPTConfigDrifted(paths) || managedClaudeConfigDrifted(paths);
46
+ }
47
+
35
48
  /**
36
- * Repair a managed ChatGPT profile the desktop app just rewrote out from under
37
- * the gateway. Hooks are the only Impel code guaranteed to run while the app
38
- * is broken (the app stops calling the provider token helper once the managed
39
- * `model_provider` key is gone), so each codex hook event checks for drift and
40
- * kicks one detached stale-only refresh — which the drift-aware staleness gate
41
- * turns into a real repair. The heartbeat lock keeps repeated hook events from
42
- * stacking refresh children while one is already running.
49
+ * Repair a managed app profile a vendor just rewrote out from under the
50
+ * gateway. Hooks and the token helper are the only Impel code guaranteed to
51
+ * run while an app is broken (a drifted config can stop calling the provider
52
+ * token helper entirely), so both check for drift and kick one detached
53
+ * stale-only refresh — which the drift-aware staleness gate turns into a real
54
+ * repair. The heartbeat lock keeps repeated events from stacking refresh
55
+ * children while one is already running. Content-checks both managed
56
+ * surfaces (ChatGPT/Codex and Claude) by default.
43
57
  */
44
- export function maybeRepairManagedCodexApp(tenantId, {
45
- drifted = managedChatGPTConfigDrifted,
58
+ export function maybeRepairManagedApps(tenantId, {
59
+ drifted = anyManagedAppConfigDrifted,
46
60
  lockIsFresh = sessionFlushLockIsFresh,
47
61
  touchLock = touchSessionFlushLock,
48
62
  spawnRefresh = spawnDetachedAppRefresh,
49
63
  } = {}) {
50
64
  if (!tenantId) return false;
51
- const paths = appPaths(undefined, tenantId);
65
+ const paths = managedAppDriftPaths(tenantId);
52
66
  if (!drifted(paths)) return false;
53
67
  const lock = path.join(paths.tenantRoot, "app-repair-heartbeat");
54
68
  if (lockIsFresh(lock, 60_000)) return false;
@@ -57,6 +71,19 @@ export function maybeRepairManagedCodexApp(tenantId, {
57
71
  return true;
58
72
  }
59
73
 
74
+ /**
75
+ * The codex session-hook entry point: same repair, scoped to ChatGPT/Codex
76
+ * drift exactly as before the helper was generalized (the codex hook fires on
77
+ * codex events; Claude drift is healed by the token helper and the Claude
78
+ * session hooks' own refresh channel).
79
+ */
80
+ export function maybeRepairManagedCodexApp(tenantId, overrides = {}) {
81
+ return maybeRepairManagedApps(tenantId, {
82
+ drifted: managedChatGPTConfigDrifted,
83
+ ...overrides,
84
+ });
85
+ }
86
+
60
87
  function startDetachedFlush({ provider, tenant, session }) {
61
88
  const invocation = impelCliInvocation([
62
89
  "sessions",
@@ -101,6 +128,12 @@ export async function cmdSessions(argv) {
101
128
  flush: false,
102
129
  });
103
130
  if (flags.provider === "codex") maybeRepairManagedCodexApp(flags.tenant);
131
+ // The Claude mirror of the codex repair above: Claude Desktop rewrites
132
+ // its co-owned profile files too, and its session hooks are the only
133
+ // Impel code guaranteed to still run afterward.
134
+ if (flags.provider === "claude_code") {
135
+ maybeRepairManagedApps(flags.tenant, { drifted: managedClaudeConfigDrifted });
136
+ }
104
137
  if (config && (config.tenantId === flags.tenant || environmentValue("SESSIONS_DEV_ORG_ID"))) {
105
138
  // Hooks fire on every session event; only spawn a flush child when no
106
139
  // live one is already polling this session's outbox (heartbeat lock).
@@ -1,14 +1,21 @@
1
1
  import { loadConfig } from "../config.js";
2
2
  import { parseFlags } from "../args.js";
3
3
  import { ensureTenantSelection, normalizeTenantId, tenantCredential } from "../tenants.js";
4
+ import { maybeRepairManagedApps } from "./sessions.js";
4
5
  import { RUNTIME_BRAND } from "../runtimeBrand.js";
5
6
 
6
7
  // This is the `apiKeyHelper` / auth-command contract: stdout (and only
7
8
  // stdout) must be exactly the bearer token, nothing else. Both Claude Code's
8
9
  // apiKeyHelper and Codex CLI's `model_providers.<id>.auth.command` call this.
9
- export async function cmdToken(argv = []) {
10
+ export async function cmdToken(argv = [], overrides = {}) {
11
+ const io = {
12
+ loadConfig,
13
+ ensureTenantSelection,
14
+ repairManagedApps: maybeRepairManagedApps,
15
+ ...overrides,
16
+ };
10
17
  const { flags } = parseFlags(argv, { tenant: { type: "string" } });
11
- const config = loadConfig();
18
+ const config = io.loadConfig();
12
19
  if (!config?.pat) {
13
20
  process.stderr.write(`${RUNTIME_BRAND.cli.command}: not authenticated. Run \`${RUNTIME_BRAND.cli.command} setup\` (or \`${RUNTIME_BRAND.cli.command} auth\`) first.\n`);
14
21
  process.exitCode = 1;
@@ -17,8 +24,21 @@ export async function cmdToken(argv = []) {
17
24
  try {
18
25
  const tenantId = flags.tenant
19
26
  ? normalizeTenantId(flags.tenant)
20
- : (await ensureTenantSelection(config)).tenantId;
27
+ : (await io.ensureTenantSelection(config)).tenantId;
21
28
  process.stdout.write(`${tenantCredential(config.pat, tenantId)}\n`);
29
+ // Drift-aware self-heal, strictly AFTER the token bytes are on stdout: the
30
+ // vendor apps call this helper on every auth, which makes it an Impel
31
+ // entry point that keeps running even after a vendor settings-writer
32
+ // rewrites a managed profile (a drifted config once stopped invoking the
33
+ // rest of the heal channel entirely). Kick the same detached,
34
+ // heartbeat-locked stale-only refresh the session hooks use — for both
35
+ // Codex and Claude drift. It must never write to stdout, never block the
36
+ // emission above, and never change this command's exit code.
37
+ try {
38
+ io.repairManagedApps(tenantId);
39
+ } catch {
40
+ // Opportunistic only; the token contract is already fulfilled.
41
+ }
22
42
  } catch (error) {
23
43
  process.stderr.write(`${RUNTIME_BRAND.cli.command}: ${error.message}\n`);
24
44
  process.exitCode = 1;
package/src/macSetup.js CHANGED
@@ -7,7 +7,7 @@ import { spawnSync } from "node:child_process";
7
7
  import { ensureImpelClaudeProfile, ensureImpelCodexProfile } from "./cliProfiles.js";
8
8
  import { findNativeBinary } from "./nativeProcess.js";
9
9
  import { syncSkillsSafe } from "./skills.js";
10
- import { verifyReviewedMacVendorCli } from "./vendorCliBinaries.js";
10
+ import { findReviewedVendorCliBinary, verifyReviewedMacVendorCli } from "./vendorCliBinaries.js";
11
11
  import { PINNED_VENDOR_CLI_VERSIONS } from "./vendorCliVersions.js";
12
12
 
13
13
  const MAX_INSTALLER_BYTES = 512 * 1024;
@@ -49,13 +49,14 @@ export const MAC_CLI_INSTALLERS = Object.freeze({
49
49
  });
50
50
 
51
51
  function detectMacClis(find, environment, verify) {
52
- const detect = (tool) => find(
53
- tool,
54
- environment,
55
- "darwin",
56
- "IMPEL",
57
- (binary) => verify(tool, binary, environment),
58
- );
52
+ // Detection must share the launcher's reviewed resolution (including the
53
+ // side-by-side fallback after vendor auto-update drift); a raw PATH probe
54
+ // here would classify a drifted-but-launchable install as missing and
55
+ // reinstall it on every setup/update pass.
56
+ const detect = (tool) => findReviewedVendorCliBinary(tool, environment, "darwin", "IMPEL", {
57
+ find,
58
+ verify,
59
+ });
59
60
  return { claude: detect("claude"), codex: detect("codex") };
60
61
  }
61
62