From a2ff465964bd083ab82044bdb98cc27a486f860c Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:48:26 +1000 Subject: [PATCH] improve: harden non-ClawHub install acknowledgement --- .../Sources/OpenClaw/OnboardingAISetup.swift | 117 ++++++++++-- .../OnboardingAISetupTests.swift | 74 +++++++ .../OpenClawProtocol/GatewayModels.swift | 6 +- docs/cli/crestodian.md | 5 +- docs/cli/models.md | 2 +- docs/cli/plugins.md | 4 +- docs/cli/promos.md | 6 +- docs/web/tui.md | 2 +- .../gateway-protocol/src/schema/crestodian.ts | 1 + scripts/e2e/codex-npm-plugin-live-docker.sh | 2 +- scripts/e2e/kitchen-sink-rpc-walk.mjs | 17 +- scripts/e2e/lib/codex-media-path/scenario.sh | 2 +- scripts/e2e/lib/kitchen-sink-plugin/sweep.sh | 6 +- .../plugin-update/corrupt-update-scenario.sh | 2 + scripts/e2e/lib/plugins/marketplace.sh | 4 +- scripts/e2e/lib/plugins/sweep.sh | 18 +- .../release-plugin-marketplace/scenario.sh | 2 +- .../release-upgrade-user-journey/scenario.sh | 3 +- .../e2e/lib/release-user-journey/scenario.sh | 6 +- scripts/e2e/live-plugin-tool-docker.sh | 2 +- scripts/protocol-gen-swift.ts | 1 + src/agents/tools/crestodian-tool.test.ts | 59 +++++- src/agents/tools/crestodian-tool.ts | 45 ++++- src/cli/hooks-cli.test.ts | 24 +++ src/cli/hooks-cli.ts | 16 +- src/cli/models-cli.test.ts | 13 ++ src/cli/models-cli.ts | 23 ++- .../non-clawhub-install-acknowledgement.ts | 6 + src/cli/plugins-cli.ts | 8 + src/cli/plugins-cli.update.test.ts | 64 +++++++ src/cli/plugins-update-command.ts | 38 ++++ src/cli/plugins-update-outcomes.ts | 10 +- src/cli/program.smoke.test.ts | 14 ++ src/cli/program/register.crestodian.ts | 14 ++ src/cli/promos-cli.test.ts | 44 +++++ src/cli/promos-cli.ts | 27 ++- src/cli/update-cli.test.ts | 57 ++++++ src/cli/update-cli/update-command.ts | 10 +- src/commands/agents.providers.test.ts | 18 +- src/commands/channels.list.test.ts | 10 +- .../channels.status.command-flow.test.ts | 6 +- .../codex-runtime-plugin-install.test.ts | 63 +++++- src/commands/codex-runtime-plugin-install.ts | 2 +- src/commands/doctor-config-flow.test.ts | 20 ++ src/commands/doctor-config-flow.ts | 3 + src/commands/doctor/repair-sequencing.test.ts | 53 ++++++ src/commands/doctor/repair-sequencing.ts | 14 +- .../missing-configured-plugin-install.test.ts | 133 +++++++++++++ .../missing-configured-plugin-install.ts | 61 +++++- .../release-configured-plugin-installs.ts | 6 +- src/commands/models/auth.test.ts | 52 ++++- src/commands/models/auth.ts | 42 +++- src/commands/models/set.test.ts | 129 ++++++++++--- src/commands/models/set.ts | 58 ++++-- src/commands/models/shared.ts | 7 +- src/commands/onboard-guided.test.ts | 76 +++++++- src/commands/onboard-guided.ts | 39 +++- src/commands/onboard.test.ts | 13 ++ src/commands/onboard.ts | 1 + .../onboarding-plugin-install.test.ts | 41 +++- src/commands/onboarding-plugin-install.ts | 9 +- src/commands/promos/claim.test.ts | 54 +++++- src/commands/promos/claim.ts | 55 ++++-- src/commands/runtime-plugin-install.ts | 47 ++++- src/commands/status-all/channels.test.ts | 6 +- src/crestodian/agent-turn.ts | 10 +- src/crestodian/chat-engine.test.ts | 68 ++++++- src/crestodian/chat-engine.ts | 44 ++++- src/crestodian/crestodian.test.ts | 40 +++- src/crestodian/crestodian.ts | 3 + src/crestodian/operations.test.ts | 15 ++ src/crestodian/operations.ts | 24 ++- src/crestodian/setup-inference.test.ts | 2 + src/crestodian/setup-inference.ts | 4 + src/crestodian/tui-backend.ts | 2 + src/flows/doctor-health-contributions.test.ts | 53 +++++- src/flows/doctor-health-contributions.ts | 19 +- src/gateway/server-methods/crestodian.ts | 3 + src/hooks/update.test.ts | 55 ++++++ src/hooks/update.ts | 33 ++++ src/infra/install-acknowledgement-codes.ts | 3 + src/infra/outbound/channel-selection.test.ts | 8 +- src/mcp/openclaw-tools-serve-config.ts | 46 ++++- src/mcp/openclaw-tools-serve.test.ts | 20 ++ src/plugins/update.test.ts | 180 +++++++++++++++++- src/plugins/update.ts | 126 +++++++++++- .../plugins/plugin-lifecycle-probe-runtime.ts | 8 +- 87 files changed, 2297 insertions(+), 241 deletions(-) create mode 100644 src/cli/promos-cli.test.ts create mode 100644 src/infra/install-acknowledgement-codes.ts diff --git a/apps/macos/Sources/OpenClaw/OnboardingAISetup.swift b/apps/macos/Sources/OpenClaw/OnboardingAISetup.swift index 93f44135747b..bdbc9cd2675a 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingAISetup.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingAISetup.swift @@ -79,6 +79,7 @@ final class OnboardingAISetupModel { private(set) var detectError: Failure? /// Set once every detected candidate failed; opens the manual key form. private(set) var exhaustedAutoCandidates = false + private(set) var pendingNonClawHubCandidateKind: String? var manualProviderID = "" var manualKey: String = "" @@ -156,6 +157,7 @@ final class OnboardingAISetupModel { self.connectedLatencyMs = nil self.detectError = nil self.exhaustedAutoCandidates = false + self.pendingNonClawHubCandidateKind = nil self.manualProviderID = "" self.manualKey = "" self.manualError = nil @@ -261,27 +263,63 @@ final class OnboardingAISetupModel { return .polling } + static func activationNeedsLegacyAcknowledgementRetry(after error: Error) -> Bool { + guard let response = error as? GatewayResponseError, + response.method == "crestodian.setup.activate", + response.code == "INVALID_REQUEST" + else { return false } + return response.message.contains("unexpected property 'acknowledgeNonClawHubInstall'") + } + /// Candidates the automatic ladder may try: skip definitively logged-out - /// installs and anything already attempted. + /// installs, Codex until its npm source is approved, and anything already attempted. private func autoCandidateAfter(kind: String?) -> Candidate? { - let startIndex: Int = if let kind, let index = self.candidates.firstIndex(where: { $0.kind == kind }) { - index + 1 - } else { - 0 + Self.candidates(after: kind, in: self.candidates).first { candidate in + candidate.credentials != false && + candidate.kind != "codex-cli" && + self.statuses[candidate.kind] == .untried } - guard startIndex <= self.candidates.count else { return nil } - return self.candidates[startIndex...].first { candidate in - candidate.credentials != false && self.statuses[candidate.kind] == .untried + } + + private static func candidates(after kind: String?, in candidates: [Candidate]) -> ArraySlice { + guard let kind, let index = candidates.firstIndex(where: { $0.kind == kind }) else { + return candidates[...] + } + return candidates[(index + 1)...] + } + + static func hasUntriedConsentGatedCodex( + candidates: [Candidate], + statuses: [String: CandidateStatus]) -> Bool + { + candidates.contains { candidate in + candidate.kind == "codex-cli" && + candidate.credentials != false && + statuses[candidate.kind] == .untried } } func userSelect(kind: String) { guard !self.isBusy else { return } guard self.statuses[kind] != .connected else { return } + if kind == "codex-cli" { + self.pendingNonClawHubCandidateKind = kind + return + } Task { await self.activate(kind: kind) } } - func activate(kind: String) async { + func cancelNonClawHubActivation() { + self.pendingNonClawHubCandidateKind = nil + } + + func confirmNonClawHubActivation() { + guard let kind = self.pendingNonClawHubCandidateKind else { return } + self.pendingNonClawHubCandidateKind = nil + Task { await self.activate(kind: kind, acknowledgeNonClawHubInstall: true) } + } + + func activate(kind: String, acknowledgeNonClawHubInstall: Bool = false) async { let token = self.attemptToken let clock = ContinuousClock() let requestTimeoutMs = Self.activationRequestTimeoutMs(for: kind) @@ -291,11 +329,29 @@ final class OnboardingAISetupModel { self.phase = .testing self.statuses[kind] = .testing do { - let data = try await GatewayConnection.shared.request( - method: "crestodian.setup.activate", - params: ["kind": AnyCodable(kind)], - timeoutMs: requestTimeoutMs, - retryTransportFailures: false) + var params = ["kind": AnyCodable(kind)] + if acknowledgeNonClawHubInstall { + params["acknowledgeNonClawHubInstall"] = AnyCodable(true) + } + let data: Data + do { + data = try await GatewayConnection.shared.request( + method: "crestodian.setup.activate", + params: params, + timeoutMs: requestTimeoutMs, + retryTransportFailures: false) + } catch { + guard acknowledgeNonClawHubInstall, + Self.activationNeedsLegacyAcknowledgementRetry(after: error) + else { throw error } + // Protocol v4 predates this optional field. Consent is already collected + // locally, so retry only the strict old-schema rejection with its wire shape. + data = try await GatewayConnection.shared.request( + method: "crestodian.setup.activate", + params: ["kind": AnyCodable(kind)], + timeoutMs: requestTimeoutMs, + retryTransportFailures: false) + } guard token == self.attemptToken else { return } let result = try JSONDecoder().decode(ActivateResult.self, from: data) if result.ok { @@ -435,6 +491,17 @@ final class OnboardingAISetupModel { await self.activate(kind: next.kind) return } + if Self.hasUntriedConsentGatedCodex( + candidates: self.candidates, + statuses: self.statuses) + { + // Codex is still viable, but its npm source requires a user selection and consent. + // Keep the ladder ready so the UI does not report that every option failed. + self.phase = .ready + self.exhaustedAutoCandidates = false + self.showManualEntry = true + return + } self.phase = .ready self.exhaustedAutoCandidates = true self.showManualEntry = true @@ -518,6 +585,28 @@ struct OnboardingAISetupView: View { .sheet(isPresented: self.$showCrestodianChat) { self.crestodianSheet } + .alert( + "Install the Codex runtime plugin?", + isPresented: Binding( + get: { self.model.pendingNonClawHubCandidateKind != nil }, + set: { isPresented in + if !isPresented { + self.model.cancelNonClawHubActivation() + } + })) + { + Button("Cancel", role: .cancel) { + self.model.cancelNonClawHubActivation() + } + Button("Install and connect") { + self.model.confirmNonClawHubActivation() + } + } message: { + Text( + "Codex requires installing @openclaw/codex from the npm registry. " + + "This source is outside ClawHub review and trust metadata. Continue only " + + "if you trust the publisher, package contents, and install source.") + } } private var detectingView: some View { diff --git a/apps/macos/Tests/OpenClawIPCTests/OnboardingAISetupTests.swift b/apps/macos/Tests/OpenClawIPCTests/OnboardingAISetupTests.swift index 9dfae6d5175c..394ce1cc4f56 100644 --- a/apps/macos/Tests/OpenClawIPCTests/OnboardingAISetupTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/OnboardingAISetupTests.swift @@ -1,5 +1,6 @@ import Foundation import OpenClawKit +import OpenClawProtocol import Testing @testable import OpenClaw @@ -73,11 +74,28 @@ struct OnboardingAISetupTests { #expect(OnboardingAISetupModel.activationReconciliationMode(after: timeout) == .polling) } + @Test func `legacy activation retry matches only the old strict schema rejection`() { + let oldGatewayError = GatewayResponseError( + method: "crestodian.setup.activate", + code: "INVALID_REQUEST", + message: "invalid crestodian.setup.activate params: at root: unexpected property 'acknowledgeNonClawHubInstall'", + details: nil) + let unrelatedError = GatewayResponseError( + method: "crestodian.setup.activate", + code: "INVALID_REQUEST", + message: "invalid crestodian.setup.activate params: at /kind: must be string", + details: nil) + + #expect(OnboardingAISetupModel.activationNeedsLegacyAcknowledgementRetry(after: oldGatewayError)) + #expect(!OnboardingAISetupModel.activationNeedsLegacyAcknowledgementRetry(after: unrelatedError)) + } + @Test func `gateway change clears route-bound setup state`() { let model = OnboardingAISetupModel() model.manualProviderID = "openai" model.manualKey = "temporary-key" model.showManualEntry = true + model.userSelect(kind: "codex-cli") model.resetForGatewayChange() @@ -87,5 +105,61 @@ struct OnboardingAISetupTests { #expect(model.manualProviderID.isEmpty) #expect(model.manualKey.isEmpty) #expect(!model.showManualEntry) + #expect(model.pendingNonClawHubCandidateKind == nil) + } + + @Test func `codex selection requires explicit non clawhub confirmation`() { + let model = OnboardingAISetupModel() + + model.userSelect(kind: "codex-cli") + + #expect(model.pendingNonClawHubCandidateKind == "codex-cli") + #expect(model.phase == .idle) + + model.cancelNonClawHubActivation() + + #expect(model.pendingNonClawHubCandidateKind == nil) + } + + @Test func `untried codex remains available after an automatic candidate fails`() { + let candidates = [ + OnboardingAISetupModel.Candidate( + kind: "codex-cli", + label: "Codex CLI", + detail: "Signed in", + modelRef: "openai/gpt-5.5", + recommended: false, + credentials: true), + OnboardingAISetupModel.Candidate( + kind: "claude-cli", + label: "Claude Code", + detail: "Signed in", + modelRef: "anthropic/claude-sonnet-4-5", + recommended: true, + credentials: true), + ] + + #expect(OnboardingAISetupModel.hasUntriedConsentGatedCodex( + candidates: candidates, + statuses: [ + "claude-cli": .failed(.init(summary: "Failed", detail: nil)), + "codex-cli": .untried, + ])) + #expect(!OnboardingAISetupModel.hasUntriedConsentGatedCodex( + candidates: candidates, + statuses: [ + "claude-cli": .failed(.init(summary: "Failed", detail: nil)), + "codex-cli": .failed(.init(summary: "Failed", detail: nil)), + ])) + } + + @Test func `optional setup acknowledgement preserves the existing initializer`() { + let params = CrestodianSetupActivateParams( + kind: AnyCodable("claude-cli"), + authchoice: nil, + apikey: nil, + workspace: nil) + + #expect(params.acknowledgenonclawhubinstall == nil) } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 8dd362f74ae8..a9821563700d 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -3830,17 +3830,20 @@ public struct CrestodianSetupActivateParams: Codable, Sendable { public let authchoice: String? public let apikey: String? public let workspace: String? + public let acknowledgenonclawhubinstall: Bool? public init( kind: AnyCodable, authchoice: String?, apikey: String?, - workspace: String?) + workspace: String?, + acknowledgenonclawhubinstall: Bool? = nil) { self.kind = kind self.authchoice = authchoice self.apikey = apikey self.workspace = workspace + self.acknowledgenonclawhubinstall = acknowledgenonclawhubinstall } private enum CodingKeys: String, CodingKey { @@ -3848,6 +3851,7 @@ public struct CrestodianSetupActivateParams: Codable, Sendable { case authchoice = "authChoice" case apikey = "apiKey" case workspace + case acknowledgenonclawhubinstall = "acknowledgeNonClawHubInstall" } } diff --git a/docs/cli/crestodian.md b/docs/cli/crestodian.md index 67ad6777b714..b0608e084302 100644 --- a/docs/cli/crestodian.md +++ b/docs/cli/crestodian.md @@ -52,6 +52,7 @@ openclaw crestodian --message "models" openclaw crestodian --message "validate config" openclaw crestodian --message "setup workspace ~/Projects/work model openai/gpt-5.5" --yes openclaw crestodian --message "set default model openai/gpt-5.5" --yes +openclaw crestodian --message "plugin install npm:@example/plugin" --yes --acknowledge-non-clawhub-install openclaw onboard --modern ``` @@ -98,7 +99,7 @@ Read-only operations run immediately: show overview, list agents, list installed Starting guided channel setup (`connect telegram`) or model-provider setup (`configure model provider`) also runs immediately. Each wizard collects explicit answers and owns the resulting writes. -Persistent, require conversational approval (or `--yes` for a direct command): write config, `config set`, `config set-ref`, setup/onboarding bootstrap, change the default model, start/stop/restart the Gateway, create agents, install or uninstall plugins, run doctor repairs that rewrite config or state. +Persistent operations require conversational approval (or `--yes` for a direct command): write config, `config set`, `config set-ref`, setup/onboarding bootstrap, change the default model, start/stop/restart the Gateway, create agents, install or uninstall plugins, run doctor repairs that rewrite config or state. A direct non-ClawHub plugin install also requires `--acknowledge-non-clawhub-install`; generic `--yes` does not acknowledge executable code provenance. Approval is given in your own words: unambiguous replies ("yes", "sure", "go ahead", "not now") resolve from a closed deterministic list, and anything else is judged by a separate host-run model call that sees only your message and the pending proposal — never by the conversation model itself, which cannot self-approve. Ambiguous replies keep the proposal pending and the conversation asks again. When no model is usable, only the closed deterministic list applies. @@ -152,7 +153,7 @@ When no model is configured, setup picks the first usable backend in this order If none are available, setup still writes the workspace and Gateway configuration, then asks whether to configure a model provider. Accepting opens the normal onboarding provider/auth and default-model steps. Declining leaves Crestodian in deterministic mode; exact setup and repair commands still work, but the normal agent cannot answer until a provider and default model are configured. Run `configure model provider` later to reopen the provider flow. -The macOS app drives the same ladder through the `crestodian.setup.detect` and `crestodian.setup.activate` gateway methods: detect lists every reusable backend it finds, activate live-tests one candidate (a real "reply with OK" completion) and only persists the model, workspace, and gateway defaults after the test passes. A failing candidate never changes config; the app automatically walks down the ladder and finally offers a manual key/token step populated from the Gateway's active text-inference provider plugins. The selected provider owns its starter model and config, and the credential is verified the same way before it is saved. +The macOS app drives the same ladder through the `crestodian.setup.detect` and `crestodian.setup.activate` gateway methods: detect lists every reusable backend it finds, activate live-tests one candidate (a real "reply with OK" completion) and only persists the model, workspace, and gateway defaults after the test passes. A failing candidate never changes config; the app automatically walks down the ladder and finally offers a manual key/token step populated from the Gateway's active text-inference provider plugins. Codex remains a manual choice until the app shows the non-ClawHub npm-source warning and the user approves installing its runtime plugin. The selected provider owns its starter model and config, and the credential is verified the same way before it is saved. ## AI conversation diff --git a/docs/cli/models.md b/docs/cli/models.md index e55c127059be..66e43dd45e82 100644 --- a/docs/cli/models.md +++ b/docs/cli/models.md @@ -85,7 +85,7 @@ openclaw models set openclaw models set-image ``` -`set` writes `agents.defaults.model.primary`; `set-image` writes `agents.defaults.imageModel.primary`. Both accept `provider/model` or a configured alias. `set` also repairs Codex/Copilot runtime plugin installs when the newly selected model needs one; `set-image` does not. Neither command accepts `--agent`; they always write agent defaults. +`set` writes `agents.defaults.model.primary`; `set-image` writes `agents.defaults.imageModel.primary`. Both accept `provider/model` or a configured alias. `set` also repairs Codex/Copilot runtime plugin installs when the newly selected model needs one; `set-image` does not. Automation can pass `--acknowledge-non-clawhub-install` to `models set` after reviewing those runtime plugin sources. Neither command accepts `--agent`; they always write agent defaults. ### Scan diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index a001c1da2290..1201336ab6e3 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -40,7 +40,7 @@ openclaw plugins info # alias for inspect openclaw plugins enable openclaw plugins disable openclaw plugins uninstall [--dry-run] [--keep-files] [--force] -openclaw plugins update | --all [--dry-run] +openclaw plugins update | --all [--dry-run] [--acknowledge-non-clawhub-install] openclaw plugins registry [--refresh] [--json] openclaw plugins doctor openclaw plugins init [--name ] [--type tool|provider] [--directory ] @@ -428,11 +428,13 @@ openclaw plugins update openclaw plugins update --all openclaw plugins update --dry-run openclaw plugins update @openclaw/voice-call +openclaw plugins update @acme/demo --acknowledge-non-clawhub-install openclaw plugins update openclaw-codex-app-server --acknowledge-clawhub-risk openclaw plugins update openclaw-codex-app-server --dangerously-force-unsafe-install ``` Updates apply to tracked plugin installs in the managed plugin index and tracked hook-pack installs in `hooks.internal.installs`. +Live updates from npm, Git, and marketplace sources show their provenance and require interactive confirmation. For reviewed automation, pass `--acknowledge-non-clawhub-install`; dry runs do not require acknowledgement because they do not replace installed package contents. diff --git a/docs/cli/promos.md b/docs/cli/promos.md index 9e969f960635..8de39c883233 100644 --- a/docs/cli/promos.md +++ b/docs/cli/promos.md @@ -23,7 +23,7 @@ Related: ```bash openclaw promos list openclaw promos claim -openclaw promos claim --api-key --set-default +openclaw promos claim --api-key --acknowledge-non-clawhub-install --set-default ``` ## `openclaw promos list` @@ -47,7 +47,9 @@ Claims a live promotion: prompts, matching the `openclaw onboard` non-interactive flags; to keep the key off the command line, export the provider's environment variable instead (for example `OPENROUTER_API_KEY`) — existing env credentials are - detected automatically and no flag is needed. + detected automatically and no flag is needed. If the provider plugin is not + installed and its source is outside ClawHub review, pass + `--acknowledge-non-clawhub-install` after reviewing and trusting that source. 4. Registers the promotion's models with their aliases. Existing aliases are never overwritten. 5. Offers to set the promotion's suggested model as your default — diff --git a/docs/web/tui.md b/docs/web/tui.md index 4f3fefde0551..f4194120fc2c 100644 --- a/docs/web/tui.md +++ b/docs/web/tui.md @@ -162,7 +162,7 @@ openclaw crestodian -m "status" # run one request and exit openclaw crestodian -m "set default model openai/gpt-5.2" --yes # apply a config write ``` -- Persistent config writes need approval: either confirm interactively or pass `--yes`. +- Persistent config writes need approval: either confirm interactively or pass `--yes`. A direct non-ClawHub plugin install also requires `--acknowledge-non-clawhub-install`; `--yes` alone is not source-provenance consent. - `--json` prints the startup overview as JSON instead of starting the chat. - From inside Crestodian, an `open-tui` request (for example, asking to talk to a normal agent) exits Crestodian and opens the regular agent TUI; use `/crestodian` there to come back. diff --git a/packages/gateway-protocol/src/schema/crestodian.ts b/packages/gateway-protocol/src/schema/crestodian.ts index cf97ec2f83a4..6851b554a9c1 100644 --- a/packages/gateway-protocol/src/schema/crestodian.ts +++ b/packages/gateway-protocol/src/schema/crestodian.ts @@ -107,6 +107,7 @@ export const CrestodianSetupActivateParamsSchema = Type.Object( /** Manual step only: the pasted API key or token; masked by clients, never echoed. */ apiKey: Type.Optional(Type.String()), workspace: Type.Optional(Type.String()), + acknowledgeNonClawHubInstall: Type.Optional(Type.Boolean()), }, { additionalProperties: false }, ); diff --git a/scripts/e2e/codex-npm-plugin-live-docker.sh b/scripts/e2e/codex-npm-plugin-live-docker.sh index 42a112d85b74..e8417a58861a 100644 --- a/scripts/e2e/codex-npm-plugin-live-docker.sh +++ b/scripts/e2e/codex-npm-plugin-live-docker.sh @@ -195,7 +195,7 @@ POST_UNINSTALL_MODEL_REF="codex/${MODEL_REF#*/}" SESSION_ID="codex-npm-plugin-live" SUCCESS_MARKER="OPENCLAW-CODEX-NPM-PLUGIN-LIVE-OK" AGENT_TURN_TIMEOUT_SECONDS="${OPENCLAW_CODEX_NPM_PLUGIN_AGENT_TIMEOUT_SECONDS:-420}" -PLUGIN_INSTALL_FLAGS=(--force) +PLUGIN_INSTALL_FLAGS=(--force --acknowledge-non-clawhub-install) if [ "${OPENCLAW_CODEX_NPM_PLUGIN_FORCE_UNSAFE_INSTALL:-0}" = "1" ]; then PLUGIN_INSTALL_FLAGS+=(--dangerously-force-unsafe-install) fi diff --git a/scripts/e2e/kitchen-sink-rpc-walk.mjs b/scripts/e2e/kitchen-sink-rpc-walk.mjs index bc3dcf3f6525..65783cbf12e3 100644 --- a/scripts/e2e/kitchen-sink-rpc-walk.mjs +++ b/scripts/e2e/kitchen-sink-rpc-walk.mjs @@ -2583,12 +2583,17 @@ export async function main() { let sampleTimer; try { console.log(`Kitchen Sink RPC walk using ${PLUGIN_SPEC} via ${runner.label}`); - await runOpenClaw(runner, ["plugins", "install", PLUGIN_SPEC], env, { - ...commandResourceOptions, - requireResourceSample: true, - resourceLabel: "plugins install", - timeoutMs: config.installTimeoutMs, - }); + await runOpenClaw( + runner, + ["plugins", "install", PLUGIN_SPEC, "--acknowledge-non-clawhub-install"], + env, + { + ...commandResourceOptions, + requireResourceSample: true, + resourceLabel: "plugins install", + timeoutMs: config.installTimeoutMs, + }, + ); runner = resolveOpenClawRunner(); console.log(`Kitchen Sink RPC runtime runner: ${runner.label}`); configureKitchenSink(env, port); diff --git a/scripts/e2e/lib/codex-media-path/scenario.sh b/scripts/e2e/lib/codex-media-path/scenario.sh index f6424fd4f5b1..de6ac7f6f22c 100644 --- a/scripts/e2e/lib/codex-media-path/scenario.sh +++ b/scripts/e2e/lib/codex-media-path/scenario.sh @@ -41,7 +41,7 @@ rm -f "$OPENCLAW_CODEX_MEDIA_PATH_APP_SERVER_LOG" openclaw_e2e_enable_openclaw_cli_timeout echo "Installing Codex plugin: $PLUGIN_SPEC" -openclaw plugins install "$PLUGIN_SPEC" --force >"$PLUGIN_INSTALL_LOG" 2>&1 +openclaw plugins install "$PLUGIN_SPEC" --force --acknowledge-non-clawhub-install >"$PLUGIN_INSTALL_LOG" 2>&1 openclaw plugins inspect codex --runtime --json >"$PLUGIN_INSPECT_LOG" node scripts/e2e/lib/codex-media-path/write-config.mjs diff --git a/scripts/e2e/lib/kitchen-sink-plugin/sweep.sh b/scripts/e2e/lib/kitchen-sink-plugin/sweep.sh index 757ad0c3f3ad..f227b6bdfcc4 100644 --- a/scripts/e2e/lib/kitchen-sink-plugin/sweep.sh +++ b/scripts/e2e/lib/kitchen-sink-plugin/sweep.sh @@ -155,11 +155,11 @@ run_success_scenario() { echo "Testing ${KITCHEN_SINK_LABEL} install from ${KITCHEN_SINK_SPEC}..." local install_args=("$KITCHEN_SINK_SPEC") if [ -n "${KITCHEN_SINK_PREINSTALL_SPEC:-}" ]; then - run_kitchen_sink_openclaw_logged "kitchen-sink-preinstall-${KITCHEN_SINK_LABEL}" plugins install "$KITCHEN_SINK_PREINSTALL_SPEC" + run_kitchen_sink_openclaw_logged "kitchen-sink-preinstall-${KITCHEN_SINK_LABEL}" plugins install "$KITCHEN_SINK_PREINSTALL_SPEC" --acknowledge-non-clawhub-install assert_kitchen_sink_cutover_preinstalled install_args+=("--force") fi - run_kitchen_sink_openclaw_logged "kitchen-sink-install-${KITCHEN_SINK_LABEL}" plugins install "${install_args[@]}" + run_kitchen_sink_openclaw_logged "kitchen-sink-install-${KITCHEN_SINK_LABEL}" plugins install "${install_args[@]}" --acknowledge-non-clawhub-install configure_kitchen_sink_runtime run_kitchen_sink_openclaw_logged "kitchen-sink-enable-${KITCHEN_SINK_LABEL}" plugins enable "$KITCHEN_SINK_ID" run_kitchen_sink_openclaw_capture "${KITCHEN_SINK_TMP_DIR}/kitchen-sink-${KITCHEN_SINK_LABEL}-plugins.json" plugins list --json @@ -178,7 +178,7 @@ run_success_scenario() { run_failure_scenario() { echo "Testing expected ${KITCHEN_SINK_LABEL} install failure from ${KITCHEN_SINK_SPEC}..." - run_expect_failure "install-${KITCHEN_SINK_LABEL}" openclaw_e2e_maybe_timeout "$KITCHEN_SINK_CLI_TIMEOUT" node "$OPENCLAW_ENTRY" plugins install "$KITCHEN_SINK_SPEC" + run_expect_failure "install-${KITCHEN_SINK_LABEL}" openclaw_e2e_maybe_timeout "$KITCHEN_SINK_CLI_TIMEOUT" node "$OPENCLAW_ENTRY" plugins install "$KITCHEN_SINK_SPEC" --acknowledge-non-clawhub-install remove_kitchen_sink_channel_config run_kitchen_sink_openclaw_capture "${KITCHEN_SINK_TMP_DIR}/kitchen-sink-${KITCHEN_SINK_LABEL}-uninstalled.json" plugins list --json assert_kitchen_sink_removed diff --git a/scripts/e2e/lib/plugin-update/corrupt-update-scenario.sh b/scripts/e2e/lib/plugin-update/corrupt-update-scenario.sh index 05d23e8b35c8..e3746e1fdb34 100644 --- a/scripts/e2e/lib/plugin-update/corrupt-update-scenario.sh +++ b/scripts/e2e/lib/plugin-update/corrupt-update-scenario.sh @@ -40,6 +40,7 @@ pack_fixture_plugin "$npm_pack_dir" /tmp/demo-corrupt-plugin.tgz demo-corrupt-pl start_npm_fixture_registry "@openclaw/demo-corrupt-plugin" "0.0.1" /tmp/demo-corrupt-plugin.tgz "$npm_registry_dir" echo "Installing managed external plugin..." +# This command runs against the published baseline, which predates candidate-only flags. node "$entry" plugins install "npm:@openclaw/demo-corrupt-plugin@0.0.1" >/tmp/openclaw-corrupt-plugin-install.log 2>&1 node "$entry" plugins inspect demo-corrupt-plugin --runtime --json >/tmp/openclaw-corrupt-plugin-before.json unset NPM_CONFIG_REGISTRY npm_config_registry @@ -90,6 +91,7 @@ if [ "$update_status" -ne 0 ]; then openclaw_e2e_maybe_timeout "${update_timeout_seconds}s" \ node "$entry" update \ --yes \ + --acknowledge-non-clawhub-install \ --no-restart \ --timeout "$update_step_timeout_seconds" \ --json \ diff --git a/scripts/e2e/lib/plugins/marketplace.sh b/scripts/e2e/lib/plugins/marketplace.sh index b700d31cbe2f..ebad458af227 100644 --- a/scripts/e2e/lib/plugins/marketplace.sh +++ b/scripts/e2e/lib/plugins/marketplace.sh @@ -20,8 +20,8 @@ run_plugins_marketplace_scenario() { node scripts/e2e/lib/plugins/assertions.mjs marketplace-list - run_plugins_openclaw_logged install-marketplace-shortcut plugins install marketplace-shortcut@claude-fixtures - run_plugins_openclaw_logged install-marketplace-direct plugins install marketplace-direct --marketplace claude-fixtures + run_plugins_openclaw_logged install-marketplace-shortcut plugins install marketplace-shortcut@claude-fixtures --acknowledge-non-clawhub-install + run_plugins_openclaw_logged install-marketplace-direct plugins install marketplace-direct --marketplace claude-fixtures --acknowledge-non-clawhub-install run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-marketplace.json" plugins list --json run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-marketplace-shortcut-inspect.json" plugins inspect marketplace-shortcut --runtime --json run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-marketplace-direct-inspect.json" plugins inspect marketplace-direct --runtime --json diff --git a/scripts/e2e/lib/plugins/sweep.sh b/scripts/e2e/lib/plugins/sweep.sh index 9a1dec2a0e79..265d754435c6 100644 --- a/scripts/e2e/lib/plugins/sweep.sh +++ b/scripts/e2e/lib/plugins/sweep.sh @@ -74,7 +74,7 @@ echo "Testing tgz install flow..." pack_dir="$(mktemp -d "$OPENCLAW_PLUGINS_TMP_DIR/openclaw-plugin-pack.XXXXXX")" pack_fixture_plugin "$pack_dir" "$OPENCLAW_PLUGINS_TMP_DIR/demo-plugin-tgz.tgz" demo-plugin-tgz 0.0.1 demo.tgz "Demo Plugin TGZ" -run_plugins_openclaw_logged install-tgz plugins install "$OPENCLAW_PLUGINS_TMP_DIR/demo-plugin-tgz.tgz" +run_plugins_openclaw_logged install-tgz plugins install "$OPENCLAW_PLUGINS_TMP_DIR/demo-plugin-tgz.tgz" --acknowledge-non-clawhub-install run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins2.json" plugins list --json run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins2-inspect.json" plugins inspect demo-plugin-tgz --runtime --json @@ -88,7 +88,7 @@ echo "Testing install from local folder (plugins.load.paths)..." dir_plugin="$(mktemp -d "$OPENCLAW_PLUGINS_TMP_DIR/openclaw-plugin-dir.XXXXXX")" write_fixture_plugin "$dir_plugin" demo-plugin-dir 0.0.1 demo.dir "Demo Plugin DIR" -run_plugins_openclaw_logged install-dir plugins install "$dir_plugin" +run_plugins_openclaw_logged install-dir plugins install "$dir_plugin" --acknowledge-non-clawhub-install run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins3.json" plugins list --json run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins3-inspect.json" plugins inspect demo-plugin-dir --runtime --json @@ -105,7 +105,7 @@ echo "Testing install from local folder with preinstalled dependencies..." dir_deps_plugin="$(mktemp -d "$OPENCLAW_PLUGINS_TMP_DIR/openclaw-plugin-dir-deps.XXXXXX")" write_fixture_plugin_with_vendored_dependency "$dir_deps_plugin" demo-plugin-dir-deps 0.0.1 demo.dir.deps "Demo Plugin DIR Deps" -run_plugins_openclaw_logged install-dir-deps plugins install "$dir_deps_plugin" +run_plugins_openclaw_logged install-dir-deps plugins install "$dir_deps_plugin" --acknowledge-non-clawhub-install run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-dir-deps.json" plugins list --json run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-dir-deps-inspect.json" plugins inspect demo-plugin-dir-deps --runtime --json @@ -119,7 +119,7 @@ echo "Testing install from npm spec (file:)..." file_pack_dir="$(mktemp -d "$OPENCLAW_PLUGINS_TMP_DIR/openclaw-plugin-filepack.XXXXXX")" write_fixture_plugin "$file_pack_dir/package" demo-plugin-file 0.0.1 demo.file "Demo Plugin FILE" -run_plugins_openclaw_logged install-file plugins install "file:$file_pack_dir/package" +run_plugins_openclaw_logged install-file plugins install "file:$file_pack_dir/package" --acknowledge-non-clawhub-install run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins4.json" plugins list --json run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins4-inspect.json" plugins inspect demo-plugin-file --runtime --json @@ -139,7 +139,7 @@ pack_fake_is_number_package "$npm_dep_pack_dir" "$OPENCLAW_PLUGINS_TMP_DIR/is-nu pack_fixture_plugin_with_invalid_extension_entry "$invalid_npm_pack_dir" "$OPENCLAW_PLUGINS_TMP_DIR/demo-plugin-invalid-metadata.tgz" demo-plugin-invalid-metadata 0.0.1 demo.invalid.metadata "Demo Plugin Invalid Metadata" start_npm_fixture_registry "@openclaw/demo-plugin-npm" "0.0.1" "$OPENCLAW_PLUGINS_TMP_DIR/demo-plugin-npm.tgz" "$npm_registry_dir" "is-number" "7.0.0" "$OPENCLAW_PLUGINS_TMP_DIR/is-number-7.0.0.tgz" "@openclaw/demo-plugin-invalid-metadata" "0.0.1" "$OPENCLAW_PLUGINS_TMP_DIR/demo-plugin-invalid-metadata.tgz" -run_plugins_openclaw_logged install-npm plugins install "npm:@openclaw/demo-plugin-npm@0.0.1" +run_plugins_openclaw_logged install-npm plugins install "npm:@openclaw/demo-plugin-npm@0.0.1" --acknowledge-non-clawhub-install run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-npm.json" plugins list --json run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-npm-inspect.json" plugins inspect demo-plugin-npm --runtime --json run_plugins_shell_logged exec-npm-plugin-cli 'node "$OPENCLAW_ENTRY" demo-npm ping >"$OPENCLAW_PLUGINS_TMP_DIR/plugins-npm-cli.txt"' @@ -154,7 +154,7 @@ run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-npm-uninstalled. node scripts/e2e/lib/plugins/assertions.mjs plugin-npm-removed echo "Testing npm install rejects malformed package metadata..." -if openclaw_e2e_maybe_timeout "$OPENCLAW_PLUGINS_CLI_TIMEOUT" node "$OPENCLAW_ENTRY" plugins install "npm:@openclaw/demo-plugin-invalid-metadata@0.0.1" >"$OPENCLAW_PLUGINS_TMP_DIR/plugins-invalid-openclaw-extensions.log" 2>&1; then +if openclaw_e2e_maybe_timeout "$OPENCLAW_PLUGINS_CLI_TIMEOUT" node "$OPENCLAW_ENTRY" plugins install "npm:@openclaw/demo-plugin-invalid-metadata@0.0.1" --acknowledge-non-clawhub-install >"$OPENCLAW_PLUGINS_TMP_DIR/plugins-invalid-openclaw-extensions.log" 2>&1; then cat "$OPENCLAW_PLUGINS_TMP_DIR/plugins-invalid-openclaw-extensions.log" echo "Expected malformed package metadata install to fail." >&2 exit 1 @@ -174,7 +174,7 @@ git -C "$git_repo" add -A git -C "$git_repo" commit -qm "test fixture" git_ref="$(git -C "$git_repo" rev-parse HEAD)" -run_plugins_openclaw_logged install-git plugins install "git:$git_repo_url@$git_ref" +run_plugins_openclaw_logged install-git plugins install "git:$git_repo_url@$git_ref" --acknowledge-non-clawhub-install run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-git.json" plugins list --json run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-git-inspect.json" plugins inspect demo-plugin-git --runtime --json run_plugins_shell_logged exec-git-plugin-cli 'node "$OPENCLAW_ENTRY" demo-git ping >"$OPENCLAW_PLUGINS_TMP_DIR/plugins-git-cli.txt"' @@ -198,7 +198,7 @@ git -C "$git_update_repo" add -A git -C "$git_update_repo" commit -qm "test fixture v1" git_update_ref_v1="$(git -C "$git_update_repo" rev-parse HEAD)" -run_plugins_openclaw_logged install-git-update plugins install "git:$git_update_repo_url@main" +run_plugins_openclaw_logged install-git-update plugins install "git:$git_update_repo_url@main" --acknowledge-non-clawhub-install write_fixture_plugin_with_cli "$git_update_repo" demo-plugin-git-update 0.0.2 demo.git.update.v2 "Demo Plugin Git Update" demo-git-update "demo-plugin-git-update:pong-v2" git -C "$git_update_repo" add -A git -C "$git_update_repo" commit -qm "test fixture v2" @@ -227,7 +227,7 @@ echo "Testing plugin install visible after explicit restart..." slash_install_dir="$(mktemp -d "$OPENCLAW_PLUGINS_TMP_DIR/openclaw-plugin-slash-install.XXXXXX")" write_fixture_plugin "$slash_install_dir" slash-install-plugin 0.0.1 demo.slash.install "Slash Install Plugin" -run_plugins_openclaw_logged install-slash-plugin plugins install "$slash_install_dir" +run_plugins_openclaw_logged install-slash-plugin plugins install "$slash_install_dir" --acknowledge-non-clawhub-install run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugin-command-install-show.json" plugins inspect slash-install-plugin --runtime --json node scripts/e2e/lib/plugins/assertions.mjs slash-install diff --git a/scripts/e2e/lib/release-plugin-marketplace/scenario.sh b/scripts/e2e/lib/release-plugin-marketplace/scenario.sh index 6555ebe2d8f6..c818124fc494 100755 --- a/scripts/e2e/lib/release-plugin-marketplace/scenario.sh +++ b/scripts/e2e/lib/release-plugin-marketplace/scenario.sh @@ -75,7 +75,7 @@ node scripts/e2e/lib/release-scenarios/write-marketplace.mjs \ openclaw plugins marketplace list release-fixtures --json >/tmp/openclaw-release-plugin-marketplace-list.json node scripts/e2e/lib/release-scenarios/assertions.mjs assert-file-contains /tmp/openclaw-release-plugin-marketplace-list.json release-marketplace-plugin -openclaw plugins install release-marketplace-plugin@release-fixtures >/tmp/openclaw-release-plugin-marketplace-install-plugin.log 2>&1 +openclaw plugins install release-marketplace-plugin@release-fixtures --acknowledge-non-clawhub-install >/tmp/openclaw-release-plugin-marketplace-install-plugin.log 2>&1 openclaw release-market ping >/tmp/openclaw-release-plugin-marketplace-cli-v1.log 2>&1 node scripts/e2e/lib/release-scenarios/assertions.mjs assert-file-contains /tmp/openclaw-release-plugin-marketplace-cli-v1.log "release-marketplace-plugin:v1" diff --git a/scripts/e2e/lib/release-upgrade-user-journey/scenario.sh b/scripts/e2e/lib/release-upgrade-user-journey/scenario.sh index 7e8ca4096a36..4b7e0a5ffdfa 100755 --- a/scripts/e2e/lib/release-upgrade-user-journey/scenario.sh +++ b/scripts/e2e/lib/release-upgrade-user-journey/scenario.sh @@ -142,6 +142,7 @@ node scripts/e2e/lib/release-scenarios/write-cli-plugin.mjs \ "Release Upgrade Plugin" \ release-upgrade \ "release-upgrade-plugin:pong" +# This command runs against the published baseline, which predates candidate-only flags. openclaw plugins install "$plugin_dir" >"$PLUGIN_INSTALL_LOG" 2>&1 openclaw release-upgrade ping >"$PLUGIN_CLI_BEFORE_LOG" 2>&1 node scripts/e2e/lib/release-scenarios/assertions.mjs assert-file-contains "$PLUGIN_CLI_BEFORE_LOG" "release-upgrade-plugin:pong" @@ -166,7 +167,7 @@ node scripts/e2e/lib/release-scenarios/assertions.mjs assert-file-contains "$PLU clickclack_plugin_dir="$(mktemp -d "$scenario_tmp/clickclack-plugin.XXXXXX")" node scripts/e2e/lib/release-user-journey/write-clickclack-plugin.mjs "$clickclack_plugin_dir" -openclaw plugins install "$clickclack_plugin_dir" >"$CLICKCLACK_PLUGIN_INSTALL_LOG" 2>&1 +openclaw plugins install "$clickclack_plugin_dir" --acknowledge-non-clawhub-install >"$CLICKCLACK_PLUGIN_INSTALL_LOG" 2>&1 openclaw channels status --json >"$STATUS_JSON" 2>"$STATUS_ERR" node scripts/e2e/lib/release-user-journey/assertions.mjs assert-channel-status clickclack "$STATUS_JSON" diff --git a/scripts/e2e/lib/release-user-journey/scenario.sh b/scripts/e2e/lib/release-user-journey/scenario.sh index 361b27acffc8..9e43a83ce9dc 100755 --- a/scripts/e2e/lib/release-user-journey/scenario.sh +++ b/scripts/e2e/lib/release-user-journey/scenario.sh @@ -192,7 +192,7 @@ plugin_a_dir="$(mktemp -d "$scenario_tmp/plugin-a.XXXXXX")" plugin_a_install_path_file="$PLUGIN_A_INSTALL_PATH_FILE" plugin_a_source_path_file="$PLUGIN_A_SOURCE_PATH_FILE" write_journey_plugin "$plugin_a_dir" journey-plugin-a 0.0.1 journey.a "Journey Plugin A" journey-a "journey-plugin-a:pong" -openclaw plugins install "$plugin_a_dir" >"$PLUGIN_A_INSTALL_LOG" 2>&1 +openclaw plugins install "$plugin_a_dir" --acknowledge-non-clawhub-install >"$PLUGIN_A_INSTALL_LOG" 2>&1 node scripts/e2e/lib/release-user-journey/assertions.mjs \ remember-plugin-install-path \ journey-plugin-a \ @@ -213,14 +213,14 @@ node scripts/e2e/lib/release-user-journey/assertions.mjs \ echo "Installing replacement external plugin..." plugin_b_dir="$(mktemp -d "$scenario_tmp/plugin-b.XXXXXX")" write_journey_plugin "$plugin_b_dir" journey-plugin-b 0.0.1 journey.b "Journey Plugin B" journey-b "journey-plugin-b:pong" -openclaw plugins install "$plugin_b_dir" >"$PLUGIN_B_INSTALL_LOG" 2>&1 +openclaw plugins install "$plugin_b_dir" --acknowledge-non-clawhub-install >"$PLUGIN_B_INSTALL_LOG" 2>&1 openclaw journey-b ping >"$PLUGIN_B_CLI_LOG" 2>&1 node scripts/e2e/lib/release-user-journey/assertions.mjs assert-file-contains "$PLUGIN_B_CLI_LOG" "journey-plugin-b:pong" echo "Installing ClickClack fixture plugin..." clickclack_plugin_dir="$(mktemp -d "$scenario_tmp/clickclack-plugin.XXXXXX")" node scripts/e2e/lib/release-user-journey/write-clickclack-plugin.mjs "$clickclack_plugin_dir" -openclaw plugins install "$clickclack_plugin_dir" >"$CLICKCLACK_PLUGIN_INSTALL_LOG" 2>&1 +openclaw plugins install "$clickclack_plugin_dir" --acknowledge-non-clawhub-install >"$CLICKCLACK_PLUGIN_INSTALL_LOG" 2>&1 echo "Configuring ClickClack..." node scripts/e2e/lib/release-user-journey/assertions.mjs configure-clickclack "http://127.0.0.1:$CLICKCLACK_PORT" diff --git a/scripts/e2e/live-plugin-tool-docker.sh b/scripts/e2e/live-plugin-tool-docker.sh index 3e061eb23452..57d1b622400d 100755 --- a/scripts/e2e/live-plugin-tool-docker.sh +++ b/scripts/e2e/live-plugin-tool-docker.sh @@ -165,7 +165,7 @@ fi plugin_tgz="${plugin_tgzs[0]}" echo "Installing fixture plugin from npm-pack: $plugin_tgz" -openclaw plugins install "npm-pack:$plugin_tgz" --force >/tmp/openclaw-plugin-install.log 2>&1 +openclaw plugins install "npm-pack:$plugin_tgz" --force --acknowledge-non-clawhub-install >/tmp/openclaw-plugin-install.log 2>&1 node scripts/e2e/lib/live-plugin-tool/assertions.mjs configure openclaw plugins enable "$PLUGIN_ID" >/tmp/openclaw-plugin-enable.log 2>&1 openclaw plugins list --json >/tmp/openclaw-plugins-list.json diff --git a/scripts/protocol-gen-swift.ts b/scripts/protocol-gen-swift.ts index c109dd5329f6..a3f8d2f45222 100644 --- a/scripts/protocol-gen-swift.ts +++ b/scripts/protocol-gen-swift.ts @@ -43,6 +43,7 @@ const STRICT_LITERAL_STRUCTS = new Set([ const DEFAULTED_OPTIONAL_INIT_PARAM_ENTRIES: readonly [string, readonly string[]][] = [ ["CrestodianChatResult", ["sensitive"]], + ["CrestodianSetupActivateParams", ["acknowledgeNonClawHubInstall"]], ["SendParams", ["buffer", "filename", "contentType"]], ["SessionOperationEvent", ["agentId"]], ["SessionsCompactionListParams", ["agentId"]], diff --git a/src/agents/tools/crestodian-tool.test.ts b/src/agents/tools/crestodian-tool.test.ts index 7c80946d5a38..5a4ba92dd716 100644 --- a/src/agents/tools/crestodian-tool.test.ts +++ b/src/agents/tools/crestodian-tool.test.ts @@ -6,6 +6,7 @@ import { resolveCrestodianDirectiveTransition, resolveCrestodianProposalTransition, type CrestodianToolDirective, + type CrestodianToolProposalRef, } from "./crestodian-tool.js"; const mocks = vi.hoisted(() => ({ @@ -46,6 +47,13 @@ function toolText(result: unknown): string { .join("\n"); } +function markProposalRenderedByHost(proposalRef: CrestodianToolProposalRef): void { + if (!proposalRef.current) { + throw new Error("expected a registered proposal"); + } + proposalRef.current.renderedByHost = true; +} + describe("crestodian tool", () => { it("runs read actions immediately", async () => { const tool = createCrestodianTool({ surface: "cli" }); @@ -66,7 +74,7 @@ describe("crestodian tool", () => { }); it("refuses mutating actions without the approved assertion", async () => { - const proposalRef: { current?: string } = {}; + const proposalRef: CrestodianToolProposalRef = {}; const tool = createCrestodianTool({ surface: "cli", approvalArmed: true, proposalRef }); const result = await tool.execute("t2", { action: "config_set", @@ -100,7 +108,7 @@ describe("crestodian tool", () => { return { applied: true }; }, ); - const proposalRef: { current?: string } = {}; + const proposalRef: CrestodianToolProposalRef = {}; // Phase 1: unarmed proposal is denied and records the exact operation. const proposingTool = createCrestodianTool({ surface: "gateway", proposalRef }); const denied = await proposingTool.execute("t3a", { @@ -111,6 +119,7 @@ describe("crestodian tool", () => { expect(toolText(denied)).toContain("needs-approval"); expect(proposalRef.current).toBeDefined(); expect(mocks.executeCrestodianOperation).not.toHaveBeenCalled(); + markProposalRenderedByHost(proposalRef); // Phase 2: the user's yes arms the turn; the identical call executes. const armedTool = createCrestodianTool({ @@ -137,8 +146,42 @@ describe("crestodian tool", () => { expect(proposalRef.current).toBeUndefined(); }); + it("shows provenance and carries exact approval for non-ClawHub plugin installs", async () => { + const proposalRef: CrestodianToolProposalRef = {}; + const proposingTool = createCrestodianTool({ surface: "cli", proposalRef }); + + const proposal = await proposingTool.execute("t3-plugin-proposal", { + action: "plugin_install", + spec: "npm:@example/plugin", + }); + + expect(toolText(proposal)).toContain("outside ClawHub review and trust metadata"); + expect(toolText(proposal)).toContain("npm:@example/plugin"); + markProposalRenderedByHost(proposalRef); + + const armedTool = createCrestodianTool({ + surface: "cli", + approvalArmed: true, + proposalRef, + }); + await armedTool.execute("t3-plugin-apply", { + action: "plugin_install", + spec: "npm:@example/plugin", + approved: true, + }); + + expect(mocks.executeCrestodianOperation).toHaveBeenCalledWith( + { kind: "plugin-install", spec: "npm:@example/plugin" }, + expect.anything(), + expect.objectContaining({ + approved: true, + acknowledgeNonClawHubInstall: true, + }), + ); + }); + it("refuses an armed call that differs from the proposed operation", async () => { - const proposalRef: { current?: string } = {}; + const proposalRef: CrestodianToolProposalRef = {}; const proposingTool = createCrestodianTool({ surface: "cli", proposalRef }); await proposingTool.execute("t3c", { action: "set_default_model", @@ -182,13 +225,14 @@ describe("crestodian tool", () => { sourceConfig: {}, issues: [{ path: "gateway.port", message: "Expected number" }], } as never); - const proposalRef: { current?: string } = {}; + const proposalRef: CrestodianToolProposalRef = {}; await createCrestodianTool({ surface: "cli", proposalRef }).execute("t4a", { action: "config_set", path: "gateway.port", value: "banana", approved: true, }); + markProposalRenderedByHost(proposalRef); const tool = createCrestodianTool({ surface: "cli", approvalArmed: true, proposalRef }); const result = await tool.execute("t4", { action: "config_set", @@ -208,13 +252,14 @@ describe("crestodian tool", () => { return { applied: true }; }, ); - const proposalRef: { current?: string } = {}; + const proposalRef: CrestodianToolProposalRef = {}; await createCrestodianTool({ surface: "cli", proposalRef }).execute("t6a", { action: "create_agent", agentId: "work", workspace: "/tmp/work", approved: true, }); + markProposalRenderedByHost(proposalRef); const tool = createCrestodianTool({ surface: "cli", approvalArmed: true, proposalRef }); await tool.execute("t6", { action: "create_agent", @@ -316,7 +361,9 @@ describe("crestodian tool", () => { args, resultText: "needs-approval: this action changes state.", }), - ).toEqual({ proposal: hash }); + ).toEqual({ + proposal: expect.objectContaining({ operationHash: hash, renderedByHost: false }), + }); // A voided approval clears it. expect( resolveCrestodianProposalTransition({ diff --git a/src/agents/tools/crestodian-tool.ts b/src/agents/tools/crestodian-tool.ts index 4b627c76a7c5..cefd72c4a020 100644 --- a/src/agents/tools/crestodian-tool.ts +++ b/src/agents/tools/crestodian-tool.ts @@ -7,7 +7,9 @@ import { Type } from "typebox"; import { executeCrestodianOperation, + formatCrestodianPersistentPlan, isPersistentCrestodianOperation, + requiresNonClawHubPluginInstallAcknowledgement, type CrestodianOperation, } from "../../crestodian/operations.js"; import type { RuntimeEnv } from "../../runtime.js"; @@ -26,10 +28,10 @@ export type CrestodianToolOptions = { approvalArmed?: boolean; /** * Approval is scoped to one exact operation: a denied mutating call records - * its canonical hash here (host-owned, survives turns), and an armed turn - * may execute only a call matching that hash. Cleared after use. + * its canonical plan here (host-owned, survives turns), and an armed turn + * may execute only after the host rendered that plan. Cleared after use. */ - proposalRef?: { current?: string }; + proposalRef?: CrestodianToolProposalRef; /** * Host handoff channel for actions the tool cannot perform itself * (interactive channel-setup wizard, opening the agent TUI). The engine @@ -38,6 +40,15 @@ export type CrestodianToolOptions = { directiveRef?: { current?: CrestodianToolDirective }; }; +export type CrestodianToolProposal = { + operationHash: string; + plan: string; + /** Set only by the hosting chat after it has rendered `plan` to the user. */ + renderedByHost: boolean; +}; + +export type CrestodianToolProposalRef = { current?: CrestodianToolProposal }; + /** Interactive handoffs the hosting chat engine executes after the turn. */ export type CrestodianToolDirective = | { kind: "channel-setup"; channel: string } @@ -50,6 +61,14 @@ export function hashCrestodianOperation(operation: CrestodianOperation): string return JSON.stringify(operation, Object.keys(operation).toSorted()); } +function createCrestodianToolProposal(operation: CrestodianOperation): CrestodianToolProposal { + return { + operationHash: hashCrestodianOperation(operation), + plan: formatCrestodianPersistentPlan(operation), + renderedByHost: false, + }; +} + /** Result markers shared with out-of-process hosts (CLI MCP runs). */ const CRESTODIAN_NEEDS_APPROVAL_PREFIX = "needs-approval:"; const CRESTODIAN_APPROVAL_MISMATCH_PREFIX = "approval-mismatch:"; @@ -106,7 +125,7 @@ function directiveForOperation(operation: CrestodianOperation): CrestodianToolDi export function resolveCrestodianProposalTransition(params: { args: Record; resultText: string; -}): { proposal: string | undefined } | null { +}): { proposal: CrestodianToolProposal | undefined } | null { let operation: CrestodianOperation; try { operation = operationForAction(params.args); @@ -120,7 +139,7 @@ export function resolveCrestodianProposalTransition(params: { return { proposal: undefined }; } if (params.resultText.startsWith(CRESTODIAN_NEEDS_APPROVAL_PREFIX)) { - return { proposal: hashCrestodianOperation(operation) }; + return { proposal: createCrestodianToolProposal(operation) }; } // Executed or errored mutation: an armed approval is single-use either way. return { proposal: undefined }; @@ -374,15 +393,17 @@ export function createCrestodianTool(options: CrestodianToolOptions): AnyAgentTo ); } const persistent = isPersistentCrestodianOperation(operation); + const proposedOperation = persistent ? options.proposalRef?.current : undefined; if (persistent) { const operationHash = hashCrestodianOperation(operation); const armedForThisOperation = params.approved === true && options.approvalArmed === true && - options.proposalRef?.current === operationHash; + proposedOperation?.operationHash === operationHash && + proposedOperation.renderedByHost; if (!armedForThisOperation) { - // Three gates must hold: the model asserts consent, the host saw an - // explicit user approval in the current turn, and the approved call + // Four gates must hold: the model asserts consent, the host showed + // the plan, the user explicitly approved, and the approved call // matches the operation registered BEFORE that approval. A generic // "yes" must never authorize a different mutation, and an armed turn // must never mint a new executable proposal for itself — otherwise @@ -397,10 +418,10 @@ export function createCrestodianTool(options: CrestodianToolOptions): AnyAgentTo ); } if (options.proposalRef) { - options.proposalRef.current = operationHash; + options.proposalRef.current = createCrestodianToolProposal(operation); } return textResult( - `${CRESTODIAN_NEEDS_APPROVAL_PREFIX} this action changes state. The proposal is registered; describe this exact change and ask the user to reply yes (their approval unlocks THIS action only — then retry the identical call with approved=true).`, + `${CRESTODIAN_NEEDS_APPROVAL_PREFIX} ${formatCrestodianPersistentPlan(operation)} The proposal is registered; show this plan and every warning verbatim, then ask the user to reply yes (their approval unlocks THIS action only — then retry the identical call with approved=true).`, { needsApproval: true }, ); } @@ -414,6 +435,10 @@ export function createCrestodianTool(options: CrestodianToolOptions): AnyAgentTo try { const result = await executeCrestodianOperation(operation, capture, { approved: persistent, + ...(requiresNonClawHubPluginInstallAcknowledgement(operation) && + proposedOperation?.renderedByHost + ? { acknowledgeNonClawHubInstall: true } + : {}), deps: { setupSurface: options.surface }, auditDetails: { via: "crestodian-agent-tool" }, }); diff --git a/src/cli/hooks-cli.test.ts b/src/cli/hooks-cli.test.ts index 748c73f1f782..ccf6046fa9f8 100644 --- a/src/cli/hooks-cli.test.ts +++ b/src/cli/hooks-cli.test.ts @@ -11,11 +11,16 @@ import { import { createEmptyInstallChecks } from "./requirements-test-fixtures.js"; const runPluginInstallCommandMock = vi.hoisted(() => vi.fn()); +const runPluginUpdateCommandMock = vi.hoisted(() => vi.fn()); vi.mock("./plugins-install-command.js", () => ({ runPluginInstallCommand: runPluginInstallCommandMock, })); +vi.mock("./plugins-update-command.js", () => ({ + runPluginUpdateCommand: runPluginUpdateCommandMock, +})); + const report: HookStatusReport = { workspaceDir: "/tmp/workspace", managedHooksDir: "/tmp/hooks", @@ -46,6 +51,7 @@ const report: HookStatusReport = { beforeEach(() => { runPluginInstallCommandMock.mockReset(); + runPluginUpdateCommandMock.mockReset(); }); function createPluginManagedHookReport(): HookStatusReport { @@ -143,4 +149,22 @@ describe("hooks cli formatting", () => { invalidateRuntimeCache: false, }); }); + + it("forwards non-ClawHub acknowledgement through deprecated update alias", async () => { + runPluginUpdateCommandMock.mockResolvedValueOnce(undefined); + const program = new Command().exitOverride(); + registerHooksCli(program); + + await program.parseAsync( + ["hooks", "update", "demo-hooks", "--acknowledge-non-clawhub-install"], + { from: "user" }, + ); + + expect(runPluginUpdateCommandMock).toHaveBeenCalledWith({ + id: "demo-hooks", + opts: expect.objectContaining({ + acknowledgeNonClawHubInstall: true, + }), + }); + }); }); diff --git a/src/cli/hooks-cli.ts b/src/cli/hooks-cli.ts index f85e2c021dd0..0e3ba9b1ebb0 100644 --- a/src/cli/hooks-cli.ts +++ b/src/cli/hooks-cli.ts @@ -43,7 +43,8 @@ export type HooksCheckOptions = { json?: boolean; }; -type HooksUpdateOptions = { +type HooksUpdateOptions = NonClawHubInstallAcknowledgementOptions & { + acknowledgeNonClawhubInstall?: boolean; all?: boolean; dryRun?: boolean; }; @@ -601,11 +602,22 @@ export function registerHooksCli(program: Command): void { .argument("[id]", "Hook pack id (omit with --all)") .option("--all", "Update all tracked hooks", false) .option("--dry-run", "Show what would change without writing", false) + .option( + "--acknowledge-non-clawhub-install", + "Acknowledge non-ClawHub hook pack update provenance without prompting", + false, + ) .action(async (id: string | undefined, opts: HooksUpdateOptions) => { defaultRuntime.log( theme.warn("`openclaw hooks update` is deprecated; use `openclaw plugins update`."), ); - await runPluginUpdateCommand({ id, opts }); + await runPluginUpdateCommand({ + id, + opts: { + ...opts, + acknowledgeNonClawHubInstall: normalizeHooksNonClawHubInstallOption(opts), + }, + }); }); hooks.action(async () => diff --git a/src/cli/models-cli.test.ts b/src/cli/models-cli.test.ts index 1e74bd13c9f5..1133ab414ac1 100644 --- a/src/cli/models-cli.test.ts +++ b/src/cli/models-cli.test.ts @@ -235,6 +235,19 @@ describe("models cli", () => { expectCommandOptions(modelsAuthListCommand, { agent: "poe", json: true }); }); + it("forwards non-ClawHub acknowledgement to models set", async () => { + await runModelsCommand([ + "models", + "set", + "openai/gpt-5.5", + "--acknowledge-non-clawhub-install", + ]); + + expect(modelsSetCommand).toHaveBeenCalledWith("openai/gpt-5.5", expect.anything(), { + acknowledgeNonClawHubInstall: true, + }); + }); + it.each([ { label: "set", diff --git a/src/cli/models-cli.ts b/src/cli/models-cli.ts index 0fb8d3e7867d..af7299a0879d 100644 --- a/src/cli/models-cli.ts +++ b/src/cli/models-cli.ts @@ -2,6 +2,18 @@ import type { Command } from "commander"; import { formatDocsLink } from "../../packages/terminal-core/src/links.js"; import { theme } from "../../packages/terminal-core/src/theme.js"; +import { NON_CLAWHUB_INSTALL_ACK_FLAG } from "./non-clawhub-install-acknowledgement.js"; + +type CommanderNonClawHubInstallOptions = { + acknowledgeNonClawHubInstall?: boolean; + acknowledgeNonClawhubInstall?: boolean; +}; + +function normalizeNonClawHubInstallAcknowledgement( + opts: CommanderNonClawHubInstallOptions, +): boolean { + return opts.acknowledgeNonClawHubInstall === true || opts.acknowledgeNonClawhubInstall === true; +} type ModelsCliRuntime = typeof import("./models-cli.runtime.js"); @@ -115,12 +127,19 @@ export function registerModelsCli(program: Command) { .command("set") .description("Set the default model") .argument("", "Model id or alias") - .action(async (model: string, _opts: unknown, command: Command) => { + .option( + NON_CLAWHUB_INSTALL_ACK_FLAG, + "Acknowledge runtime plugin installs outside ClawHub review", + false, + ) + .action(async (model: string, opts: CommanderNonClawHubInstallOptions, command: Command) => { const runtime = await loadModelsRuntime(); runtime.rejectAgentScopedModelWrite(command, "set"); await runtime.runModelsCommand(async () => { const { modelsSetCommand } = await import("../commands/models/set.js"); - await modelsSetCommand(model, runtime.defaultRuntime); + await modelsSetCommand(model, runtime.defaultRuntime, { + acknowledgeNonClawHubInstall: normalizeNonClawHubInstallAcknowledgement(opts), + }); }); }); diff --git a/src/cli/non-clawhub-install-acknowledgement.ts b/src/cli/non-clawhub-install-acknowledgement.ts index 58b106f27c58..885ed4c26606 100644 --- a/src/cli/non-clawhub-install-acknowledgement.ts +++ b/src/cli/non-clawhub-install-acknowledgement.ts @@ -17,6 +17,12 @@ export type NonClawHubInstallAcknowledgementOptions = { acknowledgeNonClawHubInstall?: boolean; }; +export type NonClawHubInstallAcknowledgementRequest = { + pluginId: string; + sourceClass: NonClawHubInstallSourceClass; + spec: string; +}; + const sourceClassLabels: Record = { git: "Git repository", "local-archive": "local archive", diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 475e54aefbb7..b8960fad70b0 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -11,6 +11,8 @@ import { applyParentDefaultHelpAction } from "./program/parent-default-help.js"; type PluginUpdateOptions = { all?: boolean; acknowledgeClawhubRisk?: boolean; + acknowledgeNonClawHubInstall?: boolean; + acknowledgeNonClawhubInstall?: boolean; dryRun?: boolean; dangerouslyForceUnsafeInstall?: boolean; }; @@ -237,6 +239,11 @@ export function registerPluginsCli(program: Command) { "Acknowledge ClawHub release trust warnings without prompting", false, ) + .option( + "--acknowledge-non-clawhub-install", + "Acknowledge non-ClawHub plugin update provenance without prompting", + false, + ) .action(async (id: string | undefined, opts: PluginUpdateOptions) => { const { runPluginUpdateCommand } = await import("./plugins-update-command.js"); await runPluginUpdateCommand({ @@ -244,6 +251,7 @@ export function registerPluginsCli(program: Command) { opts: { ...opts, acknowledgeClawHubRisk: normalizeCommanderClawHubRiskOption(opts), + acknowledgeNonClawHubInstall: normalizeCommanderNonClawHubInstallOption(opts), }, }); }); diff --git a/src/cli/plugins-cli.update.test.ts b/src/cli/plugins-cli.update.test.ts index e3913144e925..f4b76131633f 100644 --- a/src/cli/plugins-cli.update.test.ts +++ b/src/cli/plugins-cli.update.test.ts @@ -171,6 +171,70 @@ describe("plugins cli update", () => { expect(helpText).toContain("Deprecated no-op"); expect(helpText).toContain("security.installPolicy"); expect(helpText).toContain("may still block"); + expect(helpText).toContain("--acknowledge-non-clawhub-install"); + }); + + it("fails closed on a non-interactive non-ClawHub plugin update", async () => { + const cfg = createTrackedPluginConfig({ + pluginId: "demo", + spec: "@acme/demo@1.0.0", + }); + primeUpdateConfigSnapshot({ config: cfg }); + setInstalledPluginIndexInstallRecords(cfg.plugins?.installs ?? {}); + setTty(false); + updateNpmInstalledPlugins.mockImplementation(async (params) => { + expect(params.allowNonClawHubInstall).toBe(false); + await expect( + params.onNonClawHubInstall?.({ + pluginId: "demo", + source: "npm", + spec: "@acme/demo@1.0.0", + }), + ).resolves.toBe(false); + return { + config: params.config, + changed: false, + outcomes: [ + { + pluginId: "demo", + status: "skipped", + code: "non_clawhub_install_acknowledgement_required", + message: "Skipped non-ClawHub update for demo.", + }, + ], + }; + }); + + await expect(runPluginsCommand(["plugins", "update", "demo"])).rejects.toThrow("__exit__:1"); + + expect(runtimeErrors.join("\n")).toContain("WARNING - Installing plugin from npm registry"); + expect(runtimeErrors.join("\n")).toContain("--acknowledge-non-clawhub-install"); + }); + + it("forwards explicit non-ClawHub acknowledgement to plugin updates", async () => { + const cfg = createTrackedPluginConfig({ + pluginId: "demo", + spec: "@acme/demo@1.0.0", + }); + primeUpdateConfigSnapshot({ config: cfg }); + setInstalledPluginIndexInstallRecords(cfg.plugins?.installs ?? {}); + setTty(false); + updateNpmInstalledPlugins.mockImplementation(async (params) => { + expect(params.allowNonClawHubInstall).toBe(false); + await expect( + params.onNonClawHubInstall?.({ + pluginId: "demo", + source: "npm", + spec: "@acme/demo@1.0.0", + }), + ).resolves.toBe(true); + return { config: params.config, changed: false, outcomes: [] }; + }); + + await runPluginsCommand(["plugins", "update", "demo", "--acknowledge-non-clawhub-install"]); + + expect(runtimeErrors).toEqual([]); + expect(runtimeLogs.join("\n")).toContain("WARNING - Installing plugin from npm registry"); }); it("refuses plugin updates in Nix mode before package-manager work", async () => { diff --git a/src/cli/plugins-update-command.ts b/src/cli/plugins-update-command.ts index 5fe763564dab..bed384d4afb6 100644 --- a/src/cli/plugins-update-command.ts +++ b/src/cli/plugins-update-command.ts @@ -26,6 +26,10 @@ import { import { defaultRuntime } from "../runtime.js"; import { VERSION } from "../version.js"; import { resolveClawHubRiskAcknowledgementCliOptions } from "./clawhub-risk-acknowledgement.js"; +import { + confirmNonClawHubInstall, + type NonClawHubInstallSourceClass, +} from "./non-clawhub-install-acknowledgement.js"; import { containsConfigIncludeDirective, resolveCombinedPluginAndHookConfigMutationPreflight, @@ -44,6 +48,23 @@ import { promptYesNo } from "./prompt.js"; const DEPRECATED_DANGEROUS_FORCE_UNSAFE_UPDATE_WARNING = "--dangerously-force-unsafe-install is deprecated and no longer affects plugin updates because built-in install-time dangerous-code scanning has been removed. Configure security.installPolicy for operator-owned install decisions."; +function pluginUpdateSourceClass( + source: "npm" | "archive" | "path" | "git" | "marketplace", +): NonClawHubInstallSourceClass { + switch (source) { + case "npm": + return "npm"; + case "archive": + return "local-archive"; + case "path": + return "local-path"; + case "git": + return "git"; + case "marketplace": + return "marketplace"; + } +} + function mayMutatePluginInstallRecord( record: PluginInstallRecord | undefined, specOverride: string | undefined, @@ -106,6 +127,7 @@ export async function runPluginUpdateCommand(params: { opts: { all?: boolean; acknowledgeClawHubRisk?: boolean; + acknowledgeNonClawHubInstall?: boolean; dryRun?: boolean; dangerouslyForceUnsafeInstall?: boolean; }; @@ -279,6 +301,14 @@ export async function runPluginUpdateCommand(params: { action: "updating", allowPrompt: !params.opts.dryRun, }), + allowNonClawHubInstall: false, + onNonClawHubInstall: async (request) => + await confirmNonClawHubInstall({ + acknowledged: params.opts.acknowledgeNonClawHubInstall, + runtime: defaultRuntime, + sourceClass: pluginUpdateSourceClass(request.source), + spec: request.spec, + }), logger, onIntegrityDrift: async (drift) => { const specLabel = drift.resolvedSpec ?? drift.spec; @@ -303,6 +333,14 @@ export async function runPluginUpdateCommand(params: { hookIds: hookSelection.hookIds, specOverrides: hookSelection.specOverrides, dryRun: params.opts.dryRun, + allowNonClawHubInstall: false, + onNonClawHubInstall: async (request) => + await confirmNonClawHubInstall({ + acknowledged: params.opts.acknowledgeNonClawHubInstall, + runtime: defaultRuntime, + sourceClass: "npm", + spec: request.spec, + }), logger, onIntegrityDrift: async (drift) => { const specLabel = drift.resolvedSpec ?? drift.spec; diff --git a/src/cli/plugins-update-outcomes.ts b/src/cli/plugins-update-outcomes.ts index c10b0d196dfa..6736da338896 100644 --- a/src/cli/plugins-update-outcomes.ts +++ b/src/cli/plugins-update-outcomes.ts @@ -1,6 +1,9 @@ // User-facing logging for plugin and hook-pack update outcomes. import { theme } from "../../packages/terminal-core/src/theme.js"; -import { isClawHubTrustSkippedOutcome } from "../plugins/update.js"; +import { + isClawHubTrustSkippedOutcome, + isNonClawHubInstallAcknowledgementSkippedOutcome, +} from "../plugins/update.js"; type PluginUpdateCliOutcome = { status: string; @@ -27,7 +30,10 @@ export function logPluginUpdateOutcomes(params: { continue; } if (outcome.status === "skipped") { - if (isClawHubTrustSkippedOutcome(outcome)) { + if ( + isClawHubTrustSkippedOutcome(outcome) || + isNonClawHubInstallAcknowledgementSkippedOutcome(outcome) + ) { hasErrors = true; } params.log(theme.warn(outcome.message)); diff --git a/src/cli/program.smoke.test.ts b/src/cli/program.smoke.test.ts index a35ad24225e9..171132821369 100644 --- a/src/cli/program.smoke.test.ts +++ b/src/cli/program.smoke.test.ts @@ -76,6 +76,20 @@ describe("cli program (smoke)", () => { expect(options?.json).toBe(false); }); + it("forwards explicit non-ClawHub acknowledgement to Crestodian", async () => { + await runProgram([ + "crestodian", + "--message", + "plugin install npm:@example/plugin", + "--yes", + "--acknowledge-non-clawhub-install", + ]); + const options = firstMockArg(runCrestodian) as { + acknowledgeNonClawHubInstall?: boolean; + }; + expect(options.acknowledgeNonClawHubInstall).toBe(true); + }); + it("warns and ignores invalid tui timeout override", async () => { await runProgram(["tui", "--timeout-ms", "nope"]); expect(runtime.error).toHaveBeenCalledWith('warning: invalid --timeout-ms "nope"; ignoring'); diff --git a/src/cli/program/register.crestodian.ts b/src/cli/program/register.crestodian.ts index ce8b6bf54153..5252723c3be2 100644 --- a/src/cli/program/register.crestodian.ts +++ b/src/cli/program/register.crestodian.ts @@ -5,6 +5,14 @@ import { runCrestodian } from "../../crestodian/crestodian.js"; import { defaultRuntime } from "../../runtime.js"; import { runCommandWithRuntime } from "../cli-utils.js"; import { formatHelpExamples } from "../help-format.js"; +import { NON_CLAWHUB_INSTALL_ACK_FLAG } from "../non-clawhub-install-acknowledgement.js"; + +function normalizeNonClawHubInstallAcknowledgement(opts: { + acknowledgeNonClawHubInstall?: boolean; + acknowledgeNonClawhubInstall?: boolean; +}): boolean { + return opts.acknowledgeNonClawHubInstall === true || opts.acknowledgeNonClawhubInstall === true; +} /** Register the Crestodian helper command and its one-shot request flags. */ export function registerCrestodianCommand(program: Command) { @@ -13,6 +21,11 @@ export function registerCrestodianCommand(program: Command) { .description("Open the ring-zero setup and repair helper") .option("-m, --message ", "Run one Crestodian request") .option("--yes", "Approve persistent config writes for this request", false) + .option( + NON_CLAWHUB_INSTALL_ACK_FLAG, + "Acknowledge plugin install sources outside ClawHub review", + false, + ) .option("--json", "Output startup overview as JSON", false) .addHelpText( "after", @@ -32,6 +45,7 @@ export function registerCrestodianCommand(program: Command) { await runCrestodian({ message: opts.message as string | undefined, yes: Boolean(opts.yes), + acknowledgeNonClawHubInstall: normalizeNonClawHubInstallAcknowledgement(opts), json: Boolean(opts.json), }); }); diff --git a/src/cli/promos-cli.test.ts b/src/cli/promos-cli.test.ts new file mode 100644 index 000000000000..3a761d0b840c --- /dev/null +++ b/src/cli/promos-cli.test.ts @@ -0,0 +1,44 @@ +// Promos CLI tests cover non-interactive claim option normalization. +import { Command } from "commander"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { registerPromosCli } from "./promos-cli.js"; + +const mocks = vi.hoisted(() => ({ + promosClaimCommand: vi.fn(), +})); + +vi.mock("../commands/promos/claim.js", () => ({ + promosClaimCommand: mocks.promosClaimCommand, +})); + +describe("registerPromosCli", () => { + beforeEach(() => { + mocks.promosClaimCommand.mockReset(); + }); + + it("normalizes non-ClawHub install acknowledgement for promotion claims", async () => { + const program = new Command().name("openclaw"); + registerPromosCli(program); + + await program.parseAsync( + [ + "promos", + "claim", + "spring-models", + "--api-key", + "sk-test", + "--acknowledge-non-clawhub-install", + ], + { from: "user" }, + ); + + expect(mocks.promosClaimCommand).toHaveBeenCalledWith( + "spring-models", + { + acknowledgeNonClawHubInstall: true, + apiKey: "sk-test", + }, + expect.any(Object), + ); + }); +}); diff --git a/src/cli/promos-cli.ts b/src/cli/promos-cli.ts index 338bf5130ef4..25a7b9179c28 100644 --- a/src/cli/promos-cli.ts +++ b/src/cli/promos-cli.ts @@ -2,8 +2,21 @@ import type { Command } from "commander"; import { formatDocsLink } from "../../packages/terminal-core/src/links.js"; import { theme } from "../../packages/terminal-core/src/theme.js"; +import type { PromosClaimOptions } from "../commands/promos/claim.js"; import { defaultRuntime } from "../runtime.js"; import { runCommandWithRuntime } from "./cli-utils.js"; +import { NON_CLAWHUB_INSTALL_ACK_FLAG } from "./non-clawhub-install-acknowledgement.js"; + +type CommanderPromosClaimOptions = { + acknowledgeNonClawHubInstall?: boolean; + acknowledgeNonClawhubInstall?: boolean; + apiKey?: string; + setDefault?: boolean; +}; + +function normalizeNonClawHubInstallAcknowledgement(opts: CommanderPromosClaimOptions): boolean { + return opts.acknowledgeNonClawHubInstall === true || opts.acknowledgeNonClawhubInstall === true; +} export function registerPromosCli(program: Command) { const promos = program @@ -34,11 +47,21 @@ export function registerPromosCli(program: Command) { // `onboard --token` non-interactive contract (AGENTS.md: public API). The // no-argv alternative is the provider's env var, detected as existing auth. .option("--api-key ", "Provider API key for non-interactive setup") + .option( + NON_CLAWHUB_INSTALL_ACK_FLAG, + "Acknowledge provider plugin installs whose source is outside ClawHub review", + false, + ) .option("--set-default", "Set the promotion's suggested model as default without asking", false) - .action(async (slug: string, opts: { apiKey?: string; setDefault?: boolean }) => { + .action(async (slug: string, opts: CommanderPromosClaimOptions) => { await runCommandWithRuntime(defaultRuntime, async () => { const { promosClaimCommand } = await import("../commands/promos/claim.js"); - await promosClaimCommand(slug, opts, defaultRuntime); + const claimOptions: PromosClaimOptions = { + ...(opts.apiKey !== undefined ? { apiKey: opts.apiKey } : {}), + ...(opts.setDefault === true ? { setDefault: true } : {}), + acknowledgeNonClawHubInstall: normalizeNonClawHubInstallAcknowledgement(opts), + }; + await promosClaimCommand(slug, claimOptions, defaultRuntime); }); }); } diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index d451dd35d2aa..6900f9d3fe18 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -1427,6 +1427,33 @@ describe("update-cli", () => { ]); }); + it("carries non-ClawHub install acknowledgement into post-core resume", async () => { + const { entrypoints } = setupUpdatedRootRefresh({ + gatewayUpdateImpl: async (root) => + makeOkUpdateResult({ + mode: "git", + root, + before: { sha: "old-sha", version: "2026.4.26" }, + after: { sha: "new-sha", version: "2026.4.27" }, + }), + }); + + await updateCommand({ + channel: "dev", + yes: true, + restart: false, + acknowledgeNonClawHubInstall: true, + }); + + expect(spawnCall()?.[1]).toEqual([ + entrypoints[0], + "update", + "--no-restart", + "--yes", + "--acknowledge-non-clawhub-install", + ]); + }); + it("keeps downgrade post-update work in the current process", async () => { const downgradedRoot = createCaseDir("openclaw-downgraded-root"); setupUpdatedRootRefresh({ @@ -1945,6 +1972,33 @@ describe("update-cli", () => { ); }); + it("surfaces non-ClawHub acknowledgement skips as post-update warnings", async () => { + updateNpmInstalledPlugins.mockImplementationOnce( + async (params: { config: OpenClawConfig }) => ({ + changed: false, + config: params.config, + outcomes: [ + { + pluginId: "demo", + status: "skipped" as const, + code: "non_clawhub_install_acknowledgement_required" as const, + message: + 'Skipped non-ClawHub install for "demo" from @example/demo; rerun with --acknowledge-non-clawhub-install after reviewing and trusting the source.', + }, + ], + }), + ); + vi.mocked(defaultRuntime.writeJson).mockClear(); + + await updateCommand({ json: true, restart: false }); + + const jsonOutput = lastWriteJsonCall() as UpdateRunResult | undefined; + expect(jsonOutput?.postUpdate?.plugins?.status).toBe("warning"); + expect(pluginWarning(jsonOutput)?.pluginId).toBe("demo"); + expect(pluginWarning(jsonOutput)?.reason).toContain("--acknowledge-non-clawhub-install"); + expect(pluginOutcome(jsonOutput)?.status).toBe("skipped"); + }); + it("includes non-blocking ClawHub trust warnings in json post-core plugin output", async () => { const trustWarning = "╭─ REVIEW RECOMMENDED - ClawHub has not completed a fresh clean check ─╮\n" + @@ -7394,6 +7448,7 @@ describe("update-cli", () => { timeout: "9", restart: false, acknowledgeClawHubRisk: true, + acknowledgeNonClawHubInstall: true, }); expect(doctorEnv?.OPENCLAW_UPDATE_IN_PROGRESS).toBe("1"); @@ -7410,6 +7465,7 @@ describe("update-cli", () => { }); expect(syncPluginCall()?.channel).toBe("stable"); expect(syncPluginCall()?.acknowledgeClawHubRisk).toBe(true); + expect(syncPluginCall()?.allowNonClawHubInstall).toBe(true); expect(lastNpmPluginUpdateCall()?.timeoutMs).toBe(9_000); expect( vi @@ -7417,6 +7473,7 @@ describe("update-cli", () => { .mock.calls.some(([options]) => options?.skipPluginValidation === true), ).toBe(true); expect(lastNpmPluginUpdateCall()?.acknowledgeClawHubRisk).toBe(true); + expect(lastNpmPluginUpdateCall()?.allowNonClawHubInstall).toBe(true); const output = lastWriteJsonCall() as | { status?: string; diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 8fbbab0c375b..7f29af39cbea 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -133,6 +133,7 @@ import { } from "../../plugins/official-external-install-records.js"; import { isClawHubTrustSkippedOutcome, + isNonClawHubInstallAcknowledgementSkippedOutcome, syncPluginsForUpdateChannel, updateNpmInstalledPlugins, type PluginUpdateIntegrityDriftParams, @@ -704,7 +705,11 @@ function isDisabledAfterFailureOutcome(outcome: PluginUpdateOutcome): boolean { } function isActionableSkippedPostUpdateOutcome(outcome: PluginUpdateOutcome): boolean { - return isDisabledAfterFailureOutcome(outcome) || isClawHubTrustSkippedOutcome(outcome); + return ( + isDisabledAfterFailureOutcome(outcome) || + isClawHubTrustSkippedOutcome(outcome) || + isNonClawHubInstallAcknowledgementSkippedOutcome(outcome) + ); } /** @@ -2234,6 +2239,7 @@ export async function updatePluginsAfterCoreUpdate(params: { workspaceDir: params.root, }), ...clawHubRiskAcknowledgementOptions, + allowNonClawHubInstall: params.opts.acknowledgeNonClawHubInstall === true, logger: pluginLogger, }); for (const error of syncResult.summary.errors) { @@ -2309,6 +2315,7 @@ export async function updatePluginsAfterCoreUpdate(params: { logger: pluginLogger, onIntegrityDrift: onPluginIntegrityDrift, ...clawHubRiskAcknowledgementOptions, + allowNonClawHubInstall: params.opts.acknowledgeNonClawHubInstall === true, }); pluginConfig = repairResult.config; pluginsChanged ||= repairResult.changed; @@ -2331,6 +2338,7 @@ export async function updatePluginsAfterCoreUpdate(params: { logger: pluginLogger, onIntegrityDrift: onPluginIntegrityDrift, ...clawHubRiskAcknowledgementOptions, + allowNonClawHubInstall: params.opts.acknowledgeNonClawHubInstall === true, }); pluginConfig = npmResult.config; pluginsChanged ||= npmResult.changed; diff --git a/src/commands/agents.providers.test.ts b/src/commands/agents.providers.test.ts index fee37e8deeac..c775fd420291 100644 --- a/src/commands/agents.providers.test.ts +++ b/src/commands/agents.providers.test.ts @@ -160,9 +160,9 @@ describe("buildProviderStatusIndex", () => { label: "Feishu", installSpec: "@openclaw/feishu", installCommand: "openclaw plugins install @openclaw/feishu --acknowledge-non-clawhub-install", - doctorFixCommand: "openclaw doctor --fix", + doctorFixCommand: "openclaw doctor --fix --acknowledge-non-clawhub-install", repairHint: - "Install the official external plugin with: openclaw plugins install @openclaw/feishu --acknowledge-non-clawhub-install, or run: openclaw doctor --fix.", + "Install the official external plugin with: openclaw plugins install @openclaw/feishu --acknowledge-non-clawhub-install, or run: openclaw doctor --fix --acknowledge-non-clawhub-install.", }); expect( @@ -176,7 +176,7 @@ describe("buildProviderStatusIndex", () => { defaultAccountId: "default", visibleInConfiguredLists: true, repairHint: - "Install the official external plugin with: openclaw plugins install @openclaw/feishu --acknowledge-non-clawhub-install, or run: openclaw doctor --fix.", + "Install the official external plugin with: openclaw plugins install @openclaw/feishu --acknowledge-non-clawhub-install, or run: openclaw doctor --fix --acknowledge-non-clawhub-install.", }, ], ]), @@ -197,14 +197,14 @@ describe("buildProviderStatusIndex", () => { defaultAccountId: "default", visibleInConfiguredLists: true, repairHint: - "Install the official external plugin with: openclaw plugins install @openclaw/feishu --acknowledge-non-clawhub-install, or run: openclaw doctor --fix.", + "Install the official external plugin with: openclaw plugins install @openclaw/feishu --acknowledge-non-clawhub-install, or run: openclaw doctor --fix --acknowledge-non-clawhub-install.", }, ], ]), }); expect(lines).toEqual([ - "Feishu default: missing plugin - Install the official external plugin with: openclaw plugins install @openclaw/feishu --acknowledge-non-clawhub-install, or run: openclaw doctor --fix.", + "Feishu default: missing plugin - Install the official external plugin with: openclaw plugins install @openclaw/feishu --acknowledge-non-clawhub-install, or run: openclaw doctor --fix --acknowledge-non-clawhub-install.", ]); }); @@ -224,14 +224,14 @@ describe("buildProviderStatusIndex", () => { defaultAccountId: "default", visibleInConfiguredLists: true, repairHint: - "Install the official external plugin with: openclaw plugins install @openclaw/feishu --acknowledge-non-clawhub-install, or run: openclaw doctor --fix.", + "Install the official external plugin with: openclaw plugins install @openclaw/feishu --acknowledge-non-clawhub-install, or run: openclaw doctor --fix --acknowledge-non-clawhub-install.", }, ], ]), }); expect(lines).toEqual([ - "Feishu default: missing plugin - Install the official external plugin with: openclaw plugins install @openclaw/feishu --acknowledge-non-clawhub-install, or run: openclaw doctor --fix.", + "Feishu default: missing plugin - Install the official external plugin with: openclaw plugins install @openclaw/feishu --acknowledge-non-clawhub-install, or run: openclaw doctor --fix --acknowledge-non-clawhub-install.", ]); }); @@ -249,14 +249,14 @@ describe("buildProviderStatusIndex", () => { defaultAccountId: "default", visibleInConfiguredLists: true, repairHint: - "Install the official external plugin with: openclaw plugins install @openclaw/feishu --acknowledge-non-clawhub-install, or run: openclaw doctor --fix.", + "Install the official external plugin with: openclaw plugins install @openclaw/feishu --acknowledge-non-clawhub-install, or run: openclaw doctor --fix --acknowledge-non-clawhub-install.", }, ], ]), }); expect(lines).toEqual([ - "Feishu default: missing plugin - Install the official external plugin with: openclaw plugins install @openclaw/feishu --acknowledge-non-clawhub-install, or run: openclaw doctor --fix.", + "Feishu default: missing plugin - Install the official external plugin with: openclaw plugins install @openclaw/feishu --acknowledge-non-clawhub-install, or run: openclaw doctor --fix --acknowledge-non-clawhub-install.", ]); }); diff --git a/src/commands/channels.list.test.ts b/src/commands/channels.list.test.ts index 175bd1bf63ec..40d6ed95f6e1 100644 --- a/src/commands/channels.list.test.ts +++ b/src/commands/channels.list.test.ts @@ -378,9 +378,9 @@ describe("channels list", () => { installSpec: "@openclaw/discord", installCommand: "openclaw plugins install @openclaw/discord --acknowledge-non-clawhub-install", - doctorFixCommand: "openclaw doctor --fix", + doctorFixCommand: "openclaw doctor --fix --acknowledge-non-clawhub-install", repairHint: - "Install the official external plugin with: openclaw plugins install @openclaw/discord --acknowledge-non-clawhub-install, or run: openclaw doctor --fix.", + "Install the official external plugin with: openclaw plugins install @openclaw/discord --acknowledge-non-clawhub-install, or run: openclaw doctor --fix --acknowledge-non-clawhub-install.", }); mocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot, @@ -408,7 +408,7 @@ describe("channels list", () => { expect(output).toContain("configured"); expect(output).toContain("disabled"); expect(output).toContain( - "run openclaw plugins install @openclaw/discord --acknowledge-non-clawhub-install or openclaw doctor --fix", + "run openclaw plugins install @openclaw/discord --acknowledge-non-clawhub-install or openclaw doctor --fix --acknowledge-non-clawhub-install", ); expect(output).not.toContain("no configured chat channels"); }); @@ -427,9 +427,9 @@ describe("channels list", () => { installSpec: "@openclaw/discord", installCommand: "openclaw plugins install @openclaw/discord --acknowledge-non-clawhub-install", - doctorFixCommand: "openclaw doctor --fix", + doctorFixCommand: "openclaw doctor --fix --acknowledge-non-clawhub-install", repairHint: - "Install the official external plugin with: openclaw plugins install @openclaw/discord --acknowledge-non-clawhub-install, or run: openclaw doctor --fix.", + "Install the official external plugin with: openclaw plugins install @openclaw/discord --acknowledge-non-clawhub-install, or run: openclaw doctor --fix --acknowledge-non-clawhub-install.", }); mocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot, diff --git a/src/commands/channels.status.command-flow.test.ts b/src/commands/channels.status.command-flow.test.ts index d56549a50de7..1db403940395 100644 --- a/src/commands/channels.status.command-flow.test.ts +++ b/src/commands/channels.status.command-flow.test.ts @@ -55,9 +55,9 @@ vi.mock("../plugins/official-external-plugin-repair-hints.js", () => ({ installSpec: "@openclaw/feishu", installCommand: "openclaw plugins install @openclaw/feishu --acknowledge-non-clawhub-install", - doctorFixCommand: "openclaw doctor --fix", + doctorFixCommand: "openclaw doctor --fix --acknowledge-non-clawhub-install", repairHint: - "Install the official external plugin with: openclaw plugins install @openclaw/feishu --acknowledge-non-clawhub-install, or run: openclaw doctor --fix.", + "Install the official external plugin with: openclaw plugins install @openclaw/feishu --acknowledge-non-clawhub-install, or run: openclaw doctor --fix --acknowledge-non-clawhub-install.", } : null, })); @@ -331,7 +331,7 @@ describe("channelsStatusCommand SecretRef fallback flow", () => { const joined = logs.join("\n"); expect(joined).toContain("Missing official external plugins:"); expect(joined).toContain( - "Feishu: Install the official external plugin with: openclaw plugins install @openclaw/feishu --acknowledge-non-clawhub-install, or run: openclaw doctor --fix.", + "Feishu: Install the official external plugin with: openclaw plugins install @openclaw/feishu --acknowledge-non-clawhub-install, or run: openclaw doctor --fix --acknowledge-non-clawhub-install.", ); }); diff --git a/src/commands/codex-runtime-plugin-install.test.ts b/src/commands/codex-runtime-plugin-install.test.ts index 05549a49a731..0269823d4bb8 100644 --- a/src/commands/codex-runtime-plugin-install.test.ts +++ b/src/commands/codex-runtime-plugin-install.test.ts @@ -10,6 +10,11 @@ type MissingPluginInstallRepairCall = { pluginIds: string[]; env?: NodeJS.ProcessEnv; acknowledgeNonClawHubInstall?: boolean; + onNonClawHubInstall?: (request: { + pluginId: string; + sourceClass: "npm"; + spec: string; + }) => boolean | Promise; }; function readOnlyMissingPluginInstallRepairCall(): MissingPluginInstallRepairCall { @@ -66,6 +71,45 @@ describe("Codex runtime plugin install repair", () => { required: true, changes: ['Repaired missing configured plugin "codex".'], warnings: [reviewNotice], + failed: false, + }); + }); + + it("forwards explicit non-ClawHub acknowledgement to runtime plugin repair", async () => { + const { repairCodexRuntimePluginInstallForModelSelection } = + await import("./codex-runtime-plugin-install.js"); + + await repairCodexRuntimePluginInstallForModelSelection({ + cfg: {}, + model: "openai/gpt-5.5", + acknowledgeNonClawHubInstall: true, + }); + + expect(readOnlyMissingPluginInstallRepairCall().acknowledgeNonClawHubInstall).toBe(true); + }); + + it("reports a refused runtime plugin repair as failed", async () => { + mocks.repairMissingPluginInstallsForIds.mockResolvedValue({ + changes: [], + warnings: ["Non-ClawHub acknowledgement required."], + failedPluginIds: ["codex"], + }); + const onNonClawHubInstall = vi.fn(async () => false); + const { repairCodexRuntimePluginInstallForModelSelection } = + await import("./codex-runtime-plugin-install.js"); + + const result = await repairCodexRuntimePluginInstallForModelSelection({ + cfg: {}, + model: "openai/gpt-5.5", + onNonClawHubInstall, + }); + + expect(readOnlyMissingPluginInstallRepairCall().onNonClawHubInstall).toBe(onNonClawHubInstall); + expect(result).toEqual({ + required: true, + changes: [], + warnings: ["Non-ClawHub acknowledgement required."], + failed: true, }); }); @@ -106,13 +150,24 @@ describe("Codex runtime plugin install repair", () => { entries: { codex: { enabled: false } }, }, }; + const confirm = vi.fn(async () => true); + mocks.repairMissingPluginInstallsForIds.mockImplementationOnce( + async (params: MissingPluginInstallRepairCall) => { + await params.onNonClawHubInstall?.({ + pluginId: "codex", + sourceClass: "npm", + spec: "@openclaw/codex", + }); + return { changes: [], warnings: [] }; + }, + ); const { ensureCodexRuntimePluginForModelSelection } = await import("./codex-runtime-plugin-install.js"); const result = await ensureCodexRuntimePluginForModelSelection({ cfg, model: "openai/gpt-5.5", - prompter: {} as never, + prompter: { confirm } as never, runtime: {} as never, }); @@ -122,6 +177,12 @@ describe("Codex runtime plugin install repair", () => { status: "installed", cfg: { plugins: { entries: { codex: { enabled: true } } } }, }); + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("@openclaw/codex"), + initialValue: false, + }), + ); }); it("sees an agent-scoped Codex runtime pin behind a custom OpenAI route", async () => { diff --git a/src/commands/codex-runtime-plugin-install.ts b/src/commands/codex-runtime-plugin-install.ts index 6d7d144d110d..de19cf00612e 100644 --- a/src/commands/codex-runtime-plugin-install.ts +++ b/src/commands/codex-runtime-plugin-install.ts @@ -4,7 +4,7 @@ import { createRuntimePluginModelSelectionHelpers } from "./runtime-plugin-insta export const CODEX_RUNTIME_PLUGIN_ID = "codex"; const CODEX_RUNTIME_PLUGIN_LABEL = "Codex"; -const CODEX_RUNTIME_PLUGIN_NPM_SPEC = "@openclaw/codex"; +export const CODEX_RUNTIME_PLUGIN_NPM_SPEC = "@openclaw/codex"; const CODEX_RUNTIME_PLUGIN_DESCRIPTOR = { pluginId: CODEX_RUNTIME_PLUGIN_ID, label: CODEX_RUNTIME_PLUGIN_LABEL, diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index 809e2b99d396..90b0b7584339 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -1608,6 +1608,7 @@ describe("doctor config flow", () => { changeNotes: ["Migrated 1 sidecar-backed Codex OAuth profile."], warningNotes: [], authProfilesRepaired: true, + failedConfiguredPluginInstallIds: [], })); await runDoctorConfigWithInput({ @@ -1635,6 +1636,7 @@ describe("doctor config flow", () => { changeNotes: ["Removed stale OAuth auth profile shadow openai-codex."], warningNotes: [], authProfilesRepaired: true, + failedConfiguredPluginInstallIds: [], })); await expect( @@ -1657,6 +1659,24 @@ describe("doctor config flow", () => { }); }); + it("carries failed configured plugin installs into later doctor phases", async () => { + runDoctorRepairSequenceMock.mockImplementation(async (params: { state: unknown }) => ({ + state: params.state, + changeNotes: [], + warningNotes: ['Skipped missing configured plugin "matrix".'], + authProfilesRepaired: false, + failedConfiguredPluginInstallIds: ["matrix"], + })); + + const result = await runDoctorConfigWithInput({ + config: {}, + repair: true, + run: loadAndMaybeMigrateDoctorConfig, + }); + + expect(result.failedConfiguredPluginInstallIds).toEqual(["matrix"]); + }); + it("previews and repairs hooks token reuse of gateway auth", async () => { const config = { gateway: { diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index 20d2079c9438..c780846ad8b0 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -149,6 +149,7 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { let candidate = structuredClone(baseCfg); let pendingChanges = false; let fixHints: string[] = []; + let failedConfiguredPluginInstallIds: string[] = []; const doctorFixCommand = formatCliCommand("openclaw doctor --fix"); const sourceMeta = (snapshot.sourceConfig as { meta?: { lastTouchedVersion?: unknown } })?.meta; const sourceLastTouchedVersion = @@ -307,6 +308,7 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { prompter: params.prompter, }); ({ cfg, candidate, pendingChanges, fixHints } = repairSequence.state); + failedConfiguredPluginInstallIds = repairSequence.failedConfiguredPluginInstallIds; if (repairSequence.authProfilesRepaired) { await refreshGatewayAuthStateAfterAuthProfileRepair(); } @@ -378,6 +380,7 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { shouldWriteConfig: finalized.shouldWriteConfig, sourceConfigValid: snapshot.valid, preservedLegacyRootKeys: ["defaultModel"], + ...(failedConfiguredPluginInstallIds.length > 0 ? { failedConfiguredPluginInstallIds } : {}), ...(sourceLastTouchedVersion ? { sourceLastTouchedVersion } : {}), ...(legacyMigrationPartiallyValid ? { skipPluginValidationOnWrite: true } : {}), }; diff --git a/src/commands/doctor/repair-sequencing.test.ts b/src/commands/doctor/repair-sequencing.test.ts index ea15543da366..a1e3303e3a3b 100644 --- a/src/commands/doctor/repair-sequencing.test.ts +++ b/src/commands/doctor/repair-sequencing.test.ts @@ -393,6 +393,58 @@ describe("doctor repair sequencing", () => { expect(peerLinkCall?.env).toBe(process.env); }); + it("uses an explicit interactive decision for non-ClawHub repair installs", async () => { + const confirmRuntimeRepair = vi.fn().mockResolvedValueOnce(true).mockResolvedValueOnce(false); + const prompter = { confirmRuntimeRepair } as never; + + await runDoctorRepairSequence({ + state: { + cfg: {} as OpenClawConfig, + candidate: {} as OpenClawConfig, + pendingChanges: false, + fixHints: [], + }, + doctorFixCommand: "openclaw doctor --fix", + prompter, + }); + + const repairCall = mocks.repairMissingConfiguredPluginInstalls.mock.calls[0]?.[0]; + const onNonClawHubInstall = repairCall?.onNonClawHubInstall; + expect(onNonClawHubInstall).toEqual(expect.any(Function)); + const request = { + pluginId: "matrix", + sourceClass: "npm" as const, + spec: "@openclaw/matrix", + }; + await expect(onNonClawHubInstall?.(request)).resolves.toBe(true); + await expect(onNonClawHubInstall?.(request)).resolves.toBe(false); + expect(confirmRuntimeRepair).toHaveBeenNthCalledWith(1, { + message: + "WARNING - Installing plugin from npm registry: @openclaw/matrix\n" + + "This source is outside ClawHub review and trust metadata. Only continue if you trust the publisher, package contents, and install source.\n" + + "Install this non-ClawHub plugin source during doctor repair?", + initialValue: false, + requiresInteractiveConfirmation: true, + }); + }); + + it("forwards explicit non-ClawHub repair acknowledgement", async () => { + await runDoctorRepairSequence({ + state: { + cfg: {} as OpenClawConfig, + candidate: {} as OpenClawConfig, + pendingChanges: false, + fixHints: [], + }, + doctorFixCommand: "openclaw doctor --fix", + acknowledgeNonClawHubInstall: true, + }); + + expect(mocks.repairMissingConfiguredPluginInstalls).toHaveBeenCalledWith( + expect.objectContaining({ acknowledgeNonClawHubInstall: true }), + ); + }); + it("repairs stale OAuth shadows before importing and removing auth JSON", async () => { const events: string[] = []; mocks.maybeRepairLegacyOAuthSidecarProfiles.mockImplementationOnce(async () => { @@ -1017,6 +1069,7 @@ describe("doctor repair sequencing", () => { expect(result.warningNotes).toStrictEqual([ 'Failed to install missing configured plugin "brave" from @openclaw/brave-plugin: package install failed', ]); + expect(result.failedConfiguredPluginInstallIds).toEqual(["brave"]); }); it("preserves configured channels when their install repair fails", async () => { diff --git a/src/commands/doctor/repair-sequencing.ts b/src/commands/doctor/repair-sequencing.ts index 3ebeb01d02b8..02b94a7388f3 100644 --- a/src/commands/doctor/repair-sequencing.ts +++ b/src/commands/doctor/repair-sequencing.ts @@ -58,15 +58,17 @@ export async function runDoctorRepairSequence(params: { changeNotes: string[]; warningNotes: string[]; authProfilesRepaired: boolean; + failedConfiguredPluginInstallIds: string[]; }> { let state = params.state; const changeNotes: string[] = []; const warningNotes: string[] = []; const env = params.env ?? process.env; const sanitizeLines = (lines: string[]) => lines.map((line) => sanitizeForLog(line)).join("\n"); - const confirmNonClawHubRepairInstall = params.prompter + const prompter = params.prompter; + const confirmNonClawHubRepairInstall = prompter ? async (request: { sourceClass: NonClawHubInstallSourceClass; spec: string }) => - await params.prompter!.confirmRuntimeRepair({ + await prompter.confirmRuntimeRepair({ message: `${formatNonClawHubInstallWarning(request)}\nInstall this non-ClawHub plugin source during doctor repair?`, initialValue: false, requiresInteractiveConfirmation: true, @@ -266,5 +268,11 @@ export async function runDoctorRepairSequence(params: { warningNotes.push(sanitizeLines(activeToolSchemaWarnings)); } - return { state, changeNotes, warningNotes, authProfilesRepaired }; + return { + state, + changeNotes, + warningNotes, + authProfilesRepaired, + failedConfiguredPluginInstallIds: failedPluginIds, + }; } diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts index a441b6c9ffa2..94e0b169932e 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts @@ -3243,6 +3243,139 @@ describe("repairMissingConfiguredPluginInstalls", () => { expectRecordFields(updateConfig.plugins, { installs: records }); }); + it("requires per-source approval before persisted-record non-ClawHub repair", async () => { + const records = { + demo: { + source: "npm", + spec: "@openclaw/plugin-demo@1.0.0", + resolvedSpec: "@openclaw/plugin-demo@1.0.0", + resolvedVersion: "1.0.0", + installPath: "/missing/demo", + }, + }; + const onNonClawHubInstall = vi.fn(async () => false); + mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue(records); + mocks.updateNpmInstalledPlugins.mockImplementationOnce( + async (params: { + allowNonClawHubInstall?: boolean; + onNonClawHubInstall?: (request: { + pluginId: string; + source: "npm"; + spec: string; + }) => boolean | Promise; + config: Record; + }) => { + expect(params.allowNonClawHubInstall).toBe(false); + await expect( + params.onNonClawHubInstall?.({ + pluginId: "demo", + source: "npm", + spec: "@openclaw/plugin-demo@1.0.0", + }), + ).resolves.toBe(false); + return { + changed: false, + config: params.config, + outcomes: [ + { + pluginId: "demo", + status: "skipped", + code: "non_clawhub_install_acknowledgement_required", + message: "Non-ClawHub acknowledgement required.", + }, + ], + }; + }, + ); + + const { repairMissingConfiguredPluginInstalls } = + await import("./missing-configured-plugin-install.js"); + const result = await repairMissingConfiguredPluginInstalls({ + cfg: { + plugins: { + entries: { + demo: { enabled: true }, + }, + }, + }, + env: {}, + onNonClawHubInstall, + }); + + expect(onNonClawHubInstall).toHaveBeenCalledWith({ + pluginId: "demo", + sourceClass: "npm", + spec: "@openclaw/plugin-demo@1.0.0", + }); + expect(result.failedPluginIds).toEqual(["demo"]); + expect(result.warnings).toContain("Non-ClawHub acknowledgement required."); + expect(mocks.writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled(); + expect(result.records.demo).toEqual(records.demo); + }); + + it("preserves declined repair metadata while removing another stale bundled record", async () => { + const records = { + demo: { + source: "npm", + spec: "@openclaw/plugin-demo@1.0.0", + resolvedSpec: "@openclaw/plugin-demo@1.0.0", + resolvedVersion: "1.0.0", + installPath: "/missing/demo", + }, + "google-meet": { + source: "npm", + spec: "@openclaw/google-meet", + resolvedName: "@openclaw/google-meet", + installPath: "/missing/google-meet", + }, + }; + mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue(records); + mocks.loadInstalledPluginIndex.mockReturnValue({ + plugins: [ + { + pluginId: "google-meet", + origin: "bundled", + packageName: "@openclaw/google-meet", + }, + ], + diagnostics: [], + installRecords: {}, + }); + mocks.updateNpmInstalledPlugins.mockImplementationOnce( + async (params: { config: { plugins?: { installs?: Record } } }) => ({ + changed: false, + config: params.config, + outcomes: [ + { + pluginId: "demo", + status: "skipped", + code: "non_clawhub_install_acknowledgement_required", + message: "Non-ClawHub acknowledgement required.", + }, + ], + }), + ); + + const { repairMissingConfiguredPluginInstalls } = + await import("./missing-configured-plugin-install.js"); + const result = await repairMissingConfiguredPluginInstalls({ + cfg: { + plugins: { + entries: { + demo: { enabled: true }, + }, + }, + }, + env: {}, + }); + + expect(result.records).toEqual({ demo: records.demo }); + expect(mocks.writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith( + { demo: records.demo }, + { env: {} }, + ); + }); + it("keeps non-ClawHub updater warnings as persisted-record repair warnings", async () => { const records = { demo: { diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.ts b/src/commands/doctor/shared/missing-configured-plugin-install.ts index a99ad5bddda8..cca0b11ec399 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.ts @@ -12,6 +12,7 @@ import { import { listRawChannelPluginCatalogEntries } from "../../../channels/plugins/catalog.js"; import { NON_CLAWHUB_INSTALL_ACK_FLAG, + type NonClawHubInstallAcknowledgementRequest, type NonClawHubInstallSourceClass, } from "../../../cli/non-clawhub-install-acknowledgement.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; @@ -70,7 +71,9 @@ import type { PluginMetadataSnapshot } from "../../../plugins/plugin-metadata-sn import { resolveProviderInstallCatalogEntries } from "../../../plugins/provider-install-catalog.js"; import { isClawHubTrustSkippedOutcome, + isNonClawHubInstallAcknowledgementSkippedOutcome, updateNpmInstalledPlugins, + type PluginUpdateNonClawHubInstallRequest, } from "../../../plugins/update.js"; import { resolveWebSearchInstallCatalogEntriesForEnv, @@ -999,11 +1002,20 @@ function recordClawHubPackageName(value: string | undefined): string | undefined type InstallCandidateRepairReason = "stale-version-bound-runtime"; -export type NonClawHubInstallAcknowledgementRequest = { - pluginId: string; - sourceClass: NonClawHubInstallSourceClass; - spec: string; -}; +function pluginUpdateSourceClass( + source: PluginUpdateNonClawHubInstallRequest["source"], +): NonClawHubInstallSourceClass { + switch (source) { + case "archive": + return "local-archive"; + case "path": + return "local-path"; + case "git": + case "marketplace": + case "npm": + return source; + } +} export type ConfiguredPluginInstallHealthIssue = | { @@ -2061,6 +2073,9 @@ async function repairMissingPluginInstalls(params: { ); if (missingRecordedPluginIds.length > 0) { + const onNonClawHubInstall = params.onNonClawHubInstall; + const recordsBeforeForcedRepair = nextRecords === records ? records : { ...nextRecords }; + const forcedOriginalRecords = new Map(); for (const pluginId of missingRecordedPluginIds) { const record = nextRecords[pluginId]; if (!record) { @@ -2068,6 +2083,7 @@ async function repairMissingPluginInstalls(params: { } const forced = forceNpmInstallRecordRepair(record); if (forced !== record) { + forcedOriginalRecords.set(pluginId, record); if (nextRecords === records) { nextRecords = { ...records }; } @@ -2098,6 +2114,17 @@ async function repairMissingPluginInstalls(params: { }, ...(params.acknowledgeClawHubRisk ? { acknowledgeClawHubRisk: true } : {}), ...(params.onClawHubRisk ? { onClawHubRisk: params.onClawHubRisk } : {}), + allowNonClawHubInstall: params.acknowledgeNonClawHubInstall === true, + ...(onNonClawHubInstall + ? { + onNonClawHubInstall: (request: PluginUpdateNonClawHubInstallRequest) => + onNonClawHubInstall({ + pluginId: request.pluginId, + sourceClass: pluginUpdateSourceClass(request.source), + spec: request.spec, + }), + } + : {}), }); for (const outcome of updateResult.outcomes) { if (outcome.status === "updated" || outcome.status === "unchanged") { @@ -2120,9 +2147,31 @@ async function repairMissingPluginInstalls(params: { }), ); failedPluginIds.add(outcome.pluginId); + } else if (outcome.status === "skipped") { + warnings.push(outcome.message); + failedPluginIds.add(outcome.pluginId); } } - nextRecords = updateResult.config.plugins?.installs ?? nextRecords; + let updatedRecords = updateResult.config.plugins?.installs ?? nextRecords; + const acknowledgementSkippedPluginIds = updateResult.outcomes + .filter(isNonClawHubInstallAcknowledgementSkippedOutcome) + .map((outcome) => outcome.pluginId); + if (acknowledgementSkippedPluginIds.length > 0) { + updatedRecords = { ...updatedRecords }; + for (const pluginId of acknowledgementSkippedPluginIds) { + const original = forcedOriginalRecords.get(pluginId); + if (original) { + updatedRecords[pluginId] = original; + } + } + if ( + !updateResult.changed && + updateResult.outcomes.every(isNonClawHubInstallAcknowledgementSkippedOutcome) + ) { + updatedRecords = recordsBeforeForcedRepair; + } + } + nextRecords = updatedRecords; } const missingPluginIds = new Set( diff --git a/src/commands/doctor/shared/release-configured-plugin-installs.ts b/src/commands/doctor/shared/release-configured-plugin-installs.ts index 77c7441c3892..f939bba728db 100644 --- a/src/commands/doctor/shared/release-configured-plugin-installs.ts +++ b/src/commands/doctor/shared/release-configured-plugin-installs.ts @@ -3,6 +3,7 @@ import { normalizeNullableString as normalizeId } from "@openclaw/normalization- import { collectConfiguredAgentHarnessRuntimes } from "../../../agents/harness-runtimes.js"; import { listPotentialConfiguredChannelPresenceSignals } from "../../../channels/config-presence.js"; import { normalizeChatChannelId } from "../../../channels/registry.js"; +import type { NonClawHubInstallAcknowledgementRequest } from "../../../cli/non-clawhub-install-acknowledgement.js"; import { isChannelConfigured } from "../../../config/channel-configured.js"; import { detectPluginAutoEnableCandidates } from "../../../config/plugin-auto-enable.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; @@ -23,10 +24,7 @@ import { } from "../../../plugins/web-search-install-catalog.js"; import { VERSION } from "../../../version.js"; import { collectConfiguredProviderPluginIds } from "./configured-provider-plugin-installs.js"; -import { - repairMissingPluginInstallsForIds, - type NonClawHubInstallAcknowledgementRequest, -} from "./missing-configured-plugin-install.js"; +import { repairMissingPluginInstallsForIds } from "./missing-configured-plugin-install.js"; import { asObjectRecord } from "./object.js"; import { shouldDeferConfiguredPluginInstallRepair } from "./update-phase.js"; diff --git a/src/commands/models/auth.test.ts b/src/commands/models/auth.test.ts index 52c0a1f64e02..7257a1a65c8d 100644 --- a/src/commands/models/auth.test.ts +++ b/src/commands/models/auth.test.ts @@ -65,6 +65,8 @@ const mocks = vi.hoisted(() => ({ callGateway: vi.fn(), resolvePluginSetupProvider: vi.fn(), resolvePluginSetupRegistry: vi.fn(), + repairCodexRuntimePluginInstallForModelSelection: vi.fn(), + repairCopilotRuntimePluginInstallForModelSelection: vi.fn(), })); vi.mock("../../agents/auth-profiles/profiles.js", () => ({ @@ -171,6 +173,16 @@ vi.mock("../../gateway/call.js", () => ({ callGateway: mocks.callGateway, })); +vi.mock("../codex-runtime-plugin-install.js", () => ({ + repairCodexRuntimePluginInstallForModelSelection: + mocks.repairCodexRuntimePluginInstallForModelSelection, +})); + +vi.mock("../copilot-runtime-plugin-install.js", () => ({ + repairCopilotRuntimePluginInstallForModelSelection: + mocks.repairCopilotRuntimePluginInstallForModelSelection, +})); + vi.mock("../../plugins/provider-oauth-flow.js", () => ({ createVpsAwareOAuthHandlers: vi.fn(() => ({ onAuth: vi.fn(), @@ -386,15 +398,24 @@ describe("modelsAuthLoginCommand", () => { autoEnableProbes: [], diagnostics: [], }); + mocks.repairCodexRuntimePluginInstallForModelSelection.mockResolvedValue({ + warnings: [], + failed: false, + }); + mocks.repairCopilotRuntimePluginInstallForModelSelection.mockResolvedValue({ + warnings: [], + failed: false, + }); mocks.loadValidConfigOrThrow.mockImplementation(async () => currentConfig); mocks.updateConfig.mockImplementation( - async (mutator: (cfg: OpenClawConfig) => OpenClawConfig) => { - lastUpdatedConfig = mutator(currentConfig); + async (mutator: (cfg: OpenClawConfig) => OpenClawConfig | Promise) => { + lastUpdatedConfig = await mutator(currentConfig); currentConfig = lastUpdatedConfig; return lastUpdatedConfig; }, ); mocks.createClackPrompter.mockReturnValue({ + confirm: vi.fn(async () => true), note: vi.fn(async () => {}), select: vi.fn(), }); @@ -1197,6 +1218,33 @@ describe("modelsAuthLoginCommand", () => { expect(runtime.log).toHaveBeenCalledWith("Default model set to openai/gpt-5.5"); }); + it("keeps the prior default when runtime plugin repair is refused", async () => { + const runtime = createRuntime(); + currentConfig = { + agents: { + defaults: { + model: { primary: "anthropic/claude-opus-4-6" }, + models: { "anthropic/claude-opus-4-6": {} }, + }, + }, + }; + mocks.repairCodexRuntimePluginInstallForModelSelection.mockResolvedValue({ + warnings: ["Non-ClawHub acknowledgement required."], + failed: true, + }); + + await expect( + modelsAuthLoginCommand({ provider: "openai", setDefault: true }, runtime), + ).rejects.toThrow("Authentication was saved successfully"); + + expect(lastUpdatedConfig?.agents?.defaults?.model).toEqual({ + primary: "anthropic/claude-opus-4-6", + }); + expect(mocks.upsertAuthProfileWithLock).toHaveBeenCalled(); + expect(runtime.error).toHaveBeenCalledWith("Non-ClawHub acknowledgement required."); + expect(runtime.log).not.toHaveBeenCalledWith("Default model set to openai/gpt-5.5"); + }); + it("survives lockout clearing failure without blocking login", async () => { const runtime = createRuntime(); mocks.loadAuthProfileStoreForRuntime.mockImplementation(() => { diff --git a/src/commands/models/auth.ts b/src/commands/models/auth.ts index 3147b493ec0c..f464a5053c37 100644 --- a/src/commands/models/auth.ts +++ b/src/commands/models/auth.ts @@ -37,6 +37,10 @@ import { normalizeProviderId } from "../../agents/model-selection-normalize.js"; import { resolveProviderIdForAuth } from "../../agents/provider-auth-aliases.js"; import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js"; import { formatCliCommand } from "../../cli/command-format.js"; +import { + formatNonClawHubInstallWarning, + type NonClawHubInstallAcknowledgementRequest, +} from "../../cli/non-clawhub-install-acknowledgement.js"; import { parseDurationMs } from "../../cli/parse-duration.js"; import { logConfigUpdated } from "../../config/logging.js"; import { normalizeAgentModelRefForConfig } from "../../config/model-input.js"; @@ -449,6 +453,7 @@ async function persistProviderAuthResult(params: { const shouldUpdateConfig = Boolean( params.result.configPatch || (params.setDefault && defaultModel), ); + let defaultRepairFailure: string | undefined; for (const profile of profiles) { const configuredSelection = resolveConfiguredAuthSelectionForProvider( @@ -473,7 +478,7 @@ async function persistProviderAuthResult(params: { // the provider explicitly returns a config patch or the user opts into a // default-model write. if (shouldUpdateConfig) { - const updated = await updateConfig((cfg) => { + const updated = await updateConfig(async (cfg) => { const priorAgentsDefaultsModel = cfg.agents?.defaults?.model; let next = cfg; if (params.result.configPatch) { @@ -487,11 +492,39 @@ async function persistProviderAuthResult(params: { setDefault: params.setDefault, }); if (params.setDefault && defaultModel) { - next = applyDefaultModel(next, defaultModel); + const candidate = applyDefaultModel(next, defaultModel); + const onNonClawHubInstall = ({ + sourceClass, + spec, + }: NonClawHubInstallAcknowledgementRequest) => + params.prompter.confirm({ + message: `${formatNonClawHubInstallWarning({ sourceClass, spec })}\nInstall this non-ClawHub runtime plugin source?`, + initialValue: false, + }); + const repaired = await repairCodexRuntimePluginInstallForModelSelection({ + cfg: candidate, + model: defaultModel, + onNonClawHubInstall, + }); + const copilotRepaired = await repairCopilotRuntimePluginInstallForModelSelection({ + cfg: candidate, + model: defaultModel, + onNonClawHubInstall, + }); + for (const warning of [...repaired.warnings, ...copilotRepaired.warnings]) { + params.runtime.error?.(warning); + } + if (repaired.failed || copilotRepaired.failed) { + defaultRepairFailure = + `Default model was not changed because the required runtime plugin was not installed for ${defaultModel}. ` + + "Authentication was saved successfully."; + return next; + } + next = candidate; } return next; }); - if (defaultModel) { + if (defaultModel && !params.setDefault) { const repaired = await repairCodexRuntimePluginInstallForModelSelection({ cfg: updated, model: defaultModel, @@ -514,6 +547,9 @@ async function persistProviderAuthResult(params: { `Auth profile: ${profile.profileId} (${profile.credential.provider}/${credentialMode(profile.credential)})`, ); } + if (defaultRepairFailure) { + throw new Error(defaultRepairFailure); + } if (defaultModel) { params.runtime.log( params.setDefault diff --git a/src/commands/models/set.test.ts b/src/commands/models/set.test.ts index 09afc8071ba2..05f0edb80969 100644 --- a/src/commands/models/set.test.ts +++ b/src/commands/models/set.test.ts @@ -45,8 +45,14 @@ describe("modelsSetCommand", () => { beforeEach(() => { vi.clearAllMocks(); mocks.replaceConfigFile.mockResolvedValue(undefined); - mocks.repairCodexRuntimePluginInstallForModelSelection.mockResolvedValue({ warnings: [] }); - mocks.repairCopilotRuntimePluginInstallForModelSelection.mockResolvedValue({ warnings: [] }); + mocks.repairCodexRuntimePluginInstallForModelSelection.mockResolvedValue({ + warnings: [], + failed: false, + }); + mocks.repairCopilotRuntimePluginInstallForModelSelection.mockResolvedValue({ + warnings: [], + failed: false, + }); }); afterEach(() => { @@ -92,17 +98,80 @@ describe("modelsSetCommand", () => { "anthropic/claude-sonnet-4-6": {}, }); expect(replaceParams?.nextConfig.agents?.defaults?.models).not.toHaveProperty("openai/sonnet"); - expect(mocks.repairCodexRuntimePluginInstallForModelSelection).toHaveBeenCalledWith({ - cfg: replaceParams?.nextConfig, - model: "anthropic/claude-sonnet-4-6", - }); - expect(mocks.repairCopilotRuntimePluginInstallForModelSelection).toHaveBeenCalledWith({ - cfg: replaceParams?.nextConfig, - model: "anthropic/claude-sonnet-4-6", - }); + expect(mocks.repairCodexRuntimePluginInstallForModelSelection).toHaveBeenCalledWith( + expect.objectContaining({ + cfg: replaceParams?.nextConfig, + model: "anthropic/claude-sonnet-4-6", + }), + ); + expect(mocks.repairCopilotRuntimePluginInstallForModelSelection).toHaveBeenCalledWith( + expect.objectContaining({ + cfg: replaceParams?.nextConfig, + model: "anthropic/claude-sonnet-4-6", + }), + ); expect(runtime.log).toHaveBeenCalledWith("Default model: anthropic/claude-sonnet-4-6"); }); + it("does not write an unusable default when runtime plugin repair is refused", async () => { + const config = { + agents: { + defaults: { + model: { primary: "anthropic/claude-sonnet-4-6" }, + models: { "anthropic/claude-sonnet-4-6": {} }, + }, + }, + } as unknown as OpenClawConfig; + mocks.readConfigFileSnapshot.mockResolvedValue({ + valid: true, + hash: "config-hash", + sourceConfig: config, + runtimeConfig: config, + config, + }); + mocks.repairCodexRuntimePluginInstallForModelSelection.mockResolvedValue({ + warnings: ["Non-ClawHub acknowledgement required."], + failed: true, + }); + const runtime = makeRuntime(); + + await expect(modelsSetCommand("openai/gpt-5.5", runtime)).rejects.toThrow( + "Default model was not changed", + ); + + expect(mocks.replaceConfigFile).not.toHaveBeenCalled(); + expect(runtime.error).toHaveBeenCalledWith("Non-ClawHub acknowledgement required."); + expect(mocks.logConfigUpdated).not.toHaveBeenCalled(); + }); + + it("forwards explicit non-ClawHub acknowledgement for noninteractive runtime repair", async () => { + const config = { + agents: { + defaults: { + model: { primary: "anthropic/claude-sonnet-4-6" }, + }, + }, + } as unknown as OpenClawConfig; + mocks.readConfigFileSnapshot.mockResolvedValue({ + valid: true, + hash: "config-hash", + sourceConfig: config, + runtimeConfig: config, + config, + }); + + await modelsSetCommand("openai/gpt-5.5", makeRuntime(), { + acknowledgeNonClawHubInstall: true, + }); + + expect(mocks.repairCodexRuntimePluginInstallForModelSelection).toHaveBeenCalledWith( + expect.objectContaining({ acknowledgeNonClawHubInstall: true }), + ); + expect(mocks.repairCopilotRuntimePluginInstallForModelSelection).toHaveBeenCalledWith( + expect.objectContaining({ acknowledgeNonClawHubInstall: true }), + ); + }); + it("keeps authored aliases ahead of runtime-only aliases", async () => { const sourceConfig = { agents: { @@ -142,14 +211,18 @@ describe("modelsSetCommand", () => { expect(replaceParams?.nextConfig.agents?.defaults?.models).toEqual({ "openai/gpt-5.5": { alias: "sonnet" }, }); - expect(mocks.repairCodexRuntimePluginInstallForModelSelection).toHaveBeenCalledWith({ - cfg: replaceParams?.nextConfig, - model: "openai/gpt-5.5", - }); - expect(mocks.repairCopilotRuntimePluginInstallForModelSelection).toHaveBeenCalledWith({ - cfg: replaceParams?.nextConfig, - model: "openai/gpt-5.5", - }); + expect(mocks.repairCodexRuntimePluginInstallForModelSelection).toHaveBeenCalledWith( + expect.objectContaining({ + cfg: replaceParams?.nextConfig, + model: "openai/gpt-5.5", + }), + ); + expect(mocks.repairCopilotRuntimePluginInstallForModelSelection).toHaveBeenCalledWith( + expect.objectContaining({ + cfg: replaceParams?.nextConfig, + model: "openai/gpt-5.5", + }), + ); expect(runtime.log).toHaveBeenCalledWith("Default model: openai/gpt-5.5"); }); @@ -182,14 +255,18 @@ describe("modelsSetCommand", () => { expect(replaceParams?.nextConfig.agents?.defaults?.models).toEqual({ "zai/glm-4.7": {}, }); - expect(mocks.repairCodexRuntimePluginInstallForModelSelection).toHaveBeenCalledWith({ - cfg: replaceParams?.nextConfig, - model: "zai/glm-4.7", - }); - expect(mocks.repairCopilotRuntimePluginInstallForModelSelection).toHaveBeenCalledWith({ - cfg: replaceParams?.nextConfig, - model: "zai/glm-4.7", - }); + expect(mocks.repairCodexRuntimePluginInstallForModelSelection).toHaveBeenCalledWith( + expect.objectContaining({ + cfg: replaceParams?.nextConfig, + model: "zai/glm-4.7", + }), + ); + expect(mocks.repairCopilotRuntimePluginInstallForModelSelection).toHaveBeenCalledWith( + expect.objectContaining({ + cfg: replaceParams?.nextConfig, + model: "zai/glm-4.7", + }), + ); expect(runtime.log).toHaveBeenCalledWith("Default model: zai/glm-4.7"); }); }); diff --git a/src/commands/models/set.ts b/src/commands/models/set.ts index 82c200f1759d..93498df6e54e 100644 --- a/src/commands/models/set.ts +++ b/src/commands/models/set.ts @@ -1,4 +1,8 @@ /** Command for setting the default text model. */ +import { + confirmNonClawHubInstall, + type NonClawHubInstallAcknowledgementRequest, +} from "../../cli/non-clawhub-install-acknowledgement.js"; import { logConfigUpdated } from "../../config/logging.js"; import { resolveAgentModelPrimaryValue } from "../../config/model-input.js"; import type { RuntimeEnv } from "../../runtime.js"; @@ -6,29 +10,51 @@ import { repairCodexRuntimePluginInstallForModelSelection } from "../codex-runti import { repairCopilotRuntimePluginInstallForModelSelection } from "../copilot-runtime-plugin-install.js"; import { applyDefaultModelPrimaryUpdate, updateConfig } from "./shared.js"; +export type ModelsSetOptions = { + acknowledgeNonClawHubInstall?: boolean; +}; + /** Sets agents.defaults.model.primary and repairs provider runtime plugin installs when needed. */ -export async function modelsSetCommand(modelRaw: string, runtime: RuntimeEnv) { - const updated = await updateConfig((cfg, context) => { - return applyDefaultModelPrimaryUpdate({ +export async function modelsSetCommand( + modelRaw: string, + runtime: RuntimeEnv, + opts: ModelsSetOptions = {}, +) { + let selectedModel = modelRaw; + await updateConfig(async (cfg, context) => { + const next = applyDefaultModelPrimaryUpdate({ cfg, resolveCfg: context.runtimeConfig, modelRaw, field: "model", }); + selectedModel = resolveAgentModelPrimaryValue(next.agents?.defaults?.model) ?? modelRaw; + const onNonClawHubInstall = ({ sourceClass, spec }: NonClawHubInstallAcknowledgementRequest) => + confirmNonClawHubInstall({ runtime, sourceClass, spec }); + const acknowledgement = + opts.acknowledgeNonClawHubInstall === true + ? { acknowledgeNonClawHubInstall: true as const } + : { onNonClawHubInstall }; + const repaired = await repairCodexRuntimePluginInstallForModelSelection({ + cfg: next, + model: selectedModel, + ...acknowledgement, + }); + const copilotRepaired = await repairCopilotRuntimePluginInstallForModelSelection({ + cfg: next, + model: selectedModel, + ...acknowledgement, + }); + for (const warning of [...repaired.warnings, ...copilotRepaired.warnings]) { + runtime.error?.(warning); + } + if (repaired.failed || copilotRepaired.failed) { + throw new Error( + `Default model was not changed because the required runtime plugin was not installed for ${selectedModel}.`, + ); + } + return next; }); - const selectedModel = resolveAgentModelPrimaryValue(updated.agents?.defaults?.model) ?? modelRaw; - const repaired = await repairCodexRuntimePluginInstallForModelSelection({ - cfg: updated, - model: selectedModel, - }); - const copilotRepaired = await repairCopilotRuntimePluginInstallForModelSelection({ - cfg: updated, - model: selectedModel, - }); - const warnings = [...repaired.warnings, ...copilotRepaired.warnings]; - for (const warning of warnings) { - runtime.error?.(warning); - } logConfigUpdated(runtime); runtime.log(`Default model: ${selectedModel}`); diff --git a/src/commands/models/shared.ts b/src/commands/models/shared.ts index aff7b24b109b..b2a44be51eb0 100644 --- a/src/commands/models/shared.ts +++ b/src/commands/models/shared.ts @@ -70,7 +70,10 @@ export type UpdateConfigContext = { /** Reads source config, applies a mutator, and writes only the source-form config. */ export async function updateConfig( - mutator: (cfg: OpenClawConfig, context: UpdateConfigContext) => OpenClawConfig, + mutator: ( + cfg: OpenClawConfig, + context: UpdateConfigContext, + ) => OpenClawConfig | Promise, ): Promise { const snapshot = await readConfigFileSnapshot(); if (!snapshot.valid) { @@ -81,7 +84,7 @@ export async function updateConfig( const runtimeConfig = structuredClone(snapshot.runtimeConfig ?? snapshot.config); // Mutate source config so SecretRefs and unresolved placeholders do not get // overwritten by runtime-resolved secret values. - const next = mutator(sourceConfig, { runtimeConfig }); + const next = await mutator(sourceConfig, { runtimeConfig }); await replaceConfigFile({ nextConfig: next, baseHash: snapshot.hash, diff --git a/src/commands/onboard-guided.test.ts b/src/commands/onboard-guided.test.ts index 114ed6bca47d..dd0e8ad8876f 100644 --- a/src/commands/onboard-guided.test.ts +++ b/src/commands/onboard-guided.test.ts @@ -202,9 +202,11 @@ describe("runGuidedOnboarding", () => { }); it("falls through after an auth failure and surfaces both outcomes", async () => { + const confirm = vi.fn().mockResolvedValueOnce(true).mockResolvedValueOnce(false); const prompter = createWizardPrompter({ text: vi.fn(async () => "/tmp/work"), - confirm: vi.fn(async () => false), + select: vi.fn(async () => "candidate:codex-cli") as unknown as WizardPrompter["select"], + confirm, }); const activate = vi .fn() @@ -228,10 +230,82 @@ describe("runGuidedOnboarding", () => { await runGuidedOnboarding({ acceptRisk: true }, makeRuntime(), deps); expect(activate).toHaveBeenCalledTimes(2); + expect(activate).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + kind: "codex-cli", + acknowledgeNonClawHubInstall: true, + }), + ); const notes = JSON.stringify((prompter.note as ReturnType).mock.calls); expect(notes).toContain("Claude Code"); expect(notes).toContain("Authentication failed"); expect(notes).toContain("Gateway: running"); + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("outside ClawHub review and trust metadata"), + initialValue: false, + }), + ); + }); + + it("does not treat manual Codex selection as provenance consent", async () => { + const select = vi + .fn() + .mockResolvedValueOnce("candidate:codex-cli") + .mockResolvedValueOnce("action:skip") as unknown as WizardPrompter["select"]; + const prompter = createWizardPrompter({ + text: vi.fn(async () => "/tmp/work"), + select, + confirm: vi.fn(async () => false), + }); + const applySetup = vi.fn>(async () => ({ + configPath: "/tmp/config", + lines: ["Workspace"], + })); + const deps = setupDeps({ + prompter, + detect: vi.fn(async () => detection({ candidates: [candidate("codex-cli", "Codex")] })), + applySetup, + }); + + await runGuidedOnboarding({ acceptRisk: true }, makeRuntime(), deps); + + expect(deps.activate).not.toHaveBeenCalled(); + expect(applySetup).toHaveBeenCalledOnce(); + }); + + it("auto-tests Codex only when its plugin source was pre-acknowledged", async () => { + const select = vi.fn(async () => "unexpected") as unknown as WizardPrompter["select"]; + const prompter = createWizardPrompter({ + text: vi.fn(async () => "/tmp/work"), + select, + confirm: vi.fn(async () => false), + }); + const deps = setupDeps({ + prompter, + detect: vi.fn(async () => detection({ candidates: [candidate("codex-cli", "Codex")] })), + activate: vi.fn(async () => ({ + ok: true as const, + modelRef: "openai/gpt-5.5", + latencyMs: 900, + lines: ["Gateway: running"], + })), + }); + + await runGuidedOnboarding( + { acceptRisk: true, acknowledgeNonClawHubInstall: true }, + makeRuntime(), + deps, + ); + + expect(deps.activate).toHaveBeenCalledWith( + expect.objectContaining({ + kind: "codex-cli", + acknowledgeNonClawHubInstall: true, + }), + ); + expect(select).not.toHaveBeenCalled(); }); it("offers an auto-attempted transient failure for manual retry", async () => { diff --git a/src/commands/onboard-guided.ts b/src/commands/onboard-guided.ts index 2cc7c9de2547..8bbe47b6f4bf 100644 --- a/src/commands/onboard-guided.ts +++ b/src/commands/onboard-guided.ts @@ -1,4 +1,5 @@ // Guided onboarding: detect AI access, live-test it, then persist only a working route. +import { formatNonClawHubInstallWarning } from "../cli/non-clawhub-install-acknowledgement.js"; import type { CrestodianSetupApplyParams, CrestodianSetupApplyResult, @@ -106,6 +107,7 @@ async function tryCandidate(params: { runtime: RuntimeEnv; prompter: WizardPrompter; activate: ActivateSetupInference; + acknowledgeNonClawHubInstall?: boolean; }): Promise { const progress = params.prompter.progress( t("wizard.guided.testingCandidate", { @@ -119,6 +121,9 @@ async function tryCandidate(params: { workspace: params.workspace, surface: "cli", runtime: params.runtime, + ...(params.acknowledgeNonClawHubInstall === true + ? { acknowledgeNonClawHubInstall: true } + : {}), }), ); progress.stop(result.ok ? t("wizard.guided.testPassed") : t("wizard.guided.testFailed")); @@ -214,12 +219,27 @@ async function runManualStage(params: { if (!candidate) { continue; } + let acknowledgeNonClawHubInstall = params.opts.acknowledgeNonClawHubInstall === true; + if (candidate.kind === "codex-cli" && !acknowledgeNonClawHubInstall) { + const { CODEX_RUNTIME_PLUGIN_NPM_SPEC } = await import("./codex-runtime-plugin-install.js"); + acknowledgeNonClawHubInstall = await params.prompter.confirm({ + message: `${formatNonClawHubInstallWarning({ + sourceClass: "npm", + spec: CODEX_RUNTIME_PLUGIN_NPM_SPEC, + })}\nInstall this runtime plugin and test Codex now?`, + initialValue: false, + }); + if (!acknowledgeNonClawHubInstall) { + continue; + } + } const attempt = await tryCandidate({ candidate, workspace: params.workspace, runtime: params.runtime, prompter: params.prompter, activate: params.activate, + ...(acknowledgeNonClawHubInstall ? { acknowledgeNonClawHubInstall: true } : {}), }); if (attempt.kind === "success") { return { kind: "complete", lines: activationLines(attempt.result) }; @@ -333,10 +353,23 @@ async function runGuidedOnboardingFlow( const autoAttemptedKinds = new Set(); let result: GuidedSetupResult | undefined; // Logged-out CLIs stay visible as manual choices, but auto-testing them would - // only produce predictable auth failures and slow the fallback ladder. - for (const candidate of detection.candidates.filter((item) => item.credentials !== false)) { + // only produce predictable auth failures and slow the fallback ladder. Codex + // joins the automatic ladder only when the caller explicitly acknowledged + // its non-ClawHub runtime plugin source. + for (const candidate of detection.candidates.filter( + (item) => + item.credentials !== false && + (item.kind !== "codex-cli" || opts.acknowledgeNonClawHubInstall === true), + )) { autoAttemptedKinds.add(candidate.kind); - const attempt = await tryCandidate({ candidate, workspace, runtime, prompter, activate }); + const attempt = await tryCandidate({ + candidate, + workspace, + runtime, + prompter, + activate, + ...(candidate.kind === "codex-cli" ? { acknowledgeNonClawHubInstall: true } : {}), + }); if (attempt.kind === "success") { result = { kind: "complete", lines: activationLines(attempt.result) }; break; diff --git a/src/commands/onboard.test.ts b/src/commands/onboard.test.ts index 908e813a7c3e..70257039f31b 100644 --- a/src/commands/onboard.test.ts +++ b/src/commands/onboard.test.ts @@ -196,6 +196,19 @@ describe("setupWizardCommand", () => { expect(mocks.runNonInteractiveSetup).not.toHaveBeenCalled(); }); + it("keeps non-ClawHub acknowledgement on the guided flow", async () => { + const runtime = makeRuntime(); + + await setupWizardCommand({ acknowledgeNonClawHubInstall: true }, runtime); + + expect(mocks.runGuidedOnboarding).toHaveBeenCalledWith( + { acknowledgeNonClawHubInstall: true }, + runtime, + ); + expect(mocks.runInteractiveSetup).not.toHaveBeenCalled(); + expect(mocks.runNonInteractiveSetup).not.toHaveBeenCalled(); + }); + it.each([ ["--classic", { classic: true }], ["--flow quickstart", { flow: "quickstart" as const }], diff --git a/src/commands/onboard.ts b/src/commands/onboard.ts index 9b22977e59ad..3beb025503b7 100644 --- a/src/commands/onboard.ts +++ b/src/commands/onboard.ts @@ -40,6 +40,7 @@ const GUIDED_SAFE_ONBOARD_KEYS = new Set([ "resetScope", "nonInteractive", "classic", + "acknowledgeNonClawHubInstall", ]); function wantsClassicInteractiveSetup(opts: OnboardOptions): boolean { diff --git a/src/commands/onboarding-plugin-install.test.ts b/src/commands/onboarding-plugin-install.test.ts index 55a4eda0a965..8ec580a38779 100644 --- a/src/commands/onboarding-plugin-install.test.ts +++ b/src/commands/onboarding-plugin-install.test.ts @@ -8,6 +8,7 @@ import type { PluginEnableResult } from "../plugins/enable.js"; import { resolveNpmInstallSpecsForUpdateChannel } from "../plugins/install-channel-specs.js"; import { withTempDir } from "../test-helpers/temp-dir.js"; import { VERSION } from "../version.js"; +import { WizardCancelledError } from "../wizard/prompts.js"; function expectedNpmInstallSpec(spec: string): string { return resolveNpmInstallSpecsForUpdateChannel({ @@ -1539,7 +1540,7 @@ describe("ensureOnboardingPluginInstalled", () => { it("blocks local setup installs until non-ClawHub source acknowledgement is available", async () => { await withTempDir({ prefix: "openclaw-onboarding-install-local-ack-" }, async (temp) => { const workspaceDir = path.join(temp, "workspace"); - const pluginDir = path.join(workspaceDir, "plugins", "demo"); + const pluginDir = path.join(workspaceDir, "plugins", "demo; package"); await fs.mkdir(path.join(workspaceDir, ".git"), { recursive: true }); await fs.mkdir(pluginDir, { recursive: true }); const log = vi.fn(); @@ -1551,7 +1552,7 @@ describe("ensureOnboardingPluginInstalled", () => { pluginId: "demo-plugin", label: "Demo Plugin", install: { - localPath: "plugins/demo", + localPath: "plugins/demo; package", }, }, prompter: { @@ -1572,13 +1573,47 @@ describe("ensureOnboardingPluginInstalled", () => { expect.stringContaining("Installing plugin from local path"), ); expect(error).toHaveBeenCalledWith( - expect.stringContaining("--acknowledge-non-clawhub-install"), + expect.stringContaining( + `openclaw plugins install '${await fs.realpath(pluginDir)}' --acknowledge-non-clawhub-install`, + ), ); expect(enablePluginInConfig).not.toHaveBeenCalled(); expect(recordPluginInstall).not.toHaveBeenCalled(); }); }); + it("propagates cancellation from the non-ClawHub acknowledgement prompt", async () => { + await withTempDir({ prefix: "openclaw-onboarding-install-local-cancel-" }, async (temp) => { + const workspaceDir = path.join(temp, "workspace"); + const pluginDir = path.join(workspaceDir, "plugins", "demo"); + await fs.mkdir(path.join(workspaceDir, ".git"), { recursive: true }); + await fs.mkdir(pluginDir, { recursive: true }); + const cancellation = new WizardCancelledError(); + + await expect( + ensureOnboardingPluginInstalled({ + cfg: {}, + entry: { + pluginId: "demo-plugin", + label: "Demo Plugin", + install: { localPath: "plugins/demo" }, + }, + prompter: { + select: vi.fn(async () => "local"), + confirm: vi.fn(async () => { + throw cancellation; + }), + } as never, + runtime: { error: vi.fn(), log: vi.fn() } as never, + workspaceDir, + }), + ).rejects.toBe(cancellation); + + expect(enablePluginInConfig).not.toHaveBeenCalled(); + expect(recordPluginInstall).not.toHaveBeenCalled(); + }); + }); + it("hides the npm download option for bundled plugins so the menu matches non-npm channels", async () => { await withTempDir({ prefix: "openclaw-onboarding-install-bundled-prompt-" }, async (temp) => { const bundledDir = path.join(temp, "dist", "extensions", "tlon"); diff --git a/src/commands/onboarding-plugin-install.ts b/src/commands/onboarding-plugin-install.ts index 39c48e3fd39b..afa70b157437 100644 --- a/src/commands/onboarding-plugin-install.ts +++ b/src/commands/onboarding-plugin-install.ts @@ -15,6 +15,7 @@ import { } from "../cli/non-clawhub-install-acknowledgement.js"; import { resolveBundledInstallPlanForCatalogEntry } from "../cli/plugin-install-plan.js"; import { invalidatePluginRuntimeDiscoveryAfterConfigMutation } from "../cli/plugins-registry-refresh.js"; +import { quoteCliArg } from "../cli/quote-cli-arg.js"; import { assertConfigWriteAllowedInCurrentMode } from "../config/nix-mode-write-guard.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { parseClawHubPluginSpec } from "../infra/clawhub-spec.js"; @@ -58,7 +59,7 @@ import type { RuntimeEnv } from "../runtime.js"; import { withTimeout } from "../utils/with-timeout.js"; import { VERSION } from "../version.js"; import { t } from "../wizard/i18n/index.js"; -import type { WizardPrompter } from "../wizard/prompts.js"; +import { WizardCancelledError, type WizardPrompter } from "../wizard/prompts.js"; type InstallChoice = "clawhub" | "npm" | "local" | "skip"; type InstallPluginFromClawHubResult = Awaited< @@ -731,11 +732,15 @@ async function acknowledgeOnboardingNonClawHubSource(params: { initialValue: false, }); } catch (error) { + if (error instanceof WizardCancelledError) { + throw error; + } params.runtime.error?.(error instanceof Error ? error.message : String(error)); } if (!acknowledged) { + const installCommand = `openclaw plugins install ${quoteCliArg(params.installCommandSpec)} ${NON_CLAWHUB_INSTALL_ACK_FLAG}`; params.runtime.error?.( - `Install cancelled; install the plugin with ${sanitizeTerminalText(`openclaw plugins install ${params.installCommandSpec} ${NON_CLAWHUB_INSTALL_ACK_FLAG}`)} after reviewing the source, then rerun setup.`, + `Install cancelled; install the plugin with ${sanitizeTerminalText(installCommand)} after reviewing the source, then rerun setup.`, ); return false; } diff --git a/src/commands/promos/claim.test.ts b/src/commands/promos/claim.test.ts index 88018bdc9b8c..5e1e913b3793 100644 --- a/src/commands/promos/claim.test.ts +++ b/src/commands/promos/claim.test.ts @@ -150,8 +150,8 @@ beforeEach(() => { pluginId, })); mocks.fetchClawHubPromotion.mockResolvedValue(makePromotion()); - mocks.repairCodex.mockResolvedValue({ warnings: [] }); - mocks.repairCopilot.mockResolvedValue({ warnings: [] }); + mocks.repairCodex.mockResolvedValue({ warnings: [], failed: false }); + mocks.repairCopilot.mockResolvedValue({ warnings: [], failed: false }); }); afterEach(() => { @@ -183,15 +183,48 @@ describe("promosClaimCommand", () => { it("sets the suggested model as default with --set-default", async () => { const runtime = makeRuntime(); - await promosClaimCommand("spring-models", { setDefault: true }, runtime); + await promosClaimCommand( + "spring-models", + { setDefault: true, acknowledgeNonClawHubInstall: true }, + runtime, + ); const next = mocks.replaceConfigFile.mock.calls[0]?.[0]?.nextConfig; expect(next.agents.defaults.model.primary).toBe("openrouter/example/model-alpha"); // Default changes must run the same runtime plugin repair as `models set`. expect(mocks.repairCodex).toHaveBeenCalledWith( - expect.objectContaining({ model: "openrouter/example/model-alpha" }), + expect.objectContaining({ + model: "openrouter/example/model-alpha", + acknowledgeNonClawHubInstall: true, + }), + ); + expect(mocks.repairCopilot).toHaveBeenCalledWith( + expect.objectContaining({ acknowledgeNonClawHubInstall: true }), + ); + }); + + it("registers promo models without changing the default when runtime repair is refused", async () => { + mocks.repairCodex.mockResolvedValue({ + warnings: ["Non-ClawHub acknowledgement required."], + failed: true, + }); + const runtime = makeRuntime(); + + await promosClaimCommand( + "spring-models", + { setDefault: true, acknowledgeNonClawHubInstall: true }, + runtime, + ); + + const next = mocks.replaceConfigFile.mock.calls[0]?.[0]?.nextConfig; + expect(next.agents.defaults.models["openrouter/example/model-alpha"]).toEqual({ + alias: "model-alpha", + }); + expect(next.agents.defaults.model).toBeUndefined(); + expect(runtime.error).toHaveBeenCalledWith("Non-ClawHub acknowledgement required."); + expect(runtime.log).not.toHaveBeenCalledWith( + " Default model set to openrouter/example/model-alpha.", ); - expect(mocks.repairCopilot).toHaveBeenCalled(); }); it("skips aliases outside the models-aliases contract but still registers the model", async () => { @@ -428,10 +461,17 @@ describe("promosClaimCommand", () => { mocks.hasAvailableAuthForProvider.mockResolvedValue(true); mocks.applyAuthChoiceLoadedPluginProvider.mockResolvedValue({ config: {} }); - await promosClaimCommand("spring-models", {}, makeRuntime()); + await promosClaimCommand( + "spring-models", + { acknowledgeNonClawHubInstall: true }, + makeRuntime(), + ); expect(mocks.applyAuthChoiceLoadedPluginProvider).toHaveBeenCalledWith( - expect.objectContaining({ authChoice: "openrouter-api-key" }), + expect.objectContaining({ + authChoice: "openrouter-api-key", + opts: { acknowledgeNonClawHubInstall: true }, + }), ); }); diff --git a/src/commands/promos/claim.ts b/src/commands/promos/claim.ts index 357ac2c2447a..1a7f5a6ac9f2 100644 --- a/src/commands/promos/claim.ts +++ b/src/commands/promos/claim.ts @@ -2,6 +2,10 @@ import { sanitizeTerminalText } from "../../../packages/terminal-core/src/safe-text.js"; import { hasAvailableAuthForProvider } from "../../agents/model-auth.js"; import { formatCliCommand } from "../../cli/command-format.js"; +import { + confirmNonClawHubInstall, + type NonClawHubInstallAcknowledgementRequest, +} from "../../cli/non-clawhub-install-acknowledgement.js"; import { promptYesNo } from "../../cli/prompt.js"; import { readConfigFileSnapshot, replaceConfigFile } from "../../config/config.js"; import { formatConfigIssueLines } from "../../config/issue-format.js"; @@ -36,6 +40,7 @@ import { } from "../models/shared.js"; export type PromosClaimOptions = { + acknowledgeNonClawHubInstall?: boolean; apiKey?: string; setDefault?: boolean; }; @@ -256,13 +261,17 @@ async function ensureProviderAuth(params: { `Auth choice "${catalogEntry.choiceId}" does not accept --api-key; run without it to authenticate interactively.`, ); } + const providerOptions = { + ...(apiKey && catalogEntry.optionKey ? { [catalogEntry.optionKey]: apiKey } : {}), + ...(opts.acknowledgeNonClawHubInstall ? { acknowledgeNonClawHubInstall: true } : {}), + }; const applied = await applyAuthChoiceLoadedPluginProvider({ authChoice: catalogEntry.choiceId, config: structuredClone(snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig, prompter: createClackPrompter(), runtime, setDefaultModel: false, - opts: apiKey && catalogEntry.optionKey ? { [catalogEntry.optionKey]: apiKey } : undefined, + opts: Object.keys(providerOptions).length > 0 ? providerOptions : undefined, }); // The apply flow can return success-shaped results without usable auth // (cancelled retrySelection, disabled/unresolvable plugin). Revalidate @@ -328,7 +337,7 @@ export async function promosClaimCommand( const registered: string[] = []; const skippedAliases: string[] = []; const invalidAliases: string[] = []; - const updated = await updateConfig((cfg, context) => { + await updateConfig(async (cfg, context) => { let base = cfg; // The credential-reuse path skips the auth flow, which is where plugin // enablement normally happens. Enable (or refuse) the provider plugin here @@ -377,12 +386,35 @@ export async function promosClaimCommand( }, }; if (makeDefault && suggested) { - next = applyDefaultModelPrimaryUpdate({ + const candidate = applyDefaultModelPrimaryUpdate({ cfg: next, resolveCfg: context.runtimeConfig, modelRaw: suggested.modelRef, field: "model", }); + const onNonClawHubInstall = ({ + sourceClass, + spec, + }: NonClawHubInstallAcknowledgementRequest) => + confirmNonClawHubInstall({ runtime, sourceClass, spec }); + const repairOptions = { + cfg: candidate, + model: suggested.modelRef, + ...(opts.acknowledgeNonClawHubInstall + ? { acknowledgeNonClawHubInstall: true } + : { onNonClawHubInstall }), + }; + const repaired = await repairCodexRuntimePluginInstallForModelSelection(repairOptions); + const copilotRepaired = + await repairCopilotRuntimePluginInstallForModelSelection(repairOptions); + for (const warning of [...repaired.warnings, ...copilotRepaired.warnings]) { + runtime.error?.(warning); + } + if (repaired.failed || copilotRepaired.failed) { + makeDefault = false; + } else { + next = candidate; + } } return next; }); @@ -399,23 +431,6 @@ export async function promosClaimCommand( }); markPromotionSlugsNotified([promotion.slug]); - if (makeDefault && suggested) { - // `models set` repairs provider runtime plugin installs (Codex/Copilot) - // after a default change; a promo-selected default needs the same repair - // or an openai/* default can fail at execution time. - const repaired = await repairCodexRuntimePluginInstallForModelSelection({ - cfg: updated, - model: suggested.modelRef, - }); - const copilotRepaired = await repairCopilotRuntimePluginInstallForModelSelection({ - cfg: updated, - model: suggested.modelRef, - }); - for (const warning of [...repaired.warnings, ...copilotRepaired.warnings]) { - runtime.error?.(warning); - } - } - runtime.log(`Claimed "${sanitizeTerminalText(promotion.title)}".`); for (const key of registered) { runtime.log(` Added model: ${sanitizeTerminalText(key)}`); diff --git a/src/commands/runtime-plugin-install.ts b/src/commands/runtime-plugin-install.ts index 2cb5f4c4fd19..970c9200046c 100644 --- a/src/commands/runtime-plugin-install.ts +++ b/src/commands/runtime-plugin-install.ts @@ -6,6 +6,10 @@ */ import { existsSync } from "node:fs"; import path from "node:path"; +import { + formatNonClawHubInstallWarning, + type NonClawHubInstallAcknowledgementRequest, +} from "../cli/non-clawhub-install-acknowledgement.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import { enablePluginInConfig } from "../plugins/enable.js"; @@ -55,14 +59,23 @@ export type RuntimePluginRepairParams = { model?: string; agentId?: string; env?: NodeJS.ProcessEnv; + acknowledgeNonClawHubInstall?: boolean; + onNonClawHubInstall?: ( + request: NonClawHubInstallAcknowledgementRequest, + ) => boolean | Promise; +}; + +export type RuntimePluginRepairResult = { + required: boolean; + changes: string[]; + warnings: string[]; + failed: boolean; }; /** Convenience helpers bound to one runtime plugin descriptor. */ export type RuntimePluginModelSelectionHelpers = { ensure: (params: RuntimePluginEnsureParams) => Promise; - repair: ( - params: RuntimePluginRepairParams, - ) => Promise<{ required: boolean; changes: string[]; warnings: string[] }>; + repair: (params: RuntimePluginRepairParams) => Promise; }; function isInstalledRecordPresentOnDisk( @@ -105,11 +118,19 @@ async function ensureRuntimePluginForModelSelection(params: { if (isInstalledRecordPresentOnDisk(existingRecords[params.descriptor.pluginId], process.env)) { // A recorded install with package.json on disk can be repaired/enabled // without re-downloading the plugin during setup. + const onNonClawHubInstall = ({ sourceClass, spec }: NonClawHubInstallAcknowledgementRequest) => + params.prompter.confirm({ + message: `${formatNonClawHubInstallWarning({ sourceClass, spec })}\nRepair this non-ClawHub runtime plugin source?`, + initialValue: false, + }); const repair = await repairRuntimePluginInstallForModelSelection({ cfg: params.cfg, model: params.model, agentId: params.agentId, env: process.env, + ...(params.acknowledgeNonClawHubInstall + ? { acknowledgeNonClawHubInstall: true } + : { onNonClawHubInstall }), descriptor: params.descriptor, shouldEnsure: params.shouldEnsure, }); @@ -119,6 +140,15 @@ async function ensureRuntimePluginForModelSelection(params: { for (const warning of repair.warnings) { params.runtime.log?.(`${params.descriptor.warningLabel} update warning: ${warning}`); } + if (repair.failed) { + return { + cfg: params.cfg, + required: true, + installed: false, + status: "failed", + reason: repair.warnings.join("; ") || "runtime plugin repair failed", + }; + } const enableResult = enablePluginInConfig(params.cfg, params.descriptor.pluginId); return { cfg: enableResult.config, @@ -164,9 +194,13 @@ async function repairRuntimePluginInstallForModelSelection(params: { model?: string; agentId?: string; env?: NodeJS.ProcessEnv; + acknowledgeNonClawHubInstall?: boolean; + onNonClawHubInstall?: ( + request: NonClawHubInstallAcknowledgementRequest, + ) => boolean | Promise; descriptor: RuntimePluginInstallDescriptor; shouldEnsure: RuntimePluginSelection; -}): Promise<{ required: boolean; changes: string[]; warnings: string[] }> { +}): Promise { if ( !params.shouldEnsure({ cfg: params.cfg, @@ -174,7 +208,7 @@ async function repairRuntimePluginInstallForModelSelection(params: { agentId: params.agentId, }) ) { - return { required: false, changes: [], warnings: [] }; + return { required: false, changes: [], warnings: [], failed: false }; } const { repairMissingPluginInstallsForIds } = await import("./doctor/shared/missing-configured-plugin-install.js"); @@ -182,11 +216,14 @@ async function repairRuntimePluginInstallForModelSelection(params: { cfg: params.cfg, pluginIds: [params.descriptor.pluginId], ...(params.env !== undefined ? { env: params.env } : {}), + ...(params.acknowledgeNonClawHubInstall ? { acknowledgeNonClawHubInstall: true } : {}), + ...(params.onNonClawHubInstall ? { onNonClawHubInstall: params.onNonClawHubInstall } : {}), }); return { required: true, changes: result.changes, warnings: [...result.warnings, ...(result.notices ?? [])], + failed: result.failedPluginIds?.includes(params.descriptor.pluginId) === true, }; } diff --git a/src/commands/status-all/channels.test.ts b/src/commands/status-all/channels.test.ts index 8adffd229465..4f0c8a13a752 100644 --- a/src/commands/status-all/channels.test.ts +++ b/src/commands/status-all/channels.test.ts @@ -50,9 +50,9 @@ vi.mock("../../plugins/official-external-plugin-repair-hints.js", () => ({ installSpec: "@openclaw/feishu", installCommand: "openclaw plugins install @openclaw/feishu --acknowledge-non-clawhub-install", - doctorFixCommand: "openclaw doctor --fix", + doctorFixCommand: "openclaw doctor --fix --acknowledge-non-clawhub-install", repairHint: - "Install the official external plugin with: openclaw plugins install @openclaw/feishu --acknowledge-non-clawhub-install, or run: openclaw doctor --fix.", + "Install the official external plugin with: openclaw plugins install @openclaw/feishu --acknowledge-non-clawhub-install, or run: openclaw doctor --fix --acknowledge-non-clawhub-install.", } : null, })); @@ -137,7 +137,7 @@ describe("buildChannelsTable", () => { enabled: true, state: "warn", detail: - "plugin not installed - run openclaw plugins install @openclaw/feishu --acknowledge-non-clawhub-install or openclaw doctor --fix", + "plugin not installed - run openclaw plugins install @openclaw/feishu --acknowledge-non-clawhub-install or openclaw doctor --fix --acknowledge-non-clawhub-install", }, ], details: [], diff --git a/src/crestodian/agent-turn.ts b/src/crestodian/agent-turn.ts index 6224d18adfe4..9c6589c62fdf 100644 --- a/src/crestodian/agent-turn.ts +++ b/src/crestodian/agent-turn.ts @@ -43,8 +43,8 @@ export type CrestodianAgentTurnRunner = (params: { export type CrestodianAgentSession = { sessionId: string; - /** Host-owned pending-proposal fingerprint; see crestodian-tool.ts. */ - proposalRef: { current?: string }; + /** Host-owned pending proposal and render state; see crestodian-tool.ts. */ + proposalRef: import("../agents/tools/crestodian-tool.js").CrestodianToolProposalRef; /** Native CLI session id captured after CLI-harness turns for --resume reuse. */ cliSessionId?: string; }; @@ -171,7 +171,7 @@ async function planCrestodianAgentTurn( */ async function mirrorCrestodianToolStateFromEvents(params: { runId: string; - proposalRef: { current?: string }; + proposalRef: import("../agents/tools/crestodian-tool.js").CrestodianToolProposalRef; directiveRef: { current?: CrestodianAgentTurnDirective }; }): Promise<() => void> { const [ @@ -295,8 +295,8 @@ export async function runCrestodianAgentTurnWithDeps( : {}), })) as EmbeddedRunResult; } - const text = extractRunText(result)?.trim(); - if (!text) { + const text = extractRunText(result)?.trim() ?? ""; + if (!text && !params.session.proposalRef.current) { return null; } return { diff --git a/src/crestodian/chat-engine.test.ts b/src/crestodian/chat-engine.test.ts index 931050ccd9d1..4c8769cbcb2c 100644 --- a/src/crestodian/chat-engine.test.ts +++ b/src/crestodian/chat-engine.test.ts @@ -154,11 +154,21 @@ describe("CrestodianChatEngine", () => { }); it("voids an agent-loop proposal on decline and lets the AI acknowledge", async () => { - let observedProposalOnSecondTurn: string | undefined = "sentinel"; + let observedProposalOnSecondTurn: unknown = "sentinel"; const runAgentTurn = vi.fn( - async (params: { session: { proposalRef: { current?: string } } }) => { + async (params: { + session: { + proposalRef: { + current?: { operationHash: string; plan: string; renderedByHost: boolean }; + }; + }; + }) => { if (runAgentTurn.mock.calls.length === 1) { - params.session.proposalRef.current = "registered-operation"; + params.session.proposalRef.current = { + operationHash: "registered-operation", + plan: "Change the model.", + renderedByHost: false, + }; return { text: "I can change that after your approval." }; } observedProposalOnSecondTurn = params.session.proposalRef.current; @@ -395,10 +405,18 @@ describe("CrestodianChatEngine", () => { const runAgentTurn = vi.fn( async (params: { approvalArmed: boolean; - session: { proposalRef: { current?: string } }; + session: { + proposalRef: { + current?: { operationHash: string; plan: string; renderedByHost: boolean }; + }; + }; }) => { armedFlags.push(params.approvalArmed); - params.session.proposalRef.current = "op-hash"; + params.session.proposalRef.current = { + operationHash: "op-hash", + plan: "Set the default model.", + renderedByHost: false, + }; return { text: "ok" }; }, ); @@ -415,6 +433,29 @@ describe("CrestodianChatEngine", () => { expect(armedFlags).toEqual([false, true]); }); + it("renders an agent-loop provenance warning from host-owned proposal state", async () => { + const engine = new CrestodianChatEngine({ + runAgentTurn: async (params) => { + params.session.proposalRef.current = { + operationHash: "plugin-install", + plan: [ + "Install plugin from npm:@example/plugin.", + "WARNING - Installing a plugin outside ClawHub review and trust metadata.", + ].join("\n"), + renderedByHost: false, + }; + return { text: "I can take care of that." }; + }, + deps: { loadOverview: fakeOverviewLoader() }, + }); + + const reply = await engine.handle("install npm:@example/plugin"); + + expect(reply.text).toContain("I can take care of that."); + expect(reply.text).toContain("outside ClawHub review and trust metadata"); + expect(reply.text).toContain("Reply yes to approve this exact action"); + }); + it("clears a stale host proposal once the agent loop owns the conversation", async () => { const engine = new CrestodianChatEngine({ runAgentTurn: async () => ({ text: "loop reply" }), @@ -645,6 +686,23 @@ describe("CrestodianChatEngine", () => { expect(reply.text).toContain("deterministic mode"); expect(reply.text).toContain("connect telegram"); }); + + it("returns deterministic plugin install validation errors without arming approval", async () => { + const engine = new CrestodianChatEngine({ + runAgentTurn: async () => null, + planWithAssistant: async () => null, + deps: { loadOverview: fakeOverviewLoader() }, + }); + + const reply = await engine.handle("plugin install git:https://github.com/acme/demo.git"); + + expect(reply.text).toContain( + "Crestodian plugin install accepts npm or ClawHub package specs only.", + ); + expect(reply.text).not.toContain("Apply this operation"); + expect(reply.action).toBe("none"); + expect(engine.hasPendingProposal()).toBe(false); + }); }); describe("Crestodian agent loop backends", () => { diff --git a/src/crestodian/chat-engine.ts b/src/crestodian/chat-engine.ts index b620c1b0bbd2..05149c8f0dc8 100644 --- a/src/crestodian/chat-engine.ts +++ b/src/crestodian/chat-engine.ts @@ -44,6 +44,7 @@ import { loadCrestodianOverview, type CrestodianOverview } from "./overview.js"; */ export type CrestodianChatEngineOptions = { yes?: boolean; + acknowledgeNonClawHubInstall?: boolean; deps?: CrestodianCommandDeps; planWithAssistant?: CrestodianAssistantPlanner; /** Test seam for the embedded agent-loop turn runner. */ @@ -468,6 +469,7 @@ export class CrestodianChatEngine { try { result = await executeCrestodianOperation(pending, capture, { approved: true, + acknowledgeNonClawHubInstall: true, deps: this.commandDeps(), }); } catch (error) { @@ -516,7 +518,7 @@ export class CrestodianChatEngine { null, AGENT_TURN_DEADLINE_MS, ); - if (loopReply?.text) { + if (loopReply && (loopReply.text || this.agentSession.proposalRef.current)) { // The loop owns the conversation now. A stale engine-side proposal // must not survive it, or a later approval could apply an operation // the user was no longer looking at. @@ -568,10 +570,21 @@ export class CrestodianChatEngine { text: string; directive?: import("./agent-turn.js").CrestodianAgentTurnDirective; }): Promise { + const proposal = this.agentSession.proposalRef.current; + const hostRenderedPlan = + proposal && !proposal.renderedByHost + ? `${proposal.plan}\n\nReply yes to approve this exact action, or no to cancel.` + : ""; + if (proposal) { + // Approval is armable only after the host, not the model, has rendered + // the exact plan and any provenance warning to the user. + proposal.renderedByHost = true; + } + const replyText = [loopReply.text, hostRenderedPlan].filter(Boolean).join("\n\n"); if (loopReply.directive?.kind === "channel-setup") { const wizardIntro = await this.startChannelSetupWizard(loopReply.directive.channel); return { - text: [loopReply.text, wizardIntro].filter(Boolean).join("\n\n"), + text: [replyText, wizardIntro].filter(Boolean).join("\n\n"), action: "none", }; } @@ -579,12 +592,12 @@ export class CrestodianChatEngine { const setup = await this.startModelSetup(loopReply.directive.workspace); return { ...setup, - text: [loopReply.text, setup.text].filter(Boolean).join("\n\n"), + text: [replyText, setup.text].filter(Boolean).join("\n\n"), }; } if (loopReply.directive?.kind === "open-tui") { return { - text: loopReply.text, + text: replyText, action: "open-tui", handoff: loopReply.directive, }; @@ -593,10 +606,10 @@ export class CrestodianChatEngine { const handoff = await this.runOperation(loopReply.directive, undefined); return { ...handoff, - text: [loopReply.text, handoff.text].filter(Boolean).join("\n\n"), + text: [replyText, handoff.text].filter(Boolean).join("\n\n"), }; } - return { text: loopReply.text, action: "none" }; + return { text: replyText, action: "none" }; } /** @@ -676,11 +689,19 @@ export class CrestodianChatEngine { const capture = createCaptureRuntime(); if (isPersistentCrestodianOperation(operation) && !this.opts.yes) { this.clearPendingProposals(); + try { + await executeCrestodianOperation(operation, capture, { + approved: false, + deps: this.commandDeps(), + }); + } catch (error) { + capture.error(formatOperationError(error)); + return { + text: [provenance, capture.read()].filter(Boolean).join("\n\n"), + action: "none", + }; + } this.pending = operation; - await executeCrestodianOperation(operation, capture, { - approved: false, - deps: this.commandDeps(), - }); return { text: [provenance, capture.read(), approvalQuestion(operation)] .filter(Boolean) @@ -693,6 +714,9 @@ export class CrestodianChatEngine { try { result = await executeCrestodianOperation(operation, capture, { approved: this.opts.yes === true || !isPersistentCrestodianOperation(operation), + ...(this.opts.acknowledgeNonClawHubInstall === true + ? { acknowledgeNonClawHubInstall: true } + : {}), deps: this.commandDeps(), }); } catch (error) { diff --git a/src/crestodian/crestodian.test.ts b/src/crestodian/crestodian.test.ts index acc5cc4c438e..f2bbfb8bca0f 100644 --- a/src/crestodian/crestodian.test.ts +++ b/src/crestodian/crestodian.test.ts @@ -1,5 +1,5 @@ // Crestodian tests cover main rescue and audit command behavior. -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { runCrestodian } from "./crestodian.js"; import { createCrestodianTestRuntime } from "./crestodian.test-helpers.js"; import type { CrestodianOverview } from "./overview.js"; @@ -33,6 +33,44 @@ const crestodianOverviewDeps = { }; describe("runCrestodian", () => { + it("does not let generic --yes acknowledge a non-ClawHub plugin source", async () => { + const { runtime, lines } = createCrestodianTestRuntime(); + const runPluginInstall = vi.fn(async () => {}); + + await runCrestodian( + { + message: "plugin install npm:@openclaw/demo", + yes: true, + deps: { runPluginInstall }, + ...crestodianOverviewDeps, + }, + runtime, + ); + + expect(runPluginInstall).not.toHaveBeenCalled(); + expect(lines.join("\n")).toContain("--acknowledge-non-clawhub-install"); + }); + + it("forwards explicit non-ClawHub acknowledgement for one-shot installs", async () => { + const { runtime } = createCrestodianTestRuntime(); + const runPluginInstall = vi.fn(async () => {}); + + await runCrestodian( + { + message: "plugin install npm:@openclaw/demo", + yes: true, + acknowledgeNonClawHubInstall: true, + deps: { runPluginInstall }, + ...crestodianOverviewDeps, + }, + runtime, + ); + + expect(runPluginInstall).toHaveBeenCalledWith("npm:@openclaw/demo", expect.any(Object), { + acknowledgeNonClawHubInstall: true, + }); + }); + it("uses the assistant planner only to choose typed operations", async () => { const { runtime, lines } = createCrestodianTestRuntime(); let runGatewayRestartCalls = 0; diff --git a/src/crestodian/crestodian.ts b/src/crestodian/crestodian.ts index ea0c1b8425c5..a8dc36334459 100644 --- a/src/crestodian/crestodian.ts +++ b/src/crestodian/crestodian.ts @@ -30,6 +30,8 @@ type CrestodianInteractiveRunner = ( export type RunCrestodianOptions = { message?: string; yes?: boolean; + /** Explicit acknowledgement for plugin sources outside ClawHub review. */ + acknowledgeNonClawHubInstall?: boolean; json?: boolean; interactive?: boolean; /** "onboarding" swaps the greeting for the first-run setup proposal. */ @@ -69,6 +71,7 @@ async function runOneShot( const operation = await resolveCrestodianOperation(input, runtime, opts); await executeCrestodianOperation(operation, runtime, { approved: opts.yes === true || !isPersistentCrestodianOperation(operation), + ...(opts.acknowledgeNonClawHubInstall === true ? { acknowledgeNonClawHubInstall: true } : {}), deps: crestodianCommandDepsFromOptions(opts), }); } diff --git a/src/crestodian/operations.test.ts b/src/crestodian/operations.test.ts index 51eb4d0518dc..f8fcd75d113a 100644 --- a/src/crestodian/operations.test.ts +++ b/src/crestodian/operations.test.ts @@ -704,11 +704,25 @@ describe("parseCrestodianOperation", () => { }); expect(runPluginInstall).not.toHaveBeenCalled(); + const genericApproval = await executeCrestodianOperation( + { kind: "plugin-install", spec: "npm:@openclaw/demo" }, + runtime, + { + approved: true, + deps: { runPluginInstall }, + }, + ); + + expect(genericApproval.applied).toBe(false); + expect(genericApproval.message).toContain("--acknowledge-non-clawhub-install"); + expect(runPluginInstall).not.toHaveBeenCalled(); + const result = await executeCrestodianOperation( { kind: "plugin-install", spec: "npm:@openclaw/demo" }, runtime, { approved: true, + acknowledgeNonClawHubInstall: true, deps: { runPluginInstall }, }, ); @@ -735,6 +749,7 @@ describe("parseCrestodianOperation", () => { runtime, { approved: true, + acknowledgeNonClawHubInstall: true, deps: { runPluginInstall }, }, ); diff --git a/src/crestodian/operations.ts b/src/crestodian/operations.ts index 93b6ce107bc8..db1c7780261c 100644 --- a/src/crestodian/operations.ts +++ b/src/crestodian/operations.ts @@ -4,6 +4,7 @@ import type { ConfigSetOptions } from "../cli/config-set-input.js"; import { looksLikeLocalInstallSpec } from "../cli/install-spec.js"; import { formatNonClawHubInstallWarning, + NON_CLAWHUB_INSTALL_ACK_FLAG, type NonClawHubInstallAcknowledgementOptions, } from "../cli/non-clawhub-install-acknowledgement.js"; import type { DoctorOptions } from "../commands/doctor.types.js"; @@ -543,6 +544,13 @@ function isClawHubPluginInstallSpec(spec: string): boolean { return spec.trim().toLowerCase().startsWith("clawhub:"); } +/** Whether this operation needs source-specific approval in addition to generic write approval. */ +export function requiresNonClawHubPluginInstallAcknowledgement( + operation: CrestodianOperation, +): operation is Extract { + return operation.kind === "plugin-install" && !isClawHubPluginInstallSpec(operation.spec); +} + function formatCreateAgentWorkspace(workspace: string | undefined): string { return workspace ? shortenHomePath(resolveUserPath(workspace)) : shortenHomePath(process.cwd()); } @@ -759,6 +767,8 @@ async function resolveTuiAgentId(params: { type ExecuteOptions = { approved?: boolean; + /** Source-specific consent collected after showing the non-ClawHub warning. */ + acknowledgeNonClawHubInstall?: boolean; deps?: CrestodianCommandDeps; auditDetails?: Record; }; @@ -959,6 +969,18 @@ async function executePluginInstall( runtime.exit(1); return { applied: false }; } + if ( + opts.approved && + requiresNonClawHubPluginInstallAcknowledgement(operation) && + opts.acknowledgeNonClawHubInstall !== true + ) { + const message = [ + formatCrestodianPersistentPlan(operation), + `Non-ClawHub plugin installs need separate provenance acknowledgement; rerun with ${NON_CLAWHUB_INSTALL_ACK_FLAG}.`, + ].join("\n"); + runtime.log(message); + return { applied: false, message }; + } const result = await applyPersistentOperation({ auditOperation: "plugin.install", operation, @@ -982,7 +1004,7 @@ async function executePluginInstall( }); }); await runPluginInstall(operation.spec, createNoExitRuntime(ctx.runtime), { - acknowledgeNonClawHubInstall: opts.approved === true, + acknowledgeNonClawHubInstall: opts.acknowledgeNonClawHubInstall === true, }); return { summary: `Installed plugin ${operation.spec}`, details: { spec: operation.spec } }; }, diff --git a/src/crestodian/setup-inference.test.ts b/src/crestodian/setup-inference.test.ts index 25ac209c9f04..ace646c5893e 100644 --- a/src/crestodian/setup-inference.test.ts +++ b/src/crestodian/setup-inference.test.ts @@ -768,6 +768,7 @@ describe("activateSetupInference", () => { const result = await activateSetupInference({ kind: "codex-cli", workspace: "/tmp/openclaw-workspace", + acknowledgeNonClawHubInstall: true, surface: "gateway", runtime, deps: { @@ -791,6 +792,7 @@ describe("activateSetupInference", () => { expect(ensureCodex).toHaveBeenCalledOnce(); expect(ensureCodex).toHaveBeenCalledWith( expect.objectContaining({ + acknowledgeNonClawHubInstall: true, cfg: expect.objectContaining({ agents: { defaults: { model: { primary: "openai/gpt-5.4" } }, diff --git a/src/crestodian/setup-inference.ts b/src/crestodian/setup-inference.ts index afe9daf161fa..2913e645362c 100644 --- a/src/crestodian/setup-inference.ts +++ b/src/crestodian/setup-inference.ts @@ -113,6 +113,7 @@ export type ActivateSetupInferenceParams = { /** Manual step only: the pasted API key or token. Never logged. */ apiKey?: string; workspace?: string; + acknowledgeNonClawHubInstall?: boolean; surface: "cli" | "gateway"; runtime: RuntimeEnv; deps?: ActivateSetupInferenceDeps; @@ -664,6 +665,9 @@ async function activateSetupInferenceUnredacted( prompter: createQuickstartNotePrompter(params.runtime), runtime: params.runtime, workspaceDir: tempDir, + ...(params.acknowledgeNonClawHubInstall === true + ? { acknowledgeNonClawHubInstall: true } + : {}), }); if (!ensured.installed) { return { diff --git a/src/crestodian/tui-backend.ts b/src/crestodian/tui-backend.ts index d887563ae3ba..e11038e9d810 100644 --- a/src/crestodian/tui-backend.ts +++ b/src/crestodian/tui-backend.ts @@ -34,6 +34,7 @@ type RunTui = typeof defaultRunTui; export type CrestodianTuiOptions = { yes?: boolean; + acknowledgeNonClawHubInstall?: boolean; deps?: CrestodianCommandDeps; planWithAssistant?: CrestodianAssistantPlanner; runTui?: RunTui; @@ -81,6 +82,7 @@ function createEmbeddedModelSetupRuntime(runtime: RuntimeEnv): RuntimeEnv { function createChatEngine(opts: CrestodianTuiOptions): CrestodianChatEngine { return new CrestodianChatEngine({ yes: opts.yes, + acknowledgeNonClawHubInstall: opts.acknowledgeNonClawHubInstall, deps: opts.deps, planWithAssistant: opts.planWithAssistant, surface: "cli", diff --git a/src/flows/doctor-health-contributions.test.ts b/src/flows/doctor-health-contributions.test.ts index f0575c982ec2..72fa0475f45e 100644 --- a/src/flows/doctor-health-contributions.test.ts +++ b/src/flows/doctor-health-contributions.test.ts @@ -906,11 +906,12 @@ describe("doctor health contributions", () => { touchedConfig: true, }); const contribution = requireDoctorContribution("doctor:release-configured-plugin-installs"); + const prompter = buildDoctorPrompter(true); const ctx = { cfg: {}, configResult: { cfg: {}, sourceLastTouchedVersion: "2026.4.29" }, sourceConfigValid: true, - prompter: buildDoctorPrompter(true), + prompter, env: {}, } as unknown as Parameters<(typeof contribution)["run"]>[0]; @@ -923,6 +924,22 @@ describe("doctor health contributions", () => { onNonClawHubInstall: expect.any(Function), touchedVersion: "2026.4.29", }); + const repairCall = mocks.maybeRunConfiguredPluginInstallReleaseStep.mock.calls[0]?.[0]; + await expect( + repairCall?.onNonClawHubInstall?.({ + pluginId: "matrix", + sourceClass: "npm", + spec: "@openclaw/matrix", + }), + ).resolves.toBe(true); + expect(prompter.confirmRuntimeRepair).toHaveBeenCalledWith({ + message: + "WARNING - Installing plugin from npm registry: @openclaw/matrix\n" + + "This source is outside ClawHub review and trust metadata. Only continue if you trust the publisher, package contents, and install source.\n" + + "Install this non-ClawHub plugin source during doctor repair?", + initialValue: false, + requiresInteractiveConfirmation: true, + }); expect(mocks.note).toHaveBeenCalledWith( "Installed configured plugin matrix.", "Doctor changes", @@ -930,6 +947,40 @@ describe("doctor health contributions", () => { expect(ctx.cfg.meta?.lastTouchedVersion).toBe("2026.5.2-test"); }); + it("does not repeat consent for configured plugin installs declined earlier in doctor", async () => { + mocks.maybeRunConfiguredPluginInstallReleaseStep.mockResolvedValue({ + changes: [], + warnings: ['Skipped missing configured plugin "matrix".'], + touchedConfig: false, + }); + const contribution = requireDoctorContribution("doctor:release-configured-plugin-installs"); + const prompter = buildDoctorPrompter(true); + const ctx = { + cfg: {}, + configResult: { + cfg: {}, + sourceLastTouchedVersion: "2026.4.29", + failedConfiguredPluginInstallIds: ["matrix"], + }, + sourceConfigValid: true, + prompter, + options: {}, + env: {}, + } as unknown as Parameters<(typeof contribution)["run"]>[0]; + + await contribution.run(ctx); + + const repairCall = mocks.maybeRunConfiguredPluginInstallReleaseStep.mock.calls[0]?.[0]; + await expect( + repairCall?.onNonClawHubInstall?.({ + pluginId: "matrix", + sourceClass: "npm", + spec: "@openclaw/matrix", + }), + ).resolves.toBe(false); + expect(prompter.confirmRuntimeRepair).not.toHaveBeenCalled(); + }); + it("keeps legacy parent writable release repairs old-parent-readable", async () => { mocks.maybeRunConfiguredPluginInstallReleaseStep.mockResolvedValue({ changes: ["Installed configured plugin matrix."], diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index 234b0222a258..ea905a7c3a48 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -3,7 +3,7 @@ import fs from "node:fs"; import nodePath from "node:path"; import { formatNonClawHubInstallWarning, - type NonClawHubInstallSourceClass, + type NonClawHubInstallAcknowledgementRequest, } from "../cli/non-clawhub-install-acknowledgement.js"; import type { probeGatewayMemoryStatus } from "../commands/doctor-gateway-health.js"; import type { DoctorOptions, DoctorPrompter } from "../commands/doctor-prompter.js"; @@ -33,6 +33,7 @@ type DoctorConfigResult = { sourceLastTouchedVersion?: string; skipPluginValidationOnWrite?: boolean; preservedLegacyRootKeys?: readonly string[]; + failedConfiguredPluginInstallIds?: readonly string[]; }; export type DoctorHealthFlowContext = { @@ -602,15 +603,21 @@ async function runReleaseConfiguredPluginInstallsHealth( await import("../commands/doctor/shared/release-configured-plugin-installs.js"); const { note } = await loadNoteModule(); const { VERSION } = await import("../version.js"); - const confirmNonClawHubRepairInstall = async (request: { - sourceClass: NonClawHubInstallSourceClass; - spec: string; - }) => - await ctx.prompter.confirmRuntimeRepair({ + const failedConfiguredPluginInstallIds = new Set( + ctx.configResult.failedConfiguredPluginInstallIds, + ); + const confirmNonClawHubRepairInstall = async ( + request: NonClawHubInstallAcknowledgementRequest, + ) => { + if (failedConfiguredPluginInstallIds.has(request.pluginId)) { + return false; + } + return await ctx.prompter.confirmRuntimeRepair({ message: `${formatNonClawHubInstallWarning(request)}\nInstall this non-ClawHub plugin source during doctor repair?`, initialValue: false, requiresInteractiveConfirmation: true, }); + }; const result = await maybeRunConfiguredPluginInstallReleaseStep({ cfg: ctx.cfg, env: ctx.env ?? process.env, diff --git a/src/gateway/server-methods/crestodian.ts b/src/gateway/server-methods/crestodian.ts index 876ef11cbaae..a955646b1937 100644 --- a/src/gateway/server-methods/crestodian.ts +++ b/src/gateway/server-methods/crestodian.ts @@ -93,6 +93,9 @@ export const crestodianHandlers: GatewayRequestHandlers = { ...(params.authChoice !== undefined ? { authChoice: params.authChoice } : {}), ...(params.apiKey !== undefined ? { apiKey: params.apiKey } : {}), ...(params.workspace !== undefined ? { workspace: params.workspace } : {}), + ...(params.acknowledgeNonClawHubInstall === true + ? { acknowledgeNonClawHubInstall: true } + : {}), surface: "gateway", runtime, }); diff --git a/src/hooks/update.test.ts b/src/hooks/update.test.ts index cffda9f31eba..e301a682b970 100644 --- a/src/hooks/update.test.ts +++ b/src/hooks/update.test.ts @@ -38,6 +38,61 @@ describe("updateNpmInstalledHookPacks", () => { installHooksFromNpmSpecMock.mockReset(); }); + it("skips live npm updates when non-ClawHub install approval is absent", async () => { + const config = createHookInstallConfig({ + hookId: "demo-hooks", + spec: "@openclaw/demo-hooks", + }); + + const result = await updateNpmInstalledHookPacks({ + config, + hookIds: ["demo-hooks"], + allowNonClawHubInstall: false, + }); + + expect(installHooksFromNpmSpecMock).not.toHaveBeenCalled(); + expect(result.changed).toBe(false); + expect(result.outcomes).toEqual([ + { + hookId: "demo-hooks", + status: "skipped", + code: "non_clawhub_install_acknowledgement_required", + message: + 'Skipped non-ClawHub update for hook pack "demo-hooks" from @openclaw/demo-hooks; explicit install acknowledgement is required.', + }, + ]); + }); + + it("uses per-pack approval before a live npm update", async () => { + installHooksFromNpmSpecMock.mockResolvedValue({ + ok: true, + hookPackId: "demo-hooks", + hooks: ["demo"], + targetDir: "/tmp/hooks/demo-hooks", + version: "1.2.3", + }); + const onNonClawHubInstall = vi.fn(async () => true); + const config = createHookInstallConfig({ + hookId: "demo-hooks", + spec: "@openclaw/demo-hooks", + }); + + const result = await updateNpmInstalledHookPacks({ + config, + hookIds: ["demo-hooks"], + allowNonClawHubInstall: false, + onNonClawHubInstall, + }); + + expect(onNonClawHubInstall).toHaveBeenCalledWith({ + hookId: "demo-hooks", + source: "npm", + spec: "@openclaw/demo-hooks", + }); + expect(installHooksFromNpmSpecMock).toHaveBeenCalledOnce(); + expect(result.changed).toBe(true); + }); + it("aborts exact pinned hook pack updates on integrity drift by default", async () => { const warn = vi.fn(); installHooksFromNpmSpecMock.mockImplementation( diff --git a/src/hooks/update.ts b/src/hooks/update.ts index a83797b95cfc..00bb6d6e6b30 100644 --- a/src/hooks/update.ts +++ b/src/hooks/update.ts @@ -1,5 +1,6 @@ // Hook update helpers refresh installed hook records and config references. import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { NON_CLAWHUB_INSTALL_ACKNOWLEDGEMENT_REQUIRED_CODE } from "../infra/install-acknowledgement-codes.js"; import { buildNpmResolutionFields } from "../infra/install-source-utils.js"; import { expectedIntegrityForUpdate, @@ -26,6 +27,7 @@ export type HookPackUpdateOutcome = { hookId: string; status: HookPackUpdateStatus; message: string; + code?: typeof NON_CLAWHUB_INSTALL_ACKNOWLEDGEMENT_REQUIRED_CODE; currentVersion?: string; nextVersion?: string; }; @@ -45,6 +47,12 @@ export type HookPackUpdateIntegrityDriftParams = HookNpmIntegrityDriftParams & { dryRun: boolean; }; +export type HookPackUpdateNonClawHubInstallRequest = { + hookId: string; + source: "npm"; + spec: string; +}; + function createHookPackUpdateIntegrityDriftHandler(params: { hookId: string; dryRun: boolean; @@ -79,6 +87,11 @@ export async function updateNpmInstalledHookPacks(params: { hookIds?: string[]; dryRun?: boolean; specOverrides?: Record; + /** Set false when the caller requires explicit approval before an npm installer runs. */ + allowNonClawHubInstall?: boolean; + onNonClawHubInstall?: ( + request: HookPackUpdateNonClawHubInstallRequest, + ) => boolean | Promise; onIntegrityDrift?: (params: HookPackUpdateIntegrityDriftParams) => boolean | Promise; }): Promise { const logger = params.logger ?? {}; @@ -123,6 +136,26 @@ export async function updateNpmInstalledHookPacks(params: { continue; } + const approvedNonClawHubInstall = + params.dryRun || + params.allowNonClawHubInstall === true || + (params.onNonClawHubInstall + ? await params.onNonClawHubInstall({ + hookId, + source: "npm", + spec: effectiveSpec, + }) + : params.allowNonClawHubInstall !== false); + if (!approvedNonClawHubInstall) { + outcomes.push({ + hookId, + status: "skipped", + code: NON_CLAWHUB_INSTALL_ACKNOWLEDGEMENT_REQUIRED_CODE, + message: `Skipped non-ClawHub update for hook pack "${hookId}" from ${effectiveSpec}; explicit install acknowledgement is required.`, + }); + continue; + } + let installPath: string; try { installPath = record.installPath ?? resolveHookInstallDir(hookId); diff --git a/src/infra/install-acknowledgement-codes.ts b/src/infra/install-acknowledgement-codes.ts new file mode 100644 index 000000000000..b8798aaa63d9 --- /dev/null +++ b/src/infra/install-acknowledgement-codes.ts @@ -0,0 +1,3 @@ +/** Shared outcome code for installers blocked by missing non-ClawHub acknowledgement. */ +export const NON_CLAWHUB_INSTALL_ACKNOWLEDGEMENT_REQUIRED_CODE = + "non_clawhub_install_acknowledgement_required" as const; diff --git a/src/infra/outbound/channel-selection.test.ts b/src/infra/outbound/channel-selection.test.ts index c51bb09c1c8f..ae1740ab420d 100644 --- a/src/infra/outbound/channel-selection.test.ts +++ b/src/infra/outbound/channel-selection.test.ts @@ -43,8 +43,8 @@ vi.mock("../../plugins/official-external-plugin-repair-hints.js", () => ({ label: channelId === "whatsapp" ? "WhatsApp" : "Feishu", installSpec: `@openclaw/${channelId}`, installCommand: `openclaw plugins install @openclaw/${channelId} --acknowledge-non-clawhub-install`, - doctorFixCommand: "openclaw doctor --fix", - repairHint: `Install the official external plugin with: openclaw plugins install @openclaw/${channelId} --acknowledge-non-clawhub-install, or run: openclaw doctor --fix.`, + doctorFixCommand: "openclaw doctor --fix --acknowledge-non-clawhub-install", + repairHint: `Install the official external plugin with: openclaw plugins install @openclaw/${channelId} --acknowledge-non-clawhub-install, or run: openclaw doctor --fix --acknowledge-non-clawhub-install.`, } : null, })); @@ -299,7 +299,7 @@ describe("resolveMessageChannelSelection", () => { channel: "feishu", }, expectedMessage: - "Channel is unavailable: feishu. Install the official external plugin with: openclaw plugins install @openclaw/feishu --acknowledge-non-clawhub-install, or run: openclaw doctor --fix.", + "Channel is unavailable: feishu. Install the official external plugin with: openclaw plugins install @openclaw/feishu --acknowledge-non-clawhub-install, or run: openclaw doctor --fix --acknowledge-non-clawhub-install.", }, { params: { cfg: {} as never }, @@ -313,7 +313,7 @@ describe("resolveMessageChannelSelection", () => { }, params: { cfg: { channels: { whatsapp: { enabled: true } } } as never }, expectedMessage: - "Channel is required (no available channels detected). Configured official external channel WhatsApp is missing its plugin. Install the official external plugin with: openclaw plugins install @openclaw/whatsapp --acknowledge-non-clawhub-install, or run: openclaw doctor --fix.", + "Channel is required (no available channels detected). Configured official external channel WhatsApp is missing its plugin. Install the official external plugin with: openclaw plugins install @openclaw/whatsapp --acknowledge-non-clawhub-install, or run: openclaw doctor --fix --acknowledge-non-clawhub-install.", }, { setup: () => { diff --git a/src/mcp/openclaw-tools-serve-config.ts b/src/mcp/openclaw-tools-serve-config.ts index 57caaf2ee63e..cdc509e8bb0e 100644 --- a/src/mcp/openclaw-tools-serve-config.ts +++ b/src/mcp/openclaw-tools-serve-config.ts @@ -7,7 +7,11 @@ import fs from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; -import type { CrestodianToolOptions } from "../agents/tools/crestodian-tool.js"; +import type { + CrestodianToolOptions, + CrestodianToolProposal, + CrestodianToolProposalRef, +} from "../agents/tools/crestodian-tool.js"; import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; import type { BundleMcpConfig } from "../plugins/bundle-mcp.js"; @@ -62,20 +66,50 @@ export function resolveOpenClawToolsMcpCrestodianSurface( /** * Reconstruct per-turn approval state for the served crestodian tool. The * stdio server runs out of process, so the host passes the armed bit and the - * pending proposal hash through env; the host mirrors transitions back from + * pending proposal and render state through env; the host mirrors transitions back from * tool events (see mirrorCrestodianProposalFromToolEvents in agent-turn.ts). */ export function resolveOpenClawToolsMcpCrestodianApproval(env: NodeJS.ProcessEnv = process.env): { approvalArmed: boolean; - proposalRef: { current?: string }; + proposalRef: CrestodianToolProposalRef; } { - const pendingProposal = env[OPENCLAW_TOOLS_MCP_CRESTODIAN_PROPOSAL_ENV]?.trim(); + const pendingProposal = parseCrestodianToolProposal( + env[OPENCLAW_TOOLS_MCP_CRESTODIAN_PROPOSAL_ENV], + ); return { approvalArmed: env[OPENCLAW_TOOLS_MCP_CRESTODIAN_APPROVAL_ARMED_ENV]?.trim() === "1", proposalRef: pendingProposal ? { current: pendingProposal } : {}, }; } +function parseCrestodianToolProposal(raw: string | undefined): CrestodianToolProposal | undefined { + if (!raw?.trim()) { + return undefined; + } + try { + const parsed: unknown = JSON.parse(raw); + if ( + !parsed || + typeof parsed !== "object" || + !("operationHash" in parsed) || + typeof parsed.operationHash !== "string" || + !("plan" in parsed) || + typeof parsed.plan !== "string" || + !("renderedByHost" in parsed) || + typeof parsed.renderedByHost !== "boolean" + ) { + return undefined; + } + return { + operationHash: parsed.operationHash, + plan: parsed.plan, + renderedByHost: parsed.renderedByHost, + }; + } catch { + return undefined; + } +} + function resolveTsxImportSpecifier(): string { try { return createRequire(import.meta.url).resolve("tsx"); @@ -136,7 +170,9 @@ export function buildCrestodianToolsMcpServerConfig( ? { [OPENCLAW_TOOLS_MCP_CRESTODIAN_APPROVAL_ARMED_ENV]: "1" } : {}), ...(pendingProposal - ? { [OPENCLAW_TOOLS_MCP_CRESTODIAN_PROPOSAL_ENV]: pendingProposal } + ? { + [OPENCLAW_TOOLS_MCP_CRESTODIAN_PROPOSAL_ENV]: JSON.stringify(pendingProposal), + } : {}), }, }, diff --git a/src/mcp/openclaw-tools-serve.test.ts b/src/mcp/openclaw-tools-serve.test.ts index caf2fde89b1c..9e901cd3b2d2 100644 --- a/src/mcp/openclaw-tools-serve.test.ts +++ b/src/mcp/openclaw-tools-serve.test.ts @@ -4,6 +4,7 @@ import { buildCrestodianToolsMcpServerConfig, OPENCLAW_TOOLS_MCP_CRESTODIAN_SURFACE_ENV, OPENCLAW_TOOLS_MCP_TOOLS_ENV, + resolveOpenClawToolsMcpCrestodianApproval, resolveOpenClawToolsMcpCrestodianSurface, resolveOpenClawToolsMcpToolSelection, } from "./openclaw-tools-serve-config.js"; @@ -89,4 +90,23 @@ describe("OpenClaw tools MCP server", () => { [OPENCLAW_TOOLS_MCP_CRESTODIAN_SURFACE_ENV]: "gateway", }); }); + + it("round-trips host-rendered crestodian proposal state through the stdio env", () => { + const proposal = { + operationHash: "plugin-install", + plan: "Install npm:@example/plugin after reviewing its source.", + renderedByHost: true, + }; + const config = buildCrestodianToolsMcpServerConfig({ + surface: "cli", + approvalArmed: true, + proposalRef: { current: proposal }, + }); + const server = config.mcpServers.openclaw as { env?: Record }; + + expect(resolveOpenClawToolsMcpCrestodianApproval(server.env)).toEqual({ + approvalArmed: true, + proposalRef: { current: proposal }, + }); + }); }); diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index 22f94ac9834d..b78666db624f 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -83,7 +83,8 @@ vi.mock("../process/exec.js", () => ({ vi.resetModules(); -const { syncPluginsForUpdateChannel, updateNpmInstalledPlugins } = await import("./update.js"); +const { PLUGIN_UPDATE_SKIP_CODE, syncPluginsForUpdateChannel, updateNpmInstalledPlugins } = + await import("./update.js"); function createSuccessfulNpmUpdateResult(params?: { pluginId?: string; @@ -575,6 +576,86 @@ describe("updateNpmInstalledPlugins", () => { ]); }); + it("skips non-ClawHub update installers when the core update has no acknowledgement", async () => { + const installPath = createInstalledPackageDir({ + name: "@demo/plugin", + version: "1.0.0", + }); + mockNpmViewMetadata({ + name: "@demo/plugin", + version: "2.0.0", + integrity: "sha512-next", + }); + + const result = await updateNpmInstalledPlugins({ + config: createNpmInstallConfig({ + pluginId: "demo", + spec: "@demo/plugin", + installPath, + resolvedName: "@demo/plugin", + resolvedSpec: "@demo/plugin@1.0.0", + resolvedVersion: "1.0.0", + }), + pluginIds: ["demo"], + allowNonClawHubInstall: false, + }); + + expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled(); + expect(result.changed).toBe(false); + expect(result.outcomes).toEqual([ + expect.objectContaining({ + pluginId: "demo", + status: "skipped", + code: PLUGIN_UPDATE_SKIP_CODE.NON_CLAWHUB_INSTALL_ACKNOWLEDGEMENT_REQUIRED, + message: expect.stringContaining("--acknowledge-non-clawhub-install"), + }), + ]); + }); + + it("uses per-plugin approval before a non-ClawHub update installer", async () => { + const installPath = createInstalledPackageDir({ + name: "@demo/plugin", + version: "1.0.0", + }); + mockNpmViewMetadata({ + name: "@demo/plugin", + version: "2.0.0", + integrity: "sha512-next", + }); + installPluginFromNpmSpecMock.mockResolvedValue( + createSuccessfulNpmUpdateResult({ + pluginId: "demo", + targetDir: installPath, + version: "2.0.0", + }), + ); + const onNonClawHubInstall = vi.fn(async () => true); + + const result = await updateNpmInstalledPlugins({ + config: createNpmInstallConfig({ + pluginId: "demo", + spec: "@demo/plugin", + installPath, + resolvedName: "@demo/plugin", + resolvedSpec: "@demo/plugin@1.0.0", + resolvedVersion: "1.0.0", + }), + pluginIds: ["demo"], + allowNonClawHubInstall: false, + onNonClawHubInstall, + }); + + expect(onNonClawHubInstall).toHaveBeenCalledWith({ + pluginId: "demo", + source: "npm", + spec: "@demo/plugin", + }); + expect(installPluginFromNpmSpecMock).toHaveBeenCalledOnce(); + expect(result.outcomes).toEqual([ + expect.objectContaining({ pluginId: "demo", status: "updated" }), + ]); + }); + it.each([ { name: "skips integrity drift checks for unpinned npm specs during dry-run updates", @@ -3882,6 +3963,42 @@ describe("updateNpmInstalledPlugins", () => { ]); }); + it("blocks official ClawHub-to-npm fallback without non-ClawHub acknowledgement", async () => { + const installPath = createInstalledPackageDir({ + name: "@openclaw/discord", + version: "2026.5.12", + }); + installPluginFromClawHubMock.mockResolvedValueOnce({ + ok: false, + code: "artifact_unavailable", + error: "artifact unavailable", + }); + + const result = await updateNpmInstalledPlugins({ + config: createClawHubInstallConfig({ + pluginId: "discord", + installPath, + clawhubUrl: "https://clawhub.ai", + clawhubPackage: "@openclaw/discord", + clawhubFamily: "code-plugin", + clawhubChannel: "official", + spec: "clawhub:@openclaw/discord", + }), + pluginIds: ["discord"], + allowNonClawHubInstall: false, + }); + + expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled(); + expect(result.changed).toBe(false); + expect(result.outcomes).toEqual([ + expect.objectContaining({ + pluginId: "discord", + status: "skipped", + message: expect.stringContaining("--acknowledge-non-clawhub-install"), + }), + ]); + }); + it("uses exact-core npm when an official ClawHub install falls back on extended-stable", async () => { const installPath = createInstalledPackageDir({ name: "@openclaw/discord", @@ -4427,6 +4544,29 @@ describe("updateNpmInstalledPlugins", () => { }); }); + it("shows persisted marketplace source details before repair approval", async () => { + const onNonClawHubInstall = vi.fn(async () => false); + + await updateNpmInstalledPlugins({ + config: createMarketplaceInstallConfig({ + pluginId: "claude-bundle", + installPath: "/tmp/claude-bundle", + marketplaceSource: "vincentkoc/claude-marketplace", + marketplacePlugin: "claude-bundle", + }), + pluginIds: ["claude-bundle"], + allowNonClawHubInstall: false, + onNonClawHubInstall, + }); + + expect(onNonClawHubInstall).toHaveBeenCalledWith({ + pluginId: "claude-bundle", + source: "marketplace", + spec: "claude-bundle from vincentkoc/claude-marketplace", + }); + expect(installPluginFromMarketplaceMock).not.toHaveBeenCalled(); + }); + it("updates git installs and records resolved commit metadata", async () => { installPluginFromGitSpecMock.mockResolvedValue({ ok: true, @@ -4757,6 +4897,44 @@ describe("syncPluginsForUpdateChannel", () => { }); }); + it("does not externalize a bundled plugin to npm without acknowledgement", async () => { + resolveBundledPluginSourcesMock.mockReturnValue(new Map()); + const config: OpenClawConfig = { + channels: { "legacy-chat": { enabled: true } }, + plugins: { + load: { paths: [appBundledPluginRoot("legacy-chat")] }, + installs: { + "legacy-chat": { + source: "path", + sourcePath: appBundledPluginRoot("legacy-chat"), + installPath: appBundledPluginRoot("legacy-chat"), + }, + }, + }, + }; + + const result = await syncPluginsForUpdateChannel({ + channel: "stable", + allowNonClawHubInstall: false, + externalizedBundledPluginBridges: [ + { + bundledPluginId: "legacy-chat", + npmSpec: "@openclaw/legacy-chat", + channelIds: ["legacy-chat"], + }, + ], + config, + }); + + expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled(); + expect(result.changed).toBe(false); + expect(result.config).toBe(config); + expect(result.summary.switchedToNpm).toStrictEqual([]); + expect(result.summary.errors).toEqual([ + expect.stringContaining("--acknowledge-non-clawhub-install"), + ]); + }); + it("marks official externalized bundled npm installs as trusted", async () => { resolveBundledPluginSourcesMock.mockReturnValue(new Map()); installPluginFromNpmSpecMock.mockResolvedValue( diff --git a/src/plugins/update.ts b/src/plugins/update.ts index 8ec2848b314d..e96e1f151ccb 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -6,6 +6,7 @@ import type { PluginInstallRecord } from "../config/types.plugins.js"; import type { ClawHubTrustErrorCode } from "../infra/clawhub-install-trust.js"; import { parseClawHubPluginSpec } from "../infra/clawhub-spec.js"; import { satisfiesPluginApiRange } from "../infra/clawhub.js"; +import { NON_CLAWHUB_INSTALL_ACKNOWLEDGEMENT_REQUIRED_CODE } from "../infra/install-acknowledgement-codes.js"; import { unscopedPackageName } from "../infra/install-safe-path.js"; import type { NpmSpecResolution } from "../infra/install-source-utils.js"; import { createNpmMetadataEnv, resolveNpmSpecMetadata } from "../infra/install-source-utils.js"; @@ -82,6 +83,14 @@ export type PluginUpdateLogger = { /** Outcome status for one plugin update attempt. */ export type PluginUpdateStatus = "updated" | "unchanged" | "skipped" | "error"; +export const PLUGIN_UPDATE_SKIP_CODE = { + NON_CLAWHUB_INSTALL_ACKNOWLEDGEMENT_REQUIRED: NON_CLAWHUB_INSTALL_ACKNOWLEDGEMENT_REQUIRED_CODE, +} as const; + +type PluginUpdateSkipCode = + | ClawHubTrustErrorCode + | (typeof PLUGIN_UPDATE_SKIP_CODE)[keyof typeof PLUGIN_UPDATE_SKIP_CODE]; + type PluginUpdateChannelFallback = { requestedSpec: string; usedSpec: string; @@ -103,7 +112,7 @@ type BasePluginUpdateOutcome = { export type PluginUpdateOutcome = | (BasePluginUpdateOutcome & { status: "skipped"; - code?: ClawHubTrustErrorCode; + code?: PluginUpdateSkipCode; }) | (BasePluginUpdateOutcome & { status: Exclude; @@ -126,6 +135,12 @@ export type PluginUpdateIntegrityDriftParams = { dryRun: boolean; }; +export type PluginUpdateNonClawHubInstallRequest = { + pluginId: string; + source: Exclude; + spec: string; +}; + export type PluginChannelSyncSummary = { switchedToBundled: string[]; switchedToClawHub: string[]; @@ -211,6 +226,28 @@ function formatClawHubInstallFailure(params: { return `Failed to ${params.phase} ${params.pluginId}: ${params.error} (ClawHub ${params.spec}).`; } +function formatNonClawHubInstallAcknowledgementRequired(params: { + pluginId: string; + spec: string; +}): string { + return `Skipped non-ClawHub install for "${params.pluginId}" from ${params.spec}; rerun with --acknowledge-non-clawhub-install after reviewing and trusting the source.`; +} + +function describeNonClawHubInstallSource( + record: PluginInstallRecord, + effectiveSpec: string | undefined, +): string { + if (record.source === "marketplace") { + const marketplacePlugin = record.marketplacePlugin?.trim(); + const marketplaceSource = record.marketplaceSource?.trim(); + if (marketplacePlugin && marketplaceSource) { + return `${marketplacePlugin} from ${marketplaceSource}`; + } + return marketplaceSource ?? marketplacePlugin ?? effectiveSpec ?? "marketplace"; + } + return effectiveSpec ?? record.spec ?? record.gitUrl ?? record.sourcePath ?? record.source; +} + function isClawHubRiskAcknowledgementRequired(result: { ok: false; code?: string }): boolean { return result.code === CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED; } @@ -281,6 +318,16 @@ export function isClawHubTrustSkippedOutcome(outcome: { status: string; code?: s ); } +export function isNonClawHubInstallAcknowledgementSkippedOutcome(outcome: { + status: string; + code?: string; +}): boolean { + return ( + outcome.status === "skipped" && + outcome.code === PLUGIN_UPDATE_SKIP_CODE.NON_CLAWHUB_INSTALL_ACKNOWLEDGEMENT_REQUIRED + ); +} + function formatGitInstallFailure(params: { pluginId: string; spec: string; @@ -1401,6 +1448,11 @@ export async function updateNpmInstalledPlugins(params: { onIntegrityDrift?: (params: PluginUpdateIntegrityDriftParams) => boolean | Promise; acknowledgeClawHubRisk?: boolean; onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise; + /** Set false when the caller requires explicit approval before any non-ClawHub installer runs. */ + allowNonClawHubInstall?: boolean; + onNonClawHubInstall?: ( + request: PluginUpdateNonClawHubInstallRequest, + ) => boolean | Promise; }): Promise { const logger = params.logger ?? {}; const installs = params.config.plugins?.installs ?? {}; @@ -1423,6 +1475,17 @@ export async function updateNpmInstalledPlugins(params: { ...(params.acknowledgeClawHubRisk ? { acknowledgeClawHubRisk: true } : {}), ...(!params.dryRun && params.onClawHubRisk ? { onClawHubRisk: params.onClawHubRisk } : {}), }; + const approveNonClawHubInstall = async ( + request: PluginUpdateNonClawHubInstallRequest, + ): Promise => { + if (params.allowNonClawHubInstall === true) { + return true; + } + if (params.onNonClawHubInstall) { + return await params.onNonClawHubInstall(request); + } + return params.allowNonClawHubInstall !== false; + }; const recordFailure = ( pluginId: string, @@ -2072,6 +2135,28 @@ export async function updateNpmInstalledPlugins(params: { continue; } + const nonClawHubInstallSpec = describeNonClawHubInstallSource(record, effectiveSpec); + if ( + record.source !== "clawhub" && + !(await approveNonClawHubInstall({ + pluginId, + source: record.source, + spec: nonClawHubInstallSpec, + })) + ) { + outcomes.push({ + pluginId, + status: "skipped", + code: PLUGIN_UPDATE_SKIP_CODE.NON_CLAWHUB_INSTALL_ACKNOWLEDGEMENT_REQUIRED, + ...(currentVersion ? { currentVersion } : {}), + message: formatNonClawHubInstallAcknowledgementRequired({ + pluginId, + spec: nonClawHubInstallSpec, + }), + }); + continue; + } + let result: | Awaited> | Awaited> @@ -2224,6 +2309,25 @@ export async function updateNpmInstalledPlugins(params: { npmSpec: officialNpmFallbackInstallSpec, }) ) { + if ( + !(await approveNonClawHubInstall({ + pluginId, + source: "npm", + spec: officialNpmFallbackInstallSpec, + })) + ) { + outcomes.push({ + pluginId, + status: "skipped", + code: PLUGIN_UPDATE_SKIP_CODE.NON_CLAWHUB_INSTALL_ACKNOWLEDGEMENT_REQUIRED, + ...(currentVersion ? { currentVersion } : {}), + message: formatNonClawHubInstallAcknowledgementRequired({ + pluginId, + spec: officialNpmFallbackInstallSpec, + }), + }); + continue; + } logger.warn?.( `Plugin "${pluginId}" could not download official ClawHub artifact for ${activeClawHubInstallSpec ?? `clawhub:${record.clawhubPackage!}`}; using npm ${officialNpmFallbackInstallSpec} instead. Core update can still complete.`, ); @@ -2427,6 +2531,8 @@ export async function syncPluginsForUpdateChannel(params: { externalizedBundledPluginBridges?: readonly ExternalizedBundledPluginBridge[]; acknowledgeClawHubRisk?: boolean; onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise; + /** Set false when the caller requires explicit approval before an external npm install. */ + allowNonClawHubInstall?: boolean; }): Promise { const env = params.env ?? process.env; const logger = params.logger ?? {}; @@ -2577,6 +2683,15 @@ export async function syncPluginsForUpdateChannel(params: { logger, }); if (!result.ok && npmSpec && shouldFallbackClawHubBridgeToNpm({ result, npmSpec })) { + if (params.allowNonClawHubInstall === false) { + const message = formatNonClawHubInstallAcknowledgementRequired({ + pluginId: targetPluginId, + spec: effectiveNpmSpec ?? npmSpec, + }); + summary.errors.push(message); + logger.error?.(message); + continue; + } const warning = `ClawHub ${clawhubSpec} unavailable for ${targetPluginId}; falling back to npm ${effectiveNpmSpec}.`; summary.warnings.push(warning); logger.warn?.(warning); @@ -2592,6 +2707,15 @@ export async function syncPluginsForUpdateChannel(params: { }); } } else { + if (params.allowNonClawHubInstall === false) { + const message = formatNonClawHubInstallAcknowledgementRequired({ + pluginId: targetPluginId, + spec: effectiveNpmSpec ?? installSpec, + }); + summary.errors.push(message); + logger.error?.(message); + continue; + } result = await installPluginFromNpmSpec({ spec: effectiveNpmSpec, config: params.config, diff --git a/test/e2e/qa-lab/plugins/plugin-lifecycle-probe-runtime.ts b/test/e2e/qa-lab/plugins/plugin-lifecycle-probe-runtime.ts index 5f4e70f409e4..d148fe850e3e 100644 --- a/test/e2e/qa-lab/plugins/plugin-lifecycle-probe-runtime.ts +++ b/test/e2e/qa-lab/plugins/plugin-lifecycle-probe-runtime.ts @@ -553,7 +553,13 @@ async function runPluginLifecycleMatrix() { summaryTsv, "install-v1", "node", - [entry, "plugins", "install", `npm:${packageName}@1.0.0`], + [ + entry, + "plugins", + "install", + `npm:${packageName}@1.0.0`, + "--acknowledge-non-clawhub-install", + ], runEnv, ); assertVersion(pluginId, "1.0.0", runEnv);