mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 04:31:39 +00: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.
|
||||
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 * as protocol from "./index.js";
|
||||
import {
|
||||
@@ -40,7 +40,14 @@ import {
|
||||
validateWakeParams,
|
||||
type ValidationError,
|
||||
} from "./index.js";
|
||||
import type {
|
||||
ConfigSchemaLookupParams,
|
||||
ModelsListParams,
|
||||
SessionsCatalogListParams,
|
||||
TalkEvent,
|
||||
} from "./index.js";
|
||||
import * as schemaExportRegistry from "./schema-export-registry.js";
|
||||
import type * as Schema from "./schema.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", () => {
|
||||
for (const registry of [schemaExportRegistry, validatorRegistry]) {
|
||||
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", () => {
|
||||
|
||||
@@ -34,4 +34,19 @@ export * from "./migration-api.js";
|
||||
export type * from "./public-session-catalog.js";
|
||||
export * from "./validator-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 }>;
|
||||
};
|
||||
};
|
||||
|
||||
58
packages/gateway-protocol/src/schema-types.ts
Normal file
58
packages/gateway-protocol/src/schema-types.ts
Normal file
@@ -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.
|
||||
import fs from "node:fs";
|
||||
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";
|
||||
|
||||
/**
|
||||
@@ -48,7 +49,8 @@ const fixtures = JSON.parse(fs.readFileSync(fixturePath, "utf-8")) as TalkConfig
|
||||
describe("talk.config contract fixtures", () => {
|
||||
for (const fixture of fixtures.selectionCases) {
|
||||
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) {
|
||||
expect(validateTalkConfigResult(payload)).toBe(true);
|
||||
} else {
|
||||
@@ -80,7 +82,8 @@ describe("talk.config contract fixtures", () => {
|
||||
|
||||
for (const fixture of fixtures.timeoutCases) {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
57
src/cli/update-cli/update-command-launch-agent-recovery.ts
Normal file
57
src/cli/update-cli/update-command-launch-agent-recovery.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
160
src/cli/update-cli/update-command-plugins-internals.ts
Normal file
160
src/cli/update-cli/update-command-plugins-internals.ts
Normal file
@@ -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 type { PluginInstallRecord } from "../../config/types.plugins.js";
|
||||
import type { PostCorePluginUpdateResult } from "./update-command-plugins.js";
|
||||
import "./update-command-plugins.js";
|
||||
import {
|
||||
buildInvalidConfigPostCoreUpdateResult,
|
||||
collectMissingPluginInstallPayloads,
|
||||
resolvePostSyncPluginUpdateSkipIds,
|
||||
} from "./update-command-plugins-internals.js";
|
||||
|
||||
type MissingPluginInstallPayload = {
|
||||
pluginId: string;
|
||||
installPath?: string;
|
||||
reason: "missing-install-path" | "missing-package-dir" | "missing-package-json";
|
||||
export const testing = {
|
||||
buildInvalidConfigPostCoreUpdateResult,
|
||||
collectMissingPluginInstallPayloads,
|
||||
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.
|
||||
import path from "node:path";
|
||||
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 { stylePromptMessage } from "../../../packages/terminal-core/src/prompt-style.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 { PluginInstallRecord } from "../../config/types.plugins.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 { UpdateRunResult } from "../../infra/update-runner.js";
|
||||
import { normalizePluginsConfig, resolveEffectiveEnableState } from "../../plugins/config-state.js";
|
||||
import { commitPluginInstallRecordsWithConfig } from "../../plugins/install-record-commit.js";
|
||||
import {
|
||||
loadInstalledPluginIndexInstallRecords,
|
||||
withoutPluginInstallRecords,
|
||||
withPluginInstallRecords,
|
||||
} 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 {
|
||||
isClawHubTrustSkippedOutcome,
|
||||
@@ -33,46 +24,27 @@ import {
|
||||
type PluginUpdateOutcome,
|
||||
} from "../../plugins/update.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { resolveUserPath } from "../../utils.js";
|
||||
import { listPersistedBundledPluginLocationBridges } from "../plugins-location-bridges.js";
|
||||
import {
|
||||
hasNativePackageInstallPayload,
|
||||
resolveBundleInstallRecordPayload,
|
||||
validateBundleInstallRecordPayload,
|
||||
} from "./plugin-payload-validation.js";
|
||||
import {
|
||||
convergenceWarningsToOutcomes,
|
||||
runPostCorePluginConvergence,
|
||||
} from "./post-core-plugin-convergence.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 =
|
||||
"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];
|
||||
|
||||
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 {
|
||||
const trimmed = stripAnsi(message).trimStart();
|
||||
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 {
|
||||
if (entry.reason === "missing-install-path") {
|
||||
return "installPath is missing";
|
||||
@@ -295,58 +188,6 @@ function isActionableSkippedPostUpdateOutcome(outcome: PluginUpdateOutcome): boo
|
||||
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: {
|
||||
root: string;
|
||||
channel: UpdateChannel;
|
||||
|
||||
116
src/cli/update-cli/update-command-service-recovery.ts
Normal file
116
src/cli/update-cli/update-command-service-recovery.ts
Normal file
@@ -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 type { GatewayService } from "../../daemon/service.js";
|
||||
import type { UpdateRunResult } from "../../infra/update-runner.js";
|
||||
import type { GatewayRestartSnapshot } from "../daemon-cli/restart-health.js";
|
||||
import "./update-command-service.js";
|
||||
import { recoverInstalledLaunchAgentAfterUpdate } from "./update-command-launch-agent-recovery.js";
|
||||
import {
|
||||
formatPostUpdateGatewayRecoveryInstructions,
|
||||
hasLoadedLaunchdKeepAliveSupervisor,
|
||||
recoverLaunchAgentAndRecheckGatewayHealth,
|
||||
shouldUseLegacyProcessRestartAfterUpdate,
|
||||
} from "./update-command-service-recovery.js";
|
||||
|
||||
type PostUpdateLaunchAgentRecoveryResult =
|
||||
| { attempted: false; recovered: false }
|
||||
| { attempted: true; recovered: true; message: string }
|
||||
| { attempted: true; recovered: false; detail: string };
|
||||
|
||||
type UpdateCommandServiceTestApi = {
|
||||
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;
|
||||
export const testing = {
|
||||
formatPostUpdateGatewayRecoveryInstructions,
|
||||
recoverInstalledLaunchAgentAfterUpdate,
|
||||
recoverLaunchAgentAndRecheckGatewayHealth,
|
||||
hasLoadedLaunchdKeepAliveSupervisor,
|
||||
shouldUseLegacyProcessRestartAfterUpdate,
|
||||
};
|
||||
|
||||
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";
|
||||
import { summarizeGatewayServiceLayout } from "../../daemon/service-layout.js";
|
||||
import type { GatewayServiceCommandConfig } from "../../daemon/service-types.js";
|
||||
import {
|
||||
readGatewayServiceState,
|
||||
resolveGatewayService,
|
||||
type GatewayService,
|
||||
} from "../../daemon/service.js";
|
||||
import { readGatewayServiceState, resolveGatewayService } from "../../daemon/service.js";
|
||||
import { parseStrictPositiveInteger } from "../../infra/parse-finite-number.js";
|
||||
import { getSelfAndAncestorPidsSync } from "../../infra/restart-stale-pids.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 { installCompletion } from "../completion-runtime.js";
|
||||
import { runDaemonInstall, runDaemonRestart } from "../daemon-cli.js";
|
||||
import { recoverInstalledLaunchAgent } from "../daemon-cli/launchd-recovery.js";
|
||||
import {
|
||||
renderRestartDiagnostics,
|
||||
terminateStaleGatewayPids,
|
||||
waitForGatewayHealthyRestart,
|
||||
type GatewayRestartSnapshot,
|
||||
} from "../daemon-cli/restart-health.js";
|
||||
import {
|
||||
registerSignalExitBarrier,
|
||||
@@ -60,6 +54,15 @@ import {
|
||||
import { runRestartScript } from "./restart-helper.js";
|
||||
import { resolveNodeRunner, type UpdateCommandOptions } from "./shared.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 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: {
|
||||
updateMode: UpdateRunResult["mode"];
|
||||
serviceInstalled: boolean;
|
||||
@@ -109,163 +106,6 @@ export function shouldPrepareUpdatedInstallRestart(params: {
|
||||
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 = {
|
||||
stopped: boolean;
|
||||
inspected: boolean;
|
||||
|
||||
@@ -10,11 +10,7 @@ import {
|
||||
updatePluginsAfterCoreUpdate,
|
||||
type PostCorePluginUpdateResult,
|
||||
} from "./update-command-plugins.js";
|
||||
import {
|
||||
buildInvalidConfigPostCoreUpdateResult,
|
||||
collectMissingPluginInstallPayloads,
|
||||
resolvePostSyncPluginUpdateSkipIds,
|
||||
} from "./update-command-plugins.test-support.js";
|
||||
import { testing as updateCommandPluginsTesting } from "./update-command-plugins.test-support.js";
|
||||
import { resolvePostCoreUpdateChildStdio } from "./update-command-post-core.js";
|
||||
import { applyPostPluginConfigValidation } from "./update-command-post-plugin-validation.js";
|
||||
import {
|
||||
@@ -23,13 +19,7 @@ import {
|
||||
resolveUpdatedGatewayRestartPort,
|
||||
shouldPrepareUpdatedInstallRestart,
|
||||
} from "./update-command-service.js";
|
||||
import {
|
||||
formatPostUpdateGatewayRecoveryInstructions,
|
||||
hasLoadedLaunchdKeepAliveSupervisor,
|
||||
recoverInstalledLaunchAgentAfterUpdate,
|
||||
recoverLaunchAgentAndRecheckGatewayHealth,
|
||||
shouldUseLegacyProcessRestartAfterUpdate,
|
||||
} from "./update-command-service.test-support.js";
|
||||
import { testing as updateCommandServiceTesting } from "./update-command-service.test-support.js";
|
||||
|
||||
describe("resolveGatewayInstallEntrypoint", () => {
|
||||
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 expect(
|
||||
collectMissingPluginInstallPayloads({
|
||||
updateCommandPluginsTesting.collectMissingPluginInstallPayloads({
|
||||
env: { HOME: tmpDir } as NodeJS.ProcessEnv,
|
||||
records: {
|
||||
present: {
|
||||
@@ -355,7 +345,7 @@ describe("collectMissingPluginInstallPayloads", () => {
|
||||
"utf8",
|
||||
);
|
||||
await expect(
|
||||
collectMissingPluginInstallPayloads({
|
||||
updateCommandPluginsTesting.collectMissingPluginInstallPayloads({
|
||||
env: { HOME: tmpDir } as NodeJS.ProcessEnv,
|
||||
records: {
|
||||
"cursor-bundle": {
|
||||
@@ -382,7 +372,7 @@ describe("collectMissingPluginInstallPayloads", () => {
|
||||
"utf8",
|
||||
);
|
||||
await expect(
|
||||
collectMissingPluginInstallPayloads({
|
||||
updateCommandPluginsTesting.collectMissingPluginInstallPayloads({
|
||||
env: { HOME: tmpDir } as NodeJS.ProcessEnv,
|
||||
records: {
|
||||
"cursor-bundle": {
|
||||
@@ -419,7 +409,7 @@ describe("collectMissingPluginInstallPayloads", () => {
|
||||
"utf8",
|
||||
);
|
||||
await expect(
|
||||
collectMissingPluginInstallPayloads({
|
||||
updateCommandPluginsTesting.collectMissingPluginInstallPayloads({
|
||||
env: { HOME: tmpDir } as NodeJS.ProcessEnv,
|
||||
records: {
|
||||
"dual-format-bundle": {
|
||||
@@ -442,7 +432,7 @@ describe("collectMissingPluginInstallPayloads", () => {
|
||||
await fs.mkdir(path.join(bundleDir, ".codex-plugin"), { recursive: true });
|
||||
await fs.writeFile(path.join(bundleDir, ".codex-plugin", "plugin.json"), "[]", "utf8");
|
||||
await expect(
|
||||
collectMissingPluginInstallPayloads({
|
||||
updateCommandPluginsTesting.collectMissingPluginInstallPayloads({
|
||||
env: { HOME: tmpDir } as NodeJS.ProcessEnv,
|
||||
records: {
|
||||
"bad-bundle": {
|
||||
@@ -469,7 +459,7 @@ describe("collectMissingPluginInstallPayloads", () => {
|
||||
const missingDir = path.join(tmpDir, "state", "npm", "node_modules", "@openclaw", "missing");
|
||||
try {
|
||||
await expect(
|
||||
collectMissingPluginInstallPayloads({
|
||||
updateCommandPluginsTesting.collectMissingPluginInstallPayloads({
|
||||
env: { HOME: tmpDir } as NodeJS.ProcessEnv,
|
||||
skipDisabledPlugins: true,
|
||||
config: {
|
||||
@@ -500,7 +490,7 @@ describe("collectMissingPluginInstallPayloads", () => {
|
||||
const missingDir = path.join(tmpDir, "state", "npm", "node_modules", "@openclaw", "codex");
|
||||
try {
|
||||
await expect(
|
||||
collectMissingPluginInstallPayloads({
|
||||
updateCommandPluginsTesting.collectMissingPluginInstallPayloads({
|
||||
env: { HOME: tmpDir } as NodeJS.ProcessEnv,
|
||||
skipDisabledPlugins: true,
|
||||
syncOfficialPluginInstalls: true,
|
||||
@@ -540,7 +530,7 @@ describe("collectMissingPluginInstallPayloads", () => {
|
||||
const missingDir = path.join(tmpDir, "state", "clawhub", "diagnostics-otel");
|
||||
try {
|
||||
await expect(
|
||||
collectMissingPluginInstallPayloads({
|
||||
updateCommandPluginsTesting.collectMissingPluginInstallPayloads({
|
||||
env: { HOME: tmpDir } as NodeJS.ProcessEnv,
|
||||
skipDisabledPlugins: true,
|
||||
syncOfficialPluginInstalls: true,
|
||||
@@ -577,7 +567,7 @@ describe("collectMissingPluginInstallPayloads", () => {
|
||||
describe("resolvePostSyncPluginUpdateSkipIds", () => {
|
||||
it("skips plugins already switched through ClawHub or npm and repaired payloads", () => {
|
||||
expect(
|
||||
resolvePostSyncPluginUpdateSkipIds({
|
||||
updateCommandPluginsTesting.resolvePostSyncPluginUpdateSkipIds({
|
||||
switchedToClawHub: ["whatsapp"],
|
||||
switchedToNpm: ["voice-call"],
|
||||
repairedMissingPayloadIds: new Set(["telegram"]),
|
||||
@@ -588,14 +578,26 @@ describe("resolvePostSyncPluginUpdateSkipIds", () => {
|
||||
|
||||
describe("shouldUseLegacyProcessRestartAfterUpdate", () => {
|
||||
it("never restarts package updates through the pre-update process", () => {
|
||||
expect(shouldUseLegacyProcessRestartAfterUpdate({ updateMode: "npm" })).toBe(false);
|
||||
expect(shouldUseLegacyProcessRestartAfterUpdate({ updateMode: "pnpm" })).toBe(false);
|
||||
expect(shouldUseLegacyProcessRestartAfterUpdate({ updateMode: "bun" })).toBe(false);
|
||||
expect(
|
||||
updateCommandServiceTesting.shouldUseLegacyProcessRestartAfterUpdate({ updateMode: "npm" }),
|
||||
).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", () => {
|
||||
expect(shouldUseLegacyProcessRestartAfterUpdate({ updateMode: "git" })).toBe(true);
|
||||
expect(shouldUseLegacyProcessRestartAfterUpdate({ updateMode: "unknown" })).toBe(true);
|
||||
expect(
|
||||
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", () => {
|
||||
const [line] = formatPostUpdateGatewayRecoveryInstructions(result, "linux");
|
||||
const [line] = updateCommandServiceTesting.formatPostUpdateGatewayRecoveryInstructions(
|
||||
result,
|
||||
"linux",
|
||||
);
|
||||
|
||||
expect(line).toContain("the systemd user service");
|
||||
expect(line).toContain("openclaw gateway restart");
|
||||
@@ -620,14 +625,20 @@ describe("formatPostUpdateGatewayRecoveryInstructions", () => {
|
||||
});
|
||||
|
||||
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("logged-in macOS user session");
|
||||
});
|
||||
|
||||
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).not.toContain("LaunchAgent");
|
||||
@@ -635,7 +646,10 @@ describe("formatPostUpdateGatewayRecoveryInstructions", () => {
|
||||
});
|
||||
|
||||
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).not.toContain("systemd");
|
||||
@@ -664,7 +678,7 @@ describe("recoverInstalledLaunchAgentAfterUpdate", () => {
|
||||
}));
|
||||
|
||||
await expect(
|
||||
recoverInstalledLaunchAgentAfterUpdate({
|
||||
updateCommandServiceTesting.recoverInstalledLaunchAgentAfterUpdate({
|
||||
service,
|
||||
env: serviceEnv,
|
||||
deps: {
|
||||
@@ -688,7 +702,7 @@ describe("recoverInstalledLaunchAgentAfterUpdate", () => {
|
||||
const recover = vi.fn();
|
||||
|
||||
await expect(
|
||||
recoverInstalledLaunchAgentAfterUpdate({
|
||||
updateCommandServiceTesting.recoverInstalledLaunchAgentAfterUpdate({
|
||||
service: {} as never,
|
||||
deps: {
|
||||
platform: "linux",
|
||||
@@ -714,7 +728,7 @@ describe("recoverInstalledLaunchAgentAfterUpdate", () => {
|
||||
const recover = vi.fn();
|
||||
|
||||
await expect(
|
||||
recoverInstalledLaunchAgentAfterUpdate({
|
||||
updateCommandServiceTesting.recoverInstalledLaunchAgentAfterUpdate({
|
||||
service: {} as never,
|
||||
deps: {
|
||||
platform: "darwin",
|
||||
@@ -739,7 +753,7 @@ describe("recoverInstalledLaunchAgentAfterUpdate", () => {
|
||||
const recover = vi.fn(async () => null);
|
||||
|
||||
await expect(
|
||||
recoverInstalledLaunchAgentAfterUpdate({
|
||||
updateCommandServiceTesting.recoverInstalledLaunchAgentAfterUpdate({
|
||||
service: {} as never,
|
||||
deps: {
|
||||
platform: "darwin",
|
||||
@@ -782,7 +796,7 @@ describe("recoverLaunchAgentAndRecheckGatewayHealth", () => {
|
||||
const waitForHealthy = vi.fn(async () => healthy);
|
||||
|
||||
await expect(
|
||||
recoverLaunchAgentAndRecheckGatewayHealth({
|
||||
updateCommandServiceTesting.recoverLaunchAgentAndRecheckGatewayHealth({
|
||||
health: unhealthy,
|
||||
service,
|
||||
port: 18790,
|
||||
@@ -830,7 +844,7 @@ describe("recoverLaunchAgentAndRecheckGatewayHealth", () => {
|
||||
}));
|
||||
const waitForHealthy = vi.fn(async () => stillUnhealthy);
|
||||
|
||||
const result = await recoverLaunchAgentAndRecheckGatewayHealth({
|
||||
const result = await updateCommandServiceTesting.recoverLaunchAgentAndRecheckGatewayHealth({
|
||||
health: unhealthy,
|
||||
service,
|
||||
port: 18790,
|
||||
@@ -851,10 +865,15 @@ describe("hasLoadedLaunchdKeepAliveSupervisor", () => {
|
||||
const service = { isLoaded } as unknown as GatewayService;
|
||||
|
||||
await expect(
|
||||
hasLoadedLaunchdKeepAliveSupervisor({ service, env: { OPENCLAW_PROFILE: "work" } }),
|
||||
updateCommandServiceTesting.hasLoadedLaunchdKeepAliveSupervisor({
|
||||
service,
|
||||
env: { OPENCLAW_PROFILE: "work" },
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
isLoaded.mockResolvedValue(true);
|
||||
await expect(hasLoadedLaunchdKeepAliveSupervisor({ service })).resolves.toBe(true);
|
||||
await expect(
|
||||
updateCommandServiceTesting.hasLoadedLaunchdKeepAliveSupervisor({ service }),
|
||||
).resolves.toBe(true);
|
||||
|
||||
platformSpy.mockRestore();
|
||||
});
|
||||
@@ -864,7 +883,7 @@ describe("hasLoadedLaunchdKeepAliveSupervisor", () => {
|
||||
const isLoaded = vi.fn().mockResolvedValue(true);
|
||||
|
||||
await expect(
|
||||
hasLoadedLaunchdKeepAliveSupervisor({
|
||||
updateCommandServiceTesting.hasLoadedLaunchdKeepAliveSupervisor({
|
||||
service: { isLoaded } as unknown as GatewayService,
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
@@ -932,14 +951,14 @@ describe("updatePluginsAfterCoreUpdate (invalid config end-to-end)", () => {
|
||||
|
||||
describe("buildInvalidConfigPostCoreUpdateResult", () => {
|
||||
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.reason).toBe("invalid-config");
|
||||
expect(built.result.changed).toBe(false);
|
||||
});
|
||||
|
||||
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([
|
||||
"Run `openclaw doctor` to inspect the config validation errors.",
|
||||
"Once the config parses, rerun `openclaw update repair`.",
|
||||
|
||||
@@ -162,9 +162,9 @@ vi.mock("./onboard-channels.js", () => ({
|
||||
setupChannels: mocks.setupChannels,
|
||||
}));
|
||||
|
||||
vi.mock("./onboard-search.js", () => ({
|
||||
vi.mock("../flows/search-setup.js", () => ({
|
||||
resolveSearchProviderOptions: mocks.resolveSearchProviderOptions,
|
||||
setupSearch: mocks.setupSearch,
|
||||
runSearchSetupFlow: mocks.setupSearch,
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/plugin-registry.js", () => ({
|
||||
|
||||
@@ -313,7 +313,8 @@ async function promptWebToolsConfig(
|
||||
}
|
||||
|
||||
if (configureManagedProvider) {
|
||||
const { resolveSearchProviderOptions, setupSearch } = await import("./onboard-search.js");
|
||||
const { resolveSearchProviderOptions, runSearchSetupFlow } =
|
||||
await import("../flows/search-setup.js");
|
||||
const searchProviderOptions = resolveSearchProviderOptions(nextConfig);
|
||||
if (searchProviderOptions.length === 0) {
|
||||
note(
|
||||
@@ -331,7 +332,7 @@ async function promptWebToolsConfig(
|
||||
};
|
||||
}
|
||||
} else {
|
||||
workingConfig = await setupSearch(workingConfig, runtime, prompter, {
|
||||
workingConfig = await runSearchSetupFlow(workingConfig, runtime, prompter, {
|
||||
preserveDisabledSearchState: false,
|
||||
});
|
||||
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", () => {
|
||||
const payload = buildTalkConfigResponse({
|
||||
realtime: {
|
||||
provider: "openai",
|
||||
providers: {
|
||||
openai: {
|
||||
model: "gpt-realtime",
|
||||
speakerVoice: "alloy",
|
||||
const payload = buildTalkConfigResponse(
|
||||
normalizeTalkSection({
|
||||
realtime: {
|
||||
provider: "openai",
|
||||
providers: {
|
||||
openai: {
|
||||
model: "gpt-realtime",
|
||||
speakerVoice: "alloy",
|
||||
},
|
||||
},
|
||||
instructions: " Speak with crisp diction. ",
|
||||
},
|
||||
instructions: " Speak with crisp diction. ",
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
expect(payload?.realtime?.provider).toBe("openai");
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,22 +51,6 @@ function normalizeNonNegativeInteger(value: unknown): number | undefined {
|
||||
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 {
|
||||
if (!isRecord(value)) {
|
||||
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.
|
||||
*/
|
||||
export function buildTalkConfigResponse(value: unknown): TalkConfigResponse | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = normalizeTalkSection(value as TalkConfig);
|
||||
const legacyCompat = buildLegacyTalkProviderCompat(value);
|
||||
if (!normalized && !legacyCompat) {
|
||||
export function buildTalkConfigResponse(
|
||||
normalized: TalkConfig | undefined,
|
||||
): TalkConfigResponse | undefined {
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -331,11 +312,7 @@ export function buildTalkConfigResponse(value: unknown): TalkConfigResponse | un
|
||||
payload.realtime = normalized.realtime;
|
||||
}
|
||||
|
||||
// Keep legacy flat ElevenLabs fields readable for clients while migration moves writes to
|
||||
// talk.provider/providers; normalizeTalkSection intentionally excludes those provider details.
|
||||
const resolved =
|
||||
resolveActiveTalkProviderConfig(normalized) ??
|
||||
(legacyCompat ? { provider: "elevenlabs", config: legacyCompat } : undefined);
|
||||
const resolved = resolveActiveTalkProviderConfig(normalized);
|
||||
const activeProvider = resolved?.provider;
|
||||
if (activeProvider) {
|
||||
payload.provider = activeProvider;
|
||||
|
||||
@@ -481,6 +481,8 @@ async function resolveTalkResponseFromConfig(params: {
|
||||
sourceConfig: OpenClawConfig;
|
||||
runtimeConfig: OpenClawConfig;
|
||||
}): 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 configuredPayload = normalizedTalk ? buildTalkConfigResponse(normalizedTalk) : undefined;
|
||||
// Resolve provider selection from materialized config, but project provider-owned fields from
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
formatUnresolvedOpenClawPeerLinkError,
|
||||
hasPackageRuntimeDependencies,
|
||||
loadPluginInstallRuntime,
|
||||
readOptionalPackageManifest,
|
||||
runInstallSourceScan,
|
||||
sourceFamilyForInstallPolicyKind,
|
||||
sourceFamilyForInstallPolicySource,
|
||||
@@ -42,6 +43,7 @@ type ValidatedPackagePlugin = {
|
||||
export async function validatePackagePluginInstallSource(params: {
|
||||
runtime: Awaited<ReturnType<typeof loadPluginInstallRuntime>>;
|
||||
packageDir: string;
|
||||
manifest?: PackageManifest;
|
||||
expectedPluginId?: string;
|
||||
requirePluginManifest?: boolean;
|
||||
allowSourceTypeScriptEntries?: boolean;
|
||||
@@ -59,16 +61,15 @@ export async function validatePackagePluginInstallSource(params: {
|
||||
}
|
||||
| PluginInstallFailureResult
|
||||
> {
|
||||
const manifestPath = path.join(params.packageDir, "package.json");
|
||||
if (!(await params.runtime.fileExists(manifestPath))) {
|
||||
return { ok: false, error: "extracted package missing package.json" };
|
||||
const manifestResult = params.manifest
|
||||
? ({ ok: true, manifest: params.manifest } as const)
|
||||
: await readOptionalPackageManifest({ runtime: params.runtime, packageDir: params.packageDir });
|
||||
if (!manifestResult.ok) {
|
||||
return manifestResult;
|
||||
}
|
||||
|
||||
let manifest: PackageManifest;
|
||||
try {
|
||||
manifest = await params.runtime.readJsonFile<PackageManifest>(manifestPath);
|
||||
} catch (err) {
|
||||
return { ok: false, error: `invalid package.json: ${String(err)}` };
|
||||
const manifest = manifestResult.manifest;
|
||||
if (!manifest) {
|
||||
return { ok: false, error: "extracted package missing package.json" };
|
||||
}
|
||||
|
||||
const pkgName = normalizeOptionalString(manifest.name) ?? "";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import {
|
||||
scanAndLinkInstalledPackage,
|
||||
@@ -194,10 +193,11 @@ async function installPluginFromSourceDir(
|
||||
sourceDir: string;
|
||||
} & InternalPackageInstallCommonParams,
|
||||
): Promise<InstallPluginResult> {
|
||||
const nativePackageDetected = await detectNativePackageInstallSource(params.sourceDir);
|
||||
if (nativePackageDetected) {
|
||||
const nativePackageManifest = await detectNativePackageInstallSource(params.sourceDir);
|
||||
if (nativePackageManifest) {
|
||||
return await installPluginFromPackageDir({
|
||||
packageDir: params.sourceDir,
|
||||
packageManifest: nativePackageManifest,
|
||||
...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 manifestPath = path.join(packageDir, "package.json");
|
||||
if (!(await runtime.fileExists(manifestPath))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const manifest = await runtime.readJsonFile<PackageManifest>(manifestPath);
|
||||
return ensureOpenClawExtensions({ manifest }).ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const result = await readOptionalPackageManifest({ runtime, packageDir });
|
||||
const manifest = result.ok ? result.manifest : undefined;
|
||||
return manifest && ensureOpenClawExtensions({ manifest }).ok ? manifest : undefined;
|
||||
}
|
||||
|
||||
async function installPluginFromPackageDir(
|
||||
params: {
|
||||
packageDir: string;
|
||||
packageManifest?: PackageManifest;
|
||||
} & InternalPackageInstallCommonParams,
|
||||
): Promise<InstallPluginResult> {
|
||||
const runtime = await loadPluginInstallRuntime();
|
||||
@@ -260,6 +255,7 @@ async function installPluginFromPackageDir(
|
||||
const validated = await validatePackagePluginInstallSource({
|
||||
runtime,
|
||||
packageDir: params.packageDir,
|
||||
manifest: params.packageManifest,
|
||||
expectedPluginId: params.expectedPluginId,
|
||||
requirePluginManifest: params.requirePluginManifest,
|
||||
allowSourceTypeScriptEntries: params.allowSourceTypeScriptEntries,
|
||||
|
||||
@@ -172,7 +172,7 @@ vi.mock("../commands/health.js", () => ({
|
||||
healthCommand,
|
||||
}));
|
||||
|
||||
vi.mock("../commands/onboard-search.js", () => ({
|
||||
vi.mock("../flows/search-setup.js", () => ({
|
||||
listSearchProviderOptions: () => [],
|
||||
resolveSearchProviderOptions: () => [],
|
||||
hasExistingKey,
|
||||
|
||||
@@ -185,9 +185,7 @@ function getLocalizedGatewayDaemonRuntimeOptions() {
|
||||
}));
|
||||
}
|
||||
|
||||
const loadOnboardSearchModule = createLazyRuntimeModule(
|
||||
() => import("../commands/onboard-search.js"),
|
||||
);
|
||||
const loadSearchSetupModule = createLazyRuntimeModule(() => import("../flows/search-setup.js"));
|
||||
|
||||
/**
|
||||
* 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 configuredSearchProviders = listConfiguredWebSearchProviders({ config: nextConfig });
|
||||
if (webSearchProvider) {
|
||||
const { resolveExistingKey, hasExistingKey, hasKeyInEnv } = await loadOnboardSearchModule();
|
||||
const { resolveExistingKey, hasExistingKey, hasKeyInEnv } = await loadSearchSetupModule();
|
||||
const entry = configuredSearchProviders.find((e) => e.id === webSearchProvider);
|
||||
const label = entry?.label ?? webSearchProvider;
|
||||
const storedKey = entry ? resolveExistingKey(nextConfig, webSearchProvider) : undefined;
|
||||
@@ -833,7 +831,7 @@ export async function finalizeSetupWizard(
|
||||
} else {
|
||||
// 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".
|
||||
const { hasExistingKey, hasKeyInEnv } = await loadOnboardSearchModule();
|
||||
const { hasExistingKey, hasKeyInEnv } = await loadSearchSetupModule();
|
||||
const legacyDetected = configuredSearchProviders.find(
|
||||
(e) => hasExistingKey(nextConfig, e.id) || hasKeyInEnv(e),
|
||||
);
|
||||
|
||||
@@ -647,8 +647,8 @@ async function runSetupWizardOnce(
|
||||
if (opts.skipSearch) {
|
||||
await prompter.note(t("wizard.setup.skipSearch"), t("wizard.setup.searchTitle"));
|
||||
} else {
|
||||
const { setupSearch } = await import("../commands/onboard-search.js");
|
||||
nextConfig = await setupSearch(nextConfig, runtime, prompter, {
|
||||
const { runSearchSetupFlow } = await import("../flows/search-setup.js");
|
||||
nextConfig = await runSearchSetupFlow(nextConfig, runtime, prompter, {
|
||||
quickstartDefaults: flow === "quickstart",
|
||||
secretInputMode: opts.secretInputMode,
|
||||
});
|
||||
|
||||
15
test/fixtures/talk-config-contract.json
vendored
15
test/fixtures/talk-config-contract.json
vendored
@@ -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": [
|
||||
|
||||
Reference in New Issue
Block a user