mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
fix(plugins): keep beta installs on gateway release (#127791)
Keep official plugins on the gateway release stream so beta gateways do not silently install stable artifacts. Resolve hosted catalog entries at the shared boundary, stop missing cohorts before hook fallback, and keep recovery notices visible. Co-authored-by: 許元豪 <146086744+edenfunf@users.noreply.github.com> Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
+7
-5
@@ -172,11 +172,13 @@ remains a supported fallback and direct-install path. OpenClaw-owned
|
||||
`@openclaw/*` plugin packages are published on npm again; see the current list
|
||||
on [npmjs.com/org/openclaw](https://www.npmjs.com/org/openclaw) or the
|
||||
[plugin inventory](/plugins/plugin-inventory). Stable installs use `latest`.
|
||||
Beta-channel installs and updates prefer the npm `beta` dist-tag when available,
|
||||
falling back to `latest`. On the extended-stable channel, official npm plugins
|
||||
with bare/default or `latest` intent resolve to the exact installed core
|
||||
version. Exact pins and explicit non-`latest` tags, third-party packages, and
|
||||
non-npm sources are not rewritten.
|
||||
Fresh beta-channel installs of official plugins require the npm `beta` dist-tag
|
||||
and stop if that release is missing; pass an explicit version to choose another
|
||||
release. Doctor, onboarding, and plugin-update recovery paths can fall back to
|
||||
the recorded or default selector with a visible warning. On the extended-stable
|
||||
channel, official npm plugins with bare/default or `latest` intent resolve to
|
||||
the exact installed core version. Exact pins and explicit non-`latest` tags,
|
||||
third-party packages, and non-npm sources are not rewritten.
|
||||
</Note>
|
||||
|
||||
<AccordionGroup>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// Explicit ClawHub chat selectors must fail closed without invoking another installer.
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { withTempHome } from "../../config/home-env.test-harness.js";
|
||||
import { createCommandWorkspaceHarness } from "./commands-filesystem.test-support.js";
|
||||
import { handlePluginsCommand } from "./commands-plugins.js";
|
||||
@@ -81,3 +84,52 @@ describe("chat plugin install explicit ClawHub selectors", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("chat plugin install release stream", () => {
|
||||
afterEach(async () => {
|
||||
installPluginFromNpmSpecMock.mockReset();
|
||||
persistPluginInstallMock.mockReset();
|
||||
await workspaceHarness.cleanupWorkspaces();
|
||||
});
|
||||
|
||||
it("installs the beta artifact for an official plugin on a beta gateway", async () => {
|
||||
const cfg = {
|
||||
commands: { text: true, plugins: true },
|
||||
plugins: { enabled: true },
|
||||
update: { channel: "beta" },
|
||||
} as OpenClawConfig;
|
||||
installPluginFromNpmSpecMock.mockResolvedValue({
|
||||
ok: true,
|
||||
pluginId: "brave",
|
||||
targetDir: "/tmp/brave",
|
||||
version: "1.0.0",
|
||||
extensions: ["index.js"],
|
||||
npmResolution: {
|
||||
name: "@openclaw/brave-plugin",
|
||||
version: "1.0.0",
|
||||
resolvedSpec: "@openclaw/brave-plugin@1.0.0",
|
||||
},
|
||||
});
|
||||
persistPluginInstallMock.mockResolvedValue({});
|
||||
|
||||
await withTempHome("openclaw-command-plugins-home-", async (home) => {
|
||||
await fs.writeFile(
|
||||
path.join(home, ".openclaw", "openclaw.json"),
|
||||
`${JSON.stringify(cfg, null, 2)}
|
||||
`,
|
||||
);
|
||||
const workspaceDir = await workspaceHarness.createWorkspace();
|
||||
const params = buildPluginsCommandParams({
|
||||
commandBodyNormalized: "/plugins install npm:@openclaw/brave-plugin",
|
||||
cfg,
|
||||
workspaceDir,
|
||||
gatewayClientScopes: ["operator.admin", "operator.write", "operator.pairing"],
|
||||
});
|
||||
|
||||
await handlePluginsCommand(params, true);
|
||||
|
||||
const call = installPluginFromNpmSpecMock.mock.calls[0]?.[0] as { spec?: string } | undefined;
|
||||
expect(call?.spec).toBe("@openclaw/brave-plugin@beta");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -647,6 +647,7 @@ vi.mock("./prompt.js", () => ({
|
||||
vi.mock("../plugins/install.js", () => ({
|
||||
PLUGIN_INSTALL_ERROR_CODE: {
|
||||
NPM_PACKAGE_NOT_FOUND: "npm_package_not_found",
|
||||
RELEASE_COHORT_UNAVAILABLE: "release_cohort_unavailable",
|
||||
SECURITY_SCAN_BLOCKED: "security_scan_blocked",
|
||||
SECURITY_SCAN_FAILED: "security_scan_failed",
|
||||
UNSUPPORTED_PLAIN_FILE_PLUGIN: "unsupported_plain_file_plugin",
|
||||
|
||||
@@ -1597,6 +1597,56 @@ describe("plugins cli install", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("installs the beta artifact for an official ClawHub plugin on a beta gateway", async () => {
|
||||
primeSuccessfulClawHubPluginInstall();
|
||||
parseClawHubPluginSpecMock.mockReturnValue({ name: "@openclaw/brave-plugin" });
|
||||
pluginCliConfigMock.mockReturnValue({
|
||||
...createEmptyPluginConfig(),
|
||||
update: { channel: "beta" },
|
||||
} as OpenClawConfig);
|
||||
|
||||
await runPluginsCommand(["plugins", "install", "clawhub:@openclaw/brave-plugin"]);
|
||||
|
||||
expect(clawHubInstallCall().spec).toBe("clawhub:@openclaw/brave-plugin@beta");
|
||||
});
|
||||
|
||||
it("does not install a stable ClawHub release when no beta release exists", async () => {
|
||||
primeSuccessfulClawHubPluginInstall();
|
||||
parseClawHubPluginSpecMock.mockReturnValue({ name: "@openclaw/brave-plugin" });
|
||||
pluginCliConfigMock.mockReturnValue({
|
||||
...createEmptyPluginConfig(),
|
||||
update: { channel: "beta" },
|
||||
} as OpenClawConfig);
|
||||
installPluginFromClawHubMock.mockResolvedValue({
|
||||
ok: false,
|
||||
error: "Version not found on ClawHub: @openclaw/brave-plugin@beta.",
|
||||
code: "version_not_found",
|
||||
});
|
||||
|
||||
await expect(
|
||||
runPluginsCommand(["plugins", "install", "clawhub:@openclaw/brave-plugin"]),
|
||||
).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(clawHubInstallCall(0).spec).toBe("clawhub:@openclaw/brave-plugin@beta");
|
||||
expect(installPluginFromClawHubMock).toHaveBeenCalledTimes(1);
|
||||
expect(configWriteMock).not.toHaveBeenCalled();
|
||||
expect(runtimeErrors.at(-1)).toContain(
|
||||
"No clawhub:@openclaw/brave-plugin@beta release is published for this gateway",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a non-official ClawHub install on the operator selector", async () => {
|
||||
primeSuccessfulClawHubPluginInstall();
|
||||
pluginCliConfigMock.mockReturnValue({
|
||||
...createEmptyPluginConfig(),
|
||||
update: { channel: "beta" },
|
||||
} as OpenClawConfig);
|
||||
|
||||
await runPluginsCommand(["plugins", "install", "clawhub:demo"]);
|
||||
|
||||
expect(clawHubInstallCall().spec).toBe("clawhub:demo");
|
||||
});
|
||||
|
||||
it("does not show the non-ClawHub warning for explicit ClawHub installs", async () => {
|
||||
primeSuccessfulClawHubPluginInstall();
|
||||
await runPluginsCommand(["plugins", "install", "clawhub:demo"]);
|
||||
@@ -1925,6 +1975,54 @@ describe("plugins cli install", () => {
|
||||
expect(configWriteMock).toHaveBeenCalledWith(enabledCfg);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "plugin id", arg: "brave" },
|
||||
{ label: "npm package name", arg: "@openclaw/brave-plugin" },
|
||||
])(
|
||||
"installs the beta artifact for an official plugin on a beta gateway by $label",
|
||||
async ({ arg }) => {
|
||||
primeSuccessfulPluginPersistence("brave");
|
||||
pluginCliConfigMock.mockReturnValue({
|
||||
...createEmptyPluginConfig(),
|
||||
update: { channel: "beta" },
|
||||
} as OpenClawConfig);
|
||||
findBundledPluginSourceMock.mockReturnValue(undefined);
|
||||
installPluginFromNpmSpecMock.mockResolvedValue(createNpmPluginInstallResult("brave"));
|
||||
|
||||
await runPluginsCommand(["plugins", "install", arg]);
|
||||
|
||||
expect(npmInstallCall().spec).toBe("@openclaw/brave-plugin@beta");
|
||||
expect(npmInstallCall().trustedSourceLinkedOfficialInstall).toBe(true);
|
||||
// The record keeps the operator's selector so a later channel change is
|
||||
// not silently pinned to the beta dist-tag.
|
||||
expect(persistedInstallRecord("brave").spec).toBe("@openclaw/brave-plugin");
|
||||
},
|
||||
);
|
||||
|
||||
it("does not install the stable release when no beta artifact is published", async () => {
|
||||
primeSuccessfulPluginPersistence("brave");
|
||||
pluginCliConfigMock.mockReturnValue({
|
||||
...createEmptyPluginConfig(),
|
||||
update: { channel: "beta" },
|
||||
} as OpenClawConfig);
|
||||
findBundledPluginSourceMock.mockReturnValue(undefined);
|
||||
installPluginFromNpmSpecMock.mockResolvedValue({
|
||||
ok: false,
|
||||
error: "npm error code ETARGET No matching version found for @openclaw/brave-plugin@beta",
|
||||
code: "npm_package_not_found",
|
||||
});
|
||||
|
||||
await expect(runPluginsCommand(["plugins", "install", "brave"])).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(npmInstallCall(0).spec).toBe("@openclaw/brave-plugin@beta");
|
||||
expect(installPluginFromNpmSpecMock).toHaveBeenCalledTimes(1);
|
||||
expect(installHooksFromNpmSpecMock).not.toHaveBeenCalled();
|
||||
expect(configWriteMock).not.toHaveBeenCalled();
|
||||
expect(runtimeErrors.at(-1)).toContain(
|
||||
"No @openclaw/brave-plugin@beta release is published for this gateway",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes third-party external catalog integrity with catalog install trust", async () => {
|
||||
primeSuccessfulPluginPersistence("wecom-openclaw-plugin");
|
||||
findBundledPluginSourceMock.mockReturnValue(undefined);
|
||||
|
||||
@@ -63,6 +63,7 @@ export function isTerminalPluginInstallFailure(code?: string): boolean {
|
||||
return (
|
||||
code === PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED ||
|
||||
code === PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_FAILED ||
|
||||
code === PLUGIN_INSTALL_ERROR_CODE.RELEASE_COHORT_UNAVAILABLE ||
|
||||
code === PLUGIN_INSTALL_ERROR_CODE.UNSUPPORTED_PLAIN_FILE_PLUGIN
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { OpenClawConfig } from "../../../config/types.openclaw.js";
|
||||
import type { PluginInstallRecord } from "../../../config/types.plugins.js";
|
||||
import { isOpenClawOrgNpmSpec, parseRegistryNpmSpec } from "../../../infra/npm-registry-spec.js";
|
||||
import type { UpdateChannel } from "../../../infra/update-channels.js";
|
||||
import { isUnavailableClawHubTarget } from "../../../plugins/clawhub-error-codes.js";
|
||||
import { buildClawHubPluginInstallRecordFields } from "../../../plugins/clawhub-install-records.js";
|
||||
import {
|
||||
CLAWHUB_INSTALL_ERROR_CODE,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
type ClawHubRiskAcknowledgementRequest,
|
||||
} from "../../../plugins/clawhub.js";
|
||||
import {
|
||||
installWithChannelFallback,
|
||||
resolveClawHubInstallSpecsForUpdateChannel,
|
||||
resolveNpmInstallSpecsForUpdateChannel,
|
||||
} from "../../../plugins/install-channel-specs.js";
|
||||
@@ -22,6 +24,7 @@ import {
|
||||
resolveDefaultPluginNpmDir,
|
||||
resolvePluginInstallDir,
|
||||
} from "../../../plugins/install-paths.js";
|
||||
import { isUnavailableNpmTarget } from "../../../plugins/install-types.js";
|
||||
import { installPluginFromNpmSpec } from "../../../plugins/install.js";
|
||||
import {
|
||||
buildNpmResolutionInstallFields,
|
||||
@@ -135,6 +138,9 @@ export async function installCandidate(params: {
|
||||
const extensionsDir = resolveDefaultPluginExtensionsDir(params.env);
|
||||
const changes: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
// A channel fallback changes which artifact the operator gets, so it must stay
|
||||
// visible on the success path instead of being dropped with the attempt log.
|
||||
const channelNotices: string[] = [];
|
||||
const clawhubSpecs = candidate.clawhubSpec
|
||||
? resolveClawHubInstallSpecsForUpdateChannel({
|
||||
spec: candidate.clawhubSpec,
|
||||
@@ -190,21 +196,34 @@ export async function installCandidate(params: {
|
||||
!(params.preferNpm && npmInstallSpec) &&
|
||||
candidate.defaultChoice !== "npm";
|
||||
if (shouldTryClawHub) {
|
||||
const clawhubInstallSpecLabel = sanitizeTerminalText(clawhubInstallSpec);
|
||||
const clawhubResult = await installPluginFromClawHub({
|
||||
spec: clawhubInstallSpec,
|
||||
config: params.config,
|
||||
extensionsDir,
|
||||
env: params.env,
|
||||
expectedPluginId: candidate.pluginId,
|
||||
mode: params.mode === "update" || existingClawHubPackagePath ? "update" : "install",
|
||||
logger: {
|
||||
terminalLinks: false,
|
||||
warn: (message) => warnings.push(stripAnsi(message)),
|
||||
let usedClawHubSpec = clawhubInstallSpec;
|
||||
const clawhubResult = await installWithChannelFallback({
|
||||
installSpec: clawhubInstallSpec,
|
||||
// An integrity pin identifies one exact artifact, so it outranks the channel.
|
||||
...(candidate.expectedIntegrity ? {} : { fallbackSpec: clawhubSpecs?.fallbackSpec }),
|
||||
install: async (spec) => {
|
||||
usedClawHubSpec = spec;
|
||||
return await installPluginFromClawHub({
|
||||
spec,
|
||||
config: params.config,
|
||||
extensionsDir,
|
||||
env: params.env,
|
||||
expectedPluginId: candidate.pluginId,
|
||||
mode: params.mode === "update" || existingClawHubPackagePath ? "update" : "install",
|
||||
logger: {
|
||||
terminalLinks: false,
|
||||
warn: (message) => warnings.push(stripAnsi(message)),
|
||||
},
|
||||
...(params.acknowledgeClawHubRisk ? { acknowledgeClawHubRisk: true } : {}),
|
||||
...(params.onClawHubRisk ? { onClawHubRisk: params.onClawHubRisk } : {}),
|
||||
});
|
||||
},
|
||||
isRetryable: (attempt) => !attempt.ok && isUnavailableClawHubTarget(attempt),
|
||||
onFallback: (message) => {
|
||||
channelNotices.push(message);
|
||||
},
|
||||
...(params.acknowledgeClawHubRisk ? { acknowledgeClawHubRisk: true } : {}),
|
||||
...(params.onClawHubRisk ? { onClawHubRisk: params.onClawHubRisk } : {}),
|
||||
});
|
||||
const clawhubInstallSpecLabel = sanitizeTerminalText(usedClawHubSpec);
|
||||
if (clawhubResult.ok) {
|
||||
const pluginId = clawhubResult.pluginId;
|
||||
return {
|
||||
@@ -224,7 +243,7 @@ export async function installCandidate(params: {
|
||||
repairReason: params.repairReason,
|
||||
}),
|
||||
],
|
||||
notices: warnings,
|
||||
notices: [...channelNotices, ...warnings],
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
@@ -265,21 +284,9 @@ export async function installCandidate(params: {
|
||||
};
|
||||
}
|
||||
const npmInstallMode = params.mode === "update" || existingNpmPackagePath ? "update" : "install";
|
||||
let result = await installPluginFromNpmSpec({
|
||||
spec: npmInstallSpec,
|
||||
config: params.config,
|
||||
extensionsDir,
|
||||
npmDir,
|
||||
expectedPluginId: candidate.pluginId,
|
||||
expectedIntegrity: candidate.expectedIntegrity,
|
||||
...(candidate.trustedSourceLinkedOfficialInstall
|
||||
? { trustedSourceLinkedOfficialInstall: true }
|
||||
: {}),
|
||||
mode: npmInstallMode,
|
||||
});
|
||||
if (!result.ok && npmInstallMode === "install" && isPluginAlreadyExistsError(result.error)) {
|
||||
result = await installPluginFromNpmSpec({
|
||||
spec: npmInstallSpec,
|
||||
const runNpmInstall = async (spec: string, mode: "install" | "update") =>
|
||||
await installPluginFromNpmSpec({
|
||||
spec,
|
||||
config: params.config,
|
||||
extensionsDir,
|
||||
npmDir,
|
||||
@@ -288,9 +295,24 @@ export async function installCandidate(params: {
|
||||
...(candidate.trustedSourceLinkedOfficialInstall
|
||||
? { trustedSourceLinkedOfficialInstall: true }
|
||||
: {}),
|
||||
mode: "update",
|
||||
mode,
|
||||
});
|
||||
}
|
||||
const installOnce = async (spec: string) => {
|
||||
const attempt = await runNpmInstall(spec, npmInstallMode);
|
||||
return !attempt.ok && npmInstallMode === "install" && isPluginAlreadyExistsError(attempt.error)
|
||||
? await runNpmInstall(spec, "update")
|
||||
: attempt;
|
||||
};
|
||||
const result = await installWithChannelFallback({
|
||||
installSpec: npmInstallSpec,
|
||||
// An integrity pin identifies one exact artifact, so it outranks the channel.
|
||||
...(candidate.expectedIntegrity ? {} : { fallbackSpec: npmSpecs?.fallbackSpec }),
|
||||
install: installOnce,
|
||||
isRetryable: (attempt) => !attempt.ok && isUnavailableNpmTarget(attempt),
|
||||
onFallback: (message) => {
|
||||
channelNotices.push(message);
|
||||
},
|
||||
});
|
||||
if (!result.ok) {
|
||||
return {
|
||||
records: params.records,
|
||||
@@ -298,6 +320,7 @@ export async function installCandidate(params: {
|
||||
notices: [],
|
||||
warnings: [
|
||||
...warnings,
|
||||
...channelNotices,
|
||||
`Failed to install missing configured plugin "${candidate.pluginId}" from ${npmInstallSpec}: ${result.error}`,
|
||||
],
|
||||
failedPluginId: candidate.pluginId,
|
||||
@@ -328,7 +351,7 @@ export async function installCandidate(params: {
|
||||
repairReason: params.repairReason,
|
||||
}),
|
||||
],
|
||||
notices: [],
|
||||
notices: channelNotices,
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -708,6 +708,106 @@ describe("repairMissingConfiguredPluginInstalls", () => {
|
||||
expect(result.warnings).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("falls back to the operator selector when no beta release is published", async () => {
|
||||
const cfg = {
|
||||
security: { installPolicy: { enabled: true } },
|
||||
update: { channel: "beta" },
|
||||
channels: {
|
||||
matrix: { enabled: true, homeserver: "https://matrix.example.org" },
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
mocks.listChannelPluginCatalogEntries.mockReturnValue([
|
||||
{
|
||||
id: "matrix",
|
||||
pluginId: "matrix",
|
||||
meta: { label: "Matrix" },
|
||||
install: { npmSpec: "@openclaw/plugin-matrix" },
|
||||
trustedSourceLinkedOfficialInstall: true,
|
||||
},
|
||||
]);
|
||||
mocks.installPluginFromNpmSpec.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
code: "npm_package_not_found",
|
||||
error: "Package not found on npm: @openclaw/plugin-matrix@beta.",
|
||||
});
|
||||
|
||||
const { repairMissingConfiguredPluginInstalls } =
|
||||
await import("./missing-configured-plugin-install.js");
|
||||
const result = await repairMissingConfiguredPluginInstalls({ cfg, env: {} });
|
||||
|
||||
expect(mockCallArg(mocks.installPluginFromNpmSpec, 0)).toMatchObject({
|
||||
spec: "@openclaw/plugin-matrix@beta",
|
||||
});
|
||||
expect(mockCallArg(mocks.installPluginFromNpmSpec, 1)).toMatchObject({
|
||||
spec: "@openclaw/plugin-matrix",
|
||||
});
|
||||
expect(result.notices).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("No @openclaw/plugin-matrix@beta release is published"),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("retries the operator ClawHub selector when no beta release is published", async () => {
|
||||
const cfg = {
|
||||
security: { installPolicy: { enabled: true } },
|
||||
update: { channel: "beta" },
|
||||
channels: {
|
||||
matrix: { enabled: true, homeserver: "https://matrix.example.org" },
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
mocks.installPluginFromClawHub
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
code: "version_not_found",
|
||||
error: "Version not found on ClawHub: @openclaw/plugin-matrix@beta.",
|
||||
})
|
||||
.mockResolvedValue({
|
||||
ok: true,
|
||||
pluginId: "matrix",
|
||||
targetDir: "/tmp/openclaw-plugins/matrix",
|
||||
version: "1.2.3",
|
||||
clawhub: {
|
||||
source: "clawhub",
|
||||
clawhubUrl: "https://clawhub.ai",
|
||||
clawhubPackage: "@openclaw/plugin-matrix",
|
||||
clawhubFamily: "code-plugin",
|
||||
clawhubChannel: "official",
|
||||
version: "1.2.3",
|
||||
integrity: "sha256-clawhub",
|
||||
resolvedAt: "2026-05-01T00:00:00.000Z",
|
||||
clawpackSha256: "0".repeat(64),
|
||||
clawpackSpecVersion: 1,
|
||||
clawpackManifestSha256: "1".repeat(64),
|
||||
clawpackSize: 1234,
|
||||
},
|
||||
});
|
||||
mocks.listChannelPluginCatalogEntries.mockReturnValue([
|
||||
{
|
||||
id: "matrix",
|
||||
pluginId: "matrix",
|
||||
meta: { label: "Matrix" },
|
||||
install: { clawhubSpec: "clawhub:@openclaw/plugin-matrix" },
|
||||
},
|
||||
]);
|
||||
|
||||
const { repairMissingConfiguredPluginInstalls } =
|
||||
await import("./missing-configured-plugin-install.js");
|
||||
const result = await repairMissingConfiguredPluginInstalls({ cfg, env: {} });
|
||||
|
||||
expect(mockCallArg(mocks.installPluginFromClawHub, 0)).toMatchObject({
|
||||
spec: "clawhub:@openclaw/plugin-matrix@beta",
|
||||
});
|
||||
expect(mockCallArg(mocks.installPluginFromClawHub, 1)).toMatchObject({
|
||||
spec: "clawhub:@openclaw/plugin-matrix",
|
||||
});
|
||||
expect(result.notices).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("No clawhub:@openclaw/plugin-matrix@beta release is published"),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses an explicit ClawHub install spec before npm", async () => {
|
||||
const cfg = {
|
||||
security: { installPolicy: { enabled: true } },
|
||||
|
||||
@@ -482,6 +482,107 @@ describe("ensureOnboardingPluginInstalled", () => {
|
||||
expect(npmCall.expectedPluginId).toBe("codex");
|
||||
});
|
||||
|
||||
it("falls back to the operator selector when no beta release is published", async () => {
|
||||
installPluginFromNpmSpec
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
code: "npm_package_not_found",
|
||||
error: "Package not found on npm: @openclaw/codex@beta.",
|
||||
})
|
||||
.mockResolvedValue({
|
||||
ok: true,
|
||||
pluginId: "codex",
|
||||
targetDir: "/tmp/openclaw/extensions/codex",
|
||||
version: "2026.7.1",
|
||||
npmResolution: {
|
||||
name: "@openclaw/codex",
|
||||
version: "2026.7.1",
|
||||
resolvedSpec: "@openclaw/codex@2026.7.1",
|
||||
},
|
||||
});
|
||||
const note = vi.fn();
|
||||
|
||||
await ensureOnboardingPluginInstalled({
|
||||
cfg: { update: { channel: "beta" } },
|
||||
entry: {
|
||||
pluginId: "codex",
|
||||
label: "Codex",
|
||||
install: { npmSpec: "@openclaw/codex" },
|
||||
trustedSourceLinkedOfficialInstall: true,
|
||||
},
|
||||
prompter: {
|
||||
select: vi.fn(async () => "npm"),
|
||||
note,
|
||||
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
|
||||
} as never,
|
||||
runtime: { log: vi.fn() } as never,
|
||||
});
|
||||
|
||||
const calls = installPluginFromNpmSpec.mock.calls as [NpmSpecInstallCall][];
|
||||
expect(calls[0]?.[0]?.spec).toBe("@openclaw/codex@beta");
|
||||
expect(calls[1]?.[0]?.spec).toBe("@openclaw/codex");
|
||||
expect(
|
||||
note.mock.calls.some(([message]) =>
|
||||
String(message).includes("No @openclaw/codex@beta release is published"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("retries the operator ClawHub selector when no beta release is published", async () => {
|
||||
installPluginFromClawHub
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
code: "version_not_found",
|
||||
error: "Version not found on ClawHub: demo-plugin@beta.",
|
||||
})
|
||||
.mockResolvedValue({
|
||||
ok: true,
|
||||
pluginId: "demo-plugin",
|
||||
targetDir: "/tmp/demo-plugin",
|
||||
version: "2026.5.2",
|
||||
packageName: "demo-plugin",
|
||||
clawhub: {
|
||||
source: "clawhub",
|
||||
clawhubUrl: "https://clawhub.ai",
|
||||
clawhubPackage: "demo-plugin",
|
||||
clawhubFamily: "code-plugin",
|
||||
clawhubChannel: "official",
|
||||
version: "2026.5.2",
|
||||
integrity: "sha256-clawpack",
|
||||
resolvedAt: "2026-05-02T00:00:00.000Z",
|
||||
clawpackSha256: "a".repeat(64),
|
||||
clawpackSpecVersion: 1,
|
||||
clawpackManifestSha256: "b".repeat(64),
|
||||
clawpackSize: 4096,
|
||||
},
|
||||
});
|
||||
const note = vi.fn();
|
||||
|
||||
await ensureOnboardingPluginInstalled({
|
||||
cfg: { update: { channel: "beta" } } as never,
|
||||
entry: {
|
||||
pluginId: "demo-plugin",
|
||||
label: "Demo Provider",
|
||||
install: { clawhubSpec: "clawhub:demo-plugin", defaultChoice: "clawhub" },
|
||||
},
|
||||
prompter: {
|
||||
select: vi.fn(async () => "clawhub"),
|
||||
note,
|
||||
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
|
||||
} as never,
|
||||
runtime: {} as never,
|
||||
});
|
||||
|
||||
const calls = installPluginFromClawHub.mock.calls as [{ spec?: string }][];
|
||||
expect(calls[0]?.[0]?.spec).toBe("clawhub:demo-plugin@beta");
|
||||
expect(calls[1]?.[0]?.spec).toBe("clawhub:demo-plugin");
|
||||
expect(
|
||||
note.mock.calls.some(([message]) =>
|
||||
String(message).includes("No clawhub:demo-plugin@beta release is published"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("installs and records ClawHub provider plugins with source facts", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
security: {
|
||||
|
||||
@@ -21,13 +21,17 @@ import {
|
||||
findBundledPluginSourceInMap,
|
||||
resolveBundledPluginSources,
|
||||
} from "../plugins/bundled-sources.js";
|
||||
import { CLAWHUB_INSTALL_ERROR_CODE } from "../plugins/clawhub-error-codes.js";
|
||||
import {
|
||||
CLAWHUB_INSTALL_ERROR_CODE,
|
||||
isUnavailableClawHubTarget,
|
||||
} from "../plugins/clawhub-error-codes.js";
|
||||
import { buildClawHubPluginInstallRecordFields } from "../plugins/clawhub-install-records.js";
|
||||
import {
|
||||
enableExplicitlySelectedPluginInConfig,
|
||||
type PluginEnableResult,
|
||||
} from "../plugins/enable.js";
|
||||
import {
|
||||
installWithChannelFallback,
|
||||
resolveClawHubInstallSpecsForUpdateChannel,
|
||||
resolveNpmInstallSpecsForUpdateChannel,
|
||||
} from "../plugins/install-channel-specs.js";
|
||||
@@ -38,6 +42,7 @@ import {
|
||||
ALLOW_PLUGIN_INSTALL_OVERRIDES_ENV,
|
||||
} from "../plugins/install-overrides.js";
|
||||
import { resolveDefaultPluginExtensionsDir } from "../plugins/install-paths.js";
|
||||
import { isUnavailableNpmTarget } from "../plugins/install-types.js";
|
||||
import {
|
||||
installPluginFromNpmSpec,
|
||||
installPluginFromNpmPackArchive,
|
||||
@@ -1196,12 +1201,25 @@ export async function ensureOnboardingPluginInstalled(params: {
|
||||
let shouldTryNpm = choice === "npm";
|
||||
if (choice === "clawhub" && clawhubInstallSpec) {
|
||||
await params.beforePersistentEffect?.();
|
||||
const result = await installPluginFromClawHubSpecWithProgress({
|
||||
cfg: next,
|
||||
entry,
|
||||
clawhubSpec: clawhubInstallSpec,
|
||||
prompter,
|
||||
runtime,
|
||||
let usedClawHubSpec = clawhubInstallSpec;
|
||||
const result = await installWithChannelFallback({
|
||||
installSpec: clawhubInstallSpec,
|
||||
// An integrity pin identifies one exact artifact, so it outranks the channel.
|
||||
...(entry.install.expectedIntegrity ? {} : { fallbackSpec: clawhubSpecs?.fallbackSpec }),
|
||||
install: async (spec) => {
|
||||
usedClawHubSpec = spec;
|
||||
return await installPluginFromClawHubSpecWithProgress({
|
||||
cfg: next,
|
||||
entry,
|
||||
clawhubSpec: spec,
|
||||
prompter,
|
||||
runtime,
|
||||
});
|
||||
},
|
||||
isRetryable: (attempt) => !attempt.ok && isUnavailableClawHubTarget(attempt),
|
||||
onFallback: async (message) => {
|
||||
await prompter.note(message, t("wizard.plugins.installTitle"));
|
||||
},
|
||||
});
|
||||
if (result.ok) {
|
||||
return await finishOnboardingPluginInstall({
|
||||
@@ -1219,7 +1237,7 @@ export async function ensureOnboardingPluginInstalled(params: {
|
||||
});
|
||||
}
|
||||
|
||||
await notePluginInstallFailure(prompter, clawhubInstallSpec, result.error);
|
||||
await notePluginInstallFailure(prompter, usedClawHubSpec, result.error);
|
||||
const errorDetail = formatInstallErrorDetail(result.error);
|
||||
|
||||
if (!npmInstallSpec || !shouldFallbackClawHubToNpm({ result, npmSpec: npmInstallSpec })) {
|
||||
@@ -1255,12 +1273,25 @@ export async function ensureOnboardingPluginInstalled(params: {
|
||||
}
|
||||
|
||||
await params.beforePersistentEffect?.();
|
||||
const installOutcome = await installPluginFromNpmSpecWithProgress({
|
||||
cfg: next,
|
||||
entry,
|
||||
npmSpec: npmInstallSpec,
|
||||
prompter,
|
||||
runtime,
|
||||
const installOutcome = await installWithChannelFallback({
|
||||
installSpec: npmInstallSpec,
|
||||
// An integrity pin identifies one exact artifact, so it outranks the channel.
|
||||
...(entry.install.expectedIntegrity ? {} : { fallbackSpec: npmSpecs?.fallbackSpec }),
|
||||
install: async (spec) =>
|
||||
await installPluginFromNpmSpecWithProgress({
|
||||
cfg: next,
|
||||
entry,
|
||||
npmSpec: spec,
|
||||
prompter,
|
||||
runtime,
|
||||
}),
|
||||
isRetryable: (outcome) =>
|
||||
outcome.status === "completed" &&
|
||||
!outcome.result.ok &&
|
||||
isUnavailableNpmTarget(outcome.result),
|
||||
onFallback: async (message) => {
|
||||
await prompter.note(message, t("wizard.plugins.installTitle"));
|
||||
},
|
||||
});
|
||||
|
||||
if (installOutcome.status === "timed_out") {
|
||||
|
||||
@@ -23,3 +23,15 @@ export const CLAWHUB_INSTALL_ERROR_CODE = {
|
||||
/** Union of stable ClawHub install error code values. */
|
||||
export type ClawHubInstallErrorCode =
|
||||
(typeof CLAWHUB_INSTALL_ERROR_CODE)[keyof typeof CLAWHUB_INSTALL_ERROR_CODE];
|
||||
|
||||
/**
|
||||
* Detects ClawHub failures caused by a target that is not published, as opposed
|
||||
* to a broken install. Channel-aware installs use this to widen the selector
|
||||
* instead of failing when the requested release has no artifact.
|
||||
*/
|
||||
export function isUnavailableClawHubTarget(result: { ok: false; code?: string }): boolean {
|
||||
return (
|
||||
result.code === CLAWHUB_INSTALL_ERROR_CODE.PACKAGE_NOT_FOUND ||
|
||||
result.code === CLAWHUB_INSTALL_ERROR_CODE.VERSION_NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
@@ -120,3 +120,26 @@ export function resolveClawHubInstallSpecsForUpdateChannel(params: {
|
||||
fallbackLabel: betaSpec,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs the channel-resolved spec, widening to the operator's own selector
|
||||
* when that release has no published artifact. The degrade is announced rather
|
||||
* than silent, because it changes which build the operator ends up running.
|
||||
*/
|
||||
export async function installWithChannelFallback<T>(params: {
|
||||
installSpec: string;
|
||||
fallbackSpec?: string;
|
||||
install: (spec: string) => Promise<T>;
|
||||
isRetryable: (result: T) => boolean;
|
||||
onFallback: (message: string) => void | Promise<void>;
|
||||
}): Promise<T> {
|
||||
const result = await params.install(params.installSpec);
|
||||
const { fallbackSpec } = params;
|
||||
if (!fallbackSpec || fallbackSpec === params.installSpec || !params.isRetryable(result)) {
|
||||
return result;
|
||||
}
|
||||
await params.onFallback(
|
||||
`No ${params.installSpec} release is published; installing ${fallbackSpec} instead.`,
|
||||
);
|
||||
return await params.install(fallbackSpec);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ export const PLUGIN_INSTALL_ERROR_CODE = {
|
||||
INVALID_OPENCLAW_EXTENSIONS: "invalid_openclaw_extensions",
|
||||
NPM_METADATA_FAILURE: "npm_metadata_failure",
|
||||
NPM_PACKAGE_NOT_FOUND: "npm_package_not_found",
|
||||
RELEASE_COHORT_UNAVAILABLE: "release_cohort_unavailable",
|
||||
PLUGIN_ID_MISMATCH: "plugin_id_mismatch",
|
||||
SECURITY_SCAN_BLOCKED: "security_scan_blocked",
|
||||
SECURITY_SCAN_FAILED: "security_scan_failed",
|
||||
@@ -89,3 +90,19 @@ export type PackageInstallCommonParams = InstallSafetyOverrides & {
|
||||
export type InternalPackageInstallCommonParams = PackageInstallCommonParams & {
|
||||
onEffectiveMode?: (mode: "install" | "update") => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Detects npm failures caused by a target that is not published, as opposed to a
|
||||
* broken install. Channel-aware installs use this to widen the selector instead
|
||||
* of failing when the requested release has no artifact.
|
||||
*/
|
||||
export function isUnavailableNpmTarget(result: {
|
||||
ok: false;
|
||||
code?: string;
|
||||
error: string;
|
||||
}): boolean {
|
||||
return (
|
||||
result.code === PLUGIN_INSTALL_ERROR_CODE.NPM_PACKAGE_NOT_FOUND ||
|
||||
/\b(ETARGET|notarget)\b|No matching version found|dist-tag|tag .*not found/i.test(result.error)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -611,13 +611,13 @@ describe("plugin management service", () => {
|
||||
expect(mocks.persistInstall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not pin a runtime id when the hosted entry only exposes its package name", async () => {
|
||||
it("resolves hosted-only beta installs without pinning the package name as a runtime id", async () => {
|
||||
const installRecord = {
|
||||
source: "clawhub",
|
||||
spec: "clawhub:@openclaw/bluebubbles",
|
||||
installPath: "/tmp/extensions/bluebubbles",
|
||||
};
|
||||
mocks.readConfig.mockResolvedValue(configSnapshot());
|
||||
mocks.readConfig.mockResolvedValue(configSnapshot({ update: { channel: "beta" } }));
|
||||
// Package identity without a declared runtime id must not become an expectedPluginId pin.
|
||||
mockHostedOfficialCatalog([
|
||||
{
|
||||
@@ -651,6 +651,14 @@ describe("plugin management service", () => {
|
||||
expect(mocks.clawhubInstall).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({ expectedPluginId: expect.anything() }),
|
||||
);
|
||||
expect(mocks.clawhubInstall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ spec: "clawhub:@openclaw/bluebubbles@beta" }),
|
||||
);
|
||||
expect(mocks.persistInstall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
install: expect.objectContaining({ spec: "clawhub:@openclaw/bluebubbles" }),
|
||||
}),
|
||||
);
|
||||
expect(result.plugin.id).toBe("bluebubbles");
|
||||
});
|
||||
|
||||
|
||||
@@ -18,10 +18,12 @@ import { parseClawHubPluginSpec } from "../infra/clawhub-spec.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { buildNpmResolutionFields, type NpmSpecResolution } from "../infra/install-source-utils.js";
|
||||
import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js";
|
||||
import { normalizeUpdateChannel, resolveRegistryUpdateChannel } from "../infra/update-channels.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import { installBundledPluginSource } from "./bundled-install.js";
|
||||
import type { BundledPluginSource } from "./bundled-sources.js";
|
||||
import { CLAWHUB_INSTALL_ERROR_CODE } from "./clawhub-error-codes.js";
|
||||
import { CLAWHUB_INSTALL_ERROR_CODE, isUnavailableClawHubTarget } from "./clawhub-error-codes.js";
|
||||
import {
|
||||
buildClawHubPluginInstallRecordFields,
|
||||
type ClawHubPluginInstallRecordFields,
|
||||
@@ -33,6 +35,10 @@ import {
|
||||
} from "./control-plane-workspace.js";
|
||||
import { enableExplicitlySelectedPluginInConfig } from "./enable.js";
|
||||
import { installPluginFromGitSpec } from "./git-install.js";
|
||||
import {
|
||||
resolveClawHubInstallSpecsForUpdateChannel,
|
||||
resolveNpmInstallSpecsForUpdateChannel,
|
||||
} from "./install-channel-specs.js";
|
||||
import { resolveDefaultPluginExtensionsDir } from "./install-paths.js";
|
||||
import {
|
||||
resolveInstallConfigMutationPreflights,
|
||||
@@ -43,7 +49,11 @@ import {
|
||||
import { commitPluginInstallRecordsWithConfig } from "./install-record-commit.js";
|
||||
import type { InstallSafetyOverrides } from "./install-security-scan.js";
|
||||
import type { InstallPolicyWarningDetails } from "./install-security-scan.types.js";
|
||||
import type { PluginInstallLogger } from "./install-types.js";
|
||||
import {
|
||||
isUnavailableNpmTarget,
|
||||
PLUGIN_INSTALL_ERROR_CODE,
|
||||
type PluginInstallLogger,
|
||||
} from "./install-types.js";
|
||||
import {
|
||||
installPluginFromNpmPackArchive,
|
||||
installPluginFromNpmSpec,
|
||||
@@ -64,6 +74,7 @@ import {
|
||||
resolveTrustedSourceLinkedOfficialNpmSpec,
|
||||
} from "./official-external-install-records.js";
|
||||
import {
|
||||
getOfficialExternalPluginCatalogEntryForPackage,
|
||||
getOfficialExternalPluginCatalogManifest,
|
||||
listOfficialExternalPluginCatalogEntries,
|
||||
loadConfiguredHostedOfficialExternalPluginCatalogEntries,
|
||||
@@ -158,9 +169,13 @@ export type ManagedPluginSourceInstallRequest =
|
||||
| {
|
||||
source: "clawhub";
|
||||
spec: string;
|
||||
/** Spec recorded for the install; keeps user intent when `spec` is channel-resolved. */
|
||||
recordSpec?: string;
|
||||
mode?: "install" | "update";
|
||||
expectedPluginId?: string;
|
||||
expectedIntegrity?: string;
|
||||
/** Host-validated official catalog provenance for release-cohort resolution. */
|
||||
trustedSourceLinkedOfficialInstall?: true;
|
||||
acknowledgeClawHubRisk?: boolean;
|
||||
onClawHubRisk?: NonNullable<Parameters<typeof installPluginFromClawHub>[0]["onClawHubRisk"]>;
|
||||
}
|
||||
@@ -173,6 +188,8 @@ export type ManagedPluginSourceInstallRequest =
|
||||
| {
|
||||
source: "official";
|
||||
spec: string;
|
||||
/** Spec recorded for the install; keeps user intent when `spec` is channel-resolved. */
|
||||
recordSpec?: string;
|
||||
pluginId: string;
|
||||
expectedIntegrity?: string;
|
||||
mode: "install" | "update";
|
||||
@@ -181,6 +198,8 @@ export type ManagedPluginSourceInstallRequest =
|
||||
| {
|
||||
source: "npm";
|
||||
spec: string;
|
||||
/** Spec recorded for the install; keeps user intent when `spec` is channel-resolved. */
|
||||
recordSpec?: string;
|
||||
mode: "install" | "update";
|
||||
pin?: boolean;
|
||||
expectedPluginId?: string;
|
||||
@@ -1214,8 +1233,59 @@ async function persistManagedSourceInstall(params: {
|
||||
}
|
||||
}
|
||||
|
||||
/** Execute one resolved plugin source through the shared install-and-persist pipeline. */
|
||||
export async function installManagedPluginSource(params: {
|
||||
/**
|
||||
* Official plugin installs target the release stream the gateway is running,
|
||||
* the same target `openclaw doctor --fix` and `openclaw plugins update`
|
||||
* already resolve. Resolving here keeps every managed install path — CLI,
|
||||
* chat command, and any future caller — on one answer instead of letting the
|
||||
* registry default land a plugin the gateway then reports as drifted.
|
||||
*
|
||||
* Only the beta stream resolves here. The version-bound stable tracks key off a
|
||||
* per-plugin `versionBoundToOpenClaw` descriptor that a managed install request
|
||||
* does not carry, and answering for them from this boundary would pin plugins
|
||||
* the policy never opted in.
|
||||
*/
|
||||
function resolveOfficialManagedInstallSpec(params: {
|
||||
request: Extract<ManagedPluginSourceInstallRequest, { source: "official" | "npm" | "clawhub" }>;
|
||||
config: OpenClawConfig;
|
||||
}): string | null {
|
||||
const { request } = params;
|
||||
const trustedSourceLinkedOfficialInstall =
|
||||
request.source !== "official" && request.trustedSourceLinkedOfficialInstall === true;
|
||||
if (request.source === "npm" && !trustedSourceLinkedOfficialInstall) {
|
||||
return null;
|
||||
}
|
||||
// An integrity pin identifies one exact artifact, so it outranks the channel.
|
||||
if (request.expectedIntegrity) {
|
||||
return null;
|
||||
}
|
||||
const packageName =
|
||||
request.source === "clawhub"
|
||||
? parseClawHubPluginSpec(request.spec)?.name
|
||||
: parseRegistryNpmSpec(request.spec)?.name;
|
||||
if (
|
||||
!packageName ||
|
||||
(request.source !== "official" &&
|
||||
!trustedSourceLinkedOfficialInstall &&
|
||||
!getOfficialExternalPluginCatalogEntryForPackage(packageName))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const updateChannel = resolveRegistryUpdateChannel({
|
||||
configChannel: normalizeUpdateChannel(params.config.update?.channel),
|
||||
currentVersion: VERSION,
|
||||
});
|
||||
if (updateChannel !== "beta") {
|
||||
return null;
|
||||
}
|
||||
const specs =
|
||||
request.source === "clawhub"
|
||||
? resolveClawHubInstallSpecsForUpdateChannel({ spec: request.spec, updateChannel })
|
||||
: resolveNpmInstallSpecsForUpdateChannel({ spec: request.spec, updateChannel });
|
||||
return specs.installSpec === request.spec ? null : specs.installSpec;
|
||||
}
|
||||
|
||||
type ManagedPluginSourceInstallParams = {
|
||||
request: ManagedPluginSourceInstallRequest;
|
||||
snapshot: ConfigSnapshotForInstallPersist;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
@@ -1224,7 +1294,55 @@ export async function installManagedPluginSource(params: {
|
||||
runtime?: RuntimeEnv;
|
||||
invalidateRuntimeCache?: boolean;
|
||||
cleanupOnPersistenceFailure?: boolean;
|
||||
}): Promise<ManagedPluginSourceInstallResult> {
|
||||
};
|
||||
|
||||
/**
|
||||
* Installs official plugins from the release stream the gateway runs. When that
|
||||
* stream has no published artifact the install reports it instead of widening
|
||||
* back to the registry default: widening would resolve `latest` and land exactly
|
||||
* the cross-release plugin this boundary exists to prevent, and a fresh install
|
||||
* has nothing to preserve, so failing with the reason costs the operator only a
|
||||
* retry with an explicit version.
|
||||
*/
|
||||
export async function installManagedPluginSource(
|
||||
params: ManagedPluginSourceInstallParams,
|
||||
): Promise<ManagedPluginSourceInstallResult> {
|
||||
const { request } = params;
|
||||
if (request.source !== "official" && request.source !== "npm" && request.source !== "clawhub") {
|
||||
return await installResolvedManagedPluginSource(params);
|
||||
}
|
||||
const installSpec = resolveOfficialManagedInstallSpec({
|
||||
request,
|
||||
config: params.snapshot.config,
|
||||
});
|
||||
if (!installSpec) {
|
||||
return await installResolvedManagedPluginSource(params);
|
||||
}
|
||||
const result = await installResolvedManagedPluginSource({
|
||||
...params,
|
||||
request: { ...request, spec: installSpec, recordSpec: request.recordSpec ?? request.spec },
|
||||
});
|
||||
if (result.ok) {
|
||||
return result;
|
||||
}
|
||||
const isUnavailableTarget =
|
||||
request.source === "clawhub"
|
||||
? isUnavailableClawHubTarget(result)
|
||||
: isUnavailableNpmTarget(result);
|
||||
if (!isUnavailableTarget) {
|
||||
return result;
|
||||
}
|
||||
return {
|
||||
...result,
|
||||
code: PLUGIN_INSTALL_ERROR_CODE.RELEASE_COHORT_UNAVAILABLE,
|
||||
error: `No ${installSpec} release is published for this gateway. Installing ${request.spec} would resolve a build from another release; pass an explicit version to install one anyway.`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Execute one resolved plugin source through the shared install-and-persist pipeline. */
|
||||
async function installResolvedManagedPluginSource(
|
||||
params: ManagedPluginSourceInstallParams,
|
||||
): Promise<ManagedPluginSourceInstallResult> {
|
||||
const { request } = params;
|
||||
const env = params.env ?? process.env;
|
||||
const extensionsDir = resolveDefaultPluginExtensionsDir(env);
|
||||
@@ -1400,7 +1518,7 @@ export async function installManagedPluginSource(params: {
|
||||
expectedPluginId: request.expectedPluginId,
|
||||
install: (result) => ({
|
||||
...buildClawHubPluginInstallRecordFields(result.clawhub),
|
||||
spec: request.spec,
|
||||
spec: request.recordSpec ?? request.spec,
|
||||
installPath: result.targetDir,
|
||||
}),
|
||||
},
|
||||
@@ -1424,7 +1542,9 @@ export async function installManagedPluginSource(params: {
|
||||
expectedPluginId,
|
||||
install: (result) => ({
|
||||
source: "npm",
|
||||
spec: request.pin ? (result.npmResolution?.resolvedSpec ?? request.spec) : request.spec,
|
||||
spec: request.pin
|
||||
? (result.npmResolution?.resolvedSpec ?? request.spec)
|
||||
: (request.recordSpec ?? request.spec),
|
||||
installPath: result.targetDir,
|
||||
...(result.version ? { version: result.version } : {}),
|
||||
...buildNpmResolutionFields(result.npmResolution),
|
||||
@@ -1461,6 +1581,7 @@ function resolveManagedClawHubInstallRequest(params: {
|
||||
return {
|
||||
source: "clawhub",
|
||||
spec: buildClawHubSpec(packageName, version),
|
||||
...(official ? { trustedSourceLinkedOfficialInstall: true } : {}),
|
||||
...(expectedPluginId ? { expectedPluginId } : {}),
|
||||
...(expectedIntegrity ? { expectedIntegrity } : {}),
|
||||
...(params.request.acknowledgeClawHubRisk ? { acknowledgeClawHubRisk: true } : {}),
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
import type { UpdateChannel } from "../infra/update-channels.js";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import { resolveCompatibilityHostVersion } from "../version.js";
|
||||
import { CLAWHUB_INSTALL_ERROR_CODE } from "./clawhub-error-codes.js";
|
||||
import { isUnavailableClawHubTarget } from "./clawhub-error-codes.js";
|
||||
import {
|
||||
getExternalizedBundledPluginClawHubSpec,
|
||||
getExternalizedBundledPluginNpmSpec,
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
resolveClawHubInstallSpecsForUpdateChannel,
|
||||
resolveNpmInstallSpecsForUpdateChannel,
|
||||
} from "./install-channel-specs.js";
|
||||
import { PLUGIN_INSTALL_ERROR_CODE } from "./install.js";
|
||||
import { isUnavailableNpmTarget } from "./install-types.js";
|
||||
import { checkMinHostVersion } from "./min-host-version.js";
|
||||
import * as officialInstallRecords from "./official-external-install-records.js";
|
||||
import {
|
||||
@@ -417,23 +417,13 @@ export function isBundledVersionNewer(bundledVersion: string, installedVersion:
|
||||
}
|
||||
|
||||
function shouldFallbackClawHubToDefault(result: { ok: false; code?: string }): boolean {
|
||||
return (
|
||||
result.code === CLAWHUB_INSTALL_ERROR_CODE.PACKAGE_NOT_FOUND ||
|
||||
result.code === CLAWHUB_INSTALL_ERROR_CODE.VERSION_NOT_FOUND
|
||||
);
|
||||
return isUnavailableClawHubTarget(result);
|
||||
}
|
||||
|
||||
export function shouldFallbackBetaClawHubUpdate(result: { ok: false; code?: string }): boolean {
|
||||
return shouldFallbackClawHubToDefault(result);
|
||||
}
|
||||
|
||||
function isUnavailableNpmTarget(result: { ok: false; code?: string; error: string }): boolean {
|
||||
return (
|
||||
result.code === PLUGIN_INSTALL_ERROR_CODE.NPM_PACKAGE_NOT_FOUND ||
|
||||
/\b(ETARGET|notarget)\b|No matching version found|dist-tag|tag .*not found/i.test(result.error)
|
||||
);
|
||||
}
|
||||
|
||||
export function describeBetaNpmFallback(params: {
|
||||
pluginId: string;
|
||||
betaSpec: string | undefined;
|
||||
|
||||
Reference in New Issue
Block a user