refactor: mechanical dedup batch (protocol types, update-cli bridge, talk fallback) (#114432)

* refactor(onboarding): remove search setup barrel

* refactor(plugins): reuse detected package manifest

* test(update): replace global helper bridges

* refactor(talk): remove dead legacy response fallback

* refactor(protocol): derive root types from schema

The maintainer approved broadening the additive schema-backed type surface without a protocol version bump.

* fix(plugins): drop stale package path import

* test(protocol): type dynamic registry lookups

* docs(talk): explain canonical response boundary

* refactor(talk): enforce canonical response input

* fix(protocol): keep root type exports registry-free

* refactor(update): expose helpers through test facades

* fix(protocol): keep result types on leaf schema modules
This commit is contained in:
Peter Steinberger
2026-07-27 06:55:07 -04:00
committed by GitHub
parent 0f879595d4
commit c6b2ec28c8
27 changed files with 582 additions and 2198 deletions

View File

@@ -1,5 +1,5 @@
// Gateway Protocol tests cover index behavior.
import { describe, expect, it } from "vitest";
import { describe, expect, expectTypeOf, it } from "vitest";
import { TALK_TEST_PROVIDER_ID } from "../../../src/test-utils/talk-test-provider.js";
import * as protocol from "./index.js";
import {
@@ -40,7 +40,14 @@ import {
validateWakeParams,
type ValidationError,
} from "./index.js";
import type {
ConfigSchemaLookupParams,
ModelsListParams,
SessionsCatalogListParams,
TalkEvent,
} from "./index.js";
import * as schemaExportRegistry from "./schema-export-registry.js";
import type * as Schema from "./schema.js";
import * as validatorRegistry from "./validator-registry.js";
/**
@@ -68,10 +75,17 @@ describe("protocol export registries", () => {
it("re-exports every runtime registry symbol by identity", () => {
for (const registry of [schemaExportRegistry, validatorRegistry]) {
for (const [name, value] of Object.entries(registry)) {
expect(protocol[name as keyof typeof protocol], name).toBe(value);
expect((protocol as Record<string, unknown>)[name], name).toBe(value);
}
}
});
it("re-exports schema-backed protocol types from the package root", () => {
expectTypeOf<ConfigSchemaLookupParams>().toEqualTypeOf<Schema.ConfigSchemaLookupParams>();
expectTypeOf<ModelsListParams>().toEqualTypeOf<Schema.ModelsListParams>();
expectTypeOf<SessionsCatalogListParams>().toEqualTypeOf<Schema.SessionsCatalogListParams>();
expectTypeOf<TalkEvent>().toEqualTypeOf<Schema.TalkEvent>();
});
});
describe("lazy protocol validators", () => {

View File

@@ -34,4 +34,19 @@ export * from "./migration-api.js";
export type * from "./public-session-catalog.js";
export * from "./validator-registry.js";
export * from "./schema-export-registry.js";
export type * from "./type-export-registry.js";
export type * from "./schema-types.js";
// Local structural result keeps this package independent of core session types.
export type SessionsPatchResult = {
ok: true;
path: string;
key: string;
entry: Record<string, unknown>;
resolved?: {
modelProvider?: string;
model?: string;
agentRuntime?: import("./schema/agents-models-skills.js").GatewayAgentRuntime;
thinkingLevel?: string;
thinkingLevels?: Array<{ id: string; label: string }>;
};
};

View File

@@ -0,0 +1,58 @@
/**
* Type-only schema barrel for the package root.
*
* Keep this module list aligned with `schema.ts`, except for the runtime-only
* `protocol-schemas` registry. Routing root type exports through that registry
* retains the full registry in downstream declaration bundles.
*/
export type * from "./schema/primitives.js";
export type * from "./schema/agent.js";
export type * from "./schema/agents-models-skills.js";
export type * from "./schema/agents-workspace.js";
export type * from "./schema/artifacts.js";
export type * from "./schema/approvals.js";
export type * from "./schema/audit-activity.js";
export type * from "./schema/audit.js";
export type * from "./schema/board.js";
export type * from "./schema/users.js";
export type * from "./schema/channels.js";
export type * from "./schema/channel-pairing.js";
export type * from "./schema/talk-marks.js";
export type * from "./schema/commands.js";
export type * from "./schema/config.js";
export type * from "./schema/openclaw.js";
export type * from "./schema/cron.js";
export type * from "./schema/cron.types.js";
export type * from "./schema/error-codes.js";
export type * from "./schema/environments.js";
export type * from "./schema/exec-approvals.js";
export type * from "./schema/devices.js";
export type * from "./schema/frames.js";
export type * from "./schema/fs.js";
export type * from "./schema/gateway-suspend.js";
export type * from "./schema/logs-chat.js";
export type * from "./schema/migrations.js";
export type * from "./schema/nodes.js";
export type * from "./schema/push.js";
export type * from "./schema/questions.js";
export type * from "./schema/secrets.js";
export type * from "./schema/session-placement.js";
export type * from "./schema/session-discussion.js";
export type * from "./schema/sessions.js";
export type * from "./schema/sessions-sharing.js";
export type * from "./schema/sessions-suggestions.js";
export type * from "./schema/sessions-catalog.js";
export type * from "./schema/skill-history.js";
export type * from "./schema/snapshot.js";
export type * from "./schema/system-info.js";
export type * from "./schema/system-event.js";
export type * from "./schema/task-suggestions.js";
export type * from "./schema/tasks.js";
export type * from "./schema/terminal.js";
export type * from "./schema/ui-command.js";
export type * from "./schema/plugin-approvals.js";
export type * from "./schema/plugins.js";
export type * from "./schema/wizard.js";
export type * from "./schema/worker-admission.js";
export type * from "./schema/worker-inference.js";
export type * from "./schema/worktrees.js";

View File

@@ -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);
});
}

View File

