impel-cli 0.17.14 → 0.17.16

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.17.14",
3
+ "version": "0.17.16",
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
@@ -1355,6 +1355,29 @@ function mergeManagedChatGPTToml(current, managed) {
1355
1355
  return `${managed}\n${remainder ? `\n${remainder.replace(/\s*$/u, "")}\n` : ""}`;
1356
1356
  }
1357
1357
 
1358
+ /**
1359
+ * True when a managed ChatGPT profile exists but its config no longer routes
1360
+ * inference through the Impel gateway. The Codex desktop app rewrites
1361
+ * config.toml with its own settings writer (model picker, project trust,
1362
+ * migrations) and can drop the managed top-level `model_provider` key; without
1363
+ * it the built-in ChatGPT provider derives its inference endpoint from
1364
+ * chatgpt.com and sends the Impel token there, which chatgpt.com rejects with
1365
+ * 403 "Unknown personal access token". Stale-only refreshes treat this drift
1366
+ * as staleness so the profile heals immediately instead of waiting out the
1367
+ * manifest TTL.
1368
+ */
1369
+ export function managedChatGPTConfigDrifted(paths) {
1370
+ let current;
1371
+ try {
1372
+ current = fs.readFileSync(path.join(paths.chatgpt.codexHome, "config.toml"), "utf8");
1373
+ } catch {
1374
+ return false; // No managed profile; install/update owns creating one.
1375
+ }
1376
+ return !current.includes(CHATGPT_CONFIG_START)
1377
+ || readTopLevelTomlString(current, "model_provider") !== "impel"
1378
+ || !readTopLevelTomlString(current, "chatgpt_base_url");
1379
+ }
1380
+
1358
1381
  function readTopLevelTomlString(toml, key) {
1359
1382
  const topLevel = toml.split(/^\s*\[/mu, 1)[0];
1360
1383
  const match = topLevel.match(new RegExp(`^\\s*${escapeRegex(key)}\\s*=\\s*("(?:[^"\\\\]|\\\\.)*")\\s*$`, "mu"));
@@ -32,6 +32,7 @@ import {
32
32
  ensureVendorApp,
33
33
  fetchGatewayModels,
34
34
  installManagedAppFiles,
35
+ managedChatGPTConfigDrifted,
35
36
  managedLauncherName,
36
37
  normalizeAppTarget,
37
38
  quitBlockingApps,
@@ -260,9 +261,23 @@ async function maybeUpdateAllInstalledTenants(argv, overrides, platform) {
260
261
  return true;
261
262
  }
262
263
 
264
+ // winget exits with HRESULTs; the raw decimal (e.g. 2316632066) hides the
265
+ // recognizable 0x8A15xxxx APPINSTALLER facility, so render hex alongside it.
266
+ const WINGET_EXIT_HINTS = new Map([
267
+ [0x8A150002, "winget rejected the command-line arguments"],
268
+ [0x8A150014, "no package matched the winget query"],
269
+ [0x8A15001B, "the Microsoft Store client is blocked by policy"],
270
+ [0x8A15001C, "the Microsoft Store app is blocked by policy"],
271
+ ]);
272
+
263
273
  function windowsProcessFailure(result) {
264
274
  if (result?.error?.message) return redactSecretText(result.error.message);
265
- if (Number.isInteger(result?.status)) return `exit code ${result.status}`;
275
+ if (Number.isInteger(result?.status)) {
276
+ const status = result.status >>> 0;
277
+ if (status < 0x80000000) return `exit code ${result.status}`;
278
+ const hint = WINGET_EXIT_HINTS.get(status);
279
+ return `exit code ${result.status} (0x${status.toString(16).toUpperCase()}${hint ? ` — ${hint}` : ""})`;
280
+ }
266
281
  if (result?.signal) return `signal ${result.signal}`;
267
282
  return "no exit status";
268
283
  }
@@ -367,45 +382,62 @@ export async function reconcileWindowsTenantApps({
367
382
  const vendorPaths = {};
368
383
  const preparedTargets = new Set(skipVendor ? actionTargets : skipVendorTargets);
369
384
 
385
+ // A target that cannot proceed (declined confirmation, failed installer,
386
+ // missing vendor app) must not abort the other target's work: install
387
+ // recovery approves one vendor app at a time, and that approved install
388
+ // has to run even while its sibling stays missing.
389
+ const failed = [];
390
+ const fail = (target, error) => failed.push({ target, error });
391
+ const hasFailed = (target) => failed.some((failure) => failure.target === target);
392
+
370
393
  for (const target of actionTargets) {
371
394
  const isClaude = target === "claude";
395
+ const label = isClaude ? "Claude" : "ChatGPT/Codex";
372
396
  const find = isClaude ? io.findClaudeApp : io.findChatGPTApp;
373
397
  const ensure = isClaude ? io.ensureClaudeApp : io.ensureChatGPTApp;
374
398
  let binary = find(environment);
375
399
  if (!preparedTargets.has(target)) {
376
400
  const confirmed = await confirmVendorInstall(target, { platform: "win32", mode });
377
401
  if (!confirmed) {
378
- throw new Error(`${isClaude ? "Claude" : "ChatGPT/Codex"} vendor ${mode === "update" ? "update" : "installation"} requires confirmation`);
402
+ fail(target, `${label} vendor ${mode === "update" ? "update" : "installation"} requires confirmation`);
403
+ continue;
379
404
  }
380
405
  const vendor = ensure({ update: mode === "update" }, { environment });
381
406
  binary = vendor.binary;
382
407
  if (!binary) {
383
- const label = isClaude ? "Claude" : "ChatGPT/Codex";
384
- throw new Error(`${label} vendor installation failed (${windowsProcessFailure(vendor.result)})`);
408
+ fail(target, `${label} vendor installation failed (${windowsProcessFailure(vendor.result)})`);
409
+ continue;
385
410
  }
386
411
  }
387
412
  if (binary) vendorPaths[target] = binary;
388
413
  }
389
- if (actionTargets.includes("claude") && !vendorPaths.claude) {
390
- throw new Error("Claude vendor app is unavailable");
414
+ if (actionTargets.includes("claude") && !hasFailed("claude") && !vendorPaths.claude) {
415
+ fail("claude", "Claude vendor app is unavailable");
391
416
  }
392
- if (actionTargets.includes("chatgpt")) {
393
- let managed = io.findManagedChatGPTApp(paths.root);
394
- const stale = vendorPaths.chatgpt && !io.chatGPTStageIsCurrent(vendorPaths.chatgpt, paths.root);
395
- if (!managed || stale) {
396
- if (!vendorPaths.chatgpt) throw new Error("ChatGPT/Codex vendor app is unavailable");
397
- managed = io.stageChatGPTApp(vendorPaths.chatgpt, paths.root);
417
+ if (actionTargets.includes("chatgpt") && !hasFailed("chatgpt")) {
418
+ try {
419
+ let managed = io.findManagedChatGPTApp(paths.root);
420
+ const stale = vendorPaths.chatgpt && !io.chatGPTStageIsCurrent(vendorPaths.chatgpt, paths.root);
421
+ if (!managed || stale) {
422
+ if (!vendorPaths.chatgpt) throw new Error("ChatGPT/Codex vendor app is unavailable");
423
+ managed = io.stageChatGPTApp(vendorPaths.chatgpt, paths.root);
424
+ }
425
+ if (!managed) throw new Error("the managed ChatGPT/Codex app could not be staged");
426
+ } catch (error) {
427
+ fail("chatgpt", redactSecretText(error?.message || error));
398
428
  }
399
- if (!managed) throw new Error("the managed ChatGPT/Codex app could not be staged");
400
429
  }
430
+ const completedTargets = actionTargets.filter((target) => !hasFailed(target));
401
431
 
402
- configureWindowsApps(config, actionTargets, vendorPaths, catalog, {
403
- ...io,
404
- homeDir,
405
- environment,
406
- });
432
+ if (completedTargets.length) {
433
+ configureWindowsApps(config, completedTargets, vendorPaths, catalog, {
434
+ ...io,
435
+ homeDir,
436
+ environment,
437
+ });
438
+ }
407
439
  const agentTargets = [];
408
- for (const target of actionTargets) {
440
+ for (const target of completedTargets) {
409
441
  const { client, env, label } = appSkillTarget(target, paths, {
410
442
  platform: "win32",
411
443
  existsSync: io.existsSync,
@@ -430,11 +462,11 @@ export async function reconcileWindowsTenantApps({
430
462
  });
431
463
  }
432
464
  const verification = {
433
- claude: !actionTargets.includes("claude") || io.existsSync(path.join(paths.claude.userData, "configLibrary", `${CLAUDE_CONFIG_ID}.json`)),
434
- chatgpt: !actionTargets.includes("chatgpt") || io.existsSync(path.join(paths.chatgpt.codexHome, "config.toml")),
465
+ claude: !completedTargets.includes("claude") || io.existsSync(path.join(paths.claude.userData, "configLibrary", `${CLAUDE_CONFIG_ID}.json`)),
466
+ chatgpt: !completedTargets.includes("chatgpt") || io.existsSync(path.join(paths.chatgpt.codexHome, "config.toml")),
435
467
  };
436
468
  if (!Object.values(verification).every(Boolean)) throw new Error("Windows app profile verification failed");
437
- return { tenantId: config.tenantId, targets: actionTargets, unsupported, paths, verification };
469
+ return { tenantId: config.tenantId, targets: completedTargets, unsupported, paths, verification, failed };
438
470
  }
439
471
 
440
472
  /** Reconcile one explicit tenant's macOS app bundles and profiles. */
@@ -479,17 +511,27 @@ export async function reconcileMacTenantApps({
479
511
  const statuses = io.status(actionTargets, homeDir, config.tenantId, config.tenantName);
480
512
  for (const status of statuses) vendorPaths[status.target] ||= status.vendorPath;
481
513
  const preparedTargets = new Set(skipVendor ? actionTargets : skipVendorTargets);
482
- {
483
- for (const target of actionTargets.filter((target) => !preparedTargets.has(target))) {
484
- if (vendorPaths[target]) continue;
485
- const confirmed = await confirmVendorInstall(target, { platform: "darwin", mode });
486
- if (!confirmed) throw new Error(`${target} vendor installation requires confirmation`);
487
- const result = await io.ensureVendor(target, { homeDir });
488
- vendorPaths[target] = result.path;
489
- if (!result.path) throw new Error(`${target} verified vendor app is unavailable; retry the pinned download`);
514
+ // As on Windows, a declined or failed vendor install skips only that
515
+ // target so the sibling app still converges (install recovery approves one
516
+ // vendor app at a time).
517
+ const failed = [];
518
+ const fail = (target, error) => failed.push({ target, error });
519
+ const hasFailed = (target) => failed.some((failure) => failure.target === target);
520
+ for (const target of actionTargets.filter((target) => !preparedTargets.has(target))) {
521
+ if (vendorPaths[target]) continue;
522
+ const confirmed = await confirmVendorInstall(target, { platform: "darwin", mode });
523
+ if (!confirmed) {
524
+ fail(target, `${target} vendor installation requires confirmation`);
525
+ continue;
490
526
  }
527
+ const result = await io.ensureVendor(target, { homeDir });
528
+ vendorPaths[target] = result.path;
529
+ if (!result.path) fail(target, `${target} verified vendor app is unavailable; retry the pinned download`);
491
530
  }
492
- const staleTargets = statuses.filter((status) => !io.bundleCurrent(status)).map((status) => status.target);
531
+ const completedTargets = actionTargets.filter((target) => !hasFailed(target));
532
+ const staleTargets = statuses
533
+ .filter((status) => completedTargets.includes(status.target) && !io.bundleCurrent(status))
534
+ .map((status) => status.target);
493
535
  let closedApps = [];
494
536
  if (staleTargets.length) {
495
537
  const result = await io.quitApps(staleTargets, { tenantId: config.tenantId, tenantName: config.tenantName });
@@ -498,14 +540,14 @@ export async function reconcileMacTenantApps({
498
540
  const reopenTargets = new Set(staleTargets.filter((target) => closedApps.includes(
499
541
  managedLauncherName(target, { tenantId: config.tenantId, tenantName: config.tenantName }),
500
542
  )));
501
- const installed = await io.installFiles({
543
+ const installed = completedTargets.length ? await io.installFiles({
502
544
  config,
503
- targets: actionTargets,
545
+ targets: completedTargets,
504
546
  models: catalog.models,
505
547
  homeDir,
506
548
  vendorPaths,
507
549
  writeBundles: staleTargets,
508
- });
550
+ }) : [];
509
551
  const paths = appPaths(homeDir, config.tenantId, { tenantName: config.tenantName });
510
552
  const agentItems = [];
511
553
  for (const item of installed) {
@@ -527,7 +569,7 @@ export async function reconcileMacTenantApps({
527
569
  for (const item of installed) {
528
570
  if (reopenTargets.has(item.target)) io.openLauncher(item.launcher);
529
571
  }
530
- const verification = Object.fromEntries(actionTargets.map((target) => [
572
+ const verification = Object.fromEntries(completedTargets.map((target) => [
531
573
  target,
532
574
  io.existsSync(paths[target].launcher) && io.existsSync(
533
575
  target === "claude"
@@ -536,7 +578,7 @@ export async function reconcileMacTenantApps({
536
578
  ),
537
579
  ]));
538
580
  if (!Object.values(verification).every(Boolean)) throw new Error("macOS app bundle/profile verification failed");
539
- return { tenantId: config.tenantId, targets: actionTargets, unsupported, paths, verification, installed };
581
+ return { tenantId: config.tenantId, targets: completedTargets, unsupported, paths, verification, installed, failed };
540
582
  }
541
583
 
542
584
  export function reconcileTenantApps(options = {}, overrides = {}) {
@@ -662,7 +704,7 @@ export async function cmdWindowsApps(argv, overrides = {}) {
662
704
  claudeUserData: io.claudeUserData(io.environment, staleTenantId),
663
705
  tenantName: staleTenantId === stored.tenantId ? stored.tenantName : null,
664
706
  });
665
- if (manifestIsFresh(stalePaths, stored)) return true;
707
+ if (manifestIsFresh(stalePaths, stored) && !managedChatGPTConfigDrifted(stalePaths)) return true;
666
708
  }
667
709
  }
668
710
  const config = await io.selectedConfig(targets, flags.tenant || null);
@@ -1241,7 +1283,7 @@ async function refreshApps(targets, { staleOnly = false, tenantId = null } = {},
1241
1283
  ? stored.tenantName || manifest?.tenantName
1242
1284
  : manifest?.tenantName,
1243
1285
  });
1244
- if (staleOnly && manifestIsFresh(paths, stored)) return;
1286
+ if (staleOnly && manifestIsFresh(paths, stored) && !managedChatGPTConfigDrifted(paths)) return;
1245
1287
 
1246
1288
  let config;
1247
1289
  try {
@@ -2,7 +2,12 @@
2
2
 
3
3
  import { loadConfig, saveConfig, redactSecretText } from "../config.js";
4
4
  import { runInstallRecovery } from "../installRecovery/engine.js";
5
- import { printReconciliationSummary, reconcileAllTenants, selectDefaultTenant } from "../provisioning.js";
5
+ import {
6
+ printReconciliationSummary,
7
+ reconcileAllTenants,
8
+ selectDefaultTenant,
9
+ vendorAppTargetReady,
10
+ } from "../provisioning.js";
6
11
  import { fetchTenants } from "../tenants.js";
7
12
  import { promptText } from "../prompt.js";
8
13
  import { describeCliFailure, preparePlatformClis } from "../platformSetup.js";
@@ -258,11 +263,17 @@ export async function cmdConverge(argv = [], overrides = {}) {
258
263
  return ["ready", "unavailable"].includes(profileReport.tenants[0]?.cli);
259
264
  },
260
265
  retryStep: () => retrySafeState(),
261
- installVendorApp: (target) => retryTenant(async (product) => (
262
- target === "all"
263
- || product === target
264
- || (target === "codex" && product === "chatgpt")
265
- )),
266
+ // Succeed when the requested vendor app converged, even if the
267
+ // sibling app still fails: the goal check keeps demanding full
268
+ // tenant readiness, so recovery can approve one app at a time.
269
+ installVendorApp: async (target) => {
270
+ await retryTenant(async (product) => (
271
+ target === "all"
272
+ || product === target
273
+ || (target === "codex" && product === "chatgpt")
274
+ ));
275
+ return vendorAppTargetReady(retryReport, target);
276
+ },
266
277
  },
267
278
  }, overrides.recoveryOverrides || {});
268
279
  if (retryReport) mergeTenantReport(report, retryReport);
@@ -31,7 +31,12 @@ const CLAUDE_DIRECT_AUTH_ENV = [
31
31
  ];
32
32
 
33
33
  const CODEX_DIRECT_AUTH_ENV = ["CODEX_ACCESS_TOKEN", "CODEX_API_KEY", "OPENAI_API_KEY", "OPENAI_BASE_URL"];
34
- export { escapeWindowsBatchArgument, nativeSpawnInvocation, resolveNativeBinary } from "../nativeProcess.js";
34
+ export {
35
+ escapeWindowsBareArgument,
36
+ escapeWindowsBatchArgument,
37
+ nativeSpawnInvocation,
38
+ resolveNativeBinary,
39
+ } from "../nativeProcess.js";
35
40
 
36
41
  export const IMPEL_SPECIALIST_DELEGATION_INSTRUCTIONS = `You are running in an Impel tenant-scoped session. Before starting any non-trivial task, call the Impel MCP tool list_specialists. If exactly one available specialist clearly matches the user's request, its capabilities and its exclusions, delegate the complete request by calling start_specialist_run exactly once with a stable idempotency key, then call read_specialist_run until it reaches a terminal state. When the run succeeds, use the specialist's result as your response instead of redoing the work. If no specialist clearly matches, the tools are unavailable, or the run fails, continue normally yourself. Do not delegate trivial requests, do not call a specialist excluded from the request, and never invent a specialist result.`;
37
42
 
@@ -1,6 +1,8 @@
1
1
  import { spawn } from "node:child_process";
2
+ import path from "node:path";
2
3
 
3
4
  import { parseFlags } from "../args.js";
5
+ import { appPaths, managedChatGPTConfigDrifted } from "../apps.js";
4
6
  import {
5
7
  clearSessionFlushLock,
6
8
  collectSessionHook,
@@ -13,6 +15,7 @@ import {
13
15
  } from "../sessionCollector.js";
14
16
  import { loadConfig, redactSecretText } from "../config.js";
15
17
  import { impelCliInvocation } from "../selfInvocation.js";
18
+ import { spawnDetachedAppRefresh } from "../updates.js";
16
19
 
17
20
  const SPEC = {
18
21
  provider: { type: "string" },
@@ -22,6 +25,31 @@ const SPEC = {
22
25
  "impel-managed-session-hook-v1": { type: "boolean" },
23
26
  };
24
27
 
28
+ /**
29
+ * Repair a managed ChatGPT profile the desktop app just rewrote out from under
30
+ * the gateway. Hooks are the only Impel code guaranteed to run while the app
31
+ * is broken (the app stops calling the provider token helper once the managed
32
+ * `model_provider` key is gone), so each codex hook event checks for drift and
33
+ * kicks one detached stale-only refresh — which the drift-aware staleness gate
34
+ * turns into a real repair. The heartbeat lock keeps repeated hook events from
35
+ * stacking refresh children while one is already running.
36
+ */
37
+ export function maybeRepairManagedCodexApp(tenantId, {
38
+ drifted = managedChatGPTConfigDrifted,
39
+ lockIsFresh = sessionFlushLockIsFresh,
40
+ touchLock = touchSessionFlushLock,
41
+ spawnRefresh = spawnDetachedAppRefresh,
42
+ } = {}) {
43
+ if (!tenantId) return false;
44
+ const paths = appPaths(undefined, tenantId);
45
+ if (!drifted(paths)) return false;
46
+ const lock = path.join(paths.tenantRoot, "app-repair-heartbeat");
47
+ if (lockIsFresh(lock, 60_000)) return false;
48
+ touchLock(lock);
49
+ spawnRefresh(tenantId);
50
+ return true;
51
+ }
52
+
25
53
  function startDetachedFlush({ provider, tenant, session }) {
26
54
  const invocation = impelCliInvocation([
27
55
  "sessions",
@@ -65,6 +93,7 @@ export async function cmdSessions(argv) {
65
93
  config,
66
94
  flush: false,
67
95
  });
96
+ if (flags.provider === "codex") maybeRepairManagedCodexApp(flags.tenant);
68
97
  if (config && (config.tenantId === flags.tenant || process.env.IMPEL_SESSIONS_DEV_ORG_ID)) {
69
98
  // Hooks fire on every session event; only spawn a flush child when no
70
99
  // live one is already polling this session's outbox (heartbeat lock).
@@ -16,6 +16,7 @@ import {
16
16
  printReconciliationSummary,
17
17
  reconcileAllTenants,
18
18
  selectDefaultTenant,
19
+ vendorAppTargetReady,
19
20
  } from "../provisioning.js";
20
21
  import { describeCliFailure, preparePlatformClis } from "../platformSetup.js";
21
22
  import { runInstallRecovery } from "../installRecovery/engine.js";
@@ -49,9 +50,9 @@ export function resolveTenantChoice(listing, { requested = null, answer = null,
49
50
  return selectDefaultTenant(listing, { currentTenantId });
50
51
  }
51
52
 
52
- async function probeGateway(gatewayUrl, pat, tenantId, fetchImpl = fetch) {
53
+ async function probeGatewayOnce(gatewayUrl, pat, tenantId, fetchImpl, timeoutMs) {
53
54
  const controller = new AbortController();
54
- const timeout = setTimeout(() => controller.abort(), 5_000);
55
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
55
56
  try {
56
57
  const response = await fetchImpl(`${gatewayUrl}/v1/models`, {
57
58
  headers: {
@@ -69,13 +70,28 @@ async function probeGateway(gatewayUrl, pat, tenantId, fetchImpl = fetch) {
69
70
  } catch (error) {
70
71
  return {
71
72
  reachable: false,
72
- error: error?.name === "AbortError" ? "timed out after 5s" : redactSecretText(error?.message || error),
73
+ error: error?.name === "AbortError"
74
+ ? `timed out after ${Math.round(timeoutMs / 1000)}s`
75
+ : redactSecretText(error?.message || error),
73
76
  };
74
77
  } finally {
75
78
  clearTimeout(timeout);
76
79
  }
77
80
  }
78
81
 
82
+ // A single short probe misreports a cold gateway (serverless cold start plus
83
+ // long-haul latency) as unreachable, and that transient verdict gets baked
84
+ // into the convergence failure message. Retry once with a longer deadline
85
+ // before concluding the gateway is down.
86
+ export async function probeGateway(gatewayUrl, pat, tenantId, fetchImpl = fetch, timeoutsMs = [5_000, 10_000]) {
87
+ let probe = { reachable: false, error: "not probed" };
88
+ for (const timeoutMs of timeoutsMs) {
89
+ probe = await probeGatewayOnce(gatewayUrl, pat, tenantId, fetchImpl, timeoutMs);
90
+ if (probe.reachable) return probe;
91
+ }
92
+ return probe;
93
+ }
94
+
79
95
  function markProbeFailures(report, probes) {
80
96
  probes.forEach((probe, index) => {
81
97
  if (probe.reachable && !probe.rejected && probe.healthy !== false) return;
@@ -406,11 +422,17 @@ export async function cmdSetup(argv, overrides = {}) {
406
422
  return ["ready", "unavailable"].includes(profileReport.tenants[0]?.cli);
407
423
  },
408
424
  retryStep: () => retrySafeState(),
409
- installVendorApp: (target) => retryTenant(async (product) => (
410
- target === "all"
411
- || product === target
412
- || (target === "codex" && product === "chatgpt")
413
- )),
425
+ // Succeed when the requested vendor app converged, even if the
426
+ // sibling app still fails: the goal check keeps demanding full
427
+ // tenant readiness, so recovery can approve one app at a time.
428
+ installVendorApp: async (target) => {
429
+ await retryTenant(async (product) => (
430
+ target === "all"
431
+ || product === target
432
+ || (target === "codex" && product === "chatgpt")
433
+ ));
434
+ return vendorAppTargetReady(retryReport, target);
435
+ },
414
436
  },
415
437
  }, overrides.recoveryOverrides || {});
416
438
  if (retryReport) mergeTenantReport(report, retryReport);
@@ -27,8 +27,13 @@ function isExecutable(filePath, platform = process.platform) {
27
27
  if (!fs.statSync(filePath).isFile()) return false;
28
28
  if (platform !== "win32") fs.accessSync(filePath, fs.constants.X_OK);
29
29
  return true;
30
- } catch {
31
- return false;
30
+ } catch (error) {
31
+ // Windows Store App Execution Aliases (winget.exe, python.exe, …) are
32
+ // zero-byte APPEXECLINK reparse points that CreateProcess launches fine,
33
+ // but fs.stat cannot read them on Node runtimes without libuv's
34
+ // AppExecLink fstat support. Those failures surface as EINVAL/UNKNOWN,
35
+ // never ENOENT, so an existing alias must still count as executable.
36
+ return platform === "win32" && ["EINVAL", "UNKNOWN"].includes(error?.code);
32
37
  }
33
38
  }
34
39
 
@@ -119,6 +124,12 @@ function commonCandidates(tool, environment, platform) {
119
124
  locations.push([paths.join(systemRoot, "System32", "WindowsPowerShell", "v1.0"), "powershell"]);
120
125
  }
121
126
 
127
+ // winget ships solely as a per-user App Execution Alias; cover its fixed
128
+ // home for environments whose PATH omits the WindowsApps directory.
129
+ if (tool === "winget" && localAppData) {
130
+ locations.push([paths.join(localAppData, "Microsoft", "WindowsApps"), "winget"]);
131
+ }
132
+
122
133
  // Git for Windows standard install roots; `cmd` holds the PATH-safe
123
134
  // git.exe. Codex shells out to git for marketplace clones, so skill
124
135
  // syncing must find git even when the parent shell's PATH predates the
@@ -183,13 +194,13 @@ export function resolveNativeBinary(
183
194
 
184
195
  // Windows cannot execute npm's .cmd/.bat shims directly. This is the escaping
185
196
  // strategy used by cross-spawn, inlined here to keep the CLI dependency-free.
186
- // Arguments are quoted individually and command metacharacters are escaped
187
- // twice because npm shims forward them through `%*`, causing a second parse.
197
+ // Arguments are quoted individually and command metacharacters are
198
+ // caret-escaped once per cmd parse the argument will go through.
188
199
  function escapeWindowsBatchCommand(value) {
189
200
  return String(value).replace(WINDOWS_SHELL_META_RE, "^$1");
190
201
  }
191
202
 
192
- export function escapeWindowsBatchArgument(value) {
203
+ function encodeWindowsCommandArgument(value, metaEscapePasses) {
193
204
  let escaped = String(value);
194
205
  if (WINDOWS_UNSAFE_LINE_RE.test(escaped)) {
195
206
  throw new Error("cannot safely forward an argument containing a NUL or line break through a Windows batch shim");
@@ -197,17 +208,36 @@ export function escapeWindowsBatchArgument(value) {
197
208
  escaped = escaped.replace(/(?=(\\+?)?)\1"/gu, "$1$1\\\"");
198
209
  escaped = escaped.replace(/(?=(\\+?)?)\1$/gu, "$1$1");
199
210
  escaped = `"${escaped}"`;
200
- escaped = escaped.replace(WINDOWS_SHELL_META_RE, "^$1");
201
- return escaped.replace(WINDOWS_SHELL_META_RE, "^$1");
211
+ for (let pass = 0; pass < metaEscapePasses; pass += 1) {
212
+ escaped = escaped.replace(WINDOWS_SHELL_META_RE, "^$1");
213
+ }
214
+ return escaped;
215
+ }
216
+
217
+ // Batch shims re-read the command line through `%*`, so cmd parses these
218
+ // arguments twice and the metacharacter escapes must survive both passes.
219
+ export function escapeWindowsBatchArgument(value) {
220
+ return encodeWindowsCommandArgument(value, 2);
221
+ }
222
+
223
+ // A bare command name is resolved by cmd itself (covering App Execution
224
+ // Aliases such as winget.exe that PATH stat probing cannot verify), but the
225
+ // target program's command line is produced by cmd's single parse. Double
226
+ // escaping here would deliver literal carets — winget then fails with
227
+ // 0x8A150002 APPINSTALLER_CLI_ERROR_INVALID_CL_ARGUMENTS.
228
+ export function escapeWindowsBareArgument(value) {
229
+ return encodeWindowsCommandArgument(value, 1);
202
230
  }
203
231
 
204
232
  export function nativeSpawnInvocation(binary, argv, environment = process.env, platform = process.platform) {
233
+ const batchShim = platform === "win32" && WINDOWS_BATCH_EXTENSION_RE.test(binary);
205
234
  const bareWindowsCommand = (
206
235
  platform === "win32"
236
+ && !batchShim
207
237
  && WINDOWS_BARE_COMMAND_RE.test(binary)
208
238
  && path.win32.basename(binary) === binary
209
239
  );
210
- if (platform !== "win32" || (!WINDOWS_BATCH_EXTENSION_RE.test(binary) && !bareWindowsCommand)) {
240
+ if (platform !== "win32" || (!batchShim && !bareWindowsCommand)) {
211
241
  return { command: binary, args: argv, windowsVerbatimArguments: false };
212
242
  }
213
243
  if (WINDOWS_UNSAFE_LINE_RE.test(binary)) {
@@ -215,7 +245,7 @@ export function nativeSpawnInvocation(binary, argv, environment = process.env, p
215
245
  }
216
246
  const shellCommand = [
217
247
  escapeWindowsBatchCommand(pathApi(platform).normalize(binary)),
218
- ...argv.map(escapeWindowsBatchArgument),
248
+ ...argv.map(batchShim ? escapeWindowsBatchArgument : escapeWindowsBareArgument),
219
249
  ].join(" ");
220
250
  return {
221
251
  command: environmentValue(environment, "ComSpec") || "cmd.exe",
@@ -247,6 +247,7 @@ export async function reconcileAllTenants({
247
247
  for (const tenant of liveTenants) {
248
248
  const result = results.get(tenant.id);
249
249
  let supported = new Set();
250
+ let failedTargets = new Set();
250
251
  try {
251
252
  const installed = await io.reconcileApps({
252
253
  baseConfig: config,
@@ -260,10 +261,17 @@ export async function reconcileAllTenants({
260
261
  confirmVendorInstall,
261
262
  }, overrides.appOverrides || {});
262
263
  supported = new Set(installed.targets || []);
264
+ const failedApps = installed.failed || [];
265
+ failedTargets = new Set(failedApps.map((failure) => failure.target));
263
266
  for (const target of supported) vendorPreparedTargets.add(target);
264
- result.clients.claude.app = supported.has("claude") ? "ready" : "unsupported";
265
- result.clients.codex.app = supported.has("chatgpt") ? "ready" : "unsupported";
266
- result.apps = supported.size ? "ready" : "unavailable";
267
+ result.clients.claude.app = supported.has("claude")
268
+ ? "ready"
269
+ : failedTargets.has("claude") ? "failed" : "unsupported";
270
+ result.clients.codex.app = supported.has("chatgpt")
271
+ ? "ready"
272
+ : failedTargets.has("chatgpt") ? "failed" : "unsupported";
273
+ result.apps = failedTargets.size ? "failed" : supported.size ? "ready" : "unavailable";
274
+ for (const failure of failedApps) result.errors.push(redactSecretText(failure.error));
267
275
  } catch (error) {
268
276
  result.apps = "failed";
269
277
  for (const client of Object.values(result.clients)) {
@@ -276,9 +284,9 @@ export async function reconcileAllTenants({
276
284
  }
277
285
 
278
286
  if (supported.size === 0) {
279
- result.shell = "unavailable";
280
- result.clients.claude.shell = "unsupported";
281
- result.clients.codex.shell = "unsupported";
287
+ result.shell = failedTargets.size ? "failed" : "unavailable";
288
+ result.clients.claude.shell = failedTargets.has("claude") ? "failed" : "unsupported";
289
+ result.clients.codex.shell = failedTargets.has("chatgpt") ? "failed" : "unsupported";
282
290
  continue;
283
291
  }
284
292
  try {
@@ -293,12 +301,15 @@ export async function reconcileAllTenants({
293
301
  const entryProducts = new Set(entries.map((entry) => entry.product));
294
302
  result.clients.claude.shell = supported.has("claude")
295
303
  ? (entryProducts.has("claude") ? "ready" : "failed")
296
- : "unsupported";
304
+ : failedTargets.has("claude") ? "failed" : "unsupported";
297
305
  result.clients.codex.shell = supported.has("chatgpt")
298
306
  ? (entryProducts.has("chatgpt") ? "ready" : "failed")
299
- : "unsupported";
300
- result.shell = [...supported].every((target) => entryProducts.has(target)) ? "ready" : "failed";
301
- if (result.shell === "failed") throw new Error("operating-system app registration did not complete");
307
+ : failedTargets.has("chatgpt") ? "failed" : "unsupported";
308
+ const registered = [...supported].every((target) => entryProducts.has(target));
309
+ // A failed vendor target has no launchable entry, so the tenant's
310
+ // launch surface stays failed even when every present app registered.
311
+ result.shell = registered && !failedTargets.size ? "ready" : "failed";
312
+ if (!registered) throw new Error("operating-system app registration did not complete");
302
313
  } catch (error) {
303
314
  result.shell = "failed";
304
315
  for (const client of Object.values(result.clients)) {
@@ -327,6 +338,19 @@ export async function reconcileAllTenants({
327
338
  };
328
339
  }
329
340
 
341
+ /**
342
+ * Whether a single-tenant reconcile report shows the requested vendor app
343
+ * (claude, codex, or all) as converged. "unsupported" counts as converged:
344
+ * the tenant catalog offers nothing to install for that product.
345
+ */
346
+ export function vendorAppTargetReady(report, target) {
347
+ const clients = report?.tenants?.[0]?.clients;
348
+ if (!clients) return false;
349
+ const converged = (client) => ["ready", "unsupported"].includes(client?.app);
350
+ if (target === "all") return converged(clients.claude) && converged(clients.codex);
351
+ return converged(target === "claude" ? clients.claude : clients.codex);
352
+ }
353
+
330
354
  export function printReconciliationSummary(report, log = console.log) {
331
355
  log("Tenant readiness:");
332
356
  for (const tenant of report.tenants) {
package/src/skills.js CHANGED
@@ -44,6 +44,14 @@ export const SKILLS_FALLBACK_GATEWAY_URL = "https://gateway.useimpel.ai";
44
44
  const BENIGN_OUTPUT = /already (exist|install|add|present|configur)|up[ -]?to[ -]?date|no changes|nothing to (do|update)/i;
45
45
  const TRANSIENT_SKILL_OUTPUT = /timed? out|timeout of \d+ms exceeded|ECONNRESET|ETIMEDOUT|EAI_AGAIN|network error|failed to download/i;
46
46
 
47
+ // Codex `plugin marketplace upgrade` refreshes only Git marketplaces. A
48
+ // registration recorded with a non-git source_type (an older Codex, or a
49
+ // git-less fallback at registration time) fails every upgrade with this error
50
+ // until the marketplace is re-registered — otherwise the profile keeps the
51
+ // snapshot cloned at registration forever and every sync warns.
52
+ const NON_GIT_MARKETPLACE_RE = /not configured as a git marketplace/i;
53
+ const MARKETPLACE_NOT_FOUND_RE = /not found|no marketplace/i;
54
+
47
55
  const CLIENT_SPECS = {
48
56
  claude: {
49
57
  bin: "claude",
@@ -345,6 +353,27 @@ async function runSkillCommandWithRetry(run, bin, args, env) {
345
353
  return run(bin, args, env);
346
354
  }
347
355
 
356
+ /**
357
+ * Heal a Codex marketplace whose stored registration cannot be upgraded by
358
+ * re-registering it: remove the stale record (which also drops its installed
359
+ * plugins) and add the current source. The install command that follows the
360
+ * refresh phase reinstalls the plugin from the fresh snapshot. Returns true
361
+ * only when the re-registration fully succeeded.
362
+ */
363
+ async function reregisterCodexMarketplace({ run, bin, env, marketplaceName, sourceUrl }) {
364
+ const removed = await run(bin, ["plugin", "marketplace", "remove", marketplaceName], env);
365
+ if (!removed.ok && !MARKETPLACE_NOT_FOUND_RE.test(`${removed.stdout}\n${removed.stderr}`)) {
366
+ return false;
367
+ }
368
+ const added = await runSkillCommandWithRetry(
369
+ run,
370
+ bin,
371
+ ["plugin", "marketplace", "add", sourceUrl],
372
+ env,
373
+ );
374
+ return added.ok;
375
+ }
376
+
348
377
  const MARKETPLACE_FETCH_TIMEOUT_MS = 30_000;
349
378
 
350
379
  // The gateway serves marketplace.json from a serverless function with an 8-15s
@@ -494,9 +523,23 @@ export async function syncSkills({
494
523
  failures.push({ phase: command.phase, reason: `\`${spec.bin}\` disappeared mid-sync` });
495
524
  break;
496
525
  }
497
- if (!isBenign(result)) {
498
- failures.push({ phase: command.phase, reason: firstLine(result.stderr) || `exit ${result.status}` });
526
+ if (isBenign(result)) continue;
527
+ if (
528
+ client === "codex"
529
+ && command.phase === "refresh-marketplace"
530
+ && marketplaceName
531
+ && NON_GIT_MARKETPLACE_RE.test(`${result.stdout}\n${result.stderr}`)
532
+ ) {
533
+ const reregistered = await reregisterCodexMarketplace({
534
+ run,
535
+ bin: spec.bin,
536
+ env,
537
+ marketplaceName,
538
+ sourceUrl,
539
+ });
540
+ if (reregistered) continue;
499
541
  }
542
+ failures.push({ phase: command.phase, reason: firstLine(result.stderr) || `exit ${result.status}` });
500
543
  }
501
544
 
502
545
  if (failures.length > 0) {