impel-cli 0.17.0 → 0.17.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -56,7 +56,7 @@ Useful setup options:
56
56
  ```sh
57
57
  impel setup --pat <pat> # non-interactive credential input
58
58
  impel setup --skip-apps # prepare CLI profiles only
59
- impel setup --skip-clis # Windows: do not install missing vendor CLIs
59
+ impel setup --skip-clis # do not install missing vendor CLIs
60
60
  impel setup --no-recovery # disable local and assisted recovery for this run
61
61
  ```
62
62
 
@@ -133,6 +133,7 @@ Useful update options:
133
133
  ```sh
134
134
  impel update --check # inspect npm availability without changing state
135
135
  impel update --skip-apps # reconcile CLI profiles only
136
+ impel update --skip-clis # do not install missing vendor CLIs
136
137
  impel update --no-recovery # disable recovery for this run
137
138
  ```
138
139
 
@@ -231,6 +232,8 @@ compatibility for one release, but no command can enable native routing.
231
232
 
232
233
  ### macOS
233
234
 
235
+ - Missing Claude Code and Codex CLIs can be installed for the signed-in user
236
+ through the vendors' checksum-verifying native installers after confirmation.
234
237
  - Tenant apps live in `~/Applications`.
235
238
  - LaunchServices registration is non-launching and idempotent.
236
239
  - Existing bundle fingerprints and stable signing identity prevent unnecessary
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.17.0",
3
+ "version": "0.17.2",
4
4
  "description": "Prepare isolated Claude and Codex workspaces for every accessible Impel tenant",
5
5
  "type": "module",
6
6
  "bin": {
package/src/apps.js CHANGED
@@ -1373,11 +1373,13 @@ function writeVendoredClaudeBundle(paths, vendorPath, gatewayUrl, safeStorageNam
1373
1373
  });
1374
1374
 
1375
1375
  const macos = path.join(staging, "Contents", "MacOS");
1376
+ const vendorExecutable = path.join(macos, executableName);
1377
+ const signingIdentity = resolveVendoredAppSigningIdentity(vendorExecutable);
1376
1378
  writeAtomic(path.join(staging, "Contents", "Resources", "impel-compatibility.json"), JSON.stringify({
1377
1379
  schemaVersion: 4,
1378
1380
  cliVersion: CLI_VERSION,
1379
1381
  bundleFingerprint: BUNDLE_BUILD_FINGERPRINT,
1380
- signingMode: desiredSigningMode(),
1382
+ signingMode: signingModeForIdentity(signingIdentity),
1381
1383
  vendorVersion: bundleVersion(vendorPath),
1382
1384
  sourceBundle: vendorPath,
1383
1385
  bundleIdentifier: paths.claude.bundleIdentifier,
@@ -1403,7 +1405,7 @@ function writeVendoredClaudeBundle(paths, vendorPath, gatewayUrl, safeStorageNam
1403
1405
  asarHeaderSha256: newAsarHash,
1404
1406
  }, null, 2) + "\n", 0o644);
1405
1407
 
1406
- signVendoredApp(staging, path.join(macos, executableName), "Claude");
1408
+ signVendoredApp(staging, vendorExecutable, "Claude", signingIdentity);
1407
1409
  replaceDirectory(bundle, staging);
1408
1410
  } catch (error) {
1409
1411
  fs.rmSync(staging, { recursive: true, force: true });
@@ -1456,6 +1458,8 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl) {
1456
1458
  const runtimeAsarPath = path.join(staging, "Contents", "Resources", "app.asar");
1457
1459
  const fastModePatchCount = patchFastModeAuthGate(runtimeAsarPath);
1458
1460
  const asarHash = asarHeaderHash(runtimeAsarPath);
1461
+ const vendorExecutable = path.join(vendorBundle, "Contents", "MacOS", executableName);
1462
+ const signingIdentity = resolveVendoredAppSigningIdentity(vendorExecutable);
1459
1463
  const launchHost = ensureChatGPTLaunchHost();
1460
1464
  writeAtomic(
1461
1465
  path.join(staging, "Contents", "Info.plist"),
@@ -1487,7 +1491,7 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl) {
1487
1491
  schemaVersion: 3,
1488
1492
  cliVersion: CLI_VERSION,
1489
1493
  bundleFingerprint: BUNDLE_BUILD_FINGERPRINT,
1490
- signingMode: desiredSigningMode(),
1494
+ signingMode: signingModeForIdentity(signingIdentity),
1491
1495
  vendorVersion: bundleVersion(vendorPath),
1492
1496
  sourceBundle: vendorPath,
1493
1497
  bundleIdentifier: paths.chatgpt.bundleIdentifier,
@@ -1505,7 +1509,7 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl) {
1505
1509
  asarHeaderSha256: asarHash,
1506
1510
  }, null, 2) + "\n", 0o644);
1507
1511
 
1508
- signVendoredApp(staging, path.join(vendorBundle, "Contents", "MacOS", executableName), "ChatGPT");
1512
+ signVendoredApp(staging, vendorExecutable, "ChatGPT", signingIdentity);
1509
1513
  replaceDirectory(bundle, staging);
