mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
refactor: mechanical dedup batch (protocol types, update-cli bridge, talk fallback) (#114432)
* refactor(onboarding): remove search setup barrel * refactor(plugins): reuse detected package manifest * test(update): replace global helper bridges * refactor(talk): remove dead legacy response fallback * refactor(protocol): derive root types from schema The maintainer approved broadening the additive schema-backed type surface without a protocol version bump. * fix(plugins): drop stale package path import * test(protocol): type dynamic registry lookups * docs(talk): explain canonical response boundary * refactor(talk): enforce canonical response input * fix(protocol): keep root type exports registry-free * refactor(update): expose helpers through test facades * fix(protocol): keep result types on leaf schema modules
This commit is contained in:
committed by
GitHub
parent
0f879595d4
commit
c6b2ec28c8
@@ -1,5 +1,5 @@
|
|||||||
// Gateway Protocol tests cover index behavior.
|
// Gateway Protocol tests cover index behavior.
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, expectTypeOf, it } from "vitest";
|
||||||
import { TALK_TEST_PROVIDER_ID } from "../../../src/test-utils/talk-test-provider.js";
|
import { TALK_TEST_PROVIDER_ID } from "../../../src/test-utils/talk-test-provider.js";
|
||||||
import * as protocol from "./index.js";
|
import * as protocol from "./index.js";
|
||||||
import {
|
import {
|
||||||
@@ -40,7 +40,14 @@ import {
|
|||||||
validateWakeParams,
|
validateWakeParams,
|
||||||
type ValidationError,
|
type ValidationError,
|
||||||
} from "./index.js";
|
} from "./index.js";
|
||||||
|
import type {
|
||||||
|
ConfigSchemaLookupParams,
|
||||||
|
ModelsListParams,
|
||||||
|
SessionsCatalogListParams,
|
||||||
|
TalkEvent,
|
||||||
|
} from "./index.js";
|
||||||
import * as schemaExportRegistry from "./schema-export-registry.js";
|
import * as schemaExportRegistry from "./schema-export-registry.js";
|
||||||
|
import type * as Schema from "./schema.js";
|
||||||
import * as validatorRegistry from "./validator-registry.js";
|
import * as validatorRegistry from "./validator-registry.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -68,10 +75,17 @@ describe("protocol export registries", () => {
|
|||||||
it("re-exports every runtime registry symbol by identity", () => {
|
it("re-exports every runtime registry symbol by identity", () => {
|
||||||
for (const registry of [schemaExportRegistry, validatorRegistry]) {
|
for (const registry of [schemaExportRegistry, validatorRegistry]) {
|
||||||
for (const [name, value] of Object.entries(registry)) {
|
for (const [name, value] of Object.entries(registry)) {
|
||||||
expect(protocol[name as keyof typeof protocol], name).toBe(value);
|
expect((protocol as Record<string, unknown>)[name], name).toBe(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("re-exports schema-backed protocol types from the package root", () => {
|
||||||
|
expectTypeOf<ConfigSchemaLookupParams>().toEqualTypeOf<Schema.ConfigSchemaLookupParams>();
|
||||||
|
expectTypeOf<ModelsListParams>().toEqualTypeOf<Schema.ModelsListParams>();
|
||||||
|
expectTypeOf<SessionsCatalogListParams>().toEqualTypeOf<Schema.SessionsCatalogListParams>();
|
||||||
|
expectTypeOf<TalkEvent>().toEqualTypeOf<Schema.TalkEvent>();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("lazy protocol validators", () => {
|
describe("lazy protocol validators", () => {
|
||||||
|
|||||||
@@ -34,4 +34,19 @@ export * from "./migration-api.js";
|
|||||||
export type * from "./public-session-catalog.js";
|
export type * from "./public-session-catalog.js";
|
||||||
export * from "./validator-registry.js";
|
export * from "./validator-registry.js";
|
||||||
export * from "./schema-export-registry.js";
|
export * from "./schema-export-registry.js";
|
||||||
export type * from "./type-export-registry.js";
|
export type * from "./schema-types.js";
|
||||||
|
|
||||||
|
// Local structural result keeps this package independent of core session types.
|
||||||
|
export type SessionsPatchResult = {
|
||||||
|
ok: true;
|
||||||
|
path: string;
|
||||||
|
key: string;
|
||||||
|
entry: Record<string, unknown>;
|
||||||
|
resolved?: {
|
||||||
|
modelProvider?: string;
|
||||||
|
model?: string;
|
||||||
|
agentRuntime?: import("./schema/agents-models-skills.js").GatewayAgentRuntime;
|
||||||
|
thinkingLevel?: string;
|
||||||
|
thinkingLevels?: Array<{ id: string; label: string }>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Type-only schema barrel for the package root.
|
||||||
|
*
|
||||||
|
* Keep this module list aligned with `schema.ts`, except for the runtime-only
|
||||||
|
* `protocol-schemas` registry. Routing root type exports through that registry
|
||||||
|
* retains the full registry in downstream declaration bundles.
|
||||||
|
*/
|
||||||
|
export type * from "./schema/primitives.js";
|
||||||
|
export type * from "./schema/agent.js";
|
||||||
|
export type * from "./schema/agents-models-skills.js";
|
||||||
|
export type * from "./schema/agents-workspace.js";
|
||||||
|
export type * from "./schema/artifacts.js";
|
||||||
|
export type * from "./schema/approvals.js";
|
||||||
|
export type * from "./schema/audit-activity.js";
|
||||||
|
export type * from "./schema/audit.js";
|
||||||
|
export type * from "./schema/board.js";
|
||||||
|
export type * from "./schema/users.js";
|
||||||
|
export type * from "./schema/channels.js";
|
||||||
|
export type * from "./schema/channel-pairing.js";
|
||||||
|
export type * from "./schema/talk-marks.js";
|
||||||
|
export type * from "./schema/commands.js";
|
||||||
|
export type * from "./schema/config.js";
|
||||||
|
export type * from "./schema/openclaw.js";
|
||||||
|
export type * from "./schema/cron.js";
|
||||||
|
export type * from "./schema/cron.types.js";
|
||||||
|
export type * from "./schema/error-codes.js";
|
||||||
|
export type * from "./schema/environments.js";
|
||||||
|
export type * from "./schema/exec-approvals.js";
|
||||||
|
export type * from "./schema/devices.js";
|
||||||
|
export type * from "./schema/frames.js";
|
||||||
|
export type * from "./schema/fs.js";
|
||||||
|
export type * from "./schema/gateway-suspend.js";
|
||||||
|
export type * from "./schema/logs-chat.js";
|
||||||
|
export type * from "./schema/migrations.js";
|
||||||
|
export type * from "./schema/nodes.js";
|
||||||
|
export type * from "./schema/push.js";
|
||||||
|
export type * from "./schema/questions.js";
|
||||||
|
export type * from "./schema/secrets.js";
|
||||||
|
export type * from "./schema/session-placement.js";
|
||||||
|
export type * from "./schema/session-discussion.js";
|
||||||
|
export type * from "./schema/sessions.js";
|
||||||
|
export type * from "./schema/sessions-sharing.js";
|
||||||
|
export type * from "./schema/sessions-suggestions.js";
|
||||||
|
export type * from "./schema/sessions-catalog.js";
|
||||||
|
export type * from "./schema/skill-history.js";
|
||||||
|
export type * from "./schema/snapshot.js";
|
||||||
|
export type * from "./schema/system-info.js";
|
||||||
|
export type * from "./schema/system-event.js";
|
||||||
|
export type * from "./schema/task-suggestions.js";
|
||||||
|
export type * from "./schema/tasks.js";
|
||||||
|
export type * from "./schema/terminal.js";
|
||||||
|
export type * from "./schema/ui-command.js";
|
||||||
|
export type * from "./schema/plugin-approvals.js";
|
||||||
|
export type * from "./schema/plugins.js";
|
||||||
|
export type * from "./schema/wizard.js";
|
||||||
|
export type * from "./schema/worker-admission.js";
|
||||||
|
export type * from "./schema/worker-inference.js";
|
||||||
|
export type * from "./schema/worktrees.js";
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
// Gateway Protocol tests cover talk config.contract behavior.
|
// Gateway Protocol tests cover talk config.contract behavior.
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { buildTalkConfigResponse } from "../../../src/config/talk.js";
|
import { buildTalkConfigResponse, normalizeTalkSection } from "../../../src/config/talk.js";
|
||||||
|
import type { TalkConfig } from "../../../src/config/types.gateway.js";
|
||||||
import { validateTalkConfigResult } from "./index.js";
|
import { validateTalkConfigResult } from "./index.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,7 +49,8 @@ const fixtures = JSON.parse(fs.readFileSync(fixturePath, "utf-8")) as TalkConfig
|
|||||||
describe("talk.config contract fixtures", () => {
|
describe("talk.config contract fixtures", () => {
|
||||||
for (const fixture of fixtures.selectionCases) {
|
for (const fixture of fixtures.selectionCases) {
|
||||||
it(fixture.id, () => {
|
it(fixture.id, () => {
|
||||||
const payload = { config: { talk: buildTalkConfigResponse(fixture.talk) } };
|
const normalizedTalk = normalizeTalkSection(fixture.talk as TalkConfig);
|
||||||
|
const payload = { config: { talk: buildTalkConfigResponse(normalizedTalk) } };
|
||||||
if (fixture.payloadValid) {
|
if (fixture.payloadValid) {
|
||||||
expect(validateTalkConfigResult(payload)).toBe(true);
|
expect(validateTalkConfigResult(payload)).toBe(true);
|
||||||
} else {
|
} else {
|
||||||
@@ -80,7 +82,8 @@ describe("talk.config contract fixtures", () => {
|
|||||||
|
|
||||||
for (const fixture of fixtures.timeoutCases) {
|
for (const fixture of fixtures.timeoutCases) {
|
||||||
it(`timeout:${fixture.id}`, () => {
|
it(`timeout:${fixture.id}`, () => {
|
||||||
const payload = buildTalkConfigResponse(fixture.talk);
|
const normalizedTalk = normalizeTalkSection(fixture.talk as TalkConfig);
|
||||||
|
const payload = buildTalkConfigResponse(normalizedTalk);
|
||||||
expect(payload?.silenceTimeoutMs ?? fixture.fallback).toBe(fixture.expectedTimeoutMs);
|
expect(payload?.silenceTimeoutMs ?? fixture.fallback).toBe(fixture.expectedTimeoutMs);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,544 +0,0 @@
|
|||||||
// Type exports mirror the schema exports for downstream TypeScript consumers.
|
|
||||||
export type {
|
|
||||||
GatewayFrame,
|
|
||||||
ConnectParams,
|
|
||||||
WorkerAdmissionFailureReason,
|
|
||||||
WorkerAdmissionHandshake,
|
|
||||||
WorkerAdmissionResponseFrame,
|
|
||||||
WorkerConnectParams,
|
|
||||||
WorkerConnectRequestFrame,
|
|
||||||
WorkerErrorShape,
|
|
||||||
WorkerHeartbeatParams,
|
|
||||||
WorkerHeartbeatRequestFrame,
|
|
||||||
WorkerHeartbeatResult,
|
|
||||||
WorkerHeartbeatResponseFrame,
|
|
||||||
WorkerHelloOk,
|
|
||||||
WorkerLiveEvent,
|
|
||||||
WorkerLiveEventErrorDetails,
|
|
||||||
WorkerLiveEventErrorShape,
|
|
||||||
WorkerLiveEventParams,
|
|
||||||
WorkerLiveEventRequestFrame,
|
|
||||||
WorkerLiveEventResponseFrame,
|
|
||||||
WorkerLiveEventResult,
|
|
||||||
WorkerProtocolCloseReason,
|
|
||||||
WorkerTranscriptCommitErrorReason,
|
|
||||||
WorkerTranscriptCommitErrorShape,
|
|
||||||
WorkerTranscriptCommitParams,
|
|
||||||
WorkerTranscriptCommitRequestFrame,
|
|
||||||
WorkerTranscriptCommitResponseFrame,
|
|
||||||
WorkerTranscriptCommitResult,
|
|
||||||
WorkerTranscriptMessage,
|
|
||||||
GatewaySuspendTaskBlocker,
|
|
||||||
GatewaySuspendBlocker,
|
|
||||||
GatewaySuspendPrepareParams,
|
|
||||||
GatewaySuspendPrepareResult,
|
|
||||||
GatewaySuspendStatusParams,
|
|
||||||
GatewaySuspendStatusResult,
|
|
||||||
GatewaySuspendResumeParams,
|
|
||||||
GatewaySuspendResumeResult,
|
|
||||||
HelloOk,
|
|
||||||
RequestFrame,
|
|
||||||
ResponseFrame,
|
|
||||||
EventFrame,
|
|
||||||
PresenceEntry,
|
|
||||||
Snapshot,
|
|
||||||
ErrorShape,
|
|
||||||
StateVersion,
|
|
||||||
AgentEvent,
|
|
||||||
ConversationListItem,
|
|
||||||
ConversationListParams,
|
|
||||||
ConversationListResult,
|
|
||||||
ConversationSendParams,
|
|
||||||
ConversationSendResult,
|
|
||||||
ConversationTurnCancelParams,
|
|
||||||
ConversationTurnCancelResult,
|
|
||||||
ConversationTurnParams,
|
|
||||||
ConversationTurnReply,
|
|
||||||
ConversationTurnResult,
|
|
||||||
AgentIdentityParams,
|
|
||||||
AgentIdentityResult,
|
|
||||||
AgentWaitParams,
|
|
||||||
ChatEvent,
|
|
||||||
ChatRunStartupPhase,
|
|
||||||
ChatStatusEvent,
|
|
||||||
TickEvent,
|
|
||||||
ShutdownEvent,
|
|
||||||
WakeParams,
|
|
||||||
NodePairListParams,
|
|
||||||
NodePairApproveParams,
|
|
||||||
DevicePairListParams,
|
|
||||||
DevicePairApproveParams,
|
|
||||||
DevicePairRejectParams,
|
|
||||||
DevicePairSetupCodeParams,
|
|
||||||
DevicePairSetupCodeResult,
|
|
||||||
DevicePairRenameParams,
|
|
||||||
ConfigGetParams,
|
|
||||||
ConfigSetParams,
|
|
||||||
ConfigApplyParams,
|
|
||||||
ConfigPatchParams,
|
|
||||||
ConfigSchemaParams,
|
|
||||||
ConfigSchemaResponse,
|
|
||||||
SystemAgentChatParams,
|
|
||||||
SystemAgentChatQuestion,
|
|
||||||
SystemAgentChatResult,
|
|
||||||
SystemAgentChatHistoryParams,
|
|
||||||
SystemAgentChatHistoryResult,
|
|
||||||
SystemAgentChatHistoryTurn,
|
|
||||||
SystemChangeEntry,
|
|
||||||
SystemChangeKind,
|
|
||||||
SystemChangeSource,
|
|
||||||
SystemChangesListParams,
|
|
||||||
SystemChangesListResult,
|
|
||||||
SystemAgentSetupDetectParams,
|
|
||||||
SystemAgentSetupDetectResult,
|
|
||||||
SystemAgentSetupVerifyParams,
|
|
||||||
SystemAgentSetupVerifyResult,
|
|
||||||
SystemAgentSetupActivateParams,
|
|
||||||
SystemAgentSetupActivateResult,
|
|
||||||
SystemAgentSetupAuthStartParams,
|
|
||||||
SystemAgentSetupAuthStartResult,
|
|
||||||
WizardStartParams,
|
|
||||||
WizardNextParams,
|
|
||||||
WizardCancelParams,
|
|
||||||
WizardStatusParams,
|
|
||||||
WizardStep,
|
|
||||||
WizardNextResult,
|
|
||||||
WizardStartResult,
|
|
||||||
WizardStatusResult,
|
|
||||||
TalkCatalogParams,
|
|
||||||
TalkCatalogResult,
|
|
||||||
TalkClientCreateParams,
|
|
||||||
TalkClientCreateResult,
|
|
||||||
TalkClientCloseParams,
|
|
||||||
TalkClientMutationResult,
|
|
||||||
TalkClientSteerParams,
|
|
||||||
TalkAgentControlResult,
|
|
||||||
TalkClientToolCallParams,
|
|
||||||
TalkClientToolCallResult,
|
|
||||||
TalkClientTranscriptParams,
|
|
||||||
TalkConfigParams,
|
|
||||||
TalkConfigResult,
|
|
||||||
TalkSessionAppendAudioParams,
|
|
||||||
TalkSessionAcknowledgeMarkParams,
|
|
||||||
TalkSessionCancelOutputParams,
|
|
||||||
TalkSessionCancelTurnParams,
|
|
||||||
TalkSessionCreateParams,
|
|
||||||
TalkSessionCreateResult,
|
|
||||||
TalkSessionJoinParams,
|
|
||||||
TalkSessionJoinResult,
|
|
||||||
TalkSessionTurnParams,
|
|
||||||
TalkSessionTurnResult,
|
|
||||||
TalkSessionSteerParams,
|
|
||||||
TalkSessionSubmitToolResultParams,
|
|
||||||
TalkSessionCloseParams,
|
|
||||||
TalkSessionOkResult,
|
|
||||||
TalkSpeakParams,
|
|
||||||
TalkSpeakResult,
|
|
||||||
TtsSpeakParams,
|
|
||||||
TtsSpeakResult,
|
|
||||||
TalkModeParams,
|
|
||||||
ChannelsStatusParams,
|
|
||||||
ChannelsStatusResult,
|
|
||||||
ChannelsPairingListParams,
|
|
||||||
ChannelsPairingListResult,
|
|
||||||
ChannelsPairingApproveParams,
|
|
||||||
ChannelsPairingApproveResult,
|
|
||||||
ChannelsPairingDismissParams,
|
|
||||||
ChannelsPairingDismissResult,
|
|
||||||
ChannelsPairingAccount,
|
|
||||||
ChannelsPairingRequest,
|
|
||||||
ChannelsStartParams,
|
|
||||||
ChannelsStopParams,
|
|
||||||
ChannelsLogoutParams,
|
|
||||||
WebLoginStartParams,
|
|
||||||
WebLoginWaitParams,
|
|
||||||
AgentKind,
|
|
||||||
AgentSummary,
|
|
||||||
AgentsFileEntry,
|
|
||||||
AgentsCreateParams,
|
|
||||||
AgentsCreateResult,
|
|
||||||
AgentsUpdateParams,
|
|
||||||
AgentsUpdateResult,
|
|
||||||
AgentsDeleteParams,
|
|
||||||
AgentsDeleteResult,
|
|
||||||
AgentsFilesListParams,
|
|
||||||
AgentsFilesListResult,
|
|
||||||
AgentsFilesGetParams,
|
|
||||||
AgentsFilesGetResult,
|
|
||||||
AgentsFilesSetParams,
|
|
||||||
AgentsFilesSetResult,
|
|
||||||
AgentsWorkspaceEntry,
|
|
||||||
AgentsWorkspaceFile,
|
|
||||||
AgentsWorkspaceListParams,
|
|
||||||
AgentsWorkspaceListResult,
|
|
||||||
AgentsWorkspaceGetParams,
|
|
||||||
AgentsWorkspaceGetResult,
|
|
||||||
SessionFileBrowserEntry,
|
|
||||||
SessionFileBrowserResult,
|
|
||||||
SessionFileEntry,
|
|
||||||
SessionFileKind,
|
|
||||||
SessionFileRelevance,
|
|
||||||
SessionsFilesListParams,
|
|
||||||
SessionsFilesListResult,
|
|
||||||
SessionsFilesGetParams,
|
|
||||||
SessionsFilesGetResult,
|
|
||||||
SessionsFilesSetParams,
|
|
||||||
SessionsFilesSetResult,
|
|
||||||
SessionsFilesRevealParams,
|
|
||||||
SessionsFilesRevealResult,
|
|
||||||
SessionDiffFile,
|
|
||||||
SessionDiffFileStatus,
|
|
||||||
SessionsDiffParams,
|
|
||||||
SessionsDiffResult,
|
|
||||||
SessionDiscussionState,
|
|
||||||
SessionDiscussionInfo,
|
|
||||||
SessionDiscussionInfoParams,
|
|
||||||
SessionDiscussionInfoResult,
|
|
||||||
SessionDiscussionOpenParams,
|
|
||||||
SessionDiscussionOpenResult,
|
|
||||||
ArtifactSummary,
|
|
||||||
ArtifactsListParams,
|
|
||||||
ArtifactsListResult,
|
|
||||||
ArtifactsGetParams,
|
|
||||||
ArtifactsGetResult,
|
|
||||||
ArtifactsDownloadParams,
|
|
||||||
ArtifactsDownloadResult,
|
|
||||||
AgentsListParams,
|
|
||||||
AgentsListResult,
|
|
||||||
ChatMetadataParams,
|
|
||||||
ChatToolTitlesParams,
|
|
||||||
ChatToolTitlesResult,
|
|
||||||
CommandsListParams,
|
|
||||||
CommandsListResult,
|
|
||||||
CommandEntry,
|
|
||||||
PluginCatalogEntry,
|
|
||||||
PluginsInstallParams,
|
|
||||||
PluginsInstallResult,
|
|
||||||
PluginsListParams,
|
|
||||||
PluginsListResult,
|
|
||||||
PluginsRefreshParams,
|
|
||||||
PluginsRefreshResult,
|
|
||||||
PluginsSearchParams,
|
|
||||||
PluginsSearchResult,
|
|
||||||
PluginsSessionActionParams,
|
|
||||||
PluginsSessionActionResult,
|
|
||||||
PluginsSetEnabledParams,
|
|
||||||
PluginsSetEnabledResult,
|
|
||||||
PluginsUninstallParams,
|
|
||||||
PluginsUninstallResult,
|
|
||||||
AuthProbeStatus,
|
|
||||||
ModelsProbeParams,
|
|
||||||
ModelsProbeResult,
|
|
||||||
ModelsProbeTargetResult,
|
|
||||||
SkillsStatusParams,
|
|
||||||
ToolsCatalogParams,
|
|
||||||
ToolsCatalogResult,
|
|
||||||
ToolsEffectiveParams,
|
|
||||||
ToolsEffectiveResult,
|
|
||||||
ToolsInvokeParams,
|
|
||||||
ToolsInvokeResult,
|
|
||||||
SkillsBinsParams,
|
|
||||||
SkillsBinsResult,
|
|
||||||
SkillsCuratorActionParams,
|
|
||||||
SkillsCuratorActionResult,
|
|
||||||
SkillsCuratorStatusParams,
|
|
||||||
SkillsCuratorStatusResult,
|
|
||||||
SkillsSearchParams,
|
|
||||||
SkillsSearchResult,
|
|
||||||
SkillsDetailParams,
|
|
||||||
SkillsDetailResult,
|
|
||||||
SkillsProposalsListParams,
|
|
||||||
SkillsProposalsListResult,
|
|
||||||
SkillsProposalInspectParams,
|
|
||||||
SkillsProposalInspectResult,
|
|
||||||
SkillsProposalCreateParams,
|
|
||||||
SkillsProposalUpdateParams,
|
|
||||||
SkillsProposalReviseParams,
|
|
||||||
SkillsProposalRequestRevisionParams,
|
|
||||||
SkillsProposalRequestRevisionResult,
|
|
||||||
SkillsProposalActionParams,
|
|
||||||
SkillsProposalApplyResult,
|
|
||||||
SkillsProposalRecordResult,
|
|
||||||
SkillsSecurityVerdictsParams,
|
|
||||||
SkillsSecurityVerdictsResult,
|
|
||||||
SkillsSkillCardParams,
|
|
||||||
SkillsSkillCardResult,
|
|
||||||
SkillsUploadBeginParams,
|
|
||||||
SkillsUploadChunkParams,
|
|
||||||
SkillsUploadCommitParams,
|
|
||||||
SkillsInstallParams,
|
|
||||||
SkillsUpdateParams,
|
|
||||||
EnvironmentStatus,
|
|
||||||
WorkerEnvironmentState,
|
|
||||||
WorkerTunnelStatus,
|
|
||||||
WorkerEnvironmentMetadata,
|
|
||||||
EnvironmentSummary,
|
|
||||||
EnvironmentsCreateParams,
|
|
||||||
EnvironmentsCreateResult,
|
|
||||||
EnvironmentsDestroyParams,
|
|
||||||
EnvironmentsDestroyResult,
|
|
||||||
EnvironmentsListParams,
|
|
||||||
EnvironmentsListResult,
|
|
||||||
EnvironmentsStatusParams,
|
|
||||||
EnvironmentsStatusResult,
|
|
||||||
SystemInfoParams,
|
|
||||||
SystemInfoResult,
|
|
||||||
NodePairRejectParams,
|
|
||||||
NodePairRemoveParams,
|
|
||||||
NodeListParams,
|
|
||||||
NodePluginToolDescriptor,
|
|
||||||
NodePluginToolsUpdateParams,
|
|
||||||
NodeSkillDescriptor,
|
|
||||||
NodeSkillsUpdateParams,
|
|
||||||
NodeInvokeParams,
|
|
||||||
NodeInvokeInputEvent,
|
|
||||||
NodeInvokeProgressParams,
|
|
||||||
NodeInvokeResultParams,
|
|
||||||
NodeEventParams,
|
|
||||||
NodeEventResult,
|
|
||||||
NodePresenceAlivePayload,
|
|
||||||
NodePresenceAliveReason,
|
|
||||||
NodePresenceActivityPayload,
|
|
||||||
NodePendingDrainParams,
|
|
||||||
NodePendingDrainResult,
|
|
||||||
NodePendingEnqueueParams,
|
|
||||||
NodePendingEnqueueResult,
|
|
||||||
SessionsListParams,
|
|
||||||
SessionsSearchHit,
|
|
||||||
SessionsSearchParams,
|
|
||||||
SessionsSearchResult,
|
|
||||||
SessionsCleanupParams,
|
|
||||||
SessionsPreviewParams,
|
|
||||||
SessionsDescribeParams,
|
|
||||||
SessionsResolveParams,
|
|
||||||
SessionOperationEvent,
|
|
||||||
SessionCompanionExchange,
|
|
||||||
SessionObserverDigest,
|
|
||||||
SessionObserverHealth,
|
|
||||||
SessionObserverPlanProgress,
|
|
||||||
SessionMember,
|
|
||||||
SessionMemberAddParams,
|
|
||||||
SessionMemberMutationResult,
|
|
||||||
SessionMemberRemoveParams,
|
|
||||||
SessionMembersListParams,
|
|
||||||
SessionMembersListResult,
|
|
||||||
SessionSharingAction,
|
|
||||||
SessionSharingEvent,
|
|
||||||
SessionSharingIdentity,
|
|
||||||
SessionSharingRole,
|
|
||||||
SessionVisibility,
|
|
||||||
SessionVisibilitySetParams,
|
|
||||||
SessionVisibilitySetResult,
|
|
||||||
SessionsCompanionAskParams,
|
|
||||||
SessionsCompanionAskResult,
|
|
||||||
SessionsCompanionResetParams,
|
|
||||||
SessionsCompanionResetResult,
|
|
||||||
SessionsCompanionStateParams,
|
|
||||||
SessionsCompanionStateResult,
|
|
||||||
SessionsObserverVisibilityParams,
|
|
||||||
SessionsObserverVisibilityResult,
|
|
||||||
SessionPlacementState,
|
|
||||||
SessionPlacement,
|
|
||||||
SessionWorktreeInfo,
|
|
||||||
SessionsDispatchParams,
|
|
||||||
SessionsDispatchResult,
|
|
||||||
SessionsReclaimParams,
|
|
||||||
SessionsReclaimResult,
|
|
||||||
SessionsCreateResult,
|
|
||||||
SessionBranch,
|
|
||||||
SessionsBranchesListParams,
|
|
||||||
SessionsBranchesListResult,
|
|
||||||
SessionsBranchesSwitchParams,
|
|
||||||
SessionsBranchesSwitchResult,
|
|
||||||
SessionsForkParams,
|
|
||||||
SessionsForkResult,
|
|
||||||
SessionsRewindParams,
|
|
||||||
SessionsRewindResult,
|
|
||||||
SessionsPatchParams,
|
|
||||||
SessionsResetParams,
|
|
||||||
SessionsDeleteParams,
|
|
||||||
SessionsCompactParams,
|
|
||||||
SessionsUsageParams,
|
|
||||||
AuditActivityAgentRunV1,
|
|
||||||
AuditActivityEventV1,
|
|
||||||
AuditActivityInboundMessageV1,
|
|
||||||
AuditActivityListParams,
|
|
||||||
AuditActivityListResult,
|
|
||||||
AuditActivityOutboundMessageV1,
|
|
||||||
AuditActivityToolActionV1,
|
|
||||||
AuditEvent,
|
|
||||||
AuditListParams,
|
|
||||||
AuditListResult,
|
|
||||||
UserProfile,
|
|
||||||
UsersLinkEmailParams,
|
|
||||||
UsersLinkEmailResult,
|
|
||||||
UsersListParams,
|
|
||||||
UsersListResult,
|
|
||||||
UsersSelfParams,
|
|
||||||
UsersSelfResult,
|
|
||||||
UsersSetAvatarParams,
|
|
||||||
UsersSetAvatarResult,
|
|
||||||
UsersSetDisplayNameParams,
|
|
||||||
UsersSetDisplayNameResult,
|
|
||||||
TaskSuggestion,
|
|
||||||
TaskSuggestionEvent,
|
|
||||||
TaskSuggestionResolution,
|
|
||||||
TaskSuggestionsAcceptParams,
|
|
||||||
TaskSuggestionsAcceptResult,
|
|
||||||
TaskSuggestionsCreateParams,
|
|
||||||
TaskSuggestionsCreateResult,
|
|
||||||
TaskSuggestionsDismissParams,
|
|
||||||
TaskSuggestionsDismissResult,
|
|
||||||
TaskSuggestionsListParams,
|
|
||||||
TaskSuggestionsListResult,
|
|
||||||
TaskSummary,
|
|
||||||
TasksListParams,
|
|
||||||
TasksListResult,
|
|
||||||
TasksGetParams,
|
|
||||||
TasksGetResult,
|
|
||||||
TasksCancelParams,
|
|
||||||
TasksCancelResult,
|
|
||||||
CronJob,
|
|
||||||
CronListParams,
|
|
||||||
CronStatusParams,
|
|
||||||
CronGetParams,
|
|
||||||
CronAddParams,
|
|
||||||
CronAddResult,
|
|
||||||
CronDeclarativeAddResult,
|
|
||||||
CronUpdateParams,
|
|
||||||
CronRemoveParams,
|
|
||||||
CronRunParams,
|
|
||||||
CronRunsParams,
|
|
||||||
CronScratchGetParams,
|
|
||||||
CronScratchGetResult,
|
|
||||||
CronScratchSetParams,
|
|
||||||
CronScratchSetResult,
|
|
||||||
CronRunLogEntry,
|
|
||||||
ApprovalKind,
|
|
||||||
ApprovalDecision,
|
|
||||||
ApprovalAllowDecision,
|
|
||||||
ApprovalTerminalReason,
|
|
||||||
PluginApprovalSeverity,
|
|
||||||
ExecApprovalPresentation,
|
|
||||||
PluginApprovalPresentation,
|
|
||||||
ApprovalPresentation,
|
|
||||||
PendingApprovalSnapshot,
|
|
||||||
AllowedApprovalSnapshot,
|
|
||||||
DeniedApprovalSnapshot,
|
|
||||||
ExpiredApprovalSnapshot,
|
|
||||||
CancelledApprovalSnapshot,
|
|
||||||
ApprovalSnapshot,
|
|
||||||
TerminalApprovalSnapshot,
|
|
||||||
ApprovalGetParams,
|
|
||||||
ApprovalGetResult,
|
|
||||||
ApprovalHistoryParams,
|
|
||||||
ApprovalHistoryResult,
|
|
||||||
ApprovalResolveParams,
|
|
||||||
ApprovalResolveResult,
|
|
||||||
SessionApprovalEvent,
|
|
||||||
SessionApprovalReplay,
|
|
||||||
ExecApprovalsGetParams,
|
|
||||||
ExecApprovalsNodeSnapshot,
|
|
||||||
ExecApprovalsSetParams,
|
|
||||||
ExecApprovalsSnapshot,
|
|
||||||
ExecApprovalGetParams,
|
|
||||||
ExecApprovalRequestParams,
|
|
||||||
ExecApprovalResolveParams,
|
|
||||||
Question,
|
|
||||||
QuestionAnswers,
|
|
||||||
QuestionGetParams,
|
|
||||||
QuestionGetResult,
|
|
||||||
QuestionListParams,
|
|
||||||
QuestionListResult,
|
|
||||||
QuestionOption,
|
|
||||||
QuestionRecord,
|
|
||||||
QuestionRequestedEvent,
|
|
||||||
QuestionRequestParams,
|
|
||||||
QuestionRequestQuestion,
|
|
||||||
QuestionRequestResult,
|
|
||||||
QuestionResolvedEvent,
|
|
||||||
QuestionResolveParams,
|
|
||||||
QuestionResolveResult,
|
|
||||||
QuestionStatus,
|
|
||||||
QuestionWaitAnswerParams,
|
|
||||||
QuestionWaitAnswerResult,
|
|
||||||
LogsTailParams,
|
|
||||||
LogsTailResult,
|
|
||||||
TerminalOpenParams,
|
|
||||||
TerminalOpenResult,
|
|
||||||
TerminalInputParams,
|
|
||||||
TerminalUploadParams,
|
|
||||||
TerminalUploadResult,
|
|
||||||
TerminalResizeParams,
|
|
||||||
TerminalCloseParams,
|
|
||||||
TerminalAttachParams,
|
|
||||||
TerminalAttachResult,
|
|
||||||
TerminalSessionInfo,
|
|
||||||
TerminalListResult,
|
|
||||||
TerminalTextParams,
|
|
||||||
TerminalTextResult,
|
|
||||||
TerminalAckResult,
|
|
||||||
TerminalDataEvent,
|
|
||||||
TerminalExitEvent,
|
|
||||||
TerminalEvent,
|
|
||||||
PollParams,
|
|
||||||
WebPushVapidPublicKeyParams,
|
|
||||||
WebPushSubscribeParams,
|
|
||||||
WebPushUnsubscribeParams,
|
|
||||||
WebPushTestParams,
|
|
||||||
UpdateStatusParams,
|
|
||||||
UpdateRunParams,
|
|
||||||
ChatInjectParams,
|
|
||||||
WorktreeRecord,
|
|
||||||
WorktreesListParams,
|
|
||||||
WorktreesListResult,
|
|
||||||
WorktreesCreateParams,
|
|
||||||
WorktreesRemoveParams,
|
|
||||||
WorktreesRemoveResult,
|
|
||||||
WorktreesRestoreParams,
|
|
||||||
WorktreesGcParams,
|
|
||||||
WorktreesGcResult,
|
|
||||||
WorktreesBranchesParams,
|
|
||||||
WorktreeBranch,
|
|
||||||
WorktreeRepositoryStatus,
|
|
||||||
WorktreesBranchesResult,
|
|
||||||
FsDirEntry,
|
|
||||||
FsListDirParams,
|
|
||||||
FsListDirResult,
|
|
||||||
SessionGroup,
|
|
||||||
SessionsGroupsListParams,
|
|
||||||
SessionsGroupsListResult,
|
|
||||||
SessionsGroupsPutParams,
|
|
||||||
SessionsGroupsRenameParams,
|
|
||||||
SessionsGroupsDeleteParams,
|
|
||||||
SessionsGroupsMutationResult,
|
|
||||||
} from "./schema.js";
|
|
||||||
|
|
||||||
// Local structural result keeps this package independent of core session types.
|
|
||||||
export type SessionsPatchResult = {
|
|
||||||
ok: true;
|
|
||||||
path: string;
|
|
||||||
key: string;
|
|
||||||
entry: Record<string, unknown>;
|
|
||||||
resolved?: {
|
|
||||||
modelProvider?: string;
|
|
||||||
model?: string;
|
|
||||||
agentRuntime?: GatewayAgentRuntime;
|
|
||||||
thinkingLevel?: string;
|
|
||||||
thinkingLevels?: Array<{ id: string; label: string }>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type GatewayAgentRuntime = {
|
|
||||||
id: string;
|
|
||||||
fallback?: "openclaw" | "none";
|
|
||||||
source:
|
|
||||||
| "env"
|
|
||||||
| "agent"
|
|
||||||
| "defaults"
|
|
||||||
| "model"
|
|
||||||
| "provider"
|
|
||||||
| "implicit"
|
|
||||||
| "session"
|
|
||||||
| "session-key";
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import {
|
||||||
|
readGatewayServiceState,
|
||||||
|
resolveGatewayService,
|
||||||
|
type GatewayService,
|
||||||
|
} from "../../daemon/service.js";
|
||||||
|
import { recoverInstalledLaunchAgent } from "../daemon-cli/launchd-recovery.js";
|
||||||
|
|
||||||
|
export type PostUpdateLaunchAgentRecoveryResult =
|
||||||
|
| { attempted: false; recovered: false }
|
||||||
|
| { attempted: true; recovered: true; message: string }
|
||||||
|
| { attempted: true; recovered: false; detail: string };
|
||||||
|
|
||||||
|
type PostUpdateLaunchAgentRecoveryDeps = {
|
||||||
|
platform?: NodeJS.Platform;
|
||||||
|
readState?: typeof readGatewayServiceState;
|
||||||
|
recover?: typeof recoverInstalledLaunchAgent;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function recoverInstalledLaunchAgentAfterUpdate(params: {
|
||||||
|
service?: GatewayService;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
deps?: PostUpdateLaunchAgentRecoveryDeps;
|
||||||
|
}): Promise<PostUpdateLaunchAgentRecoveryResult> {
|
||||||
|
const platform = params.deps?.platform ?? process.platform;
|
||||||
|
if (platform !== "darwin") {
|
||||||
|
return { attempted: false, recovered: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = params.service ?? resolveGatewayService();
|
||||||
|
const readState = params.deps?.readState ?? readGatewayServiceState;
|
||||||
|
const recover = params.deps?.recover ?? recoverInstalledLaunchAgent;
|
||||||
|
const state = await readState(service, { env: params.env }).catch(() => null);
|
||||||
|
if (state?.loaded) {
|
||||||
|
return { attempted: false, recovered: false };
|
||||||
|
}
|
||||||
|
if (state && !state.installed && !state.runtime?.missingSupervision) {
|
||||||
|
return { attempted: false, recovered: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const recovered = await recover({ result: "restarted", env: state?.env ?? params.env }).catch(
|
||||||
|
() => null,
|
||||||
|
);
|
||||||
|
if (!recovered) {
|
||||||
|
return {
|
||||||
|
attempted: true,
|
||||||
|
recovered: false,
|
||||||
|
detail:
|
||||||
|
"LaunchAgent was installed but not loaded; automatic bootstrap/kickstart recovery failed.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
attempted: true,
|
||||||
|
recovered: true,
|
||||||
|
message: recovered.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||||
|
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||||
|
import type { PluginInstallRecord } from "../../config/types.plugins.js";
|
||||||
|
import { pathExists } from "../../infra/fs-safe.js";
|
||||||
|
import type { UpdateRunResult } from "../../infra/update-runner.js";
|
||||||
|
import { normalizePluginsConfig, resolveEffectiveEnableState } from "../../plugins/config-state.js";
|
||||||
|
import {
|
||||||
|
resolveTrustedSourceLinkedOfficialClawHubSpec,
|
||||||
|
resolveTrustedSourceLinkedOfficialNpmSpec,
|
||||||
|
} from "../../plugins/official-external-install-records.js";
|
||||||
|
import { resolveUserPath } from "../../utils.js";
|
||||||
|
import {
|
||||||
|
hasNativePackageInstallPayload,
|
||||||
|
resolveBundleInstallRecordPayload,
|
||||||
|
validateBundleInstallRecordPayload,
|
||||||
|
} from "./plugin-payload-validation.js";
|
||||||
|
|
||||||
|
export type PostCorePluginUpdateResult = NonNullable<
|
||||||
|
NonNullable<UpdateRunResult["postUpdate"]>["plugins"]
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type MissingPluginInstallPayload = {
|
||||||
|
pluginId: string;
|
||||||
|
installPath?: string;
|
||||||
|
reason: "missing-install-path" | "missing-package-dir" | "missing-package-json";
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolvePostSyncPluginUpdateSkipIds(params: {
|
||||||
|
switchedToClawHub: readonly string[];
|
||||||
|
switchedToNpm: readonly string[];
|
||||||
|
repairedMissingPayloadIds: ReadonlySet<string>;
|
||||||
|
}): Set<string> {
|
||||||
|
return new Set([
|
||||||
|
...params.switchedToClawHub,
|
||||||
|
...params.switchedToNpm,
|
||||||
|
...params.repairedMissingPayloadIds,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTrackedPackageInstallRecord(record: PluginInstallRecord): boolean {
|
||||||
|
return (
|
||||||
|
record.source === "npm" ||
|
||||||
|
record.source === "clawhub" ||
|
||||||
|
record.source === "git" ||
|
||||||
|
record.source === "marketplace"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function collectMissingPluginInstallPayloads(params: {
|
||||||
|
records: Record<string, PluginInstallRecord>;
|
||||||
|
config?: OpenClawConfig;
|
||||||
|
skipDisabledPlugins?: boolean;
|
||||||
|
syncOfficialPluginInstalls?: boolean;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
}): Promise<MissingPluginInstallPayload[]> {
|
||||||
|
const env = params.env ?? process.env;
|
||||||
|
const normalizedPluginConfig =
|
||||||
|
params.skipDisabledPlugins && params.config
|
||||||
|
? normalizePluginsConfig(params.config.plugins)
|
||||||
|
: undefined;
|
||||||
|
const missing: MissingPluginInstallPayload[] = [];
|
||||||
|
for (const [pluginId, record] of Object.entries(params.records).toSorted(([left], [right]) =>
|
||||||
|
left.localeCompare(right),
|
||||||
|
)) {
|
||||||
|
if (!isTrackedPackageInstallRecord(record)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const officialNpmSpec = params.syncOfficialPluginInstalls
|
||||||
|
? resolveTrustedSourceLinkedOfficialNpmSpec({ pluginId, record })
|
||||||
|
: undefined;
|
||||||
|
const officialClawHubSpec = params.syncOfficialPluginInstalls
|
||||||
|
? resolveTrustedSourceLinkedOfficialClawHubSpec({ pluginId, record })
|
||||||
|
: undefined;
|
||||||
|
if (normalizedPluginConfig && params.config) {
|
||||||
|
const enableState = resolveEffectiveEnableState({
|
||||||
|
id: pluginId,
|
||||||
|
origin: "global",
|
||||||
|
config: normalizedPluginConfig,
|
||||||
|
rootConfig: params.config,
|
||||||
|
});
|
||||||
|
if (!enableState.enabled && !officialNpmSpec && !officialClawHubSpec) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const rawInstallPath = normalizeOptionalString(record.installPath);
|
||||||
|
if (!rawInstallPath) {
|
||||||
|
missing.push({ pluginId, reason: "missing-install-path" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const installPath = resolveUserPath(rawInstallPath, env);
|
||||||
|
if (!(await pathExists(installPath))) {
|
||||||
|
missing.push({ pluginId, installPath, reason: "missing-package-dir" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const bundlePayload = resolveBundleInstallRecordPayload({ record, installPath });
|
||||||
|
if (bundlePayload.isBundlePayload) {
|
||||||
|
if (await hasNativePackageInstallPayload(installPath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const bundleFailure = validateBundleInstallRecordPayload({
|
||||||
|
pluginId,
|
||||||
|
installPath,
|
||||||
|
record,
|
||||||
|
bundleFormat: bundlePayload.bundleFormat,
|
||||||
|
});
|
||||||
|
if (bundleFailure) {
|
||||||
|
missing.push({ pluginId, installPath, reason: "missing-package-json" });
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const packageJsonPath = path.join(installPath, "package.json");
|
||||||
|
if (!(await pathExists(packageJsonPath))) {
|
||||||
|
missing.push({ pluginId, installPath, reason: "missing-package-json" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return missing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the post-core-update result we return when the active config cannot
|
||||||
|
* even be parsed. Mandatory post-core convergence requires a parseable
|
||||||
|
* config to know which plugins are configured; if one isn't available, we
|
||||||
|
* refuse to restart the gateway and surface this as a hard error so the
|
||||||
|
* existing `status === "error"` => `exit 1` pre-restart gate fires.
|
||||||
|
*/
|
||||||
|
export function buildInvalidConfigPostCoreUpdateResult(): {
|
||||||
|
message: string;
|
||||||
|
guidance: string[];
|
||||||
|
result: PostCorePluginUpdateResult;
|
||||||
|
} {
|
||||||
|
const guidance = [
|
||||||
|
"Run `openclaw doctor` to inspect the config validation errors.",
|
||||||
|
"Once the config parses, rerun `openclaw update repair`.",
|
||||||
|
];
|
||||||
|
const message =
|
||||||
|
"Plugin post-update convergence skipped because the config is invalid; refusing to restart the gateway with an unverified plugin set.";
|
||||||
|
return {
|
||||||
|
message,
|
||||||
|
guidance,
|
||||||
|
result: {
|
||||||
|
status: "error",
|
||||||
|
reason: "invalid-config",
|
||||||
|
changed: false,
|
||||||
|
sync: {
|
||||||
|
changed: false,
|
||||||
|
switchedToBundled: [],
|
||||||
|
switchedToNpm: [],
|
||||||
|
warnings: [],
|
||||||
|
errors: [],
|
||||||
|
},
|
||||||
|
npm: {
|
||||||
|
changed: false,
|
||||||
|
outcomes: [],
|
||||||
|
},
|
||||||
|
integrityDrifts: [],
|
||||||
|
warnings: [{ reason: "invalid-config", message, guidance }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,52 +1,11 @@
|
|||||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
import {
|
||||||
import type { PluginInstallRecord } from "../../config/types.plugins.js";
|
buildInvalidConfigPostCoreUpdateResult,
|
||||||
import type { PostCorePluginUpdateResult } from "./update-command-plugins.js";
|
collectMissingPluginInstallPayloads,
|
||||||
import "./update-command-plugins.js";
|
resolvePostSyncPluginUpdateSkipIds,
|
||||||
|
} from "./update-command-plugins-internals.js";
|
||||||
|
|
||||||
type MissingPluginInstallPayload = {
|
export const testing = {
|
||||||
pluginId: string;
|
buildInvalidConfigPostCoreUpdateResult,
|
||||||
installPath?: string;
|
collectMissingPluginInstallPayloads,
|
||||||
reason: "missing-install-path" | "missing-package-dir" | "missing-package-json";
|
resolvePostSyncPluginUpdateSkipIds,
|
||||||
};
|
};
|
||||||
|
|
||||||
type UpdateCommandPluginsTestApi = {
|
|
||||||
buildInvalidConfigPostCoreUpdateResult(): {
|
|
||||||
message: string;
|
|
||||||
guidance: string[];
|
|
||||||
result: PostCorePluginUpdateResult;
|
|
||||||
};
|
|
||||||
collectMissingPluginInstallPayloads(params: {
|
|
||||||
records: Record<string, PluginInstallRecord>;
|
|
||||||
config?: OpenClawConfig;
|
|
||||||
skipDisabledPlugins?: boolean;
|
|
||||||
syncOfficialPluginInstalls?: boolean;
|
|
||||||
env?: NodeJS.ProcessEnv;
|
|
||||||
}): Promise<MissingPluginInstallPayload[]>;
|
|
||||||
resolvePostSyncPluginUpdateSkipIds(params: {
|
|
||||||
switchedToClawHub: readonly string[];
|
|
||||||
switchedToNpm: readonly string[];
|
|
||||||
repairedMissingPayloadIds: ReadonlySet<string>;
|
|
||||||
}): Set<string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
function getTestApi(): UpdateCommandPluginsTestApi {
|
|
||||||
return (globalThis as Record<PropertyKey, unknown>)[
|
|
||||||
Symbol.for("openclaw.updateCommandPluginsTestApi")
|
|
||||||
] as UpdateCommandPluginsTestApi;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildInvalidConfigPostCoreUpdateResult() {
|
|
||||||
return getTestApi().buildInvalidConfigPostCoreUpdateResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function collectMissingPluginInstallPayloads(
|
|
||||||
params: Parameters<UpdateCommandPluginsTestApi["collectMissingPluginInstallPayloads"]>[0],
|
|
||||||
): Promise<MissingPluginInstallPayload[]> {
|
|
||||||
return await getTestApi().collectMissingPluginInstallPayloads(params);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolvePostSyncPluginUpdateSkipIds(
|
|
||||||
params: Parameters<UpdateCommandPluginsTestApi["resolvePostSyncPluginUpdateSkipIds"]>[0],
|
|
||||||
): Set<string> {
|
|
||||||
return getTestApi().resolvePostSyncPluginUpdateSkipIds(params);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
// Plugin synchronization and convergence after the core update.
|
// Plugin synchronization and convergence after the core update.
|
||||||
import path from "node:path";
|
|
||||||
import { confirm, isCancel, text } from "@clack/prompts";
|
import { confirm, isCancel, text } from "@clack/prompts";
|
||||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
|
||||||
import { stripAnsi } from "../../../packages/terminal-core/src/ansi.js";
|
import { stripAnsi } from "../../../packages/terminal-core/src/ansi.js";
|
||||||
import { stylePromptMessage } from "../../../packages/terminal-core/src/prompt-style.js";
|
import { stylePromptMessage } from "../../../packages/terminal-core/src/prompt-style.js";
|
||||||
import { sanitizeTerminalText } from "../../../packages/terminal-core/src/safe-text.js";
|
import { sanitizeTerminalText } from "../../../packages/terminal-core/src/safe-text.js";
|
||||||
@@ -10,20 +8,13 @@ import { readConfigFileSnapshot } from "../../config/config.js";
|
|||||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||||
import type { PluginInstallRecord } from "../../config/types.plugins.js";
|
import type { PluginInstallRecord } from "../../config/types.plugins.js";
|
||||||
import type { ClawHubRiskAcknowledgementRequest } from "../../infra/clawhub-install-trust.js";
|
import type { ClawHubRiskAcknowledgementRequest } from "../../infra/clawhub-install-trust.js";
|
||||||
import { pathExists } from "../../infra/fs-safe.js";
|
|
||||||
import type { UpdateChannel } from "../../infra/update-channels.js";
|
import type { UpdateChannel } from "../../infra/update-channels.js";
|
||||||
import type { UpdateRunResult } from "../../infra/update-runner.js";
|
|
||||||
import { normalizePluginsConfig, resolveEffectiveEnableState } from "../../plugins/config-state.js";
|
|
||||||
import { commitPluginInstallRecordsWithConfig } from "../../plugins/install-record-commit.js";
|
import { commitPluginInstallRecordsWithConfig } from "../../plugins/install-record-commit.js";
|
||||||
import {
|
import {
|
||||||
loadInstalledPluginIndexInstallRecords,
|
loadInstalledPluginIndexInstallRecords,
|
||||||
withoutPluginInstallRecords,
|
withoutPluginInstallRecords,
|
||||||
withPluginInstallRecords,
|
withPluginInstallRecords,
|
||||||
} from "../../plugins/installed-plugin-index-records.js";
|
} from "../../plugins/installed-plugin-index-records.js";
|
||||||
import {
|
|
||||||
resolveTrustedSourceLinkedOfficialClawHubSpec,
|
|
||||||
resolveTrustedSourceLinkedOfficialNpmSpec,
|
|
||||||
} from "../../plugins/official-external-install-records.js";
|
|
||||||
import { refreshPluginRegistryAfterConfigMutation } from "../../plugins/registry-refresh.js";
|
import { refreshPluginRegistryAfterConfigMutation } from "../../plugins/registry-refresh.js";
|
||||||
import {
|
import {
|
||||||
isClawHubTrustSkippedOutcome,
|
isClawHubTrustSkippedOutcome,
|
||||||
@@ -33,46 +24,27 @@ import {
|
|||||||
type PluginUpdateOutcome,
|
type PluginUpdateOutcome,
|
||||||
} from "../../plugins/update.js";
|
} from "../../plugins/update.js";
|
||||||
import { defaultRuntime } from "../../runtime.js";
|
import { defaultRuntime } from "../../runtime.js";
|
||||||
import { resolveUserPath } from "../../utils.js";
|
|
||||||
import { listPersistedBundledPluginLocationBridges } from "../plugins-location-bridges.js";
|
import { listPersistedBundledPluginLocationBridges } from "../plugins-location-bridges.js";
|
||||||
import {
|
|
||||||
hasNativePackageInstallPayload,
|
|
||||||
resolveBundleInstallRecordPayload,
|
|
||||||
validateBundleInstallRecordPayload,
|
|
||||||
} from "./plugin-payload-validation.js";
|
|
||||||
import {
|
import {
|
||||||
convergenceWarningsToOutcomes,
|
convergenceWarningsToOutcomes,
|
||||||
runPostCorePluginConvergence,
|
runPostCorePluginConvergence,
|
||||||
} from "./post-core-plugin-convergence.js";
|
} from "./post-core-plugin-convergence.js";
|
||||||
import { readPackageVersion, type UpdateCommandOptions } from "./shared.js";
|
import { readPackageVersion, type UpdateCommandOptions } from "./shared.js";
|
||||||
|
import {
|
||||||
|
buildInvalidConfigPostCoreUpdateResult,
|
||||||
|
collectMissingPluginInstallPayloads,
|
||||||
|
resolvePostSyncPluginUpdateSkipIds,
|
||||||
|
type MissingPluginInstallPayload,
|
||||||
|
type PostCorePluginUpdateResult,
|
||||||
|
} from "./update-command-plugins-internals.js";
|
||||||
|
|
||||||
|
export type { PostCorePluginUpdateResult } from "./update-command-plugins-internals.js";
|
||||||
|
|
||||||
const POST_UPDATE_PLUGIN_REPAIR_GUIDANCE =
|
const POST_UPDATE_PLUGIN_REPAIR_GUIDANCE =
|
||||||
"Run openclaw update repair to retry post-update plugin repair.";
|
"Run openclaw update repair to retry post-update plugin repair.";
|
||||||
|
|
||||||
export type PostCorePluginUpdateResult = NonNullable<
|
|
||||||
NonNullable<UpdateRunResult["postUpdate"]>["plugins"]
|
|
||||||
>;
|
|
||||||
|
|
||||||
type MissingPluginInstallPayload = {
|
|
||||||
pluginId: string;
|
|
||||||
installPath?: string;
|
|
||||||
reason: "missing-install-path" | "missing-package-dir" | "missing-package-json";
|
|
||||||
};
|
|
||||||
|
|
||||||
type PostUpdatePluginWarning = NonNullable<PostCorePluginUpdateResult["warnings"]>[number];
|
type PostUpdatePluginWarning = NonNullable<PostCorePluginUpdateResult["warnings"]>[number];
|
||||||
|
|
||||||
function resolvePostSyncPluginUpdateSkipIds(params: {
|
|
||||||
switchedToClawHub: readonly string[];
|
|
||||||
switchedToNpm: readonly string[];
|
|
||||||
repairedMissingPayloadIds: ReadonlySet<string>;
|
|
||||||
}): Set<string> {
|
|
||||||
return new Set([
|
|
||||||
...params.switchedToClawHub,
|
|
||||||
...params.switchedToNpm,
|
|
||||||
...params.repairedMissingPayloadIds,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isClawHubTrustNotice(message: string): boolean {
|
function isClawHubTrustNotice(message: string): boolean {
|
||||||
const trimmed = stripAnsi(message).trimStart();
|
const trimmed = stripAnsi(message).trimStart();
|
||||||
return (
|
return (
|
||||||
@@ -133,85 +105,6 @@ function resolveUpdateClawHubRiskAcknowledgementOptions(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTrackedPackageInstallRecord(record: PluginInstallRecord): boolean {
|
|
||||||
return (
|
|
||||||
record.source === "npm" ||
|
|
||||||
record.source === "clawhub" ||
|
|
||||||
record.source === "git" ||
|
|
||||||
record.source === "marketplace"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function collectMissingPluginInstallPayloads(params: {
|
|
||||||
records: Record<string, PluginInstallRecord>;
|
|
||||||
config?: OpenClawConfig;
|
|
||||||
skipDisabledPlugins?: boolean;
|
|
||||||
syncOfficialPluginInstalls?: boolean;
|
|
||||||
env?: NodeJS.ProcessEnv;
|
|
||||||
}): Promise<MissingPluginInstallPayload[]> {
|
|
||||||
const env = params.env ?? process.env;
|
|
||||||
const normalizedPluginConfig =
|
|
||||||
params.skipDisabledPlugins && params.config
|
|
||||||
? normalizePluginsConfig(params.config.plugins)
|
|
||||||
: undefined;
|
|
||||||
const missing: MissingPluginInstallPayload[] = [];
|
|
||||||
for (const [pluginId, record] of Object.entries(params.records).toSorted(([left], [right]) =>
|
|
||||||
left.localeCompare(right),
|
|
||||||
)) {
|
|
||||||
if (!isTrackedPackageInstallRecord(record)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const officialNpmSpec = params.syncOfficialPluginInstalls
|
|
||||||
? resolveTrustedSourceLinkedOfficialNpmSpec({ pluginId, record })
|
|
||||||
: undefined;
|
|
||||||
const officialClawHubSpec = params.syncOfficialPluginInstalls
|
|
||||||
? resolveTrustedSourceLinkedOfficialClawHubSpec({ pluginId, record })
|
|
||||||
: undefined;
|
|
||||||
if (normalizedPluginConfig && params.config) {
|
|
||||||
const enableState = resolveEffectiveEnableState({
|
|
||||||
id: pluginId,
|
|
||||||
origin: "global",
|
|
||||||
config: normalizedPluginConfig,
|
|
||||||
rootConfig: params.config,
|
|
||||||
});
|
|
||||||
if (!enableState.enabled && !officialNpmSpec && !officialClawHubSpec) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const rawInstallPath = normalizeOptionalString(record.installPath);
|
|
||||||
if (!rawInstallPath) {
|
|
||||||
missing.push({ pluginId, reason: "missing-install-path" });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const installPath = resolveUserPath(rawInstallPath, env);
|
|
||||||
if (!(await pathExists(installPath))) {
|
|
||||||
missing.push({ pluginId, installPath, reason: "missing-package-dir" });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const bundlePayload = resolveBundleInstallRecordPayload({ record, installPath });
|
|
||||||
if (bundlePayload.isBundlePayload) {
|
|
||||||
if (await hasNativePackageInstallPayload(installPath)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const bundleFailure = validateBundleInstallRecordPayload({
|
|
||||||
pluginId,
|
|
||||||
installPath,
|
|
||||||
record,
|
|
||||||
bundleFormat: bundlePayload.bundleFormat,
|
|
||||||
});
|
|
||||||
if (bundleFailure) {
|
|
||||||
missing.push({ pluginId, installPath, reason: "missing-package-json" });
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const packageJsonPath = path.join(installPath, "package.json");
|
|
||||||
if (!(await pathExists(packageJsonPath))) {
|
|
||||||
missing.push({ pluginId, installPath, reason: "missing-package-json" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return missing;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatMissingPluginPayloadReason(entry: MissingPluginInstallPayload): string {
|
function formatMissingPluginPayloadReason(entry: MissingPluginInstallPayload): string {
|
||||||
if (entry.reason === "missing-install-path") {
|
if (entry.reason === "missing-install-path") {
|
||||||
return "installPath is missing";
|
return "installPath is missing";
|
||||||
@@ -295,58 +188,6 @@ function isActionableSkippedPostUpdateOutcome(outcome: PluginUpdateOutcome): boo
|
|||||||
return isDisabledAfterFailureOutcome(outcome) || isClawHubTrustSkippedOutcome(outcome);
|
return isDisabledAfterFailureOutcome(outcome) || isClawHubTrustSkippedOutcome(outcome);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the post-core-update result we return when the active config cannot
|
|
||||||
* even be parsed. Mandatory post-core convergence requires a parseable
|
|
||||||
* config to know which plugins are configured; if one isn't available, we
|
|
||||||
* refuse to restart the gateway and surface this as a hard error so the
|
|
||||||
* existing `status === "error"` ⇒ `exit 1` pre-restart gate fires.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
function buildInvalidConfigPostCoreUpdateResult(): {
|
|
||||||
message: string;
|
|
||||||
guidance: string[];
|
|
||||||
result: PostCorePluginUpdateResult;
|
|
||||||
} {
|
|
||||||
const guidance = [
|
|
||||||
"Run `openclaw doctor` to inspect the config validation errors.",
|
|
||||||
"Once the config parses, rerun `openclaw update repair`.",
|
|
||||||
];
|
|
||||||
const message =
|
|
||||||
"Plugin post-update convergence skipped because the config is invalid; refusing to restart the gateway with an unverified plugin set.";
|
|
||||||
return {
|
|
||||||
message,
|
|
||||||
guidance,
|
|
||||||
result: {
|
|
||||||
status: "error",
|
|
||||||
reason: "invalid-config",
|
|
||||||
changed: false,
|
|
||||||
sync: {
|
|
||||||
changed: false,
|
|
||||||
switchedToBundled: [],
|
|
||||||
switchedToNpm: [],
|
|
||||||
warnings: [],
|
|
||||||
errors: [],
|
|
||||||
},
|
|
||||||
npm: {
|
|
||||||
changed: false,
|
|
||||||
outcomes: [],
|
|
||||||
},
|
|
||||||
integrityDrifts: [],
|
|
||||||
warnings: [{ reason: "invalid-config", message, guidance }],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.env.VITEST || process.env.NODE_ENV === "test") {
|
|
||||||
(globalThis as Record<PropertyKey, unknown>)[Symbol.for("openclaw.updateCommandPluginsTestApi")] =
|
|
||||||
{
|
|
||||||
buildInvalidConfigPostCoreUpdateResult,
|
|
||||||
collectMissingPluginInstallPayloads,
|
|
||||||
resolvePostSyncPluginUpdateSkipIds,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updatePluginsAfterCoreUpdate(params: {
|
export async function updatePluginsAfterCoreUpdate(params: {
|
||||||
root: string;
|
root: string;
|
||||||
channel: UpdateChannel;
|
channel: UpdateChannel;
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||||
|
import type { GatewayService } from "../../daemon/service.js";
|
||||||
|
import type { UpdateRunResult } from "../../infra/update-runner.js";
|
||||||
|
import { replaceCliName, resolveCliName } from "../cli-name.js";
|
||||||
|
import { formatCliCommand } from "../command-format.js";
|
||||||
|
import {
|
||||||
|
waitForGatewayHealthyRestart,
|
||||||
|
type GatewayRestartSnapshot,
|
||||||
|
} from "../daemon-cli/restart-health.js";
|
||||||
|
import {
|
||||||
|
recoverInstalledLaunchAgentAfterUpdate,
|
||||||
|
type PostUpdateLaunchAgentRecoveryResult,
|
||||||
|
} from "./update-command-launch-agent-recovery.js";
|
||||||
|
|
||||||
|
const CLI_NAME = resolveCliName();
|
||||||
|
|
||||||
|
export function isPackageManagerUpdateMode(
|
||||||
|
mode: UpdateRunResult["mode"],
|
||||||
|
): mode is "npm" | "pnpm" | "bun" {
|
||||||
|
return mode === "npm" || mode === "pnpm" || mode === "bun";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldUseLegacyProcessRestartAfterUpdate(params: {
|
||||||
|
updateMode: UpdateRunResult["mode"];
|
||||||
|
}): boolean {
|
||||||
|
return !isPackageManagerUpdateMode(params.updateMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
type PostUpdateGatewayHealthRecoveryDeps = {
|
||||||
|
recoverLaunchAgent?: typeof recoverInstalledLaunchAgentAfterUpdate;
|
||||||
|
waitForHealthy?: typeof waitForGatewayHealthyRestart;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function recoverLaunchAgentAndRecheckGatewayHealth(params: {
|
||||||
|
health: GatewayRestartSnapshot;
|
||||||
|
service: GatewayService;
|
||||||
|
port: number;
|
||||||
|
expectedVersion?: string;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
deps?: PostUpdateGatewayHealthRecoveryDeps;
|
||||||
|
}): Promise<{
|
||||||
|
health: GatewayRestartSnapshot;
|
||||||
|
launchAgentRecovery: PostUpdateLaunchAgentRecoveryResult | null;
|
||||||
|
}> {
|
||||||
|
if (params.health.healthy) {
|
||||||
|
return { health: params.health, launchAgentRecovery: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const recoverLaunchAgent =
|
||||||
|
params.deps?.recoverLaunchAgent ?? recoverInstalledLaunchAgentAfterUpdate;
|
||||||
|
const launchAgentRecovery = await recoverLaunchAgent({
|
||||||
|
service: params.service,
|
||||||
|
env: params.env,
|
||||||
|
});
|
||||||
|
if (!launchAgentRecovery.recovered) {
|
||||||
|
return { health: params.health, launchAgentRecovery };
|
||||||
|
}
|
||||||
|
|
||||||
|
const waitForHealthy = params.deps?.waitForHealthy ?? waitForGatewayHealthyRestart;
|
||||||
|
const health = await waitForHealthy({
|
||||||
|
service: params.service,
|
||||||
|
port: params.port,
|
||||||
|
expectedVersion: params.expectedVersion,
|
||||||
|
env: params.env,
|
||||||
|
supervisorKeepsAlive: true,
|
||||||
|
});
|
||||||
|
return { health, launchAgentRecovery };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hasLoadedLaunchdKeepAliveSupervisor(params: {
|
||||||
|
service: GatewayService;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
if (process.platform !== "darwin") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// OpenClaw's loaded LaunchAgent has canonical KeepAlive policy. Read this once before
|
||||||
|
// polling so an unloaded agent can still reach the existing recovery path promptly.
|
||||||
|
return await params.service.isLoaded({ env: params.env }).catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPostUpdateGatewayRecoveryLine(platform: NodeJS.Platform): string {
|
||||||
|
const restartCommand = replaceCliName(formatCliCommand("openclaw gateway restart"), CLI_NAME);
|
||||||
|
const installCommand = replaceCliName(
|
||||||
|
formatCliCommand("openclaw gateway install --force"),
|
||||||
|
CLI_NAME,
|
||||||
|
);
|
||||||
|
const statusCommand = replaceCliName(
|
||||||
|
formatCliCommand("openclaw gateway status --deep"),
|
||||||
|
CLI_NAME,
|
||||||
|
);
|
||||||
|
if (platform === "darwin") {
|
||||||
|
return `Recovery: run \`${restartCommand}\`; if the LaunchAgent is installed but not loaded, run \`${installCommand}\` from the logged-in macOS user session, then rerun \`${statusCommand}\`.`;
|
||||||
|
}
|
||||||
|
if (platform === "linux") {
|
||||||
|
return `Recovery: run \`${restartCommand}\`; if the systemd user service is missing, stale, or not active, run \`${installCommand}\` from the same user account, then rerun \`${statusCommand}\`.`;
|
||||||
|
}
|
||||||
|
if (platform === "win32") {
|
||||||
|
return `Recovery: run \`${restartCommand}\`; if the gateway Scheduled Task or Windows login item is missing, stale, or not running, run \`${installCommand}\` from the same user account, then rerun \`${statusCommand}\`.`;
|
||||||
|
}
|
||||||
|
return `Recovery: run \`${restartCommand}\`; if the local service manager reports the gateway service is missing, stale, or not running, run \`${installCommand}\` from the same user account, then rerun \`${statusCommand}\`.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPostUpdateGatewayRecoveryInstructions(
|
||||||
|
result: UpdateRunResult,
|
||||||
|
platform: NodeJS.Platform = process.platform,
|
||||||
|
): string[] {
|
||||||
|
const lines = [formatPostUpdateGatewayRecoveryLine(platform)];
|
||||||
|
const beforeVersion = normalizeOptionalString(result.before?.version);
|
||||||
|
if (isPackageManagerUpdateMode(result.mode) && beforeVersion) {
|
||||||
|
lines.push(
|
||||||
|
`Rollback: reinstall OpenClaw ${beforeVersion} with the same package manager, then rerun \`${replaceCliName(formatCliCommand("openclaw gateway install --force"), CLI_NAME)}\`.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
@@ -1,98 +1,15 @@
|
|||||||
import type { GatewayServiceState } from "../../daemon/service-types.js";
|
import { recoverInstalledLaunchAgentAfterUpdate } from "./update-command-launch-agent-recovery.js";
|
||||||
import type { GatewayService } from "../../daemon/service.js";
|
import {
|
||||||
import type { UpdateRunResult } from "../../infra/update-runner.js";
|
formatPostUpdateGatewayRecoveryInstructions,
|
||||||
import type { GatewayRestartSnapshot } from "../daemon-cli/restart-health.js";
|
hasLoadedLaunchdKeepAliveSupervisor,
|
||||||
import "./update-command-service.js";
|
recoverLaunchAgentAndRecheckGatewayHealth,
|
||||||
|
shouldUseLegacyProcessRestartAfterUpdate,
|
||||||
|
} from "./update-command-service-recovery.js";
|
||||||
|
|
||||||
type PostUpdateLaunchAgentRecoveryResult =
|
export const testing = {
|
||||||
| { attempted: false; recovered: false }
|
formatPostUpdateGatewayRecoveryInstructions,
|
||||||
| { attempted: true; recovered: true; message: string }
|
recoverInstalledLaunchAgentAfterUpdate,
|
||||||
| { attempted: true; recovered: false; detail: string };
|
recoverLaunchAgentAndRecheckGatewayHealth,
|
||||||
|
hasLoadedLaunchdKeepAliveSupervisor,
|
||||||
type UpdateCommandServiceTestApi = {
|
shouldUseLegacyProcessRestartAfterUpdate,
|
||||||
formatPostUpdateGatewayRecoveryInstructions(
|
|
||||||
result: UpdateRunResult,
|
|
||||||
platform?: NodeJS.Platform,
|
|
||||||
): string[];
|
|
||||||
recoverInstalledLaunchAgentAfterUpdate(params: {
|
|
||||||
service?: GatewayService;
|
|
||||||
env?: NodeJS.ProcessEnv;
|
|
||||||
deps?: {
|
|
||||||
platform?: NodeJS.Platform;
|
|
||||||
readState?: (
|
|
||||||
service: GatewayService,
|
|
||||||
args: { env?: NodeJS.ProcessEnv },
|
|
||||||
) => Promise<GatewayServiceState>;
|
|
||||||
recover?: (params: {
|
|
||||||
result: "restarted";
|
|
||||||
env?: Record<string, string | undefined>;
|
|
||||||
}) => Promise<{ result: "restarted"; loaded: true; message: string } | null>;
|
|
||||||
};
|
|
||||||
}): Promise<PostUpdateLaunchAgentRecoveryResult>;
|
|
||||||
recoverLaunchAgentAndRecheckGatewayHealth(params: {
|
|
||||||
health: GatewayRestartSnapshot;
|
|
||||||
service: GatewayService;
|
|
||||||
port: number;
|
|
||||||
expectedVersion?: string;
|
|
||||||
env?: NodeJS.ProcessEnv;
|
|
||||||
deps?: {
|
|
||||||
recoverLaunchAgent?: (params: {
|
|
||||||
service?: GatewayService;
|
|
||||||
env?: NodeJS.ProcessEnv;
|
|
||||||
}) => Promise<PostUpdateLaunchAgentRecoveryResult>;
|
|
||||||
waitForHealthy?: (params: {
|
|
||||||
service: GatewayService;
|
|
||||||
port: number;
|
|
||||||
expectedVersion?: string;
|
|
||||||
env?: NodeJS.ProcessEnv;
|
|
||||||
}) => Promise<GatewayRestartSnapshot>;
|
|
||||||
};
|
|
||||||
}): Promise<{
|
|
||||||
health: GatewayRestartSnapshot;
|
|
||||||
launchAgentRecovery: PostUpdateLaunchAgentRecoveryResult | null;
|
|
||||||
}>;
|
|
||||||
hasLoadedLaunchdKeepAliveSupervisor(params: {
|
|
||||||
service: GatewayService;
|
|
||||||
env?: NodeJS.ProcessEnv;
|
|
||||||
}): Promise<boolean>;
|
|
||||||
shouldUseLegacyProcessRestartAfterUpdate(params: {
|
|
||||||
updateMode: UpdateRunResult["mode"];
|
|
||||||
}): boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function getTestApi(): UpdateCommandServiceTestApi {
|
|
||||||
return (globalThis as Record<PropertyKey, unknown>)[
|
|
||||||
Symbol.for("openclaw.updateCommandServiceTestApi")
|
|
||||||
] as UpdateCommandServiceTestApi;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatPostUpdateGatewayRecoveryInstructions(
|
|
||||||
result: UpdateRunResult,
|
|
||||||
platform?: NodeJS.Platform,
|
|
||||||
): string[] {
|
|
||||||
return getTestApi().formatPostUpdateGatewayRecoveryInstructions(result, platform);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function recoverInstalledLaunchAgentAfterUpdate(
|
|
||||||
params: Parameters<UpdateCommandServiceTestApi["recoverInstalledLaunchAgentAfterUpdate"]>[0],
|
|
||||||
): Promise<PostUpdateLaunchAgentRecoveryResult> {
|
|
||||||
return await getTestApi().recoverInstalledLaunchAgentAfterUpdate(params);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function recoverLaunchAgentAndRecheckGatewayHealth(
|
|
||||||
params: Parameters<UpdateCommandServiceTestApi["recoverLaunchAgentAndRecheckGatewayHealth"]>[0],
|
|
||||||
) {
|
|
||||||
return await getTestApi().recoverLaunchAgentAndRecheckGatewayHealth(params);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function hasLoadedLaunchdKeepAliveSupervisor(
|
|
||||||
params: Parameters<UpdateCommandServiceTestApi["hasLoadedLaunchdKeepAliveSupervisor"]>[0],
|
|
||||||
): Promise<boolean> {
|
|
||||||
return await getTestApi().hasLoadedLaunchdKeepAliveSupervisor(params);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldUseLegacyProcessRestartAfterUpdate(
|
|
||||||
params: Parameters<UpdateCommandServiceTestApi["shouldUseLegacyProcessRestartAfterUpdate"]>[0],
|
|
||||||
): boolean {
|
|
||||||
return getTestApi().shouldUseLegacyProcessRestartAfterUpdate(params);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -28,11 +28,7 @@ import {
|
|||||||
} from "../../daemon/schtasks.js";
|
} from "../../daemon/schtasks.js";
|
||||||
import { summarizeGatewayServiceLayout } from "../../daemon/service-layout.js";
|
import { summarizeGatewayServiceLayout } from "../../daemon/service-layout.js";
|
||||||
import type { GatewayServiceCommandConfig } from "../../daemon/service-types.js";
|
import type { GatewayServiceCommandConfig } from "../../daemon/service-types.js";
|
||||||
import {
|
import { readGatewayServiceState, resolveGatewayService } from "../../daemon/service.js";
|
||||||
readGatewayServiceState,
|
|
||||||
resolveGatewayService,
|
|
||||||
type GatewayService,
|
|
||||||
} from "../../daemon/service.js";
|
|
||||||
import { parseStrictPositiveInteger } from "../../infra/parse-finite-number.js";
|
import { parseStrictPositiveInteger } from "../../infra/parse-finite-number.js";
|
||||||
import { getSelfAndAncestorPidsSync } from "../../infra/restart-stale-pids.js";
|
import { getSelfAndAncestorPidsSync } from "../../infra/restart-stale-pids.js";
|
||||||
import { nodeVersionSatisfiesEngine } from "../../infra/runtime-guard.js";
|
import { nodeVersionSatisfiesEngine } from "../../infra/runtime-guard.js";
|
||||||
@@ -45,12 +41,10 @@ import { replaceCliName, resolveCliName } from "../cli-name.js";
|
|||||||
import { formatCliCommand } from "../command-format.js";
|
import { formatCliCommand } from "../command-format.js";
|
||||||
import { installCompletion } from "../completion-runtime.js";
|
import { installCompletion } from "../completion-runtime.js";
|
||||||
import { runDaemonInstall, runDaemonRestart } from "../daemon-cli.js";
|
import { runDaemonInstall, runDaemonRestart } from "../daemon-cli.js";
|
||||||
import { recoverInstalledLaunchAgent } from "../daemon-cli/launchd-recovery.js";
|
|
||||||
import {
|
import {
|
||||||
renderRestartDiagnostics,
|
renderRestartDiagnostics,
|
||||||
terminateStaleGatewayPids,
|
terminateStaleGatewayPids,
|
||||||
waitForGatewayHealthyRestart,
|
waitForGatewayHealthyRestart,
|
||||||
type GatewayRestartSnapshot,
|
|
||||||
} from "../daemon-cli/restart-health.js";
|
} from "../daemon-cli/restart-health.js";
|
||||||
import {
|
import {
|
||||||
registerSignalExitBarrier,
|
registerSignalExitBarrier,
|
||||||
@@ -60,6 +54,15 @@ import {
|
|||||||
import { runRestartScript } from "./restart-helper.js";
|
import { runRestartScript } from "./restart-helper.js";
|
||||||
import { resolveNodeRunner, type UpdateCommandOptions } from "./shared.js";
|
import { resolveNodeRunner, type UpdateCommandOptions } from "./shared.js";
|
||||||
import { createUpdateConfigSnapshot } from "./update-command-config.js";
|
import { createUpdateConfigSnapshot } from "./update-command-config.js";
|
||||||
|
import {
|
||||||
|
formatPostUpdateGatewayRecoveryInstructions,
|
||||||
|
hasLoadedLaunchdKeepAliveSupervisor,
|
||||||
|
isPackageManagerUpdateMode,
|
||||||
|
recoverLaunchAgentAndRecheckGatewayHealth,
|
||||||
|
shouldUseLegacyProcessRestartAfterUpdate,
|
||||||
|
} from "./update-command-service-recovery.js";
|
||||||
|
|
||||||
|
export { isPackageManagerUpdateMode } from "./update-command-service-recovery.js";
|
||||||
|
|
||||||
const CLI_NAME = resolveCliName();
|
const CLI_NAME = resolveCliName();
|
||||||
const SERVICE_REFRESH_TIMEOUT_MS = 60_000;
|
const SERVICE_REFRESH_TIMEOUT_MS = 60_000;
|
||||||
@@ -80,12 +83,6 @@ const JSON_MODE_SERVICE_STDOUT = new Writable({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export function isPackageManagerUpdateMode(
|
|
||||||
mode: UpdateRunResult["mode"],
|
|
||||||
): mode is "npm" | "pnpm" | "bun" {
|
|
||||||
return mode === "npm" || mode === "pnpm" || mode === "bun";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldPrepareUpdatedInstallRestart(params: {
|
export function shouldPrepareUpdatedInstallRestart(params: {
|
||||||
updateMode: UpdateRunResult["mode"];
|
updateMode: UpdateRunResult["mode"];
|
||||||
serviceInstalled: boolean;
|
serviceInstalled: boolean;
|
||||||
@@ -109,163 +106,6 @@ export function shouldPrepareUpdatedInstallRestart(params: {
|
|||||||
return params.serviceLoaded;
|
return params.serviceLoaded;
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldUseLegacyProcessRestartAfterUpdate(params: {
|
|
||||||
updateMode: UpdateRunResult["mode"];
|
|
||||||
}): boolean {
|
|
||||||
return !isPackageManagerUpdateMode(params.updateMode);
|
|
||||||
}
|
|
||||||
|
|
||||||
type PostUpdateLaunchAgentRecoveryResult =
|
|
||||||
| { attempted: false; recovered: false }
|
|
||||||
| { attempted: true; recovered: true; message: string }
|
|
||||||
| { attempted: true; recovered: false; detail: string };
|
|
||||||
|
|
||||||
type PostUpdateLaunchAgentRecoveryDeps = {
|
|
||||||
platform?: NodeJS.Platform;
|
|
||||||
readState?: typeof readGatewayServiceState;
|
|
||||||
recover?: typeof recoverInstalledLaunchAgent;
|
|
||||||
};
|
|
||||||
|
|
||||||
async function recoverInstalledLaunchAgentAfterUpdate(params: {
|
|
||||||
service?: GatewayService;
|
|
||||||
env?: NodeJS.ProcessEnv;
|
|
||||||
deps?: PostUpdateLaunchAgentRecoveryDeps;
|
|
||||||
}): Promise<PostUpdateLaunchAgentRecoveryResult> {
|
|
||||||
const platform = params.deps?.platform ?? process.platform;
|
|
||||||
if (platform !== "darwin") {
|
|
||||||
return { attempted: false, recovered: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
const service = params.service ?? resolveGatewayService();
|
|
||||||
const readState = params.deps?.readState ?? readGatewayServiceState;
|
|
||||||
const recover = params.deps?.recover ?? recoverInstalledLaunchAgent;
|
|
||||||
const state = await readState(service, { env: params.env }).catch(() => null);
|
|
||||||
if (state?.loaded) {
|
|
||||||
return { attempted: false, recovered: false };
|
|
||||||
}
|
|
||||||
if (state && !state.installed && !state.runtime?.missingSupervision) {
|
|
||||||
return { attempted: false, recovered: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
const recovered = await recover({ result: "restarted", env: state?.env ?? params.env }).catch(
|
|
||||||
() => null,
|
|
||||||
);
|
|
||||||
if (!recovered) {
|
|
||||||
return {
|
|
||||||
attempted: true,
|
|
||||||
recovered: false,
|
|
||||||
detail:
|
|
||||||
"LaunchAgent was installed but not loaded; automatic bootstrap/kickstart recovery failed.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
attempted: true,
|
|
||||||
recovered: true,
|
|
||||||
message: recovered.message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
type PostUpdateGatewayHealthRecoveryDeps = {
|
|
||||||
recoverLaunchAgent?: typeof recoverInstalledLaunchAgentAfterUpdate;
|
|
||||||
waitForHealthy?: typeof waitForGatewayHealthyRestart;
|
|
||||||
};
|
|
||||||
|
|
||||||
async function recoverLaunchAgentAndRecheckGatewayHealth(params: {
|
|
||||||
health: GatewayRestartSnapshot;
|
|
||||||
service: GatewayService;
|
|
||||||
port: number;
|
|
||||||
expectedVersion?: string;
|
|
||||||
env?: NodeJS.ProcessEnv;
|
|
||||||
deps?: PostUpdateGatewayHealthRecoveryDeps;
|
|
||||||
}): Promise<{
|
|
||||||
health: GatewayRestartSnapshot;
|
|
||||||
launchAgentRecovery: PostUpdateLaunchAgentRecoveryResult | null;
|
|
||||||
}> {
|
|
||||||
if (params.health.healthy) {
|
|
||||||
return { health: params.health, launchAgentRecovery: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
const recoverLaunchAgent =
|
|
||||||
params.deps?.recoverLaunchAgent ?? recoverInstalledLaunchAgentAfterUpdate;
|
|
||||||
const launchAgentRecovery = await recoverLaunchAgent({
|
|
||||||
service: params.service,
|
|
||||||
env: params.env,
|
|
||||||
});
|
|
||||||
if (!launchAgentRecovery.recovered) {
|
|
||||||
return { health: params.health, launchAgentRecovery };
|
|
||||||
}
|
|
||||||
|
|
||||||
const waitForHealthy = params.deps?.waitForHealthy ?? waitForGatewayHealthyRestart;
|
|
||||||
const health = await waitForHealthy({
|
|
||||||
service: params.service,
|
|
||||||
port: params.port,
|
|
||||||
expectedVersion: params.expectedVersion,
|
|
||||||
env: params.env,
|
|
||||||
supervisorKeepsAlive: true,
|
|
||||||
});
|
|
||||||
return { health, launchAgentRecovery };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function hasLoadedLaunchdKeepAliveSupervisor(params: {
|
|
||||||
service: GatewayService;
|
|
||||||
env?: NodeJS.ProcessEnv;
|
|
||||||
}): Promise<boolean> {
|
|
||||||
if (process.platform !== "darwin") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// OpenClaw's loaded LaunchAgent has canonical KeepAlive policy. Read this once before
|
|
||||||
// polling so an unloaded agent can still reach the existing recovery path promptly.
|
|
||||||
return await params.service.isLoaded({ env: params.env }).catch(() => false);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPostUpdateGatewayRecoveryLine(platform: NodeJS.Platform): string {
|
|
||||||
const restartCommand = replaceCliName(formatCliCommand("openclaw gateway restart"), CLI_NAME);
|
|
||||||
const installCommand = replaceCliName(
|
|
||||||
formatCliCommand("openclaw gateway install --force"),
|
|
||||||
CLI_NAME,
|
|
||||||
);
|
|
||||||
const statusCommand = replaceCliName(
|
|
||||||
formatCliCommand("openclaw gateway status --deep"),
|
|
||||||
CLI_NAME,
|
|
||||||
);
|
|
||||||
if (platform === "darwin") {
|
|
||||||
return `Recovery: run \`${restartCommand}\`; if the LaunchAgent is installed but not loaded, run \`${installCommand}\` from the logged-in macOS user session, then rerun \`${statusCommand}\`.`;
|
|
||||||
}
|
|
||||||
if (platform === "linux") {
|
|
||||||
return `Recovery: run \`${restartCommand}\`; if the systemd user service is missing, stale, or not active, run \`${installCommand}\` from the same user account, then rerun \`${statusCommand}\`.`;
|
|
||||||
}
|
|
||||||
if (platform === "win32") {
|
|
||||||
return `Recovery: run \`${restartCommand}\`; if the gateway Scheduled Task or Windows login item is missing, stale, or not running, run \`${installCommand}\` from the same user account, then rerun \`${statusCommand}\`.`;
|
|
||||||
}
|
|
||||||
return `Recovery: run \`${restartCommand}\`; if the local service manager reports the gateway service is missing, stale, or not running, run \`${installCommand}\` from the same user account, then rerun \`${statusCommand}\`.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPostUpdateGatewayRecoveryInstructions(
|
|
||||||
result: UpdateRunResult,
|
|
||||||
platform: NodeJS.Platform = process.platform,
|
|
||||||
): string[] {
|
|
||||||
const lines = [formatPostUpdateGatewayRecoveryLine(platform)];
|
|
||||||
const beforeVersion = normalizeOptionalString(result.before?.version);
|
|
||||||
if (isPackageManagerUpdateMode(result.mode) && beforeVersion) {
|
|
||||||
lines.push(
|
|
||||||
`Rollback: reinstall OpenClaw ${beforeVersion} with the same package manager, then rerun \`${replaceCliName(formatCliCommand("openclaw gateway install --force"), CLI_NAME)}\`.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.env.VITEST || process.env.NODE_ENV === "test") {
|
|
||||||
(globalThis as Record<PropertyKey, unknown>)[Symbol.for("openclaw.updateCommandServiceTestApi")] =
|
|
||||||
{
|
|
||||||
formatPostUpdateGatewayRecoveryInstructions,
|
|
||||||
recoverInstalledLaunchAgentAfterUpdate,
|
|
||||||
recoverLaunchAgentAndRecheckGatewayHealth,
|
|
||||||
hasLoadedLaunchdKeepAliveSupervisor,
|
|
||||||
shouldUseLegacyProcessRestartAfterUpdate,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PreManagedServiceStop = {
|
export type PreManagedServiceStop = {
|
||||||
stopped: boolean;
|
stopped: boolean;
|
||||||
inspected: boolean;
|
inspected: boolean;
|
||||||
|
|||||||
@@ -10,11 +10,7 @@ import {
|
|||||||
updatePluginsAfterCoreUpdate,
|
updatePluginsAfterCoreUpdate,
|
||||||
type PostCorePluginUpdateResult,
|
type PostCorePluginUpdateResult,
|
||||||
} from "./update-command-plugins.js";
|
} from "./update-command-plugins.js";
|
||||||
import {
|
import { testing as updateCommandPluginsTesting } from "./update-command-plugins.test-support.js";
|
||||||
buildInvalidConfigPostCoreUpdateResult,
|
|
||||||
collectMissingPluginInstallPayloads,
|
|
||||||
resolvePostSyncPluginUpdateSkipIds,
|
|
||||||
} from "./update-command-plugins.test-support.js";
|
|
||||||
import { resolvePostCoreUpdateChildStdio } from "./update-command-post-core.js";
|
import { resolvePostCoreUpdateChildStdio } from "./update-command-post-core.js";
|
||||||
import { applyPostPluginConfigValidation } from "./update-command-post-plugin-validation.js";
|
import { applyPostPluginConfigValidation } from "./update-command-post-plugin-validation.js";
|
||||||
import {
|
import {
|
||||||
@@ -23,13 +19,7 @@ import {
|
|||||||
resolveUpdatedGatewayRestartPort,
|
resolveUpdatedGatewayRestartPort,
|
||||||
shouldPrepareUpdatedInstallRestart,
|
shouldPrepareUpdatedInstallRestart,
|
||||||
} from "./update-command-service.js";
|
} from "./update-command-service.js";
|
||||||
import {
|
import { testing as updateCommandServiceTesting } from "./update-command-service.test-support.js";
|
||||||
formatPostUpdateGatewayRecoveryInstructions,
|
|
||||||
hasLoadedLaunchdKeepAliveSupervisor,
|
|
||||||
recoverInstalledLaunchAgentAfterUpdate,
|
|
||||||
recoverLaunchAgentAndRecheckGatewayHealth,
|
|
||||||
shouldUseLegacyProcessRestartAfterUpdate,
|
|
||||||
} from "./update-command-service.test-support.js";
|
|
||||||
|
|
||||||
describe("resolveGatewayInstallEntrypoint", () => {
|
describe("resolveGatewayInstallEntrypoint", () => {
|
||||||
it("prefers dist/index.js over dist/entry.js when both exist", async () => {
|
it("prefers dist/index.js over dist/entry.js when both exist", async () => {
|
||||||
@@ -294,7 +284,7 @@ describe("collectMissingPluginInstallPayloads", () => {
|
|||||||
await fs.mkdir(noPackageJsonDir, { recursive: true });
|
await fs.mkdir(noPackageJsonDir, { recursive: true });
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
collectMissingPluginInstallPayloads({
|
updateCommandPluginsTesting.collectMissingPluginInstallPayloads({
|
||||||
env: { HOME: tmpDir } as NodeJS.ProcessEnv,
|
env: { HOME: tmpDir } as NodeJS.ProcessEnv,
|
||||||
records: {
|
records: {
|
||||||
present: {
|
present: {
|
||||||
@@ -355,7 +345,7 @@ describe("collectMissingPluginInstallPayloads", () => {
|
|||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
await expect(
|
await expect(
|
||||||
collectMissingPluginInstallPayloads({
|
updateCommandPluginsTesting.collectMissingPluginInstallPayloads({
|
||||||
env: { HOME: tmpDir } as NodeJS.ProcessEnv,
|
env: { HOME: tmpDir } as NodeJS.ProcessEnv,
|
||||||
records: {
|
records: {
|
||||||
"cursor-bundle": {
|
"cursor-bundle": {
|
||||||
@@ -382,7 +372,7 @@ describe("collectMissingPluginInstallPayloads", () => {
|
|||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
await expect(
|
await expect(
|
||||||
collectMissingPluginInstallPayloads({
|
updateCommandPluginsTesting.collectMissingPluginInstallPayloads({
|
||||||
env: { HOME: tmpDir } as NodeJS.ProcessEnv,
|
env: { HOME: tmpDir } as NodeJS.ProcessEnv,
|
||||||
records: {
|
records: {
|
||||||
"cursor-bundle": {
|
"cursor-bundle": {
|
||||||
@@ -419,7 +409,7 @@ describe("collectMissingPluginInstallPayloads", () => {
|
|||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
await expect(
|
await expect(
|
||||||
collectMissingPluginInstallPayloads({
|
updateCommandPluginsTesting.collectMissingPluginInstallPayloads({
|
||||||
env: { HOME: tmpDir } as NodeJS.ProcessEnv,
|
env: { HOME: tmpDir } as NodeJS.ProcessEnv,
|
||||||
records: {
|
records: {
|
||||||
"dual-format-bundle": {
|
"dual-format-bundle": {
|
||||||
@@ -442,7 +432,7 @@ describe("collectMissingPluginInstallPayloads", () => {
|
|||||||
await fs.mkdir(path.join(bundleDir, ".codex-plugin"), { recursive: true });
|
await fs.mkdir(path.join(bundleDir, ".codex-plugin"), { recursive: true });
|
||||||
await fs.writeFile(path.join(bundleDir, ".codex-plugin", "plugin.json"), "[]", "utf8");
|
await fs.writeFile(path.join(bundleDir, ".codex-plugin", "plugin.json"), "[]", "utf8");
|
||||||
await expect(
|
await expect(
|
||||||
collectMissingPluginInstallPayloads({
|
updateCommandPluginsTesting.collectMissingPluginInstallPayloads({
|
||||||
env: { HOME: tmpDir } as NodeJS.ProcessEnv,
|
env: { HOME: tmpDir } as NodeJS.ProcessEnv,
|
||||||
records: {
|
records: {
|
||||||
"bad-bundle": {
|
"bad-bundle": {
|
||||||
@@ -469,7 +459,7 @@ describe("collectMissingPluginInstallPayloads", () => {
|
|||||||
const missingDir = path.join(tmpDir, "state", "npm", "node_modules", "@openclaw", "missing");
|
const missingDir = path.join(tmpDir, "state", "npm", "node_modules", "@openclaw", "missing");
|
||||||
try {
|
try {
|
||||||
await expect(
|
await expect(
|
||||||
collectMissingPluginInstallPayloads({
|
updateCommandPluginsTesting.collectMissingPluginInstallPayloads({
|
||||||
env: { HOME: tmpDir } as NodeJS.ProcessEnv,
|
env: { HOME: tmpDir } as NodeJS.ProcessEnv,
|
||||||
skipDisabledPlugins: true,
|
skipDisabledPlugins: true,
|
||||||
config: {
|
config: {
|
||||||
@@ -500,7 +490,7 @@ describe("collectMissingPluginInstallPayloads", () => {
|
|||||||
const missingDir = path.join(tmpDir, "state", "npm", "node_modules", "@openclaw", "codex");
|
const missingDir = path.join(tmpDir, "state", "npm", "node_modules", "@openclaw", "codex");
|
||||||
try {
|
try {
|
||||||
await expect(
|
await expect(
|
||||||
collectMissingPluginInstallPayloads({
|
updateCommandPluginsTesting.collectMissingPluginInstallPayloads({
|
||||||
env: { HOME: tmpDir } as NodeJS.ProcessEnv,
|
env: { HOME: tmpDir } as NodeJS.ProcessEnv,
|
||||||
skipDisabledPlugins: true,
|
skipDisabledPlugins: true,
|
||||||
syncOfficialPluginInstalls: true,
|
syncOfficialPluginInstalls: true,
|
||||||
@@ -540,7 +530,7 @@ describe("collectMissingPluginInstallPayloads", () => {
|
|||||||
const missingDir = path.join(tmpDir, "state", "clawhub", "diagnostics-otel");
|
const missingDir = path.join(tmpDir, "state", "clawhub", "diagnostics-otel");
|
||||||
try {
|
try {
|
||||||
await expect(
|
await expect(
|
||||||
collectMissingPluginInstallPayloads({
|
updateCommandPluginsTesting.collectMissingPluginInstallPayloads({
|
||||||
env: { HOME: tmpDir } as NodeJS.ProcessEnv,
|
env: { HOME: tmpDir } as NodeJS.ProcessEnv,
|
||||||
skipDisabledPlugins: true,
|
skipDisabledPlugins: true,
|
||||||
syncOfficialPluginInstalls: true,
|
syncOfficialPluginInstalls: true,
|
||||||
@@ -577,7 +567,7 @@ describe("collectMissingPluginInstallPayloads", () => {
|
|||||||
describe("resolvePostSyncPluginUpdateSkipIds", () => {
|
describe("resolvePostSyncPluginUpdateSkipIds", () => {
|
||||||
it("skips plugins already switched through ClawHub or npm and repaired payloads", () => {
|
it("skips plugins already switched through ClawHub or npm and repaired payloads", () => {
|
||||||
expect(
|
expect(
|
||||||
resolvePostSyncPluginUpdateSkipIds({
|
updateCommandPluginsTesting.resolvePostSyncPluginUpdateSkipIds({
|
||||||
switchedToClawHub: ["whatsapp"],
|
switchedToClawHub: ["whatsapp"],
|
||||||
switchedToNpm: ["voice-call"],
|
switchedToNpm: ["voice-call"],
|
||||||
repairedMissingPayloadIds: new Set(["telegram"]),
|
repairedMissingPayloadIds: new Set(["telegram"]),
|
||||||
@@ -588,14 +578,26 @@ describe("resolvePostSyncPluginUpdateSkipIds", () => {
|
|||||||
|
|
||||||
describe("shouldUseLegacyProcessRestartAfterUpdate", () => {
|
describe("shouldUseLegacyProcessRestartAfterUpdate", () => {
|
||||||
it("never restarts package updates through the pre-update process", () => {
|
it("never restarts package updates through the pre-update process", () => {
|
||||||
expect(shouldUseLegacyProcessRestartAfterUpdate({ updateMode: "npm" })).toBe(false);
|
expect(
|
||||||
expect(shouldUseLegacyProcessRestartAfterUpdate({ updateMode: "pnpm" })).toBe(false);
|
updateCommandServiceTesting.shouldUseLegacyProcessRestartAfterUpdate({ updateMode: "npm" }),
|
||||||
expect(shouldUseLegacyProcessRestartAfterUpdate({ updateMode: "bun" })).toBe(false);
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
updateCommandServiceTesting.shouldUseLegacyProcessRestartAfterUpdate({ updateMode: "pnpm" }),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
updateCommandServiceTesting.shouldUseLegacyProcessRestartAfterUpdate({ updateMode: "bun" }),
|
||||||
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the in-process restart path for non-package updates", () => {
|
it("keeps the in-process restart path for non-package updates", () => {
|
||||||
expect(shouldUseLegacyProcessRestartAfterUpdate({ updateMode: "git" })).toBe(true);
|
expect(
|
||||||
expect(shouldUseLegacyProcessRestartAfterUpdate({ updateMode: "unknown" })).toBe(true);
|
updateCommandServiceTesting.shouldUseLegacyProcessRestartAfterUpdate({ updateMode: "git" }),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
updateCommandServiceTesting.shouldUseLegacyProcessRestartAfterUpdate({
|
||||||
|
updateMode: "unknown",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -608,7 +610,10 @@ describe("formatPostUpdateGatewayRecoveryInstructions", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
it("uses systemd wording on Linux instead of macOS LaunchAgent instructions", () => {
|
it("uses systemd wording on Linux instead of macOS LaunchAgent instructions", () => {
|
||||||
const [line] = formatPostUpdateGatewayRecoveryInstructions(result, "linux");
|
const [line] = updateCommandServiceTesting.formatPostUpdateGatewayRecoveryInstructions(
|
||||||
|
result,
|
||||||
|
"linux",
|
||||||
|
);
|
||||||
|
|
||||||
expect(line).toContain("the systemd user service");
|
expect(line).toContain("the systemd user service");
|
||||||
expect(line).toContain("openclaw gateway restart");
|
expect(line).toContain("openclaw gateway restart");
|
||||||
@@ -620,14 +625,20 @@ describe("formatPostUpdateGatewayRecoveryInstructions", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("keeps LaunchAgent recovery wording on macOS", () => {
|
it("keeps LaunchAgent recovery wording on macOS", () => {
|
||||||
const [line] = formatPostUpdateGatewayRecoveryInstructions(result, "darwin");
|
const [line] = updateCommandServiceTesting.formatPostUpdateGatewayRecoveryInstructions(
|
||||||
|
result,
|
||||||
|
"darwin",
|
||||||
|
);
|
||||||
|
|
||||||
expect(line).toContain("the LaunchAgent is installed but not loaded");
|
expect(line).toContain("the LaunchAgent is installed but not loaded");
|
||||||
expect(line).toContain("logged-in macOS user session");
|
expect(line).toContain("logged-in macOS user session");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses Windows service-manager wording on Windows", () => {
|
it("uses Windows service-manager wording on Windows", () => {
|
||||||
const [line] = formatPostUpdateGatewayRecoveryInstructions(result, "win32");
|
const [line] = updateCommandServiceTesting.formatPostUpdateGatewayRecoveryInstructions(
|
||||||
|
result,
|
||||||
|
"win32",
|
||||||
|
);
|
||||||
|
|
||||||
expect(line).toContain("the gateway Scheduled Task or Windows login item");
|
expect(line).toContain("the gateway Scheduled Task or Windows login item");
|
||||||
expect(line).not.toContain("LaunchAgent");
|
expect(line).not.toContain("LaunchAgent");
|
||||||
@@ -635,7 +646,10 @@ describe("formatPostUpdateGatewayRecoveryInstructions", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("uses generic service-manager wording for unsupported Node platforms", () => {
|
it("uses generic service-manager wording for unsupported Node platforms", () => {
|
||||||
const [line] = formatPostUpdateGatewayRecoveryInstructions(result, "freebsd");
|
const [line] = updateCommandServiceTesting.formatPostUpdateGatewayRecoveryInstructions(
|
||||||
|
result,
|
||||||
|
"freebsd",
|
||||||
|
);
|
||||||
|
|
||||||
expect(line).toContain("local service manager");
|
expect(line).toContain("local service manager");
|
||||||
expect(line).not.toContain("systemd");
|
expect(line).not.toContain("systemd");
|
||||||
@@ -664,7 +678,7 @@ describe("recoverInstalledLaunchAgentAfterUpdate", () => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
recoverInstalledLaunchAgentAfterUpdate({
|
updateCommandServiceTesting.recoverInstalledLaunchAgentAfterUpdate({
|
||||||
service,
|
service,
|
||||||
env: serviceEnv,
|
env: serviceEnv,
|
||||||
deps: {
|
deps: {
|
||||||
@@ -688,7 +702,7 @@ describe("recoverInstalledLaunchAgentAfterUpdate", () => {
|
|||||||
const recover = vi.fn();
|
const recover = vi.fn();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
recoverInstalledLaunchAgentAfterUpdate({
|
updateCommandServiceTesting.recoverInstalledLaunchAgentAfterUpdate({
|
||||||
service: {} as never,
|
service: {} as never,
|
||||||
deps: {
|
deps: {
|
||||||
platform: "linux",
|
platform: "linux",
|
||||||
@@ -714,7 +728,7 @@ describe("recoverInstalledLaunchAgentAfterUpdate", () => {
|
|||||||
const recover = vi.fn();
|
const recover = vi.fn();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
recoverInstalledLaunchAgentAfterUpdate({
|
updateCommandServiceTesting.recoverInstalledLaunchAgentAfterUpdate({
|
||||||
service: {} as never,
|
service: {} as never,
|
||||||
deps: {
|
deps: {
|
||||||
platform: "darwin",
|
platform: "darwin",
|
||||||
@@ -739,7 +753,7 @@ describe("recoverInstalledLaunchAgentAfterUpdate", () => {
|
|||||||
const recover = vi.fn(async () => null);
|
const recover = vi.fn(async () => null);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
recoverInstalledLaunchAgentAfterUpdate({
|
updateCommandServiceTesting.recoverInstalledLaunchAgentAfterUpdate({
|
||||||
service: {} as never,
|
service: {} as never,
|
||||||
deps: {
|
deps: {
|
||||||
platform: "darwin",
|
platform: "darwin",
|
||||||
@@ -782,7 +796,7 @@ describe("recoverLaunchAgentAndRecheckGatewayHealth", () => {
|
|||||||
const waitForHealthy = vi.fn(async () => healthy);
|
const waitForHealthy = vi.fn(async () => healthy);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
recoverLaunchAgentAndRecheckGatewayHealth({
|
updateCommandServiceTesting.recoverLaunchAgentAndRecheckGatewayHealth({
|
||||||
health: unhealthy,
|
health: unhealthy,
|
||||||
service,
|
service,
|
||||||
port: 18790,
|
port: 18790,
|
||||||
@@ -830,7 +844,7 @@ describe("recoverLaunchAgentAndRecheckGatewayHealth", () => {
|
|||||||
}));
|
}));
|
||||||
const waitForHealthy = vi.fn(async () => stillUnhealthy);
|
const waitForHealthy = vi.fn(async () => stillUnhealthy);
|
||||||
|
|
||||||
const result = await recoverLaunchAgentAndRecheckGatewayHealth({
|
const result = await updateCommandServiceTesting.recoverLaunchAgentAndRecheckGatewayHealth({
|
||||||
health: unhealthy,
|
health: unhealthy,
|
||||||
service,
|
service,
|
||||||
port: 18790,
|
port: 18790,
|
||||||
@@ -851,10 +865,15 @@ describe("hasLoadedLaunchdKeepAliveSupervisor", () => {
|
|||||||
const service = { isLoaded } as unknown as GatewayService;
|
const service = { isLoaded } as unknown as GatewayService;
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
hasLoadedLaunchdKeepAliveSupervisor({ service, env: { OPENCLAW_PROFILE: "work" } }),
|
updateCommandServiceTesting.hasLoadedLaunchdKeepAliveSupervisor({
|
||||||
|
service,
|
||||||
|
env: { OPENCLAW_PROFILE: "work" },
|
||||||
|
}),
|
||||||
).resolves.toBe(false);
|
).resolves.toBe(false);
|
||||||
isLoaded.mockResolvedValue(true);
|
isLoaded.mockResolvedValue(true);
|
||||||
await expect(hasLoadedLaunchdKeepAliveSupervisor({ service })).resolves.toBe(true);
|
await expect(
|
||||||
|
updateCommandServiceTesting.hasLoadedLaunchdKeepAliveSupervisor({ service }),
|
||||||
|
).resolves.toBe(true);
|
||||||
|
|
||||||
platformSpy.mockRestore();
|
platformSpy.mockRestore();
|
||||||
});
|
});
|
||||||
@@ -864,7 +883,7 @@ describe("hasLoadedLaunchdKeepAliveSupervisor", () => {
|
|||||||
const isLoaded = vi.fn().mockResolvedValue(true);
|
const isLoaded = vi.fn().mockResolvedValue(true);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
hasLoadedLaunchdKeepAliveSupervisor({
|
updateCommandServiceTesting.hasLoadedLaunchdKeepAliveSupervisor({
|
||||||
service: { isLoaded } as unknown as GatewayService,
|
service: { isLoaded } as unknown as GatewayService,
|
||||||
}),
|
}),
|
||||||
).resolves.toBe(false);
|
).resolves.toBe(false);
|
||||||
@@ -932,14 +951,14 @@ describe("updatePluginsAfterCoreUpdate (invalid config end-to-end)", () => {
|
|||||||
|
|
||||||
describe("buildInvalidConfigPostCoreUpdateResult", () => {
|
describe("buildInvalidConfigPostCoreUpdateResult", () => {
|
||||||
it("returns status:error so the existing pre-restart gate exits 1 instead of restarting on invalid config", () => {
|
it("returns status:error so the existing pre-restart gate exits 1 instead of restarting on invalid config", () => {
|
||||||
const built = buildInvalidConfigPostCoreUpdateResult();
|
const built = updateCommandPluginsTesting.buildInvalidConfigPostCoreUpdateResult();
|
||||||
expect(built.result.status).toBe("error");
|
expect(built.result.status).toBe("error");
|
||||||
expect(built.result.reason).toBe("invalid-config");
|
expect(built.result.reason).toBe("invalid-config");
|
||||||
expect(built.result.changed).toBe(false);
|
expect(built.result.changed).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("surfaces actionable repair guidance in both the structural warnings and the message string", () => {
|
it("surfaces actionable repair guidance in both the structural warnings and the message string", () => {
|
||||||
const built = buildInvalidConfigPostCoreUpdateResult();
|
const built = updateCommandPluginsTesting.buildInvalidConfigPostCoreUpdateResult();
|
||||||
expect(built.guidance).toStrictEqual([
|
expect(built.guidance).toStrictEqual([
|
||||||
"Run `openclaw doctor` to inspect the config validation errors.",
|
"Run `openclaw doctor` to inspect the config validation errors.",
|
||||||
"Once the config parses, rerun `openclaw update repair`.",
|
"Once the config parses, rerun `openclaw update repair`.",
|
||||||
|
|||||||
@@ -162,9 +162,9 @@ vi.mock("./onboard-channels.js", () => ({
|
|||||||
setupChannels: mocks.setupChannels,
|
setupChannels: mocks.setupChannels,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./onboard-search.js", () => ({
|
vi.mock("../flows/search-setup.js", () => ({
|
||||||
resolveSearchProviderOptions: mocks.resolveSearchProviderOptions,
|
resolveSearchProviderOptions: mocks.resolveSearchProviderOptions,
|
||||||
setupSearch: mocks.setupSearch,
|
runSearchSetupFlow: mocks.setupSearch,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../plugins/plugin-registry.js", () => ({
|
vi.mock("../plugins/plugin-registry.js", () => ({
|
||||||
|
|||||||
@@ -313,7 +313,8 @@ async function promptWebToolsConfig(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (configureManagedProvider) {
|
if (configureManagedProvider) {
|
||||||
const { resolveSearchProviderOptions, setupSearch } = await import("./onboard-search.js");
|
const { resolveSearchProviderOptions, runSearchSetupFlow } =
|
||||||
|
await import("../flows/search-setup.js");
|
||||||
const searchProviderOptions = resolveSearchProviderOptions(nextConfig);
|
const searchProviderOptions = resolveSearchProviderOptions(nextConfig);
|
||||||
if (searchProviderOptions.length === 0) {
|
if (searchProviderOptions.length === 0) {
|
||||||
note(
|
note(
|
||||||
@@ -331,7 +332,7 @@ async function promptWebToolsConfig(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
workingConfig = await setupSearch(workingConfig, runtime, prompter, {
|
workingConfig = await runSearchSetupFlow(workingConfig, runtime, prompter, {
|
||||||
preserveDisabledSearchState: false,
|
preserveDisabledSearchState: false,
|
||||||
});
|
});
|
||||||
const selectedSearch = workingConfig.tools?.web?.search;
|
const selectedSearch = workingConfig.tools?.web?.search;
|
||||||
|
|||||||
@@ -1,290 +0,0 @@
|
|||||||
// Onboard search provider tests cover provider discovery, credential reuse, and search setup choices.
|
|
||||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
||||||
import type { OpenClawConfig } from "../config/config.js";
|
|
||||||
import type { PluginWebSearchProviderEntry } from "../plugins/types.js";
|
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
|
||||||
resolvePluginWebSearchProviders: vi.fn<
|
|
||||||
(params?: { config?: OpenClawConfig }) => PluginWebSearchProviderEntry[]
|
|
||||||
>(() => []),
|
|
||||||
resolveWebSearchInstallCatalogEntries: vi.fn(() => []),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../plugins/web-search-providers.runtime.js", () => ({
|
|
||||||
resolvePluginWebSearchProviders: mocks.resolvePluginWebSearchProviders,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../plugins/web-search-install-catalog.js", () => ({
|
|
||||||
resolveWebSearchInstallCatalogEntries: mocks.resolveWebSearchInstallCatalogEntries,
|
|
||||||
}));
|
|
||||||
|
|
||||||
function createCustomProviderEntry(): PluginWebSearchProviderEntry {
|
|
||||||
return {
|
|
||||||
id: "custom-search" as never,
|
|
||||||
pluginId: "custom-plugin",
|
|
||||||
label: "Custom Search",
|
|
||||||
hint: "Custom provider",
|
|
||||||
onboardingScopes: ["text-inference"],
|
|
||||||
envVars: ["CUSTOM_SEARCH_API_KEY"],
|
|
||||||
placeholder: "custom-...",
|
|
||||||
signupUrl: "https://example.com/custom",
|
|
||||||
credentialPath: "plugins.entries.custom-plugin.config.webSearch.apiKey",
|
|
||||||
getCredentialValue: () => undefined,
|
|
||||||
setCredentialValue: () => {},
|
|
||||||
getConfiguredCredentialValue: (config) =>
|
|
||||||
(
|
|
||||||
config?.plugins?.entries?.["custom-plugin"]?.config as
|
|
||||||
| { webSearch?: { apiKey?: unknown } }
|
|
||||||
| undefined
|
|
||||||
)?.webSearch?.apiKey,
|
|
||||||
setConfiguredCredentialValue: (configTarget, value) => {
|
|
||||||
const entries = ((configTarget.plugins ??= {}).entries ??= {});
|
|
||||||
const pluginEntry = (entries["custom-plugin"] ??= {});
|
|
||||||
const pluginConfig = ((pluginEntry as Record<string, unknown>).config ??= {}) as Record<
|
|
||||||
string,
|
|
||||||
unknown
|
|
||||||
>;
|
|
||||||
const webSearch = (pluginConfig.webSearch ??= {}) as Record<string, unknown>;
|
|
||||||
webSearch.apiKey = value;
|
|
||||||
},
|
|
||||||
createTool: () => null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createBundledFirecrawlEntry(): PluginWebSearchProviderEntry {
|
|
||||||
return {
|
|
||||||
id: "firecrawl",
|
|
||||||
pluginId: "firecrawl",
|
|
||||||
label: "Firecrawl Search",
|
|
||||||
hint: "Structured results",
|
|
||||||
onboardingScopes: ["text-inference"],
|
|
||||||
envVars: ["FIRECRAWL_API_KEY"],
|
|
||||||
placeholder: "fc-...",
|
|
||||||
signupUrl: "https://example.com/firecrawl",
|
|
||||||
credentialPath: "plugins.entries.firecrawl.config.webSearch.apiKey",
|
|
||||||
getCredentialValue: () => undefined,
|
|
||||||
setCredentialValue: () => {},
|
|
||||||
getConfiguredCredentialValue: (config) =>
|
|
||||||
(
|
|
||||||
config?.plugins?.entries?.firecrawl?.config as
|
|
||||||
| { webSearch?: { apiKey?: unknown } }
|
|
||||||
| undefined
|
|
||||||
)?.webSearch?.apiKey,
|
|
||||||
setConfiguredCredentialValue: () => {},
|
|
||||||
createTool: () => null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createBundledDuckDuckGoEntry(): PluginWebSearchProviderEntry {
|
|
||||||
return {
|
|
||||||
id: "duckduckgo",
|
|
||||||
pluginId: "duckduckgo",
|
|
||||||
label: "DuckDuckGo Search (experimental)",
|
|
||||||
hint: "Free fallback",
|
|
||||||
onboardingScopes: ["text-inference"],
|
|
||||||
requiresCredential: false,
|
|
||||||
envVars: [],
|
|
||||||
placeholder: "(no key needed)",
|
|
||||||
signupUrl: "https://duckduckgo.com/",
|
|
||||||
credentialPath: "",
|
|
||||||
getCredentialValue: () => "duckduckgo-no-key-needed",
|
|
||||||
setCredentialValue: () => {},
|
|
||||||
createTool: () => null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("onboard-search provider resolution", () => {
|
|
||||||
let mod: typeof import("./onboard-search.js");
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
mod = await import("./onboard-search.js");
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses config-aware non-bundled provider hooks when resolving existing keys", () => {
|
|
||||||
const customEntry = createCustomProviderEntry();
|
|
||||||
mocks.resolvePluginWebSearchProviders.mockImplementation((params) =>
|
|
||||||
params?.config ? [customEntry] : [],
|
|
||||||
);
|
|
||||||
|
|
||||||
const cfg: OpenClawConfig = {
|
|
||||||
tools: {
|
|
||||||
web: {
|
|
||||||
search: {
|
|
||||||
provider: "custom-search" as never,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: {
|
|
||||||
entries: {
|
|
||||||
"custom-plugin": {
|
|
||||||
config: {
|
|
||||||
webSearch: {
|
|
||||||
apiKey: "custom-key",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(mod.hasExistingKey(cfg, "custom-search" as never)).toBe(true);
|
|
||||||
expect(mod.resolveExistingKey(cfg, "custom-search" as never)).toBe("custom-key");
|
|
||||||
|
|
||||||
const updated = mod.applySearchKey(cfg, "custom-search" as never, "next-key");
|
|
||||||
expect(
|
|
||||||
(
|
|
||||||
updated.plugins?.entries?.["custom-plugin"]?.config as
|
|
||||||
| { webSearch?: { apiKey?: unknown } }
|
|
||||||
| undefined
|
|
||||||
)?.webSearch?.apiKey,
|
|
||||||
).toBe("next-key");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses config-aware non-bundled providers when building secret refs", async () => {
|
|
||||||
const customEntry = createCustomProviderEntry();
|
|
||||||
mocks.resolvePluginWebSearchProviders.mockImplementation((params) =>
|
|
||||||
params?.config ? [customEntry] : [],
|
|
||||||
);
|
|
||||||
|
|
||||||
const cfg: OpenClawConfig = {
|
|
||||||
plugins: {
|
|
||||||
installs: {
|
|
||||||
"custom-plugin": {
|
|
||||||
installPath: "/tmp/custom-plugin",
|
|
||||||
source: "path",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const notes: Array<{ title?: string; message: string }> = [];
|
|
||||||
const prompter = {
|
|
||||||
intro: vi.fn(async () => {}),
|
|
||||||
outro: vi.fn(async () => {}),
|
|
||||||
note: vi.fn(async (message: string, title?: string) => {
|
|
||||||
notes.push({ title, message });
|
|
||||||
}),
|
|
||||||
select: vi.fn(async () => "custom-search"),
|
|
||||||
multiselect: vi.fn(async () => []),
|
|
||||||
text: vi.fn(async () => ""),
|
|
||||||
confirm: vi.fn(async () => true),
|
|
||||||
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await mod.setupSearch(cfg, {} as never, prompter as never, {
|
|
||||||
secretInputMode: "ref",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.tools?.web?.search?.provider).toBe("custom-search");
|
|
||||||
expect(result.tools?.web?.search?.enabled).toBe(true);
|
|
||||||
expect(
|
|
||||||
(
|
|
||||||
result.plugins?.entries?.["custom-plugin"]?.config as
|
|
||||||
| { webSearch?: { apiKey?: unknown } }
|
|
||||||
| undefined
|
|
||||||
)?.webSearch?.apiKey,
|
|
||||||
).toEqual({
|
|
||||||
source: "env",
|
|
||||||
provider: "default",
|
|
||||||
id: "CUSTOM_SEARCH_API_KEY",
|
|
||||||
});
|
|
||||||
expect(notes.map((note) => note.message).join("\n")).toContain("CUSTOM_SEARCH_API_KEY");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not treat hard-disabled bundled providers as selectable credentials", () => {
|
|
||||||
mocks.resolvePluginWebSearchProviders.mockReturnValue([]);
|
|
||||||
|
|
||||||
const cfg: OpenClawConfig = {
|
|
||||||
tools: {
|
|
||||||
web: {
|
|
||||||
search: {
|
|
||||||
provider: "firecrawl",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: {
|
|
||||||
enabled: false,
|
|
||||||
entries: {
|
|
||||||
firecrawl: {
|
|
||||||
config: {
|
|
||||||
webSearch: {
|
|
||||||
apiKey: "fc-disabled-key",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(mod.hasExistingKey(cfg, "firecrawl")).toBe(false);
|
|
||||||
expect(mod.resolveExistingKey(cfg, "firecrawl")).toBeUndefined();
|
|
||||||
expect(mod.applySearchProviderSelection(cfg, "firecrawl")).toBe(cfg);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("supports explicit keyless provider selection without defaulting to it", async () => {
|
|
||||||
const duckduckgoEntry = createBundledDuckDuckGoEntry();
|
|
||||||
mocks.resolvePluginWebSearchProviders.mockImplementation((params) =>
|
|
||||||
params?.config ? [duckduckgoEntry] : [duckduckgoEntry],
|
|
||||||
);
|
|
||||||
|
|
||||||
const notes: string[] = [];
|
|
||||||
const prompter = {
|
|
||||||
intro: vi.fn(async () => {}),
|
|
||||||
outro: vi.fn(async () => {}),
|
|
||||||
note: vi.fn(async (message: string) => {
|
|
||||||
notes.push(message);
|
|
||||||
}),
|
|
||||||
select: vi.fn(async () => "duckduckgo"),
|
|
||||||
multiselect: vi.fn(async () => []),
|
|
||||||
text: vi.fn(async () => {
|
|
||||||
throw new Error("text prompt should not run for keyless providers");
|
|
||||||
}),
|
|
||||||
confirm: vi.fn(async () => true),
|
|
||||||
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await mod.setupSearch({} as OpenClawConfig, {} as never, prompter as never);
|
|
||||||
|
|
||||||
expect(prompter.select).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ initialValue: "__skip__" }),
|
|
||||||
);
|
|
||||||
expect(result.tools?.web?.search?.provider).toBe("duckduckgo");
|
|
||||||
expect(result.plugins?.entries?.duckduckgo?.enabled).toBe(true);
|
|
||||||
expect(notes.join("\n")).toContain("works without an API key");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the runtime onboarding search surface when no config is present", () => {
|
|
||||||
const firecrawlEntry = createBundledFirecrawlEntry();
|
|
||||||
const duckduckgoEntry = createBundledDuckDuckGoEntry();
|
|
||||||
const tavilyEntry: PluginWebSearchProviderEntry = {
|
|
||||||
...firecrawlEntry,
|
|
||||||
id: "tavily",
|
|
||||||
pluginId: "tavily",
|
|
||||||
label: "Tavily Search",
|
|
||||||
hint: "Research search",
|
|
||||||
envVars: ["TAVILY_API_KEY"],
|
|
||||||
signupUrl: "https://example.com/tavily",
|
|
||||||
credentialPath: "plugins.entries.tavily.config.webSearch.apiKey",
|
|
||||||
};
|
|
||||||
const customEntry = createCustomProviderEntry();
|
|
||||||
|
|
||||||
mocks.resolvePluginWebSearchProviders.mockReturnValue([
|
|
||||||
customEntry,
|
|
||||||
duckduckgoEntry,
|
|
||||||
firecrawlEntry,
|
|
||||||
tavilyEntry,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const options = mod.resolveSearchProviderOptions();
|
|
||||||
|
|
||||||
expect(options.map((entry) => entry.id)).toEqual([
|
|
||||||
"custom-search",
|
|
||||||
"duckduckgo",
|
|
||||||
"firecrawl",
|
|
||||||
"tavily",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,717 +0,0 @@
|
|||||||
// Onboard search tests cover provider options, credential handling, and search setup config mutation.
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import type { OpenClawConfig } from "../config/config.js";
|
|
||||||
import type { PluginWebSearchProviderEntry } from "../plugins/types.js";
|
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
|
||||||
import type { WizardPrompter } from "../wizard/prompts.js";
|
|
||||||
import { listSearchProviderOptions, setupSearch } from "./onboard-search.js";
|
|
||||||
|
|
||||||
type WebSearchConfigRecord = {
|
|
||||||
plugins?: {
|
|
||||||
entries?: Record<
|
|
||||||
string,
|
|
||||||
{ enabled?: boolean; config?: { webSearch?: Record<string, unknown> } }
|
|
||||||
>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const SEARCH_PROVIDER_PLUGINS: Record<
|
|
||||||
string,
|
|
||||||
{ pluginId: string; envVars: string[]; label: string; credentialLabel?: string }
|
|
||||||
> = {
|
|
||||||
brave: { pluginId: "brave", envVars: ["BRAVE_API_KEY"], label: "Brave Search" },
|
|
||||||
firecrawl: { pluginId: "firecrawl", envVars: ["FIRECRAWL_API_KEY"], label: "Firecrawl" },
|
|
||||||
gemini: { pluginId: "google", envVars: ["GEMINI_API_KEY", "GOOGLE_API_KEY"], label: "Gemini" },
|
|
||||||
grok: { pluginId: "xai", envVars: ["XAI_API_KEY"], label: "Grok" },
|
|
||||||
kimi: {
|
|
||||||
pluginId: "moonshot",
|
|
||||||
envVars: ["KIMI_API_KEY", "MOONSHOT_API_KEY"],
|
|
||||||
label: "Kimi",
|
|
||||||
credentialLabel: "Moonshot / Kimi API key",
|
|
||||||
},
|
|
||||||
minimax: {
|
|
||||||
pluginId: "minimax",
|
|
||||||
envVars: [
|
|
||||||
"MINIMAX_CODE_PLAN_KEY",
|
|
||||||
"MINIMAX_CODING_API_KEY",
|
|
||||||
"MINIMAX_OAUTH_TOKEN",
|
|
||||||
"MINIMAX_API_KEY",
|
|
||||||
],
|
|
||||||
label: "MiniMax Search",
|
|
||||||
credentialLabel: "MiniMax Token Plan key or OAuth token",
|
|
||||||
},
|
|
||||||
perplexity: {
|
|
||||||
pluginId: "perplexity",
|
|
||||||
envVars: ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"],
|
|
||||||
label: "Perplexity",
|
|
||||||
},
|
|
||||||
tavily: { pluginId: "tavily", envVars: ["TAVILY_API_KEY"], label: "Tavily" },
|
|
||||||
};
|
|
||||||
|
|
||||||
function getWebSearchConfig(config: OpenClawConfig | undefined, pluginId: string) {
|
|
||||||
return (config as WebSearchConfigRecord | undefined)?.plugins?.entries?.[pluginId]?.config
|
|
||||||
?.webSearch;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureWebSearchConfig(config: OpenClawConfig, pluginId: string) {
|
|
||||||
const entries = ((config.plugins ??= {}).entries ??= {});
|
|
||||||
const pluginEntry = (entries[pluginId] ??= {}) as {
|
|
||||||
enabled?: boolean;
|
|
||||||
config?: { webSearch?: Record<string, unknown> };
|
|
||||||
};
|
|
||||||
pluginEntry.config ??= {};
|
|
||||||
pluginEntry.config.webSearch ??= {};
|
|
||||||
return pluginEntry.config.webSearch;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createSearchProviderEntry(id: string): PluginWebSearchProviderEntry {
|
|
||||||
const metadata = SEARCH_PROVIDER_PLUGINS[id];
|
|
||||||
if (!metadata) {
|
|
||||||
throw new Error(`missing search provider fixture: ${id}`);
|
|
||||||
}
|
|
||||||
const entry: PluginWebSearchProviderEntry = {
|
|
||||||
id: id as never,
|
|
||||||
pluginId: metadata.pluginId,
|
|
||||||
label: metadata.label,
|
|
||||||
hint: `${metadata.label} web search`,
|
|
||||||
onboardingScopes: ["text-inference"],
|
|
||||||
envVars: metadata.envVars,
|
|
||||||
placeholder: `${id}-key`,
|
|
||||||
signupUrl: `https://example.com/${id}`,
|
|
||||||
credentialLabel:
|
|
||||||
metadata.credentialLabel ??
|
|
||||||
(id === "gemini" ? "Google Gemini API key" : `${metadata.label} API key`),
|
|
||||||
credentialPath: `plugins.entries.${metadata.pluginId}.config.webSearch.apiKey`,
|
|
||||||
getCredentialValue: () => undefined,
|
|
||||||
setCredentialValue: () => {},
|
|
||||||
getConfiguredCredentialValue: (config) => getWebSearchConfig(config, metadata.pluginId)?.apiKey,
|
|
||||||
setConfiguredCredentialValue: (config, value) => {
|
|
||||||
ensureWebSearchConfig(config, metadata.pluginId).apiKey = value;
|
|
||||||
},
|
|
||||||
createTool: () => null,
|
|
||||||
applySelectionConfig: (config) => {
|
|
||||||
const next: OpenClawConfig = { ...config, plugins: { ...config.plugins } };
|
|
||||||
const entries = { ...next.plugins?.entries } as NonNullable<
|
|
||||||
NonNullable<OpenClawConfig["plugins"]>["entries"]
|
|
||||||
>;
|
|
||||||
entries[metadata.pluginId] = { ...entries[metadata.pluginId], enabled: true };
|
|
||||||
next.plugins = { ...next.plugins, entries };
|
|
||||||
if (id !== "firecrawl" || next.tools?.web?.fetch?.provider) {
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...next,
|
|
||||||
tools: {
|
|
||||||
...next.tools,
|
|
||||||
web: {
|
|
||||||
...next.tools?.web,
|
|
||||||
fetch: { provider: "firecrawl" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
if (id === "kimi") {
|
|
||||||
entry.runSetup = async ({ config, prompter }) => {
|
|
||||||
const baseUrl = await prompter.select({
|
|
||||||
message: "Moonshot endpoint",
|
|
||||||
options: [{ value: "https://api.moonshot.ai/v1", label: "Moonshot" }],
|
|
||||||
initialValue: "https://api.moonshot.ai/v1",
|
|
||||||
});
|
|
||||||
const modelChoice = await prompter.select({
|
|
||||||
message: "Moonshot web-search model",
|
|
||||||
options: [{ value: "__keep__", label: "Keep default" }],
|
|
||||||
initialValue: "__keep__",
|
|
||||||
});
|
|
||||||
const webSearch = ensureWebSearchConfig(config, metadata.pluginId);
|
|
||||||
webSearch.baseUrl = baseUrl;
|
|
||||||
webSearch.model = modelChoice === "__keep__" ? "kimi-k2.6" : modelChoice;
|
|
||||||
return config;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return entry;
|
|
||||||
}
|
|
||||||
|
|
||||||
const searchProviderFixture = vi.hoisted(() => ({
|
|
||||||
resolvePluginWebSearchProviders: vi.fn(() =>
|
|
||||||
["brave", "firecrawl", "gemini", "grok", "kimi", "minimax", "perplexity", "tavily"].map((id) =>
|
|
||||||
createSearchProviderEntry(id),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../plugins/web-search-providers.runtime.js", () => ({
|
|
||||||
resolvePluginWebSearchProviders: searchProviderFixture.resolvePluginWebSearchProviders,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const runtime: RuntimeEnv = {
|
|
||||||
log: vi.fn(),
|
|
||||||
error: vi.fn(),
|
|
||||||
exit: ((code: number) => {
|
|
||||||
throw new Error(`unexpected exit ${code}`);
|
|
||||||
}) as RuntimeEnv["exit"],
|
|
||||||
};
|
|
||||||
|
|
||||||
const SEARCH_PROVIDER_ENV_VARS = [
|
|
||||||
"BRAVE_API_KEY",
|
|
||||||
"FIRECRAWL_API_KEY",
|
|
||||||
"GEMINI_API_KEY",
|
|
||||||
"GOOGLE_API_KEY",
|
|
||||||
"KIMI_API_KEY",
|
|
||||||
"MOONSHOT_API_KEY",
|
|
||||||
"MINIMAX_API_KEY",
|
|
||||||
"MINIMAX_CODE_PLAN_KEY",
|
|
||||||
"MINIMAX_CODING_API_KEY",
|
|
||||||
"MINIMAX_OAUTH_TOKEN",
|
|
||||||
"OPENROUTER_API_KEY",
|
|
||||||
"PERPLEXITY_API_KEY",
|
|
||||||
"TAVILY_API_KEY",
|
|
||||||
"XAI_API_KEY",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
let originalSearchProviderEnv: Partial<Record<(typeof SEARCH_PROVIDER_ENV_VARS)[number], string>> =
|
|
||||||
{};
|
|
||||||
|
|
||||||
function createPrompter(params: {
|
|
||||||
selectValue?: string;
|
|
||||||
selectValues?: string[];
|
|
||||||
textValue?: string;
|
|
||||||
}): {
|
|
||||||
prompter: WizardPrompter;
|
|
||||||
notes: Array<{ title?: string; message: string }>;
|
|
||||||
} {
|
|
||||||
const notes: Array<{ title?: string; message: string }> = [];
|
|
||||||
const remainingSelectValues = [...(params.selectValues ?? [])];
|
|
||||||
const prompter: WizardPrompter = {
|
|
||||||
intro: vi.fn(async () => {}),
|
|
||||||
outro: vi.fn(async () => {}),
|
|
||||||
note: vi.fn(async (message: string, title?: string) => {
|
|
||||||
notes.push({ title, message });
|
|
||||||
}),
|
|
||||||
select: vi.fn(
|
|
||||||
async () => remainingSelectValues.shift() ?? params.selectValue ?? "perplexity",
|
|
||||||
) as unknown as WizardPrompter["select"],
|
|
||||||
multiselect: vi.fn(async () => []) as unknown as WizardPrompter["multiselect"],
|
|
||||||
text: vi.fn(async () => params.textValue ?? ""),
|
|
||||||
confirm: vi.fn(async () => true),
|
|
||||||
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
|
|
||||||
};
|
|
||||||
return { prompter, notes };
|
|
||||||
}
|
|
||||||
|
|
||||||
function mockCalls<T extends unknown[]>(fn: unknown): T[] {
|
|
||||||
return (fn as { mock: { calls: T[] } }).mock.calls;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createPerplexityConfig(apiKey: string, enabled?: boolean): OpenClawConfig {
|
|
||||||
return {
|
|
||||||
tools: {
|
|
||||||
web: {
|
|
||||||
search: {
|
|
||||||
provider: "perplexity",
|
|
||||||
...(enabled === undefined ? {} : { enabled }),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: {
|
|
||||||
entries: {
|
|
||||||
perplexity: {
|
|
||||||
config: {
|
|
||||||
webSearch: {
|
|
||||||
apiKey,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function pluginWebSearchApiKey(config: OpenClawConfig, pluginId: string): unknown {
|
|
||||||
const entry = (
|
|
||||||
config.plugins?.entries as
|
|
||||||
| Record<string, { config?: { webSearch?: { apiKey?: unknown } } }>
|
|
||||||
| undefined
|
|
||||||
)?.[pluginId];
|
|
||||||
return entry?.config?.webSearch?.apiKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createDisabledFirecrawlConfig(apiKey?: string): OpenClawConfig {
|
|
||||||
return {
|
|
||||||
tools: {
|
|
||||||
web: {
|
|
||||||
search: {
|
|
||||||
provider: "firecrawl",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: {
|
|
||||||
entries: {
|
|
||||||
firecrawl: {
|
|
||||||
enabled: false,
|
|
||||||
...(apiKey
|
|
||||||
? {
|
|
||||||
config: {
|
|
||||||
webSearch: {
|
|
||||||
apiKey,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function readFirecrawlPluginApiKey(config: OpenClawConfig): string | undefined {
|
|
||||||
const pluginConfig = config.plugins?.entries?.firecrawl?.config as
|
|
||||||
| {
|
|
||||||
webSearch?: {
|
|
||||||
apiKey?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
return pluginConfig?.webSearch?.apiKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runBlankPerplexityKeyEntry(
|
|
||||||
apiKey: string,
|
|
||||||
enabled?: boolean,
|
|
||||||
): Promise<OpenClawConfig> {
|
|
||||||
const cfg = createPerplexityConfig(apiKey, enabled);
|
|
||||||
const { prompter } = createPrompter({
|
|
||||||
selectValue: "perplexity",
|
|
||||||
textValue: "",
|
|
||||||
});
|
|
||||||
return setupSearch(cfg, runtime, prompter);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runQuickstartPerplexitySetup(
|
|
||||||
apiKey: string,
|
|
||||||
enabled?: boolean,
|
|
||||||
): Promise<{ result: OpenClawConfig; prompter: WizardPrompter }> {
|
|
||||||
const cfg = createPerplexityConfig(apiKey, enabled);
|
|
||||||
const { prompter } = createPrompter({ selectValue: "perplexity" });
|
|
||||||
const result = await setupSearch(cfg, runtime, prompter, {
|
|
||||||
quickstartDefaults: true,
|
|
||||||
});
|
|
||||||
return { result, prompter };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("setupSearch", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
originalSearchProviderEnv = Object.fromEntries(
|
|
||||||
SEARCH_PROVIDER_ENV_VARS.map((key) => [key, process.env[key]]),
|
|
||||||
);
|
|
||||||
for (const key of SEARCH_PROVIDER_ENV_VARS) {
|
|
||||||
delete process.env[key];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
for (const key of SEARCH_PROVIDER_ENV_VARS) {
|
|
||||||
const value = originalSearchProviderEnv[key];
|
|
||||||
if (value === undefined) {
|
|
||||||
delete process.env[key];
|
|
||||||
} else {
|
|
||||||
process.env[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns config unchanged when user skips", async () => {
|
|
||||||
const cfg: OpenClawConfig = {};
|
|
||||||
const { prompter } = createPrompter({ selectValue: "__skip__" });
|
|
||||||
const result = await setupSearch(cfg, runtime, prompter);
|
|
||||||
expect(result).toBe(cfg);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sets provider keys and enables plugin entries", async () => {
|
|
||||||
const cases = [
|
|
||||||
{ provider: "perplexity", pluginId: "perplexity", key: "pplx-test-key" },
|
|
||||||
{ provider: "brave", pluginId: "brave", key: "BSA-test-key" },
|
|
||||||
{ provider: "firecrawl", pluginId: "firecrawl", key: "fc-test-key" },
|
|
||||||
{ provider: "grok", pluginId: "xai", key: "xai-test" },
|
|
||||||
{ provider: "tavily", pluginId: "tavily", key: "tvly-test-key" },
|
|
||||||
{
|
|
||||||
provider: "gemini",
|
|
||||||
pluginId: "google",
|
|
||||||
key: "AIza-test",
|
|
||||||
textMessage: "Google Gemini API key",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const entry of cases) {
|
|
||||||
const cfg: OpenClawConfig = {};
|
|
||||||
const { prompter } = createPrompter({
|
|
||||||
selectValue: entry.provider,
|
|
||||||
textValue: entry.key,
|
|
||||||
});
|
|
||||||
const result = await setupSearch(cfg, runtime, prompter);
|
|
||||||
expect(result.tools?.web?.search?.provider).toBe(entry.provider);
|
|
||||||
expect(result.tools?.web?.search?.enabled).toBe(true);
|
|
||||||
expect(pluginWebSearchApiKey(result, entry.pluginId)).toBe(entry.key);
|
|
||||||
expect(result.plugins?.entries?.[entry.pluginId]?.enabled).toBe(true);
|
|
||||||
if (entry.textMessage) {
|
|
||||||
const textCall = mockCalls<[Record<string, unknown>]>(prompter.text).find(
|
|
||||||
([options]) => options.message === entry.textMessage,
|
|
||||||
);
|
|
||||||
expect(textCall?.[0].sensitive).toBe(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const kimiCfg: OpenClawConfig = {};
|
|
||||||
const { prompter: kimiPrompter } = createPrompter({
|
|
||||||
selectValues: ["kimi", "https://api.moonshot.ai/v1", "__keep__"],
|
|
||||||
textValue: "sk-moonshot",
|
|
||||||
});
|
|
||||||
const kimiResult = await setupSearch(kimiCfg, runtime, kimiPrompter);
|
|
||||||
const kimiWebSearchConfig = kimiResult.plugins?.entries?.moonshot?.config?.webSearch as
|
|
||||||
| {
|
|
||||||
baseUrl?: string;
|
|
||||||
model?: string;
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
expect(kimiResult.tools?.web?.search?.provider).toBe("kimi");
|
|
||||||
expect(kimiResult.tools?.web?.search?.enabled).toBe(true);
|
|
||||||
expect(pluginWebSearchApiKey(kimiResult, "moonshot")).toBe("sk-moonshot");
|
|
||||||
expect(kimiResult.plugins?.entries?.moonshot?.enabled).toBe(true);
|
|
||||||
expect(kimiWebSearchConfig?.baseUrl).toBe("https://api.moonshot.ai/v1");
|
|
||||||
expect(kimiWebSearchConfig?.model).toBe("kimi-k2.6");
|
|
||||||
|
|
||||||
const disabledCfg = createDisabledFirecrawlConfig();
|
|
||||||
const { prompter: disabledPrompter } = createPrompter({
|
|
||||||
selectValue: "firecrawl",
|
|
||||||
textValue: "fc-disabled-key",
|
|
||||||
});
|
|
||||||
const disabledResult = await setupSearch(disabledCfg, runtime, disabledPrompter);
|
|
||||||
expect(disabledResult.tools?.web?.search?.provider).toBe("firecrawl");
|
|
||||||
expect(disabledResult.tools?.web?.search?.enabled).toBe(true);
|
|
||||||
expect(disabledResult.plugins?.entries?.firecrawl?.enabled).toBe(true);
|
|
||||||
expect(readFirecrawlPluginApiKey(disabledResult)).toBe("fc-disabled-key");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows missing-key note when no key is provided and no env var", async () => {
|
|
||||||
const original = process.env.BRAVE_API_KEY;
|
|
||||||
delete process.env.BRAVE_API_KEY;
|
|
||||||
try {
|
|
||||||
const cfg: OpenClawConfig = {};
|
|
||||||
const { prompter, notes } = createPrompter({
|
|
||||||
selectValue: "brave",
|
|
||||||
textValue: "",
|
|
||||||
});
|
|
||||||
const result = await setupSearch(cfg, runtime, prompter);
|
|
||||||
expect(result.tools?.web?.search?.provider).toBe("brave");
|
|
||||||
expect(result.tools?.web?.search?.enabled).toBe(false);
|
|
||||||
const missingNote = notes.find((n) => n.message.includes("No Brave Search API key stored"));
|
|
||||||
expect(missingNote?.message).toContain("No Brave Search API key stored");
|
|
||||||
} finally {
|
|
||||||
if (original === undefined) {
|
|
||||||
delete process.env.BRAVE_API_KEY;
|
|
||||||
} else {
|
|
||||||
process.env.BRAVE_API_KEY = original;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps keyless Firecrawl fetch configured when search setup has no key", async () => {
|
|
||||||
const original = process.env.FIRECRAWL_API_KEY;
|
|
||||||
delete process.env.FIRECRAWL_API_KEY;
|
|
||||||
try {
|
|
||||||
const { prompter } = createPrompter({
|
|
||||||
selectValue: "firecrawl",
|
|
||||||
textValue: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await setupSearch({}, runtime, prompter);
|
|
||||||
|
|
||||||
expect(result.tools?.web?.search?.provider).toBe("firecrawl");
|
|
||||||
expect(result.tools?.web?.search?.enabled).toBe(false);
|
|
||||||
expect(result.tools?.web?.fetch?.provider).toBe("firecrawl");
|
|
||||||
expect(result.plugins?.entries?.firecrawl?.enabled).toBe(true);
|
|
||||||
} finally {
|
|
||||||
if (original === undefined) {
|
|
||||||
delete process.env.FIRECRAWL_API_KEY;
|
|
||||||
} else {
|
|
||||||
process.env.FIRECRAWL_API_KEY = original;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps existing key when user leaves input blank", async () => {
|
|
||||||
const result = await runBlankPerplexityKeyEntry(
|
|
||||||
"existing-key", // pragma: allowlist secret
|
|
||||||
);
|
|
||||||
expect(pluginWebSearchApiKey(result, "perplexity")).toBe("existing-key");
|
|
||||||
expect(result.tools?.web?.search?.enabled).toBe(true);
|
|
||||||
|
|
||||||
const disabledResult = await runBlankPerplexityKeyEntry(
|
|
||||||
"existing-key", // pragma: allowlist secret
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
expect(pluginWebSearchApiKey(disabledResult, "perplexity")).toBe("existing-key");
|
|
||||||
expect(disabledResult.tools?.web?.search?.enabled).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("quickstart skips key prompt when config key exists", async () => {
|
|
||||||
const { result, prompter } = await runQuickstartPerplexitySetup(
|
|
||||||
"stored-pplx-key", // pragma: allowlist secret
|
|
||||||
);
|
|
||||||
expect(result.tools?.web?.search?.provider).toBe("perplexity");
|
|
||||||
expect(pluginWebSearchApiKey(result, "perplexity")).toBe("stored-pplx-key");
|
|
||||||
expect(result.tools?.web?.search?.enabled).toBe(true);
|
|
||||||
expect(prompter.text).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
const { result: disabledResult, prompter: disabledPrompter } =
|
|
||||||
await runQuickstartPerplexitySetup(
|
|
||||||
"stored-pplx-key", // pragma: allowlist secret
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
expect(disabledResult.tools?.web?.search?.provider).toBe("perplexity");
|
|
||||||
expect(pluginWebSearchApiKey(disabledResult, "perplexity")).toBe("stored-pplx-key");
|
|
||||||
expect(disabledResult.tools?.web?.search?.enabled).toBe(false);
|
|
||||||
expect(disabledPrompter.text).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("quickstart skips key prompt when canonical plugin config key exists", async () => {
|
|
||||||
const cfg: OpenClawConfig = {
|
|
||||||
tools: {
|
|
||||||
web: {
|
|
||||||
search: {
|
|
||||||
provider: "tavily",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: {
|
|
||||||
entries: {
|
|
||||||
tavily: {
|
|
||||||
enabled: true,
|
|
||||||
config: {
|
|
||||||
webSearch: {
|
|
||||||
apiKey: "tvly-existing-key",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const { prompter } = createPrompter({ selectValue: "tavily" });
|
|
||||||
const result = await setupSearch(cfg, runtime, prompter, {
|
|
||||||
quickstartDefaults: true,
|
|
||||||
});
|
|
||||||
expect(result.tools?.web?.search?.provider).toBe("tavily");
|
|
||||||
expect(pluginWebSearchApiKey(result, "tavily")).toBe("tvly-existing-key");
|
|
||||||
expect(result.tools?.web?.search?.enabled).toBe(true);
|
|
||||||
expect(prompter.text).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("quickstart falls through to key prompt when no key and no env var", async () => {
|
|
||||||
const original = process.env.XAI_API_KEY;
|
|
||||||
delete process.env.XAI_API_KEY;
|
|
||||||
try {
|
|
||||||
const cfg: OpenClawConfig = {};
|
|
||||||
const { prompter } = createPrompter({ selectValue: "grok", textValue: "" });
|
|
||||||
const result = await setupSearch(cfg, runtime, prompter, {
|
|
||||||
quickstartDefaults: true,
|
|
||||||
});
|
|
||||||
expect(prompter.text).toHaveBeenCalled();
|
|
||||||
expect(result.tools?.web?.search?.provider).toBe("grok");
|
|
||||||
expect(result.tools?.web?.search?.enabled).toBe(false);
|
|
||||||
} finally {
|
|
||||||
if (original === undefined) {
|
|
||||||
delete process.env.XAI_API_KEY;
|
|
||||||
} else {
|
|
||||||
process.env.XAI_API_KEY = original;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses provider-specific credential copy for kimi in onboarding", async () => {
|
|
||||||
const cfg: OpenClawConfig = {};
|
|
||||||
const { prompter } = createPrompter({
|
|
||||||
selectValue: "kimi",
|
|
||||||
textValue: "",
|
|
||||||
});
|
|
||||||
await setupSearch(cfg, runtime, prompter);
|
|
||||||
const textCall = mockCalls<[Record<string, unknown>]>(prompter.text).find(
|
|
||||||
([options]) => options.message === "Moonshot / Kimi API key",
|
|
||||||
);
|
|
||||||
expect(textCall?.[0].message).toBe("Moonshot / Kimi API key");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("quickstart skips key prompt when env var is available", async () => {
|
|
||||||
const orig = process.env.BRAVE_API_KEY;
|
|
||||||
process.env.BRAVE_API_KEY = "env-brave-key"; // pragma: allowlist secret
|
|
||||||
try {
|
|
||||||
const cfg: OpenClawConfig = {};
|
|
||||||
const { prompter } = createPrompter({ selectValue: "brave" });
|
|
||||||
const result = await setupSearch(cfg, runtime, prompter, {
|
|
||||||
quickstartDefaults: true,
|
|
||||||
});
|
|
||||||
expect(result.tools?.web?.search?.provider).toBe("brave");
|
|
||||||
expect(result.tools?.web?.search?.enabled).toBe(true);
|
|
||||||
expect(prompter.text).not.toHaveBeenCalled();
|
|
||||||
} finally {
|
|
||||||
if (orig === undefined) {
|
|
||||||
delete process.env.BRAVE_API_KEY;
|
|
||||||
} else {
|
|
||||||
process.env.BRAVE_API_KEY = orig;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("quickstart detects an existing firecrawl key even when the plugin is disabled", async () => {
|
|
||||||
const cfg = createDisabledFirecrawlConfig("fc-configured-key");
|
|
||||||
const { prompter } = createPrompter({ selectValue: "firecrawl" });
|
|
||||||
const result = await setupSearch(cfg, runtime, prompter, {
|
|
||||||
quickstartDefaults: true,
|
|
||||||
});
|
|
||||||
expect(prompter.text).not.toHaveBeenCalled();
|
|
||||||
expect(result.tools?.web?.search?.provider).toBe("firecrawl");
|
|
||||||
expect(result.tools?.web?.search?.enabled).toBe(true);
|
|
||||||
expect(result.plugins?.entries?.firecrawl?.enabled).toBe(true);
|
|
||||||
expect(readFirecrawlPluginApiKey(result)).toBe("fc-configured-key");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves disabled firecrawl plugin state and allowlist when web search stays disabled", async () => {
|
|
||||||
const original = process.env.FIRECRAWL_API_KEY;
|
|
||||||
process.env.FIRECRAWL_API_KEY = "env-firecrawl-key"; // pragma: allowlist secret
|
|
||||||
const cfg: OpenClawConfig = {
|
|
||||||
tools: {
|
|
||||||
web: {
|
|
||||||
search: {
|
|
||||||
provider: "firecrawl",
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: {
|
|
||||||
allow: ["google"],
|
|
||||||
entries: {
|
|
||||||
firecrawl: {
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
const { prompter } = createPrompter({ selectValue: "firecrawl" });
|
|
||||||
const result = await setupSearch(cfg, runtime, prompter, {
|
|
||||||
quickstartDefaults: true,
|
|
||||||
});
|
|
||||||
expect(prompter.text).not.toHaveBeenCalled();
|
|
||||||
expect(result.tools?.web?.search?.provider).toBe("firecrawl");
|
|
||||||
expect(result.tools?.web?.search?.enabled).toBe(false);
|
|
||||||
expect(result.plugins?.entries?.firecrawl?.enabled).toBe(false);
|
|
||||||
expect(result.plugins?.allow).toEqual(["google"]);
|
|
||||||
} finally {
|
|
||||||
if (original === undefined) {
|
|
||||||
delete process.env.FIRECRAWL_API_KEY;
|
|
||||||
} else {
|
|
||||||
process.env.FIRECRAWL_API_KEY = original;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stores env-backed SecretRef for perplexity ref mode", async () => {
|
|
||||||
const originalPerplexity = process.env.PERPLEXITY_API_KEY;
|
|
||||||
const originalOpenRouter = process.env.OPENROUTER_API_KEY;
|
|
||||||
delete process.env.PERPLEXITY_API_KEY;
|
|
||||||
delete process.env.OPENROUTER_API_KEY;
|
|
||||||
try {
|
|
||||||
const { prompter } = createPrompter({ selectValue: "perplexity" });
|
|
||||||
const result = await setupSearch({}, runtime, prompter, {
|
|
||||||
secretInputMode: "ref", // pragma: allowlist secret
|
|
||||||
});
|
|
||||||
expect(result.tools?.web?.search?.provider).toBe("perplexity");
|
|
||||||
expect(pluginWebSearchApiKey(result, "perplexity")).toEqual({
|
|
||||||
source: "env",
|
|
||||||
provider: "default",
|
|
||||||
id: "PERPLEXITY_API_KEY", // pragma: allowlist secret
|
|
||||||
});
|
|
||||||
expect(prompter.text).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
process.env.OPENROUTER_API_KEY = "sk-or-test";
|
|
||||||
const { prompter: openRouterPrompter } = createPrompter({ selectValue: "perplexity" });
|
|
||||||
const openRouterResult = await setupSearch({}, runtime, openRouterPrompter, {
|
|
||||||
secretInputMode: "ref", // pragma: allowlist secret
|
|
||||||
});
|
|
||||||
expect(pluginWebSearchApiKey(openRouterResult, "perplexity")).toEqual({
|
|
||||||
source: "env",
|
|
||||||
provider: "default",
|
|
||||||
id: "OPENROUTER_API_KEY", // pragma: allowlist secret
|
|
||||||
});
|
|
||||||
expect(openRouterPrompter.text).not.toHaveBeenCalled();
|
|
||||||
} finally {
|
|
||||||
if (originalPerplexity === undefined) {
|
|
||||||
delete process.env.PERPLEXITY_API_KEY;
|
|
||||||
} else {
|
|
||||||
process.env.PERPLEXITY_API_KEY = originalPerplexity;
|
|
||||||
}
|
|
||||||
if (originalOpenRouter === undefined) {
|
|
||||||
delete process.env.OPENROUTER_API_KEY;
|
|
||||||
} else {
|
|
||||||
process.env.OPENROUTER_API_KEY = originalOpenRouter;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stores env-backed SecretRefs for simple providers", async () => {
|
|
||||||
const original = process.env.TAVILY_API_KEY;
|
|
||||||
delete process.env.TAVILY_API_KEY;
|
|
||||||
try {
|
|
||||||
for (const entry of [
|
|
||||||
{ provider: "brave", pluginId: "brave", env: "BRAVE_API_KEY" },
|
|
||||||
{ provider: "tavily", pluginId: "tavily", env: "TAVILY_API_KEY" },
|
|
||||||
]) {
|
|
||||||
const { prompter } = createPrompter({ selectValue: entry.provider });
|
|
||||||
const result = await setupSearch({}, runtime, prompter, {
|
|
||||||
secretInputMode: "ref", // pragma: allowlist secret
|
|
||||||
});
|
|
||||||
expect(result.tools?.web?.search?.provider).toBe(entry.provider);
|
|
||||||
expect(pluginWebSearchApiKey(result, entry.pluginId)).toEqual({
|
|
||||||
source: "env",
|
|
||||||
provider: "default",
|
|
||||||
id: entry.env,
|
|
||||||
});
|
|
||||||
expect(result.plugins?.entries?.[entry.pluginId]?.enabled).toBe(true);
|
|
||||||
expect(prompter.text).not.toHaveBeenCalled();
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (original === undefined) {
|
|
||||||
delete process.env.TAVILY_API_KEY;
|
|
||||||
} else {
|
|
||||||
process.env.TAVILY_API_KEY = original;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stores plaintext key when secretInputMode is unset", async () => {
|
|
||||||
const cfg: OpenClawConfig = {};
|
|
||||||
const { prompter } = createPrompter({
|
|
||||||
selectValue: "brave",
|
|
||||||
textValue: "BSA-plain",
|
|
||||||
});
|
|
||||||
const result = await setupSearch(cfg, runtime, prompter);
|
|
||||||
expect(pluginWebSearchApiKey(result, "brave")).toBe("BSA-plain");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("exports search providers in alphabetical order", () => {
|
|
||||||
const providers = listSearchProviderOptions();
|
|
||||||
const values = providers.map((e) => e.id);
|
|
||||||
expect(values).toEqual([...values].toSorted());
|
|
||||||
for (const providerId of [
|
|
||||||
"brave",
|
|
||||||
"firecrawl",
|
|
||||||
"gemini",
|
|
||||||
"grok",
|
|
||||||
"kimi",
|
|
||||||
"minimax",
|
|
||||||
"perplexity",
|
|
||||||
"tavily",
|
|
||||||
]) {
|
|
||||||
expect(values).toContain(providerId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
/**
|
|
||||||
* Search setup command barrel.
|
|
||||||
*
|
|
||||||
* Keeps legacy onboard imports pointed at the shared search setup flow without
|
|
||||||
* pulling that flow into unrelated command modules.
|
|
||||||
*/
|
|
||||||
export {
|
|
||||||
applySearchKey,
|
|
||||||
applySearchProviderSelection,
|
|
||||||
hasExistingKey,
|
|
||||||
hasKeyInEnv,
|
|
||||||
listSearchProviderOptions,
|
|
||||||
resolveExistingKey,
|
|
||||||
resolveSearchProviderOptions,
|
|
||||||
runSearchSetupFlow as setupSearch,
|
|
||||||
} from "../flows/search-setup.js";
|
|
||||||
@@ -165,18 +165,20 @@ describe("talk normalization", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("preserves normalized realtime instructions in talk.config payloads", () => {
|
it("preserves normalized realtime instructions in talk.config payloads", () => {
|
||||||
const payload = buildTalkConfigResponse({
|
const payload = buildTalkConfigResponse(
|
||||||
realtime: {
|
normalizeTalkSection({
|
||||||
provider: "openai",
|
realtime: {
|
||||||
providers: {
|
provider: "openai",
|
||||||
openai: {
|
providers: {
|
||||||
model: "gpt-realtime",
|
openai: {
|
||||||
speakerVoice: "alloy",
|
model: "gpt-realtime",
|
||||||
|
speakerVoice: "alloy",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
instructions: " Speak with crisp diction. ",
|
||||||
},
|
},
|
||||||
instructions: " Speak with crisp diction. ",
|
}),
|
||||||
},
|
);
|
||||||
});
|
|
||||||
|
|
||||||
expect(payload?.realtime?.provider).toBe("openai");
|
expect(payload?.realtime?.provider).toBe("openai");
|
||||||
expect(payload?.realtime?.instructions).toBe("Speak with crisp diction.");
|
expect(payload?.realtime?.instructions).toBe("Speak with crisp diction.");
|
||||||
@@ -255,14 +257,4 @@ describe("talk normalization", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not inject provider apiKey defaults during snapshot materialization", () => {
|
|
||||||
const payload = buildTalkConfigResponse({
|
|
||||||
voiceId: "voice-123",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(payload?.provider).toBe("elevenlabs");
|
|
||||||
expect(payload?.resolved?.config.voiceId).toBe("voice-123");
|
|
||||||
expect(payload?.resolved?.config.apiKey).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
+6
-29
@@ -51,22 +51,6 @@ function normalizeNonNegativeInteger(value: unknown): number | undefined {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildLegacyTalkProviderCompat(
|
|
||||||
value: Record<string, unknown>,
|
|
||||||
): TalkProviderConfig | undefined {
|
|
||||||
const provider: TalkProviderConfig = {};
|
|
||||||
for (const key of ["voiceId", "voiceAliases", "modelId", "outputFormat"] as const) {
|
|
||||||
if (value[key] !== undefined) {
|
|
||||||
provider[key] = value[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const apiKey = normalizeTalkSecretInput(value.apiKey);
|
|
||||||
if (apiKey !== undefined) {
|
|
||||||
provider.apiKey = apiKey;
|
|
||||||
}
|
|
||||||
return Object.keys(provider).length > 0 ? provider : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeTalkProviderConfig(value: unknown): TalkProviderConfig | undefined {
|
function normalizeTalkProviderConfig(value: unknown): TalkProviderConfig | undefined {
|
||||||
if (!isRecord(value)) {
|
if (!isRecord(value)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -292,16 +276,13 @@ export function resolveActiveTalkProviderConfig(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the gateway `talk.config` payload from persisted config.
|
* Build the gateway `talk.config` payload from canonical Talk config.
|
||||||
* The response includes canonical provider data plus the resolved provider when selection is unambiguous.
|
* The response includes canonical provider data plus the resolved provider when selection is unambiguous.
|
||||||
*/
|
*/
|
||||||
export function buildTalkConfigResponse(value: unknown): TalkConfigResponse | undefined {
|
export function buildTalkConfigResponse(
|
||||||
if (!isRecord(value)) {
|
normalized: TalkConfig | undefined,
|
||||||
return undefined;
|
): TalkConfigResponse | undefined {
|
||||||
}
|
if (!normalized) {
|
||||||
const normalized = normalizeTalkSection(value as TalkConfig);
|
|
||||||
const legacyCompat = buildLegacyTalkProviderCompat(value);
|
|
||||||
if (!normalized && !legacyCompat) {
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,11 +312,7 @@ export function buildTalkConfigResponse(value: unknown): TalkConfigResponse | un
|
|||||||
payload.realtime = normalized.realtime;
|
payload.realtime = normalized.realtime;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep legacy flat ElevenLabs fields readable for clients while migration moves writes to
|
const resolved = resolveActiveTalkProviderConfig(normalized);
|
||||||
// talk.provider/providers; normalizeTalkSection intentionally excludes those provider details.
|
|
||||||
const resolved =
|
|
||||||
resolveActiveTalkProviderConfig(normalized) ??
|
|
||||||
(legacyCompat ? { provider: "elevenlabs", config: legacyCompat } : undefined);
|
|
||||||
const activeProvider = resolved?.provider;
|
const activeProvider = resolved?.provider;
|
||||||
if (activeProvider) {
|
if (activeProvider) {
|
||||||
payload.provider = activeProvider;
|
payload.provider = activeProvider;
|
||||||
|
|||||||
@@ -481,6 +481,8 @@ async function resolveTalkResponseFromConfig(params: {
|
|||||||
sourceConfig: OpenClawConfig;
|
sourceConfig: OpenClawConfig;
|
||||||
runtimeConfig: OpenClawConfig;
|
runtimeConfig: OpenClawConfig;
|
||||||
}): Promise<TalkConfigResponse | undefined> {
|
}): Promise<TalkConfigResponse | undefined> {
|
||||||
|
// Normalize once at the Gateway boundary. Legacy flat provider fields belong to doctor
|
||||||
|
// migration and must not leak into steady-state response construction.
|
||||||
const normalizedTalk = normalizeTalkSection(params.sourceConfig.talk);
|
const normalizedTalk = normalizeTalkSection(params.sourceConfig.talk);
|
||||||
const configuredPayload = normalizedTalk ? buildTalkConfigResponse(normalizedTalk) : undefined;
|
const configuredPayload = normalizedTalk ? buildTalkConfigResponse(normalizedTalk) : undefined;
|
||||||
// Resolve provider selection from materialized config, but project provider-owned fields from
|
// Resolve provider selection from materialized config, but project provider-owned fields from
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
formatUnresolvedOpenClawPeerLinkError,
|
formatUnresolvedOpenClawPeerLinkError,
|
||||||
hasPackageRuntimeDependencies,
|
hasPackageRuntimeDependencies,
|
||||||
loadPluginInstallRuntime,
|
loadPluginInstallRuntime,
|
||||||
|
readOptionalPackageManifest,
|
||||||
runInstallSourceScan,
|
runInstallSourceScan,
|
||||||
sourceFamilyForInstallPolicyKind,
|
sourceFamilyForInstallPolicyKind,
|
||||||
sourceFamilyForInstallPolicySource,
|
sourceFamilyForInstallPolicySource,
|
||||||
@@ -42,6 +43,7 @@ type ValidatedPackagePlugin = {
|
|||||||
export async function validatePackagePluginInstallSource(params: {
|
export async function validatePackagePluginInstallSource(params: {
|
||||||
runtime: Awaited<ReturnType<typeof loadPluginInstallRuntime>>;
|
runtime: Awaited<ReturnType<typeof loadPluginInstallRuntime>>;
|
||||||
packageDir: string;
|
packageDir: string;
|
||||||
|
manifest?: PackageManifest;
|
||||||
expectedPluginId?: string;
|
expectedPluginId?: string;
|
||||||
requirePluginManifest?: boolean;
|
requirePluginManifest?: boolean;
|
||||||
allowSourceTypeScriptEntries?: boolean;
|
allowSourceTypeScriptEntries?: boolean;
|
||||||
@@ -59,16 +61,15 @@ export async function validatePackagePluginInstallSource(params: {
|
|||||||
}
|
}
|
||||||
| PluginInstallFailureResult
|
| PluginInstallFailureResult
|
||||||
> {
|
> {
|
||||||
const manifestPath = path.join(params.packageDir, "package.json");
|
const manifestResult = params.manifest
|
||||||
if (!(await params.runtime.fileExists(manifestPath))) {
|
? ({ ok: true, manifest: params.manifest } as const)
|
||||||
return { ok: false, error: "extracted package missing package.json" };
|
: await readOptionalPackageManifest({ runtime: params.runtime, packageDir: params.packageDir });
|
||||||
|
if (!manifestResult.ok) {
|
||||||
|
return manifestResult;
|
||||||
}
|
}
|
||||||
|
const manifest = manifestResult.manifest;
|
||||||
let manifest: PackageManifest;
|
if (!manifest) {
|
||||||
try {
|
return { ok: false, error: "extracted package missing package.json" };
|
||||||
manifest = await params.runtime.readJsonFile<PackageManifest>(manifestPath);
|
|
||||||
} catch (err) {
|
|
||||||
return { ok: false, error: `invalid package.json: ${String(err)}` };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const pkgName = normalizeOptionalString(manifest.name) ?? "";
|
const pkgName = normalizeOptionalString(manifest.name) ?? "";
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
|
||||||
import { resolveUserPath } from "../utils.js";
|
import { resolveUserPath } from "../utils.js";
|
||||||
import {
|
import {
|
||||||
scanAndLinkInstalledPackage,
|
scanAndLinkInstalledPackage,
|
||||||
@@ -194,10 +193,11 @@ async function installPluginFromSourceDir(
|
|||||||
sourceDir: string;
|
sourceDir: string;
|
||||||
} & InternalPackageInstallCommonParams,
|
} & InternalPackageInstallCommonParams,
|
||||||
): Promise<InstallPluginResult> {
|
): Promise<InstallPluginResult> {
|
||||||
const nativePackageDetected = await detectNativePackageInstallSource(params.sourceDir);
|
const nativePackageManifest = await detectNativePackageInstallSource(params.sourceDir);
|
||||||
if (nativePackageDetected) {
|
if (nativePackageManifest) {
|
||||||
return await installPluginFromPackageDir({
|
return await installPluginFromPackageDir({
|
||||||
packageDir: params.sourceDir,
|
packageDir: params.sourceDir,
|
||||||
|
packageManifest: nativePackageManifest,
|
||||||
...pickPackageInstallCommonParams(params),
|
...pickPackageInstallCommonParams(params),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -214,24 +214,19 @@ async function installPluginFromSourceDir(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function detectNativePackageInstallSource(packageDir: string): Promise<boolean> {
|
async function detectNativePackageInstallSource(
|
||||||
|
packageDir: string,
|
||||||
|
): Promise<PackageManifest | undefined> {
|
||||||
const runtime = await loadPluginInstallRuntime();
|
const runtime = await loadPluginInstallRuntime();
|
||||||
const manifestPath = path.join(packageDir, "package.json");
|
const result = await readOptionalPackageManifest({ runtime, packageDir });
|
||||||
if (!(await runtime.fileExists(manifestPath))) {
|
const manifest = result.ok ? result.manifest : undefined;
|
||||||
return false;
|
return manifest && ensureOpenClawExtensions({ manifest }).ok ? manifest : undefined;
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const manifest = await runtime.readJsonFile<PackageManifest>(manifestPath);
|
|
||||||
return ensureOpenClawExtensions({ manifest }).ok;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function installPluginFromPackageDir(
|
async function installPluginFromPackageDir(
|
||||||
params: {
|
params: {
|
||||||
packageDir: string;
|
packageDir: string;
|
||||||
|
packageManifest?: PackageManifest;
|
||||||
} & InternalPackageInstallCommonParams,
|
} & InternalPackageInstallCommonParams,
|
||||||
): Promise<InstallPluginResult> {
|
): Promise<InstallPluginResult> {
|
||||||
const runtime = await loadPluginInstallRuntime();
|
const runtime = await loadPluginInstallRuntime();
|
||||||
@@ -260,6 +255,7 @@ async function installPluginFromPackageDir(
|
|||||||
const validated = await validatePackagePluginInstallSource({
|
const validated = await validatePackagePluginInstallSource({
|
||||||
runtime,
|
runtime,
|
||||||
packageDir: params.packageDir,
|
packageDir: params.packageDir,
|
||||||
|
manifest: params.packageManifest,
|
||||||
expectedPluginId: params.expectedPluginId,
|
expectedPluginId: params.expectedPluginId,
|
||||||
requirePluginManifest: params.requirePluginManifest,
|
requirePluginManifest: params.requirePluginManifest,
|
||||||
allowSourceTypeScriptEntries: params.allowSourceTypeScriptEntries,
|
allowSourceTypeScriptEntries: params.allowSourceTypeScriptEntries,
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ vi.mock("../commands/health.js", () => ({
|
|||||||
healthCommand,
|
healthCommand,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../commands/onboard-search.js", () => ({
|
vi.mock("../flows/search-setup.js", () => ({
|
||||||
listSearchProviderOptions: () => [],
|
listSearchProviderOptions: () => [],
|
||||||
resolveSearchProviderOptions: () => [],
|
resolveSearchProviderOptions: () => [],
|
||||||
hasExistingKey,
|
hasExistingKey,
|
||||||
|
|||||||
@@ -185,9 +185,7 @@ function getLocalizedGatewayDaemonRuntimeOptions() {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadOnboardSearchModule = createLazyRuntimeModule(
|
const loadSearchSetupModule = createLazyRuntimeModule(() => import("../flows/search-setup.js"));
|
||||||
() => import("../commands/onboard-search.js"),
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure the gateway service matches the onboarding decision: prompt/decide
|
* Ensure the gateway service matches the onboarding decision: prompt/decide
|
||||||
@@ -734,7 +732,7 @@ export async function finalizeSetupWizard(
|
|||||||
const webSearchEnabled = nextConfig.tools?.web?.search?.enabled;
|
const webSearchEnabled = nextConfig.tools?.web?.search?.enabled;
|
||||||
const configuredSearchProviders = listConfiguredWebSearchProviders({ config: nextConfig });
|
const configuredSearchProviders = listConfiguredWebSearchProviders({ config: nextConfig });
|
||||||
if (webSearchProvider) {
|
if (webSearchProvider) {
|
||||||
const { resolveExistingKey, hasExistingKey, hasKeyInEnv } = await loadOnboardSearchModule();
|
const { resolveExistingKey, hasExistingKey, hasKeyInEnv } = await loadSearchSetupModule();
|
||||||
const entry = configuredSearchProviders.find((e) => e.id === webSearchProvider);
|
const entry = configuredSearchProviders.find((e) => e.id === webSearchProvider);
|
||||||
const label = entry?.label ?? webSearchProvider;
|
const label = entry?.label ?? webSearchProvider;
|
||||||
const storedKey = entry ? resolveExistingKey(nextConfig, webSearchProvider) : undefined;
|
const storedKey = entry ? resolveExistingKey(nextConfig, webSearchProvider) : undefined;
|
||||||
@@ -833,7 +831,7 @@ export async function finalizeSetupWizard(
|
|||||||
} else {
|
} else {
|
||||||
// Legacy configs may have a working key (e.g. apiKey or BRAVE_API_KEY) without
|
// Legacy configs may have a working key (e.g. apiKey or BRAVE_API_KEY) without
|
||||||
// an explicit provider. Runtime auto-detects these, so avoid saying "skipped".
|
// an explicit provider. Runtime auto-detects these, so avoid saying "skipped".
|
||||||
const { hasExistingKey, hasKeyInEnv } = await loadOnboardSearchModule();
|
const { hasExistingKey, hasKeyInEnv } = await loadSearchSetupModule();
|
||||||
const legacyDetected = configuredSearchProviders.find(
|
const legacyDetected = configuredSearchProviders.find(
|
||||||
(e) => hasExistingKey(nextConfig, e.id) || hasKeyInEnv(e),
|
(e) => hasExistingKey(nextConfig, e.id) || hasKeyInEnv(e),
|
||||||
);
|
);
|
||||||
|
|||||||
+2
-2
@@ -647,8 +647,8 @@ async function runSetupWizardOnce(
|
|||||||
if (opts.skipSearch) {
|
if (opts.skipSearch) {
|
||||||
await prompter.note(t("wizard.setup.skipSearch"), t("wizard.setup.searchTitle"));
|
await prompter.note(t("wizard.setup.skipSearch"), t("wizard.setup.searchTitle"));
|
||||||
} else {
|
} else {
|
||||||
const { setupSearch } = await import("../commands/onboard-search.js");
|
const { runSearchSetupFlow } = await import("../flows/search-setup.js");
|
||||||
nextConfig = await setupSearch(nextConfig, runtime, prompter, {
|
nextConfig = await runSearchSetupFlow(nextConfig, runtime, prompter, {
|
||||||
quickstartDefaults: flow === "quickstart",
|
quickstartDefaults: flow === "quickstart",
|
||||||
secretInputMode: opts.secretInputMode,
|
secretInputMode: opts.secretInputMode,
|
||||||
});
|
});
|
||||||
|
|||||||
-15
@@ -77,21 +77,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "legacy_payload_fallback",
|
|
||||||
"defaultProvider": "elevenlabs",
|
|
||||||
"payloadValid": true,
|
|
||||||
"expectedSelection": {
|
|
||||||
"provider": "elevenlabs",
|
|
||||||
"normalizedPayload": true,
|
|
||||||
"voiceId": "voice-legacy",
|
|
||||||
"apiKey": "xxxxx"
|
|
||||||
},
|
|
||||||
"talk": {
|
|
||||||
"voiceId": "voice-legacy",
|
|
||||||
"apiKey": "xxxxx"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"timeoutCases": [
|
"timeoutCases": [
|
||||||
|
|||||||
Reference in New Issue
Block a user