diff --git a/packages/gateway-protocol/src/index.test.ts b/packages/gateway-protocol/src/index.test.ts index 7f1bfefbc32b..6e5c6e319b34 100644 --- a/packages/gateway-protocol/src/index.test.ts +++ b/packages/gateway-protocol/src/index.test.ts @@ -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)[name], name).toBe(value); } } }); + + it("re-exports schema-backed protocol types from the package root", () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); }); describe("lazy protocol validators", () => { diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index cb127b143ee9..c3bedd43b75d 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -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; + resolved?: { + modelProvider?: string; + model?: string; + agentRuntime?: import("./schema/agents-models-skills.js").GatewayAgentRuntime; + thinkingLevel?: string; + thinkingLevels?: Array<{ id: string; label: string }>; + }; +}; diff --git a/packages/gateway-protocol/src/schema-types.ts b/packages/gateway-protocol/src/schema-types.ts new file mode 100644 index 000000000000..303247a73e0e --- /dev/null +++ b/packages/gateway-protocol/src/schema-types.ts @@ -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"; diff --git a/packages/gateway-protocol/src/talk-config.contract.test.ts b/packages/gateway-protocol/src/talk-config.contract.test.ts index f6040061726e..306f8734368b 100644 --- a/packages/gateway-protocol/src/talk-config.contract.test.ts +++ b/packages/gateway-protocol/src/talk-config.contract.test.ts @@ -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); }); } diff --git a/packages/gateway-protocol/src/type-export-registry.ts b/packages/gateway-protocol/src/type-export-registry.ts deleted file mode 100644 index f27207f250e0..000000000000 --- a/packages/gateway-protocol/src/type-export-registry.ts +++ /dev/null @@ -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; - 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"; -}; diff --git a/src/cli/update-cli/update-command-launch-agent-recovery.ts b/src/cli/update-cli/update-command-launch-agent-recovery.ts new file mode 100644 index 000000000000..9f0c8be2c11a --- /dev/null +++ b/src/cli/update-cli/update-command-launch-agent-recovery.ts @@ -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 { + 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, + }; +} diff --git a/src/cli/update-cli/update-command-plugins-internals.ts b/src/cli/update-cli/update-command-plugins-internals.ts new file mode 100644 index 000000000000..25277e93b7f7 --- /dev/null +++ b/src/cli/update-cli/update-command-plugins-internals.ts @@ -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["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; +}): Set { + 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; + config?: OpenClawConfig; + skipDisabledPlugins?: boolean; + syncOfficialPluginInstalls?: boolean; + env?: NodeJS.ProcessEnv; +}): Promise { + 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 }], + }, + }; +} diff --git a/src/cli/update-cli/update-command-plugins.test-support.ts b/src/cli/update-cli/update-command-plugins.test-support.ts index 6511f4b4cdb6..0fb80f361064 100644 --- a/src/cli/update-cli/update-command-plugins.test-support.ts +++ b/src/cli/update-cli/update-command-plugins.test-support.ts @@ -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; - config?: OpenClawConfig; - skipDisabledPlugins?: boolean; - syncOfficialPluginInstalls?: boolean; - env?: NodeJS.ProcessEnv; - }): Promise; - resolvePostSyncPluginUpdateSkipIds(params: { - switchedToClawHub: readonly string[]; - switchedToNpm: readonly string[]; - repairedMissingPayloadIds: ReadonlySet; - }): Set; -}; - -function getTestApi(): UpdateCommandPluginsTestApi { - return (globalThis as Record)[ - Symbol.for("openclaw.updateCommandPluginsTestApi") - ] as UpdateCommandPluginsTestApi; -} - -export function buildInvalidConfigPostCoreUpdateResult() { - return getTestApi().buildInvalidConfigPostCoreUpdateResult(); -} - -export async function collectMissingPluginInstallPayloads( - params: Parameters[0], -): Promise { - return await getTestApi().collectMissingPluginInstallPayloads(params); -} - -export function resolvePostSyncPluginUpdateSkipIds( - params: Parameters[0], -): Set { - return getTestApi().resolvePostSyncPluginUpdateSkipIds(params); -} diff --git a/src/cli/update-cli/update-command-plugins.ts b/src/cli/update-cli/update-command-plugins.ts index 38a3a7ce0dc1..06bcd6dfa4ef 100644 --- a/src/cli/update-cli/update-command-plugins.ts +++ b/src/cli/update-cli/update-command-plugins.ts @@ -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["plugins"] ->; - -type MissingPluginInstallPayload = { - pluginId: string; - installPath?: string; - reason: "missing-install-path" | "missing-package-dir" | "missing-package-json"; -}; - type PostUpdatePluginWarning = NonNullable[number]; -function resolvePostSyncPluginUpdateSkipIds(params: { - switchedToClawHub: readonly string[]; - switchedToNpm: readonly string[]; - repairedMissingPayloadIds: ReadonlySet; -}): Set { - 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; - config?: OpenClawConfig; - skipDisabledPlugins?: boolean; - syncOfficialPluginInstalls?: boolean; - env?: NodeJS.ProcessEnv; -}): Promise { - 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)[Symbol.for("openclaw.updateCommandPluginsTestApi")] = - { - buildInvalidConfigPostCoreUpdateResult, - collectMissingPluginInstallPayloads, - resolvePostSyncPluginUpdateSkipIds, - }; -} - export async function updatePluginsAfterCoreUpdate(params: { root: string; channel: UpdateChannel; diff --git a/src/cli/update-cli/update-command-service-recovery.ts b/src/cli/update-cli/update-command-service-recovery.ts new file mode 100644 index 000000000000..b5fee19c6c77 --- /dev/null +++ b/src/cli/update-cli/update-command-service-recovery.ts @@ -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 { + 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; +} diff --git a/src/cli/update-cli/update-command-service.test-support.ts b/src/cli/update-cli/update-command-service.test-support.ts index 37d07863847e..1abf042693d6 100644 --- a/src/cli/update-cli/update-command-service.test-support.ts +++ b/src/cli/update-cli/update-command-service.test-support.ts @@ -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; - recover?: (params: { - result: "restarted"; - env?: Record; - }) => Promise<{ result: "restarted"; loaded: true; message: string } | null>; - }; - }): Promise; - recoverLaunchAgentAndRecheckGatewayHealth(params: { - health: GatewayRestartSnapshot; - service: GatewayService; - port: number; - expectedVersion?: string; - env?: NodeJS.ProcessEnv; - deps?: { - recoverLaunchAgent?: (params: { - service?: GatewayService; - env?: NodeJS.ProcessEnv; - }) => Promise; - waitForHealthy?: (params: { - service: GatewayService; - port: number; - expectedVersion?: string; - env?: NodeJS.ProcessEnv; - }) => Promise; - }; - }): Promise<{ - health: GatewayRestartSnapshot; - launchAgentRecovery: PostUpdateLaunchAgentRecoveryResult | null; - }>; - hasLoadedLaunchdKeepAliveSupervisor(params: { - service: GatewayService; - env?: NodeJS.ProcessEnv; - }): Promise; - shouldUseLegacyProcessRestartAfterUpdate(params: { - updateMode: UpdateRunResult["mode"]; - }): boolean; +export const testing = { + formatPostUpdateGatewayRecoveryInstructions, + recoverInstalledLaunchAgentAfterUpdate, + recoverLaunchAgentAndRecheckGatewayHealth, + hasLoadedLaunchdKeepAliveSupervisor, + shouldUseLegacyProcessRestartAfterUpdate, }; - -function getTestApi(): UpdateCommandServiceTestApi { - return (globalThis as Record)[ - 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[0], -): Promise { - return await getTestApi().recoverInstalledLaunchAgentAfterUpdate(params); -} - -export async function recoverLaunchAgentAndRecheckGatewayHealth( - params: Parameters[0], -) { - return await getTestApi().recoverLaunchAgentAndRecheckGatewayHealth(params); -} - -export async function hasLoadedLaunchdKeepAliveSupervisor( - params: Parameters[0], -): Promise { - return await getTestApi().hasLoadedLaunchdKeepAliveSupervisor(params); -} - -export function shouldUseLegacyProcessRestartAfterUpdate( - params: Parameters[0], -): boolean { - return getTestApi().shouldUseLegacyProcessRestartAfterUpdate(params); -} diff --git a/src/cli/update-cli/update-command-service.ts b/src/cli/update-cli/update-command-service.ts index 32becbdef2f6..e4d6495a0b7f 100644 --- a/src/cli/update-cli/update-command-service.ts +++ b/src/cli/update-cli/update-command-service.ts @@ -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 { - 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 { - 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)[Symbol.for("openclaw.updateCommandServiceTestApi")] = - { - formatPostUpdateGatewayRecoveryInstructions, - recoverInstalledLaunchAgentAfterUpdate, - recoverLaunchAgentAndRecheckGatewayHealth, - hasLoadedLaunchdKeepAliveSupervisor, - shouldUseLegacyProcessRestartAfterUpdate, - }; -} - export type PreManagedServiceStop = { stopped: boolean; inspected: boolean; diff --git a/src/cli/update-cli/update-command.test.ts b/src/cli/update-cli/update-command.test.ts index 6d7ca70f77aa..2eb675e0876e 100644 --- a/src/cli/update-cli/update-command.test.ts +++ b/src/cli/update-cli/update-command.test.ts @@ -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`.", diff --git a/src/commands/configure.wizard.test.ts b/src/commands/configure.wizard.test.ts index c3ef75d61b7a..77e5ea82ba64 100644 --- a/src/commands/configure.wizard.test.ts +++ b/src/commands/configure.wizard.test.ts @@ -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", () => ({ diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts index c060410baf8d..417350827b66 100644 --- a/src/commands/configure.wizard.ts +++ b/src/commands/configure.wizard.ts @@ -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; diff --git a/src/commands/onboard-search.providers.test.ts b/src/commands/onboard-search.providers.test.ts deleted file mode 100644 index 3100ffbdaae6..000000000000 --- a/src/commands/onboard-search.providers.test.ts +++ /dev/null @@ -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).config ??= {}) as Record< - string, - unknown - >; - const webSearch = (pluginConfig.webSearch ??= {}) as Record; - 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", - ]); - }); -}); diff --git a/src/commands/onboard-search.test.ts b/src/commands/onboard-search.test.ts deleted file mode 100644 index 3b1d48883004..000000000000 --- a/src/commands/onboard-search.test.ts +++ /dev/null @@ -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 } } - >; - }; -}; - -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 }; - }; - 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["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> = - {}; - -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(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 - | 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 { - 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]>(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]>(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); - } - }); -}); diff --git a/src/commands/onboard-search.ts b/src/commands/onboard-search.ts deleted file mode 100644 index c9b937908482..000000000000 --- a/src/commands/onboard-search.ts +++ /dev/null @@ -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"; diff --git a/src/config/talk.normalize.test.ts b/src/config/talk.normalize.test.ts index 4671f36d65c0..8f7c279b9259 100644 --- a/src/config/talk.normalize.test.ts +++ b/src/config/talk.normalize.test.ts @@ -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(); - }); }); diff --git a/src/config/talk.ts b/src/config/talk.ts index 014c233aa55d..975fc3fddba7 100644 --- a/src/config/talk.ts +++ b/src/config/talk.ts @@ -51,22 +51,6 @@ function normalizeNonNegativeInteger(value: unknown): number | undefined { return value; } -function buildLegacyTalkProviderCompat( - value: Record, -): 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; diff --git a/src/gateway/server-methods/talk.ts b/src/gateway/server-methods/talk.ts index 093f5845e7a7..01f9d0b678ad 100644 --- a/src/gateway/server-methods/talk.ts +++ b/src/gateway/server-methods/talk.ts @@ -481,6 +481,8 @@ async function resolveTalkResponseFromConfig(params: { sourceConfig: OpenClawConfig; runtimeConfig: OpenClawConfig; }): Promise { + // 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 diff --git a/src/plugins/install-installed-package.ts b/src/plugins/install-installed-package.ts index d4d6123977e8..847da086eb19 100644 --- a/src/plugins/install-installed-package.ts +++ b/src/plugins/install-installed-package.ts @@ -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>; 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(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) ?? ""; diff --git a/src/plugins/install-package.ts b/src/plugins/install-package.ts index 4706b4d36030..f70f986af13d 100644 --- a/src/plugins/install-package.ts +++ b/src/plugins/install-package.ts @@ -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 { - 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 { +async function detectNativePackageInstallSource( + packageDir: string, +): Promise { const runtime = await loadPluginInstallRuntime(); - const manifestPath = path.join(packageDir, "package.json"); - if (!(await runtime.fileExists(manifestPath))) { - return false; - } - - try { - const manifest = await runtime.readJsonFile(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 { 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, diff --git a/src/wizard/setup.finalize.test.ts b/src/wizard/setup.finalize.test.ts index 665c8aeb3a4d..9cd017abc0d8 100644 --- a/src/wizard/setup.finalize.test.ts +++ b/src/wizard/setup.finalize.test.ts @@ -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, diff --git a/src/wizard/setup.finalize.ts b/src/wizard/setup.finalize.ts index f25578f93fab..1d8ce36778ad 100644 --- a/src/wizard/setup.finalize.ts +++ b/src/wizard/setup.finalize.ts @@ -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), ); diff --git a/src/wizard/setup.ts b/src/wizard/setup.ts index 7a2bb9a16e7e..5d757009a926 100644 --- a/src/wizard/setup.ts +++ b/src/wizard/setup.ts @@ -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, }); diff --git a/test/fixtures/talk-config-contract.json b/test/fixtures/talk-config-contract.json index f09d12ba988f..3d196be94399 100644 --- a/test/fixtures/talk-config-contract.json +++ b/test/fixtures/talk-config-contract.json @@ -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": [