1510
1514
  } catch (error) {
1511
1515
  fs.rmSync(staging, { recursive: true, force: true });
@@ -2131,6 +2135,16 @@ if (process.type === "browser" && displayName) {
2131
2135
  `;
2132
2136
  }
2133
2137
 
2138
+ function resolveVendoredAppSigningIdentity(vendorExecutable) {
2139
+ if (process.platform !== "darwin" || !isMachO(vendorExecutable)) return null;
2140
+ return resolveSigningIdentity();
2141
+ }
2142
+
2143
+ export function signingModeForIdentity(identity) {
2144
+ if (!identity) return desiredSigningMode();
2145
+ return identity.mode === "external" ? identity.descriptor : identity.mode;
2146
+ }
2147
+
2134
2148
  function signVendoredApp(bundle, vendorExecutable, label, identity = null) {
2135
2149
  if (process.platform !== "darwin" || !isMachO(vendorExecutable)) return;
2136
2150
  // Resolve (and, on first use, create) the signing identity only once we know
package/src/cli.js CHANGED
@@ -19,6 +19,7 @@ import { cmdSessions } from "./commands/sessions.js";
19
19
  import { cmdUpdate } from "./commands/update.js";
20
20
  import { cmdExperimental } from "./commands/experimental.js";
21
21
  import { cmdConverge } from "./commands/converge.js";
22
+ import { refuseElevatedMacExecution } from "./privileges.js";
22
23
 
23
24
  const HELP = `impel — isolated Impel workspaces for every tenant
24
25
 
@@ -75,6 +76,7 @@ function normalizeTarget(token) {
75
76
  }
76
77
 
77
78
  export async function main(argv) {
79
+ if (refuseElevatedMacExecution(argv)) return;
78
80
  const [cmd, ...rest] = argv;
79
81
 
80
82
  switch (cmd) {
package/src/codesign.js CHANGED
@@ -117,7 +117,10 @@ export function resolveSigningIdentity(deps = {}) {
117
117
  }
118
118
 
119
119
  try {
120
- const identity = ensureLocalSigningIdentity({ run, configDir, fsImpl, randomBytes });
120
+ const identity = withSigningIdentityLock(
121
+ { configDir, fsImpl },
122
+ () => ensureLocalSigningIdentity({ run, configDir, fsImpl, randomBytes }),
123
+ );
121
124
  if (cache) cachedIdentity = identity;
122
125
  return identity;
123
126
  } catch (error) {
@@ -142,25 +145,33 @@ function ensureLocalSigningIdentity({ run, configDir, fsImpl, randomBytes }) {
142
145
  // therefore the designated requirement) stays stable across updates.
143
146
  if (fsImpl.existsSync(keychain) && fsImpl.existsSync(passwordFile)) {
144
147
  const password = fsImpl.readFileSync(passwordFile, "utf8").trim();
145
- unlockKeychain(run, keychain, password);
146
- ensureKeychainInSearchList(run, keychain);
147
- const existing = findLocalIdentitySha(run, keychain);
148
- if (existing) return localIdentity(existing, keychain);
149
-
150
- // v0.13.0 imported a self-signed certificate but never established code-
151
- // signing trust for it. `security find-identity -v` therefore reported
152
- // zero valid identities and every rebuild fell back to ad-hoc signing.
153
- // Repair that exact state in place so the already-generated identity stays
154
- // stable instead of accumulating a second certificate with the same name.
155
- trustExistingLocalCertificate({ run, keychain, fsImpl });
156
- const repaired = findLocalIdentitySha(run, keychain);
157
- if (repaired) return localIdentity(repaired, keychain);
158
- throw new Error("existing signing identity could not be trusted");
148
+ try {
149
+ if (!password) throw new Error("stored signing keychain password is empty");
150
+ unlockKeychain(run, keychain, password);
151
+ runOrThrow(run, "/usr/bin/security", ["set-keychain-settings", keychain], "configure signing keychain");
152
+ ensureKeychainInSearchList(run, keychain);
153
+ let existing = findLocalIdentitySha(run, keychain);
154
+ if (existing) return localIdentity(existing, keychain);
155
+
156
+ // Repair identities created before trust was established, plus imports
157
+ // interrupted after the private key reached the keychain.
158
+ authorizeCodesignKey(run, keychain, password);
159
+ trustExistingLocalCertificate({ run, keychain, fsImpl });
160
+ existing = findLocalIdentitySha(run, keychain);
161
+ if (existing) return localIdentity(existing, keychain);
162
+ throw new Error("existing signing identity could not be trusted");
163
+ } catch {
164
+ // This keychain and password file are entirely Impel-owned. A mismatched,
165
+ // empty, or half-imported pair can never provide stable identity, so reset
166
+ // it atomically instead of falling back forever on every future rebuild.
167
+ resetLocalSigningIdentity({ run, keychain, passwordFile, fsImpl });
168
+ }
169
+ } else if (fsImpl.existsSync(keychain) || fsImpl.existsSync(passwordFile)) {
170
+ resetLocalSigningIdentity({ run, keychain, passwordFile, fsImpl });
159
171
  }
160
172
 
161
173
  const password = randomBytes(24).toString("hex");
162
- createLocalSigningIdentity({ run, dir, keychain, password, fsImpl });
163
- fsImpl.writeFileSync(passwordFile, `${password}\n`, { mode: 0o600 });
174
+ createLocalSigningIdentity({ run, dir, keychain, passwordFile, password, fsImpl });
164
175
 
165
176
  ensureKeychainInSearchList(run, keychain);
166
177
  const sha = findLocalIdentitySha(run, keychain);
@@ -183,7 +194,7 @@ function findLocalIdentitySha(run, keychain) {
183
194
  return parseIdentitySha(result.stdout, SIGNING_IDENTITY_NAME);
184
195
  }
185
196
 
186
- function createLocalSigningIdentity({ run, dir, keychain, password, fsImpl }) {
197
+ function createLocalSigningIdentity({ run, dir, keychain, passwordFile, password, fsImpl }) {
187
198
  const work = fsImpl.mkdtempSync(path.join(os.tmpdir(), "impel-codesign-"));
188
199
  const keyPath = path.join(work, "key.pem");
189
200
  const certPath = path.join(work, "cert.pem");
@@ -211,26 +222,89 @@ function createLocalSigningIdentity({ run, dir, keychain, password, fsImpl }) {
211
222
  "-name", SIGNING_IDENTITY_NAME,
212
223
  ], "package signing certificate");
213
224
 
214
- if (!fsImpl.existsSync(keychain)) {
215
- runOrThrow(run, "/usr/bin/security", ["create-keychain", "-p", password, keychain], "create signing keychain");
225
+ runOrThrow(run, "/usr/bin/security", ["create-keychain", "-p", password, keychain], "create signing keychain");
226
+ try {
227
+ writePrivateAtomic(fsImpl, passwordFile, `${password}\n`);
228
+ } catch (error) {
229
+ resetLocalSigningIdentity({ run, keychain, passwordFile, fsImpl });
230
+ throw error;
216
231
  }
217
232
  // No auto-lock: codesign must reach the private key non-interactively.
218
- run("/usr/bin/security", ["set-keychain-settings", keychain]);
233
+ runOrThrow(run, "/usr/bin/security", ["set-keychain-settings", keychain], "configure signing keychain");
219
234
  unlockKeychain(run, keychain, password);
220
235
  runOrThrow(run, "/usr/bin/security", [
221
236
  "import", p12Path, "-k", keychain, "-P", p12Pass, "-T", "/usr/bin/codesign",
222
237
  ], "import signing identity");
223
238
  // Grant codesign non-interactive access to the key so signing never pops a
224
239
  // "codesign wants to use a key" GUI prompt. We own the keychain password.
225
- runOrThrow(run, "/usr/bin/security", [
226
- "set-key-partition-list", "-S", "apple-tool:,apple:,codesign:", "-s", "-k", password, keychain,
227
- ], "authorize codesign for the signing key");
240
+ authorizeCodesignKey(run, keychain, password);
228
241
  trustLocalCertificate(run, keychain, certPath);
229
242
  } finally {
230
243
  fsImpl.rmSync(work, { recursive: true, force: true });
231
244
  }
232
245
  }
233
246
 
247
+ function authorizeCodesignKey(run, keychain, password) {
248
+ runOrThrow(run, "/usr/bin/security", [
249
+ "set-key-partition-list", "-S", "apple-tool:,apple:,codesign:", "-s", "-k", password, keychain,
250
+ ], "authorize codesign for the signing key");
251
+ }
252
+
253
+ function writePrivateAtomic(fsImpl, filePath, contents) {
254
+ const temporary = `${filePath}.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
255
+ try {
256
+ fsImpl.writeFileSync(temporary, contents, { mode: 0o600 });
257
+ fsImpl.renameSync(temporary, filePath);
258
+ fsImpl.chmodSync?.(filePath, 0o600);
259
+ } finally {
260
+ fsImpl.rmSync(temporary, { force: true });
261
+ }
262
+ }
263
+
264
+ function resetLocalSigningIdentity({ run, keychain, passwordFile, fsImpl }) {
265
+ if (fsImpl.existsSync(keychain)) {
266
+ run("/usr/bin/security", ["delete-keychain", keychain]);
267
+ }
268
+ fsImpl.rmSync(keychain, { force: true });
269
+ fsImpl.rmSync(passwordFile, { force: true });
270
+ }
271
+
272
+ const SIGNING_LOCK_STALE_MS = 5 * 60 * 1_000;
273
+ const SIGNING_LOCK_WAIT_MS = 15_000;
274
+ const SIGNING_LOCK_POLL_MS = 100;
275
+ const SIGNING_LOCK_SLEEP = new Int32Array(new SharedArrayBuffer(4));
276
+
277
+ function withSigningIdentityLock({ configDir, fsImpl }, run) {
278
+ const dir = path.join(configDir, "codesign");
279
+ const lock = path.join(dir, ".identity-lock");
280
+ fsImpl.mkdirSync(dir, { recursive: true, mode: 0o700 });
281
+ const deadline = Date.now() + SIGNING_LOCK_WAIT_MS;
282
+ while (true) {
283
+ try {
284
+ fsImpl.mkdirSync(lock, { mode: 0o700 });
285
+ break;
286
+ } catch (error) {
287
+ if (error?.code !== "EEXIST") throw error;
288
+ try {
289
+ if (Date.now() - fsImpl.statSync(lock).mtimeMs > SIGNING_LOCK_STALE_MS) {
290
+ fsImpl.rmSync(lock, { recursive: true, force: true });
291
+ continue;
292
+ }
293
+ } catch (statError) {
294
+ if (statError?.code !== "ENOENT") throw statError;
295
+ continue;
296
+ }
297
+ if (Date.now() >= deadline) throw new Error("timed out waiting for another Impel signing operation");
298
+ Atomics.wait(SIGNING_LOCK_SLEEP, 0, 0, SIGNING_LOCK_POLL_MS);
299
+ }
300
+ }
301
+ try {
302
+ return run();
303
+ } finally {
304
+ fsImpl.rmSync(lock, { recursive: true, force: true });
305
+ }
306
+ }
307
+
234
308
  function unlockKeychain(run, keychain, password) {
235
309
  runOrThrow(
236
310
  run,
@@ -43,6 +43,7 @@ import { syncAgentProfilesSafe } from "../agents.js";
43
43
  import { secureManagedCodexHome } from "../codexSecurity.js";
44
44
  import { impelCliInvocation } from "../selfInvocation.js";
45
45
  import { createProgressLogger, withProgress } from "../progress.js";
46
+ import { findNativeBinary } from "../nativeProcess.js";
46
47
  import {
47
48
  ensureWindowsClaudeApp,
48
49
  ensureWindowsChatGPTApp,
@@ -180,6 +181,21 @@ export function installedAppTenantIds(targets, {
180
181
  return installed.sort((left, right) => left.localeCompare(right));
181
182
  }
182
183
 
184
+ async function maybeRunLegacyUpdateHandoff(argv, overrides) {
185
+ if (overrides.singleTenantUpdate) return null;
186
+ const [action, target, ...rest] = argv;
187
+ if (action !== "update" || target !== "all" || rest.length > 0) return null;
188
+
189
+ // impel-cli <=0.16.5 re-executes a newly installed build with this exact
190
+ // command. Route that cross-version handoff through the current full-tenant
191
+ // reconciler so first-time tenant targets are not limited to local installs.
192
+ const runConvergence = overrides.runAllTenantConvergence || (async () => {
193
+ const { cmdConverge } = await import("./converge.js");
194
+ return cmdConverge([]);
195
+ });
196
+ return runConvergence();
197
+ }
198
+
183
199
  async function maybeUpdateAllInstalledTenants(argv, overrides, platform) {
184
200
  if (overrides.singleTenantUpdate) return null;
185
201
  const [action = "status", rawTarget, ...rest] = argv;
@@ -310,6 +326,7 @@ export async function reconcileWindowsTenantApps({
310
326
  syncSkills: syncSkillsSafe,
311
327
  syncAgents: syncAgentProfilesSafe,
312
328
  existsSync: fs.existsSync,
329
+ findBinary: findNativeBinary,
313
330
  log: (message) => console.log(message),
314
331
  ...overrides,
315
332
  };
@@ -360,17 +377,23 @@ export async function reconcileWindowsTenantApps({
360
377
  homeDir,
361
378
  environment,
362
379
  });
380
+ const agentTargets = [];
363
381
  for (const target of actionTargets) {
364
382
  const { client, env, label } = appSkillTarget(target, paths);
365
- await io.syncSkills({ client, gatewayUrl: resolveSkillsGateway(config.gatewayUrl), env, label });
383
+ if (io.findBinary(client, environment, "win32")) {
384
+ await io.syncSkills({ client, gatewayUrl: resolveSkillsGateway(config.gatewayUrl), env, label });
385
+ agentTargets.push(target);
386
+ }
366
387
  if (target === "chatgpt") secureManagedCodexHome(paths.chatgpt.codexHome);
367
388
  }
368
- await io.syncAgents({
369
- profiles: actionTargets.map((target) => appAgentProfile(target, paths)),
370
- gatewayUrl: config.gatewayUrl,
371
- credential: config.pat,
372
- tenantId: config.tenantId,
373
- });
389
+ if (agentTargets.length) {
390
+ await io.syncAgents({
391
+ profiles: agentTargets.map((target) => appAgentProfile(target, paths)),
392
+ gatewayUrl: config.gatewayUrl,
393
+ credential: config.pat,
394
+ tenantId: config.tenantId,
395
+ });
396
+ }
374
397
  const verification = {
375
398
  claude: !actionTargets.includes("claude") || io.existsSync(path.join(paths.claude.userData, "configLibrary", `${CLAUDE_CONFIG_ID}.json`)),
376
399
  chatgpt: !actionTargets.includes("chatgpt") || io.existsSync(path.join(paths.chatgpt.codexHome, "config.toml")),
@@ -389,6 +412,7 @@ export async function reconcileMacTenantApps({
389
412
  skipVendorTargets = [],
390
413
  confirmVendorInstall = async () => true,
391
414
  homeDir = os.homedir(),
415
+ environment = process.env,
392
416
  } = {}, overrides = {}) {
393
417
  const io = {
394
418
  status: appStatus,
@@ -402,6 +426,7 @@ export async function reconcileMacTenantApps({
402
426
  secureCodexHome: secureManagedCodexHome,
403
427
  openLauncher: openManagedLauncher,
404
428
  existsSync: fs.existsSync,
429
+ findBinary: findNativeBinary,
405
430
  log: (message) => console.log(message),
406
431
  ...overrides,
407
432
  };
@@ -447,17 +472,23 @@ export async function reconcileMacTenantApps({
447
472
  writeBundles: staleTargets,
448
473
  });
449
474
  const paths = appPaths(homeDir, config.tenantId, { tenantName: config.tenantName });
475
+ const agentItems = [];
450
476
  for (const item of installed) {
451
477
  const { client, env, label } = appSkillTarget(item.target, paths);
452
- await io.syncSkills({ client, gatewayUrl: resolveSkillsGateway(config.gatewayUrl), env, label });
478
+ if (io.findBinary(client, environment, "darwin")) {
479
+ await io.syncSkills({ client, gatewayUrl: resolveSkillsGateway(config.gatewayUrl), env, label });
480
+ agentItems.push(item);
481
+ }
453
482
  if (item.target === "chatgpt") io.secureCodexHome(paths.chatgpt.codexHome);
454
483
  }
455
- await io.syncAgents({
456
- profiles: installed.map((item) => appAgentProfile(item.target, paths)),
457
- gatewayUrl: config.gatewayUrl,
458
- credential: config.pat,
459
- tenantId: config.tenantId,
460
- });
484
+ if (agentItems.length) {
485
+ await io.syncAgents({
486
+ profiles: agentItems.map((item) => appAgentProfile(item.target, paths)),
487
+ gatewayUrl: config.gatewayUrl,
488
+ credential: config.pat,
489
+ tenantId: config.tenantId,
490
+ });
491
+ }
461
492
  for (const item of installed) {
462
493
  if (reopenTargets.has(item.target)) io.openLauncher(item.launcher);
463
494
  }
@@ -712,6 +743,8 @@ export async function cmdWindowsApps(argv, overrides = {}) {
712
743
 
713
744
  export async function cmdApps(argv, overrides = {}) {
714
745
  const platform = overrides.platform || process.platform;
746
+ const legacyUpdateHandoff = await maybeRunLegacyUpdateHandoff(argv, overrides);
747
+ if (legacyUpdateHandoff !== null) return legacyUpdateHandoff;
715
748
  const allTenantUpdate = await maybeUpdateAllInstalledTenants(argv, overrides, platform);
716
749
  if (allTenantUpdate !== null) return allTenantUpdate;
717
750
  if (platform === "win32") return cmdWindowsApps(argv, overrides);
@@ -5,6 +5,7 @@ import { runInstallRecovery } from "../installRecovery/engine.js";
5
5
  import { printReconciliationSummary, reconcileAllTenants, selectDefaultTenant } from "../provisioning.js";
6
6
  import { fetchTenants } from "../tenants.js";
7
7
  import { promptText } from "../prompt.js";
8
+ import { describeCliFailure, preparePlatformClis } from "../platformSetup.js";
8
9
  import { restoreNativeProfiles } from "./use.js";
9
10
 
10
11
  function confirmed(answer) {
@@ -24,6 +25,7 @@ function mergeTenantReport(report, replacement) {
24
25
 
25
26
  export async function cmdConverge(argv = [], overrides = {}) {
26
27
  const skipApps = argv.includes("--skip-apps");
28
+ const skipClis = argv.includes("--skip-clis");
27
29
  const noRecovery = argv.includes("--no-recovery");
28
30
  const io = {
29
31
  loadConfig,
@@ -36,8 +38,12 @@ export async function cmdConverge(argv = [], overrides = {}) {
36
38
  isTTY: process.stdin.isTTY,
37
39
  platform: process.platform,
38
40
  environment: process.env,
41
+ preparePlatformClis,
39
42
  ...overrides,
40
43
  };
44
+ if (overrides.prepareWindowsClis && !overrides.preparePlatformClis) {
45
+ io.preparePlatformClis = overrides.prepareWindowsClis;
46
+ }
41
47
  const config = io.loadConfig();
42
48
  if (!config?.pat) {
43
49
  console.error("impel update: not authenticated; run `impel setup` first.");
@@ -93,8 +99,94 @@ export async function cmdConverge(argv = [], overrides = {}) {
93
99
  environment: io.environment,
94
100
  confirmVendorInstall: confirmInstall,
95
101
  }, overrides.reconcileOverrides || {});
102
+
103
+ let sharedFailure = null;
104
+ const prepareSharedClis = async ({ confirmedTools = null, inspectOnly = false } = {}) => {
105
+ try {
106
+ const inspected = await io.preparePlatformClis({
107
+ gatewayUrl: config.gatewayUrl,
108
+ tenantId: selected.id,
109
+ platform: io.platform,
110
+ skipInstall: true,
111
+ });
112
+ let prepared = inspected;
113
+ if (inspected.missingAfter.length && !inspectOnly && !skipClis) {
114
+ let installTools = confirmedTools;
115
+ if (installTools === null) {
116
+ installTools = [];
117
+ if (io.isTTY) {
118
+ for (const tool of inspected.missingAfter) {
119
+ if (!inspected.installCommands?.[tool]) continue;
120
+ const label = tool === "claude" ? "Claude Code" : "Codex";
121
+ if (confirmed(await io.promptText(`Install the official ${label} CLI for this user? [y/N] `))) {
122
+ installTools.push(tool);
123
+ }
124
+ }
125
+ }
126
+ }
127
+ if (installTools.length) {
128
+ prepared = await io.preparePlatformClis({
129
+ gatewayUrl: config.gatewayUrl,
130
+ tenantId: selected.id,
131
+ platform: io.platform,
132
+ skipInstall: false,
133
+ installTools,
134
+ });
135
+ }
136
+ }
137
+ sharedFailure = prepared.missingAfter.length ? describeCliFailure(prepared) : null;
138
+ return prepared;
139
+ } catch (error) {
140
+ sharedFailure = redactSecretText(error?.message || error);
141
+ return { binaries: {}, missingAfter: ["claude", "codex"] };
142
+ }
143
+ };
144
+
145
+ await prepareSharedClis();
96
146
  let report = await run();
97
- if (!report.passed && !noRecovery) {
147
+ if (sharedFailure && !noRecovery) {
148
+ let sharedReady = false;
149
+ const recovery = await io.recoverInstall({
150
+ failure: {
151
+ scope: "shared",
152
+ platform: io.platform,
153
+ architecture: process.arch,
154
+ step: "update.shared_vendor_clis",
155
+ message: sharedFailure,
156
+ },
157
+ config,
158
+ goals: [{
159
+ id: "shared-vendor-clis-ready",
160
+ description: "Shared vendor CLI prerequisites are installed and discoverable",
161
+ run: () => sharedReady,
162
+ }],
163
+ explicit: true,
164
+ noRecovery: false,
165
+ actionContext: {
166
+ tenantId: selected.id,
167
+ installVendorClis: async (tool) => {
168
+ await prepareSharedClis({ confirmedTools: [tool] });
169
+ sharedReady = !sharedFailure;
170
+ return io.preparePlatformClis({
171
+ gatewayUrl: config.gatewayUrl,
172
+ tenantId: selected.id,
173
+ platform: io.platform,
174
+ skipInstall: true,
175
+ });
176
+ },
177
+ retryStep: async () => {
178
+ await prepareSharedClis({ inspectOnly: true });
179
+ sharedReady = !sharedFailure;
180
+ return sharedReady;
181
+ },
182
+ },
183
+ }, overrides.recoveryOverrides || {});
184
+ if (recovery.fixed) {
185
+ await prepareSharedClis({ inspectOnly: true });
186
+ report = await run();
187
+ }
188
+ }
189
+ if (!sharedFailure && !report.passed && !noRecovery) {
98
190
  for (const failed of report.tenants.filter((tenant) => tenant.status === "failed")) {
99
191
  const tenant = listing.tenants.find((candidate) => candidate.id === failed.tenantId);
100
192
  if (!tenant) continue;
@@ -165,7 +257,8 @@ export async function cmdConverge(argv = [], overrides = {}) {
165
257
  }
166
258
  printReconciliationSummary(report);
167
259
  console.log(`CLI tenant: ${selected.id} (change with \`impel tenant use <tenant>\`)`);
168
- if (!report.passed) {
260
+ if (!report.passed || sharedFailure) {
261
+ if (sharedFailure) console.error(`Shared update failure: ${sharedFailure}`);
169
262
  console.error("impel update: one or more tenant surfaces remain incomplete.");
170
263
  process.exitCode = 1;
171
264
  return false;
@@ -17,7 +17,7 @@ import {
17
17
  reconcileAllTenants,
18
18
  selectDefaultTenant,
19
19
  } from "../provisioning.js";
20
- import { prepareWindowsClis } from "../windowsSetup.js";
20
+ import { describeCliFailure, preparePlatformClis } from "../platformSetup.js";
21
21
  import { runInstallRecovery } from "../installRecovery/engine.js";
22
22
  import { restoreNativeProfiles } from "./use.js";
23
23
 
@@ -33,7 +33,7 @@ Usage:
33
33
  impel setup --pat <pat> Non-interactive authentication
34
34
  impel setup --tenant <org> Choose the default tenant for CLI launches
35
35
  impel setup --skip-apps Skip desktop apps
36
- impel setup --skip-clis Windows: skip missing vendor CLI installation
36
+ impel setup --skip-clis Do not install missing vendor CLIs
37
37
  impel setup --no-recovery Disable automatic install recovery
38
38
  `;
39
39
 
@@ -53,16 +53,19 @@ async function probeGateway(gatewayUrl, pat, tenantId, fetchImpl = fetch) {
53
53
  const controller = new AbortController();
54
54
  const timeout = setTimeout(() => controller.abort(), 5_000);
55
55
  try {
56
- const response = await fetchImpl(`${gatewayUrl}/anthropic/v1/messages`, {
57
- method: "POST",
56
+ const response = await fetchImpl(`${gatewayUrl}/v1/models`, {
58
57
  headers: {
59
- "content-type": "application/json",
58
+ accept: "application/json",
60
59
  authorization: `Bearer ${tenantCredential(pat, tenantId)}`,
61
60
  },
62
- body: "{}",
63
61
  signal: controller.signal,
64
62
  });
65
- return { reachable: true, status: response.status, rejected: [401, 403].includes(response.status) };
63
+ return {
64
+ reachable: true,
65
+ healthy: response.ok,
66
+ status: response.status,
67
+ rejected: [401, 403].includes(response.status),
68
+ };
66
69
  } catch (error) {
67
70
  return {
68
71
  reachable: false,
@@ -75,7 +78,7 @@ async function probeGateway(gatewayUrl, pat, tenantId, fetchImpl = fetch) {
75
78
 
76
79
  function markProbeFailures(report, probes) {
77
80
  probes.forEach((probe, index) => {
78
- if (probe.reachable && !probe.rejected) return;
81
+ if (probe.reachable && !probe.rejected && probe.healthy !== false) return;
79
82
  const tenant = report.tenants[index];
80
83
  tenant.cli = "failed";
81
84
  tenant.status = "failed";
@@ -84,7 +87,9 @@ function markProbeFailures(report, probes) {
84
87
  }
85
88
  tenant.errors.push(probe.rejected
86
89
  ? `credential rejected (HTTP ${probe.status})`
87
- : `gateway unreachable (${probe.error || "unknown error"})`);
90
+ : probe.reachable
91
+ ? `gateway readiness failed (HTTP ${probe.status})`
92
+ : `gateway unreachable (${probe.error || "unknown error"})`);
88
93
  });
89
94
  report.passed = report.tenants.every((tenant) => (
90
95
  ["ready", "unavailable"].includes(tenant.cli)
@@ -109,20 +114,6 @@ function mergeTenantReport(report, replacement) {
109
114
  return recomputeReport(report);
110
115
  }
111
116
 
112
- function describeWindowsCliFailure(prepared) {
113
- const labels = { claude: "Claude Code", codex: "Codex" };
114
- const details = [];
115
- for (const tool of prepared.missingAfter || []) {
116
- const installation = prepared.installations?.[tool];
117
- const failure = installation?.failure;
118
- const reason = failure?.message || failure?.code
119
- || (Number.isInteger(failure?.status) ? `exit ${failure.status}` : "not discoverable after installation");
120
- details.push(`${labels[tool] || tool}'s native installer failed: ${redactSecretText(reason)}`);
121
- if (installation?.command) details.push(`manual ${tool} installer: ${redactSecretText(installation.command)}`);
122
- }
123
- return details.join("; ") || `missing vendor CLIs: ${(prepared.missingAfter || []).join(", ")}`;
124
- }
125
-
126
117
  function confirmed(answer) {
127
118
  return /^(?:y|yes)$/iu.test(String(answer || "").trim());
128
119
  }
@@ -134,7 +125,7 @@ export async function cmdSetup(argv, overrides = {}) {
134
125
  promptSecret,
135
126
  promptText,
136
127
  fetchTenants,
137
- prepareWindowsClis,
128
+ preparePlatformClis,
138
129
  reconcile: reconcileAllTenants,
139
130
  probe: probeGateway,
140
131
  recoverInstall: runInstallRecovery,
@@ -144,6 +135,9 @@ export async function cmdSetup(argv, overrides = {}) {
144
135
  environment: process.env,
145
136
  ...overrides,
146
137
  };
138
+ if (overrides.prepareWindowsClis && !overrides.preparePlatformClis) {
139
+ io.preparePlatformClis = overrides.prepareWindowsClis;
140
+ }
147
141
  const { flags, positionals } = parseFlags(argv, {
148
142
  pat: { type: "string" },
149
143
  tenant: { type: "string" },
@@ -234,11 +228,11 @@ export async function cmdSetup(argv, overrides = {}) {
234
228
 
235
229
  let sharedFailure = null;
236
230
  const prepareSharedClis = async ({ confirmedTools = null, inspectOnly = false } = {}) => {
237
- if (io.platform !== "win32") return { binaries: {}, missingAfter: [] };
238
231
  try {
239
- const inspected = await io.prepareWindowsClis({
232
+ const inspected = await io.preparePlatformClis({
240
233
  gatewayUrl,
241
234
  tenantId: selected.id,
235
+ platform: io.platform,
242
236
  skipInstall: true,
243
237
  });
244
238
  let prepared = inspected;
@@ -248,6 +242,7 @@ export async function cmdSetup(argv, overrides = {}) {
248
242
  installTools = [];
249
243
  if (io.isTTY) {
250
244
  for (const tool of inspected.missingAfter) {
245
+ if (!inspected.installCommands?.[tool]) continue;
251
246
  const label = tool === "claude" ? "Claude Code" : "Codex";
252
247
  if (confirmed(await io.promptText(`Install the official ${label} CLI for this user? [y/N] `))) {
253
248
  installTools.push(tool);
@@ -256,16 +251,17 @@ export async function cmdSetup(argv, overrides = {}) {
256
251
  }
257
252
  }
258
253
  if (installTools.length) {
259
- prepared = await io.prepareWindowsClis({
254
+ prepared = await io.preparePlatformClis({
260
255
  gatewayUrl,
261
256
  tenantId: selected.id,
257
+ platform: io.platform,
262
258
  skipInstall: false,
263
259
  installTools,
264
260
  });
265
261
  }
266
262
  }
267
263
  if (prepared.missingAfter.length) {
268
- sharedFailure = describeWindowsCliFailure(prepared);
264
+ sharedFailure = describeCliFailure(prepared);
269
265
  return prepared;
270
266
  }
271
267
  sharedFailure = null;
@@ -344,10 +340,13 @@ export async function cmdSetup(argv, overrides = {}) {
344
340
  },
345
341
  },
346
342
  }, overrides.recoveryOverrides || {});
347
- if (recovery.fixed) await prepareSharedClis({ inspectOnly: true });
343
+ if (recovery.fixed) {
344
+ await prepareSharedClis({ inspectOnly: true });
345
+ report = await verifyConvergence();
346
+ }
348
347
  }
349
348
 
350
- if (!flags["no-recovery"]) {
349
+ if (!sharedFailure && !flags["no-recovery"]) {
351
350
  for (const failed of report.tenants.filter((tenant) => tenant.status === "failed")) {
352
351
  const tenant = orderedTenants.find((candidate) => candidate.id === failed.tenantId);
353
352
  if (!tenant) continue;
@@ -30,6 +30,7 @@ Usage:
30
30
  impel update Update the CLI and reconcile every accessible tenant
31
31
  impel update --check Report whether an update is available; change nothing
32
32
  impel update --skip-apps Reconcile only isolated CLI profiles
33
+ impel update --skip-clis Do not install missing vendor CLIs
33
34
  impel update --no-recovery Disable local and hosted recovery for this run
34
35
  `;
35
36
 
@@ -85,6 +86,7 @@ function defaultSelfUpdate(spec) {
85
86
  // NEW code performs them, not the process that started the update.
86
87
  export function defaultRunConvergence({
87
88
  skipApps = false,
89
+ skipClis = false,
88
90
  noRecovery = false,
89
91
  spawn = spawnSync,
90
92
  execPath = process.execPath,
@@ -92,6 +94,7 @@ export function defaultRunConvergence({
92
94
  } = {}) {
93
95
  const args = [cliBin, "_converge"];
94
96
  if (skipApps) args.push("--skip-apps");
97
+ if (skipClis) args.push("--skip-clis");
95
98
  if (noRecovery) args.push("--no-recovery");
96
99
  const result = spawn(execPath, args, {
97
100
  stdio: "inherit",
@@ -157,6 +160,7 @@ export async function cmdUpdate(argv, overrides = {}) {
157
160
  const { flags } = parseFlags(argv, {
158
161
  check: { type: "boolean" },
159
162
  "skip-apps": { type: "boolean" },
163
+ "skip-clis": { type: "boolean" },
160
164
  "refresh-cache": { type: "boolean" },
161
165
  repair: { type: "boolean" },
162
166
  "no-recovery": { type: "boolean" },
@@ -263,6 +267,7 @@ export async function cmdUpdate(argv, overrides = {}) {
263
267
  console.log("Tenants: discovering, installing, repairing, and upgrading every accessible tenant…");
264
268
  const convergenceArgs = {
265
269
  skipApps: Boolean(flags["skip-apps"]),
270
+ skipClis: Boolean(flags["skip-clis"]),
266
271
  noRecovery: Boolean(flags["no-recovery"]),
267
272
  };
268
273
  if (!await io.runConvergence(convergenceArgs)) {
@@ -174,6 +174,7 @@ function announceUpload(session, failure) {
174
174
  );
175
175
  session.io.log("Sanitized payload preview:");
176
176
  session.io.log(JSON.stringify(failure, null, 2));
177
+ session.io.log(`Install recovery ID: ${session.fingerprint.slice(0, 16)}`);
177
178
  }
178
179
 
179
180
  function terminalReturn(session, status, summary, extras = {}) {
@@ -280,6 +281,8 @@ export async function runInstallRecovery(options, overrides = {}) {
280
281
  let inference;
281
282
  try {
282
283
  const seat = await io.resolveSeat(options.config);
284
+ seat.recoveryId = session.fingerprint.slice(0, 16);
285
+ seat.cliVersion = CLI_VERSION;
283
286
  inference = io.createInference(seat);
284
287
  io.log(`Install recovery: assisted diagnosis via ${inference.model} (org ${inference.orgId}).`);
285
288
  } catch (error) {
@@ -293,7 +296,7 @@ export async function runInstallRecovery(options, overrides = {}) {
293
296
  const messages = [
294
297
  { role: "user", content: buildFirstUserMessage(session, failure, deterministicSummary, goalReport) },
295
298
  ];
296
- const tools = installRecoveryToolDefinitions();
299
+ const tools = installRecoveryToolDefinitions(session.actionContext);
297
300
  const deadline = io.now() + RECOVERY_LIMITS.maxWallClockMs;
298
301
  const callCounts = new Map();
299
302
  let declinedSystemCalls = 0;
@@ -107,6 +107,7 @@ export function createRecoveryInference(seat, fetchImpl = fetch) {
107
107
  const modelCandidates = [seat.model, ...FALLBACK_MODELS.filter((model) => model !== seat.model)];
108
108
 
109
109
  async function completeOnce(model, { system, messages, tools }) {
110
+ const requestId = `impel-recovery-${seat.recoveryId || "untracked"}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
110
111
  return requestJson(
111
112
  url,
112
113
  {
@@ -116,7 +117,8 @@ export function createRecoveryInference(seat, fetchImpl = fetch) {
116
117
  accept: "application/json",
117
118
  authorization: `Bearer ${seat.bearer}`,
118
119
  "anthropic-version": "2023-06-01",
119
- "x-request-id": `impel-recovery-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
120
+ "user-agent": `impel-cli-install-recovery/${seat.cliVersion || "unknown"} recovery/${seat.recoveryId || "untracked"}`,
121
+ "x-request-id": requestId,
120
122
  },
121
123
  body: JSON.stringify({
122
124
  model,
@@ -105,7 +105,7 @@ export const INSTALL_RECOVERY_TOOLS = Object.freeze({
105
105
  install_vendor_cli: {
106
106
  risk: "system",
107
107
  description:
108
- "Run the official vendor installer for one missing CLI (Windows only). Uses the vendor's checksum-verifying native installer; requires the user's confirmation.",
108
+ "Run the official vendor installer for one missing CLI on macOS or Windows. Uses a checksum-verifying native installer and requires the user's confirmation.",
109
109
  schema: {
110
110
  tool: { type: "string", enum: ["claude", "codex"], required: true },
111
111
  },
@@ -138,8 +138,26 @@ export const INSTALL_RECOVERY_TOOLS = Object.freeze({
138
138
  },
139
139
  });
140
140
 
141
- export function installRecoveryToolDefinitions() {
142
- return Object.entries(INSTALL_RECOVERY_TOOLS).map(([name, tool]) => ({
141
+ function recoveryToolAvailable(name, context) {
142
+ if (!context) return true;
143
+ switch (name) {
144
+ case "probe_gateway": return typeof context.probeGateway === "function";
145
+ case "check_auth_helper": return Boolean(context.tenantId) && context.platform !== "win32";
146
+ case "repair_impel_profile": return typeof context.repairProfiles === "function";
147
+ case "retry_step": return typeof context.retryStep === "function";
148
+ case "install_vendor_cli":
149
+ return ["darwin", "win32"].includes(context.platform)
150
+ && typeof context.installVendorClis === "function";
151
+ case "install_vendor_app": return typeof context.installVendorApp === "function";
152
+ case "repair_user_path": return context.platform === "win32";
153
+ default: return true;
154
+ }
155
+ }
156
+
157
+ export function installRecoveryToolDefinitions(context = null) {
158
+ return Object.entries(INSTALL_RECOVERY_TOOLS)
159
+ .filter(([name]) => recoveryToolAvailable(name, context))
160
+ .map(([name, tool]) => ({
143
161
  name,
144
162
  description: tool.description,
145
163
  input_schema: {
@@ -155,7 +173,7 @@ export function installRecoveryToolDefinitions() {
155
173
  .map(([key]) => key),
156
174
  additionalProperties: false,
157
175
  },
158
- }));
176
+ }));
159
177
  }
160
178
 
161
179
  /** Validate a model-proposed call. Returns normalized input or null. */
@@ -469,7 +487,7 @@ async function retryStep(input, context) {
469
487
  }
470
488
 
471
489
  async function installVendorCli(input, context) {
472
- if (context.platform !== "win32" || typeof context.installVendorClis !== "function") {
490
+ if (!["darwin", "win32"].includes(context.platform) || typeof context.installVendorClis !== "function") {
473
491
  return result("failed", "Automated vendor CLI installation is unavailable on this platform.");
474
492
  }
475
493
  const outcome = await context.installVendorClis(input.tool);
@@ -0,0 +1,260 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { spawnSync } from "node:child_process";
6
+
7
+ import { ensureImpelClaudeProfile, ensureImpelCodexProfile } from "./cliProfiles.js";
8
+ import { findNativeBinary } from "./nativeProcess.js";
9
+ import { syncSkillsSafe } from "./skills.js";
10
+
11
+ const MAX_INSTALLER_BYTES = 512 * 1024;
12
+ const INSTALLER_DOWNLOAD_TIMEOUT_MS = 30_000;
13
+ const INSTALLER_RUN_TIMEOUT_MS = 10 * 60 * 1_000;
14
+ const MAC_SIGNING_TEAMS = Object.freeze({ claude: "Q6L2SF6YDW", codex: "2DC432GLL2" });
15
+
16
+ /**
17
+ * The script bytes are pinned per impel-cli release. Both vendor scripts then
18
+ * verify the downloaded native binary against the vendor release checksum.
19
+ */
20
+ export const MAC_CLI_INSTALLERS = Object.freeze({
21
+ claude: Object.freeze({
22
+ url: "https://claude.ai/install.sh",
23
+ sha256: "b3f79015b54c751440a6488f07b1b64f9088742b9052bc1bd356d13108320d2a",
24
+ command: "curl -fsSL https://claude.ai/install.sh | bash -s stable",
25
+ args: Object.freeze(["stable"]),
26
+ environment: Object.freeze({}),
27
+ }),
28
+ codex: Object.freeze({
29
+ url: "https://chatgpt.com/codex/install.sh",
30
+ sha256: "1154e9daf713aacd1534efca8042bfd6665ad24bc1d1dfd86b8f439fe60a7a5d",
31
+ command: "curl -fsSL https://chatgpt.com/codex/install.sh | CODEX_NON_INTERACTIVE=1 CODEX_RELEASE=0.144.6 sh",
32
+ args: Object.freeze([]),
33
+ environment: Object.freeze({ CODEX_NON_INTERACTIVE: "1", CODEX_RELEASE: "0.144.6" }),
34
+ }),
35
+ });
36
+
37
+ function verifyMacCli(tool, binary, environment, run = spawnSync) {
38
+ try {
39
+ const result = run(binary, ["--version"], {
40
+ encoding: "utf8",
41
+ env: environment,
42
+ stdio: ["ignore", "pipe", "pipe"],
43
+ timeout: 15_000,
44
+ });
45
+ if (result?.status !== 0 || result?.error) return false;
46
+ const explicitOverride = tool === "claude" ? environment.IMPEL_CLAUDE_BIN : environment.IMPEL_CODEX_BIN;
47
+ if (explicitOverride && path.resolve(explicitOverride) === path.resolve(binary)) return true;
48
+ const resolved = fs.realpathSync(binary);
49
+ const verified = run("/usr/bin/codesign", ["--verify", "--strict", resolved], {
50
+ encoding: "utf8",
51
+ stdio: ["ignore", "pipe", "pipe"],
52
+ timeout: 15_000,
53
+ });
54
+ if (verified?.status !== 0 || verified?.error) return false;
55
+ const details = run("/usr/bin/codesign", ["-dv", "--verbose=4", resolved], {
56
+ encoding: "utf8",
57
+ stdio: ["ignore", "pipe", "pipe"],
58
+ timeout: 15_000,
59
+ });
60
+ return details?.status === 0
61
+ && String(details.stderr || details.stdout || "").includes(`TeamIdentifier=${MAC_SIGNING_TEAMS[tool]}`);
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
66
+
67
+ function detectMacClis(find, environment, verify) {
68
+ const detect = (tool) => find(
69
+ tool,
70
+ environment,
71
+ "darwin",
72
+ "IMPEL",
73
+ (binary) => verify(tool, binary, environment),
74
+ );
75
+ return { claude: detect("claude"), codex: detect("codex") };
76
+ }
77
+
78
+ async function downloadPinnedInstaller(tool, { fetchImpl, fsImpl, installers }) {
79
+ const installer = installers[tool];
80
+ if (!installer) throw new Error(`unsupported macOS CLI installer: ${tool}`);
81
+ const controller = new AbortController();
82
+ const timeout = setTimeout(() => controller.abort(), INSTALLER_DOWNLOAD_TIMEOUT_MS);
83
+ let response;
84
+ try {
85
+ response = await fetchImpl(installer.url, { redirect: "follow", signal: controller.signal });
86
+ } finally {
87
+ clearTimeout(timeout);
88
+ }
89
+ if (!response.ok) throw new Error(`official ${tool} installer returned HTTP ${response.status}`);
90
+ const finalUrl = new URL(response.url || installer.url);
91
+ if (finalUrl.protocol !== "https:" || !["claude.ai", "chatgpt.com"].includes(finalUrl.hostname)) {
92
+ throw new Error(`official ${tool} installer redirected to an untrusted origin`);
93
+ }
94
+ const bytes = Buffer.from(await response.arrayBuffer());
95
+ if (bytes.length === 0 || bytes.length > MAX_INSTALLER_BYTES) {
96
+ throw new Error(`official ${tool} installer had an invalid size`);
97
+ }
98
+ const digest = crypto.createHash("sha256").update(bytes).digest("hex");
99
+ if (digest !== installer.sha256) {
100
+ throw new Error(`official ${tool} installer checksum did not match this impel-cli release`);
101
+ }
102
+ const source = bytes.toString("utf8");
103
+ if (!source.startsWith("#!")) throw new Error(`official ${tool} installer was not a shell script`);
104
+ const directory = fsImpl.mkdtempSync(path.join(os.tmpdir(), `impel-${tool}-installer-`));
105
+ const script = path.join(directory, "install.sh");
106
+ fsImpl.writeFileSync(script, bytes, { mode: 0o700 });
107
+ return { directory, script };
108
+ }
109
+
110
+ async function installMacCli(tool, io) {
111
+ const installer = io.installers[tool];
112
+ let downloaded = null;
113
+ let downloadError = null;
114
+ for (let attempt = 0; attempt < 2 && !downloaded; attempt += 1) {
115
+ try {
116
+ downloaded = await downloadPinnedInstaller(tool, io);
117
+ } catch (error) {
118
+ downloadError = error;
119
+ }
120
+ }
121
+ if (!downloaded) throw downloadError || new Error(`official ${tool} installer download failed`);
122
+ try {
123
+ const environment = {
124
+ ...vendorInstallerEnvironment(io.environment),
125
+ ...(installer.environment || {}),
126
+ };
127
+ return io.run("/bin/bash", [downloaded.script, ...(installer.args || [])], {
128
+ env: environment,
129
+ stdio: "inherit",
130
+ timeout: INSTALLER_RUN_TIMEOUT_MS,
131
+ });
132
+ } finally {
133
+ io.fsImpl.rmSync(downloaded.directory, { recursive: true, force: true });
134
+ }
135
+ }
136
+
137
+ function vendorInstallerEnvironment(environment) {
138
+ const allowed = new Set([
139
+ "HOME", "USER", "LOGNAME", "SHELL", "PATH", "TMPDIR", "LANG", "TERM",
140
+ "SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS",
141
+ "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy",
142
+ ]);
143
+ return Object.fromEntries(Object.entries(environment).filter(([name]) => (
144
+ allowed.has(name) || name.startsWith("LC_")
145
+ )));
146
+ }
147
+
148
+ function assertUserOwnedMacInstall(environment, fsImpl, geteuid) {
149
+ if (environment.SUDO_USER || geteuid() === 0) {
150
+ throw new Error("refusing to install vendor CLIs under sudo or as root on macOS");
151
+ }
152
+ const home = environment.HOME || os.homedir();
153
+ if (fsImpl.existsSync(home)) {
154
+ const owner = fsImpl.statSync(home).uid;
155
+ if (Number.isInteger(owner) && owner !== geteuid()) {
156
+ throw new Error("the macOS home directory is not owned by the current user");
157
+ }
158
+ }
159
+ }
160
+
161
+ function installationFailure(result, thrown = null) {
162
+ const error = thrown || result?.error;
163
+ return error
164
+ ? { code: error.code || null, message: error.message || String(error), status: null, signal: null }
165
+ : {
166
+ code: null,
167
+ message: null,
168
+ status: Number.isInteger(result?.status) ? result.status : null,
169
+ signal: result?.signal || null,
170
+ };
171
+ }
172
+
173
+ export async function prepareMacClis({
174
+ gatewayUrl,
175
+ tenantId,
176
+ skipInstall = false,
177
+ installTools = ["claude", "codex"],
178
+ } = {}, dependencies = {}) {
179
+ const io = {
180
+ environment: process.env,
181
+ find: findNativeBinary,
182
+ verify: verifyMacCli,
183
+ run: spawnSync,
184
+ fetchImpl: fetch,
185
+ fsImpl: fs,
186
+ geteuid: () => typeof process.geteuid === "function" ? process.geteuid() : null,
187
+ installers: MAC_CLI_INSTALLERS,
188
+ ensureClaudeProfile: ensureImpelClaudeProfile,
189
+ ensureCodexProfile: ensureImpelCodexProfile,
190
+ syncSkills: syncSkillsSafe,
191
+ ...dependencies,
192
+ };
193
+ const before = detectMacClis(io.find, io.environment, io.verify);
194
+ const missingBefore = Object.entries(before).filter(([, binary]) => !binary).map(([tool]) => tool);
195
+ const requested = new Set(installTools);
196
+ if (requested.size !== installTools.length || [...requested].some((tool) => !io.installers[tool])) {
197
+ throw new Error("installTools must contain unique supported macOS CLI names");
198
+ }
199
+
200
+ const installations = {};
201
+ if (missingBefore.length && !skipInstall) {
202
+ assertUserOwnedMacInstall(io.environment, io.fsImpl, io.geteuid);
203
+ for (const tool of missingBefore.filter((candidate) => requested.has(candidate))) {
204
+ try {
205
+ const result = await installMacCli(tool, io);
206
+ const succeeded = result?.status === 0 && !result?.error;
207
+ installations[tool] = {
208
+ attempted: true,
209
+ succeeded,
210
+ failure: succeeded ? null : installationFailure(result),
211
+ command: io.installers[tool].command,
212
+ };
213
+ } catch (error) {
214
+ installations[tool] = {
215
+ attempted: true,
216
+ succeeded: false,
217
+ failure: installationFailure(null, error),
218
+ command: io.installers[tool].command,
219
+ };
220
+ }
221
+ }
222
+ }
223
+
224
+ const installAttempted = Object.keys(installations).length > 0;
225
+ const binaries = installAttempted
226
+ ? detectMacClis(io.find, io.environment, io.verify)
227
+ : before;
228
+ const claudeProfile = io.ensureClaudeProfile(gatewayUrl, tenantId);
229
+ const codexProfile = io.ensureCodexProfile(gatewayUrl, tenantId);
230
+ if (binaries.claude) {
231
+ await io.syncSkills({
232
+ client: "claude",
233
+ gatewayUrl,
234
+ env: { CLAUDE_CONFIG_DIR: claudeProfile.configDir },
235
+ label: "Impel isolated Claude (impel claude)",
236
+ });
237
+ }
238
+ if (binaries.codex) {
239
+ await io.syncSkills({
240
+ client: "codex",
241
+ gatewayUrl,
242
+ env: { CODEX_HOME: codexProfile.codexHome },
243
+ label: "Impel isolated Codex (impel codex)",
244
+ });
245
+ }
246
+
247
+ return {
248
+ binaries,
249
+ missingBefore,
250
+ missingAfter: Object.entries(binaries).filter(([, binary]) => !binary).map(([tool]) => tool),
251
+ installAttempted,
252
+ installSucceeded: installAttempted
253
+ ? Object.values(installations).every((installation) => installation.succeeded)
254
+ : null,
255
+ installFailure: Object.values(installations).find((installation) => installation.failure)?.failure || null,
256
+ installations,
257
+ installCommands: Object.fromEntries(missingBefore.map((tool) => [tool, io.installers[tool].command])),
258
+ profiles: { claude: claudeProfile.configDir, codex: codexProfile.codexHome },
259
+ };
260
+ }
@@ -75,12 +75,12 @@ function commonCandidates(tool, environment, platform) {
75
75
  const home = environmentValue(environment, "USERPROFILE") || environmentValue(environment, "HOME") || os.homedir();
76
76
  const locations = [];
77
77
 
78
- if (tool === "claude") {
78
+ if (tool === "claude" || tool === "codex") {
79
79
  locations.push(
80
- [paths.join(home, ".local", "bin"), "claude"],
81
- [paths.join(home, ".claude", "local"), "claude"],
80
+ [paths.join(home, ".local", "bin"), tool],
82
81
  );
83
82
  }
83
+ if (tool === "claude") locations.push([paths.join(home, ".claude", "local"), "claude"]);
84
84
 
85
85
  if (platform === "win32") {
86
86
  const appData = environmentValue(environment, "APPDATA");
@@ -0,0 +1,49 @@
1
+ import { ensureImpelClaudeProfile, ensureImpelCodexProfile } from "./cliProfiles.js";
2
+ import { redactSecretText } from "./config.js";
3
+ import { findNativeBinary } from "./nativeProcess.js";
4
+ import { prepareMacClis } from "./macSetup.js";
5
+ import { prepareWindowsClis } from "./windowsSetup.js";
6
+
7
+ export async function preparePlatformClis(options = {}, dependencies = {}) {
8
+ const platform = options.platform || process.platform;
9
+ if (platform === "darwin") return prepareMacClis(options, dependencies);
10
+ if (platform === "win32") return prepareWindowsClis(options, dependencies);
11
+
12
+ const environment = dependencies.environment || process.env;
13
+ const find = dependencies.find || findNativeBinary;
14
+ const binaries = {
15
+ claude: find("claude", environment, platform),
16
+ codex: find("codex", environment, platform),
17
+ };
18
+ const claudeProfile = (dependencies.ensureClaudeProfile || ensureImpelClaudeProfile)(options.gatewayUrl, options.tenantId);
19
+ const codexProfile = (dependencies.ensureCodexProfile || ensureImpelCodexProfile)(options.gatewayUrl, options.tenantId);
20
+ const missingAfter = Object.entries(binaries).filter(([, binary]) => !binary).map(([tool]) => tool);
21
+ return {
22
+ binaries,
23
+ missingBefore: [...missingAfter],
24
+ missingAfter,
25
+ installAttempted: false,
26
+ installSucceeded: null,
27
+ installFailure: null,
28
+ installations: {},
29
+ installCommands: {},
30
+ profiles: { claude: claudeProfile.configDir, codex: codexProfile.codexHome },
31
+ };
32
+ }
33
+
34
+ export function describeCliFailure(prepared) {
35
+ const labels = { claude: "Claude Code", codex: "Codex" };
36
+ const details = [];
37
+ for (const tool of prepared.missingAfter || []) {
38
+ const installation = prepared.installations?.[tool];
39
+ const failure = installation?.failure;
40
+ const reason = failure?.message || failure?.code
41
+ || (Number.isInteger(failure?.status)
42
+ ? `exit ${failure.status}`
43
+ : installation ? "not discoverable after installation" : "not installed or discoverable");
44
+ details.push(`${labels[tool] || tool}: ${redactSecretText(reason)}`);
45
+ const command = installation?.command || prepared.installCommands?.[tool];
46
+ if (command) details.push(`manual ${tool} installer: ${redactSecretText(command)}`);
47
+ }
48
+ return details.join("; ") || `missing vendor CLIs: ${(prepared.missingAfter || []).join(", ")}`;
49
+ }
@@ -0,0 +1,32 @@
1
+ function effectiveUserId() {
2
+ return typeof process.geteuid === "function" ? process.geteuid() : null;
3
+ }
4
+
5
+ export function commandMutatesUserState(argv = []) {
6
+ const [command, subcommand] = argv;
7
+ if (["setup", "update", "upgrade", "_converge"].includes(command)) return true;
8
+ if (command === "app" || command === "apps") {
9
+ return subcommand !== "status";
10
+ }
11
+ return false;
12
+ }
13
+
14
+ export function elevatedMacExecution({
15
+ argv = [],
16
+ platform = process.platform,
17
+ geteuid = effectiveUserId,
18
+ } = {}) {
19
+ return platform === "darwin"
20
+ && commandMutatesUserState(argv)
21
+ && geteuid() === 0;
22
+ }
23
+
24
+ export function refuseElevatedMacExecution(argv = [], dependencies = {}) {
25
+ if (!elevatedMacExecution({ argv, ...dependencies })) return false;
26
+ console.error(
27
+ "impel: do not run setup, update, or app management with sudo on macOS. "
28
+ + "Run the command as the signed-in user so Impel state and Keychain entries have the correct owner."
29
+ );
30
+ process.exitCode = 1;
31
+ return true;
32
+ }
@@ -110,19 +110,21 @@ async function prepareTenantCli(config, tenant, io, binaries) {
110
110
  try {
111
111
  const profile = definition.ensure();
112
112
  const root = definition.root(profile);
113
+ if (!binaries[client]) {
114
+ clients[client] = {
115
+ status: "failed",
116
+ root,
117
+ error: `${client === "claude" ? "Claude Code" : "Codex"} CLI is not installed or discoverable`,
118
+ };
119
+ continue;
120
+ }
113
121
  await io.syncSkills({
114
122
  client,
115
123
  gatewayUrl,
116
124
  env: definition.env(profile),
117
125
  label: `Impel ${client === "claude" ? "Claude" : "Codex"} CLI (${tenant.id})`,
118
126
  });
119
- clients[client] = binaries[client]
120
- ? { status: "ready", root, error: null }
121
- : {
122
- status: "failed",
123
- root,
124
- error: `${client === "claude" ? "Claude Code" : "Codex"} CLI is not installed or discoverable`,
125
- };
127
+ clients[client] = { status: "ready", root, error: null };
126
128
  } catch (error) {
127
129
  clients[client] = {
128
130
  status: "failed",
@@ -133,7 +135,7 @@ async function prepareTenantCli(config, tenant, io, binaries) {
133
135
  }
134
136
 
135
137
  const agentProfiles = Object.entries(clients)
136
- .filter(([, client]) => client.root)
138
+ .filter(([, client]) => client.root && client.status === "ready")
137
139
  .map(([client, value]) => ({
138
140
  client,
139
141
  root: value.root,