impel-cli 0.20.52 → 0.20.54
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/RELEASE_NOTES.md +34 -0
- package/package.json +1 -1
- package/src/apps.js +26 -5
- package/src/cli.js +2 -0
- package/src/commands/apps.js +7 -5
- package/src/commands/converge.js +22 -0
- package/src/commands/launch.js +23 -1
- package/src/commands/skills.js +19 -1
- package/src/macSetup.js +6 -0
- package/src/managedApps.js +194 -0
- package/src/provisioning.js +5 -0
- package/src/skillBundle.js +389 -0
- package/src/skills.js +237 -10
- package/src/tenants.js +13 -1
- package/src/windowsSetup.js +6 -0
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
# Release notes
|
|
2
2
|
|
|
3
|
+
## 0.20.54 — Restore ChatGPT Store compatibility and scope skills per tenant
|
|
4
|
+
|
|
5
|
+
- Restores managed ChatGPT/Codex installation on Windows by pinning Store
|
|
6
|
+
package `26.814.5167.0` (embedded Codex `0.148.0-alpha.15`) — the build the
|
|
7
|
+
Store actually fulfills, after it stopped serving the previous package.
|
|
8
|
+
Retains exact package, publisher, executable, and runtime verification.
|
|
9
|
+
- Holds the macOS ChatGPT desktop pin at `26.810.41047` (embedded Codex
|
|
10
|
+
`0.148.0-alpha.9`) rather than advancing it with Windows. Build
|
|
11
|
+
`26.814.41957` breaks the managed Tasks board: its view is destroyed instead
|
|
12
|
+
of hidden when returning to the app. The pins are deliberately split for this
|
|
13
|
+
release and rejoin once a macOS build passes the launch smoke.
|
|
14
|
+
- Syncs skills with the selected tenant's credential so tenant-scoped skills
|
|
15
|
+
reach managed profiles. An uncredentialed fetch returns the gateway's
|
|
16
|
+
globals-only view with a 200, so the previous sync silently installed global
|
|
17
|
+
skills only.
|
|
18
|
+
- Reconciles each managed profile against the fetched view, removing skills
|
|
19
|
+
this CLI installed that the tenant no longer receives, and leaving
|
|
20
|
+
user-installed skills untouched.
|
|
21
|
+
- Leaves installed skills byte-identical and exits non-zero when the gateway
|
|
22
|
+
cannot determine the tenant, so an identity outage can never be mistaken for
|
|
23
|
+
an empty view.
|
|
24
|
+
|
|
25
|
+
## 0.20.53 — Launch distributed managed CLIs
|
|
26
|
+
|
|
27
|
+
- Adds `impel claude` and `impel codex` fallback launch through an installed
|
|
28
|
+
tenant-managed app when a standalone CLI credential is not configured.
|
|
29
|
+
- Selects an explicit `--tenant`, configured tenant, or the sole installed
|
|
30
|
+
tenant; ambiguous installations fail closed with an actionable tenant list.
|
|
31
|
+
- Validates the fixed application root, adjacent managed manifest, runtime
|
|
32
|
+
ownership, platform, logical tenant identity, and enrollment credential
|
|
33
|
+
presence without reading or placing the credential on the command line.
|
|
34
|
+
- Keeps the existing standalone Claude and Codex launch behavior unchanged
|
|
35
|
+
whenever its separate CLI credential is configured.
|
|
36
|
+
|
|
3
37
|
## 0.20.52 — Open embedded Tasks tickets safely
|
|
4
38
|
|
|
5
39
|
- Makes cards, list rows, fleet items, and the card menu open their exact
|
package/package.json
CHANGED
package/src/apps.js
CHANGED
|
@@ -105,17 +105,38 @@ export const PINNED_VENDOR_APPS = Object.freeze({
|
|
|
105
105
|
}),
|
|
106
106
|
}),
|
|
107
107
|
chatgpt: Object.freeze({
|
|
108
|
+
// DELIBERATELY SPLIT FROM THE WINDOWS PIN BELOW (2026-08-18).
|
|
109
|
+
//
|
|
110
|
+
// These normally move together, and the default should remain one
|
|
111
|
+
// coordinated build. macOS is held one build back because ChatGPT
|
|
112
|
+
// 26.814.41957 changed its onboarding flow and the managed Tasks board's
|
|
113
|
+
// WebContentsView is destroyed rather than hidden when navigating back to
|
|
114
|
+
// the app: the launch smoke fails reproducibly with zero surviving board
|
|
115
|
+
// targets. That is a real incompatibility with the desktop-tasks
|
|
116
|
+
// integration, not a flaky check, so the newer macOS build is not shipped
|
|
117
|
+
// until it is fixed.
|
|
118
|
+
//
|
|
119
|
+
// Windows cannot wait: the Store stopped fulfilling the old package
|
|
120
|
+
// entirely, so holding both would leave Windows unable to install at all.
|
|
121
|
+
// Rejoin the two as soon as a macOS build passes the launch smoke.
|
|
108
122
|
version: "26.810.41047",
|
|
109
123
|
codexVersion: "0.148.0-alpha.9",
|
|
110
124
|
bundleName: "ChatGPT.app",
|
|
111
125
|
windows: Object.freeze({
|
|
112
126
|
storeProductId: "9PLM9XGG6VKS",
|
|
113
127
|
packageName: "OpenAI.Codex",
|
|
114
|
-
// The
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
|
|
128
|
+
// The exact AppX version Windows registers for this Store product, and
|
|
129
|
+
// the only version production validates: ensureWindowsChatGPTApp installs
|
|
130
|
+
// by storeProductId and then fail-closed-checks the REGISTERED package
|
|
131
|
+
// against this value. updateManifestUrl below is advertisement, read by
|
|
132
|
+
// the contract test alone and never by the install path.
|
|
133
|
+
//
|
|
134
|
+
// Those two disagree as of 2026-08-18: the manifest advertises
|
|
135
|
+
// 26.814.5517.0 while the Store installs 26.814.5167.0. Pin what actually
|
|
136
|
+
// installs — a pin matching only the advertisement would fail closed on
|
|
137
|
+
// every real machine.
|
|
138
|
+
packageVersion: "26.814.5167.0",
|
|
139
|
+
codexVersion: "0.148.0-alpha.15",
|
|
119
140
|
publisherId: "2p2nqsd0c76g0",
|
|
120
141
|
executable: "app\\ChatGPT.exe",
|
|
121
142
|
updateManifestUrl: "https://persistent.oaistatic.com/codex-app-prod/windows-store-update.json",
|
package/src/cli.js
CHANGED
|
@@ -47,6 +47,8 @@ Work:
|
|
|
47
47
|
impel codex [args...] Launch Codex with an isolated Impel profile
|
|
48
48
|
impel codex --agent <id|exact-title> ... Run one fixed tenant agent without a parent hop
|
|
49
49
|
impel codex --benchmark ... Tag native-agent MCP calls as benchmark traffic
|
|
50
|
+
impel claude|codex --tenant <id> ... Select an enrolled Managed Software Center tenant
|
|
51
|
+
when the CLI has no separate Impel authentication
|
|
50
52
|
impel remote handoff|dispatch|handback Move or control provider-native sessions remotely
|
|
51
53
|
impel remote status|viewer|proxy Inspect, control, or connect to a remote run
|
|
52
54
|
impel tenant list List accessible organizations
|
package/src/commands/apps.js
CHANGED
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
readTenantManifest,
|
|
43
43
|
removeManagedApps,
|
|
44
44
|
} from "../apps.js";
|
|
45
|
+
import { resolveSkillCredential } from "../skillBundle.js";
|
|
45
46
|
import { resolveSkillsGateway, syncSkillsSafe } from "../skills.js";
|
|
46
47
|
import { syncAgentProfilesSafe } from "../agents.js";
|
|
47
48
|
import { secureManagedCodexHome } from "../codexSecurity.js";
|
|
@@ -490,7 +491,7 @@ export async function reconcileWindowsTenantApps({
|
|
|
490
491
|
// Merge the sync env so an app-embedded binary (IMPEL_CODEX_BIN) satisfies
|
|
491
492
|
// the availability check even when no standalone CLI is installed.
|
|
492
493
|
if (io.findBinary(client, { ...environment, ...env }, "win32")) {
|
|
493
|
-
if (RUNTIME_BRAND.features.skills) await io.syncSkills({ client, gatewayUrl: resolveSkillsGateway(config.gatewayUrl), env, label, homeDir: os.homedir() });
|
|
494
|
+
if (RUNTIME_BRAND.features.skills) await io.syncSkills({ client, gatewayUrl: resolveSkillsGateway(config.gatewayUrl), credential: resolveSkillCredential(config), env, label, homeDir: os.homedir() });
|
|
494
495
|
agentTargets.push(target);
|
|
495
496
|
} else {
|
|
496
497
|
io.log(`Skipping skill/agent sync for ${label} — no ${client === "claude" ? "Claude Code" : "Codex"} binary is available to run plugin commands.`);
|
|
@@ -611,7 +612,7 @@ export async function reconcileMacTenantApps({
|
|
|
611
612
|
for (const item of installed) {
|
|
612
613
|
const { client, env, label } = appSkillTarget(item.target, paths);
|
|
613
614
|
if (io.findBinary(client, environment, "darwin")) {
|
|
614
|
-
if (RUNTIME_BRAND.features.skills) await io.syncSkills({ client, gatewayUrl: resolveSkillsGateway(config.gatewayUrl), env, label, homeDir: os.homedir() });
|
|
615
|
+
if (RUNTIME_BRAND.features.skills) await io.syncSkills({ client, gatewayUrl: resolveSkillsGateway(config.gatewayUrl), credential: resolveSkillCredential(config), env, label, homeDir: os.homedir() });
|
|
615
616
|
agentItems.push(item);
|
|
616
617
|
}
|
|
617
618
|
if (item.target === "chatgpt") io.secureCodexHome(paths.chatgpt.codexHome);
|
|
@@ -859,6 +860,7 @@ export async function cmdWindowsApps(argv, overrides = {}) {
|
|
|
859
860
|
io.syncSkills({
|
|
860
861
|
client,
|
|
861
862
|
gatewayUrl: resolveSkillsGateway(config.gatewayUrl),
|
|
863
|
+
credential: resolveSkillCredential(config),
|
|
862
864
|
env,
|
|
863
865
|
label,
|
|
864
866
|
homeDir: os.homedir(),
|
|
@@ -1119,7 +1121,7 @@ export async function cmdApps(argv, overrides = {}) {
|
|
|
1119
1121
|
const { client, env, label } = appSkillTarget(item.target, paths);
|
|
1120
1122
|
if (RUNTIME_BRAND.features.skills && !flags["skip-skills"]) {
|
|
1121
1123
|
await withProgress(`Syncing skills for ${label}`, (spinner) => (
|
|
1122
|
-
io.syncSkills({ client, gatewayUrl, env, label, homeDir: os.homedir(), logger: createProgressLogger(spinner) })
|
|
1124
|
+
io.syncSkills({ client, gatewayUrl, credential: resolveSkillCredential(config), env, label, homeDir: os.homedir(), logger: createProgressLogger(spinner) })
|
|
1123
1125
|
));
|
|
1124
1126
|
}
|
|
1125
1127
|
if (item.target === "chatgpt") io.secureCodexHome(paths.chatgpt.codexHome);
|
|
@@ -1250,7 +1252,7 @@ export async function provisionAndOpenManagedApps({
|
|
|
1250
1252
|
const { client, env, label } = appSkillTarget(item.target, paths);
|
|
1251
1253
|
if (RUNTIME_BRAND.features.skills) {
|
|
1252
1254
|
await withProgress(`Syncing skills for ${label}`, (spinner) => (
|
|
1253
|
-
io.syncSkills({ client, gatewayUrl, env, label, homeDir: os.homedir(), logger: createProgressLogger(spinner) })
|
|
1255
|
+
io.syncSkills({ client, gatewayUrl, credential: resolveSkillCredential(config), env, label, homeDir: os.homedir(), logger: createProgressLogger(spinner) })
|
|
1254
1256
|
));
|
|
1255
1257
|
}
|
|
1256
1258
|
if (item.target === "chatgpt") io.secureCodexHome(paths.chatgpt.codexHome);
|
|
@@ -1422,7 +1424,7 @@ async function refreshApps(targets, { staleOnly = false, tenantId = null } = {},
|
|
|
1422
1424
|
const gatewayUrl = resolveSkillsGateway(config.gatewayUrl);
|
|
1423
1425
|
for (const status of supportedStatuses) {
|
|
1424
1426
|
const { client, env, label } = appSkillTarget(status.target, tenantPaths);
|
|
1425
|
-
if (RUNTIME_BRAND.features.skills) await io.syncSkills({ client, gatewayUrl, env, label, homeDir: os.homedir() });
|
|
1427
|
+
if (RUNTIME_BRAND.features.skills) await io.syncSkills({ client, gatewayUrl, credential: resolveSkillCredential(config), env, label, homeDir: os.homedir() });
|
|
1426
1428
|
if (status.target === "chatgpt") io.secureCodexHome(tenantPaths.chatgpt.codexHome);
|
|
1427
1429
|
}
|
|
1428
1430
|
if (RUNTIME_BRAND.features.agents) {
|
package/src/commands/converge.js
CHANGED
|
@@ -12,9 +12,25 @@ import { promptText } from "../prompt.js";
|
|
|
12
12
|
import { describeCliFailure, preparePlatformClis } from "../platformSetup.js";
|
|
13
13
|
import { restoreNativeProfiles } from "./use.js";
|
|
14
14
|
import { createTenantRecoveryReportTracker } from "./tenantRecoveryReport.js";
|
|
15
|
+
import { resolveSkillCredential } from "../skillBundle.js";
|
|
15
16
|
|
|
16
17
|
const RETRYABLE_TENANT_DISCOVERY_ERROR = /could not reach|request timed out|fetch failed|ECONNRESET|ECONNABORTED|ETIMEDOUT|EAI_AGAIN|ENETUNREACH|network error|socket/iu;
|
|
17
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Wait before the single discovery retry.
|
|
21
|
+
*
|
|
22
|
+
* An immediate retry cannot change the answer for the failure it most often
|
|
23
|
+
* sees: the OS caches a negative DNS result, so a second lookup microseconds
|
|
24
|
+
* later reads the same cached entry. This is long enough for a resolver or
|
|
25
|
+
* interface blip to clear and short enough that nobody watching decides the
|
|
26
|
+
* command has hung.
|
|
27
|
+
*/
|
|
28
|
+
export const TENANT_DISCOVERY_RETRY_DELAY_MS = 1_500;
|
|
29
|
+
|
|
30
|
+
function sleep(milliseconds) {
|
|
31
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
32
|
+
}
|
|
33
|
+
|
|
18
34
|
function confirmed(answer) {
|
|
19
35
|
return /^(?:y|yes)$/iu.test(String(answer || "").trim());
|
|
20
36
|
}
|
|
@@ -36,6 +52,7 @@ export async function cmdConverge(argv = [], overrides = {}) {
|
|
|
36
52
|
environment: process.env,
|
|
37
53
|
preparePlatformClis,
|
|
38
54
|
discoveryLogger: console,
|
|
55
|
+
sleep,
|
|
39
56
|
...overrides,
|
|
40
57
|
};
|
|
41
58
|
if (overrides.prepareWindowsClis && !overrides.preparePlatformClis) {
|
|
@@ -59,6 +76,7 @@ export async function cmdConverge(argv = [], overrides = {}) {
|
|
|
59
76
|
return false;
|
|
60
77
|
}
|
|
61
78
|
io.discoveryLogger.log("Tenants: discovery request failed transiently; retrying once…");
|
|
79
|
+
await io.sleep(TENANT_DISCOVERY_RETRY_DELAY_MS);
|
|
62
80
|
try {
|
|
63
81
|
listing = await io.fetchTenants(config);
|
|
64
82
|
} catch (retryError) {
|
|
@@ -135,6 +153,7 @@ export async function cmdConverge(argv = [], overrides = {}) {
|
|
|
135
153
|
const inspected = await io.preparePlatformClis({
|
|
136
154
|
gatewayUrl: config.gatewayUrl,
|
|
137
155
|
tenantId: selected.id,
|
|
156
|
+
credential: resolveSkillCredential({ pat: config.pat, tenantId: selected.id }),
|
|
138
157
|
platform: io.platform,
|
|
139
158
|
skipInstall: true,
|
|
140
159
|
});
|
|
@@ -166,6 +185,7 @@ export async function cmdConverge(argv = [], overrides = {}) {
|
|
|
166
185
|
prepared = await io.preparePlatformClis({
|
|
167
186
|
gatewayUrl: config.gatewayUrl,
|
|
168
187
|
tenantId: selected.id,
|
|
188
|
+
credential: resolveSkillCredential({ pat: config.pat, tenantId: selected.id }),
|
|
169
189
|
platform: io.platform,
|
|
170
190
|
skipInstall: false,
|
|
171
191
|
installTools,
|
|
@@ -217,6 +237,7 @@ export async function cmdConverge(argv = [], overrides = {}) {
|
|
|
217
237
|
skipInstall: true,
|
|
218
238
|
// Goals re-run before every system-risk step: the probe must be
|
|
219
239
|
// pure discovery — no installs, profile writes, or skill syncs.
|
|
240
|
+
// Deliberately no credential: nothing here reaches a skills fetch.
|
|
220
241
|
inspectOnly: true,
|
|
221
242
|
});
|
|
222
243
|
return prepared.missingAfter.length === 0;
|
|
@@ -232,6 +253,7 @@ export async function cmdConverge(argv = [], overrides = {}) {
|
|
|
232
253
|
return io.preparePlatformClis({
|
|
233
254
|
gatewayUrl: config.gatewayUrl,
|
|
234
255
|
tenantId: selected.id,
|
|
256
|
+
credential: resolveSkillCredential({ pat: config.pat, tenantId: selected.id }),
|
|
235
257
|
platform: io.platform,
|
|
236
258
|
skipInstall: true,
|
|
237
259
|
});
|
package/src/commands/launch.js
CHANGED
|
@@ -29,6 +29,12 @@ import { IMPEL_NATIVE_BENCHMARK_ENV } from "../selfInvocation.js";
|
|
|
29
29
|
import { resolveReviewedVendorCliBinary } from "../vendorCliBinaries.js";
|
|
30
30
|
import { PINNED_VENDOR_CLI_VERSIONS } from "../vendorCliVersions.js";
|
|
31
31
|
import { RUNTIME_BRAND } from "../runtimeBrand.js";
|
|
32
|
+
import {
|
|
33
|
+
installedManagedAppCandidates,
|
|
34
|
+
parseManagedTenantArgument,
|
|
35
|
+
runManagedAppCli,
|
|
36
|
+
selectManagedAppCandidate,
|
|
37
|
+
} from "../managedApps.js";
|
|
32
38
|
import {
|
|
33
39
|
cacheProviderReadiness,
|
|
34
40
|
cachedProviderReadiness,
|
|
@@ -462,7 +468,23 @@ export async function cmdLaunch(tool, argv) {
|
|
|
462
468
|
}
|
|
463
469
|
const config = loadConfig();
|
|
464
470
|
if (!config?.pat) {
|
|
465
|
-
|
|
471
|
+
try {
|
|
472
|
+
const parsed = parseManagedTenantArgument(nativeArgv);
|
|
473
|
+
const candidate = selectManagedAppCandidate(installedManagedAppCandidates(tool), {
|
|
474
|
+
requestedTenant: parsed.tenantId,
|
|
475
|
+
configuredTenant: config?.tenantId || null,
|
|
476
|
+
});
|
|
477
|
+
if (candidate) {
|
|
478
|
+
const exitCode = await runManagedAppCli(candidate, tool, parsed.argv);
|
|
479
|
+
if (exitCode !== 0) process.exitCode = exitCode;
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
} catch (error) {
|
|
483
|
+
console.error(`impel ${tool}: ${redactSecretText(error?.message || error)}`);
|
|
484
|
+
process.exitCode = 1;
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
console.error(`impel ${tool}: not authenticated and no enrolled managed application is installed. Run \`impel auth\` or install it from Managed Software Center.`);
|
|
466
488
|
process.exitCode = 1;
|
|
467
489
|
return;
|
|
468
490
|
}
|
package/src/commands/skills.js
CHANGED
|
@@ -14,6 +14,7 @@ import { CODEX_HOME } from "../codexSetup.js";
|
|
|
14
14
|
import { tenantCliProfilePaths } from "../cliProfiles.js";
|
|
15
15
|
import { CLAUDE_CONFIG_ID, appPaths } from "../apps.js";
|
|
16
16
|
import { environmentValue } from "../nativeProcess.js";
|
|
17
|
+
import { resolveSkillCredential } from "../skillBundle.js";
|
|
17
18
|
import { resolveSkillsGateway, syncSkillsSafe } from "../skills.js";
|
|
18
19
|
import { ensureTenantSelection } from "../tenants.js";
|
|
19
20
|
import { windowsClaudeUserData, windowsManagedChatGPTRoot } from "../windowsApps.js";
|
|
@@ -113,6 +114,7 @@ export async function cmdSkills(argv, overrides = {}) {
|
|
|
113
114
|
loadConfig,
|
|
114
115
|
ensureTenantSelection,
|
|
115
116
|
managedProfiles: managedSkillProfiles,
|
|
117
|
+
resolveCredential: resolveSkillCredential,
|
|
116
118
|
syncSkills: syncSkillsSafe,
|
|
117
119
|
...overrides,
|
|
118
120
|
};
|
|
@@ -121,6 +123,10 @@ export async function cmdSkills(argv, overrides = {}) {
|
|
|
121
123
|
const tenantId = config?.pat
|
|
122
124
|
? (await io.ensureTenantSelection(config)).tenantId
|
|
123
125
|
: null;
|
|
126
|
+
// Bound to the tenant just selected, not to the bare PAT: on a multi-tenant
|
|
127
|
+
// account the PAT alone resolves to identity's default tenant, which is not
|
|
128
|
+
// necessarily the one these profiles are configured for.
|
|
129
|
+
const credential = io.resolveCredential(config);
|
|
124
130
|
const clients = target === "all" ? ["claude", "codex"] : [target];
|
|
125
131
|
const profiles = clients.flatMap((client) => (
|
|
126
132
|
io.managedProfiles(client, {
|
|
@@ -129,15 +135,27 @@ export async function cmdSkills(argv, overrides = {}) {
|
|
|
129
135
|
}).map((profile) => ({ client, profile }))
|
|
130
136
|
));
|
|
131
137
|
|
|
132
|
-
await withProgress(`Syncing skills into ${profiles.length} managed profile${profiles.length === 1 ? "" : "s"}`, (spinner) => (
|
|
138
|
+
const results = await withProgress(`Syncing skills into ${profiles.length} managed profile${profiles.length === 1 ? "" : "s"}`, (spinner) => (
|
|
133
139
|
mapWithConcurrency(profiles, 4, ({ client, profile }) => (
|
|
134
140
|
io.syncSkills({
|
|
135
141
|
client,
|
|
136
142
|
gatewayUrl,
|
|
143
|
+
credential,
|
|
137
144
|
env: profile.env,
|
|
138
145
|
label: profile.label,
|
|
139
146
|
logger: createProgressLogger(spinner),
|
|
140
147
|
})
|
|
141
148
|
))
|
|
142
149
|
));
|
|
150
|
+
|
|
151
|
+
// Contract §2: an unavailable gateway must exit non-zero. It is reported
|
|
152
|
+
// rather than thrown because the other profiles' syncs are still valid work
|
|
153
|
+
// and their results have already been printed.
|
|
154
|
+
if (results.some((result) => result?.reason === "unavailable")) {
|
|
155
|
+
console.error(
|
|
156
|
+
"impel skills: the gateway could not determine this machine's tenant. "
|
|
157
|
+
+ "Installed skills were left unchanged; re-run `impel skills sync` once it recovers.",
|
|
158
|
+
);
|
|
159
|
+
process.exitCode = 1;
|
|
160
|
+
}
|
|
143
161
|
}
|
package/src/macSetup.js
CHANGED
|
@@ -171,6 +171,10 @@ function installationFailure(result, thrown = null) {
|
|
|
171
171
|
export async function prepareMacClis({
|
|
172
172
|
gatewayUrl,
|
|
173
173
|
tenantId,
|
|
174
|
+
// Tenant credential for the authenticated skills fetch. Optional: an
|
|
175
|
+
// unauthenticated setup still provisions profiles, it just receives the
|
|
176
|
+
// gateway's globals-only view (see syncSkills).
|
|
177
|
+
credential = null,
|
|
174
178
|
skipInstall = false,
|
|
175
179
|
inspectOnly = false,
|
|
176
180
|
installTools = ["claude", "codex"],
|
|
@@ -246,6 +250,7 @@ export async function prepareMacClis({
|
|
|
246
250
|
await io.syncSkills({
|
|
247
251
|
client: "claude",
|
|
248
252
|
gatewayUrl,
|
|
253
|
+
credential,
|
|
249
254
|
env: {
|
|
250
255
|
CLAUDE_CONFIG_DIR: claudeProfile.configDir,
|
|
251
256
|
IMPEL_CLAUDE_BIN: binaries.claude,
|
|
@@ -259,6 +264,7 @@ export async function prepareMacClis({
|
|
|
259
264
|
await io.syncSkills({
|
|
260
265
|
client: "codex",
|
|
261
266
|
gatewayUrl,
|
|
267
|
+
credential,
|
|
262
268
|
env: {
|
|
263
269
|
CODEX_HOME: codexProfile.codexHome,
|
|
264
270
|
IMPEL_CODEX_BIN: binaries.codex,
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
|
|
6
|
+
const TENANT = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/u;
|
|
7
|
+
const APPLICATION_BY_TOOL = Object.freeze({ claude: "claude", codex: "chatgpt" });
|
|
8
|
+
|
|
9
|
+
function regularFile(file) {
|
|
10
|
+
try {
|
|
11
|
+
const stat = fs.lstatSync(file);
|
|
12
|
+
return stat.isFile() && !stat.isSymbolicLink();
|
|
13
|
+
} catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function parseCandidate(manifestPath, launcher, tool, platform, home) {
|
|
19
|
+
if (!regularFile(manifestPath) || !regularFile(launcher)) return null;
|
|
20
|
+
let manifest;
|
|
21
|
+
try {
|
|
22
|
+
const raw = fs.readFileSync(manifestPath, "utf8");
|
|
23
|
+
if (Buffer.byteLength(raw, "utf8") > 512 * 1024) return null;
|
|
24
|
+
manifest = JSON.parse(raw);
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
const applicationId = APPLICATION_BY_TOOL[tool];
|
|
29
|
+
const tenantId = manifest?.tenant?.id;
|
|
30
|
+
const expectedPlatform = platform === "win32" ? "windows" : "macos";
|
|
31
|
+
if (
|
|
32
|
+
manifest?.schemaVersion !== 2
|
|
33
|
+
|| manifest?.kind !== "com.useimpel.managed-application"
|
|
34
|
+
|| manifest?.runtime?.owner !== "impel-apps"
|
|
35
|
+
|| manifest?.runtime?.entrypoint !== "engine/main.mjs"
|
|
36
|
+
|| manifest?.platform !== expectedPlatform
|
|
37
|
+
|| manifest?.application?.id !== applicationId
|
|
38
|
+
|| manifest?.application?.adapter !== applicationId
|
|
39
|
+
|| !TENANT.test(tenantId || "")
|
|
40
|
+
|| String(tenantId).includes("..")
|
|
41
|
+
|| manifest?.logicalApplicationId !== `${tenantId}.${applicationId}`
|
|
42
|
+
) return null;
|
|
43
|
+
const credential = path.join(home, ".config", "impel", "apps", "credentials", `${tenantId}.json`);
|
|
44
|
+
if (!regularFile(credential)) return null;
|
|
45
|
+
return {
|
|
46
|
+
tenantId,
|
|
47
|
+
tenantDisplayName: typeof manifest.tenant.displayName === "string" ? manifest.tenant.displayName : tenantId,
|
|
48
|
+
launcher,
|
|
49
|
+
manifestPath,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function macCandidates(tool, home, applicationsRoot) {
|
|
54
|
+
let entries;
|
|
55
|
+
try {
|
|
56
|
+
entries = fs.readdirSync(applicationsRoot, { withFileTypes: true });
|
|
57
|
+
} catch {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
const candidates = [];
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
if (!entry.isDirectory() || !entry.name.endsWith(".app")) continue;
|
|
63
|
+
const root = path.join(applicationsRoot, entry.name, "Contents");
|
|
64
|
+
const candidate = parseCandidate(
|
|
65
|
+
path.join(root, "Resources", "app-manifest.json"),
|
|
66
|
+
path.join(root, "MacOS", "impel-apps-launcher"),
|
|
67
|
+
tool,
|
|
68
|
+
"darwin",
|
|
69
|
+
home,
|
|
70
|
+
);
|
|
71
|
+
if (candidate) candidates.push(candidate);
|
|
72
|
+
}
|
|
73
|
+
return candidates;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function windowsCandidates(tool, home, applicationsRoot) {
|
|
77
|
+
const candidates = [];
|
|
78
|
+
let tenants;
|
|
79
|
+
try {
|
|
80
|
+
tenants = fs.readdirSync(applicationsRoot, { withFileTypes: true });
|
|
81
|
+
} catch {
|
|
82
|
+
return candidates;
|
|
83
|
+
}
|
|
84
|
+
for (const tenant of tenants) {
|
|
85
|
+
if (!tenant.isDirectory() || !TENANT.test(tenant.name) || tenant.name.includes("..")) continue;
|
|
86
|
+
const root = path.join(applicationsRoot, tenant.name, APPLICATION_BY_TOOL[tool]);
|
|
87
|
+
const candidate = parseCandidate(
|
|
88
|
+
path.join(root, "resources", "app-manifest.json"),
|
|
89
|
+
path.join(root, "impel-apps-launcher.exe"),
|
|
90
|
+
tool,
|
|
91
|
+
"win32",
|
|
92
|
+
home,
|
|
93
|
+
);
|
|
94
|
+
if (candidate) candidates.push(candidate);
|
|
95
|
+
}
|
|
96
|
+
return candidates;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function installedManagedAppCandidates(tool, {
|
|
100
|
+
platform = process.platform,
|
|
101
|
+
home = os.homedir(),
|
|
102
|
+
applicationsRoot = platform === "win32"
|
|
103
|
+
? path.join(process.env.ProgramFiles || "C:\\Program Files", "Impel", "Apps")
|
|
104
|
+
: "/Applications",
|
|
105
|
+
} = {}) {
|
|
106
|
+
if (!APPLICATION_BY_TOOL[tool]) return [];
|
|
107
|
+
const candidates = platform === "win32"
|
|
108
|
+
? windowsCandidates(tool, home, applicationsRoot)
|
|
109
|
+
: platform === "darwin" ? macCandidates(tool, home, applicationsRoot) : [];
|
|
110
|
+
return candidates.sort((left, right) => left.tenantId.localeCompare(right.tenantId));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function parseManagedTenantArgument(argv) {
|
|
114
|
+
const passthrough = [];
|
|
115
|
+
let tenantId = null;
|
|
116
|
+
let literal = false;
|
|
117
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
118
|
+
const argument = argv[index];
|
|
119
|
+
if (literal) {
|
|
120
|
+
passthrough.push(argument);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (argument === "--") {
|
|
124
|
+
literal = true;
|
|
125
|
+
passthrough.push(argument);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (argument === "--tenant") {
|
|
129
|
+
tenantId = argv[index += 1] || null;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (argument.startsWith("--tenant=")) {
|
|
133
|
+
tenantId = argument.slice("--tenant=".length) || null;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
passthrough.push(argument);
|
|
137
|
+
}
|
|
138
|
+
if (tenantId !== null && (!TENANT.test(tenantId) || tenantId.includes(".."))) {
|
|
139
|
+
throw new Error("--tenant requires a valid managed tenant id");
|
|
140
|
+
}
|
|
141
|
+
return { tenantId, argv: passthrough };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function selectManagedAppCandidate(candidates, {
|
|
145
|
+
requestedTenant = null,
|
|
146
|
+
environment = process.env,
|
|
147
|
+
configuredTenant = null,
|
|
148
|
+
} = {}) {
|
|
149
|
+
const tenantId = requestedTenant || environment.IMPEL_MANAGED_TENANT || configuredTenant;
|
|
150
|
+
if (tenantId) {
|
|
151
|
+
const selected = candidates.find((candidate) => candidate.tenantId === tenantId);
|
|
152
|
+
if (!selected) throw new Error(`no installed managed application is available for tenant "${tenantId}"`);
|
|
153
|
+
return selected;
|
|
154
|
+
}
|
|
155
|
+
if (candidates.length === 1) return candidates[0];
|
|
156
|
+
if (candidates.length === 0) return null;
|
|
157
|
+
throw new Error(
|
|
158
|
+
`more than one managed tenant is installed (${candidates.map(({ tenantId: id }) => id).join(", ")}); pass --tenant <id>`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function childExitCode(code, signal) {
|
|
163
|
+
if (Number.isInteger(code)) return code;
|
|
164
|
+
const signalNumber = signal ? os.constants.signals[signal] : null;
|
|
165
|
+
return Number.isInteger(signalNumber) ? 128 + signalNumber : 1;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function runManagedAppCli(candidate, tool, argv, {
|
|
169
|
+
environment = process.env,
|
|
170
|
+
cwd = process.cwd(),
|
|
171
|
+
spawnProcess = spawn,
|
|
172
|
+
} = {}) {
|
|
173
|
+
return new Promise((resolve) => {
|
|
174
|
+
const child = spawnProcess(candidate.launcher, ["--managed-cli", tool, "--", ...argv], {
|
|
175
|
+
cwd,
|
|
176
|
+
env: environment,
|
|
177
|
+
stdio: "inherit",
|
|
178
|
+
windowsHide: false,
|
|
179
|
+
});
|
|
180
|
+
let settled = false;
|
|
181
|
+
child.once("error", (error) => {
|
|
182
|
+
if (settled) return;
|
|
183
|
+
settled = true;
|
|
184
|
+
console.error(`impel ${tool}: could not launch the managed application: ${error.message}`);
|
|
185
|
+
resolve(127);
|
|
186
|
+
});
|
|
187
|
+
child.once("exit", (code, signal) => {
|
|
188
|
+
if (settled) return;
|
|
189
|
+
settled = true;
|
|
190
|
+
resolve(childExitCode(code, signal));
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
package/src/provisioning.js
CHANGED
|
@@ -7,6 +7,7 @@ import { crossAppModelsEnabled, redactSecretText } from "./config.js";
|
|
|
7
7
|
import { ensureImpelClaudeProfile, ensureImpelCodexProfile, tenantCliProfilePaths } from "./cliProfiles.js";
|
|
8
8
|
import { mapWithConcurrency } from "./progress.js";
|
|
9
9
|
import { registerTenantShellEntries } from "./shellEntries.js";
|
|
10
|
+
import { resolveSkillCredential } from "./skillBundle.js";
|
|
10
11
|
import { resolveSkillsGateway, syncSkillsSafe } from "./skills.js";
|
|
11
12
|
import {
|
|
12
13
|
normalizeTenantId,
|
|
@@ -128,6 +129,10 @@ async function prepareTenantCli(config, tenant, io, binaries) {
|
|
|
128
129
|
if (RUNTIME_BRAND.features.skills) await io.syncSkills({
|
|
129
130
|
client,
|
|
130
131
|
gatewayUrl,
|
|
132
|
+
// Scoped to THIS tenant, not the config's currently selected one: this
|
|
133
|
+
// loop provisions every accessible tenant, so a credential built from
|
|
134
|
+
// the selected tenant would give every profile the same tenant's view.
|
|
135
|
+
credential: resolveSkillCredential({ pat: config.pat, tenantId: tenant.id }),
|
|
131
136
|
env: definition.env(profile),
|
|
132
137
|
label: `Impel ${client === "claude" ? "Claude" : "Codex"} CLI (${tenant.id})`,
|
|
133
138
|
// Lets Windows spawns use a git-safe cwd (see skillSyncSpawnDirectory).
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
// Fetches the selected tenant's skills from the Bifrost registry and
|
|
2
|
+
// reconciles them into a managed client profile's `skills/` directory.
|
|
3
|
+
//
|
|
4
|
+
// Contract: impel-gateway `docs/skills-tenant-scope-cli-contract.md`.
|
|
5
|
+
//
|
|
6
|
+
// Why this exists at all: the gateway filters every skills-serving surface by
|
|
7
|
+
// the requesting tenant, and "no credential" is not an error there — it is the
|
|
8
|
+
// globals-only view, served with a 200. The vendor plugin commands
|
|
9
|
+
// (`claude plugin install`, `codex plugin add`) fetch the gateway themselves
|
|
10
|
+
// and cannot be given a credential, so every profile they populate receives
|
|
11
|
+
// globals only. This module is the authenticated path: it asks the gateway for
|
|
12
|
+
// the bundle AS THE TENANT and writes the answer to disk itself.
|
|
13
|
+
//
|
|
14
|
+
// Three gateway outcomes, deliberately NOT unified (contract §2):
|
|
15
|
+
//
|
|
16
|
+
// 200 + view -> authoritative; install it and remove managed skills
|
|
17
|
+
// absent from it.
|
|
18
|
+
// 200 + globals -> also authoritative (the credential was refused, not
|
|
19
|
+
// unanswered). Reconcile, but say so: on a machine that
|
|
20
|
+
// expected scoped skills this usually means the credential
|
|
21
|
+
// never arrived.
|
|
22
|
+
// 503 unavailable -> identity could not answer. CHANGE NOTHING ON DISK.
|
|
23
|
+
//
|
|
24
|
+
// The 503 rule is the load-bearing one. An identity outage rendered as an empty
|
|
25
|
+
// view is indistinguishable from "your tenant's skills were all removed", and
|
|
26
|
+
// the removal pass below would then faithfully delete every working skill on
|
|
27
|
+
// the machine. So a failed fetch must never reach reconciliation as an empty
|
|
28
|
+
// collection — it aborts before touching anything.
|
|
29
|
+
|
|
30
|
+
import crypto from "node:crypto";
|
|
31
|
+
import fs from "node:fs";
|
|
32
|
+
import path from "node:path";
|
|
33
|
+
import zlib from "node:zlib";
|
|
34
|
+
|
|
35
|
+
import { normalizeGatewayUrl, redactSecretText } from "./config.js";
|
|
36
|
+
import { normalizeTenantId, tenantCredential } from "./tenants.js";
|
|
37
|
+
|
|
38
|
+
/** Marks the skills this CLI installed, so reconciliation never touches a user's own. */
|
|
39
|
+
export const MANAGED_SKILL_MANIFEST = ".impel-managed-skills.json";
|
|
40
|
+
export const MANAGED_SKILL_MANIFEST_VERSION = 1;
|
|
41
|
+
|
|
42
|
+
/** The tenant-filtered bundle of every skill the requesting tenant may see. */
|
|
43
|
+
const BUNDLE_PATH = "/api/skills/serve/all/download.zip";
|
|
44
|
+
|
|
45
|
+
const BUNDLE_FETCH_TIMEOUT_MS = 60_000;
|
|
46
|
+
// The gateway serves this from a serverless function with a cold TTFB, and the
|
|
47
|
+
// bundle is a megabyte-scale zip. One retry covers a cold start; more would
|
|
48
|
+
// just extend an outage the caller should hear about promptly.
|
|
49
|
+
const BUNDLE_FETCH_ATTEMPTS = 2;
|
|
50
|
+
|
|
51
|
+
// A decompressed bundle is a few megabytes. This ceiling is far above that and
|
|
52
|
+
// exists only so a malformed or hostile response cannot exhaust memory.
|
|
53
|
+
const MAX_BUNDLE_BYTES = 64 * 1024 * 1024;
|
|
54
|
+
const MAX_ENTRY_BYTES = 16 * 1024 * 1024;
|
|
55
|
+
const MAX_ENTRIES = 4096;
|
|
56
|
+
|
|
57
|
+
// Skill directory names are the first path segment of every bundle entry and
|
|
58
|
+
// become directories under the profile. Constrain them to what the registry
|
|
59
|
+
// actually publishes so no entry can traverse, hide, or collide with the
|
|
60
|
+
// manifest.
|
|
61
|
+
const SKILL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Thrown when the gateway could not determine the tenant (contract §2, row 3).
|
|
65
|
+
*
|
|
66
|
+
* A distinct type rather than a flag because the whole point is that callers
|
|
67
|
+
* must not treat it like an empty view: an `instanceof` check is harder to
|
|
68
|
+
* collapse by accident than an `if (result.skills.length === 0)`.
|
|
69
|
+
*/
|
|
70
|
+
export class SkillsUnavailableError extends Error {
|
|
71
|
+
constructor(message) {
|
|
72
|
+
super(message);
|
|
73
|
+
this.name = "SkillsUnavailableError";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The bundle URL for a gateway origin. */
|
|
78
|
+
export function skillBundleUrl(gatewayUrl) {
|
|
79
|
+
return `${normalizeGatewayUrl(gatewayUrl)}${BUNDLE_PATH}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Fetch the tenant's skill bundle.
|
|
84
|
+
*
|
|
85
|
+
* The credential rides an `Authorization: Bearer` header. It is a tenant
|
|
86
|
+
* credential (PAT scoped to one tenant id), never the bare PAT: the bare PAT
|
|
87
|
+
* resolves to whatever tenant identity picks for it, which on a multi-tenant
|
|
88
|
+
* account is not necessarily the tenant the caller selected.
|
|
89
|
+
*
|
|
90
|
+
* Returns the raw zip bytes. Throws SkillsUnavailableError for anything that
|
|
91
|
+
* means "the answer is unknown" — a 5xx, a transport failure, or a timeout —
|
|
92
|
+
* so no caller can mistake an outage for an empty tenant.
|
|
93
|
+
*/
|
|
94
|
+
export async function fetchSkillBundle({
|
|
95
|
+
gatewayUrl,
|
|
96
|
+
credential,
|
|
97
|
+
fetchImpl = fetch,
|
|
98
|
+
timeoutMs = BUNDLE_FETCH_TIMEOUT_MS,
|
|
99
|
+
attempts = BUNDLE_FETCH_ATTEMPTS,
|
|
100
|
+
}) {
|
|
101
|
+
if (!credential) {
|
|
102
|
+
// Deliberately not a silent globals-only fetch. A caller that reached here
|
|
103
|
+
// without a credential has a bug, and quietly serving it the narrow view is
|
|
104
|
+
// exactly the failure this module exists to end.
|
|
105
|
+
throw new SkillsUnavailableError("a tenant credential is required to fetch tenant-scoped skills");
|
|
106
|
+
}
|
|
107
|
+
const url = skillBundleUrl(gatewayUrl);
|
|
108
|
+
let lastError = null;
|
|
109
|
+
for (let attempt = 0; attempt < Math.max(1, attempts); attempt += 1) {
|
|
110
|
+
const controller = new AbortController();
|
|
111
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
112
|
+
try {
|
|
113
|
+
const response = await fetchImpl(url, {
|
|
114
|
+
headers: { authorization: `Bearer ${credential}`, accept: "application/zip" },
|
|
115
|
+
signal: controller.signal,
|
|
116
|
+
});
|
|
117
|
+
if (response.status >= 500) {
|
|
118
|
+
// The gateway's "identity could not answer" branch. Retry once — a cold
|
|
119
|
+
// start looks the same from here — then surface it as unavailable.
|
|
120
|
+
lastError = new SkillsUnavailableError(
|
|
121
|
+
`the gateway could not determine this machine's tenant (HTTP ${response.status})`,
|
|
122
|
+
);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (!response.ok) {
|
|
126
|
+
// A 4xx is an answer: the credential is rejected or the route is gone.
|
|
127
|
+
// Still not something to reconcile against, but it will not fix itself
|
|
128
|
+
// on a retry, so fail immediately and name it.
|
|
129
|
+
throw new SkillsUnavailableError(`the skills registry rejected this request (HTTP ${response.status})`);
|
|
130
|
+
}
|
|
131
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
132
|
+
if (bytes.length > MAX_BUNDLE_BYTES) {
|
|
133
|
+
throw new SkillsUnavailableError("the skills registry returned an implausibly large bundle");
|
|
134
|
+
}
|
|
135
|
+
return bytes;
|
|
136
|
+
} catch (error) {
|
|
137
|
+
if (error instanceof SkillsUnavailableError && !String(error.message).includes("HTTP 5")) {
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
lastError = error?.name === "AbortError"
|
|
141
|
+
? new SkillsUnavailableError(`the skills registry did not respond within ${timeoutMs}ms`)
|
|
142
|
+
: error;
|
|
143
|
+
} finally {
|
|
144
|
+
clearTimeout(timeout);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
throw lastError instanceof SkillsUnavailableError
|
|
148
|
+
? lastError
|
|
149
|
+
: new SkillsUnavailableError(
|
|
150
|
+
`could not reach the skills registry: ${redactSecretText(lastError?.message || lastError)}`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Read a zip archive into `{ path, contents }` entries.
|
|
156
|
+
*
|
|
157
|
+
* Written here rather than shelling out to `unzip`/`tar`: this CLI is
|
|
158
|
+
* dependency-free by design, and Windows has no dependable bundled unzip.
|
|
159
|
+
*
|
|
160
|
+
* The central directory is the authority for what the archive contains — the
|
|
161
|
+
* local headers are not, because the gateway streams entries with data
|
|
162
|
+
* descriptors (bit 3 set), which leaves the local header's sizes zeroed.
|
|
163
|
+
*/
|
|
164
|
+
export function readZipEntries(buffer) {
|
|
165
|
+
const eocd = findEndOfCentralDirectory(buffer);
|
|
166
|
+
if (!eocd) throw new Error("the skills bundle is not a readable zip archive");
|
|
167
|
+
const { entryCount, directoryOffset } = eocd;
|
|
168
|
+
if (entryCount > MAX_ENTRIES) throw new Error("the skills bundle declares too many entries");
|
|
169
|
+
|
|
170
|
+
const entries = [];
|
|
171
|
+
let offset = directoryOffset;
|
|
172
|
+
for (let index = 0; index < entryCount; index += 1) {
|
|
173
|
+
if (offset + 46 > buffer.length || buffer.readUInt32LE(offset) !== 0x02014b50) {
|
|
174
|
+
throw new Error("the skills bundle has a malformed central directory");
|
|
175
|
+
}
|
|
176
|
+
const method = buffer.readUInt16LE(offset + 10);
|
|
177
|
+
const compressedSize = buffer.readUInt32LE(offset + 20);
|
|
178
|
+
const uncompressedSize = buffer.readUInt32LE(offset + 24);
|
|
179
|
+
const nameLength = buffer.readUInt16LE(offset + 28);
|
|
180
|
+
const extraLength = buffer.readUInt16LE(offset + 30);
|
|
181
|
+
const commentLength = buffer.readUInt16LE(offset + 32);
|
|
182
|
+
const localOffset = buffer.readUInt32LE(offset + 42);
|
|
183
|
+
const name = buffer.toString("utf8", offset + 46, offset + 46 + nameLength);
|
|
184
|
+
offset += 46 + nameLength + extraLength + commentLength;
|
|
185
|
+
|
|
186
|
+
// Directory entries carry a trailing slash and no content.
|
|
187
|
+
if (name.endsWith("/")) continue;
|
|
188
|
+
if (uncompressedSize > MAX_ENTRY_BYTES) {
|
|
189
|
+
throw new Error(`the skills bundle entry "${name}" is implausibly large`);
|
|
190
|
+
}
|
|
191
|
+
entries.push({
|
|
192
|
+
path: name,
|
|
193
|
+
contents: inflateEntry(buffer, localOffset, method, compressedSize, name),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
return entries;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function findEndOfCentralDirectory(buffer) {
|
|
200
|
+
// The EOCD is at the tail, after a comment of up to 64 KiB.
|
|
201
|
+
const earliest = Math.max(0, buffer.length - (0xffff + 22));
|
|
202
|
+
for (let index = buffer.length - 22; index >= earliest; index -= 1) {
|
|
203
|
+
if (buffer.readUInt32LE(index) !== 0x06054b50) continue;
|
|
204
|
+
return {
|
|
205
|
+
entryCount: buffer.readUInt16LE(index + 10),
|
|
206
|
+
directoryOffset: buffer.readUInt32LE(index + 16),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function inflateEntry(buffer, localOffset, method, compressedSize, name) {
|
|
213
|
+
if (localOffset + 30 > buffer.length || buffer.readUInt32LE(localOffset) !== 0x04034b50) {
|
|
214
|
+
throw new Error(`the skills bundle entry "${name}" has a malformed header`);
|
|
215
|
+
}
|
|
216
|
+
// Only the name/extra lengths are trustworthy in a local header when a data
|
|
217
|
+
// descriptor is in play; the sizes come from the central directory above.
|
|
218
|
+
const nameLength = buffer.readUInt16LE(localOffset + 26);
|
|
219
|
+
const extraLength = buffer.readUInt16LE(localOffset + 28);
|
|
220
|
+
const start = localOffset + 30 + nameLength + extraLength;
|
|
221
|
+
const payload = buffer.subarray(start, start + compressedSize);
|
|
222
|
+
if (method === 0) return Buffer.from(payload);
|
|
223
|
+
if (method !== 8) throw new Error(`the skills bundle entry "${name}" uses an unsupported compression method`);
|
|
224
|
+
return zlib.inflateRawSync(payload);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Group flat zip entries into skills, keyed by their top-level directory.
|
|
229
|
+
*
|
|
230
|
+
* Rejects rather than sanitizes anything that does not look like a published
|
|
231
|
+
* skill file. A bundle is a trusted-ish source, but it is still remote input
|
|
232
|
+
* being written to a path derived from its own contents, so absolute paths,
|
|
233
|
+
* `..` segments, and backslash separators are refused outright — silently
|
|
234
|
+
* "cleaning" such a path is how a traversal becomes a write outside the
|
|
235
|
+
* profile.
|
|
236
|
+
*/
|
|
237
|
+
export function groupBundleSkills(entries) {
|
|
238
|
+
const skills = new Map();
|
|
239
|
+
for (const entry of entries) {
|
|
240
|
+
const normalized = String(entry.path).replace(/^\.\//u, "");
|
|
241
|
+
if (!normalized
|
|
242
|
+
|| normalized.includes("\\")
|
|
243
|
+
|| normalized.startsWith("/")
|
|
244
|
+
|| /^[A-Za-z]:/u.test(normalized)
|
|
245
|
+
|| normalized.split("/").some((segment) => segment === ".." || segment === "." || segment === "")) {
|
|
246
|
+
throw new Error(`the skills bundle contains an unsafe path "${entry.path}"`);
|
|
247
|
+
}
|
|
248
|
+
const [name, ...rest] = normalized.split("/");
|
|
249
|
+
if (!SKILL_NAME_RE.test(name)) {
|
|
250
|
+
throw new Error(`the skills bundle contains an unsafe skill name "${name}"`);
|
|
251
|
+
}
|
|
252
|
+
if (rest.length === 0) continue; // A stray top-level file is not part of any skill.
|
|
253
|
+
if (!skills.has(name)) skills.set(name, []);
|
|
254
|
+
skills.get(name).push({ path: rest.join("/"), contents: entry.contents });
|
|
255
|
+
}
|
|
256
|
+
// A directory with no SKILL.md is not a skill either client will load, and
|
|
257
|
+
// writing it would leave an inert directory that reconciliation then has to
|
|
258
|
+
// reason about forever.
|
|
259
|
+
for (const [name, files] of skills) {
|
|
260
|
+
if (!files.some((file) => file.path === "SKILL.md")) skills.delete(name);
|
|
261
|
+
}
|
|
262
|
+
return skills;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Stable digest of one skill's full contents, used to skip unchanged writes. */
|
|
266
|
+
export function skillDigest(files) {
|
|
267
|
+
const hash = crypto.createHash("sha256");
|
|
268
|
+
for (const file of [...files].sort((left, right) => left.path.localeCompare(right.path))) {
|
|
269
|
+
hash.update(file.path);
|
|
270
|
+
hash.update("\0");
|
|
271
|
+
hash.update(file.contents);
|
|
272
|
+
hash.update("\0");
|
|
273
|
+
}
|
|
274
|
+
return hash.digest("hex");
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function readManagedManifest(manifestPath) {
|
|
278
|
+
let raw;
|
|
279
|
+
try {
|
|
280
|
+
const stat = fs.lstatSync(manifestPath);
|
|
281
|
+
if (stat.isSymbolicLink() || !stat.isFile()) return null;
|
|
282
|
+
raw = fs.readFileSync(manifestPath, "utf8");
|
|
283
|
+
} catch {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
try {
|
|
287
|
+
const manifest = JSON.parse(raw);
|
|
288
|
+
if (manifest?.version !== MANAGED_SKILL_MANIFEST_VERSION || !Array.isArray(manifest.skills)) return null;
|
|
289
|
+
// Only well-formed names survive; a corrupted entry must not become a
|
|
290
|
+
// deletion target on the next pass.
|
|
291
|
+
manifest.skills = manifest.skills.filter((name) => typeof name === "string" && SKILL_NAME_RE.test(name));
|
|
292
|
+
return manifest;
|
|
293
|
+
} catch {
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function removeSkillDirectory(directory) {
|
|
299
|
+
// Never follow a symlink out of the profile: remove the link itself.
|
|
300
|
+
let stat;
|
|
301
|
+
try {
|
|
302
|
+
stat = fs.lstatSync(directory);
|
|
303
|
+
} catch {
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (stat.isSymbolicLink()) fs.unlinkSync(directory);
|
|
307
|
+
else fs.rmSync(directory, { recursive: true, force: true });
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function writeSkill(skillsDir, name, files) {
|
|
311
|
+
const target = path.join(skillsDir, name);
|
|
312
|
+
// Replace wholesale rather than merging: a skill that dropped a file upstream
|
|
313
|
+
// must not keep a stale copy of it locally.
|
|
314
|
+
removeSkillDirectory(target);
|
|
315
|
+
for (const file of files) {
|
|
316
|
+
const filePath = path.join(target, file.path);
|
|
317
|
+
// Belt and braces over groupBundleSkills' checks: assert the resolved path
|
|
318
|
+
// is still inside the skill directory after the OS has had its say.
|
|
319
|
+
const resolved = path.resolve(filePath);
|
|
320
|
+
if (resolved !== path.resolve(target) && !resolved.startsWith(path.resolve(target) + path.sep)) {
|
|
321
|
+
throw new Error(`refusing to write skill file outside ${target}`);
|
|
322
|
+
}
|
|
323
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
324
|
+
fs.writeFileSync(filePath, file.contents);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Reconcile one profile's `skills/` directory against the fetched view.
|
|
330
|
+
*
|
|
331
|
+
* Reconciliation, not append (contract §3): a skill this CLI installed that has
|
|
332
|
+
* left the tenant's view is removed. The manifest is what bounds that — only
|
|
333
|
+
* names recorded in a previous managed sync are deletion candidates, so a skill
|
|
334
|
+
* the user wrote or installed by other means is never touched, even if it
|
|
335
|
+
* shares a name with something the registry once served.
|
|
336
|
+
*/
|
|
337
|
+
export function reconcileSkills(skillsDir, skills, { now = new Date() } = {}) {
|
|
338
|
+
fs.mkdirSync(skillsDir, { recursive: true });
|
|
339
|
+
const manifestPath = path.join(skillsDir, MANAGED_SKILL_MANIFEST);
|
|
340
|
+
const prior = readManagedManifest(manifestPath);
|
|
341
|
+
const priorDigests = prior?.digests && typeof prior.digests === "object" ? prior.digests : {};
|
|
342
|
+
const previouslyManaged = new Set(prior?.skills || []);
|
|
343
|
+
|
|
344
|
+
const installed = [];
|
|
345
|
+
const updated = [];
|
|
346
|
+
const digests = {};
|
|
347
|
+
for (const [name, files] of [...skills].sort(([left], [right]) => left.localeCompare(right))) {
|
|
348
|
+
const digest = skillDigest(files);
|
|
349
|
+
digests[name] = digest;
|
|
350
|
+
installed.push(name);
|
|
351
|
+
const unchanged = priorDigests[name] === digest
|
|
352
|
+
&& previouslyManaged.has(name)
|
|
353
|
+
&& fs.existsSync(path.join(skillsDir, name, "SKILL.md"));
|
|
354
|
+
if (unchanged) continue;
|
|
355
|
+
writeSkill(skillsDir, name, files);
|
|
356
|
+
updated.push(name);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const present = new Set(installed);
|
|
360
|
+
const removed = [];
|
|
361
|
+
for (const name of previouslyManaged) {
|
|
362
|
+
if (present.has(name)) continue;
|
|
363
|
+
removeSkillDirectory(path.join(skillsDir, name));
|
|
364
|
+
removed.push(name);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
fs.writeFileSync(manifestPath, `${JSON.stringify({
|
|
368
|
+
version: MANAGED_SKILL_MANIFEST_VERSION,
|
|
369
|
+
syncedAt: now.toISOString(),
|
|
370
|
+
skills: installed,
|
|
371
|
+
digests,
|
|
372
|
+
}, null, 2)}\n`);
|
|
373
|
+
|
|
374
|
+
return { installed, updated, removed: removed.sort() };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Resolve the tenant credential for a skills fetch, or null when the CLI is not
|
|
379
|
+
* authenticated. Callers degrade to a warning rather than failing: an
|
|
380
|
+
* unauthenticated machine legitimately has no tenant view to ask for.
|
|
381
|
+
*/
|
|
382
|
+
export function resolveSkillCredential(config) {
|
|
383
|
+
if (!config?.pat || !config?.tenantId) return null;
|
|
384
|
+
try {
|
|
385
|
+
return tenantCredential(config.pat, normalizeTenantId(config.tenantId));
|
|
386
|
+
} catch {
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
}
|
package/src/skills.js
CHANGED
|
@@ -21,10 +21,18 @@
|
|
|
21
21
|
|
|
22
22
|
import { spawn } from "node:child_process";
|
|
23
23
|
import fs from "node:fs";
|
|
24
|
+
import os from "node:os";
|
|
24
25
|
import path from "node:path";
|
|
25
26
|
|
|
26
27
|
import { resolveDefaultGateway, normalizeGatewayUrl } from "./config.js";
|
|
27
28
|
import { findNativeBinary, nativeCommandInvocation } from "./nativeProcess.js";
|
|
29
|
+
import {
|
|
30
|
+
fetchSkillBundle,
|
|
31
|
+
groupBundleSkills,
|
|
32
|
+
readZipEntries,
|
|
33
|
+
reconcileSkills,
|
|
34
|
+
SkillsUnavailableError,
|
|
35
|
+
} from "./skillBundle.js";
|
|
28
36
|
import { findReviewedVendorCliBinary } from "./vendorCliBinaries.js";
|
|
29
37
|
import { PINNED_VENDOR_CLI_VERSIONS } from "./vendorCliVersions.js";
|
|
30
38
|
|
|
@@ -269,6 +277,180 @@ export function skillSyncSpawnDirectory({
|
|
|
269
277
|
}
|
|
270
278
|
}
|
|
271
279
|
|
|
280
|
+
/**
|
|
281
|
+
* The `skills/` directory the client reads for a profile described by `env`.
|
|
282
|
+
*
|
|
283
|
+
* Both clients discover skills from a plain directory under their profile root
|
|
284
|
+
* — `<CLAUDE_CONFIG_DIR>/skills` and `<CODEX_HOME>/skills` — which is what lets
|
|
285
|
+
* the authenticated sync install a tenant's view without going through the
|
|
286
|
+
* vendor plugin machinery at all. An absent override means the native profile,
|
|
287
|
+
* exactly as a bare `claude` or `codex` would resolve it.
|
|
288
|
+
*/
|
|
289
|
+
export function profileSkillsDirectory(client, env = {}, { homeDir = os.homedir() } = {}) {
|
|
290
|
+
return path.join(profileRoot(client, env, { homeDir }), "skills");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* The profile root a client resolves for these env overrides.
|
|
295
|
+
*
|
|
296
|
+
* Callers that manage the NATIVE profile pass no override, meaning "wherever a
|
|
297
|
+
* bare `claude`/`codex` would look". That is a resolution, not an absence, and
|
|
298
|
+
* it has to be computed rather than left implicit: `runSkillCommand` merges
|
|
299
|
+
* `process.env` into every child, so an ambient `CLAUDE_CONFIG_DIR` — which is
|
|
300
|
+
* exactly what an `impel claude` session sets — would otherwise silently
|
|
301
|
+
* redirect a native-profile command at the isolated profile.
|
|
302
|
+
*/
|
|
303
|
+
export function profileRoot(client, env = {}, { homeDir = os.homedir() } = {}) {
|
|
304
|
+
if (client === "claude") return env.CLAUDE_CONFIG_DIR || path.join(homeDir, ".claude");
|
|
305
|
+
return env.CODEX_HOME || process.env.CODEX_HOME || path.join(homeDir, ".codex");
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Env overrides that pin a vendor command to exactly `root`.
|
|
310
|
+
*
|
|
311
|
+
* Without this, "no override" means "inherit", and the inherited value wins.
|
|
312
|
+
* Pinning makes the profile a command reads identical to the one this module
|
|
313
|
+
* inspected and wrote — otherwise cleanup can detect state in one profile and
|
|
314
|
+
* mutate another.
|
|
315
|
+
*/
|
|
316
|
+
function profilePinnedEnv(client, env, root) {
|
|
317
|
+
return client === "claude"
|
|
318
|
+
? { ...env, CLAUDE_CONFIG_DIR: root }
|
|
319
|
+
: { ...env, CODEX_HOME: root };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Install the tenant's skills straight into a managed profile (IMP-576).
|
|
324
|
+
*
|
|
325
|
+
* This is the authenticated path, and it is the only one that can see a
|
|
326
|
+
* tenant-scoped skill: the vendor plugin commands below fetch the gateway
|
|
327
|
+
* themselves with no credential, and the gateway answers an uncredentialed
|
|
328
|
+
* request with the globals-only view — a 200, not an error. So a profile
|
|
329
|
+
* populated only by those commands silently receives globals forever.
|
|
330
|
+
*
|
|
331
|
+
* Returns a result the caller reports, or `null` when there is no credential to
|
|
332
|
+
* sync with, which tells the caller to fall back to the unauthenticated plugin
|
|
333
|
+
* path rather than leaving the profile with nothing.
|
|
334
|
+
*/
|
|
335
|
+
/**
|
|
336
|
+
* Retire the globals-only plugin the pre-IMP-576 sync installed.
|
|
337
|
+
*
|
|
338
|
+
* Every machine that ran an older impel-cli has `bifrost-all-skills` installed
|
|
339
|
+
* as a vendor plugin, holding the unauthenticated (globals-only) view. Once the
|
|
340
|
+
* authenticated path writes those same skills into the profile directly, the
|
|
341
|
+
* plugin copy is both redundant and stale — the client would load
|
|
342
|
+
* `research-software` twice, from two different snapshots.
|
|
343
|
+
*
|
|
344
|
+
* Best-effort by design: this is cleanup of a superseded mechanism, and a
|
|
345
|
+
* machine with no vendor binary has no plugin to remove anyway. Failures are
|
|
346
|
+
* silent because the authenticated sync has already succeeded by this point,
|
|
347
|
+
* and a warning here would report a healthy profile as degraded.
|
|
348
|
+
*/
|
|
349
|
+
async function retireLegacySkillPlugin({ runCommand, commandBinary, env, client, homeDir, root }) {
|
|
350
|
+
if (!commandBinary) return;
|
|
351
|
+
// Pin the child to the profile this sync inspected. Detection reads `root`
|
|
352
|
+
// directly, so an inherited override would let us find the plugin in one
|
|
353
|
+
// profile and uninstall from another.
|
|
354
|
+
env = profilePinnedEnv(client, env, root);
|
|
355
|
+
// The exact ids to uninstall come from the profile's own registry, never from
|
|
356
|
+
// a guess. Claude registers the plugin as PLUGIN@MARKETPLACE and rejects the
|
|
357
|
+
// bare id with "not found in installed plugins" once it was installed from a
|
|
358
|
+
// named marketplace — the same trap buildSkillCommands already documents for
|
|
359
|
+
// `plugin update`. Reading the registry also means a profile carrying an
|
|
360
|
+
// older marketplace name is still cleaned up.
|
|
361
|
+
const plugins = installedLegacySkillPlugins(client, env, { homeDir, root });
|
|
362
|
+
if (plugins.length === 0) return;
|
|
363
|
+
try {
|
|
364
|
+
const help = await runCommand(commandBinary, ["plugin", "--help"], env);
|
|
365
|
+
if (help.missing || !help.ok) return;
|
|
366
|
+
for (const plugin of plugins) {
|
|
367
|
+
await runCommand(commandBinary, ["plugin", "remove", plugin], env);
|
|
368
|
+
}
|
|
369
|
+
} catch {
|
|
370
|
+
// The profile is already correct; a failed cleanup is not worth surfacing.
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Ids under which the pre-IMP-576 bundled plugin is currently installed.
|
|
376
|
+
*
|
|
377
|
+
* Claude records installs in `plugins/installed_plugins.json` keyed by
|
|
378
|
+
* `PLUGIN@MARKETPLACE`; Codex records marketplaces in config.toml and its
|
|
379
|
+
* `plugin remove` accepts the bare plugin name, so only Claude needs the
|
|
380
|
+
* qualified lookup. An unreadable or absent registry yields nothing to do.
|
|
381
|
+
*/
|
|
382
|
+
export function installedLegacySkillPlugins(client, env = {}, { homeDir = os.homedir(), root = null } = {}) {
|
|
383
|
+
if (client !== "claude") return [SKILL_PLUGIN_NAME];
|
|
384
|
+
const profile = root || profileRoot(client, env, { homeDir });
|
|
385
|
+
let registry;
|
|
386
|
+
try {
|
|
387
|
+
registry = JSON.parse(fs.readFileSync(path.join(profile, "plugins", "installed_plugins.json"), "utf8"));
|
|
388
|
+
} catch {
|
|
389
|
+
return [];
|
|
390
|
+
}
|
|
391
|
+
const plugins = registry?.plugins;
|
|
392
|
+
if (!plugins || typeof plugins !== "object" || Array.isArray(plugins)) return [];
|
|
393
|
+
return Object.keys(plugins).filter((id) => id === SKILL_PLUGIN_NAME || id.startsWith(`${SKILL_PLUGIN_NAME}@`));
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function syncTenantSkills({
|
|
397
|
+
client,
|
|
398
|
+
gatewayUrl,
|
|
399
|
+
credential,
|
|
400
|
+
env,
|
|
401
|
+
homeDir,
|
|
402
|
+
fetchImpl,
|
|
403
|
+
logger,
|
|
404
|
+
displayLabel,
|
|
405
|
+
runCommand,
|
|
406
|
+
commandBinary,
|
|
407
|
+
}) {
|
|
408
|
+
if (!credential) return null;
|
|
409
|
+
let bundle;
|
|
410
|
+
try {
|
|
411
|
+
bundle = await fetchSkillBundle({ gatewayUrl, credential, fetchImpl });
|
|
412
|
+
} catch (error) {
|
|
413
|
+
if (error instanceof SkillsUnavailableError) {
|
|
414
|
+
// Contract §2: an unavailable answer must leave the installed set exactly
|
|
415
|
+
// as it is. Returning here — before reconcileSkills is ever reached — is
|
|
416
|
+
// what guarantees that; there is no partial-write path to unwind.
|
|
417
|
+
logger.warn(
|
|
418
|
+
`impel: skill sync for ${displayLabel} could not be completed (${error.message}); `
|
|
419
|
+
+ "installed skills were left unchanged.",
|
|
420
|
+
);
|
|
421
|
+
return { client, label: displayLabel, synced: false, reason: "unavailable", changed: false };
|
|
422
|
+
}
|
|
423
|
+
throw error;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const skills = groupBundleSkills(readZipEntries(bundle));
|
|
427
|
+
// Resolved once and reused for the write, the detection, and the removal, so
|
|
428
|
+
// those three can never disagree about which profile they are operating on.
|
|
429
|
+
const resolvedHome = homeDir || os.homedir();
|
|
430
|
+
const root = profileRoot(client, env, { homeDir: resolvedHome });
|
|
431
|
+
const { installed, updated, removed } = reconcileSkills(path.join(root, "skills"), skills);
|
|
432
|
+
// Only after the tenant view is safely on disk: removing the legacy plugin
|
|
433
|
+
// first would leave a machine with no skills at all if the write then failed.
|
|
434
|
+
await retireLegacySkillPlugin({ runCommand, commandBinary, env, client, homeDir: resolvedHome, root });
|
|
435
|
+
const changes = [
|
|
436
|
+
...(updated.length ? [`${updated.length} updated`] : []),
|
|
437
|
+
...(removed.length ? [`${removed.length} removed`] : []),
|
|
438
|
+
];
|
|
439
|
+
logger.log(
|
|
440
|
+
`Skills: ${displayLabel} up to date (${installed.length} skill${installed.length === 1 ? "" : "s"}`
|
|
441
|
+
+ `${changes.length ? `, ${changes.join(", ")}` : ""}).`,
|
|
442
|
+
);
|
|
443
|
+
return {
|
|
444
|
+
client,
|
|
445
|
+
label: displayLabel,
|
|
446
|
+
synced: true,
|
|
447
|
+
reason: "tenant-scoped",
|
|
448
|
+
installed,
|
|
449
|
+
updated,
|
|
450
|
+
removed,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
272
454
|
const SKILL_COMMAND_TIMEOUT_MS = 120_000;
|
|
273
455
|
const SKILL_COMMAND_OUTPUT_LIMIT = 10 * 1024 * 1024;
|
|
274
456
|
|
|
@@ -495,6 +677,7 @@ export async function syncSkills({
|
|
|
495
677
|
gatewayUrl,
|
|
496
678
|
env = {},
|
|
497
679
|
label,
|
|
680
|
+
credential = null,
|
|
498
681
|
fetchImpl = fetch,
|
|
499
682
|
run = runSkillCommand,
|
|
500
683
|
logger = console,
|
|
@@ -515,24 +698,68 @@ export async function syncSkills({
|
|
|
515
698
|
return { client, label: displayLabel, skipped: true, reason: "disabled" };
|
|
516
699
|
}
|
|
517
700
|
|
|
701
|
+
// Callers that manage real profiles pass their home directory so Windows
|
|
702
|
+
// spawns happen from a git-safe cwd (see skillSyncSpawnDirectory).
|
|
703
|
+
const spawnDirectory = skillSyncSpawnDirectory({ platform, homeDir });
|
|
704
|
+
const runCommand = spawnDirectory
|
|
705
|
+
? (commandBin, commandArgs, commandEnv) => run(commandBin, commandArgs, commandEnv, { cwd: spawnDirectory })
|
|
706
|
+
: run;
|
|
707
|
+
|
|
518
708
|
let commandBinary = spec.bin;
|
|
709
|
+
let vendorBinaryMissing = false;
|
|
519
710
|
if (platform === "darwin" && run === runSkillCommand) {
|
|
520
711
|
commandBinary = findVendorBinary(client, { ...process.env, ...env }, platform);
|
|
521
|
-
|
|
712
|
+
vendorBinaryMissing = !commandBinary;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// The authenticated path first: it is the only one that can see this tenant's
|
|
716
|
+
// scoped skills. It needs no vendor binary — it writes the profile directly —
|
|
717
|
+
// so a version-drifted or entirely absent vendor CLI must not stop it. That is
|
|
718
|
+
// why the binary check above only records its result here instead of returning.
|
|
719
|
+
if (credential) {
|
|
720
|
+
try {
|
|
721
|
+
const result = await syncTenantSkills({
|
|
722
|
+
client,
|
|
723
|
+
gatewayUrl,
|
|
724
|
+
credential,
|
|
725
|
+
env,
|
|
726
|
+
homeDir,
|
|
727
|
+
fetchImpl,
|
|
728
|
+
logger,
|
|
729
|
+
displayLabel,
|
|
730
|
+
runCommand,
|
|
731
|
+
commandBinary,
|
|
732
|
+
});
|
|
733
|
+
if (result) return result;
|
|
734
|
+
} catch (error) {
|
|
735
|
+
// Reaching here means the bundle was fetched but could not be read or
|
|
736
|
+
// written — a malformed archive, or an unwritable profile. Fall through
|
|
737
|
+
// to the unauthenticated plugin path so the profile still gets globals
|
|
738
|
+
// rather than nothing, and say plainly that scoped skills are missing.
|
|
522
739
|
logger.warn(
|
|
523
|
-
`impel:
|
|
524
|
-
+
|
|
740
|
+
`impel: tenant-scoped skill sync for ${displayLabel} failed (${error?.message || error}); `
|
|
741
|
+
+ "falling back to the shared marketplace, which serves global skills only.",
|
|
525
742
|
);
|
|
526
|
-
return { client, label: displayLabel, skipped: true, reason: "binary-version-mismatch" };
|
|
527
743
|
}
|
|
744
|
+
} else {
|
|
745
|
+
// No credential is not a failure — an unauthenticated machine has no tenant
|
|
746
|
+
// view to ask for — but it is worth naming, because the gateway answers an
|
|
747
|
+
// uncredentialed fetch with a 200 and the narrow view, so the profile below
|
|
748
|
+
// will look successfully synced while missing every scoped skill.
|
|
749
|
+
logger.warn(
|
|
750
|
+
`impel: syncing global skills only for ${displayLabel} — no tenant credential is available. `
|
|
751
|
+
+ "Run `impel setup` to receive this tenant's scoped skills.",
|
|
752
|
+
);
|
|
528
753
|
}
|
|
529
754
|
|
|
530
|
-
//
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
755
|
+
// Only the unauthenticated plugin path below actually needs the vendor CLI.
|
|
756
|
+
if (vendorBinaryMissing) {
|
|
757
|
+
logger.warn(
|
|
758
|
+
`impel: skipping skill sync for ${displayLabel} — reviewed ${spec.label} `
|
|
759
|
+
+ `v${PINNED_VENDOR_CLI_VERSIONS[client]} is not installed; run \`impel update\`.`,
|
|
760
|
+
);
|
|
761
|
+
return { client, label: displayLabel, skipped: true, reason: "binary-version-mismatch" };
|
|
762
|
+
}
|
|
536
763
|
|
|
537
764
|
try {
|
|
538
765
|
// Confirm the binary and its `plugin` subcommand exist before doing anything.
|
package/src/tenants.js
CHANGED
|
@@ -25,6 +25,10 @@ const PRODUCT_ACCESS_VALUES = new Set([
|
|
|
25
25
|
const PROVIDER_SCOPE = Object.freeze({ claude: PAT_SCOPE_CLAUDE, codex: PAT_SCOPE_CODEX });
|
|
26
26
|
const TENANT_ID_RE = /^[A-Za-z0-9_.-]{1,128}$/u;
|
|
27
27
|
const PAT_SCOPE_RE = /^[a-z0-9][a-z0-9:._-]{0,63}$/u;
|
|
28
|
+
// Node surfaces a name-resolution failure through these `getaddrinfo` codes.
|
|
29
|
+
// `EAI_AGAIN` is a transient resolver error; `ENOTFOUND` is a negative answer
|
|
30
|
+
// the OS then caches, which is why an immediate retry re-reads the same result.
|
|
31
|
+
const DNS_FAILURE_CODES = new Set(["ENOTFOUND", "EAI_AGAIN"]);
|
|
28
32
|
|
|
29
33
|
export function normalizeProductAccess(value, { allowMissing = false } = {}) {
|
|
30
34
|
if ((value === undefined || value === null || value === "") && allowMissing) return null;
|
|
@@ -136,7 +140,15 @@ export async function fetchTenants(config, fetchImpl = fetchHttp1) {
|
|
|
136
140
|
signal: controller.signal,
|
|
137
141
|
});
|
|
138
142
|
} catch (error) {
|
|
139
|
-
|
|
143
|
+
// A resolver failure is not a control-plane outage, and the raw
|
|
144
|
+
// `getaddrinfo ENOTFOUND` reads like one. Name the machine's own DNS so the
|
|
145
|
+
// remedy is looked for locally. The `could not reach` prefix is preserved
|
|
146
|
+
// because `cmdConverge` matches on it to decide the failure is retryable.
|
|
147
|
+
const message = error?.name === "AbortError"
|
|
148
|
+
? "request timed out"
|
|
149
|
+
: DNS_FAILURE_CODES.has(error?.code)
|
|
150
|
+
? `${error.code}; this machine could not resolve the hostname. Check its DNS resolver or VPN`
|
|
151
|
+
: error?.message || error;
|
|
140
152
|
throw new Error(`could not reach ${appUrl}: ${redactSecretText(message)}`);
|
|
141
153
|
} finally {
|
|
142
154
|
clearTimeout(timeout);
|
package/src/windowsSetup.js
CHANGED
|
@@ -120,6 +120,10 @@ function installationFailure(result, thrown = null) {
|
|
|
120
120
|
export async function prepareWindowsClis({
|
|
121
121
|
gatewayUrl,
|
|
122
122
|
tenantId,
|
|
123
|
+
// Tenant credential for the authenticated skills fetch. Optional: an
|
|
124
|
+
// unauthenticated setup still provisions profiles, it just receives the
|
|
125
|
+
// gateway's globals-only view (see syncSkills).
|
|
126
|
+
credential = null,
|
|
123
127
|
skipInstall = false,
|
|
124
128
|
inspectOnly = false,
|
|
125
129
|
installTools = ["claude", "codex"],
|
|
@@ -222,6 +226,7 @@ export async function prepareWindowsClis({
|
|
|
222
226
|
await io.syncSkills({
|
|
223
227
|
client: "claude",
|
|
224
228
|
gatewayUrl,
|
|
229
|
+
credential,
|
|
225
230
|
env: { CLAUDE_CONFIG_DIR: claudeProfile.configDir },
|
|
226
231
|
label: "Impel isolated Claude (impel claude)",
|
|
227
232
|
// Lets Windows spawns use a git-safe cwd (see skillSyncSpawnDirectory).
|
|
@@ -232,6 +237,7 @@ export async function prepareWindowsClis({
|
|
|
232
237
|
await io.syncSkills({
|
|
233
238
|
client: "codex",
|
|
234
239
|
gatewayUrl,
|
|
240
|
+
credential,
|
|
235
241
|
env: { CODEX_HOME: codexProfile.codexHome },
|
|
236
242
|
label: "Impel isolated Codex (impel codex)",
|
|
237
243
|
homeDir: os.homedir(),
|