impel-cli 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +695 -0
- package/bin/impel.js +7 -0
- package/package.json +29 -0
- package/src/apps.js +1263 -0
- package/src/args.js +36 -0
- package/src/claudeSetup.js +207 -0
- package/src/cli.js +184 -0
- package/src/cliProfiles.js +216 -0
- package/src/codexSecurity.js +184 -0
- package/src/codexSetup.js +224 -0
- package/src/commands/apps.js +538 -0
- package/src/commands/auth.js +89 -0
- package/src/commands/doctor.js +215 -0
- package/src/commands/experimental.js +60 -0
- package/src/commands/launch.js +161 -0
- package/src/commands/mcp.js +94 -0
- package/src/commands/setup.js +350 -0
- package/src/commands/skills.js +108 -0
- package/src/commands/status.js +95 -0
- package/src/commands/tasks.js +359 -0
- package/src/commands/tenant.js +77 -0
- package/src/commands/token.js +25 -0
- package/src/commands/update.js +217 -0
- package/src/commands/use.js +208 -0
- package/src/config.js +98 -0
- package/src/doctor.js +546 -0
- package/src/nativeProcess.js +192 -0
- package/src/prompt.js +51 -0
- package/src/selfInvocation.js +21 -0
- package/src/skills.js +314 -0
- package/src/tenants.js +194 -0
- package/src/updates.js +181 -0
- package/src/windowsApps.js +439 -0
- package/src/windowsSetup.js +122 -0
package/src/apps.js
ADDED
|
@@ -0,0 +1,1263 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import crypto from "node:crypto";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import {
|
|
8
|
+
hardenManagedCodexToml,
|
|
9
|
+
secureAllManagedCodexHomes,
|
|
10
|
+
secureManagedCodexHome,
|
|
11
|
+
} from "./codexSecurity.js";
|
|
12
|
+
import { normalizeTenantId } from "./tenants.js";
|
|
13
|
+
import { crossAppModelsEnabled, redactSecretText } from "./config.js";
|
|
14
|
+
|
|
15
|
+
export const CLAUDE_CONFIG_ID = "1ced0000-0000-4000-8000-000000000001";
|
|
16
|
+
const CHATGPT_CONFIG_START = "# >>> impel app managed gateway >>>";
|
|
17
|
+
const CHATGPT_CONFIG_END = "# <<< impel app managed gateway <<<";
|
|
18
|
+
const CLI_VERSION = JSON.parse(
|
|
19
|
+
fs.readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8"),
|
|
20
|
+
).version;
|
|
21
|
+
const APP_DEFINITIONS = {
|
|
22
|
+
claude: {
|
|
23
|
+
label: "Impel Claude",
|
|
24
|
+
cask: "claude",
|
|
25
|
+
names: ["Claude.app"],
|
|
26
|
+
executableNames: ["Claude"],
|
|
27
|
+
},
|
|
28
|
+
chatgpt: {
|
|
29
|
+
label: "Impel ChatGPT",
|
|
30
|
+
cask: "codex-app",
|
|
31
|
+
names: ["ChatGPT.app", "Codex.app"],
|
|
32
|
+
executableNames: ["ChatGPT", "Codex"],
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const STANDARD_REASONING_LEVELS = [
|
|
37
|
+
{ effort: "low", description: "Fast responses with lighter reasoning" },
|
|
38
|
+
{ effort: "medium", description: "Balances speed and reasoning depth for everyday tasks" },
|
|
39
|
+
{ effort: "high", description: "Greater reasoning depth for complex problems" },
|
|
40
|
+
{ effort: "xhigh", description: "Extra high reasoning depth for complex problems" },
|
|
41
|
+
];
|
|
42
|
+
const MAX_REASONING_LEVEL = { effort: "max", description: "Maximum reasoning depth for the hardest problems" };
|
|
43
|
+
const ULTRA_REASONING_LEVEL = { effort: "ultra", description: "Maximum reasoning with automatic task delegation" };
|
|
44
|
+
const FAST_SERVICE_TIER = { id: "priority", name: "Fast", description: "1.5x speed, increased usage" };
|
|
45
|
+
const CLAUDE_PLAN_USAGE_GUARD = "if(!Ze().hasOrgPolicyBackend())return;if((r=fr())";
|
|
46
|
+
const IMPEL_CLAUDE_PLAN_USAGE_GUARD = `${"if(!wze())return;".padEnd("if(!Ze().hasOrgPolicyBackend())return;".length, " ")}if((r=fr())`;
|
|
47
|
+
const CLAUDE_PLAN_USAGE_REQUEST = 'P.net.fetch(`${ht()}/api/organizations/${o}/usage`,{signal:AbortSignal.timeout(mcr)})';
|
|
48
|
+
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, " ");
|
|
49
|
+
|
|
50
|
+
export const FALLBACK_MODELS = [
|
|
51
|
+
{ id: "claude-opus-4-8", provider: "claude", display_name: "Claude Opus 4.8", family: "opus", family_default: true, default: true, context_window: 200000 },
|
|
52
|
+
{ id: "claude-sonnet-5", provider: "claude", display_name: "Claude Sonnet 5", family: "sonnet", family_default: true, context_window: 200000 },
|
|
53
|
+
{ id: "claude-sonnet-4-6", provider: "claude", display_name: "Claude Sonnet 4.6", family: "sonnet", context_window: 200000 },
|
|
54
|
+
{ id: "claude-haiku-4-5", provider: "claude", display_name: "Claude Haiku 4.5", family: "haiku", family_default: true, context_window: 200000 },
|
|
55
|
+
{ id: "claude-fable-5", provider: "claude", display_name: "Claude Fable 5", family: "fable", family_default: true, context_window: 200000 },
|
|
56
|
+
{ id: "gpt-5.6-sol", provider: "codex", display_name: "GPT-5.6-Sol", description: "Latest frontier agentic coding model.", default: true, priority: 1, default_reasoning_level: "medium", supported_reasoning_levels: [...STANDARD_REASONING_LEVELS, MAX_REASONING_LEVEL, ULTRA_REASONING_LEVEL], service_tiers: [FAST_SERVICE_TIER], context_window: 372000, max_context_window: 372000, use_responses_lite: true, tool_mode: "code_mode_only", multi_agent_version: "v2" },
|
|
57
|
+
{ id: "gpt-5.6-terra", provider: "codex", display_name: "GPT-5.6-Terra", description: "Balanced agentic coding model for everyday work.", priority: 2, default_reasoning_level: "medium", supported_reasoning_levels: [...STANDARD_REASONING_LEVELS, MAX_REASONING_LEVEL, ULTRA_REASONING_LEVEL], service_tiers: [FAST_SERVICE_TIER], context_window: 372000, max_context_window: 372000, use_responses_lite: true, tool_mode: "code_mode_only", multi_agent_version: "v2" },
|
|
58
|
+
{ id: "gpt-5.6-luna", provider: "codex", display_name: "GPT-5.6-Luna", description: "Fast and affordable agentic coding model.", priority: 3, default_reasoning_level: "medium", supported_reasoning_levels: [...STANDARD_REASONING_LEVELS, MAX_REASONING_LEVEL], service_tiers: [FAST_SERVICE_TIER], context_window: 372000, max_context_window: 372000, use_responses_lite: true, tool_mode: "code_mode_only", multi_agent_version: "v1" },
|
|
59
|
+
{ id: "gpt-5.5", provider: "codex", display_name: "GPT-5.5", description: "Frontier model for complex coding, research, and real-world work.", priority: 7, default_reasoning_level: "medium", supported_reasoning_levels: STANDARD_REASONING_LEVELS, service_tiers: [FAST_SERVICE_TIER], context_window: 272000, max_context_window: 272000, use_responses_lite: false, tool_mode: null, multi_agent_version: null },
|
|
60
|
+
{ id: "gpt-5.4", provider: "codex", display_name: "GPT-5.4", description: "Strong model for everyday coding.", priority: 16, default_reasoning_level: "medium", supported_reasoning_levels: STANDARD_REASONING_LEVELS, service_tiers: [FAST_SERVICE_TIER], context_window: 272000, max_context_window: 1000000, use_responses_lite: false, tool_mode: null, multi_agent_version: null },
|
|
61
|
+
{ id: "gpt-5.4-mini", provider: "codex", display_name: "GPT-5.4-Mini", description: "Small, fast, and cost-efficient model for simpler coding tasks.", priority: 23, default_reasoning_level: "medium", supported_reasoning_levels: STANDARD_REASONING_LEVELS, service_tiers: [], context_window: 272000, max_context_window: 272000, use_responses_lite: false, tool_mode: null, multi_agent_version: null },
|
|
62
|
+
{ id: "gpt-5.3-codex-spark", provider: "codex", display_name: "GPT-5.3-Codex-Spark", description: "Ultra-fast coding model.", priority: 26, default_reasoning_level: "high", supported_reasoning_levels: STANDARD_REASONING_LEVELS, service_tiers: [], context_window: 128000, max_context_window: 128000, use_responses_lite: false, tool_mode: null, multi_agent_version: null },
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
export function normalizeAppTarget(value) {
|
|
66
|
+
if (value == null || value === "all") return ["claude", "chatgpt"];
|
|
67
|
+
if (value === "codex") return ["chatgpt"];
|
|
68
|
+
if (value === "claude" || value === "chatgpt") return [value];
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Bump when the written config/manifest schema changes; a mismatch forces the
|
|
73
|
+
// slow open path (and thus a full config rewrite) after a CLI update.
|
|
74
|
+
export const CURRENT_CONFIG_VERSION = 8;
|
|
75
|
+
|
|
76
|
+
/** Parse the tenant's install manifest, or null when absent/corrupt. */
|
|
77
|
+
export function readTenantManifest(homeDir = os.homedir(), tenantId = null) {
|
|
78
|
+
try {
|
|
79
|
+
const manifest = JSON.parse(
|
|
80
|
+
fs.readFileSync(path.join(appPaths(homeDir, tenantId).tenantRoot, "manifest.json"), "utf8"),
|
|
81
|
+
);
|
|
82
|
+
return manifest && typeof manifest === "object" ? manifest : null;
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Parse a vendored bundle's build record, or null when absent/corrupt. */
|
|
89
|
+
export function readBundleCompatibility(launcher) {
|
|
90
|
+
try {
|
|
91
|
+
const compatibility = JSON.parse(
|
|
92
|
+
fs.readFileSync(path.join(launcher, "Contents", "Resources", "impel-compatibility.json"), "utf8"),
|
|
93
|
+
);
|
|
94
|
+
return compatibility && typeof compatibility === "object" ? compatibility : null;
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A vendored bundle needs no rebuild when it was built by this CLI version
|
|
102
|
+
* from the vendor app version currently on disk. Rebuilds are the expensive
|
|
103
|
+
* part of install/open (bundle clone + asar patch + deep codesign), so this
|
|
104
|
+
* check is what makes repeat opens fast.
|
|
105
|
+
*/
|
|
106
|
+
export function bundleIsCurrent(status) {
|
|
107
|
+
if (!status.launcherInstalled || !status.vendorPath || !status.vendorVersion) return false;
|
|
108
|
+
const compatibility = readBundleCompatibility(status.launcher);
|
|
109
|
+
return Boolean(
|
|
110
|
+
compatibility
|
|
111
|
+
&& compatibility.cliVersion === CLI_VERSION
|
|
112
|
+
&& compatibility.vendorVersion === status.vendorVersion
|
|
113
|
+
&& (status.target !== "chatgpt" || (
|
|
114
|
+
compatibility.vendorSignaturesPreserved === true
|
|
115
|
+
&& compatibility.resourceRoot === "linked_vendor_resources"
|
|
116
|
+
)),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Install/update must not run while the vendor or Impel-managed apps are open:
|
|
121
|
+
// `brew` refuses to upgrade a running cask, and cloning/replacing a bundle
|
|
122
|
+
// under a running instance produces broken copies and codesign failures.
|
|
123
|
+
|
|
124
|
+
/** osascript application names whose bundles install/update will touch. */
|
|
125
|
+
export function blockingAppNames(targets) {
|
|
126
|
+
const names = [];
|
|
127
|
+
for (const target of targets) {
|
|
128
|
+
const definition = APP_DEFINITIONS[target];
|
|
129
|
+
for (const bundleName of definition.names) names.push(bundleName.replace(/\.app$/u, ""));
|
|
130
|
+
names.push(definition.label);
|
|
131
|
+
}
|
|
132
|
+
return names;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* pgrep/pkill -f pattern for one app's processes. Anchored on
|
|
137
|
+
* "/<Name>.app/Contents/MacOS/" so "Claude" never matches the space-prefixed
|
|
138
|
+
* "Impel Claude.app" path (and vice versa).
|
|
139
|
+
*/
|
|
140
|
+
export function appProcessPattern(name) {
|
|
141
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
142
|
+
return `/${escaped}\\.app/Contents/MacOS/`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const QUIT_POLL_MS = 500;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Closes every running app whose bundle the given targets will replace:
|
|
149
|
+
* graceful AppleScript quit first, SIGTERM if it lingers, and a hard error
|
|
150
|
+
* telling the user to close it themselves if even that fails.
|
|
151
|
+
*/
|
|
152
|
+
export async function quitBlockingApps(targets, {
|
|
153
|
+
log = console.log,
|
|
154
|
+
isRunning = (name) =>
|
|
155
|
+
spawnSync("/usr/bin/pgrep", ["-f", appProcessPattern(name)], { stdio: "ignore" }).status === 0,
|
|
156
|
+
requestQuit = (name) =>
|
|
157
|
+
// osascript `tell` would LAUNCH a non-running app, so callers must gate on
|
|
158
|
+
// isRunning first; the timeout covers apps stuck on a save/confirm dialog.
|
|
159
|
+
spawnSync("/usr/bin/osascript", ["-e", `tell application ${JSON.stringify(name)} to quit`], {
|
|
160
|
+
stdio: "ignore",
|
|
161
|
+
timeout: 8_000,
|
|
162
|
+
}),
|
|
163
|
+
forceQuit = (name) =>
|
|
164
|
+
spawnSync("/usr/bin/pkill", ["-f", appProcessPattern(name)], { stdio: "ignore" }),
|
|
165
|
+
wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
166
|
+
quitWaitMs = 10_000,
|
|
167
|
+
killWaitMs = 5_000,
|
|
168
|
+
} = {}) {
|
|
169
|
+
async function goneWithin(name, budgetMs) {
|
|
170
|
+
for (let waited = 0; waited <= budgetMs; waited += QUIT_POLL_MS) {
|
|
171
|
+
if (!isRunning(name)) return true;
|
|
172
|
+
await wait(QUIT_POLL_MS);
|
|
173
|
+
}
|
|
174
|
+
return !isRunning(name);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
for (const name of blockingAppNames(targets)) {
|
|
178
|
+
if (!isRunning(name)) continue;
|
|
179
|
+
log(`${name} is running; closing it before touching app bundles…`);
|
|
180
|
+
requestQuit(name);
|
|
181
|
+
if (await goneWithin(name, quitWaitMs)) continue;
|
|
182
|
+
log(`${name} did not quit gracefully; terminating it…`);
|
|
183
|
+
forceQuit(name);
|
|
184
|
+
if (await goneWithin(name, killWaitMs)) continue;
|
|
185
|
+
throw new Error(`${name} is still running; close it manually, then re-run`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function appPaths(homeDir = os.homedir(), tenantId = null, { claudeUserData = null } = {}) {
|
|
190
|
+
const root = process.env.IMPEL_APP_HOME || path.join(homeDir, ".config", "impel", "apps");
|
|
191
|
+
const tenantRoot = tenantId
|
|
192
|
+
? path.join(root, "tenants", normalizeTenantId(tenantId))
|
|
193
|
+
: root;
|
|
194
|
+
return {
|
|
195
|
+
root,
|
|
196
|
+
tenantRoot,
|
|
197
|
+
tokenHelper: path.join(tenantRoot, "bin", "token"),
|
|
198
|
+
claude: {
|
|
199
|
+
root: claudeUserData || path.join(tenantRoot, "claude"),
|
|
200
|
+
userData: claudeUserData || path.join(tenantRoot, "claude", "user-data"),
|
|
201
|
+
launcher: path.join(homeDir, "Applications", "Impel Claude.app"),
|
|
202
|
+
},
|
|
203
|
+
chatgpt: {
|
|
204
|
+
root: path.join(tenantRoot, "chatgpt"),
|
|
205
|
+
codexHome: path.join(tenantRoot, "chatgpt", "codex-home"),
|
|
206
|
+
browserData: path.join(tenantRoot, "chatgpt", "browser-data"),
|
|
207
|
+
catalog: path.join(tenantRoot, "chatgpt", "codex-home", "models.json"),
|
|
208
|
+
launcher: path.join(homeDir, "Applications", "Impel ChatGPT.app"),
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export async function fetchGatewayModels(config, fetchImpl = fetch) {
|
|
214
|
+
const controller = new AbortController();
|
|
215
|
+
const timeout = setTimeout(() => controller.abort(), 20000);
|
|
216
|
+
try {
|
|
217
|
+
const response = await fetchImpl(`${config.gatewayUrl}/v1/models`, {
|
|
218
|
+
headers: { authorization: `Bearer ${config.pat}` },
|
|
219
|
+
signal: controller.signal,
|
|
220
|
+
});
|
|
221
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
222
|
+
const payload = await response.json();
|
|
223
|
+
if (config.tenantId && payload?.org_id !== config.tenantId) {
|
|
224
|
+
throw new Error("gateway model catalog tenant did not match the selected tenant");
|
|
225
|
+
}
|
|
226
|
+
if (config.productAccess && payload?.product_access !== config.productAccess) {
|
|
227
|
+
throw new Error("gateway model catalog product access did not match the live entitlement");
|
|
228
|
+
}
|
|
229
|
+
if (!Array.isArray(payload?.data)) throw new Error("response has no model data array");
|
|
230
|
+
const models = payload.data.filter(isGatewayModel);
|
|
231
|
+
if (models.length === 0) throw new Error("gateway returned no supported models");
|
|
232
|
+
return { models, source: "gateway", version: payload.version ?? null };
|
|
233
|
+
} finally {
|
|
234
|
+
clearTimeout(timeout);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function installManagedAppFiles({
|
|
239
|
+
config,
|
|
240
|
+
targets,
|
|
241
|
+
models,
|
|
242
|
+
homeDir = os.homedir(),
|
|
243
|
+
vendorPaths = {},
|
|
244
|
+
writeBundles = true,
|
|
245
|
+
writeTokenHelperFile = true,
|
|
246
|
+
claudeUserData = null,
|
|
247
|
+
chatgptInvocations = null,
|
|
248
|
+
}) {
|
|
249
|
+
// true = rebuild every target's bundle; false = configs only; an array
|
|
250
|
+
// limits the expensive rebuild to just the stale targets.
|
|
251
|
+
const bundleTargets = new Set(writeBundles === true ? targets : writeBundles || []);
|
|
252
|
+
const paths = appPaths(homeDir, config.tenantId, { claudeUserData });
|
|
253
|
+
if (targets.includes("chatgpt")) secureAllManagedCodexHomes({ appsRoot: paths.root });
|
|
254
|
+
fs.mkdirSync(paths.tenantRoot, { recursive: true, mode: 0o700 });
|
|
255
|
+
if (writeTokenHelperFile) writeTokenHelper(paths.tokenHelper, config.tenantId);
|
|
256
|
+
const resolvedVendorPaths = Object.fromEntries(targets.map((target) => [
|
|
257
|
+
target,
|
|
258
|
+
vendorPaths[target] || findVendorApp(target, homeDir),
|
|
259
|
+
]));
|
|
260
|
+
const vendorCodexModels = targets.includes("chatgpt")
|
|
261
|
+
? readVendorCodexModels(resolvedVendorPaths.chatgpt)
|
|
262
|
+
: new Map();
|
|
263
|
+
|
|
264
|
+
const installed = [];
|
|
265
|
+
for (const target of targets) {
|
|
266
|
+
if (target === "claude") writeClaudeConfig(paths, config, models);
|
|
267
|
+
else writeChatGPTConfig(paths, config, models, vendorCodexModels, chatgptInvocations);
|
|
268
|
+
// Non-bundle targets are the background-refresh / fast-open path: configs,
|
|
269
|
+
// token helper, catalog, and manifest only. Bundle swaps require the app
|
|
270
|
+
// to be closed (see quitBlockingApps), so those paths never attempt one.
|
|
271
|
+
if (bundleTargets.has(target)) {
|
|
272
|
+
if (target === "chatgpt") writeVendoredChatGPTBundle(paths, resolvedVendorPaths.chatgpt, config.gatewayUrl);
|
|
273
|
+
else writeVendoredClaudeBundle(paths, resolvedVendorPaths.claude, config.gatewayUrl);
|
|
274
|
+
}
|
|
275
|
+
installed.push({ target, launcher: paths[target].launcher });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
writeAtomic(path.join(paths.tenantRoot, "manifest.json"), JSON.stringify({
|
|
279
|
+
schemaVersion: 3,
|
|
280
|
+
configVersion: CURRENT_CONFIG_VERSION,
|
|
281
|
+
modelCatalogVersion: 3,
|
|
282
|
+
gatewayUrl: config.gatewayUrl,
|
|
283
|
+
tenantId: config.tenantId,
|
|
284
|
+
experiments: { crossAppModels: crossAppModelsEnabled(config) },
|
|
285
|
+
targets,
|
|
286
|
+
models: models.map((model) => model.id),
|
|
287
|
+
updatedAt: new Date().toISOString(),
|
|
288
|
+
}, null, 2) + "\n", 0o600);
|
|
289
|
+
return installed;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function vendorSearchRoots(homeDir) {
|
|
293
|
+
return ["/Applications", path.join(homeDir, "Applications")];
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function readBundleIdentifier(appPath) {
|
|
297
|
+
const result = spawnSync(
|
|
298
|
+
"/usr/bin/plutil",
|
|
299
|
+
["-extract", "CFBundleIdentifier", "raw", path.join(appPath, "Contents", "Info.plist")],
|
|
300
|
+
{ encoding: "utf8" },
|
|
301
|
+
);
|
|
302
|
+
return result.status === 0 ? result.stdout.trim() : null;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Only Electron-shaped bundles can be vendored — the clone gets asar patches —
|
|
307
|
+
* and app names genuinely collide: OpenAI's native ChatGPT *chat* app installs
|
|
308
|
+
* as ChatGPT.app, the same name older Codex desktop builds used. A candidate
|
|
309
|
+
* must carry the expected executable AND the Electron layout to count.
|
|
310
|
+
*/
|
|
311
|
+
export function isVendableVendorApp(target, appPath) {
|
|
312
|
+
const definition = APP_DEFINITIONS[target];
|
|
313
|
+
const hasExecutable = definition.executableNames.some((name) =>
|
|
314
|
+
fs.existsSync(path.join(appPath, "Contents", "MacOS", name)),
|
|
315
|
+
);
|
|
316
|
+
return (
|
|
317
|
+
hasExecutable
|
|
318
|
+
&& fs.existsSync(path.join(appPath, "Contents", "Info.plist"))
|
|
319
|
+
&& fs.existsSync(path.join(appPath, "Contents", "Resources", "app.asar"))
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function findVendorApp(target, homeDir = os.homedir(), { roots = vendorSearchRoots(homeDir) } = {}) {
|
|
324
|
+
const definition = APP_DEFINITIONS[target];
|
|
325
|
+
for (const base of roots) {
|
|
326
|
+
for (const name of definition.names) {
|
|
327
|
+
const candidate = path.join(base, name);
|
|
328
|
+
if (fs.existsSync(candidate) && isVendableVendorApp(target, candidate)) return candidate;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Name-matched bundles that are NOT vendable — surfaced so "why was my app ignored?" is answerable. */
|
|
335
|
+
export function rejectedVendorCandidates(target, homeDir = os.homedir(), { roots = vendorSearchRoots(homeDir) } = {}) {
|
|
336
|
+
const definition = APP_DEFINITIONS[target];
|
|
337
|
+
const rejected = [];
|
|
338
|
+
for (const base of roots) {
|
|
339
|
+
for (const name of definition.names) {
|
|
340
|
+
const candidate = path.join(base, name);
|
|
341
|
+
if (fs.existsSync(candidate) && !isVendableVendorApp(target, candidate)) {
|
|
342
|
+
rejected.push({ path: candidate, bundleId: readBundleIdentifier(candidate) });
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return rejected;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function ensureVendorApp(target, { homeDir = os.homedir(), update = false } = {}) {
|
|
350
|
+
const definition = APP_DEFINITIONS[target];
|
|
351
|
+
const current = findVendorApp(target, homeDir);
|
|
352
|
+
const brew = findCommand("brew");
|
|
353
|
+
if (!brew) return { path: current, action: current ? "existing" : "missing", note: "Homebrew is not installed" };
|
|
354
|
+
|
|
355
|
+
if (current && !update) return { path: current, action: "existing" };
|
|
356
|
+
if (current && update) {
|
|
357
|
+
const listed = spawnSync(brew, ["list", "--cask", definition.cask], { stdio: "ignore" });
|
|
358
|
+
if (listed.status !== 0) {
|
|
359
|
+
return { path: current, action: "existing", note: "vendor app is not Homebrew-managed; its built-in updater remains active" };
|
|
360
|
+
}
|
|
361
|
+
const upgraded = spawnSync(brew, ["upgrade", "--cask", definition.cask], { stdio: "inherit" });
|
|
362
|
+
return { path: findVendorApp(target, homeDir) || current, action: upgraded.status === 0 ? "updated" : "update-failed" };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Name-collision transparency: e.g. OpenAI's native ChatGPT chat app
|
|
366
|
+
// (com.openai.chat) at /Applications/ChatGPT.app is NOT the Codex desktop
|
|
367
|
+
// app and cannot be vendored — say so instead of silently installing.
|
|
368
|
+
for (const candidate of rejectedVendorCandidates(target, homeDir)) {
|
|
369
|
+
console.log(
|
|
370
|
+
`${target}: ignoring ${candidate.path} (${candidate.bundleId || "unknown bundle id"}) — not a vendorable ${definition.label.replace("Impel ", "")} desktop app; installing the \`${definition.cask}\` cask instead`,
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
fs.mkdirSync(path.join(homeDir, "Applications"), { recursive: true });
|
|
374
|
+
const installed = spawnSync(brew, ["install", "--cask", `--appdir=${path.join(homeDir, "Applications")}`, definition.cask], { stdio: "inherit" });
|
|
375
|
+
return { path: findVendorApp(target, homeDir), action: installed.status === 0 ? "installed" : "install-failed" };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export function appStatus(targets, homeDir = os.homedir(), tenantId = null) {
|
|
379
|
+
const paths = appPaths(homeDir, tenantId);
|
|
380
|
+
return targets.map((target) => {
|
|
381
|
+
const vendorPath = findVendorApp(target, homeDir);
|
|
382
|
+
return {
|
|
383
|
+
target,
|
|
384
|
+
label: APP_DEFINITIONS[target].label,
|
|
385
|
+
launcher: paths[target].launcher,
|
|
386
|
+
launcherInstalled: fs.existsSync(paths[target].launcher),
|
|
387
|
+
vendorPath,
|
|
388
|
+
vendorVersion: vendorPath ? bundleVersion(vendorPath) : null,
|
|
389
|
+
configPath: target === "claude"
|
|
390
|
+
? path.join(paths.claude.userData, "configLibrary", `${CLAUDE_CONFIG_ID}.json`)
|
|
391
|
+
: path.join(paths.chatgpt.codexHome, "config.toml"),
|
|
392
|
+
};
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export function removeManagedApps(targets, {
|
|
397
|
+
homeDir = os.homedir(),
|
|
398
|
+
keepData = false,
|
|
399
|
+
tenantId = null,
|
|
400
|
+
claudeUserData = null,
|
|
401
|
+
} = {}) {
|
|
402
|
+
const paths = appPaths(homeDir, tenantId, { claudeUserData });
|
|
403
|
+
for (const target of targets) {
|
|
404
|
+
fs.rmSync(paths[target].launcher, { recursive: true, force: true });
|
|
405
|
+
if (!keepData) fs.rmSync(paths[target].root, { recursive: true, force: true });
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function writeClaudeConfig(paths, config, models) {
|
|
410
|
+
const dir = path.join(paths.claude.userData, "configLibrary");
|
|
411
|
+
const experimental = crossAppModelsEnabled(config);
|
|
412
|
+
const claudeModels = experimental
|
|
413
|
+
? projectClaudeDesktopModels(models)
|
|
414
|
+
: models.filter((model) => model.provider === "claude").map((model) => ({
|
|
415
|
+
name: model.id,
|
|
416
|
+
...(model.family ? { anthropicFamilyTier: model.family } : {}),
|
|
417
|
+
...(model.family_default ? { isFamilyDefault: true } : {}),
|
|
418
|
+
}));
|
|
419
|
+
const body = {
|
|
420
|
+
inferenceProvider: "gateway",
|
|
421
|
+
inferenceGatewayBaseUrl: experimental
|
|
422
|
+
? `${config.gatewayUrl}/experimental/anthropic`
|
|
423
|
+
: `${config.gatewayUrl}/anthropic`,
|
|
424
|
+
inferenceGatewayApiKey: config.pat,
|
|
425
|
+
inferenceGatewayAuthScheme: "bearer",
|
|
426
|
+
modelDiscoveryEnabled: false,
|
|
427
|
+
inferenceModels: claudeModels,
|
|
428
|
+
};
|
|
429
|
+
writeAtomic(path.join(dir, `${CLAUDE_CONFIG_ID}.json`), JSON.stringify(body, null, 2) + "\n", 0o600);
|
|
430
|
+
writeAtomic(path.join(dir, "_meta.json"), JSON.stringify({
|
|
431
|
+
appliedId: CLAUDE_CONFIG_ID,
|
|
432
|
+
entries: [{ id: CLAUDE_CONFIG_ID, name: "Impel Gateway" }],
|
|
433
|
+
}, null, 2) + "\n", 0o600);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function writeChatGPTConfig(paths, config, models, vendorCodexModels, invocations = null) {
|
|
437
|
+
const experimental = crossAppModelsEnabled(config);
|
|
438
|
+
const orderedModels = experimental
|
|
439
|
+
? [...models.filter((model) => model.provider === "codex"), ...models.filter((model) => model.provider === "claude")]
|
|
440
|
+
: models.filter((model) => model.provider === "codex");
|
|
441
|
+
const codexModels = orderedModels
|
|
442
|
+
.map((model, index) => model.provider === "codex"
|
|
443
|
+
? codexCatalogEntry(model, index, vendorCodexModels.get(model.id))
|
|
444
|
+
: foreignClaudeCodexCatalogEntry(model, index))
|
|
445
|
+
.filter(Boolean);
|
|
446
|
+
const configPath = path.join(paths.chatgpt.codexHome, "config.toml");
|
|
447
|
+
secureManagedCodexHome(paths.chatgpt.codexHome);
|
|
448
|
+
const currentToml = fs.existsSync(configPath) ? fs.readFileSync(configPath, "utf8") : "";
|
|
449
|
+
const configuredModel = readTopLevelTomlString(currentToml, "model");
|
|
450
|
+
const defaultModelID = orderedModels.find((model) => model.provider === "codex" && model.default)?.id;
|
|
451
|
+
const selectedModel = codexModels.find((model) => model.slug === configuredModel)
|
|
452
|
+
|| codexModels.find((model) => model.slug === defaultModelID)
|
|
453
|
+
|| codexModels[0];
|
|
454
|
+
if (!selectedModel) throw new Error("no Codex models are available for Impel ChatGPT");
|
|
455
|
+
const configuredEffort = readTopLevelTomlString(currentToml, "model_reasoning_effort");
|
|
456
|
+
const supportedEfforts = Array.isArray(selectedModel.supported_reasoning_levels)
|
|
457
|
+
? selectedModel.supported_reasoning_levels.map((level) => level.effort)
|
|
458
|
+
: [];
|
|
459
|
+
const selectedEffort = supportedEfforts.includes(configuredEffort)
|
|
460
|
+
? configuredEffort
|
|
461
|
+
: (supportedEfforts.includes(selectedModel.default_reasoning_level)
|
|
462
|
+
? selectedModel.default_reasoning_level
|
|
463
|
+
: (supportedEfforts[0] || null));
|
|
464
|
+
const configuredTier = readTopLevelTomlString(currentToml, "service_tier");
|
|
465
|
+
const supportedTiers = Array.isArray(selectedModel.service_tiers) ? selectedModel.service_tiers.map((tier) => tier.id) : [];
|
|
466
|
+
const selectedTier = supportedTiers.includes(configuredTier)
|
|
467
|
+
? configuredTier
|
|
468
|
+
: (configuredTier === "fast" && supportedTiers.includes("priority") ? "priority" : null);
|
|
469
|
+
const catalog = {
|
|
470
|
+
fetched_at: new Date().toISOString(),
|
|
471
|
+
client_version: "impel-managed-2",
|
|
472
|
+
models: codexModels,
|
|
473
|
+
};
|
|
474
|
+
writeAtomic(paths.chatgpt.catalog, JSON.stringify(catalog, null, 2) + "\n", 0o600);
|
|
475
|
+
const auth = invocations?.auth || { command: paths.tokenHelper, args: null };
|
|
476
|
+
const mcp = invocations?.mcp || {
|
|
477
|
+
command: "impel",
|
|
478
|
+
args: config.tenantId ? ["mcp", "--tenant", config.tenantId] : ["mcp"],
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
const managedToml = [
|
|
482
|
+
CHATGPT_CONFIG_START,
|
|
483
|
+
"# Managed by `impel app update`. This profile is isolated from ~/.codex.",
|
|
484
|
+
`model = ${tomlString(selectedModel.slug)}`,
|
|
485
|
+
'model_provider = "impel"',
|
|
486
|
+
`chatgpt_base_url = ${tomlString(`${config.gatewayUrl}/chatgpt_passthrough/backend-api`)}`,
|
|
487
|
+
`model_catalog_json = ${tomlString(paths.chatgpt.catalog)}`,
|
|
488
|
+
...(selectedEffort ? [`model_reasoning_effort = ${tomlString(selectedEffort)}`] : []),
|
|
489
|
+
...(selectedTier ? [`service_tier = ${tomlString(selectedTier)}`] : []),
|
|
490
|
+
"",
|
|
491
|
+
// The built-in ChatGPT provider derives its inference endpoint from
|
|
492
|
+
// chatgpt.com even when chatgpt_base_url points at the gateway. Keep the
|
|
493
|
+
// native account client for status/usage, but route inference explicitly.
|
|
494
|
+
"[model_providers.impel]",
|
|
495
|
+
'name = "Impel Gateway"',
|
|
496
|
+
`base_url = ${tomlString(experimental
|
|
497
|
+
? `${config.gatewayUrl}/experimental/openai/v1`
|
|
498
|
+
: `${config.gatewayUrl}/chatgpt_passthrough/backend-api/codex`)}`,
|
|
499
|
+
'wire_api = "responses"',
|
|
500
|
+
"supports_websockets = false",
|
|
501
|
+
"request_max_retries = 2",
|
|
502
|
+
"stream_max_retries = 2",
|
|
503
|
+
"stream_idle_timeout_ms = 600000",
|
|
504
|
+
"",
|
|
505
|
+
"[model_providers.impel.auth]",
|
|
506
|
+
`command = ${tomlString(auth.command)}`,
|
|
507
|
+
...(Array.isArray(auth.args)
|
|
508
|
+
? [`args = [${auth.args.map((argument) => tomlString(argument)).join(", ")}]`]
|
|
509
|
+
: []),
|
|
510
|
+
"timeout_ms = 5000",
|
|
511
|
+
"refresh_interval_ms = 300000",
|
|
512
|
+
"",
|
|
513
|
+
"[mcp_servers.impel]",
|
|
514
|
+
`command = ${tomlString(mcp.command)}`,
|
|
515
|
+
`args = [${mcp.args.map((argument) => tomlString(argument)).join(", ")}]`,
|
|
516
|
+
CHATGPT_CONFIG_END,
|
|
517
|
+
].join("\n");
|
|
518
|
+
const mergedToml = mergeManagedChatGPTToml(currentToml, managedToml);
|
|
519
|
+
writeAtomic(configPath, hardenManagedCodexToml(mergedToml, configPath), 0o600);
|
|
520
|
+
secureManagedCodexHome(paths.chatgpt.codexHome);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
export function projectClaudeDesktopModels(models) {
|
|
524
|
+
const ordered = [
|
|
525
|
+
...models.filter((model) => model.provider === "claude"),
|
|
526
|
+
...models.filter((model) => model.provider !== "claude"),
|
|
527
|
+
];
|
|
528
|
+
return ordered.flatMap((model) => {
|
|
529
|
+
const capability = model.client_capabilities?.claude_desktop;
|
|
530
|
+
if (!capability || typeof capability.label !== "string" || capability.label.trim() === "") {
|
|
531
|
+
console.warn(`impel: omitting model ${redactSecretText(model.id)} from Impel Claude: missing verified desktop capabilities`);
|
|
532
|
+
return [];
|
|
533
|
+
}
|
|
534
|
+
return [{
|
|
535
|
+
name: model.id,
|
|
536
|
+
labelOverride: capability.label,
|
|
537
|
+
...(typeof capability.anthropic_family_tier === "string" && capability.anthropic_family_tier
|
|
538
|
+
? { anthropicFamilyTier: capability.anthropic_family_tier }
|
|
539
|
+
: {}),
|
|
540
|
+
...(capability.family_default === true ? { isFamilyDefault: true } : {}),
|
|
541
|
+
...(capability.supports_1m === true ? { supports1m: true } : {}),
|
|
542
|
+
}];
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
export function foreignClaudeCodexCatalogEntry(model, index) {
|
|
547
|
+
const capability = model.client_capabilities?.codex;
|
|
548
|
+
const levels = capability?.supported_reasoning_levels;
|
|
549
|
+
const tiers = capability?.service_tiers;
|
|
550
|
+
const modalities = capability?.input_modalities;
|
|
551
|
+
const valid = capability && typeof capability === "object"
|
|
552
|
+
&& Number.isInteger(capability.context_window) && capability.context_window > 0
|
|
553
|
+
&& Number.isInteger(capability.max_context_window) && capability.max_context_window >= capability.context_window
|
|
554
|
+
&& Array.isArray(levels) && levels.every((level) => (
|
|
555
|
+
level && typeof level.effort === "string" && typeof level.description === "string"
|
|
556
|
+
))
|
|
557
|
+
&& Array.isArray(tiers) && tiers.every((tier) => (
|
|
558
|
+
tier && typeof tier.id === "string" && typeof tier.name === "string" && typeof tier.description === "string"
|
|
559
|
+
))
|
|
560
|
+
&& Array.isArray(modalities) && modalities.every((value) => typeof value === "string")
|
|
561
|
+
&& ["supports_parallel_tool_calls", "supports_reasoning_summaries", "supports_verbosity", "supports_search_tool", "supports_image_detail_original"]
|
|
562
|
+
.every((key) => typeof capability[key] === "boolean")
|
|
563
|
+
&& (capability.default_reasoning_level == null
|
|
564
|
+
|| capability.default_reasoning_level === ""
|
|
565
|
+
|| levels.some((level) => level.effort === capability.default_reasoning_level))
|
|
566
|
+
&& (capability.apply_patch_tool_type == null || typeof capability.apply_patch_tool_type === "string")
|
|
567
|
+
&& (capability.web_search_tool_type == null || typeof capability.web_search_tool_type === "string");
|
|
568
|
+
if (!valid) {
|
|
569
|
+
console.warn(`impel: omitting model ${redactSecretText(model.id)} from Impel ChatGPT: missing verified Codex capabilities`);
|
|
570
|
+
return null;
|
|
571
|
+
}
|
|
572
|
+
return {
|
|
573
|
+
slug: model.id,
|
|
574
|
+
display_name: model.display_name || model.id,
|
|
575
|
+
description: model.description || `Claude model available through the Impel gateway (${model.id}).`,
|
|
576
|
+
...(capability.default_reasoning_level ? { default_reasoning_level: capability.default_reasoning_level } : {}),
|
|
577
|
+
supported_reasoning_levels: levels,
|
|
578
|
+
service_tiers: tiers,
|
|
579
|
+
shell_type: "shell_command",
|
|
580
|
+
visibility: "list",
|
|
581
|
+
supported_in_api: true,
|
|
582
|
+
priority: Number.isInteger(model.priority) ? model.priority : index + 1,
|
|
583
|
+
base_instructions: "You are an AI coding agent. Follow repository instructions, collaborate with the user, make scoped changes, and verify your work.",
|
|
584
|
+
include_skills_usage_instructions: false,
|
|
585
|
+
supports_reasoning_summaries: capability.supports_reasoning_summaries,
|
|
586
|
+
support_verbosity: capability.supports_verbosity,
|
|
587
|
+
...(capability.supports_reasoning_summaries ? { default_reasoning_summary: "none" } : {}),
|
|
588
|
+
...(capability.supports_verbosity ? { default_verbosity: "low" } : {}),
|
|
589
|
+
...(capability.apply_patch_tool_type ? { apply_patch_tool_type: capability.apply_patch_tool_type } : {}),
|
|
590
|
+
...(capability.web_search_tool_type ? { web_search_tool_type: capability.web_search_tool_type } : {}),
|
|
591
|
+
truncation_policy: { mode: "tokens", limit: 10000 },
|
|
592
|
+
context_window: capability.context_window,
|
|
593
|
+
max_context_window: capability.max_context_window,
|
|
594
|
+
effective_context_window_percent: 95,
|
|
595
|
+
experimental_supported_tools: [],
|
|
596
|
+
input_modalities: modalities,
|
|
597
|
+
supports_parallel_tool_calls: capability.supports_parallel_tool_calls,
|
|
598
|
+
supports_image_detail_original: capability.supports_image_detail_original,
|
|
599
|
+
supports_search_tool: capability.supports_search_tool,
|
|
600
|
+
use_responses_lite: false,
|
|
601
|
+
tool_mode: null,
|
|
602
|
+
multi_agent_version: null,
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function codexCatalogEntry(model, index, vendorModel) {
|
|
607
|
+
const generated = {
|
|
608
|
+
slug: model.id,
|
|
609
|
+
display_name: model.display_name || model.id,
|
|
610
|
+
description: `Available through the Impel gateway (${model.id}).`,
|
|
611
|
+
default_reasoning_level: "medium",
|
|
612
|
+
supported_reasoning_levels: STANDARD_REASONING_LEVELS,
|
|
613
|
+
shell_type: "shell_command",
|
|
614
|
+
visibility: "list",
|
|
615
|
+
supported_in_api: true,
|
|
616
|
+
priority: index + 1,
|
|
617
|
+
base_instructions: "You are Codex, an AI coding agent. Follow repository instructions, collaborate with the user, make scoped changes, and verify your work.",
|
|
618
|
+
include_skills_usage_instructions: false,
|
|
619
|
+
supports_reasoning_summaries: true,
|
|
620
|
+
default_reasoning_summary: "none",
|
|
621
|
+
support_verbosity: true,
|
|
622
|
+
default_verbosity: "low",
|
|
623
|
+
apply_patch_tool_type: "freeform",
|
|
624
|
+
web_search_tool_type: "text_and_image",
|
|
625
|
+
truncation_policy: { mode: "tokens", limit: 10000 },
|
|
626
|
+
context_window: model.context_window || 200000,
|
|
627
|
+
max_context_window: model.context_window || 200000,
|
|
628
|
+
effective_context_window_percent: 95,
|
|
629
|
+
experimental_supported_tools: [],
|
|
630
|
+
input_modalities: ["text", "image"],
|
|
631
|
+
supports_parallel_tool_calls: true,
|
|
632
|
+
supports_image_detail_original: true,
|
|
633
|
+
supports_search_tool: true,
|
|
634
|
+
use_responses_lite: false,
|
|
635
|
+
tool_mode: null,
|
|
636
|
+
multi_agent_version: null,
|
|
637
|
+
};
|
|
638
|
+
const entry = vendorModel ? { ...generated, ...vendorModel } : generated;
|
|
639
|
+
return {
|
|
640
|
+
...entry,
|
|
641
|
+
slug: model.id,
|
|
642
|
+
display_name: model.display_name || entry.display_name || model.id,
|
|
643
|
+
description: model.description || entry.description,
|
|
644
|
+
default_reasoning_level: model.default_reasoning_level || entry.default_reasoning_level,
|
|
645
|
+
supported_reasoning_levels: Array.isArray(model.supported_reasoning_levels)
|
|
646
|
+
? model.supported_reasoning_levels
|
|
647
|
+
: entry.supported_reasoning_levels,
|
|
648
|
+
service_tiers: Array.isArray(model.service_tiers) ? model.service_tiers : (entry.service_tiers || []),
|
|
649
|
+
visibility: "list",
|
|
650
|
+
supported_in_api: true,
|
|
651
|
+
priority: Number.isInteger(model.priority) ? model.priority : (entry.priority || index + 1),
|
|
652
|
+
context_window: model.context_window || entry.context_window || 200000,
|
|
653
|
+
max_context_window: model.max_context_window || entry.max_context_window || model.context_window || 200000,
|
|
654
|
+
...(typeof model.use_responses_lite === "boolean" ? { use_responses_lite: model.use_responses_lite } : {}),
|
|
655
|
+
...(Object.hasOwn(model, "tool_mode") ? { tool_mode: model.tool_mode } : {}),
|
|
656
|
+
...(Object.hasOwn(model, "multi_agent_version") ? { multi_agent_version: model.multi_agent_version } : {}),
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function readVendorCodexModels(vendorPath) {
|
|
661
|
+
const binary = vendorPath && path.join(vendorPath, "Contents", "Resources", "codex");
|
|
662
|
+
if (!binary || !fs.existsSync(binary)) return new Map();
|
|
663
|
+
const env = { ...process.env };
|
|
664
|
+
delete env.CODEX_HOME;
|
|
665
|
+
for (const args of [["debug", "models"], ["debug", "models", "--bundled"]]) {
|
|
666
|
+
const result = spawnSync(binary, args, { encoding: "utf8", env, maxBuffer: 20 * 1024 * 1024 });
|
|
667
|
+
if (result.status !== 0) continue;
|
|
668
|
+
try {
|
|
669
|
+
const payload = JSON.parse(result.stdout);
|
|
670
|
+
const models = Array.isArray(payload) ? payload : payload.models;
|
|
671
|
+
if (Array.isArray(models)) return new Map(models.map((model) => [model.slug, model]));
|
|
672
|
+
} catch {
|
|
673
|
+
// Fall through to the bundled catalog or the Impel-generated schema.
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
return new Map();
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function mergeManagedChatGPTToml(current, managed) {
|
|
680
|
+
let remainder = current;
|
|
681
|
+
const start = current.indexOf(CHATGPT_CONFIG_START);
|
|
682
|
+
const end = current.indexOf(CHATGPT_CONFIG_END);
|
|
683
|
+
if (start !== -1 && end > start) {
|
|
684
|
+
remainder = current.slice(0, start) + current.slice(end + CHATGPT_CONFIG_END.length);
|
|
685
|
+
} else if (current.startsWith("# Managed by `impel app update`. This profile is isolated from ~/.codex.")) {
|
|
686
|
+
const legacyEnd = current.match(/refresh_interval_ms = \d+\r?\n/u);
|
|
687
|
+
if (legacyEnd?.index != null) remainder = current.slice(legacyEnd.index + legacyEnd[0].length);
|
|
688
|
+
}
|
|
689
|
+
remainder = remainder.replace(/^\s+/u, "");
|
|
690
|
+
return `${managed}\n${remainder ? `\n${remainder.replace(/\s*$/u, "")}\n` : ""}`;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function readTopLevelTomlString(toml, key) {
|
|
694
|
+
const topLevel = toml.split(/^\s*\[/mu, 1)[0];
|
|
695
|
+
const match = topLevel.match(new RegExp(`^\\s*${escapeRegex(key)}\\s*=\\s*("(?:[^"\\\\]|\\\\.)*")\\s*$`, "mu"));
|
|
696
|
+
if (!match) return null;
|
|
697
|
+
try {
|
|
698
|
+
return JSON.parse(match[1]);
|
|
699
|
+
} catch {
|
|
700
|
+
return null;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function writeTokenHelper(target, tenantId) {
|
|
705
|
+
const cliPath = fileURLToPath(new URL("../bin/impel.js", import.meta.url));
|
|
706
|
+
const node = shellQuote(process.execPath);
|
|
707
|
+
const cli = shellQuote(cliPath);
|
|
708
|
+
const tenantArgs = tenantId ? ` --tenant ${shellQuote(tenantId)}` : "";
|
|
709
|
+
// The apps invoke this helper whenever they need auth, which makes it the
|
|
710
|
+
// one hook that fires on Finder/Dock launches too. Piggyback a detached,
|
|
711
|
+
// TTL-gated config/catalog/skills refresh (`app refresh --stale-only`) so
|
|
712
|
+
// the managed apps keep themselves current without touching stdout — the
|
|
713
|
+
// caller consumes stdout as the token.
|
|
714
|
+
const script = `#!/bin/sh
|
|
715
|
+
(${node} ${cli} app refresh --stale-only </dev/null >/dev/null 2>&1 &)
|
|
716
|
+
exec ${node} ${cli} token${tenantArgs}
|
|
717
|
+
`;
|
|
718
|
+
writeAtomic(target, script, 0o700);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function writeVendoredClaudeBundle(paths, vendorPath, gatewayUrl) {
|
|
722
|
+
if (!vendorPath) throw new Error("Claude vendor app is unavailable");
|
|
723
|
+
const executableName = APP_DEFINITIONS.claude.executableNames.find((name) => (
|
|
724
|
+
fs.existsSync(path.join(vendorPath, "Contents", "MacOS", name))
|
|
725
|
+
));
|
|
726
|
+
if (!executableName) throw new Error(`Claude executable is missing from ${vendorPath}`);
|
|
727
|
+
|
|
728
|
+
const bundle = paths.claude.launcher;
|
|
729
|
+
const staging = `${bundle}.tmp-${process.pid}`;
|
|
730
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
731
|
+
cloneAppBundle(vendorPath, staging);
|
|
732
|
+
try {
|
|
733
|
+
const plistPath = path.join(staging, "Contents", "Info.plist");
|
|
734
|
+
const asarPath = path.join(staging, "Contents", "Resources", "app.asar");
|
|
735
|
+
if (!fs.existsSync(plistPath) || !fs.existsSync(asarPath)) {
|
|
736
|
+
const bundleId = readBundleIdentifier(vendorPath);
|
|
737
|
+
throw new Error(
|
|
738
|
+
`unsupported Claude bundle layout in ${vendorPath}${bundleId ? ` (bundle id ${bundleId})` : ""}`,
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const oldAsarHash = asarHeaderHash(asarPath);
|
|
743
|
+
let planUsageGuardPatchCount = 0;
|
|
744
|
+
let planUsageRequestPatchCount = 0;
|
|
745
|
+
if (claudeUsageCompatibilityEnabled(gatewayUrl)) {
|
|
746
|
+
planUsageGuardPatchCount = patchFixedWidthAsarString(
|
|
747
|
+
asarPath,
|
|
748
|
+
CLAUDE_PLAN_USAGE_GUARD,
|
|
749
|
+
IMPEL_CLAUDE_PLAN_USAGE_GUARD,
|
|
750
|
+
"Claude plan-usage eligibility guard",
|
|
751
|
+
);
|
|
752
|
+
planUsageRequestPatchCount = patchFixedWidthAsarString(
|
|
753
|
+
asarPath,
|
|
754
|
+
CLAUDE_PLAN_USAGE_REQUEST,
|
|
755
|
+
IMPEL_CLAUDE_PLAN_USAGE_REQUEST,
|
|
756
|
+
"Claude plan-usage gateway request",
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
const newAsarHash = asarHeaderHash(asarPath);
|
|
760
|
+
|
|
761
|
+
updateBundleIdentity(plistPath, {
|
|
762
|
+
displayName: "Impel Claude",
|
|
763
|
+
executable: executableName,
|
|
764
|
+
identifier: "com.useimpel.claude",
|
|
765
|
+
name: "Impel Claude",
|
|
766
|
+
});
|
|
767
|
+
updateAsarIntegrityPlist(plistPath, oldAsarHash, newAsarHash);
|
|
768
|
+
setPlistEnvironmentString(plistPath, "CLAUDE_USER_DATA_DIR", paths.claude.userData);
|
|
769
|
+
rebrandElectronHelpers(staging, {
|
|
770
|
+
fromName: "Claude",
|
|
771
|
+
toName: "Impel Claude",
|
|
772
|
+
fromIdentifier: "com.anthropic.claudefordesktop.helper",
|
|
773
|
+
toIdentifier: "com.useimpel.claude.helper",
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
const macos = path.join(staging, "Contents", "MacOS");
|
|
777
|
+
writeAtomic(path.join(staging, "Contents", "Resources", "impel-compatibility.json"), JSON.stringify({
|
|
778
|
+
schemaVersion: 1,
|
|
779
|
+
cliVersion: CLI_VERSION,
|
|
780
|
+
vendorVersion: bundleVersion(vendorPath),
|
|
781
|
+
sourceBundle: vendorPath,
|
|
782
|
+
bundleIdentifier: "com.useimpel.claude",
|
|
783
|
+
profileRoot: paths.claude.userData,
|
|
784
|
+
launchMode: "LSEnvironment",
|
|
785
|
+
patches: {
|
|
786
|
+
aggregatePlanUsageGuard: planUsageGuardPatchCount,
|
|
787
|
+
aggregatePlanUsageRequest: planUsageRequestPatchCount,
|
|
788
|
+
},
|
|
789
|
+
asarHeaderSha256: newAsarHash,
|
|
790
|
+
}, null, 2) + "\n", 0o644);
|
|
791
|
+
|
|
792
|
+
signVendoredApp(staging, path.join(macos, executableName), "Claude");
|
|
793
|
+
replaceDirectory(bundle, staging);
|
|
794
|
+
} catch (error) {
|
|
795
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
796
|
+
throw error;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function claudeUsageCompatibilityEnabled(gatewayUrl) {
|
|
801
|
+
try {
|
|
802
|
+
return new URL(gatewayUrl).origin === "https://gateway.useimpel.com";
|
|
803
|
+
} catch {
|
|
804
|
+
return false;
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl) {
|
|
809
|
+
if (!vendorPath) throw new Error("ChatGPT/Codex vendor app is unavailable");
|
|
810
|
+
const definition = APP_DEFINITIONS.chatgpt;
|
|
811
|
+
const executableName = definition.executableNames.find((name) => (
|
|
812
|
+
fs.existsSync(path.join(vendorPath, "Contents", "MacOS", name))
|
|
813
|
+
));
|
|
814
|
+
if (!executableName) throw new Error(`ChatGPT/Codex executable is missing from ${vendorPath}`);
|
|
815
|
+
|
|
816
|
+
const bundle = paths.chatgpt.launcher;
|
|
817
|
+
const staging = `${bundle}.tmp-${process.pid}`;
|
|
818
|
+
const vendorBundleName = path.basename(vendorPath);
|
|
819
|
+
const vendorBundle = path.join(staging, "Contents", "Resources", vendorBundleName);
|
|
820
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
821
|
+
cloneAppBundle(vendorPath, vendorBundle);
|
|
822
|
+
try {
|
|
823
|
+
const asarPath = path.join(vendorBundle, "Contents", "Resources", "app.asar");
|
|
824
|
+
const plistPath = path.join(vendorBundle, "Contents", "Info.plist");
|
|
825
|
+
if (!fs.existsSync(asarPath) || !fs.existsSync(plistPath)) {
|
|
826
|
+
const bundleId = readBundleIdentifier(vendorPath);
|
|
827
|
+
const hint = bundleId === "com.openai.chat"
|
|
828
|
+
? " — this is OpenAI's ChatGPT chat app, not the Codex desktop app; install the desktop app with `brew install --cask codex-app` and re-run"
|
|
829
|
+
: bundleId
|
|
830
|
+
? ` (bundle id ${bundleId})`
|
|
831
|
+
: "";
|
|
832
|
+
throw new Error(`unsupported ChatGPT/Codex bundle layout in ${vendorPath}${hint}`);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
const asarHash = asarHeaderHash(asarPath);
|
|
836
|
+
|
|
837
|
+
const macos = path.join(staging, "Contents", "MacOS");
|
|
838
|
+
writeAtomic(path.join(staging, "Contents", "Info.plist"), vendoredChatGPTWrapperPlist(vendorPath), 0o644);
|
|
839
|
+
const vendorIcon = path.join(vendorBundle, "Contents", "Resources", "electron.icns");
|
|
840
|
+
if (fs.existsSync(vendorIcon)) {
|
|
841
|
+
fs.copyFileSync(vendorIcon, path.join(staging, "Contents", "Resources", "electron.icns"));
|
|
842
|
+
}
|
|
843
|
+
linkVendoredChatGPTResources(staging, vendorBundleName);
|
|
844
|
+
writeAtomic(
|
|
845
|
+
path.join(macos, "launch"),
|
|
846
|
+
vendoredChatGPTLauncher(paths, vendorBundleName, executableName, gatewayUrl),
|
|
847
|
+
0o755,
|
|
848
|
+
);
|
|
849
|
+
writeAtomic(path.join(staging, "Contents", "Resources", "impel-compatibility.json"), JSON.stringify({
|
|
850
|
+
schemaVersion: 1,
|
|
851
|
+
cliVersion: CLI_VERSION,
|
|
852
|
+
vendorVersion: bundleVersion(vendorPath),
|
|
853
|
+
sourceBundle: vendorPath,
|
|
854
|
+
patches: { fastModeForGatewayAuth: 0, desktopAPIForGatewayAuth: 0 },
|
|
855
|
+
vendorSignaturesPreserved: true,
|
|
856
|
+
resourceRoot: "linked_vendor_resources",
|
|
857
|
+
asarHeaderSha256: asarHash,
|
|
858
|
+
}, null, 2) + "\n", 0o644);
|
|
859
|
+
|
|
860
|
+
signVendoredApp(staging, path.join(vendorBundle, "Contents", "MacOS", executableName), "ChatGPT");
|
|
861
|
+
replaceDirectory(bundle, staging);
|
|
862
|
+
} catch (error) {
|
|
863
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
864
|
+
throw error;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function linkVendoredChatGPTResources(bundle, vendorBundleName) {
|
|
869
|
+
const outerResources = path.join(bundle, "Contents", "Resources");
|
|
870
|
+
const vendorResources = path.join(outerResources, vendorBundleName, "Contents", "Resources");
|
|
871
|
+
for (const entry of fs.readdirSync(vendorResources, { withFileTypes: true })) {
|
|
872
|
+
// The wrapper owns its icon and compatibility record. Every other vendor
|
|
873
|
+
// resource is exposed at the outer Electron resource root through a
|
|
874
|
+
// relative link, leaving the OpenAI-signed nested bundle untouched.
|
|
875
|
+
if (entry.name === "electron.icns") continue;
|
|
876
|
+
const target = path.join(vendorBundleName, "Contents", "Resources", entry.name);
|
|
877
|
+
fs.symlinkSync(target, path.join(outerResources, entry.name));
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function patchFixedWidthAsarString(asarPath, original, replacement, label) {
|
|
882
|
+
if (Buffer.byteLength(original, "latin1") !== Buffer.byteLength(replacement, "latin1")) {
|
|
883
|
+
throw new Error(`${label} replacement must preserve the vendor ASAR byte length`);
|
|
884
|
+
}
|
|
885
|
+
const archive = fs.readFileSync(asarPath);
|
|
886
|
+
const metadata = readAsarMetadata(archive);
|
|
887
|
+
const source = archive.toString("latin1");
|
|
888
|
+
const patchOffsets = [];
|
|
889
|
+
for (let offset = source.indexOf(original); offset !== -1; offset = source.indexOf(original, offset + original.length)) {
|
|
890
|
+
patchOffsets.push(offset);
|
|
891
|
+
}
|
|
892
|
+
if (patchOffsets.length === 0) {
|
|
893
|
+
throw new Error(`this desktop app version has an unsupported ${label}; update impel-cli before installing it`);
|
|
894
|
+
}
|
|
895
|
+
const patchedArchive = Buffer.from(source.replaceAll(original, replacement), "latin1");
|
|
896
|
+
const entries = new Set(patchOffsets.map((offset) => findAsarEntry(metadata.header, metadata.dataOffset, offset)));
|
|
897
|
+
for (const entry of entries) updateAsarEntryIntegrity(patchedArchive, metadata.dataOffset, entry);
|
|
898
|
+
const header = Buffer.from(JSON.stringify(metadata.header));
|
|
899
|
+
if (header.length !== metadata.headerLength) {
|
|
900
|
+
throw new Error(`desktop app ASAR header changed size while applying the ${label} patch`);
|
|
901
|
+
}
|
|
902
|
+
header.copy(patchedArchive, 16);
|
|
903
|
+
fs.writeFileSync(asarPath, patchedArchive);
|
|
904
|
+
return patchOffsets.length;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function cloneAppBundle(source, destination) {
|
|
908
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
909
|
+
if (process.platform === "darwin") {
|
|
910
|
+
const cloned = spawnSync("/bin/cp", ["-cR", source, destination], { encoding: "utf8" });
|
|
911
|
+
if (cloned.status === 0) return;
|
|
912
|
+
fs.rmSync(destination, { recursive: true, force: true });
|
|
913
|
+
}
|
|
914
|
+
fs.cpSync(source, destination, { recursive: true, dereference: false, preserveTimestamps: true });
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function readAsarMetadata(archive) {
|
|
918
|
+
if (archive.length < 16) throw new Error("ChatGPT/Codex app.asar header is invalid");
|
|
919
|
+
const headerLength = archive.readUInt32LE(12);
|
|
920
|
+
const dataOffset = 8 + archive.readUInt32LE(4);
|
|
921
|
+
if (headerLength <= 0 || 16 + headerLength > dataOffset || dataOffset > archive.length) {
|
|
922
|
+
throw new Error("ChatGPT/Codex app.asar offsets are invalid");
|
|
923
|
+
}
|
|
924
|
+
let header;
|
|
925
|
+
try {
|
|
926
|
+
header = JSON.parse(archive.subarray(16, 16 + headerLength).toString("utf8"));
|
|
927
|
+
} catch {
|
|
928
|
+
throw new Error("ChatGPT/Codex app.asar header is not valid JSON");
|
|
929
|
+
}
|
|
930
|
+
return { dataOffset, header, headerLength };
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function findAsarEntry(header, dataOffset, archiveOffset) {
|
|
934
|
+
const relativeOffset = archiveOffset - dataOffset;
|
|
935
|
+
let found = null;
|
|
936
|
+
const visit = (directory) => {
|
|
937
|
+
for (const entry of Object.values(directory?.files || {})) {
|
|
938
|
+
if (entry.files) visit(entry);
|
|
939
|
+
else if (entry.offset != null) {
|
|
940
|
+
const start = Number(entry.offset);
|
|
941
|
+
if (start <= relativeOffset && relativeOffset < start + Number(entry.size)) found = entry;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
};
|
|
945
|
+
visit(header);
|
|
946
|
+
if (!found?.integrity) throw new Error("Fast-mode renderer asset has no supported ASAR integrity metadata");
|
|
947
|
+
return found;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function updateAsarEntryIntegrity(archive, dataOffset, entry) {
|
|
951
|
+
const start = dataOffset + Number(entry.offset);
|
|
952
|
+
const contents = archive.subarray(start, start + Number(entry.size));
|
|
953
|
+
const blockSize = Number(entry.integrity.blockSize);
|
|
954
|
+
if (entry.integrity.algorithm !== "SHA256" || !Number.isInteger(blockSize) || blockSize <= 0) {
|
|
955
|
+
throw new Error("Fast-mode renderer asset uses unsupported ASAR integrity settings");
|
|
956
|
+
}
|
|
957
|
+
entry.integrity.hash = sha256(contents);
|
|
958
|
+
entry.integrity.blocks = [];
|
|
959
|
+
for (let offset = 0; offset < contents.length; offset += blockSize) {
|
|
960
|
+
entry.integrity.blocks.push(sha256(contents.subarray(offset, offset + blockSize)));
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function updateBundleIdentity(plistPath, identity) {
|
|
965
|
+
let plist = fs.readFileSync(plistPath, "utf8");
|
|
966
|
+
plist = replacePlistString(plist, "CFBundleDisplayName", identity.displayName);
|
|
967
|
+
plist = replacePlistString(plist, "CFBundleExecutable", identity.executable);
|
|
968
|
+
plist = replacePlistString(plist, "CFBundleIdentifier", identity.identifier);
|
|
969
|
+
plist = replacePlistString(plist, "CFBundleName", identity.name);
|
|
970
|
+
fs.writeFileSync(plistPath, plist, { mode: 0o644 });
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function updateAsarIntegrityPlist(plistPath, oldAsarHash, newAsarHash) {
|
|
974
|
+
let plist = fs.readFileSync(plistPath, "utf8");
|
|
975
|
+
if (!plist.includes(oldAsarHash)) {
|
|
976
|
+
throw new Error("ChatGPT/Codex ASAR integrity metadata is missing or unsupported");
|
|
977
|
+
}
|
|
978
|
+
plist = plist.replace(oldAsarHash, newAsarHash);
|
|
979
|
+
fs.writeFileSync(plistPath, plist, { mode: 0o644 });
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function replacePlistString(plist, key, value) {
|
|
983
|
+
const pattern = new RegExp(`(<key>${escapeRegex(key)}</key>\\s*<string>)[^<]*(</string>)`, "u");
|
|
984
|
+
if (!pattern.test(plist)) throw new Error(`vendored app Info.plist has no ${key}`);
|
|
985
|
+
return plist.replace(pattern, `$1${xmlEscape(value)}$2`);
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
function setPlistEnvironmentString(plistPath, key, value) {
|
|
989
|
+
let plist = fs.readFileSync(plistPath, "utf8");
|
|
990
|
+
const entry = `<key>${xmlEscape(key)}</key><string>${xmlEscape(value)}</string>`;
|
|
991
|
+
const existing = new RegExp(`<key>${escapeRegex(key)}</key>\\s*<string>[^<]*</string>`, "u");
|
|
992
|
+
if (existing.test(plist)) {
|
|
993
|
+
plist = plist.replace(existing, entry);
|
|
994
|
+
} else {
|
|
995
|
+
const environment = /(<key>LSEnvironment<\/key>\s*<dict>)/u;
|
|
996
|
+
if (environment.test(plist)) {
|
|
997
|
+
plist = plist.replace(environment, `$1${entry}`);
|
|
998
|
+
} else {
|
|
999
|
+
const closing = plist.lastIndexOf("</dict>");
|
|
1000
|
+
if (closing === -1) throw new Error("vendored app Info.plist has no root dictionary");
|
|
1001
|
+
plist = `${plist.slice(0, closing)}<key>LSEnvironment</key><dict>${entry}</dict>${plist.slice(closing)}`;
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
fs.writeFileSync(plistPath, plist, { mode: 0o644 });
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
function rebrandElectronHelpers(bundle, { fromName, toName, fromIdentifier, toIdentifier }) {
|
|
1008
|
+
const frameworks = path.join(bundle, "Contents", "Frameworks");
|
|
1009
|
+
if (!fs.existsSync(frameworks)) throw new Error("vendored app has no Electron helper bundles");
|
|
1010
|
+
const helperPattern = new RegExp(`^${escapeRegex(fromName)} Helper(?: \\([^)]+\\))?\\.app$`, "u");
|
|
1011
|
+
const helpers = fs.readdirSync(frameworks, { withFileTypes: true })
|
|
1012
|
+
.filter((entry) => entry.isDirectory() && helperPattern.test(entry.name));
|
|
1013
|
+
if (helpers.length === 0) throw new Error("vendored app has no recognized Electron helper bundles");
|
|
1014
|
+
|
|
1015
|
+
for (const helper of helpers) {
|
|
1016
|
+
const oldBundleName = helper.name.slice(0, -4);
|
|
1017
|
+
const newBundleName = oldBundleName.replace(fromName, toName);
|
|
1018
|
+
const oldBundle = path.join(frameworks, helper.name);
|
|
1019
|
+
const plistPath = path.join(oldBundle, "Contents", "Info.plist");
|
|
1020
|
+
const oldExecutable = path.join(oldBundle, "Contents", "MacOS", oldBundleName);
|
|
1021
|
+
const newExecutable = path.join(oldBundle, "Contents", "MacOS", newBundleName);
|
|
1022
|
+
if (!fs.existsSync(plistPath) || !fs.existsSync(oldExecutable)) {
|
|
1023
|
+
throw new Error(`unsupported Electron helper layout in ${oldBundle}`);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
let plist = fs.readFileSync(plistPath, "utf8");
|
|
1027
|
+
plist = replacePlistString(plist, "CFBundleDisplayName", newBundleName);
|
|
1028
|
+
plist = replacePlistString(plist, "CFBundleExecutable", newBundleName);
|
|
1029
|
+
plist = replacePlistString(plist, "CFBundleIdentifier", toIdentifier);
|
|
1030
|
+
plist = replacePlistString(
|
|
1031
|
+
plist,
|
|
1032
|
+
"CFBundleName",
|
|
1033
|
+
oldBundleName === `${fromName} Helper` ? toName : newBundleName,
|
|
1034
|
+
);
|
|
1035
|
+
plist = plist.replaceAll(`msauth.${fromIdentifier}`, `msauth.${toIdentifier}`);
|
|
1036
|
+
fs.writeFileSync(plistPath, plist, { mode: 0o644 });
|
|
1037
|
+
fs.renameSync(oldExecutable, newExecutable);
|
|
1038
|
+
fs.renameSync(oldBundle, path.join(frameworks, `${newBundleName}.app`));
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function vendoredChatGPTWrapperPlist(vendorPath) {
|
|
1043
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
1044
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
1045
|
+
<plist version="1.0"><dict>
|
|
1046
|
+
<key>CFBundleDisplayName</key><string>Impel ChatGPT</string>
|
|
1047
|
+
<key>CFBundleExecutable</key><string>launch</string>
|
|
1048
|
+
<key>CFBundleIconFile</key><string>electron.icns</string>
|
|
1049
|
+
<key>CFBundleIdentifier</key><string>com.useimpel.chatgpt</string>
|
|
1050
|
+
<key>CFBundleName</key><string>Impel ChatGPT</string>
|
|
1051
|
+
<key>CFBundlePackageType</key><string>APPL</string>
|
|
1052
|
+
<key>CFBundleShortVersionString</key><string>${xmlEscape(bundleVersion(vendorPath) || "1")}</string>
|
|
1053
|
+
<key>LSMinimumSystemVersion</key><string>12.0</string>
|
|
1054
|
+
<key>NSHighResolutionCapable</key><true/>
|
|
1055
|
+
</dict></plist>
|
|
1056
|
+
`;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function vendoredChatGPTLauncher(paths, vendorBundleName, executableName, gatewayUrl) {
|
|
1060
|
+
const codexAccountBaseUrl = `${gatewayUrl}/chatgpt_passthrough/backend-api`;
|
|
1061
|
+
return `#!/bin/sh
|
|
1062
|
+
set -eu
|
|
1063
|
+
umask 077
|
|
1064
|
+
HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
1065
|
+
VENDOR_APP="$HERE/../Resources/"${shellQuote(vendorBundleName)}
|
|
1066
|
+
export CODEX_HOME=${shellQuote(paths.chatgpt.codexHome)}
|
|
1067
|
+
export CODEX_AUTHAPI_BASE_URL=${shellQuote(codexAccountBaseUrl)}
|
|
1068
|
+
unset CODEX_ACCESS_TOKEN CODEX_API_KEY OPENAI_API_KEY OPENAI_BASE_URL
|
|
1069
|
+
secure_codex_home() {
|
|
1070
|
+
[ ! -L "$CODEX_HOME" ] || exit 1
|
|
1071
|
+
mkdir -p "$CODEX_HOME"
|
|
1072
|
+
chmod 700 "$CODEX_HOME"
|
|
1073
|
+
rm -rf -- "$CODEX_HOME/shell_snapshots"
|
|
1074
|
+
for FILE in "$CODEX_HOME/auth.json" "$CODEX_HOME/config.toml" "$CODEX_HOME/models.json"; do
|
|
1075
|
+
[ ! -L "$FILE" ] || exit 1
|
|
1076
|
+
[ ! -e "$FILE" ] || chmod 600 "$FILE"
|
|
1077
|
+
done
|
|
1078
|
+
}
|
|
1079
|
+
secure_codex_home
|
|
1080
|
+
CODEX_BIN="$VENDOR_APP/Contents/Resources/codex"
|
|
1081
|
+
"$CODEX_BIN" features disable shell_snapshot >/dev/null 2>&1
|
|
1082
|
+
secure_codex_home
|
|
1083
|
+
TOKEN=$(${shellQuote(paths.tokenHelper)})
|
|
1084
|
+
[ -n "$TOKEN" ] || exit 1
|
|
1085
|
+
printf 'at-%s\\n' "$TOKEN" | "$CODEX_BIN" login --with-access-token >/dev/null 2>&1
|
|
1086
|
+
unset TOKEN
|
|
1087
|
+
secure_codex_home
|
|
1088
|
+
BROWSER_DATA=${shellQuote(paths.chatgpt.browserData)}
|
|
1089
|
+
mkdir -p "$BROWSER_DATA"
|
|
1090
|
+
# Electron resolves resources from the Impel wrapper when a nested app is
|
|
1091
|
+
# executed directly. The wrapper exposes the vendor resource tree there, so
|
|
1092
|
+
# app.asar and companion binaries resolve without LaunchServices handing the
|
|
1093
|
+
# process to the separately running, identically identified vendor app.
|
|
1094
|
+
exec "$VENDOR_APP/Contents/MacOS/${executableName}" --user-data-dir="$BROWSER_DATA" "$@"
|
|
1095
|
+
`;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
function signVendoredApp(bundle, vendorExecutable, label) {
|
|
1099
|
+
if (process.platform !== "darwin" || !isMachO(vendorExecutable)) return;
|
|
1100
|
+
spawnSync("/usr/bin/xattr", ["-dr", "com.apple.quarantine", bundle], { stdio: "ignore" });
|
|
1101
|
+
let signed = codesign(vendoredAppSigningArgs(bundle, label));
|
|
1102
|
+
if (signed.status !== 0) throw new Error(`failed to sign vendored Impel ${label} app: ${signed.stderr.trim()}`);
|
|
1103
|
+
if (label === "Claude") {
|
|
1104
|
+
signed = signVendoredClaudeCapabilities(bundle);
|
|
1105
|
+
if (signed.status !== 0) throw new Error(`failed to restore vendored Impel Claude capabilities: ${signed.stderr.trim()}`);
|
|
1106
|
+
}
|
|
1107
|
+
const verified = spawnSync("/usr/bin/codesign", ["--verify", "--deep", "--strict", bundle], { encoding: "utf8" });
|
|
1108
|
+
if (verified.status !== 0) throw new Error(`vendored Impel ${label} signature verification failed: ${verified.stderr.trim()}`);
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
export function vendoredAppSigningArgs(bundle, label) {
|
|
1112
|
+
// Browser control authenticates the ChatGPT process by OpenAI's code-signing
|
|
1113
|
+
// identity. Deep ad-hoc signing replaces that identity on every nested code
|
|
1114
|
+
// object, including Contents/MacOS/ChatGPT, so sign only the modified outer
|
|
1115
|
+
// bundle and preserve the vendor signatures within it. Claude deliberately
|
|
1116
|
+
// retains its separate deep-signing and entitlement-restoration flow below.
|
|
1117
|
+
return label === "ChatGPT"
|
|
1118
|
+
? ["--force", "--sign", "-", bundle]
|
|
1119
|
+
: ["--force", "--deep", "--sign", "-", bundle];
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
function signVendoredClaudeCapabilities(bundle) {
|
|
1123
|
+
const fullEntitlements = entitlementPlist({
|
|
1124
|
+
"com.apple.security.cs.allow-jit": true,
|
|
1125
|
+
// Ad-hoc signatures have no common Apple Team ID, so hardened runtime
|
|
1126
|
+
// otherwise refuses to map the app's separately signed Electron framework.
|
|
1127
|
+
"com.apple.security.cs.disable-library-validation": true,
|
|
1128
|
+
"com.apple.security.device.audio-input": true,
|
|
1129
|
+
"com.apple.security.device.bluetooth": true,
|
|
1130
|
+
"com.apple.security.device.camera": true,
|
|
1131
|
+
"com.apple.security.device.print": true,
|
|
1132
|
+
"com.apple.security.device.usb": true,
|
|
1133
|
+
"com.apple.security.personal-information.location": true,
|
|
1134
|
+
"com.apple.security.personal-information.photos-library": true,
|
|
1135
|
+
"com.apple.security.virtualization": true,
|
|
1136
|
+
});
|
|
1137
|
+
const jitEntitlements = entitlementPlist({
|
|
1138
|
+
"com.apple.security.cs.allow-jit": true,
|
|
1139
|
+
"com.apple.security.cs.disable-library-validation": true,
|
|
1140
|
+
});
|
|
1141
|
+
const fullPath = path.join(os.tmpdir(), `impel-claude-entitlements-${process.pid}.plist`);
|
|
1142
|
+
const jitPath = path.join(os.tmpdir(), `impel-claude-helper-entitlements-${process.pid}.plist`);
|
|
1143
|
+
fs.writeFileSync(fullPath, fullEntitlements, { mode: 0o600 });
|
|
1144
|
+
fs.writeFileSync(jitPath, jitEntitlements, { mode: 0o600 });
|
|
1145
|
+
try {
|
|
1146
|
+
const frameworks = path.join(bundle, "Contents", "Frameworks");
|
|
1147
|
+
const helpers = fs.readdirSync(frameworks, { withFileTypes: true })
|
|
1148
|
+
.filter((entry) => entry.isDirectory() && /^Impel Claude Helper(?: \([^)]+\))?\.app$/u.test(entry.name));
|
|
1149
|
+
for (const helper of helpers) {
|
|
1150
|
+
const entitlements = helper.name === "Impel Claude Helper.app" ? fullPath : jitPath;
|
|
1151
|
+
const result = codesign([
|
|
1152
|
+
"--force", "--deep", "--options", "runtime", "--entitlements", entitlements, "--sign", "-",
|
|
1153
|
+
path.join(frameworks, helper.name),
|
|
1154
|
+
]);
|
|
1155
|
+
if (result.status !== 0) return result;
|
|
1156
|
+
}
|
|
1157
|
+
return codesign([
|
|
1158
|
+
"--force", "--options", "runtime", "--entitlements", fullPath, "--sign", "-", bundle,
|
|
1159
|
+
]);
|
|
1160
|
+
} finally {
|
|
1161
|
+
fs.rmSync(fullPath, { force: true });
|
|
1162
|
+
fs.rmSync(jitPath, { force: true });
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
function entitlementPlist(values) {
|
|
1167
|
+
const entries = Object.entries(values).map(([key, value]) => (
|
|
1168
|
+
` <key>${xmlEscape(key)}</key>\n <${value ? "true" : "false"}/>`
|
|
1169
|
+
)).join("\n");
|
|
1170
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
1171
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
1172
|
+
<plist version="1.0"><dict>
|
|
1173
|
+
${entries}
|
|
1174
|
+
</dict></plist>
|
|
1175
|
+
`;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
function codesign(args) {
|
|
1179
|
+
return spawnSync("/usr/bin/codesign", args, { encoding: "utf8" });
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
function isMachO(file) {
|
|
1183
|
+
const handle = fs.openSync(file, "r");
|
|
1184
|
+
try {
|
|
1185
|
+
const magic = Buffer.alloc(4);
|
|
1186
|
+
if (fs.readSync(handle, magic, 0, 4, 0) !== 4) return false;
|
|
1187
|
+
return ["cffaedfe", "feedfacf", "cafebabe", "bebafeca"].includes(magic.toString("hex"));
|
|
1188
|
+
} finally {
|
|
1189
|
+
fs.closeSync(handle);
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
function asarHeaderHash(file) {
|
|
1194
|
+
const archive = fs.readFileSync(file);
|
|
1195
|
+
const { headerLength } = readAsarMetadata(archive);
|
|
1196
|
+
return sha256(archive.subarray(16, 16 + headerLength));
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
function sha256(contents) {
|
|
1200
|
+
return crypto.createHash("sha256").update(contents).digest("hex");
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
function replaceDirectory(destination, staging) {
|
|
1204
|
+
const backup = `${destination}.previous-${process.pid}`;
|
|
1205
|
+
fs.rmSync(backup, { recursive: true, force: true });
|
|
1206
|
+
if (fs.existsSync(destination)) fs.renameSync(destination, backup);
|
|
1207
|
+
try {
|
|
1208
|
+
fs.renameSync(staging, destination);
|
|
1209
|
+
fs.rmSync(backup, { recursive: true, force: true });
|
|
1210
|
+
} catch (error) {
|
|
1211
|
+
if (!fs.existsSync(destination) && fs.existsSync(backup)) fs.renameSync(backup, destination);
|
|
1212
|
+
throw error;
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
function isGatewayModel(model) {
|
|
1217
|
+
return model && typeof model.id === "string" && (model.provider === "claude" || model.provider === "codex");
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
function writeAtomic(target, contents, mode) {
|
|
1221
|
+
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
1222
|
+
if (fs.existsSync(target) && fs.readFileSync(target, "utf8") === contents) {
|
|
1223
|
+
fs.chmodSync(target, mode);
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
const tmp = `${target}.tmp-${process.pid}`;
|
|
1227
|
+
fs.writeFileSync(tmp, contents, { mode });
|
|
1228
|
+
fs.renameSync(tmp, target);
|
|
1229
|
+
fs.chmodSync(target, mode);
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
function bundleVersion(bundle) {
|
|
1233
|
+
const plistPath = path.join(bundle, "Contents", "Info.plist");
|
|
1234
|
+
const result = spawnSync("/usr/bin/plutil", ["-extract", "CFBundleShortVersionString", "raw", plistPath], { encoding: "utf8" });
|
|
1235
|
+
if (result.status === 0) return result.stdout.trim();
|
|
1236
|
+
try {
|
|
1237
|
+
const plist = fs.readFileSync(plistPath, "utf8");
|
|
1238
|
+
return plist.match(/<key>CFBundleShortVersionString<\/key>\s*<string>([^<]+)<\/string>/u)?.[1] || null;
|
|
1239
|
+
} catch {
|
|
1240
|
+
return null;
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
function findCommand(command) {
|
|
1245
|
+
const result = spawnSync("/usr/bin/which", [command], { encoding: "utf8" });
|
|
1246
|
+
return result.status === 0 ? result.stdout.trim() : null;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
function shellQuote(value) {
|
|
1250
|
+
return `'${String(value).replace(/'/g, `'"'"'`)}'`;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
function escapeRegex(value) {
|
|
1254
|
+
return String(value).replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
function tomlString(value) {
|
|
1258
|
+
return JSON.stringify(String(value));
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
function xmlEscape(value) {
|
|
1262
|
+
return String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
1263
|
+
}
|