improve: harden non-ClawHub install acknowledgement

This commit is contained in:
Jesse Merhi
2026-07-11 12:48:26 +10:00
parent ee83289bba
commit a2ff465964
87 changed files with 2297 additions and 241 deletions
@@ -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<Candidate> {
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 {
@@ -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)
}
}
@@ -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"
}
}
+3 -2
View File
@@ -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
+1 -1
View File
@@ -85,7 +85,7 @@ openclaw models set <model-or-alias>
openclaw models set-image <model-or-alias>
```
`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
+3 -1
View File
@@ -40,7 +40,7 @@ openclaw plugins info <id> # alias for inspect
openclaw plugins enable <id>
openclaw plugins disable <id>
openclaw plugins uninstall <id> [--dry-run] [--keep-files] [--force]
openclaw plugins update <id-or-npm-spec> | --all [--dry-run]
openclaw plugins update <id-or-npm-spec> | --all [--dry-run] [--acknowledge-non-clawhub-install]
openclaw plugins registry [--refresh] [--json]
openclaw plugins doctor
openclaw plugins init <id> [--name <name>] [--type tool|provider] [--directory <path>]
@@ -428,11 +428,13 @@ openclaw plugins update <id-or-npm-spec>
openclaw plugins update --all
openclaw plugins update <id-or-npm-spec> --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.
<AccordionGroup>
<Accordion title="Resolving plugin id vs npm spec">
+4 -2
View File
@@ -23,7 +23,7 @@ Related:
```bash
openclaw promos list
openclaw promos claim <slug>
openclaw promos claim <slug> --api-key <key> --set-default
openclaw promos claim <slug> --api-key <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 —
+1 -1
View File
@@ -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.
@@ -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 },
);
+1 -1
View File
@@ -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
+11 -6
View File
@@ -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);
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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
@@ -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 \
+2 -2
View File
@@ -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
+9 -9
View File
@@ -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
@@ -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"
@@ -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"
@@ -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"
+1 -1
View File
@@ -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
+1
View File
@@ -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"]],
+53 -6
View File
@@ -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({
+35 -10
View File
@@ -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<string, unknown>;
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" },
});
+24
View File
@@ -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,
}),
});
});
});
+14 -2
View File
@@ -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 () =>
+13
View File
@@ -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",
+21 -2
View File
@@ -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>", "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),
});
});
});
@@ -17,6 +17,12 @@ export type NonClawHubInstallAcknowledgementOptions = {
acknowledgeNonClawHubInstall?: boolean;
};
export type NonClawHubInstallAcknowledgementRequest = {
pluginId: string;
sourceClass: NonClawHubInstallSourceClass;
spec: string;
};
const sourceClassLabels: Record<NonClawHubInstallSourceClass, string> = {
git: "Git repository",
"local-archive": "local archive",
+8
View File
@@ -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),
},
});
});
+64
View File
@@ -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 () => {
+38
View File
@@ -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;
+8 -2
View File
@@ -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));
+14
View File
@@ -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');
+14
View File
@@ -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 <text>", "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),
});
});
+44
View File
@@ -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),
);
});
});
+25 -2
View File
@@ -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 <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);
});
});
}
+57
View File
@@ -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;
+9 -1
View File
@@ -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;
+9 -9
View File
@@ -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.",
]);
});
+5 -5
View File
@@ -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,
@@ -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.",
);
});
@@ -10,6 +10,11 @@ type MissingPluginInstallRepairCall = {
pluginIds: string[];
env?: NodeJS.ProcessEnv;
acknowledgeNonClawHubInstall?: boolean;
onNonClawHubInstall?: (request: {
pluginId: string;
sourceClass: "npm";
spec: string;
}) => boolean | Promise<boolean>;
};
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 () => {
+1 -1
View File
@@ -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,
+20
View File
@@ -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: {
+3
View File
@@ -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 } : {}),
};
@@ -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 () => {
+11 -3
View File
@@ -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,
};
}
@@ -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<boolean>;
config: Record<string, unknown>;
}) => {
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<string, unknown> } } }) => ({
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: {
@@ -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<string, PluginInstallRecord>();
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(
@@ -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";
+50 -2
View File
@@ -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<OpenClawConfig>) => {
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(() => {
+39 -3
View File
@@ -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
+103 -26
View File
@@ -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");
});
});
+42 -16
View File
@@ -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}`);
+5 -2
View File
@@ -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<OpenClawConfig>,
): Promise<OpenClawConfig> {
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,
+75 -1
View File
@@ -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<typeof vi.fn>).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<NonNullable<GuidedOnboardingDeps["applySetup"]>>(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 () => {
+36 -3
View File
@@ -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<CandidateAttempt> {
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<SetupInferenceCandidate["kind"]>();
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;
+13
View File
@@ -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 }],
+1
View File
@@ -40,6 +40,7 @@ const GUIDED_SAFE_ONBOARD_KEYS = new Set([
"resetScope",
"nonInteractive",
"classic",
"acknowledgeNonClawHubInstall",
]);
function wantsClassicInteractiveSetup(opts: OnboardOptions): boolean {
+38 -3
View File
@@ -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");
+7 -2
View File
@@ -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;
}
+47 -7
View File
@@ -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 },
}),
);
});
+35 -20
View File
@@ -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)}`);
+42 -5
View File
@@ -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<boolean>;
};
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<RuntimePluginInstallResult>;
repair: (
params: RuntimePluginRepairParams,
) => Promise<{ required: boolean; changes: string[]; warnings: string[] }>;
repair: (params: RuntimePluginRepairParams) => Promise<RuntimePluginRepairResult>;
};
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<boolean>;
descriptor: RuntimePluginInstallDescriptor;
shouldEnsure: RuntimePluginSelection;
}): Promise<{ required: boolean; changes: string[]; warnings: string[] }> {
}): Promise<RuntimePluginRepairResult> {
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,
};
}
+3 -3
View File
@@ -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: [],
+5 -5
View File
@@ -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 {
+63 -5
View File
@@ -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", () => {
+34 -10
View File
@@ -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<CrestodianChatReply> {
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) {
+39 -1
View File
@@ -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;
+3
View File
@@ -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),
});
}
+15
View File
@@ -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 },
},
);
+23 -1
View File
@@ -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<CrestodianOperation, { kind: "plugin-install" }> {
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<string, unknown>;
};
@@ -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 } };
},
+2
View File
@@ -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" } },
+4
View File
@@ -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 {
+2
View File
@@ -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",
+52 -1
View File
@@ -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."],
+13 -6
View File
@@ -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,
+3
View File
@@ -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,
});
+55
View File
@@ -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(
+33
View File
@@ -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<string, string>;
/** Set false when the caller requires explicit approval before an npm installer runs. */
allowNonClawHubInstall?: boolean;
onNonClawHubInstall?: (
request: HookPackUpdateNonClawHubInstallRequest,
) => boolean | Promise<boolean>;
onIntegrityDrift?: (params: HookPackUpdateIntegrityDriftParams) => boolean | Promise<boolean>;
}): Promise<HookPackUpdateSummary> {
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);
@@ -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;
+4 -4
View File
@@ -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: () => {
+41 -5
View File
@@ -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),
}
: {}),
},
},
+20
View File
@@ -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<string, string> };
expect(resolveOpenClawToolsMcpCrestodianApproval(server.env)).toEqual({
approvalArmed: true,
proposalRef: { current: proposal },
});
});
});
+179 -1
View File
@@ -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(
+125 -1
View File
@@ -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<PluginUpdateStatus, "skipped">;
@@ -126,6 +135,12 @@ export type PluginUpdateIntegrityDriftParams = {
dryRun: boolean;
};
export type PluginUpdateNonClawHubInstallRequest = {
pluginId: string;
source: Exclude<PluginInstallRecord["source"], "clawhub">;
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<boolean>;
acknowledgeClawHubRisk?: boolean;
onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise<boolean>;
/** Set false when the caller requires explicit approval before any non-ClawHub installer runs. */
allowNonClawHubInstall?: boolean;
onNonClawHubInstall?: (
request: PluginUpdateNonClawHubInstallRequest,
) => boolean | Promise<boolean>;
}): Promise<PluginUpdateSummary> {
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<boolean> => {
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<ReturnType<typeof installPluginFromNpmSpec>>
| Awaited<ReturnType<typeof installPluginFromClawHub>>
@@ -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<boolean>;
/** Set false when the caller requires explicit approval before an external npm install. */
allowNonClawHubInstall?: boolean;
}): Promise<PluginChannelSyncResult> {
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,
@@ -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);