@@ -1,544 +0,0 @@
// Type exports mirror the schema exports for downstream TypeScript consumers.
export type {
GatewayFrame,
ConnectParams,
WorkerAdmissionFailureReason,
WorkerAdmissionHandshake,
WorkerAdmissionResponseFrame,
WorkerConnectParams,
WorkerConnectRequestFrame,
WorkerErrorShape,
WorkerHeartbeatParams,
WorkerHeartbeatRequestFrame,
WorkerHeartbeatResult,
WorkerHeartbeatResponseFrame,
WorkerHelloOk,
WorkerLiveEvent,
WorkerLiveEventErrorDetails,
WorkerLiveEventErrorShape,
WorkerLiveEventParams,
WorkerLiveEventRequestFrame,
WorkerLiveEventResponseFrame,
WorkerLiveEventResult,
WorkerProtocolCloseReason,
WorkerTranscriptCommitErrorReason,
WorkerTranscriptCommitErrorShape,
WorkerTranscriptCommitParams,
WorkerTranscriptCommitRequestFrame,
WorkerTranscriptCommitResponseFrame,
WorkerTranscriptCommitResult,
WorkerTranscriptMessage,
GatewaySuspendTaskBlocker,
GatewaySuspendBlocker,
GatewaySuspendPrepareParams,
GatewaySuspendPrepareResult,
GatewaySuspendStatusParams,
GatewaySuspendStatusResult,
GatewaySuspendResumeParams,
GatewaySuspendResumeResult,
HelloOk,
RequestFrame,
ResponseFrame,
EventFrame,
PresenceEntry,
Snapshot,
ErrorShape,
StateVersion,
AgentEvent,
ConversationListItem,
ConversationListParams,
ConversationListResult,
ConversationSendParams,
ConversationSendResult,
ConversationTurnCancelParams,
ConversationTurnCancelResult,
ConversationTurnParams,
ConversationTurnReply,
ConversationTurnResult,
AgentIdentityParams,
AgentIdentityResult,
AgentWaitParams,
ChatEvent,
ChatRunStartupPhase,
ChatStatusEvent,
TickEvent,
ShutdownEvent,
WakeParams,
NodePairListParams,
NodePairApproveParams,
DevicePairListParams,
DevicePairApproveParams,
DevicePairRejectParams,
DevicePairSetupCodeParams,
DevicePairSetupCodeResult,
DevicePairRenameParams,
ConfigGetParams,
ConfigSetParams,
ConfigApplyParams,
ConfigPatchParams,
ConfigSchemaParams,
ConfigSchemaResponse,
SystemAgentChatParams,
SystemAgentChatQuestion,
SystemAgentChatResult,
SystemAgentChatHistoryParams,
SystemAgentChatHistoryResult,
SystemAgentChatHistoryTurn,
SystemChangeEntry,
SystemChangeKind,
SystemChangeSource,
SystemChangesListParams,
SystemChangesListResult,
SystemAgentSetupDetectParams,
SystemAgentSetupDetectResult,
SystemAgentSetupVerifyParams,
SystemAgentSetupVerifyResult,
SystemAgentSetupActivateParams,
SystemAgentSetupActivateResult,
SystemAgentSetupAuthStartParams,
SystemAgentSetupAuthStartResult,
WizardStartParams,
WizardNextParams,
WizardCancelParams,
WizardStatusParams,
WizardStep,
WizardNextResult,
WizardStartResult,
WizardStatusResult,
TalkCatalogParams,
TalkCatalogResult,
TalkClientCreateParams,
TalkClientCreateResult,
TalkClientCloseParams,
TalkClientMutationResult,
TalkClientSteerParams,
TalkAgentControlResult,
TalkClientToolCallParams,
TalkClientToolCallResult,
TalkClientTranscriptParams,
TalkConfigParams,
TalkConfigResult,
TalkSessionAppendAudioParams,
TalkSessionAcknowledgeMarkParams,
TalkSessionCancelOutputParams,
TalkSessionCancelTurnParams,
TalkSessionCreateParams,
TalkSessionCreateResult,
TalkSessionJoinParams,
TalkSessionJoinResult,
TalkSessionTurnParams,
TalkSessionTurnResult,
TalkSessionSteerParams,
TalkSessionSubmitToolResultParams,
TalkSessionCloseParams,
TalkSessionOkResult,
TalkSpeakParams,
TalkSpeakResult,
TtsSpeakParams,
TtsSpeakResult,
TalkModeParams,
ChannelsStatusParams,
ChannelsStatusResult,
ChannelsPairingListParams,
ChannelsPairingListResult,
ChannelsPairingApproveParams,
ChannelsPairingApproveResult,
ChannelsPairingDismissParams,
ChannelsPairingDismissResult,
ChannelsPairingAccount,
ChannelsPairingRequest,
ChannelsStartParams,
ChannelsStopParams,
ChannelsLogoutParams,
WebLoginStartParams,
WebLoginWaitParams,
AgentKind,
AgentSummary,
AgentsFileEntry,
AgentsCreateParams,
AgentsCreateResult,
AgentsUpdateParams,
AgentsUpdateResult,
AgentsDeleteParams,
AgentsDeleteResult,
AgentsFilesListParams,
AgentsFilesListResult,
AgentsFilesGetParams,
AgentsFilesGetResult,
AgentsFilesSetParams,
AgentsFilesSetResult,
AgentsWorkspaceEntry,
AgentsWorkspaceFile,
AgentsWorkspaceListParams,
AgentsWorkspaceListResult,
AgentsWorkspaceGetParams,
AgentsWorkspaceGetResult,
SessionFileBrowserEntry,
SessionFileBrowserResult,
SessionFileEntry,
SessionFileKind,
SessionFileRelevance,
SessionsFilesListParams,
SessionsFilesListResult,
SessionsFilesGetParams,
SessionsFilesGetResult,
SessionsFilesSetParams,
SessionsFilesSetResult,
SessionsFilesRevealParams,
SessionsFilesRevealResult,
SessionDiffFile,
SessionDiffFileStatus,
SessionsDiffParams,
SessionsDiffResult,
SessionDiscussionState,
SessionDiscussionInfo,
SessionDiscussionInfoParams,
SessionDiscussionInfoResult,
SessionDiscussionOpenParams,
SessionDiscussionOpenResult,
ArtifactSummary,
ArtifactsListParams,
ArtifactsListResult,
ArtifactsGetParams,
ArtifactsGetResult,
ArtifactsDownloadParams,
ArtifactsDownloadResult,
AgentsListParams,
AgentsListResult,
ChatMetadataParams,
ChatToolTitlesParams,
ChatToolTitlesResult,
CommandsListParams,
CommandsListResult,
CommandEntry,
PluginCatalogEntry,
PluginsInstallParams,
PluginsInstallResult,
PluginsListParams,
PluginsListResult,
PluginsRefreshParams,
PluginsRefreshResult,
PluginsSearchParams,
PluginsSearchResult,
PluginsSessionActionParams,
PluginsSessionActionResult,
PluginsSetEnabledParams,
PluginsSetEnabledResult,
PluginsUninstallParams,
PluginsUninstallResult,
AuthProbeStatus,
ModelsProbeParams,
ModelsProbeResult,
ModelsProbeTargetResult,
SkillsStatusParams,
ToolsCatalogParams,
ToolsCatalogResult,
ToolsEffectiveParams,
ToolsEffectiveResult,
ToolsInvokeParams,
ToolsInvokeResult,
SkillsBinsParams,
SkillsBinsResult,
SkillsCuratorActionParams,
SkillsCuratorActionResult,
SkillsCuratorStatusParams,
SkillsCuratorStatusResult,
SkillsSearchParams,
SkillsSearchResult,
SkillsDetailParams,
SkillsDetailResult,
SkillsProposalsListParams,
SkillsProposalsListResult,
SkillsProposalInspectParams,
SkillsProposalInspectResult,
SkillsProposalCreateParams,
SkillsProposalUpdateParams,
SkillsProposalReviseParams,
SkillsProposalRequestRevisionParams,
SkillsProposalRequestRevisionResult,
SkillsProposalActionParams,
SkillsProposalApplyResult,
SkillsProposalRecordResult,
SkillsSecurityVerdictsParams,
SkillsSecurityVerdictsResult,
SkillsSkillCardParams,
SkillsSkillCardResult,
SkillsUploadBeginParams,
SkillsUploadChunkParams,
SkillsUploadCommitParams,
SkillsInstallParams,
SkillsUpdateParams,
EnvironmentStatus,
WorkerEnvironmentState,
WorkerTunnelStatus,
WorkerEnvironmentMetadata,
EnvironmentSummary,
EnvironmentsCreateParams,
EnvironmentsCreateResult,
EnvironmentsDestroyParams,
EnvironmentsDestroyResult,
EnvironmentsListParams,
EnvironmentsListResult,
EnvironmentsStatusParams,
EnvironmentsStatusResult,
SystemInfoParams,
SystemInfoResult,
NodePairRejectParams,
NodePairRemoveParams,
NodeListParams,
NodePluginToolDescriptor,
NodePluginToolsUpdateParams,
NodeSkillDescriptor,
NodeSkillsUpdateParams,
NodeInvokeParams,
NodeInvokeInputEvent,
NodeInvokeProgressParams,
NodeInvokeResultParams,
NodeEventParams,
NodeEventResult,
NodePresenceAlivePayload,
NodePresenceAliveReason,
NodePresenceActivityPayload,
NodePendingDrainParams,
NodePendingDrainResult,
NodePendingEnqueueParams,
NodePendingEnqueueResult,
SessionsListParams,
SessionsSearchHit,
SessionsSearchParams,
SessionsSearchResult,
SessionsCleanupParams,
SessionsPreviewParams,
SessionsDescribeParams,
SessionsResolveParams,
SessionOperationEvent,
SessionCompanionExchange,
SessionObserverDigest,
SessionObserverHealth,
SessionObserverPlanProgress,
SessionMember,
SessionMemberAddParams,
SessionMemberMutationResult,
SessionMemberRemoveParams,
SessionMembersListParams,
SessionMembersListResult,
SessionSharingAction,
SessionSharingEvent,
SessionSharingIdentity,
SessionSharingRole,
SessionVisibility,
SessionVisibilitySetParams,
SessionVisibilitySetResult,
SessionsCompanionAskParams,
SessionsCompanionAskResult,
SessionsCompanionResetParams,
SessionsCompanionResetResult,
SessionsCompanionStateParams,
SessionsCompanionStateResult,
SessionsObserverVisibilityParams,
SessionsObserverVisibilityResult,
SessionPlacementState,
SessionPlacement,
SessionWorktreeInfo,
SessionsDispatchParams,
SessionsDispatchResult,
SessionsReclaimParams,
SessionsReclaimResult,
SessionsCreateResult,
SessionBranch,
SessionsBranchesListParams,
SessionsBranchesListResult,
SessionsBranchesSwitchParams,
SessionsBranchesSwitchResult,
SessionsForkParams,
SessionsForkResult,
SessionsRewindParams,
SessionsRewindResult,
SessionsPatchParams,
SessionsResetParams,
SessionsDeleteParams,
SessionsCompactParams,
SessionsUsageParams,
AuditActivityAgentRunV1,
AuditActivityEventV1,
AuditActivityInboundMessageV1,
AuditActivityListParams,
AuditActivityListResult,
AuditActivityOutboundMessageV1,
AuditActivityToolActionV1,
AuditEvent,
AuditListParams,
AuditListResult,
UserProfile,
UsersLinkEmailParams,
UsersLinkEmailResult,
UsersListParams,
UsersListResult,
UsersSelfParams,
UsersSelfResult,
UsersSetAvatarParams,
UsersSetAvatarResult,
UsersSetDisplayNameParams,
UsersSetDisplayNameResult,
TaskSuggestion,
TaskSuggestionEvent,
TaskSuggestionResolution,
TaskSuggestionsAcceptParams,
TaskSuggestionsAcceptResult,
TaskSuggestionsCreateParams,
TaskSuggestionsCreateResult,
TaskSuggestionsDismissParams,
TaskSuggestionsDismissResult,
TaskSuggestionsListParams,
TaskSuggestionsListResult,
TaskSummary,
TasksListParams,
TasksListResult,
TasksGetParams,
TasksGetResult,
TasksCancelParams,
TasksCancelResult,
CronJob,
CronListParams,
CronStatusParams,
CronGetParams,
CronAddParams,
CronAddResult,
CronDeclarativeAddResult,
CronUpdateParams,
CronRemoveParams,
CronRunParams,
CronRunsParams,
CronScratchGetParams,
CronScratchGetResult,
CronScratchSetParams,
CronScratchSetResult,
CronRunLogEntry,
ApprovalKind,
ApprovalDecision,
ApprovalAllowDecision,
ApprovalTerminalReason,
PluginApprovalSeverity,
ExecApprovalPresentation,
PluginApprovalPresentation,
ApprovalPresentation,
PendingApprovalSnapshot,
AllowedApprovalSnapshot,
DeniedApprovalSnapshot,
ExpiredApprovalSnapshot,
CancelledApprovalSnapshot,
ApprovalSnapshot,
TerminalApprovalSnapshot,
ApprovalGetParams,
ApprovalGetResult,
ApprovalHistoryParams,
ApprovalHistoryResult,
ApprovalResolveParams,
ApprovalResolveResult,
SessionApprovalEvent,
SessionApprovalReplay,
ExecApprovalsGetParams,
ExecApprovalsNodeSnapshot,
ExecApprovalsSetParams,
ExecApprovalsSnapshot,
ExecApprovalGetParams,
ExecApprovalRequestParams,
ExecApprovalResolveParams,
Question,
QuestionAnswers,
QuestionGetParams,
QuestionGetResult,
QuestionListParams,
QuestionListResult,
QuestionOption,
QuestionRecord,
QuestionRequestedEvent,
QuestionRequestParams,
QuestionRequestQuestion,
QuestionRequestResult,
QuestionResolvedEvent,
QuestionResolveParams,
QuestionResolveResult,
QuestionStatus,
QuestionWaitAnswerParams,
QuestionWaitAnswerResult,
LogsTailParams,
LogsTailResult,
TerminalOpenParams,
TerminalOpenResult,
TerminalInputParams,
TerminalUploadParams,
TerminalUploadResult,
TerminalResizeParams,
TerminalCloseParams,
TerminalAttachParams,
TerminalAttachResult,
TerminalSessionInfo,
TerminalListResult,
TerminalTextParams,
TerminalTextResult,
TerminalAckResult,
TerminalDataEvent,
TerminalExitEvent,
TerminalEvent,
PollParams,
WebPushVapidPublicKeyParams,
WebPushSubscribeParams,
WebPushUnsubscribeParams,
WebPushTestParams,
UpdateStatusParams,
UpdateRunParams,
ChatInjectParams,
WorktreeRecord,
WorktreesListParams,
WorktreesListResult,
WorktreesCreateParams,
WorktreesRemoveParams,
WorktreesRemoveResult,
WorktreesRestoreParams,
WorktreesGcParams,
WorktreesGcResult,
WorktreesBranchesParams,
WorktreeBranch,
WorktreeRepositoryStatus,
WorktreesBranchesResult,
FsDirEntry,
FsListDirParams,
FsListDirResult,
SessionGroup,
SessionsGroupsListParams,
SessionsGroupsListResult,
SessionsGroupsPutParams,
SessionsGroupsRenameParams,
SessionsGroupsDeleteParams,
SessionsGroupsMutationResult,
} from "./schema.js";
// Local structural result keeps this package independent of core session types.
export type SessionsPatchResult = {
ok: true;
path: string;
key: string;
entry: Record<string, unknown>;
resolved?: {
modelProvider?: string;
model?: string;
agentRuntime?: GatewayAgentRuntime;
thinkingLevel?: string;
thinkingLevels?: Array<{ id: string; label: string }>;
};
};
type GatewayAgentRuntime = {
id: string;
fallback?: "openclaw" | "none";
source:
| "env"
| "agent"
| "defaults"
| "model"
| "provider"
| "implicit"
| "session"
| "session-key";
};

View File

@@ -0,0 +1,57 @@
import {
readGatewayServiceState,
resolveGatewayService,
type GatewayService,
} from "../../daemon/service.js";
import { recoverInstalledLaunchAgent } from "../daemon-cli/launchd-recovery.js";
export type PostUpdateLaunchAgentRecoveryResult =
| { attempted: false; recovered: false }
| { attempted: true; recovered: true; message: string }
| { attempted: true; recovered: false; detail: string };
type PostUpdateLaunchAgentRecoveryDeps = {
platform?: NodeJS.Platform;
readState?: typeof readGatewayServiceState;
recover?: typeof recoverInstalledLaunchAgent;
};
export async function recoverInstalledLaunchAgentAfterUpdate(params: {
service?: GatewayService;
env?: NodeJS.ProcessEnv;
deps?: PostUpdateLaunchAgentRecoveryDeps;
}): Promise<PostUpdateLaunchAgentRecoveryResult> {
const platform = params.deps?.platform ?? process.platform;
if (platform !== "darwin") {
return { attempted: false, recovered: false };
}
const service = params.service ?? resolveGatewayService();
const readState = params.deps?.readState ?? readGatewayServiceState;
const recover = params.deps?.recover ?? recoverInstalledLaunchAgent;
const state = await readState(service, { env: params.env }).catch(() => null);
if (state?.loaded) {
return { attempted: false, recovered: false };
}
if (state && !state.installed && !state.runtime?.missingSupervision) {
return { attempted: false, recovered: false };
}
const recovered = await recover({ result: "restarted", env: state?.env ?? params.env }).catch(
() => null,
);
if (!recovered) {
return {
attempted: true,
recovered: false,
detail:
"LaunchAgent was installed but not loaded; automatic bootstrap/kickstart recovery failed.",
};
}
return {
attempted: true,
recovered: true,
message: recovered.message,
};
}

View File

