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 +1 -1
- package/src/apps.js +23 -0
- package/src/commands/apps.js +80 -38
- package/src/commands/converge.js +17 -6
- package/src/commands/launch.js +6 -1
- package/src/commands/sessions.js +29 -0
- package/src/commands/setup.js +30 -8
- package/src/nativeProcess.js +39 -9
- package/src/provisioning.js +34 -10
- package/src/skills.js +45 -2
package/package.json
CHANGED
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"));
|
package/src/commands/apps.js
CHANGED
|
@@ -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))
|
|
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
|
-
|
|
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
|
-
|
|
384
|
-
|
|
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
|
-
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
if (!
|
|
397
|
-
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
|
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: !
|
|
434
|
-
chatgpt: !
|
|
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:
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
|
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:
|
|
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(
|
|
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:
|
|
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 {
|
package/src/commands/converge.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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);
|
package/src/commands/launch.js
CHANGED
|
@@ -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 {
|
|
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
|
|
package/src/commands/sessions.js
CHANGED
|
@@ -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).
|
package/src/commands/setup.js
CHANGED
|
@@ -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
|
|
53
|
+
async function probeGatewayOnce(gatewayUrl, pat, tenantId, fetchImpl, timeoutMs) {
|
|
53
54
|
const controller = new AbortController();
|
|
54
|
-
const timeout = setTimeout(() => controller.abort(),
|
|
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"
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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);
|
package/src/nativeProcess.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
187
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
201
|
-
|
|
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" || (!
|
|
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",
|
package/src/provisioning.js
CHANGED
|
@@ -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")
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
301
|
-
|
|
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 (
|
|
498
|
-
|
|
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) {
|