impel-cli 0.20.37 → 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/README.md +25 -12
- package/RELEASE_NOTES.md +21 -0
- package/bin/impel.js +10 -1
- package/docs/native-agent-host-capability-matrix.md +16 -4
- package/package.json +1 -1
- package/scripts/capture-release-fixtures.mjs +56 -0
- package/scripts/clean-machine/README.md +51 -0
- package/scripts/clean-machine/run-clean-windows.sh +115 -0
- package/scripts/clean-machine/userdata.ps1.template +102 -0
- package/scripts/regen-managed-artifacts.mjs +475 -0
- package/scripts/verify-vendor-pins.mjs +207 -0
- package/src/agents.js +256 -32
- package/src/apps.js +233 -2
- package/src/cli.js +1 -0
- package/src/commands/apps.js +25 -2
- package/src/commands/launch.js +177 -4
- package/src/commands/mcp.js +1 -1
- package/src/commands/sessions.js +44 -11
- package/src/commands/token.js +23 -3
- package/src/macSetup.js +9 -8
- package/src/nativeAgentTransport.js +13 -4
- package/src/selfInvocation.js +12 -2
- package/src/vendorCliBinaries.js +36 -7
- package/src/verbatimRelay.js +9 -1
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
|
-
|
|
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.
|
|
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");
|
package/src/cli.js
CHANGED
|
@@ -34,6 +34,7 @@ Get started:
|
|
|
34
34
|
|
|
35
35
|
Work:
|
|
36
36
|
impel claude [args...] Launch Claude Code with an isolated Impel profile
|
|
37
|
+
impel claude --agent <id|exact-title> ... Run one fixed tenant agent without a parent hop
|
|
37
38
|
impel codex [args...] Launch Codex with an isolated Impel profile
|
|
38
39
|
impel codex --agent <id|exact-title> ... Run one fixed tenant agent without a parent hop
|
|
39
40
|
impel remote handoff|dispatch|handback Move or control provider-native sessions remotely
|
package/src/commands/apps.js
CHANGED
|
@@ -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 (
|
|
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 (
|
|
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 {
|
package/src/commands/launch.js
CHANGED
|
@@ -6,7 +6,11 @@ import {
|
|
|
6
6
|
ensureImpelClaudeProfile,
|
|
7
7
|
ensureImpelCodexProfile,
|
|
8
8
|
} from "../cliProfiles.js";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
resolveManagedClaudeAgent,
|
|
11
|
+
resolveManagedCodexAgentProfile,
|
|
12
|
+
syncAgentProfilesSafe,
|
|
13
|
+
} from "../agents.js";
|
|
10
14
|
import {
|
|
11
15
|
crossAppModelsEnabled,
|
|
12
16
|
loadConfig,
|
|
@@ -77,10 +81,43 @@ const IMPEL_CODEX_RUNTIME_OVERRIDES = [
|
|
|
77
81
|
];
|
|
78
82
|
|
|
79
83
|
const IMPEL_CODEX_UNRESTRICTED_ARGUMENT = "--dangerously-bypass-approvals-and-sandbox";
|
|
84
|
+
const IMPEL_CLAUDE_MANAGED_AGENT_ARGUMENTS = [
|
|
85
|
+
"--allow-dangerously-skip-permissions",
|
|
86
|
+
"--permission-mode",
|
|
87
|
+
"bypassPermissions",
|
|
88
|
+
// Pinned Claude 2.1.220 treats agents, skills, commands, and settings as
|
|
89
|
+
// source-scoped. Load only the isolated user profile so project/local agent
|
|
90
|
+
// definitions cannot replace the integrity-verified adapter after launch.
|
|
91
|
+
"--setting-sources",
|
|
92
|
+
"user",
|
|
93
|
+
];
|
|
80
94
|
|
|
81
|
-
export function impelLaunchArguments(tool, argv, {
|
|
95
|
+
export function impelLaunchArguments(tool, argv, {
|
|
96
|
+
claudeManagedAgent = null,
|
|
97
|
+
codexAgentProfile = null,
|
|
98
|
+
} = {}) {
|
|
82
99
|
if (!RUNTIME_BRAND.features.agents) return [...argv];
|
|
83
100
|
if (tool === "claude") {
|
|
101
|
+
if (claudeManagedAgent) {
|
|
102
|
+
const { launchName, launchAgentsJson } = claudeManagedAgent;
|
|
103
|
+
if (typeof launchName !== "string"
|
|
104
|
+
|| !launchName
|
|
105
|
+
|| typeof launchAgentsJson !== "string"
|
|
106
|
+
|| !launchAgentsJson) {
|
|
107
|
+
throw new Error("managed Claude launch configuration is invalid");
|
|
108
|
+
}
|
|
109
|
+
if (Buffer.byteLength(launchAgentsJson, "utf8") > 12 * 1024) {
|
|
110
|
+
throw new Error("managed Claude launch configuration is too large");
|
|
111
|
+
}
|
|
112
|
+
return [
|
|
113
|
+
...IMPEL_CLAUDE_MANAGED_AGENT_ARGUMENTS,
|
|
114
|
+
"--agents",
|
|
115
|
+
launchAgentsJson,
|
|
116
|
+
"--agent",
|
|
117
|
+
launchName,
|
|
118
|
+
...argv,
|
|
119
|
+
];
|
|
120
|
+
}
|
|
84
121
|
return ["--append-system-prompt", IMPEL_CLAUDE_PARENT_DELEGATION_INSTRUCTIONS, ...argv];
|
|
85
122
|
}
|
|
86
123
|
if (tool === "codex") {
|
|
@@ -99,6 +136,113 @@ export function impelLaunchArguments(tool, argv, { codexAgentProfile = null } =
|
|
|
99
136
|
throw new Error(`unsupported CLI launcher: ${tool}`);
|
|
100
137
|
}
|
|
101
138
|
|
|
139
|
+
const CLAUDE_MANAGED_AGENT_LOCKED_OPTIONS = new Set([
|
|
140
|
+
"--add-dir",
|
|
141
|
+
"--agents",
|
|
142
|
+
"--allow-tools",
|
|
143
|
+
"--allow-dangerously-skip-permissions",
|
|
144
|
+
"--allowedTools",
|
|
145
|
+
"--allowed-tools",
|
|
146
|
+
"--append-subagent-system-prompt",
|
|
147
|
+
"--append-system-prompt",
|
|
148
|
+
"--append-system-prompt-file",
|
|
149
|
+
"--bare",
|
|
150
|
+
"--brief",
|
|
151
|
+
"--chrome",
|
|
152
|
+
"--dangerously-skip-permissions",
|
|
153
|
+
"--disable-slash-commands",
|
|
154
|
+
"--disallowedTools",
|
|
155
|
+
"--disallowed-tools",
|
|
156
|
+
"--effort",
|
|
157
|
+
"--exclude-dynamic-system-prompt-sections",
|
|
158
|
+
"--fallback-model",
|
|
159
|
+
"--file",
|
|
160
|
+
"--ide",
|
|
161
|
+
"--managed-settings",
|
|
162
|
+
"--mcp-config",
|
|
163
|
+
"--model",
|
|
164
|
+
"--no-chrome",
|
|
165
|
+
"--permission-mode",
|
|
166
|
+
"--permission-prompt-tool",
|
|
167
|
+
"--plugin-dir",
|
|
168
|
+
"--plugin-dir-no-mcp",
|
|
169
|
+
"--plugin-url",
|
|
170
|
+
"--safe-mode",
|
|
171
|
+
"--setting-sources",
|
|
172
|
+
"--settings",
|
|
173
|
+
"--strict-mcp-config",
|
|
174
|
+
"--system-prompt",
|
|
175
|
+
"--system-prompt-file",
|
|
176
|
+
"--tmux",
|
|
177
|
+
"--tools",
|
|
178
|
+
"--worktree",
|
|
179
|
+
]);
|
|
180
|
+
|
|
181
|
+
function claudeManagedAgentLockedOption(argument) {
|
|
182
|
+
if (argument === "-w" || (argument.startsWith("-w") && argument.length > 2)) return "-w";
|
|
183
|
+
if (CLAUDE_MANAGED_AGENT_LOCKED_OPTIONS.has(argument)) return argument;
|
|
184
|
+
return [...CLAUDE_MANAGED_AGENT_LOCKED_OPTIONS].find((option) =>
|
|
185
|
+
argument.startsWith(`${option}=`)
|
|
186
|
+
) || null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function parseClaudeAgentLaunchArguments(argv) {
|
|
190
|
+
const passthrough = [];
|
|
191
|
+
const selectors = [];
|
|
192
|
+
let literal = false;
|
|
193
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
194
|
+
const argument = argv[index];
|
|
195
|
+
if (literal) {
|
|
196
|
+
passthrough.push(argument);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (argument === "--") {
|
|
200
|
+
literal = true;
|
|
201
|
+
passthrough.push(argument);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (argument === "--agent") {
|
|
205
|
+
const value = argv[index += 1];
|
|
206
|
+
selectors.push(typeof value === "string" && value && !value.startsWith("-") ? value : null);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (argument.startsWith("--agent=")) {
|
|
210
|
+
const value = argument.slice("--agent=".length);
|
|
211
|
+
selectors.push(value && !value.startsWith("-") ? value : null);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
passthrough.push(argument);
|
|
215
|
+
}
|
|
216
|
+
const selector = selectors.length === 1 ? selectors[0] : null;
|
|
217
|
+
return {
|
|
218
|
+
selector,
|
|
219
|
+
argv: selector === null ? [...argv] : passthrough,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function assertManagedClaudeAgentLaunchArguments(argv) {
|
|
224
|
+
let literal = false;
|
|
225
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
226
|
+
const argument = argv[index];
|
|
227
|
+
if (literal) continue;
|
|
228
|
+
if (argument === "--") {
|
|
229
|
+
literal = true;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (argument === "--agent") {
|
|
233
|
+
index += 1;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (argument.startsWith("--agent=")) continue;
|
|
237
|
+
const locked = claudeManagedAgentLockedOption(argument);
|
|
238
|
+
if (locked) {
|
|
239
|
+
throw new Error(
|
|
240
|
+
`${locked} cannot be used with a verified managed Claude agent; Impel locks model, instructions, tools, MCP, settings, and permission policy`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
102
246
|
const CODEX_AGENT_LOCKED_OPTIONS = new Set([
|
|
103
247
|
"-c",
|
|
104
248
|
"--config",
|
|
@@ -264,8 +408,11 @@ function runNativeCli(tool, argv, environment) {
|
|
|
264
408
|
|
|
265
409
|
export async function cmdLaunch(tool, argv) {
|
|
266
410
|
let nativeArgv = [...argv];
|
|
411
|
+
let claudeAgentSelector = null;
|
|
267
412
|
let codexAgentSelector = null;
|
|
268
|
-
if (tool === "
|
|
413
|
+
if (tool === "claude" && RUNTIME_BRAND.features.agents) {
|
|
414
|
+
({ selector: claudeAgentSelector, argv: nativeArgv } = parseClaudeAgentLaunchArguments(argv));
|
|
415
|
+
} else if (tool === "codex") {
|
|
269
416
|
try {
|
|
270
417
|
({ selector: codexAgentSelector, argv: nativeArgv } = parseCodexAgentLaunchArguments(argv));
|
|
271
418
|
} catch (error) {
|
|
@@ -356,6 +503,29 @@ export async function cmdLaunch(tool, argv) {
|
|
|
356
503
|
});
|
|
357
504
|
}
|
|
358
505
|
|
|
506
|
+
let claudeManagedAgent = null;
|
|
507
|
+
if (RUNTIME_BRAND.features.agents && claudeAgentSelector !== null) {
|
|
508
|
+
try {
|
|
509
|
+
claudeManagedAgent = resolveManagedClaudeAgent(
|
|
510
|
+
agentProfile.root,
|
|
511
|
+
tenantId,
|
|
512
|
+
claudeAgentSelector,
|
|
513
|
+
);
|
|
514
|
+
if (claudeManagedAgent) {
|
|
515
|
+
assertManagedClaudeAgentLaunchArguments(nativeArgv);
|
|
516
|
+
} else {
|
|
517
|
+
// `--agent` is also Claude's native user-agent selector. If the exact
|
|
518
|
+
// value is not in the integrity-tracked Impel manifest, preserve the
|
|
519
|
+
// user's original argv and ordinary permission behavior byte-for-byte.
|
|
520
|
+
nativeArgv = [...argv];
|
|
521
|
+
}
|
|
522
|
+
} catch (error) {
|
|
523
|
+
console.error(`impel claude: ${redactSecretText(error?.message || error)}`);
|
|
524
|
+
process.exitCode = 1;
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
359
529
|
let codexAgentProfile = null;
|
|
360
530
|
if (codexAgentSelector !== null) {
|
|
361
531
|
try {
|
|
@@ -377,7 +547,10 @@ export async function cmdLaunch(tool, argv) {
|
|
|
377
547
|
const { env: launchEnvironment } = withGitEnvironment(environment, { baseEnvironment: environment });
|
|
378
548
|
const exitCode = await runNativeCli(
|
|
379
549
|
tool,
|
|
380
|
-
impelLaunchArguments(tool, nativeArgv, {
|
|
550
|
+
impelLaunchArguments(tool, nativeArgv, {
|
|
551
|
+
claudeManagedAgent,
|
|
552
|
+
codexAgentProfile,
|
|
553
|
+
}),
|
|
381
554
|
launchEnvironment,
|
|
382
555
|
);
|
|
383
556
|
if (exitCode !== 0) process.exitCode = exitCode;
|
package/src/commands/mcp.js
CHANGED
|
@@ -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");
|
package/src/commands/sessions.js
CHANGED
|
@@ -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
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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
|
|
45
|
-
drifted =
|
|
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 =
|
|
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).
|