@@ -0,0 +1,160 @@
import path from "node:path";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { PluginInstallRecord } from "../../config/types.plugins.js";
import { pathExists } from "../../infra/fs-safe.js";
import type { UpdateRunResult } from "../../infra/update-runner.js";
import { normalizePluginsConfig, resolveEffectiveEnableState } from "../../plugins/config-state.js";
import {
resolveTrustedSourceLinkedOfficialClawHubSpec,
resolveTrustedSourceLinkedOfficialNpmSpec,
} from "../../plugins/official-external-install-records.js";
import { resolveUserPath } from "../../utils.js";
import {
hasNativePackageInstallPayload,
resolveBundleInstallRecordPayload,
validateBundleInstallRecordPayload,
} from "./plugin-payload-validation.js";
export type PostCorePluginUpdateResult = NonNullable<
NonNullable<UpdateRunResult["postUpdate"]>["plugins"]
>;
export type MissingPluginInstallPayload = {
pluginId: string;
installPath?: string;
reason: "missing-install-path" | "missing-package-dir" | "missing-package-json";
};
export function resolvePostSyncPluginUpdateSkipIds(params: {
switchedToClawHub: readonly string[];
switchedToNpm: readonly string[];
repairedMissingPayloadIds: ReadonlySet<string>;
}): Set<string> {
return new Set([
...params.switchedToClawHub,
...params.switchedToNpm,
...params.repairedMissingPayloadIds,
]);
}
function isTrackedPackageInstallRecord(record: PluginInstallRecord): boolean {
return (
record.source === "npm" ||
record.source === "clawhub" ||
record.source === "git" ||
record.source === "marketplace"
);
}
export async function collectMissingPluginInstallPayloads(params: {
records: Record<string, PluginInstallRecord>;
config?: OpenClawConfig;
skipDisabledPlugins?: boolean;
syncOfficialPluginInstalls?: boolean;
env?: NodeJS.ProcessEnv;
}): Promise<MissingPluginInstallPayload[]> {
const env = params.env ?? process.env;
const normalizedPluginConfig =
params.skipDisabledPlugins && params.config
? normalizePluginsConfig(params.config.plugins)
: undefined;
const missing: MissingPluginInstallPayload[] = [];
for (const [pluginId, record] of Object.entries(params.records).toSorted(([left], [right]) =>
left.localeCompare(right),
)) {
if (!isTrackedPackageInstallRecord(record)) {
continue;
}
const officialNpmSpec = params.syncOfficialPluginInstalls
? resolveTrustedSourceLinkedOfficialNpmSpec({ pluginId, record })
: undefined;
const officialClawHubSpec = params.syncOfficialPluginInstalls
? resolveTrustedSourceLinkedOfficialClawHubSpec({ pluginId, record })
: undefined;
if (normalizedPluginConfig && params.config) {
const enableState = resolveEffectiveEnableState({
id: pluginId,
origin: "global",
config: normalizedPluginConfig,
rootConfig: params.config,
});
if (!enableState.enabled && !officialNpmSpec && !officialClawHubSpec) {
continue;
}
}
const rawInstallPath = normalizeOptionalString(record.installPath);
if (!rawInstallPath) {
missing.push({ pluginId, reason: "missing-install-path" });
continue;
}
const installPath = resolveUserPath(rawInstallPath, env);
if (!(await pathExists(installPath))) {
missing.push({ pluginId, installPath, reason: "missing-package-dir" });
continue;
}
const bundlePayload = resolveBundleInstallRecordPayload({ record, installPath });
if (bundlePayload.isBundlePayload) {
if (await hasNativePackageInstallPayload(installPath)) {
continue;
}
const bundleFailure = validateBundleInstallRecordPayload({
pluginId,
installPath,
record,
bundleFormat: bundlePayload.bundleFormat,
});
if (bundleFailure) {
missing.push({ pluginId, installPath, reason: "missing-package-json" });
}
continue;
}
const packageJsonPath = path.join(installPath, "package.json");
if (!(await pathExists(packageJsonPath))) {
missing.push({ pluginId, installPath, reason: "missing-package-json" });
}
}
return missing;
}
/**
* Build the post-core-update result we return when the active config cannot
* even be parsed. Mandatory post-core convergence requires a parseable
* config to know which plugins are configured; if one isn't available, we
* refuse to restart the gateway and surface this as a hard error so the
* existing `status === "error"` => `exit 1` pre-restart gate fires.
*/
export function buildInvalidConfigPostCoreUpdateResult(): {
message: string;
guidance: string[];
result: PostCorePluginUpdateResult;
} {
const guidance = [
"Run `openclaw doctor` to inspect the config validation errors.",
"Once the config parses, rerun `openclaw update repair`.",
];
const message =
"Plugin post-update convergence skipped because the config is invalid; refusing to restart the gateway with an unverified plugin set.";
return {
message,
guidance,
result: {
status: "error",
reason: "invalid-config",
changed: false,
sync: {
changed: false,
switchedToBundled: [],
switchedToNpm: [],
warnings: [],
errors: [],
},
npm: {
changed: false,
outcomes: [],
},
integrityDrifts: [],
warnings: [{ reason: "invalid-config", message, guidance }],
},
};
}

View File

@@ -1,52 +1,11 @@
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { PluginInstallRecord } from "../../config/types.plugins.js";
import type { PostCorePluginUpdateResult } from "./update-command-plugins.js";
import "./update-command-plugins.js";
import {
buildInvalidConfigPostCoreUpdateResult,
collectMissingPluginInstallPayloads,
resolvePostSyncPluginUpdateSkipIds,
} from "./update-command-plugins-internals.js";
type MissingPluginInstallPayload = {
pluginId: string;
installPath?: string;
reason: "missing-install-path" | "missing-package-dir" | "missing-package-json";
export const testing = {
buildInvalidConfigPostCoreUpdateResult,
collectMissingPluginInstallPayloads,
resolvePostSyncPluginUpdateSkipIds,
};
type UpdateCommandPluginsTestApi = {
buildInvalidConfigPostCoreUpdateResult(): {
message: string;
guidance: string[];
result: PostCorePluginUpdateResult;
};
collectMissingPluginInstallPayloads(params: {
records: Record<string, PluginInstallRecord>;
config?: OpenClawConfig;
skipDisabledPlugins?: boolean;
syncOfficialPluginInstalls?: boolean;
env?: NodeJS.ProcessEnv;
}): Promise<MissingPluginInstallPayload[]>;
resolvePostSyncPluginUpdateSkipIds(params: {
switchedToClawHub: readonly string[];
switchedToNpm: readonly string[];
repairedMissingPayloadIds: ReadonlySet<string>;
}): Set<string>;
};
function getTestApi(): UpdateCommandPluginsTestApi {
return (globalThis as Record<PropertyKey, unknown>)[
Symbol.for("openclaw.updateCommandPluginsTestApi")
] as UpdateCommandPluginsTestApi;
}
export function buildInvalidConfigPostCoreUpdateResult() {
return getTestApi().buildInvalidConfigPostCoreUpdateResult();
}
export async function collectMissingPluginInstallPayloads(
params: Parameters<UpdateCommandPluginsTestApi["collectMissingPluginInstallPayloads"]>[0],
): Promise<MissingPluginInstallPayload[]> {
return await getTestApi().collectMissingPluginInstallPayloads(params);
}
export function resolvePostSyncPluginUpdateSkipIds(
params: Parameters<UpdateCommandPluginsTestApi["resolvePostSyncPluginUpdateSkipIds"]>[0],
): Set<string> {
return getTestApi().resolvePostSyncPluginUpdateSkipIds(params);
}

View File

@@ -1,7 +1,5 @@
// Plugin synchronization and convergence after the core update.
import path from "node:path";
import { confirm, isCancel, text } from "@clack/prompts";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { stripAnsi } from "../../../packages/terminal-core/src/ansi.js";
import { stylePromptMessage } from "../../../packages/terminal-core/src/prompt-style.js";
import { sanitizeTerminalText } from "../../../packages/terminal-core/src/safe-text.js";
@@ -10,20 +8,13 @@ import { readConfigFileSnapshot } from "../../config/config.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { PluginInstallRecord } from "../../config/types.plugins.js";
import type { ClawHubRiskAcknowledgementRequest } from "../../infra/clawhub-install-trust.js";
import { pathExists } from "../../infra/fs-safe.js";
import type { UpdateChannel } from "../../infra/update-channels.js";
import type { UpdateRunResult } from "../../infra/update-runner.js";
import { normalizePluginsConfig, resolveEffectiveEnableState } from "../../plugins/config-state.js";
import { commitPluginInstallRecordsWithConfig } from "../../plugins/install-record-commit.js";
import {
loadInstalledPluginIndexInstallRecords,
withoutPluginInstallRecords,
withPluginInstallRecords,
} from "../../plugins/installed-plugin-index-records.js";
import {
resolveTrustedSourceLinkedOfficialClawHubSpec,
resolveTrustedSourceLinkedOfficialNpmSpec,
} from "../../plugins/official-external-install-records.js";
import { refreshPluginRegistryAfterConfigMutation } from "../../plugins/registry-refresh.js";
import {
isClawHubTrustSkippedOutcome,
@@ -33,46 +24,27 @@ import {
type PluginUpdateOutcome,
} from "../../plugins/update.js";
import { defaultRuntime } from "../../runtime.js";
import { resolveUserPath } from "../../utils.js";
import { listPersistedBundledPluginLocationBridges } from "../plugins-location-bridges.js";
import {
hasNativePackageInstallPayload,
resolveBundleInstallRecordPayload,
validateBundleInstallRecordPayload,
} from "./plugin-payload-validation.js";
import {
convergenceWarningsToOutcomes,
runPostCorePluginConvergence,
} from "./post-core-plugin-convergence.js";
import { readPackageVersion, type UpdateCommandOptions } from "./shared.js";
import {
buildInvalidConfigPostCoreUpdateResult,
collectMissingPluginInstallPayloads,
resolvePostSyncPluginUpdateSkipIds,
type MissingPluginInstallPayload,
type PostCorePluginUpdateResult,
} from "./update-command-plugins-internals.js";
export type { PostCorePluginUpdateResult } from "./update-command-plugins-internals.js";
const POST_UPDATE_PLUGIN_REPAIR_GUIDANCE =
"Run openclaw update repair to retry post-update plugin repair.";
export type PostCorePluginUpdateResult = NonNullable<
NonNullable<UpdateRunResult["postUpdate"]>["plugins"]
>;
type MissingPluginInstallPayload = {
pluginId: string;
installPath?: string;
reason: "missing-install-path" | "missing-package-dir" | "missing-package-json";
};
type PostUpdatePluginWarning = NonNullable<PostCorePluginUpdateResult["warnings"]>[number];
function resolvePostSyncPluginUpdateSkipIds(params: {
switchedToClawHub: readonly string[];
switchedToNpm: readonly string[];
repairedMissingPayloadIds: ReadonlySet<string>;
}): Set<string> {
return new Set([
...params.switchedToClawHub,
...params.switchedToNpm,
...params.repairedMissingPayloadIds,
]);
}
function isClawHubTrustNotice(message: string): boolean {
const trimmed = stripAnsi(message).trimStart();
return (
@@ -133,85 +105,6 @@ function resolveUpdateClawHubRiskAcknowledgementOptions(
};
}
function isTrackedPackageInstallRecord(record: PluginInstallRecord): boolean {
return (
record.source === "npm" ||
record.source === "clawhub" ||
record.source === "git" ||
record.source === "marketplace"
);
}
async function collectMissingPluginInstallPayloads(params: {
records: Record<string, PluginInstallRecord>;
config?: OpenClawConfig;
skipDisabledPlugins?: boolean;
syncOfficialPluginInstalls?: boolean;
env?: NodeJS.ProcessEnv;
}): Promise<MissingPluginInstallPayload[]> {
const env = params.env ?? process.env;
const normalizedPluginConfig =
params.skipDisabledPlugins && params.config
? normalizePluginsConfig(params.config.plugins)
: undefined;
const missing: MissingPluginInstallPayload[] = [];
for (const [pluginId, record] of Object.entries(params.records).toSorted(([left], [right]) =>
left.localeCompare(right),
)) {
if (!isTrackedPackageInstallRecord(record)) {
continue;
}
const officialNpmSpec = params.syncOfficialPluginInstalls
? resolveTrustedSourceLinkedOfficialNpmSpec({ pluginId, record })
: undefined;
const officialClawHubSpec = params.syncOfficialPluginInstalls
? resolveTrustedSourceLinkedOfficialClawHubSpec({ pluginId, record })
: undefined;
if (normalizedPluginConfig && params.config) {
const enableState = resolveEffectiveEnableState({
id: pluginId,
origin: "global",
config: normalizedPluginConfig,
rootConfig: params.config,
});
if (!enableState.enabled && !officialNpmSpec && !officialClawHubSpec) {
continue;
}
}
const rawInstallPath = normalizeOptionalString(record.installPath);
if (!rawInstallPath) {
missing.push({ pluginId, reason: "missing-install-path" });
continue;
}
const installPath = resolveUserPath(rawInstallPath, env);
if (!(await pathExists(installPath))) {
missing.push({ pluginId, installPath, reason: "missing-package-dir" });
continue;
}
const bundlePayload = resolveBundleInstallRecordPayload({ record, installPath });
if (bundlePayload.isBundlePayload) {
if (await hasNativePackageInstallPayload(installPath)) {
continue;
}
const bundleFailure = validateBundleInstallRecordPayload({
pluginId,
installPath,
record,
bundleFormat: bundlePayload.bundleFormat,
});
if (bundleFailure) {
missing.push({ pluginId, installPath, reason: "missing-package-json" });
}
continue;
}
const packageJsonPath = path.join(installPath, "package.json");
if (!(await pathExists(packageJsonPath))) {
missing.push({ pluginId, installPath, reason: "missing-package-json" });
}
}
return missing;
}
function formatMissingPluginPayloadReason(entry: MissingPluginInstallPayload): string {
if (entry.reason === "missing-install-path") {
return "installPath is missing";
@@ -295,58 +188,6 @@ function isActionableSkippedPostUpdateOutcome(outcome: PluginUpdateOutcome): boo
return isDisabledAfterFailureOutcome(outcome) || isClawHubTrustSkippedOutcome(outcome);
}
/**
* Build the post-core-update result we return when the active config cannot
* even be parsed. Mandatory post-core convergence requires a parseable
* config to know which plugins are configured; if one isn't available, we
* refuse to restart the gateway and surface this as a hard error so the
* existing `status === "error"` ⇒ `exit 1` pre-restart gate fires.
*
*/
function buildInvalidConfigPostCoreUpdateResult(): {
message: string;
guidance: string[];
result: PostCorePluginUpdateResult;
} {
const guidance = [
"Run `openclaw doctor` to inspect the config validation errors.",
"Once the config parses, rerun `openclaw update repair`.",
];
const message =
"Plugin post-update convergence skipped because the config is invalid; refusing to restart the gateway with an unverified plugin set.";
return {
message,
guidance,
result: {
status: "error",
reason: "invalid-config",
changed: false,
sync: {
changed: false,
switchedToBundled: [],
switchedToNpm: [],
warnings: [],
errors: [],
},
npm: {
changed: false,
outcomes: [],
},
integrityDrifts: [],
warnings: [{ reason: "invalid-config", message, guidance }],
},
};
}
if (process.env.VITEST || process.env.NODE_ENV === "test") {
(globalThis as Record<PropertyKey, unknown>)[Symbol.for("openclaw.updateCommandPluginsTestApi")] =
{
buildInvalidConfigPostCoreUpdateResult,
collectMissingPluginInstallPayloads,
resolvePostSyncPluginUpdateSkipIds,
};
}
export async function updatePluginsAfterCoreUpdate(params: {
root: string;
channel: UpdateChannel;

View File

@@ -0,0 +1,116 @@
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import type { GatewayService } from "../../daemon/service.js";
import type { UpdateRunResult } from "../../infra/update-runner.js";
import { replaceCliName, resolveCliName } from "../cli-name.js";
import { formatCliCommand } from "../command-format.js";
import {
waitForGatewayHealthyRestart,
type GatewayRestartSnapshot,
} from "../daemon-cli/restart-health.js";
import {
recoverInstalledLaunchAgentAfterUpdate,
type PostUpdateLaunchAgentRecoveryResult,
} from "./update-command-launch-agent-recovery.js";
const CLI_NAME = resolveCliName();
export function isPackageManagerUpdateMode(
mode: UpdateRunResult["mode"],
): mode is "npm" | "pnpm" | "bun" {
return mode === "npm" || mode === "pnpm" || mode === "bun";
}
export function shouldUseLegacyProcessRestartAfterUpdate(params: {
updateMode: UpdateRunResult["mode"];
}): boolean {
return !isPackageManagerUpdateMode(params.updateMode);
}
type PostUpdateGatewayHealthRecoveryDeps = {
recoverLaunchAgent?: typeof recoverInstalledLaunchAgentAfterUpdate;
waitForHealthy?: typeof waitForGatewayHealthyRestart;
};
export async function recoverLaunchAgentAndRecheckGatewayHealth(params: {
health: GatewayRestartSnapshot;
service: GatewayService;
port: number;
expectedVersion?: string;
env?: NodeJS.ProcessEnv;
deps?: PostUpdateGatewayHealthRecoveryDeps;
}): Promise<{
health: GatewayRestartSnapshot;
launchAgentRecovery: PostUpdateLaunchAgentRecoveryResult | null;
}> {
if (params.health.healthy) {
return { health: params.health, launchAgentRecovery: null };
}
const recoverLaunchAgent =
params.deps?.recoverLaunchAgent ?? recoverInstalledLaunchAgentAfterUpdate;
const launchAgentRecovery = await recoverLaunchAgent({
service: params.service,
env: params.env,
});
if (!launchAgentRecovery.recovered) {
return { health: params.health, launchAgentRecovery };
}
const waitForHealthy = params.deps?.waitForHealthy ?? waitForGatewayHealthyRestart;
const health = await waitForHealthy({
service: params.service,
port: params.port,
expectedVersion: params.expectedVersion,
env: params.env,
supervisorKeepsAlive: true,
});
return { health, launchAgentRecovery };
}
export async function hasLoadedLaunchdKeepAliveSupervisor(params: {
service: GatewayService;
env?: NodeJS.ProcessEnv;
}): Promise<boolean> {
if (process.platform !== "darwin") {
return false;
}
// OpenClaw's loaded LaunchAgent has canonical KeepAlive policy. Read this once before
// polling so an unloaded agent can still reach the existing recovery path promptly.
return await params.service.isLoaded({ env: params.env }).catch(() => false);
}
function formatPostUpdateGatewayRecoveryLine(platform: NodeJS.Platform): string {
const restartCommand = replaceCliName(formatCliCommand("openclaw gateway restart"), CLI_NAME);
const installCommand = replaceCliName(
formatCliCommand("openclaw gateway install --force"),
CLI_NAME,
);
const statusCommand = replaceCliName(
formatCliCommand("openclaw gateway status --deep"),
CLI_NAME,
);
if (platform === "darwin") {
return `Recovery: run \`${restartCommand}\`; if the LaunchAgent is installed but not loaded, run \`${installCommand}\` from the logged-in macOS user session, then rerun \`${statusCommand}\`.`;
}
if (platform === "linux") {
return `Recovery: run \`${restartCommand}\`; if the systemd user service is missing, stale, or not active, run \`${installCommand}\` from the same user account, then rerun \`${statusCommand}\`.`;
}
if (platform === "win32") {
return `Recovery: run \`${restartCommand}\`; if the gateway Scheduled Task or Windows login item is missing, stale, or not running, run \`${installCommand}\` from the same user account, then rerun \`${statusCommand}\`.`;
}
return `Recovery: run \`${restartCommand}\`; if the local service manager reports the gateway service is missing, stale, or not running, run \`${installCommand}\` from the same user account, then rerun \`${statusCommand}\`.`;
}
export function formatPostUpdateGatewayRecoveryInstructions(
result: UpdateRunResult,
platform: NodeJS.Platform = process.platform,
): string[] {
const lines = [formatPostUpdateGatewayRecoveryLine(platform)];
const beforeVersion = normalizeOptionalString(result.before?.version);
if (isPackageManagerUpdateMode(result.mode) && beforeVersion) {
lines.push(
`Rollback: reinstall OpenClaw ${beforeVersion} with the same package manager, then rerun \`${replaceCliName(formatCliCommand("openclaw gateway install --force"), CLI_NAME)}\`.`,
);
}
return lines;
}

View File

@@ -1,98 +1,15 @@
import type { GatewayServiceState } from "../../daemon/service-types.js";
import type { GatewayService } from "../../daemon/service.js";
import type { UpdateRunResult } from "../../infra/update-runner.js";
import type { GatewayRestartSnapshot } from "../daemon-cli/restart-health.js";
import "./update-command-service.js";
import { recoverInstalledLaunchAgentAfterUpdate } from "./update-command-launch-agent-recovery.js";
import {
formatPostUpdateGatewayRecoveryInstructions,
hasLoadedLaunchdKeepAliveSupervisor,
recoverLaunchAgentAndRecheckGatewayHealth,
shouldUseLegacyProcessRestartAfterUpdate,
} from "./update-command-service-recovery.js";
type PostUpdateLaunchAgentRecoveryResult =
| { attempted: false; recovered: false }
| { attempted: true; recovered: true; message: string }
| { attempted: true; recovered: false; detail: string };
type UpdateCommandServiceTestApi = {
formatPostUpdateGatewayRecoveryInstructions(
result: UpdateRunResult,
platform?: NodeJS.Platform,
): string[];
recoverInstalledLaunchAgentAfterUpdate(params: {
service?: GatewayService;
env?: NodeJS.ProcessEnv;
deps?: {
platform?: NodeJS.Platform;
readState?: (
service: GatewayService,
args: { env?: NodeJS.ProcessEnv },
) => Promise<GatewayServiceState>;
recover?: (params: {
result: "restarted";
env?: Record<string, string | undefined>;
}) => Promise<{ result: "restarted"; loaded: true; message: string } | null>;
};
}): Promise<PostUpdateLaunchAgentRecoveryResult>;
recoverLaunchAgentAndRecheckGatewayHealth(params: {
health: GatewayRestartSnapshot;
service: GatewayService;
port: number;
expectedVersion?: string;
env?: NodeJS.ProcessEnv;
deps?: {
recoverLaunchAgent?: (params: {
service?: GatewayService;
env?: NodeJS.ProcessEnv;
}) => Promise<PostUpdateLaunchAgentRecoveryResult>;
waitForHealthy?: (params: {
service: GatewayService;
port: number;
expectedVersion?: string;
env?: NodeJS.ProcessEnv;
}) => Promise<GatewayRestartSnapshot>;
};
}): Promise<{
health: GatewayRestartSnapshot;
launchAgentRecovery: PostUpdateLaunchAgentRecoveryResult | null;
}>;
hasLoadedLaunchdKeepAliveSupervisor(params: {
service: GatewayService;
env?: NodeJS.ProcessEnv;
}): Promise<boolean>;
shouldUseLegacyProcessRestartAfterUpdate(params: {
updateMode: UpdateRunResult["mode"];
}): boolean;
export const testing = {
formatPostUpdateGatewayRecoveryInstructions,
recoverInstalledLaunchAgentAfterUpdate,
recoverLaunchAgentAndRecheckGatewayHealth,
hasLoadedLaunchdKeepAliveSupervisor,
shouldUseLegacyProcessRestartAfterUpdate,
};
function getTestApi(): UpdateCommandServiceTestApi {
return (globalThis as Record<PropertyKey, unknown>)[
Symbol.for("openclaw.updateCommandServiceTestApi")
] as UpdateCommandServiceTestApi;
}
export function formatPostUpdateGatewayRecoveryInstructions(
result: UpdateRunResult,
platform?: NodeJS.Platform,
): string[] {
return getTestApi().formatPostUpdateGatewayRecoveryInstructions(result, platform);
}
export async function recoverInstalledLaunchAgentAfterUpdate(
params: Parameters<UpdateCommandServiceTestApi["recoverInstalledLaunchAgentAfterUpdate"]>[0],
): Promise<PostUpdateLaunchAgentRecoveryResult> {
return await getTestApi().recoverInstalledLaunchAgentAfterUpdate(params);
}
export async function recoverLaunchAgentAndRecheckGatewayHealth(
params: Parameters<UpdateCommandServiceTestApi["recoverLaunchAgentAndRecheckGatewayHealth"]>[0],
) {
return await getTestApi().recoverLaunchAgentAndRecheckGatewayHealth(params);
}
export async function hasLoadedLaunchdKeepAliveSupervisor(
params: Parameters<UpdateCommandServiceTestApi["hasLoadedLaunchdKeepAliveSupervisor"]>[0],
): Promise<boolean> {
return await getTestApi().hasLoadedLaunchdKeepAliveSupervisor(params);
}
export function shouldUseLegacyProcessRestartAfterUpdate(
params: Parameters<UpdateCommandServiceTestApi["shouldUseLegacyProcessRestartAfterUpdate"]>[0],
): boolean {
return getTestApi().shouldUseLegacyProcessRestartAfterUpdate(params);
}

View File

@@ -28,11 +28,7 @@ import {
} from "../../daemon/schtasks.js";
import { summarizeGatewayServiceLayout } from "../../daemon/service-layout.js";
import type { GatewayServiceCommandConfig } from "../../daemon/service-types.js";
import {
readGatewayServiceState,
resolveGatewayService,
type GatewayService,
} from "../../daemon/service.js";
import { readGatewayServiceState, resolveGatewayService } from "../../daemon/service.js";
import { parseStrictPositiveInteger } from "../../infra/parse-finite-number.js";
import { getSelfAndAncestorPidsSync } from "../../infra/restart-stale-pids.js";
import { nodeVersionSatisfiesEngine } from "../../infra/runtime-guard.js";
@@ -45,12 +41,10 @@ import { replaceCliName, resolveCliName } from "../cli-name.js";
import { formatCliCommand } from "../command-format.js";
import { installCompletion } from "../completion-runtime.js";
import { runDaemonInstall, runDaemonRestart } from "../daemon-cli.js";
import { recoverInstalledLaunchAgent } from "../daemon-cli/launchd-recovery.js";
import {
renderRestartDiagnostics,
terminateStaleGatewayPids,
waitForGatewayHealthyRestart,
type GatewayRestartSnapshot,
} from "../daemon-cli/restart-health.js";
import {
registerSignalExitBarrier,
@@ -60,6 +54,15 @@ import {
import { runRestartScript } from "./restart-helper.js";
import { resolveNodeRunner, type UpdateCommandOptions } from "./shared.js";
import { createUpdateConfigSnapshot } from "./update-command-config.js";
import {
formatPostUpdateGatewayRecoveryInstructions,
hasLoadedLaunchdKeepAliveSupervisor,
isPackageManagerUpdateMode,
recoverLaunchAgentAndRecheckGatewayHealth,
shouldUseLegacyProcessRestartAfterUpdate,
} from "./update-command-service-recovery.js";
export { isPackageManagerUpdateMode } from "./update-command-service-recovery.js";
const CLI_NAME = resolveCliName();
const SERVICE_REFRESH_TIMEOUT_MS = 60_000;
@@ -80,12 +83,6 @@ const JSON_MODE_SERVICE_STDOUT = new Writable({
},
});
export function isPackageManagerUpdateMode(
mode: UpdateRunResult["mode"],
): mode is "npm" | "pnpm" | "bun" {
return mode === "npm" || mode === "pnpm" || mode === "bun";
}
export function shouldPrepareUpdatedInstallRestart(params: {
updateMode: UpdateRunResult["mode"];
serviceInstalled: boolean;
@@ -109,163 +106,6 @@ export function shouldPrepareUpdatedInstallRestart(params: {
return params.serviceLoaded;
}
function shouldUseLegacyProcessRestartAfterUpdate(params: {
updateMode: UpdateRunResult["mode"];
}): boolean {
return !isPackageManagerUpdateMode(params.updateMode);
}
type PostUpdateLaunchAgentRecoveryResult =
| { attempted: false; recovered: false }
| { attempted: true; recovered: true; message: string }
| { attempted: true; recovered: false; detail: string };
type PostUpdateLaunchAgentRecoveryDeps = {
platform?: NodeJS.Platform;
readState?: typeof readGatewayServiceState;
recover?: typeof recoverInstalledLaunchAgent;
};
async function recoverInstalledLaunchAgentAfterUpdate(params: {
service?: GatewayService;
env?: NodeJS.ProcessEnv;
deps?: PostUpdateLaunchAgentRecoveryDeps;
}): Promise<PostUpdateLaunchAgentRecoveryResult> {
const platform = params.deps?.platform ?? process.platform;
if (platform !== "darwin") {
return { attempted: false, recovered: false };
}
const service = params.service ?? resolveGatewayService();
const readState = params.deps?.readState ?? readGatewayServiceState;
const recover = params.deps?.recover ?? recoverInstalledLaunchAgent;
const state = await readState(service, { env: params.env }).catch(() => null);
if (state?.loaded) {
return { attempted: false, recovered: false };
}
if (state && !state.installed && !state.runtime?.missingSupervision) {
return { attempted: false, recovered: false };
}
const recovered = await recover({ result: "restarted", env: state?.env ?? params.env }).catch(
() => null,
);
if (!recovered) {
return {
attempted: true,
recovered: false,
detail:
"LaunchAgent was installed but not loaded; automatic bootstrap/kickstart recovery failed.",
};
}
return {
attempted: true,
recovered: true,
message: recovered.message,
};
}
type PostUpdateGatewayHealthRecoveryDeps = {
recoverLaunchAgent?: typeof recoverInstalledLaunchAgentAfterUpdate;
waitForHealthy?: typeof waitForGatewayHealthyRestart;
};
async function recoverLaunchAgentAndRecheckGatewayHealth(params: {
health: GatewayRestartSnapshot;
service: GatewayService;
port: number;
expectedVersion?: string;
env?: NodeJS.ProcessEnv;
deps?: PostUpdateGatewayHealthRecoveryDeps;
}): Promise<{
health: GatewayRestartSnapshot;
launchAgentRecovery: PostUpdateLaunchAgentRecoveryResult | null;
}> {
if (params.health.healthy) {
return { health: params.health, launchAgentRecovery: null };
}
const recoverLaunchAgent =
params.deps?.recoverLaunchAgent ?? recoverInstalledLaunchAgentAfterUpdate;
const launchAgentRecovery = await recoverLaunchAgent({
service: params.service,
env: params.env,
});
if (!launchAgentRecovery.recovered) {
return { health: params.health, launchAgentRecovery };
}
const waitForHealthy = params.deps?.waitForHealthy ?? waitForGatewayHealthyRestart;
const health = await waitForHealthy({
service: params.service,
port: params.port,
expectedVersion: params.expectedVersion,
env: params.env,
supervisorKeepsAlive: true,
});
return { health, launchAgentRecovery };
}
async function hasLoadedLaunchdKeepAliveSupervisor(params: {
service: GatewayService;
env?: NodeJS.ProcessEnv;
}): Promise<boolean> {
if (process.platform !== "darwin") {
return false;
}
// OpenClaw's loaded LaunchAgent has canonical KeepAlive policy. Read this once before
// polling so an unloaded agent can still reach the existing recovery path promptly.
return await params.service.isLoaded({ env: params.env }).catch(() => false);
}
function formatPostUpdateGatewayRecoveryLine(platform: NodeJS.Platform): string {
const restartCommand = replaceCliName(formatCliCommand("openclaw gateway restart"), CLI_NAME);
const installCommand = replaceCliName(
formatCliCommand("openclaw gateway install --force"),
CLI_NAME,
);
const statusCommand = replaceCliName(
formatCliCommand("openclaw gateway status --deep"),
CLI_NAME,
);
if (platform === "darwin") {
return `Recovery: run \`${restartCommand}\`; if the LaunchAgent is installed but not loaded, run \`${installCommand}\` from the logged-in macOS user session, then rerun \`${statusCommand}\`.`;
}
if (platform === "linux") {
return `Recovery: run \`${restartCommand}\`; if the systemd user service is missing, stale, or not active, run \`${installCommand}\` from the same user account, then rerun \`${statusCommand}\`.`;
}
if (platform === "win32") {
return `Recovery: run \`${restartCommand}\`; if the gateway Scheduled Task or Windows login item is missing, stale, or not running, run \`${installCommand}\` from the same user account, then rerun \`${statusCommand}\`.`;
}
return `Recovery: run \`${restartCommand}\`; if the local service manager reports the gateway service is missing, stale, or not running, run \`${installCommand}\` from the same user account, then rerun \`${statusCommand}\`.`;
}
function formatPostUpdateGatewayRecoveryInstructions(
result: UpdateRunResult,
platform: NodeJS.Platform = process.platform,
): string[] {
const lines = [formatPostUpdateGatewayRecoveryLine(platform)];
const beforeVersion = normalizeOptionalString(result.before?.version);
if (isPackageManagerUpdateMode(result.mode) && beforeVersion) {
lines.push(
`Rollback: reinstall OpenClaw ${beforeVersion} with the same package manager, then rerun \`${replaceCliName(formatCliCommand("openclaw gateway install --force"), CLI_NAME)}\`.`,
);
}
return lines;
}
if (process.env.VITEST || process.env.NODE_ENV === "test") {
(globalThis as Record<PropertyKey, unknown>)[Symbol.for("openclaw.updateCommandServiceTestApi")] =
{
formatPostUpdateGatewayRecoveryInstructions,
recoverInstalledLaunchAgentAfterUpdate,
recoverLaunchAgentAndRecheckGatewayHealth,
hasLoadedLaunchdKeepAliveSupervisor,
shouldUseLegacyProcessRestartAfterUpdate,
};
}
export type PreManagedServiceStop = {
stopped: boolean;
inspected: boolean;

View File

@@ -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`.",

View File

@@ -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", () => ({

View File

@@ -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;

View File

@@ -1,290 +0,0 @@
// Onboard search provider tests cover provider discovery, credential reuse, and search setup choices.
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { PluginWebSearchProviderEntry } from "../plugins/types.js";
const mocks = vi.hoisted(() => ({
resolvePluginWebSearchProviders: vi.fn<
(params?: { config?: OpenClawConfig }) => PluginWebSearchProviderEntry[]
>(() => []),
resolveWebSearchInstallCatalogEntries: vi.fn(() => []),
}));
vi.mock("../plugins/web-search-providers.runtime.js", () => ({
resolvePluginWebSearchProviders: mocks.resolvePluginWebSearchProviders,
}));
vi.mock("../plugins/web-search-install-catalog.js", () => ({
resolveWebSearchInstallCatalogEntries: mocks.resolveWebSearchInstallCatalogEntries,
}));
function createCustomProviderEntry(): PluginWebSearchProviderEntry {
return {
id: "custom-search" as never,
pluginId: "custom-plugin",
label: "Custom Search",
hint: "Custom provider",
onboardingScopes: ["text-inference"],
envVars: ["CUSTOM_SEARCH_API_KEY"],
placeholder: "custom-...",
signupUrl: "https://example.com/custom",
credentialPath: "plugins.entries.custom-plugin.config.webSearch.apiKey",
getCredentialValue: () => undefined,
setCredentialValue: () => {},
getConfiguredCredentialValue: (config) =>
(
config?.plugins?.entries?.["custom-plugin"]?.config as
| { webSearch?: { apiKey?: unknown } }
| undefined
)?.webSearch?.apiKey,
setConfiguredCredentialValue: (configTarget, value) => {
const entries = ((configTarget.plugins ??= {}).entries ??= {});
const pluginEntry = (entries["custom-plugin"] ??= {});
const pluginConfig = ((pluginEntry as Record<string, unknown>).config ??= {}) as Record<
string,
unknown
>;
const webSearch = (pluginConfig.webSearch ??= {}) as Record<string, unknown>;
webSearch.apiKey = value;
},
createTool: () => null,
};
}
function createBundledFirecrawlEntry(): PluginWebSearchProviderEntry {
return {
id: "firecrawl",
pluginId: "firecrawl",
label: "Firecrawl Search",
hint: "Structured results",
onboardingScopes: ["text-inference"],
envVars: ["FIRECRAWL_API_KEY"],
placeholder: "fc-...",
signupUrl: "https://example.com/firecrawl",
credentialPath: "plugins.entries.firecrawl.config.webSearch.apiKey",
getCredentialValue: () => undefined,
setCredentialValue: () => {},
getConfiguredCredentialValue: (config) =>
(
config?.plugins?.entries?.firecrawl?.config as
| { webSearch?: { apiKey?: unknown } }
| undefined
)?.webSearch?.apiKey,
setConfiguredCredentialValue: () => {},
createTool: () => null,
};
}
function createBundledDuckDuckGoEntry(): PluginWebSearchProviderEntry {
return {
id: "duckduckgo",
pluginId: "duckduckgo",
label: "DuckDuckGo Search (experimental)",
hint: "Free fallback",
onboardingScopes: ["text-inference"],
requiresCredential: false,
envVars: [],
placeholder: "(no key needed)",
signupUrl: "https://duckduckgo.com/",
credentialPath: "",
getCredentialValue: () => "duckduckgo-no-key-needed",
setCredentialValue: () => {},
createTool: () => null,
};
}
describe("onboard-search provider resolution", () => {
let mod: typeof import("./onboard-search.js");
beforeAll(async () => {
mod = await import("./onboard-search.js");
});
afterEach(() => {
vi.clearAllMocks();
});
it("uses config-aware non-bundled provider hooks when resolving existing keys", () => {
const customEntry = createCustomProviderEntry();
mocks.resolvePluginWebSearchProviders.mockImplementation((params) =>
params?.config ? [customEntry] : [],
);
const cfg: OpenClawConfig = {
tools: {
web: {
search: {
provider: "custom-search" as never,
},
},
},
plugins: {
entries: {
"custom-plugin": {
config: {
webSearch: {
apiKey: "custom-key",
},
},
},
},
},
};
expect(mod.hasExistingKey(cfg, "custom-search" as never)).toBe(true);
expect(mod.resolveExistingKey(cfg, "custom-search" as never)).toBe("custom-key");
const updated = mod.applySearchKey(cfg, "custom-search" as never, "next-key");
expect(
(
updated.plugins?.entries?.["custom-plugin"]?.config as
| { webSearch?: { apiKey?: unknown } }
| undefined
)?.webSearch?.apiKey,
).toBe("next-key");
});
it("uses config-aware non-bundled providers when building secret refs", async () => {
const customEntry = createCustomProviderEntry();
mocks.resolvePluginWebSearchProviders.mockImplementation((params) =>
params?.config ? [customEntry] : [],
);
const cfg: OpenClawConfig = {
plugins: {
installs: {
"custom-plugin": {
installPath: "/tmp/custom-plugin",
source: "path",
},
},
},
};
const notes: Array<{ title?: string; message: string }> = [];
const prompter = {
intro: vi.fn(async () => {}),
outro: vi.fn(async () => {}),
note: vi.fn(async (message: string, title?: string) => {
notes.push({ title, message });
}),
select: vi.fn(async () => "custom-search"),
multiselect: vi.fn(async () => []),
text: vi.fn(async () => ""),
confirm: vi.fn(async () => true),
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
};
const result = await mod.setupSearch(cfg, {} as never, prompter as never, {
secretInputMode: "ref",
});
expect(result.tools?.web?.search?.provider).toBe("custom-search");
expect(result.tools?.web?.search?.enabled).toBe(true);
expect(
(
result.plugins?.entries?.["custom-plugin"]?.config as
| { webSearch?: { apiKey?: unknown } }
| undefined
)?.webSearch?.apiKey,
).toEqual({
source: "env",
provider: "default",
id: "CUSTOM_SEARCH_API_KEY",
});
expect(notes.map((note) => note.message).join("\n")).toContain("CUSTOM_SEARCH_API_KEY");
});
it("does not treat hard-disabled bundled providers as selectable credentials", () => {
mocks.resolvePluginWebSearchProviders.mockReturnValue([]);
const cfg: OpenClawConfig = {
tools: {
web: {
search: {
provider: "firecrawl",
},
},
},
plugins: {
enabled: false,
entries: {
firecrawl: {
config: {
webSearch: {
apiKey: "fc-disabled-key",
},
},
},
},
},
};
expect(mod.hasExistingKey(cfg, "firecrawl")).toBe(false);
expect(mod.resolveExistingKey(cfg, "firecrawl")).toBeUndefined();
expect(mod.applySearchProviderSelection(cfg, "firecrawl")).toBe(cfg);
});
it("supports explicit keyless provider selection without defaulting to it", async () => {
const duckduckgoEntry = createBundledDuckDuckGoEntry();
mocks.resolvePluginWebSearchProviders.mockImplementation((params) =>
params?.config ? [duckduckgoEntry] : [duckduckgoEntry],
);
const notes: string[] = [];
const prompter = {
intro: vi.fn(async () => {}),
outro: vi.fn(async () => {}),
note: vi.fn(async (message: string) => {
notes.push(message);
}),
select: vi.fn(async () => "duckduckgo"),
multiselect: vi.fn(async () => []),
text: vi.fn(async () => {
throw new Error("text prompt should not run for keyless providers");
}),
confirm: vi.fn(async () => true),
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
};
const result = await mod.setupSearch({} as OpenClawConfig, {} as never, prompter as never);
expect(prompter.select).toHaveBeenCalledWith(
expect.objectContaining({ initialValue: "__skip__" }),
);
expect(result.tools?.web?.search?.provider).toBe("duckduckgo");
expect(result.plugins?.entries?.duckduckgo?.enabled).toBe(true);
expect(notes.join("\n")).toContain("works without an API key");
});
it("uses the runtime onboarding search surface when no config is present", () => {
const firecrawlEntry = createBundledFirecrawlEntry();
const duckduckgoEntry = createBundledDuckDuckGoEntry();
const tavilyEntry: PluginWebSearchProviderEntry = {
...firecrawlEntry,
id: "tavily",
pluginId: "tavily",
label: "Tavily Search",
hint: "Research search",
envVars: ["TAVILY_API_KEY"],
signupUrl: "https://example.com/tavily",
credentialPath: "plugins.entries.tavily.config.webSearch.apiKey",
};
const customEntry = createCustomProviderEntry();
mocks.resolvePluginWebSearchProviders.mockReturnValue([
customEntry,
duckduckgoEntry,
firecrawlEntry,
tavilyEntry,
]);
const options = mod.resolveSearchProviderOptions();
expect(options.map((entry) => entry.id)).toEqual([
"custom-search",
"duckduckgo",
"firecrawl",
"tavily",
]);
});
});

View File

@@ -1,717 +0,0 @@
// Onboard search tests cover provider options, credential handling, and search setup config mutation.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { PluginWebSearchProviderEntry } from "../plugins/types.js";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { listSearchProviderOptions, setupSearch } from "./onboard-search.js";
type WebSearchConfigRecord = {
plugins?: {
entries?: Record<
string,
{ enabled?: boolean; config?: { webSearch?: Record<string, unknown> } }
>;
};
};
const SEARCH_PROVIDER_PLUGINS: Record<
string,
{ pluginId: string; envVars: string[]; label: string; credentialLabel?: string }
> = {
brave: { pluginId: "brave", envVars: ["BRAVE_API_KEY"], label: "Brave Search" },
firecrawl: { pluginId: "firecrawl", envVars: ["FIRECRAWL_API_KEY"], label: "Firecrawl" },
gemini: { pluginId: "google", envVars: ["GEMINI_API_KEY", "GOOGLE_API_KEY"], label: "Gemini" },
grok: { pluginId: "xai", envVars: ["XAI_API_KEY"], label: "Grok" },
kimi: {
pluginId: "moonshot",
envVars: ["KIMI_API_KEY", "MOONSHOT_API_KEY"],
label: "Kimi",
credentialLabel: "Moonshot / Kimi API key",
},
minimax: {
pluginId: "minimax",
envVars: [
"MINIMAX_CODE_PLAN_KEY",
"MINIMAX_CODING_API_KEY",
"MINIMAX_OAUTH_TOKEN",
"MINIMAX_API_KEY",
],
label: "MiniMax Search",
credentialLabel: "MiniMax Token Plan key or OAuth token",
},
perplexity: {
pluginId: "perplexity",
envVars: ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"],
label: "Perplexity",
},
tavily: { pluginId: "tavily", envVars: ["TAVILY_API_KEY"], label: "Tavily" },
};
function getWebSearchConfig(config: OpenClawConfig | undefined, pluginId: string) {
return (config as WebSearchConfigRecord | undefined)?.plugins?.entries?.[pluginId]?.config
?.webSearch;
}
function ensureWebSearchConfig(config: OpenClawConfig, pluginId: string) {
const entries = ((config.plugins ??= {}).entries ??= {});
const pluginEntry = (entries[pluginId] ??= {}) as {
enabled?: boolean;
config?: { webSearch?: Record<string, unknown> };
};
pluginEntry.config ??= {};
pluginEntry.config.webSearch ??= {};
return pluginEntry.config.webSearch;
}
function createSearchProviderEntry(id: string): PluginWebSearchProviderEntry {
const metadata = SEARCH_PROVIDER_PLUGINS[id];
if (!metadata) {
throw new Error(`missing search provider fixture: ${id}`);
}
const entry: PluginWebSearchProviderEntry = {
id: id as never,
pluginId: metadata.pluginId,
label: metadata.label,
hint: `${metadata.label} web search`,
onboardingScopes: ["text-inference"],
envVars: metadata.envVars,
placeholder: `${id}-key`,
signupUrl: `https://example.com/${id}`,
credentialLabel:
metadata.credentialLabel ??
(id === "gemini" ? "Google Gemini API key" : `${metadata.label} API key`),
credentialPath: `plugins.entries.${metadata.pluginId}.config.webSearch.apiKey`,
getCredentialValue: () => undefined,
setCredentialValue: () => {},
getConfiguredCredentialValue: (config) => getWebSearchConfig(config, metadata.pluginId)?.apiKey,
setConfiguredCredentialValue: (config, value) => {
ensureWebSearchConfig(config, metadata.pluginId).apiKey = value;
},
createTool: () => null,
applySelectionConfig: (config) => {
const next: OpenClawConfig = { ...config, plugins: { ...config.plugins } };
const entries = { ...next.plugins?.entries } as NonNullable<
NonNullable<OpenClawConfig["plugins"]>["entries"]
>;
entries[metadata.pluginId] = { ...entries[metadata.pluginId], enabled: true };
next.plugins = { ...next.plugins, entries };
if (id !== "firecrawl" || next.tools?.web?.fetch?.provider) {
return next;
}
return {
...next,
tools: {
...next.tools,
web: {
...next.tools?.web,
fetch: { provider: "firecrawl" },
},
},
};
},
};
if (id === "kimi") {
entry.runSetup = async ({ config, prompter }) => {
const baseUrl = await prompter.select({
message: "Moonshot endpoint",
options: [{ value: "https://api.moonshot.ai/v1", label: "Moonshot" }],
initialValue: "https://api.moonshot.ai/v1",
});
const modelChoice = await prompter.select({
message: "Moonshot web-search model",
options: [{ value: "__keep__", label: "Keep default" }],
initialValue: "__keep__",
});
const webSearch = ensureWebSearchConfig(config, metadata.pluginId);
webSearch.baseUrl = baseUrl;
webSearch.model = modelChoice === "__keep__" ? "kimi-k2.6" : modelChoice;
return config;
};
}
return entry;
}
const searchProviderFixture = vi.hoisted(() => ({
resolvePluginWebSearchProviders: vi.fn(() =>
["brave", "firecrawl", "gemini", "grok", "kimi", "minimax", "perplexity", "tavily"].map((id) =>
createSearchProviderEntry(id),
),
),
}));
vi.mock("../plugins/web-search-providers.runtime.js", () => ({
resolvePluginWebSearchProviders: searchProviderFixture.resolvePluginWebSearchProviders,
}));
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: ((code: number) => {
throw new Error(`unexpected exit ${code}`);
}) as RuntimeEnv["exit"],
};
const SEARCH_PROVIDER_ENV_VARS = [
"BRAVE_API_KEY",
"FIRECRAWL_API_KEY",
"GEMINI_API_KEY",
"GOOGLE_API_KEY",
"KIMI_API_KEY",
"MOONSHOT_API_KEY",
"MINIMAX_API_KEY",
"MINIMAX_CODE_PLAN_KEY",
"MINIMAX_CODING_API_KEY",
"MINIMAX_OAUTH_TOKEN",
"OPENROUTER_API_KEY",
"PERPLEXITY_API_KEY",
"TAVILY_API_KEY",
"XAI_API_KEY",
] as const;
let originalSearchProviderEnv: Partial<Record<(typeof SEARCH_PROVIDER_ENV_VARS)[number], string>> =
{};
function createPrompter(params: {
selectValue?: string;
selectValues?: string[];
textValue?: string;
}): {
prompter: WizardPrompter;
notes: Array<{ title?: string; message: string }>;
} {
const notes: Array<{ title?: string; message: string }> = [];
const remainingSelectValues = [...(params.selectValues ?? [])];
const prompter: WizardPrompter = {
intro: vi.fn(async () => {}),
outro: vi.fn(async () => {}),
note: vi.fn(async (message: string, title?: string) => {
notes.push({ title, message });
}),
select: vi.fn(
async () => remainingSelectValues.shift() ?? params.selectValue ?? "perplexity",
) as unknown as WizardPrompter["select"],
multiselect: vi.fn(async () => []) as unknown as WizardPrompter["multiselect"],
text: vi.fn(async () => params.textValue ?? ""),
confirm: vi.fn(async () => true),
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
};
return { prompter, notes };
}
function mockCalls<T extends unknown[]>(fn: unknown): T[] {
return (fn as { mock: { calls: T[] } }).mock.calls;
}
function createPerplexityConfig(apiKey: string, enabled?: boolean): OpenClawConfig {
return {
tools: {
web: {
search: {
provider: "perplexity",
...(enabled === undefined ? {} : { enabled }),
},
},
},
plugins: {
entries: {
perplexity: {
config: {
webSearch: {
apiKey,
},
},
},
},
},
};
}
function pluginWebSearchApiKey(config: OpenClawConfig, pluginId: string): unknown {
const entry = (
config.plugins?.entries as
| Record<string, { config?: { webSearch?: { apiKey?: unknown } } }>
| undefined
)?.[pluginId];
return entry?.config?.webSearch?.apiKey;
}
function createDisabledFirecrawlConfig(apiKey?: string): OpenClawConfig {
return {
tools: {
web: {
search: {
provider: "firecrawl",
},
},
},
plugins: {
entries: {
firecrawl: {
enabled: false,
...(apiKey
? {
config: {
webSearch: {
apiKey,
},
},
}
: {}),
},
},
},
};
}
function readFirecrawlPluginApiKey(config: OpenClawConfig): string | undefined {
const pluginConfig = config.plugins?.entries?.firecrawl?.config as
| {
webSearch?: {
apiKey?: string;
};
}
| undefined;
return pluginConfig?.webSearch?.apiKey;
}
async function runBlankPerplexityKeyEntry(
apiKey: string,
enabled?: boolean,
): Promise<OpenClawConfig> {
const cfg = createPerplexityConfig(apiKey, enabled);
const { prompter } = createPrompter({
selectValue: "perplexity",
textValue: "",
});
return setupSearch(cfg, runtime, prompter);
}
async function runQuickstartPerplexitySetup(
apiKey: string,
enabled?: boolean,
): Promise<{ result: OpenClawConfig; prompter: WizardPrompter }> {
const cfg = createPerplexityConfig(apiKey, enabled);
const { prompter } = createPrompter({ selectValue: "perplexity" });
const result = await setupSearch(cfg, runtime, prompter, {
quickstartDefaults: true,
});
return { result, prompter };
}
describe("setupSearch", () => {
beforeEach(() => {
originalSearchProviderEnv = Object.fromEntries(
SEARCH_PROVIDER_ENV_VARS.map((key) => [key, process.env[key]]),
);
for (const key of SEARCH_PROVIDER_ENV_VARS) {
delete process.env[key];
}
});
afterEach(() => {
for (const key of SEARCH_PROVIDER_ENV_VARS) {
const value = originalSearchProviderEnv[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
it("returns config unchanged when user skips", async () => {
const cfg: OpenClawConfig = {};
const { prompter } = createPrompter({ selectValue: "__skip__" });
const result = await setupSearch(cfg, runtime, prompter);
expect(result).toBe(cfg);
});
it("sets provider keys and enables plugin entries", async () => {
const cases = [
{ provider: "perplexity", pluginId: "perplexity", key: "pplx-test-key" },
{ provider: "brave", pluginId: "brave", key: "BSA-test-key" },
{ provider: "firecrawl", pluginId: "firecrawl", key: "fc-test-key" },
{ provider: "grok", pluginId: "xai", key: "xai-test" },
{ provider: "tavily", pluginId: "tavily", key: "tvly-test-key" },
{
provider: "gemini",
pluginId: "google",
key: "AIza-test",
textMessage: "Google Gemini API key",
},
];
for (const entry of cases) {
const cfg: OpenClawConfig = {};
const { prompter } = createPrompter({
selectValue: entry.provider,
textValue: entry.key,
});
const result = await setupSearch(cfg, runtime, prompter);
expect(result.tools?.web?.search?.provider).toBe(entry.provider);
expect(result.tools?.web?.search?.enabled).toBe(true);
expect(pluginWebSearchApiKey(result, entry.pluginId)).toBe(entry.key);
expect(result.plugins?.entries?.[entry.pluginId]?.enabled).toBe(true);
if (entry.textMessage) {
const textCall = mockCalls<[Record<string, unknown>]>(prompter.text).find(
([options]) => options.message === entry.textMessage,
);
expect(textCall?.[0].sensitive).toBe(true);
}
}
const kimiCfg: OpenClawConfig = {};
const { prompter: kimiPrompter } = createPrompter({
selectValues: ["kimi", "https://api.moonshot.ai/v1", "__keep__"],
textValue: "sk-moonshot",
});
const kimiResult = await setupSearch(kimiCfg, runtime, kimiPrompter);
const kimiWebSearchConfig = kimiResult.plugins?.entries?.moonshot?.config?.webSearch as
| {
baseUrl?: string;
model?: string;
}
| undefined;
expect(kimiResult.tools?.web?.search?.provider).toBe("kimi");
expect(kimiResult.tools?.web?.search?.enabled).toBe(true);
expect(pluginWebSearchApiKey(kimiResult, "moonshot")).toBe("sk-moonshot");
expect(kimiResult.plugins?.entries?.moonshot?.enabled).toBe(true);
expect(kimiWebSearchConfig?.baseUrl).toBe("https://api.moonshot.ai/v1");
expect(kimiWebSearchConfig?.model).toBe("kimi-k2.6");
const disabledCfg = createDisabledFirecrawlConfig();
const { prompter: disabledPrompter } = createPrompter({
selectValue: "firecrawl",
textValue: "fc-disabled-key",
});
const disabledResult = await setupSearch(disabledCfg, runtime, disabledPrompter);
expect(disabledResult.tools?.web?.search?.provider).toBe("firecrawl");
expect(disabledResult.tools?.web?.search?.enabled).toBe(true);
expect(disabledResult.plugins?.entries?.firecrawl?.enabled).toBe(true);
expect(readFirecrawlPluginApiKey(disabledResult)).toBe("fc-disabled-key");
});
it("shows missing-key note when no key is provided and no env var", async () => {
const original = process.env.BRAVE_API_KEY;
delete process.env.BRAVE_API_KEY;
try {
const cfg: OpenClawConfig = {};
const { prompter, notes } = createPrompter({
selectValue: "brave",
textValue: "",
});
const result = await setupSearch(cfg, runtime, prompter);
expect(result.tools?.web?.search?.provider).toBe("brave");
expect(result.tools?.web?.search?.enabled).toBe(false);
const missingNote = notes.find((n) => n.message.includes("No Brave Search API key stored"));
expect(missingNote?.message).toContain("No Brave Search API key stored");
} finally {
if (original === undefined) {
delete process.env.BRAVE_API_KEY;
} else {
process.env.BRAVE_API_KEY = original;
}
}
});
it("keeps keyless Firecrawl fetch configured when search setup has no key", async () => {
const original = process.env.FIRECRAWL_API_KEY;
delete process.env.FIRECRAWL_API_KEY;
try {
const { prompter } = createPrompter({
selectValue: "firecrawl",
textValue: "",
});
const result = await setupSearch({}, runtime, prompter);
expect(result.tools?.web?.search?.provider).toBe("firecrawl");
expect(result.tools?.web?.search?.enabled).toBe(false);
expect(result.tools?.web?.fetch?.provider).toBe("firecrawl");
expect(result.plugins?.entries?.firecrawl?.enabled).toBe(true);
} finally {
if (original === undefined) {
delete process.env.FIRECRAWL_API_KEY;
} else {
process.env.FIRECRAWL_API_KEY = original;
}
}
});
it("keeps existing key when user leaves input blank", async () => {
const result = await runBlankPerplexityKeyEntry(
"existing-key", // pragma: allowlist secret
);
expect(pluginWebSearchApiKey(result, "perplexity")).toBe("existing-key");
expect(result.tools?.web?.search?.enabled).toBe(true);
const disabledResult = await runBlankPerplexityKeyEntry(
"existing-key", // pragma: allowlist secret
false,
);
expect(pluginWebSearchApiKey(disabledResult, "perplexity")).toBe("existing-key");
expect(disabledResult.tools?.web?.search?.enabled).toBe(false);
});
it("quickstart skips key prompt when config key exists", async () => {
const { result, prompter } = await runQuickstartPerplexitySetup(
"stored-pplx-key", // pragma: allowlist secret
);
expect(result.tools?.web?.search?.provider).toBe("perplexity");
expect(pluginWebSearchApiKey(result, "perplexity")).toBe("stored-pplx-key");
expect(result.tools?.web?.search?.enabled).toBe(true);
expect(prompter.text).not.toHaveBeenCalled();
const { result: disabledResult, prompter: disabledPrompter } =
await runQuickstartPerplexitySetup(
"stored-pplx-key", // pragma: allowlist secret
false,
);
expect(disabledResult.tools?.web?.search?.provider).toBe("perplexity");
expect(pluginWebSearchApiKey(disabledResult, "perplexity")).toBe("stored-pplx-key");
expect(disabledResult.tools?.web?.search?.enabled).toBe(false);
expect(disabledPrompter.text).not.toHaveBeenCalled();
});
it("quickstart skips key prompt when canonical plugin config key exists", async () => {
const cfg: OpenClawConfig = {
tools: {
web: {
search: {
provider: "tavily",
},
},
},
plugins: {
entries: {
tavily: {
enabled: true,
config: {
webSearch: {
apiKey: "tvly-existing-key",
},
},
},
},
},
};
const { prompter } = createPrompter({ selectValue: "tavily" });
const result = await setupSearch(cfg, runtime, prompter, {
quickstartDefaults: true,
});
expect(result.tools?.web?.search?.provider).toBe("tavily");
expect(pluginWebSearchApiKey(result, "tavily")).toBe("tvly-existing-key");
expect(result.tools?.web?.search?.enabled).toBe(true);
expect(prompter.text).not.toHaveBeenCalled();
});
it("quickstart falls through to key prompt when no key and no env var", async () => {
const original = process.env.XAI_API_KEY;
delete process.env.XAI_API_KEY;
try {
const cfg: OpenClawConfig = {};
const { prompter } = createPrompter({ selectValue: "grok", textValue: "" });
const result = await setupSearch(cfg, runtime, prompter, {
quickstartDefaults: true,
});
expect(prompter.text).toHaveBeenCalled();
expect(result.tools?.web?.search?.provider).toBe("grok");
expect(result.tools?.web?.search?.enabled).toBe(false);
} finally {
if (original === undefined) {
delete process.env.XAI_API_KEY;
} else {
process.env.XAI_API_KEY = original;
}
}
});
it("uses provider-specific credential copy for kimi in onboarding", async () => {
const cfg: OpenClawConfig = {};
const { prompter } = createPrompter({
selectValue: "kimi",
textValue: "",
});
await setupSearch(cfg, runtime, prompter);
const textCall = mockCalls<[Record<string, unknown>]>(prompter.text).find(
([options]) => options.message === "Moonshot / Kimi API key",
);
expect(textCall?.[0].message).toBe("Moonshot / Kimi API key");
});
it("quickstart skips key prompt when env var is available", async () => {
const orig = process.env.BRAVE_API_KEY;
process.env.BRAVE_API_KEY = "env-brave-key"; // pragma: allowlist secret
try {
const cfg: OpenClawConfig = {};
const { prompter } = createPrompter({ selectValue: "brave" });
const result = await setupSearch(cfg, runtime, prompter, {
quickstartDefaults: true,
});
expect(result.tools?.web?.search?.provider).toBe("brave");
expect(result.tools?.web?.search?.enabled).toBe(true);
expect(prompter.text).not.toHaveBeenCalled();
} finally {
if (orig === undefined) {
delete process.env.BRAVE_API_KEY;
} else {
process.env.BRAVE_API_KEY = orig;
}
}
});
it("quickstart detects an existing firecrawl key even when the plugin is disabled", async () => {
const cfg = createDisabledFirecrawlConfig("fc-configured-key");
const { prompter } = createPrompter({ selectValue: "firecrawl" });
const result = await setupSearch(cfg, runtime, prompter, {
quickstartDefaults: true,
});
expect(prompter.text).not.toHaveBeenCalled();
expect(result.tools?.web?.search?.provider).toBe("firecrawl");
expect(result.tools?.web?.search?.enabled).toBe(true);
expect(result.plugins?.entries?.firecrawl?.enabled).toBe(true);
expect(readFirecrawlPluginApiKey(result)).toBe("fc-configured-key");
});
it("preserves disabled firecrawl plugin state and allowlist when web search stays disabled", async () => {
const original = process.env.FIRECRAWL_API_KEY;
process.env.FIRECRAWL_API_KEY = "env-firecrawl-key"; // pragma: allowlist secret
const cfg: OpenClawConfig = {
tools: {
web: {
search: {
provider: "firecrawl",
enabled: false,
},
},
},
plugins: {
allow: ["google"],
entries: {
firecrawl: {
enabled: false,
},
},
},
};
try {
const { prompter } = createPrompter({ selectValue: "firecrawl" });
const result = await setupSearch(cfg, runtime, prompter, {
quickstartDefaults: true,
});
expect(prompter.text).not.toHaveBeenCalled();
expect(result.tools?.web?.search?.provider).toBe("firecrawl");
expect(result.tools?.web?.search?.enabled).toBe(false);
expect(result.plugins?.entries?.firecrawl?.enabled).toBe(false);
expect(result.plugins?.allow).toEqual(["google"]);
} finally {
if (original === undefined) {
delete process.env.FIRECRAWL_API_KEY;
} else {
process.env.FIRECRAWL_API_KEY = original;
}
}
});
it("stores env-backed SecretRef for perplexity ref mode", async () => {
const originalPerplexity = process.env.PERPLEXITY_API_KEY;
const originalOpenRouter = process.env.OPENROUTER_API_KEY;
delete process.env.PERPLEXITY_API_KEY;
delete process.env.OPENROUTER_API_KEY;
try {
const { prompter } = createPrompter({ selectValue: "perplexity" });
const result = await setupSearch({}, runtime, prompter, {
secretInputMode: "ref", // pragma: allowlist secret
});
expect(result.tools?.web?.search?.provider).toBe("perplexity");
expect(pluginWebSearchApiKey(result, "perplexity")).toEqual({
source: "env",
provider: "default",
id: "PERPLEXITY_API_KEY", // pragma: allowlist secret
});
expect(prompter.text).not.toHaveBeenCalled();
process.env.OPENROUTER_API_KEY = "sk-or-test";
const { prompter: openRouterPrompter } = createPrompter({ selectValue: "perplexity" });
const openRouterResult = await setupSearch({}, runtime, openRouterPrompter, {
secretInputMode: "ref", // pragma: allowlist secret
});
expect(pluginWebSearchApiKey(openRouterResult, "perplexity")).toEqual({
source: "env",
provider: "default",
id: "OPENROUTER_API_KEY", // pragma: allowlist secret
});
expect(openRouterPrompter.text).not.toHaveBeenCalled();
} finally {
if (originalPerplexity === undefined) {
delete process.env.PERPLEXITY_API_KEY;
} else {
process.env.PERPLEXITY_API_KEY = originalPerplexity;
}
if (originalOpenRouter === undefined) {
delete process.env.OPENROUTER_API_KEY;
} else {
process.env.OPENROUTER_API_KEY = originalOpenRouter;
}
}
});
it("stores env-backed SecretRefs for simple providers", async () => {
const original = process.env.TAVILY_API_KEY;
delete process.env.TAVILY_API_KEY;
try {
for (const entry of [
{ provider: "brave", pluginId: "brave", env: "BRAVE_API_KEY" },
{ provider: "tavily", pluginId: "tavily", env: "TAVILY_API_KEY" },
]) {
const { prompter } = createPrompter({ selectValue: entry.provider });
const result = await setupSearch({}, runtime, prompter, {
secretInputMode: "ref", // pragma: allowlist secret
});
expect(result.tools?.web?.search?.provider).toBe(entry.provider);
expect(pluginWebSearchApiKey(result, entry.pluginId)).toEqual({
source: "env",
provider: "default",
id: entry.env,
});
expect(result.plugins?.entries?.[entry.pluginId]?.enabled).toBe(true);
expect(prompter.text).not.toHaveBeenCalled();
}
} finally {
if (original === undefined) {
delete process.env.TAVILY_API_KEY;
} else {
process.env.TAVILY_API_KEY = original;
}
}
});
it("stores plaintext key when secretInputMode is unset", async () => {
const cfg: OpenClawConfig = {};
const { prompter } = createPrompter({
selectValue: "brave",
textValue: "BSA-plain",
});
const result = await setupSearch(cfg, runtime, prompter);
expect(pluginWebSearchApiKey(result, "brave")).toBe("BSA-plain");
});
it("exports search providers in alphabetical order", () => {
const providers = listSearchProviderOptions();
const values = providers.map((e) => e.id);
expect(values).toEqual([...values].toSorted());
for (const providerId of [
"brave",
"firecrawl",
"gemini",
"grok",
"kimi",
"minimax",
"perplexity",
"tavily",
]) {
expect(values).toContain(providerId);
}
});
});

View File

@@ -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";

View File

@@ -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();
});
});

View File

@@ -51,22 +51,6 @@ function normalizeNonNegativeInteger(value: unknown): number | undefined {
return value;
}
function buildLegacyTalkProviderCompat(
value: Record<string, unknown>,
): TalkProviderConfig | undefined {
const provider: TalkProviderConfig = {};
for (const key of ["voiceId", "voiceAliases", "modelId", "outputFormat"] as const) {
if (value[key] !== undefined) {
provider[key] = value[key];
}
}
const apiKey = normalizeTalkSecretInput(value.apiKey);
if (apiKey !== undefined) {
provider.apiKey = apiKey;
}
return Object.keys(provider).length > 0 ? provider : undefined;
}
function normalizeTalkProviderConfig(value: unknown): TalkProviderConfig | undefined {
if (!isRecord(value)) {
return undefined;
@@ -292,16 +276,13 @@ export function resolveActiveTalkProviderConfig(
}
/**
* Build the gateway `talk.config` payload from persisted config.
* Build the gateway `talk.config` payload from canonical Talk config.
* The response includes canonical provider data plus the resolved provider when selection is unambiguous.
*/
export function buildTalkConfigResponse(value: unknown): TalkConfigResponse | undefined {
if (!isRecord(value)) {
return undefined;
}
const normalized = normalizeTalkSection(value as TalkConfig);
const legacyCompat = buildLegacyTalkProviderCompat(value);
if (!normalized && !legacyCompat) {
export function buildTalkConfigResponse(
normalized: TalkConfig | undefined,
): TalkConfigResponse | undefined {
if (!normalized) {
return undefined;
}
@@ -331,11 +312,7 @@ export function buildTalkConfigResponse(value: unknown): TalkConfigResponse | un
payload.realtime = normalized.realtime;
}
// Keep legacy flat ElevenLabs fields readable for clients while migration moves writes to
// talk.provider/providers; normalizeTalkSection intentionally excludes those provider details.
const resolved =
resolveActiveTalkProviderConfig(normalized) ??
(legacyCompat ? { provider: "elevenlabs", config: legacyCompat } : undefined);
const resolved = resolveActiveTalkProviderConfig(normalized);
const activeProvider = resolved?.provider;
if (activeProvider) {
payload.provider = activeProvider;

View File

@@ -481,6 +481,8 @@ async function resolveTalkResponseFromConfig(params: {
sourceConfig: OpenClawConfig;
runtimeConfig: OpenClawConfig;
}): Promise<TalkConfigResponse | undefined> {
// Normalize once at the Gateway boundary. Legacy flat provider fields belong to doctor
// migration and must not leak into steady-state response construction.
const normalizedTalk = normalizeTalkSection(params.sourceConfig.talk);
const configuredPayload = normalizedTalk ? buildTalkConfigResponse(normalizedTalk) : undefined;
// Resolve provider selection from materialized config, but project provider-owned fields from

View File

@@ -12,6 +12,7 @@ import {
formatUnresolvedOpenClawPeerLinkError,
hasPackageRuntimeDependencies,
loadPluginInstallRuntime,
readOptionalPackageManifest,
runInstallSourceScan,
sourceFamilyForInstallPolicyKind,
sourceFamilyForInstallPolicySource,
@@ -42,6 +43,7 @@ type ValidatedPackagePlugin = {
export async function validatePackagePluginInstallSource(params: {
runtime: Awaited<ReturnType<typeof loadPluginInstallRuntime>>;
packageDir: string;
manifest?: PackageManifest;
expectedPluginId?: string;
requirePluginManifest?: boolean;
allowSourceTypeScriptEntries?: boolean;
@@ -59,16 +61,15 @@ export async function validatePackagePluginInstallSource(params: {
}
| PluginInstallFailureResult
> {
const manifestPath = path.join(params.packageDir, "package.json");
if (!(await params.runtime.fileExists(manifestPath))) {
return { ok: false, error: "extracted package missing package.json" };
const manifestResult = params.manifest
? ({ ok: true, manifest: params.manifest } as const)
: await readOptionalPackageManifest({ runtime: params.runtime, packageDir: params.packageDir });
if (!manifestResult.ok) {
return manifestResult;
}
let manifest: PackageManifest;
try {
manifest = await params.runtime.readJsonFile<PackageManifest>(manifestPath);
} catch (err) {
return { ok: false, error: `invalid package.json: ${String(err)}` };
const manifest = manifestResult.manifest;
if (!manifest) {
return { ok: false, error: "extracted package missing package.json" };
}
const pkgName = normalizeOptionalString(manifest.name) ?? "";

View File

@@ -1,5 +1,4 @@
import fs from "node:fs/promises";
import path from "node:path";
import { resolveUserPath } from "../utils.js";
import {
scanAndLinkInstalledPackage,
@@ -194,10 +193,11 @@ async function installPluginFromSourceDir(
sourceDir: string;
} & InternalPackageInstallCommonParams,
): Promise<InstallPluginResult> {
const nativePackageDetected = await detectNativePackageInstallSource(params.sourceDir);
if (nativePackageDetected) {
const nativePackageManifest = await detectNativePackageInstallSource(params.sourceDir);
if (nativePackageManifest) {
return await installPluginFromPackageDir({
packageDir: params.sourceDir,
packageManifest: nativePackageManifest,
...pickPackageInstallCommonParams(params),
});
}
@@ -214,24 +214,19 @@ async function installPluginFromSourceDir(
});
}
async function detectNativePackageInstallSource(packageDir: string): Promise<boolean> {
async function detectNativePackageInstallSource(
packageDir: string,
): Promise<PackageManifest | undefined> {
const runtime = await loadPluginInstallRuntime();
const manifestPath = path.join(packageDir, "package.json");
if (!(await runtime.fileExists(manifestPath))) {
return false;
}
try {
const manifest = await runtime.readJsonFile<PackageManifest>(manifestPath);
return ensureOpenClawExtensions({ manifest }).ok;
} catch {
return false;
}
const result = await readOptionalPackageManifest({ runtime, packageDir });
const manifest = result.ok ? result.manifest : undefined;
return manifest && ensureOpenClawExtensions({ manifest }).ok ? manifest : undefined;
}
async function installPluginFromPackageDir(
params: {
packageDir: string;
packageManifest?: PackageManifest;
} & InternalPackageInstallCommonParams,
): Promise<InstallPluginResult> {
const runtime = await loadPluginInstallRuntime();
@@ -260,6 +255,7 @@ async function installPluginFromPackageDir(
const validated = await validatePackagePluginInstallSource({
runtime,
packageDir: params.packageDir,
manifest: params.packageManifest,
expectedPluginId: params.expectedPluginId,
requirePluginManifest: params.requirePluginManifest,
allowSourceTypeScriptEntries: params.allowSourceTypeScriptEntries,

View File

@@ -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,

View File

@@ -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),
);

View File

@@ -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,
});

View File

@@ -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": [