diff --git a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml index 5a1e6e7415c..cbe4ae1a639 100644 --- a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml +++ b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml @@ -430,6 +430,11 @@ jobs: command: pnpm test:docker:doctor-switch timeout_minutes: 60 release_path: true + - suite_id: docker-update-channel-switch + label: Update Channel Switch Docker E2E + command: pnpm test:docker:update-channel-switch + timeout_minutes: 60 + release_path: true - suite_id: docker-session-runtime-context label: Session Runtime Context Docker E2E command: pnpm test:docker:session-runtime-context diff --git a/CHANGELOG.md b/CHANGELOG.md index 094bc06d8b5..18b5e8c8406 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ Docs: https://docs.openclaw.ai ### Fixes +- Onboarding/setup: keep first-run config reads, plugin compatibility notices, and post-model sanity checks on cold metadata paths unless the user chooses to browse all models, avoiding full plugin/runtime catalog work between prompts. Thanks @shakkernerd. +- Onboarding/auth: run manifest-owned provider auth choices through scoped setup providers so selecting OpenAI Codex browser/device auth no longer loads every provider runtime before OAuth starts. Thanks @shakkernerd. +- Onboarding/auth: keep the post-auth default-model policy lookup on manifest/setup metadata so the next prompt appears without loading broad provider runtime. Thanks @shakkernerd. +- Onboarding/models: keep skip-auth and provider-scoped model picker prompts off the full global model catalog path, and cache provider catalog hook resolution so setup no longer stalls after auth on large plugin registries. Thanks @shakkernerd. - Gateway/Bonjour: suppress known @homebridge/ciao cancellation and network assertion failures through scoped process handlers so malformed mDNS packets or restricted VPS networking disable/restart Bonjour instead of crashing the gateway. Fixes #67578. Thanks @zenassist26-create. - Discord: keep late clicks on already-resolved exec approval buttons quiet when elevated mode auto-resolved the request, while still surfacing real approval submission failures. Fixes #66906. Thanks @rlerikse. diff --git a/docs/help/testing.md b/docs/help/testing.md index c7b012fa725..33b8728efb6 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -172,6 +172,10 @@ runs the same lanes before release approval. - Use `--platform macos`, `--platform windows`, or `--platform linux` while iterating on one guest. Use `--json` for the summary artifact path and per-lane status. + - The OpenAI lane uses `openai/gpt-5.5` for the live agent-turn proof by + default. Pass `--model ` or set + `OPENCLAW_PARALLELS_OPENAI_MODEL` when deliberately validating another + OpenAI model. - Wrap long local runs in a host timeout so Parallels transport stalls cannot consume the rest of the testing window: @@ -603,7 +607,7 @@ These Docker runners split into two buckets: `OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=90000`. Override those env vars when you explicitly want the larger exhaustive scan. - `test:docker:all` builds the live Docker image once via `test:docker:live-build`, then reuses it for the live Docker lanes. It also builds one shared `scripts/e2e/Dockerfile` image via `test:docker:e2e-build` and reuses it for the E2E container smoke runners that exercise the built app. The aggregate uses a weighted local scheduler: `OPENCLAW_DOCKER_ALL_PARALLELISM` controls process slots, while resource caps keep heavy live, npm-install, and multi-service lanes from all starting at once. Defaults are 10 slots, `OPENCLAW_DOCKER_ALL_LIVE_LIMIT=6`, `OPENCLAW_DOCKER_ALL_NPM_LIMIT=8`, and `OPENCLAW_DOCKER_ALL_SERVICE_LIMIT=7`; tune `OPENCLAW_DOCKER_ALL_WEIGHT_LIMIT` or `OPENCLAW_DOCKER_ALL_DOCKER_LIMIT` only when the Docker host has more headroom. The runner performs a Docker preflight by default, removes stale OpenClaw E2E containers, prints status every 30 seconds, stores successful lane timings in `.artifacts/docker-tests/lane-timings.json`, and uses those timings to start longer lanes first on later runs. Use `OPENCLAW_DOCKER_ALL_DRY_RUN=1` to print the weighted lane manifest without building or running Docker. -- Container smoke runners: `test:docker:openwebui`, `test:docker:onboard`, `test:docker:npm-onboard-channel-agent`, `test:docker:session-runtime-context`, `test:docker:agents-delete-shared-workspace`, `test:docker:gateway-network`, `test:docker:browser-cdp-snapshot`, `test:docker:mcp-channels`, `test:docker:pi-bundle-mcp-tools`, `test:docker:cron-mcp-cleanup`, `test:docker:plugins`, `test:docker:plugin-update`, and `test:docker:config-reload` boot one or more real containers and verify higher-level integration paths. +- Container smoke runners: `test:docker:openwebui`, `test:docker:onboard`, `test:docker:npm-onboard-channel-agent`, `test:docker:update-channel-switch`, `test:docker:session-runtime-context`, `test:docker:agents-delete-shared-workspace`, `test:docker:gateway-network`, `test:docker:browser-cdp-snapshot`, `test:docker:mcp-channels`, `test:docker:pi-bundle-mcp-tools`, `test:docker:cron-mcp-cleanup`, `test:docker:plugins`, `test:docker:plugin-update`, and `test:docker:config-reload` boot one or more real containers and verify higher-level integration paths. The live-model Docker runners also bind-mount only the needed CLI auth homes (or all supported ones when the run is not narrowed), then copy them into the container home before the run so external-CLI OAuth can refresh tokens without mutating the host auth store: @@ -615,6 +619,7 @@ The live-model Docker runners also bind-mount only the needed CLI auth homes (or - Open WebUI live smoke: `pnpm test:docker:openwebui` (script: `scripts/e2e/openwebui-docker.sh`) - Onboarding wizard (TTY, full scaffolding): `pnpm test:docker:onboard` (script: `scripts/e2e/onboard-docker.sh`) - Npm tarball onboarding/channel/agent smoke: `pnpm test:docker:npm-onboard-channel-agent` installs the packed OpenClaw tarball globally in Docker, configures OpenAI via env-ref onboarding plus Telegram by default, verifies doctor repairs activated plugin runtime deps, and runs one mocked OpenAI agent turn. Reuse a prebuilt tarball with `OPENCLAW_NPM_ONBOARD_PACKAGE_TGZ=/path/to/openclaw-*.tgz`, skip the host rebuild with `OPENCLAW_NPM_ONBOARD_HOST_BUILD=0`, or switch channel with `OPENCLAW_NPM_ONBOARD_CHANNEL=discord`. +- Update channel switch smoke: `pnpm test:docker:update-channel-switch` installs the packed OpenClaw tarball globally in Docker, switches from package `stable` to git `dev`, verifies the persisted channel and plugin post-update work, then switches back to package `stable` and checks update status. - Session runtime context smoke: `pnpm test:docker:session-runtime-context` verifies hidden runtime context transcript persistence plus doctor repair of affected duplicated prompt-rewrite branches. - Bun global install smoke: `bash scripts/e2e/bun-global-install-smoke.sh` packs the current tree, installs it with `bun install -g` in an isolated home, and verifies `openclaw infer image providers --json` returns bundled image providers instead of hanging. Reuse a prebuilt tarball with `OPENCLAW_BUN_GLOBAL_SMOKE_PACKAGE_TGZ=/path/to/openclaw-*.tgz`, skip the host build with `OPENCLAW_BUN_GLOBAL_SMOKE_HOST_BUILD=0`, or copy `dist/` from a built Docker image with `OPENCLAW_BUN_GLOBAL_SMOKE_DIST_IMAGE=openclaw-dockerfile-smoke:local`. - Installer Docker smoke: `bash scripts/test-install-sh-docker.sh` shares one npm cache across its root, update, and direct-npm containers. Update smoke defaults to npm `latest` as the stable baseline before upgrading to the candidate tarball. Non-root installer checks keep an isolated npm cache so root-owned cache entries do not mask user-local install behavior. Set `OPENCLAW_INSTALL_SMOKE_NPM_CACHE_DIR=/path/to/cache` to reuse the root/update/direct-npm cache across local reruns. diff --git a/extensions/openai/setup-api.ts b/extensions/openai/setup-api.ts index 4d41fff3771..0993f07f54c 100644 --- a/extensions/openai/setup-api.ts +++ b/extensions/openai/setup-api.ts @@ -1,11 +1,111 @@ import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; +import type { ProviderAuthContext, ProviderAuthResult } from "openclaw/plugin-sdk/plugin-entry"; +import type { ProviderAuthMethod } from "openclaw/plugin-sdk/plugin-entry"; +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; +import { + OPENAI_API_KEY_LABEL, + OPENAI_API_KEY_WIZARD_GROUP, + OPENAI_CODEX_DEVICE_PAIRING_HINT, + OPENAI_CODEX_DEVICE_PAIRING_LABEL, + OPENAI_CODEX_LOGIN_HINT, + OPENAI_CODEX_LOGIN_LABEL, + OPENAI_CODEX_WIZARD_GROUP, +} from "./auth-choice-copy.js"; import { buildOpenAICodexCliBackend } from "./cli-backend.js"; +async function runOpenAIProviderAuthMethod( + methodId: string, + ctx: ProviderAuthContext, +): Promise { + const { buildOpenAIProvider } = await import("./openai-provider.js"); + const method = buildOpenAIProvider().auth.find((entry) => entry.id === methodId); + if (!method) { + return { profiles: [] }; + } + return method.run(ctx); +} + +async function runOpenAICodexProviderAuthMethod( + methodId: string, + ctx: ProviderAuthContext, +): Promise { + const { buildOpenAICodexProviderPlugin } = await import("./openai-codex-provider.js"); + const method = buildOpenAICodexProviderPlugin().auth.find((entry) => entry.id === methodId); + if (!method) { + return { profiles: [] }; + } + return method.run(ctx); +} + +function buildOpenAISetupProvider(): ProviderPlugin { + const apiKeyMethod = { + id: "api-key", + label: OPENAI_API_KEY_LABEL, + hint: "Use your OpenAI API key directly", + kind: "api_key", + wizard: { + choiceId: "openai-api-key", + choiceLabel: OPENAI_API_KEY_LABEL, + ...OPENAI_API_KEY_WIZARD_GROUP, + }, + run: async (ctx) => runOpenAIProviderAuthMethod("api-key", ctx), + } satisfies ProviderAuthMethod; + + return { + id: "openai", + label: "OpenAI", + docsPath: "/providers/models", + envVars: ["OPENAI_API_KEY"], + auth: [apiKeyMethod], + }; +} + +function buildOpenAICodexSetupProvider(): ProviderPlugin { + const oauthMethod = { + id: "oauth", + label: OPENAI_CODEX_LOGIN_LABEL, + hint: OPENAI_CODEX_LOGIN_HINT, + kind: "oauth", + wizard: { + choiceId: "openai-codex", + choiceLabel: OPENAI_CODEX_LOGIN_LABEL, + choiceHint: OPENAI_CODEX_LOGIN_HINT, + assistantPriority: -30, + ...OPENAI_CODEX_WIZARD_GROUP, + }, + run: async (ctx) => runOpenAICodexProviderAuthMethod("oauth", ctx), + } satisfies ProviderAuthMethod; + + const deviceCodeMethod = { + id: "device-code", + label: OPENAI_CODEX_DEVICE_PAIRING_LABEL, + hint: OPENAI_CODEX_DEVICE_PAIRING_HINT, + kind: "device_code", + wizard: { + choiceId: "openai-codex-device-code", + choiceLabel: OPENAI_CODEX_DEVICE_PAIRING_LABEL, + choiceHint: OPENAI_CODEX_DEVICE_PAIRING_HINT, + assistantPriority: -10, + ...OPENAI_CODEX_WIZARD_GROUP, + }, + run: async (ctx) => runOpenAICodexProviderAuthMethod("device-code", ctx), + } satisfies ProviderAuthMethod; + + return { + id: "openai-codex", + label: "OpenAI Codex", + docsPath: "/providers/models", + auth: [oauthMethod, deviceCodeMethod], + }; +} + export default definePluginEntry({ id: "openai", name: "OpenAI Setup", description: "Lightweight OpenAI setup hooks", register(api) { + api.registerProvider(buildOpenAISetupProvider()); + api.registerProvider(buildOpenAICodexSetupProvider()); api.registerCliBackend(buildOpenAICodexCliBackend()); }, }); diff --git a/package.json b/package.json index 9a098cb9e2f..ba3f9def3e5 100644 --- a/package.json +++ b/package.json @@ -1542,6 +1542,7 @@ "test:docker:plugins": "bash scripts/e2e/plugins-docker.sh", "test:docker:qr": "bash scripts/e2e/qr-import-docker.sh", "test:docker:session-runtime-context": "bash scripts/e2e/session-runtime-context-docker.sh", + "test:docker:update-channel-switch": "bash scripts/e2e/update-channel-switch-docker.sh", "test:e2e": "node scripts/run-vitest.mjs run --config test/vitest/vitest.e2e.config.ts", "test:e2e:openshell": "OPENCLAW_E2E_OPENSHELL=1 node scripts/run-vitest.mjs run --config test/vitest/vitest.e2e.config.ts extensions/openshell/src/backend.e2e.test.ts", "test:extension": "node scripts/test-extension.mjs", diff --git a/scripts/e2e/Dockerfile b/scripts/e2e/Dockerfile index 036e2c6ead4..91bbcffcd1b 100644 --- a/scripts/e2e/Dockerfile +++ b/scripts/e2e/Dockerfile @@ -40,7 +40,7 @@ RUN --mount=type=cache,id=openclaw-pnpm-store,target=/home/appuser/.local/share/ FROM deps AS build -COPY --chown=appuser:appuser tsconfig.json tsconfig.plugin-sdk.dts.json tsdown.config.ts vitest.config.ts openclaw.mjs ./ +COPY --chown=appuser:appuser .oxlintrc.json tsconfig.json tsconfig.plugin-sdk.dts.json tsconfig.oxlint*.json tsdown.config.ts vitest.config.ts openclaw.mjs ./ COPY --chown=appuser:appuser src ./src COPY --chown=appuser:appuser test ./test COPY --chown=appuser:appuser scripts ./scripts diff --git a/scripts/e2e/parallels-linux-smoke.sh b/scripts/e2e/parallels-linux-smoke.sh index 2d6837c26c4..4b0712a24be 100644 --- a/scripts/e2e/parallels-linux-smoke.sh +++ b/scripts/e2e/parallels-linux-smoke.sh @@ -13,6 +13,7 @@ API_KEY_ENV="" AUTH_CHOICE="" AUTH_KEY_FLAG="" MODEL_ID="" +MODEL_ID_EXPLICIT=0 INSTALL_URL="https://openclaw.ai/install.sh" HOST_PORT="18427" HOST_PORT_EXPLICIT=0 @@ -103,6 +104,8 @@ Options: --mode --provider Provider auth/model lane. Default: openai + --model Override the model used for the agent-turn smoke. + Default: openai/gpt-5.5 for the OpenAI lane --api-key-env Host env var name for provider API key. Default: OPENAI_API_KEY for openai, ANTHROPIC_API_KEY for anthropic --openai-api-key-env Alias for --api-key-env (backward compatible) @@ -142,6 +145,11 @@ while [[ $# -gt 0 ]]; do PROVIDER="$2" shift 2 ;; + --model) + MODEL_ID="$2" + MODEL_ID_EXPLICIT=1 + shift 2 + ;; --api-key-env|--openai-api-key-env) API_KEY_ENV="$2" shift 2 @@ -200,19 +208,19 @@ case "$PROVIDER" in openai) AUTH_CHOICE="openai-api-key" AUTH_KEY_FLAG="openai-api-key" - MODEL_ID="openai/gpt-5.5" + [[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_OPENAI_MODEL:-openai/gpt-5.5}" [[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="OPENAI_API_KEY" ;; anthropic) AUTH_CHOICE="apiKey" AUTH_KEY_FLAG="anthropic-api-key" - MODEL_ID="anthropic/claude-sonnet-4-6" + [[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_ANTHROPIC_MODEL:-anthropic/claude-sonnet-4-6}" [[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="ANTHROPIC_API_KEY" ;; minimax) AUTH_CHOICE="minimax-global-api" AUTH_KEY_FLAG="minimax-api-key" - MODEL_ID="minimax/MiniMax-M2.7" + [[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_MINIMAX_MODEL:-minimax/MiniMax-M2.7}" [[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="MINIMAX_API_KEY" ;; *) @@ -764,13 +772,38 @@ verify_gateway_status() { return 1 } +prepare_agent_workspace() { + guest_exec /bin/sh -lc 'set -eu +workspace="${OPENCLAW_WORKSPACE_DIR:-$HOME/.openclaw/workspace}" +mkdir -p "$workspace/.openclaw" +cat > "$workspace/IDENTITY.md" <<'"'"'IDENTITY_EOF'"'"' +# Identity + +- Name: OpenClaw +- Purpose: Parallels Linux smoke test assistant. +IDENTITY_EOF +cat > "$workspace/.openclaw/workspace-state.json" <<'"'"'STATE_EOF'"'"' +{ + "version": 1, + "setupCompletedAt": "2026-01-01T00:00:00.000Z" +} +STATE_EOF +rm -f "$workspace/BOOTSTRAP.md"' +} + verify_local_turn() { guest_exec openclaw models set "$MODEL_ID" - guest_exec /usr/bin/env "$API_KEY_ENV=$API_KEY_VALUE" openclaw agent \ - --local \ - --agent main \ - --message ping \ - --json + guest_exec openclaw config set agents.defaults.skipBootstrap true --strict-json + prepare_agent_workspace + guest_exec /bin/sh -lc "$(cat < Provider auth/model lane. Default: openai + --model Override the model used for the agent-turn smoke. + Default: openai/gpt-5.5 for the OpenAI lane --api-key-env Host env var name for provider API key. Default: OPENAI_API_KEY for openai, ANTHROPIC_API_KEY for anthropic --openai-api-key-env Alias for --api-key-env (backward compatible) @@ -184,6 +187,11 @@ while [[ $# -gt 0 ]]; do PROVIDER="$2" shift 2 ;; + --model) + MODEL_ID="$2" + MODEL_ID_EXPLICIT=1 + shift 2 + ;; --api-key-env|--openai-api-key-env) API_KEY_ENV="$2" shift 2 @@ -258,19 +266,19 @@ case "$PROVIDER" in openai) AUTH_CHOICE="openai-api-key" AUTH_KEY_FLAG="openai-api-key" - MODEL_ID="openai/gpt-5.5" + [[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_OPENAI_MODEL:-openai/gpt-5.5}" [[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="OPENAI_API_KEY" ;; anthropic) AUTH_CHOICE="apiKey" AUTH_KEY_FLAG="anthropic-api-key" - MODEL_ID="anthropic/claude-sonnet-4-6" + [[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_ANTHROPIC_MODEL:-anthropic/claude-sonnet-4-6}" [[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="ANTHROPIC_API_KEY" ;; minimax) AUTH_CHOICE="minimax-global-api" AUTH_KEY_FLAG="minimax-api-key" - MODEL_ID="minimax/MiniMax-M2.7" + [[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_MINIMAX_MODEL:-minimax/MiniMax-M2.7}" [[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="MINIMAX_API_KEY" ;; *) @@ -1474,11 +1482,28 @@ show_gateway_status_compat() { verify_turn() { guest_current_user_exec "$GUEST_NODE_BIN" "$GUEST_OPENCLAW_ENTRY" models set "$MODEL_ID" + guest_current_user_exec "$GUEST_NODE_BIN" "$GUEST_OPENCLAW_ENTRY" config set agents.defaults.skipBootstrap true --strict-json guest_current_user_sh "$(cat < "\$workspace/IDENTITY.md" <<'IDENTITY_EOF' +# Identity + +- Name: OpenClaw +- Purpose: Parallels macOS smoke test assistant. +IDENTITY_EOF +cat > "\$workspace/.openclaw/workspace-state.json" <<'STATE_EOF' +{ + "version": 1, + "setupCompletedAt": "2026-01-01T00:00:00.000Z" +} +STATE_EOF +rm -f "\$workspace/BOOTSTRAP.md" exec /usr/bin/env $(shell_quote "$API_KEY_ENV=$API_KEY_VALUE") \ $(shell_quote "$GUEST_NODE_BIN") $(shell_quote "$GUEST_OPENCLAW_ENTRY") agent \ --agent main \ + --session-id parallels-macos-smoke \ --message $(shell_quote "Reply with exact ASCII text OK only.") \ --json EOF diff --git a/scripts/e2e/parallels-npm-update-smoke.sh b/scripts/e2e/parallels-npm-update-smoke.sh index 64566b87d4f..7dfb84e3335 100755 --- a/scripts/e2e/parallels-npm-update-smoke.sh +++ b/scripts/e2e/parallels-npm-update-smoke.sh @@ -13,6 +13,7 @@ API_KEY_ENV="" AUTH_CHOICE="" AUTH_KEY_FLAG="" MODEL_ID="" +MODEL_ID_EXPLICIT=0 PYTHON_BIN="${PYTHON_BIN:-}" PACKAGE_SPEC="" UPDATE_TARGET="" @@ -120,6 +121,8 @@ Options: Default: all --provider Provider auth/model lane. Default: openai + --model Override the model used for agent-turn smoke checks. + Default: openai/gpt-5.5 for the OpenAI lane --api-key-env Host env var name for provider API key. Default: OPENAI_API_KEY for openai, ANTHROPIC_API_KEY for anthropic --openai-api-key-env Alias for --api-key-env (backward compatible) @@ -149,6 +152,11 @@ while [[ $# -gt 0 ]]; do PROVIDER="$2" shift 2 ;; + --model) + MODEL_ID="$2" + MODEL_ID_EXPLICIT=1 + shift 2 + ;; --api-key-env|--openai-api-key-env) API_KEY_ENV="$2" shift 2 @@ -206,19 +214,19 @@ case "$PROVIDER" in openai) AUTH_CHOICE="openai-api-key" AUTH_KEY_FLAG="openai-api-key" - MODEL_ID="openai/gpt-5.5" + [[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_OPENAI_MODEL:-openai/gpt-5.5}" [[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="OPENAI_API_KEY" ;; anthropic) AUTH_CHOICE="apiKey" AUTH_KEY_FLAG="anthropic-api-key" - MODEL_ID="anthropic/claude-sonnet-4-6" + [[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_ANTHROPIC_MODEL:-anthropic/claude-sonnet-4-6}" [[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="ANTHROPIC_API_KEY" ;; minimax) AUTH_CHOICE="minimax-global-api" AUTH_KEY_FLAG="minimax-api-key" - MODEL_ID="minimax/MiniMax-M2.7" + [[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_MINIMAX_MODEL:-minimax/MiniMax-M2.7}" [[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="MINIMAX_API_KEY" ;; *) @@ -1104,7 +1112,8 @@ cat > "\$workspace/.openclaw/workspace-state.json" <<'STATE_EOF' } STATE_EOF rm -f "\$workspace/BOOTSTRAP.md" -/opt/homebrew/bin/openclaw models set "$MODEL_ID" + /opt/homebrew/bin/openclaw models set "$MODEL_ID" + /opt/homebrew/bin/openclaw config set agents.defaults.skipBootstrap true --strict-json /opt/homebrew/bin/openclaw agent --agent main --session-id "parallels-npm-update-macos-transport-recovery-$expected_needle" --message "Reply with exact ASCII text OK only." --json EOF macos_desktop_user_exec /bin/bash "$script_path" @@ -1235,7 +1244,8 @@ if (-not \$gatewayReady) { \$providerBytes = [Convert]::FromBase64String('$provider_key_b64') \$providerValue = [Text.Encoding]::UTF8.GetString(\$providerBytes) Set-Item -Path ('Env:' + '$API_KEY_ENV') -Value \$providerValue -& \$openclaw models set '$MODEL_ID' + & \$openclaw models set '$MODEL_ID' + & \$openclaw config set agents.defaults.skipBootstrap true --strict-json \$workspace = \$env:OPENCLAW_WORKSPACE_DIR if (-not \$workspace) { \$workspace = Join-Path \$env:USERPROFILE '.openclaw\\workspace' @@ -1692,7 +1702,8 @@ if [ -n "$expected_needle" ]; then esac fi /opt/homebrew/bin/openclaw update status --json -/opt/homebrew/bin/openclaw models set "$MODEL_ID" + /opt/homebrew/bin/openclaw models set "$MODEL_ID" + /opt/homebrew/bin/openclaw config set agents.defaults.skipBootstrap true --strict-json # Same-guest npm upgrades can leave launchd holding the old gateway process or # module graph briefly; wait for a fresh RPC-ready restart before the agent turn. # Fresh npm installs may not have a launchd service yet, so fall back to the @@ -1826,6 +1837,7 @@ if [ -n "$expected_needle" ]; then fi openclaw update status --json openclaw models set "$MODEL_ID" +openclaw config set agents.defaults.skipBootstrap true --strict-json workspace="\${OPENCLAW_WORKSPACE_DIR:-\$HOME/.openclaw/workspace}" mkdir -p "\$workspace/.openclaw" cat > "\$workspace/IDENTITY.md" <<'IDENTITY_EOF' @@ -1911,6 +1923,7 @@ if platform_enabled macos; then bash "$ROOT_DIR/scripts/e2e/parallels-macos-smoke.sh" \ --mode fresh \ --provider "$PROVIDER" \ + --model "$MODEL_ID" \ --api-key-env "$API_KEY_ENV" \ --target-package-spec "$PACKAGE_SPEC" \ --json >"$RUN_DIR/macos-fresh.log" 2>&1 & @@ -1922,6 +1935,7 @@ if platform_enabled windows; then bash "$ROOT_DIR/scripts/e2e/parallels-windows-smoke.sh" \ --mode fresh \ --provider "$PROVIDER" \ + --model "$MODEL_ID" \ --api-key-env "$API_KEY_ENV" \ --target-package-spec "$PACKAGE_SPEC" \ --json >"$RUN_DIR/windows-fresh.log" 2>&1 & @@ -1933,6 +1947,7 @@ if platform_enabled linux; then bash "$ROOT_DIR/scripts/e2e/parallels-linux-smoke.sh" \ --mode fresh \ --provider "$PROVIDER" \ + --model "$MODEL_ID" \ --api-key-env "$API_KEY_ENV" \ --target-package-spec "$PACKAGE_SPEC" \ --json >"$RUN_DIR/linux-fresh.log" 2>&1 & diff --git a/scripts/e2e/parallels-windows-smoke.sh b/scripts/e2e/parallels-windows-smoke.sh index a7799cd3e86..63088e20709 100644 --- a/scripts/e2e/parallels-windows-smoke.sh +++ b/scripts/e2e/parallels-windows-smoke.sh @@ -12,6 +12,7 @@ API_KEY_ENV="" AUTH_CHOICE="" AUTH_KEY_FLAG="" MODEL_ID="" +MODEL_ID_EXPLICIT=0 INSTALL_URL="https://openclaw.ai/install.ps1" HOST_PORT="18426" HOST_PORT_EXPLICIT=0 @@ -138,6 +139,8 @@ Options: --mode --provider Provider auth/model lane. Default: openai + --model Override the model used for the agent-turn smoke. + Default: openai/gpt-5.5 for the OpenAI lane --api-key-env Host env var name for provider API key. Default: OPENAI_API_KEY for openai, ANTHROPIC_API_KEY for anthropic --openai-api-key-env Alias for --api-key-env (backward compatible) @@ -183,6 +186,11 @@ while [[ $# -gt 0 ]]; do PROVIDER="$2" shift 2 ;; + --model) + MODEL_ID="$2" + MODEL_ID_EXPLICIT=1 + shift 2 + ;; --api-key-env|--openai-api-key-env) API_KEY_ENV="$2" shift 2 @@ -249,19 +257,19 @@ case "$PROVIDER" in openai) AUTH_CHOICE="openai-api-key" AUTH_KEY_FLAG="openai-api-key" - MODEL_ID="openai/gpt-5.5" + [[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_OPENAI_MODEL:-openai/gpt-5.5}" [[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="OPENAI_API_KEY" ;; anthropic) AUTH_CHOICE="apiKey" AUTH_KEY_FLAG="anthropic-api-key" - MODEL_ID="anthropic/claude-sonnet-4-6" + [[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_ANTHROPIC_MODEL:-anthropic/claude-sonnet-4-6}" [[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="ANTHROPIC_API_KEY" ;; minimax) AUTH_CHOICE="minimax-global-api" AUTH_KEY_FLAG="minimax-api-key" - MODEL_ID="minimax/MiniMax-M2.7" + [[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_MINIMAX_MODEL:-minimax/MiniMax-M2.7}" [[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="MINIMAX_API_KEY" ;; *) @@ -2367,8 +2375,31 @@ show_gateway_status_compat() { verify_turn() { guest_run_openclaw "" "" models set "$MODEL_ID" + guest_run_openclaw "" "" config set agents.defaults.skipBootstrap true --strict-json + guest_powershell "$(cat <<'EOF' +$workspace = $env:OPENCLAW_WORKSPACE_DIR +if (-not $workspace) { + $workspace = Join-Path $env:USERPROFILE '.openclaw\workspace' +} +$stateDir = Join-Path $workspace '.openclaw' +New-Item -ItemType Directory -Path $stateDir -Force | Out-Null +@' +# Identity + +- Name: OpenClaw +- Purpose: Parallels Windows smoke test assistant. +'@ | Set-Content -Path (Join-Path $workspace 'IDENTITY.md') -Encoding UTF8 +@' +{ + "version": 1, + "setupCompletedAt": "2026-01-01T00:00:00.000Z" +} +'@ | Set-Content -Path (Join-Path $stateDir 'workspace-state.json') -Encoding UTF8 +Remove-Item (Join-Path $workspace 'BOOTSTRAP.md') -Force -ErrorAction SilentlyContinue +EOF +)" guest_run_openclaw "$API_KEY_ENV" "$API_KEY_VALUE" \ - agent --agent main --message "Reply with exact ASCII text OK only." --json + agent --agent main --session-id parallels-windows-smoke --message "Reply with exact ASCII text OK only." --json } capture_latest_ref_failure() { diff --git a/scripts/e2e/update-channel-switch-docker.sh b/scripts/e2e/update-channel-switch-docker.sh new file mode 100755 index 00000000000..203c211db4e --- /dev/null +++ b/scripts/e2e/update-channel-switch-docker.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh" + +IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-update-channel-switch-e2e" OPENCLAW_UPDATE_CHANNEL_SWITCH_E2E_IMAGE)" +SKIP_BUILD="${OPENCLAW_UPDATE_CHANNEL_SWITCH_E2E_SKIP_BUILD:-0}" + +docker_e2e_build_or_reuse "$IMAGE_NAME" update-channel-switch "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "" "$SKIP_BUILD" + +echo "Running update channel switch E2E..." +docker run --rm \ + -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ + -e OPENCLAW_SKIP_CHANNELS=1 \ + -e OPENCLAW_SKIP_PROVIDERS=1 \ + "$IMAGE_NAME" \ + bash -lc 'set -euo pipefail + +export npm_config_loglevel=error +export npm_config_fund=false +export npm_config_audit=false +export npm_config_prefix=/tmp/npm-prefix +export NPM_CONFIG_PREFIX=/tmp/npm-prefix +export PNPM_HOME=/tmp/pnpm-home +export PATH="/tmp/npm-prefix/bin:/tmp/pnpm-home:$PATH" +export CI=true +export OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 +export OPENCLAW_NO_ONBOARD=1 +export OPENCLAW_NO_PROMPT=1 + +cat > /app/.gitignore <<'"'"'GITIGNORE'"'"' +node_modules +**/node_modules/ +dist +dist-runtime +.turbo +coverage +GITIGNORE + +node --import tsx scripts/write-package-dist-inventory.ts + +git config --global user.email "docker-e2e@openclaw.local" +git config --global user.name "OpenClaw Docker E2E" +git config --global gc.auto 0 +git -C /app init -q +git -C /app config gc.auto 0 +git -C /app add -A +git -C /app commit -qm "test fixture" +fixture_sha="$(git -C /app rev-parse HEAD)" + +pkg_tgz="$(npm pack --ignore-scripts --silent --pack-destination /tmp /app | tail -n 1 | tr -d "\r")" +pkg_tgz_path="/tmp/$pkg_tgz" +if [ ! -f "$pkg_tgz_path" ]; then + echo "npm pack failed (expected $pkg_tgz_path)" + exit 1 +fi + +npm install -g --prefix /tmp/npm-prefix --omit=optional "$pkg_tgz_path" + +home_dir="$(mktemp -d /tmp/openclaw-update-channel-switch-home.XXXXXX)" +export HOME="$home_dir" +mkdir -p "$HOME/.openclaw" +cat > "$HOME/.openclaw/openclaw.json" <<'"'"'JSON'"'"' +{ + "update": { + "channel": "stable" + }, + "plugins": {} +} +JSON + +export OPENCLAW_GIT_DIR=/app +export OPENCLAW_UPDATE_DEV_TARGET_REF="$fixture_sha" + +echo "==> package -> git dev channel" +set +e +dev_json="$(openclaw update --channel dev --yes --json --no-restart)" +dev_status=$? +set -e +printf "%s\n" "$dev_json" +if [ "$dev_status" -ne 0 ]; then + exit "$dev_status" +fi +DEV_JSON="$dev_json" node - <<'"'"'NODE'"'"' +const payload = JSON.parse(process.env.DEV_JSON); +if (payload.status !== "ok") { + throw new Error(`expected dev update status ok, got ${payload.status}`); +} +if (payload.mode !== "git") { + throw new Error(`expected dev update mode git, got ${payload.mode}`); +} +if (payload.postUpdate?.plugins?.status !== "ok") { + throw new Error(`expected plugin post-update ok, got ${JSON.stringify(payload.postUpdate?.plugins)}`); +} +NODE + +node - <<'"'"'NODE'"'"' +const fs = require("node:fs"); +const path = require("node:path"); +const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); +const config = JSON.parse(fs.readFileSync(configPath, "utf8")); +if (config.update?.channel !== "dev") { + throw new Error(`expected persisted update.channel dev, got ${JSON.stringify(config.update?.channel)}`); +} +NODE + +status_json="$(openclaw update status --json)" +printf "%s\n" "$status_json" +STATUS_JSON="$status_json" node - <<'"'"'NODE'"'"' +const payload = JSON.parse(process.env.STATUS_JSON); +if (payload.update?.installKind !== "git") { + throw new Error(`expected git install after dev switch, got ${payload.update?.installKind}`); +} +if (payload.channel?.value !== "dev" || payload.channel?.source !== "config") { + throw new Error(`expected dev config channel after dev switch, got ${JSON.stringify(payload.channel)}`); +} +NODE + +echo "==> git -> package stable channel" +set +e +stable_json="$(openclaw update --channel stable --tag "$pkg_tgz_path" --yes --json --no-restart)" +stable_status=$? +set -e +printf "%s\n" "$stable_json" +if [ "$stable_status" -ne 0 ]; then + exit "$stable_status" +fi +STABLE_JSON="$stable_json" node - <<'"'"'NODE'"'"' +const payload = JSON.parse(process.env.STABLE_JSON); +if (payload.status !== "ok") { + throw new Error(`expected stable update status ok, got ${payload.status}`); +} +if (!["npm", "pnpm", "bun"].includes(payload.mode)) { + throw new Error(`expected package-manager mode after stable switch, got ${payload.mode}`); +} +if (payload.postUpdate?.plugins?.status !== "ok") { + throw new Error(`expected plugin post-update ok, got ${JSON.stringify(payload.postUpdate?.plugins)}`); +} +NODE + +node - <<'"'"'NODE'"'"' +const fs = require("node:fs"); +const path = require("node:path"); +const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); +const config = JSON.parse(fs.readFileSync(configPath, "utf8")); +if (config.update?.channel !== "stable") { + throw new Error(`expected persisted update.channel stable, got ${JSON.stringify(config.update?.channel)}`); +} +NODE + +status_json="$(openclaw update status --json)" +printf "%s\n" "$status_json" +STATUS_JSON="$status_json" node - <<'"'"'NODE'"'"' +const payload = JSON.parse(process.env.STATUS_JSON); +if (payload.update?.installKind !== "package") { + throw new Error(`expected package install after stable switch, got ${payload.update?.installKind}`); +} +if (payload.channel?.value !== "stable" || payload.channel?.source !== "config") { + throw new Error(`expected stable config channel after stable switch, got ${JSON.stringify(payload.channel)}`); +} +NODE + +echo "OK" +' diff --git a/scripts/test-docker-all.mjs b/scripts/test-docker-all.mjs index a0df9dd0f29..10aa26964a9 100644 --- a/scripts/test-docker-all.mjs +++ b/scripts/test-docker-all.mjs @@ -246,6 +246,14 @@ const lanes = [ npmLane("doctor-switch", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:doctor-switch", { weight: 3, }), + npmLane( + "update-channel-switch", + "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:update-channel-switch", + { + timeoutMs: 30 * 60 * 1000, + weight: 3, + }, + ), lane("plugins", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:plugins", { resources: ["npm", "service"], weight: 6, diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index a52affc945c..e60bbd18a07 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -1693,41 +1693,68 @@ describe("update-cli", () => { expect(syncConfig?.plugins?.entries).toBeUndefined(); }); - it("skips plugin sync in the old process after switching from package to git", async () => { + it("persists channel and runs post-update work after switching from package to git", async () => { const tempDir = createCaseDir("openclaw-update"); + const gitRoot = path.join(tempDir, "..", "openclaw"); const completionCacheSpy = vi .spyOn(updateCliShared, "tryWriteCompletionCache") .mockResolvedValue(undefined); mockPackageInstallStatus(tempDir); + vi.mocked(readConfigFileSnapshot).mockResolvedValue({ + ...baseSnapshot, + parsed: { update: { channel: "stable" } }, + resolved: { update: { channel: "stable" } } as OpenClawConfig, + sourceConfig: { update: { channel: "stable" } } as OpenClawConfig, + runtimeConfig: { update: { channel: "stable" } } as OpenClawConfig, + config: { update: { channel: "stable" } } as OpenClawConfig, + }); vi.mocked(runGatewayUpdate).mockResolvedValue( makeOkUpdateResult({ mode: "git", - root: path.join(tempDir, "..", "openclaw"), + root: gitRoot, after: { version: "2026.4.10" }, }), ); - serviceLoaded.mockResolvedValue(true); - syncPluginsForUpdateChannel.mockRejectedValue( - new Error("Config validation failed: old host version"), + syncPluginsForUpdateChannel.mockImplementation(async ({ config }) => ({ + changed: false, + config, + summary: { + switchedToBundled: [], + switchedToNpm: [], + warnings: [], + errors: [], + }, + })); + updateNpmInstalledPlugins.mockImplementation(async ({ config }) => ({ + changed: false, + config, + outcomes: [], + })); + + await updateCommand({ channel: "dev", yes: true, restart: false }); + + const persistedConfig = vi.mocked(replaceConfigFile).mock.calls[0]?.[0]?.nextConfig; + expect(persistedConfig?.update?.channel).toBe("dev"); + expect(syncPluginsForUpdateChannel).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "dev", + config: expect.objectContaining({ + update: expect.objectContaining({ channel: "dev" }), + }), + workspaceDir: gitRoot, + }), ); - - await updateCommand({ channel: "dev", yes: true }); - - expect(syncPluginsForUpdateChannel).not.toHaveBeenCalled(); - expect(replaceConfigFile).not.toHaveBeenCalled(); - expect(completionCacheSpy).not.toHaveBeenCalled(); + expect(updateNpmInstalledPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + update: expect.objectContaining({ channel: "dev" }), + }), + }), + ); + expect(completionCacheSpy).toHaveBeenCalledWith(gitRoot, false); expect(runRestartScript).not.toHaveBeenCalled(); expect(runDaemonRestart).not.toHaveBeenCalled(); - expect(defaultRuntime.exit).toHaveBeenCalledWith(0); expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1); - expect( - vi - .mocked(defaultRuntime.log) - .mock.calls.map((call) => String(call[0])) - .join("\n"), - ).toContain( - "Switched from a package install to a git checkout. Skipping remaining post-update work in the old CLI process; rerun follow-up commands from the new git install if needed.", - ); }); it("explains why git updates cannot run with edited files", async () => { vi.mocked(defaultRuntime.log).mockClear(); diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index d2250ddc2b8..78c243d9c9c 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -1339,54 +1339,30 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { return; } - if (switchToGit && result.status === "ok" && result.mode === "git") { - if (!opts.json) { - defaultRuntime.log( - theme.muted( - "Switched from a package install to a git checkout. Skipping remaining post-update work in the old CLI process; rerun follow-up commands from the new git install if needed.", - ), - ); - } else { - defaultRuntime.writeJson(result); - } - defaultRuntime.exit(0); - return; - } - let postUpdateConfigSnapshot = configSnapshot; if (requestedChannel && configSnapshot.valid && requestedChannel !== storedChannel) { - if (switchToGit) { - if (!opts.json) { - defaultRuntime.log( - theme.muted( - `Skipped persisting update.channel=${requestedChannel} in the pre-update CLI process after switching to a git install.`, - ), - ); - } - } else { - const next = { - ...configSnapshot.sourceConfig, - update: { - ...configSnapshot.sourceConfig.update, - channel: requestedChannel, - }, - }; - await replaceConfigFile({ - nextConfig: next, - baseHash: configSnapshot.hash, - }); - postUpdateConfigSnapshot = { - ...configSnapshot, - hash: undefined, - parsed: next, - sourceConfig: asResolvedSourceConfig(next), - resolved: asResolvedSourceConfig(next), - runtimeConfig: asRuntimeConfig(next), - config: asRuntimeConfig(next), - }; - if (!opts.json) { - defaultRuntime.log(theme.muted(`Update channel set to ${requestedChannel}.`)); - } + const next = { + ...configSnapshot.sourceConfig, + update: { + ...configSnapshot.sourceConfig.update, + channel: requestedChannel, + }, + }; + await replaceConfigFile({ + nextConfig: next, + baseHash: configSnapshot.hash, + }); + postUpdateConfigSnapshot = { + ...configSnapshot, + hash: undefined, + parsed: next, + sourceConfig: asResolvedSourceConfig(next), + resolved: asResolvedSourceConfig(next), + runtimeConfig: asRuntimeConfig(next), + config: asRuntimeConfig(next), + }; + if (!opts.json) { + defaultRuntime.log(theme.muted(`Update channel set to ${requestedChannel}.`)); } } @@ -1409,16 +1385,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { postCorePluginUpdate = freshProcessResult.pluginUpdate; } - const deferOldProcessPostUpdateWork = switchToGit && result.mode === "git"; - if (deferOldProcessPostUpdateWork) { - if (!opts.json) { - defaultRuntime.log( - theme.muted( - "Skipped plugin update sync in the pre-update CLI process after switching to a git install.", - ), - ); - } - } else if (!pluginsUpdatedInFreshProcess) { + if (!pluginsUpdatedInFreshProcess) { postCorePluginUpdate = await runPostCorePluginUpdate({ root: postUpdateRoot, channel, @@ -1468,34 +1435,24 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { } } - if (deferOldProcessPostUpdateWork) { - if (!opts.json) { - defaultRuntime.log( - theme.muted( - "Skipped completion/restart follow-ups in the pre-update CLI process after switching to a git install.", - ), - ); - } - } else { - await tryWriteCompletionCache(postUpdateRoot, Boolean(opts.json)); - await tryInstallShellCompletion({ - jsonMode: Boolean(opts.json), - skipPrompt: Boolean(opts.yes), - }); + await tryWriteCompletionCache(postUpdateRoot, Boolean(opts.json)); + await tryInstallShellCompletion({ + jsonMode: Boolean(opts.json), + skipPrompt: Boolean(opts.yes), + }); - const restartOk = await maybeRestartService({ - shouldRestart, - result: resultWithPostUpdate, - opts, - refreshServiceEnv: refreshGatewayServiceEnv, - gatewayPort, - restartScriptPath, - invocationCwd, - }); - if (!restartOk) { - defaultRuntime.exit(1); - return; - } + const restartOk = await maybeRestartService({ + shouldRestart, + result: resultWithPostUpdate, + opts, + refreshServiceEnv: refreshGatewayServiceEnv, + gatewayPort, + restartScriptPath, + invocationCwd, + }); + if (!restartOk) { + defaultRuntime.exit(1); + return; } if (!opts.json) { diff --git a/src/commands/auth-choice.apply.api-providers.test.ts b/src/commands/auth-choice.apply.api-providers.test.ts index a0f2ee9fea1..59557ea1d63 100644 --- a/src/commands/auth-choice.apply.api-providers.test.ts +++ b/src/commands/auth-choice.apply.api-providers.test.ts @@ -5,9 +5,11 @@ import { normalizeApiKeyTokenProviderAuthChoice } from "./auth-choice.apply.api- const resolvePluginProviders = vi.hoisted(() => vi.fn(), ); +const resolvePluginSetupProvider = vi.hoisted(() => vi.fn(() => undefined)); vi.mock("../plugins/provider-auth-choice.runtime.js", () => ({ resolvePluginProviders, + resolvePluginSetupProvider, })); function createProvider(params: { diff --git a/src/commands/auth-choice.apply.plugin-provider.test.ts b/src/commands/auth-choice.apply.plugin-provider.test.ts index d4a87d82442..5082fff2237 100644 --- a/src/commands/auth-choice.apply.plugin-provider.test.ts +++ b/src/commands/auth-choice.apply.plugin-provider.test.ts @@ -12,18 +12,33 @@ type ResolveProviderInstallCatalogEntry = typeof import("../plugins/provider-install-catalog.js").resolveProviderInstallCatalogEntry; type EnsureOnboardingPluginInstalled = typeof import("../commands/onboarding-plugin-install.js").ensureOnboardingPluginInstalled; +type ResolveManifestProviderAuthChoice = + typeof import("../plugins/provider-auth-choices.js").resolveManifestProviderAuthChoice; +type ResolvePluginSetupProvider = + typeof import("../plugins/provider-auth-choice.runtime.js").resolvePluginSetupProvider; const resolvePluginProviders = vi.hoisted(() => vi.fn<() => ProviderPlugin[]>(() => [])); +const resolvePluginSetupProvider = vi.hoisted(() => + vi.fn(() => undefined), +); const resolveProviderPluginChoice = vi.hoisted(() => vi.fn<() => { provider: ProviderPlugin; method: ProviderAuthMethod } | null>(), ); const runProviderModelSelectedHook = vi.hoisted(() => vi.fn(async () => {})); vi.mock("../plugins/provider-auth-choice.runtime.js", () => ({ resolvePluginProviders, + resolvePluginSetupProvider, resolveProviderPluginChoice, runProviderModelSelectedHook, })); +const resolveManifestProviderAuthChoice = vi.hoisted(() => + vi.fn(() => undefined), +); +vi.mock("../plugins/provider-auth-choices.js", () => ({ + resolveManifestProviderAuthChoice, +})); + const upsertAuthProfile = vi.hoisted(() => vi.fn()); vi.mock("../agents/auth-profiles.js", () => ({ upsertAuthProfile, @@ -172,6 +187,8 @@ describe("applyAuthChoiceLoadedPluginProvider", () => { beforeEach(() => { vi.clearAllMocks(); applyAuthProfileConfig.mockImplementation((config) => config); + resolveManifestProviderAuthChoice.mockReturnValue(undefined); + resolvePluginSetupProvider.mockReturnValue(undefined); resolveProviderInstallCatalogEntry.mockReturnValue(undefined); ensureOnboardingPluginInstalled.mockImplementation(async ({ cfg, entry }) => ({ cfg, @@ -320,6 +337,36 @@ describe("applyAuthChoiceLoadedPluginProvider", () => { }); }); + it("uses manifest-owned setup providers without loading the broad provider runtime", async () => { + const provider = buildProvider(); + resolveManifestProviderAuthChoice.mockReturnValue({ + pluginId: "local-provider-plugin", + providerId: LOCAL_PROVIDER_ID, + methodId: LOCAL_AUTH_METHOD_ID, + choiceId: LOCAL_PROVIDER_ID, + choiceLabel: LOCAL_PROVIDER_LABEL, + }); + resolvePluginSetupProvider.mockReturnValue(provider); + resolveProviderPluginChoice.mockReturnValue({ + provider, + method: provider.auth[0], + }); + + const result = await applyAuthChoiceLoadedPluginProvider(buildParams()); + + expect(result?.config.agents?.defaults?.model).toEqual({ + primary: LOCAL_DEFAULT_MODEL, + }); + expect(resolvePluginSetupProvider).toHaveBeenCalledWith({ + provider: LOCAL_PROVIDER_ID, + config: {}, + workspaceDir: "/tmp/workspace", + env: undefined, + pluginIds: ["local-provider-plugin"], + }); + expect(resolvePluginProviders).not.toHaveBeenCalled(); + }); + it("installs a missing provider plugin and retries setup resolution", async () => { const provider = buildProvider(); resolveProviderInstallCatalogEntry.mockReturnValue(buildLocalProviderInstallCatalogEntry()); diff --git a/src/commands/auth-choice.model-check.test.ts b/src/commands/auth-choice.model-check.test.ts new file mode 100644 index 00000000000..b6e61286b98 --- /dev/null +++ b/src/commands/auth-choice.model-check.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { warnIfModelConfigLooksOff } from "./auth-choice.model-check.js"; +import { makePrompter } from "./setup/__tests__/test-utils.js"; + +const loadModelCatalog = vi.hoisted(() => vi.fn()); +vi.mock("../agents/model-catalog.js", () => ({ + loadModelCatalog, +})); + +const ensureAuthProfileStore = vi.hoisted(() => vi.fn(() => ({ version: 1, profiles: {} }))); +const listProfilesForProvider = vi.hoisted(() => vi.fn(() => [])); +vi.mock("../agents/auth-profiles.js", () => ({ + ensureAuthProfileStore, + listProfilesForProvider, +})); + +const resolveEnvApiKey = vi.hoisted(() => vi.fn(() => undefined)); +const hasUsableCustomProviderApiKey = vi.hoisted(() => vi.fn(() => false)); +vi.mock("../agents/model-auth.js", () => ({ + resolveEnvApiKey, + hasUsableCustomProviderApiKey, +})); + +describe("warnIfModelConfigLooksOff", () => { + beforeEach(() => { + vi.clearAllMocks(); + loadModelCatalog.mockResolvedValue([]); + }); + + it("skips catalog validation when requested while keeping auth checks", async () => { + const note = vi.fn(async () => {}); + const prompter = makePrompter({ note }); + const config = { + agents: { + defaults: { + model: "openai-codex/gpt-5.5", + }, + }, + } as OpenClawConfig; + + await warnIfModelConfigLooksOff(config, prompter, { validateCatalog: false }); + + expect(loadModelCatalog).not.toHaveBeenCalled(); + expect(ensureAuthProfileStore).toHaveBeenCalledOnce(); + expect(listProfilesForProvider).toHaveBeenCalledWith( + expect.objectContaining({ profiles: {} }), + "openai-codex", + ); + expect(note).toHaveBeenCalledWith( + expect.stringContaining('No auth configured for provider "openai-codex"'), + "Model check", + ); + }); + + it("keeps full catalog validation enabled by default", async () => { + const note = vi.fn(async () => {}); + const prompter = makePrompter({ note }); + const config = { + agents: { + defaults: { + model: "openai-codex/gpt-5.5", + }, + }, + } as OpenClawConfig; + + await warnIfModelConfigLooksOff(config, prompter); + + expect(loadModelCatalog).toHaveBeenCalledWith({ + config, + useCache: false, + }); + }); +}); diff --git a/src/commands/auth-choice.model-check.ts b/src/commands/auth-choice.model-check.ts index 0cced85226f..8624f3547af 100644 --- a/src/commands/auth-choice.model-check.ts +++ b/src/commands/auth-choice.model-check.ts @@ -9,25 +9,27 @@ import { buildProviderAuthRecoveryHint } from "./provider-auth-guidance.js"; export async function warnIfModelConfigLooksOff( config: OpenClawConfig, prompter: WizardPrompter, - options?: { agentId?: string; agentDir?: string }, + options?: { agentId?: string; agentDir?: string; validateCatalog?: boolean }, ) { const ref = resolveDefaultModelForAgent({ cfg: config, agentId: options?.agentId, }); const warnings: string[] = []; - const catalog = await loadModelCatalog({ - config, - useCache: false, - }); - if (catalog.length > 0) { - const known = catalog.some( - (entry) => entry.provider === ref.provider && entry.id === ref.model, - ); - if (!known) { - warnings.push( - `Model not found: ${ref.provider}/${ref.model}. Update agents.defaults.model or run /models list.`, + if (options?.validateCatalog !== false) { + const catalog = await loadModelCatalog({ + config, + useCache: false, + }); + if (catalog.length > 0) { + const known = catalog.some( + (entry) => entry.provider === ref.provider && entry.id === ref.model, ); + if (!known) { + warnings.push( + `Model not found: ${ref.provider}/${ref.model}. Update agents.defaults.model or run /models list.`, + ); + } } } diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index 99cf5f99878..d6043e21f73 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -624,6 +624,7 @@ describe("applyAuthChoice", () => { providerAuthChoiceTesting.setDepsForTest({ loadPluginProviderRuntime: async () => ({ resolvePluginProviders, + resolvePluginSetupProvider: () => undefined, resolveProviderPluginChoice, runProviderModelSelectedHook, }), diff --git a/src/commands/configure.gateway-auth.ts b/src/commands/configure.gateway-auth.ts index a507356f80a..5047a0e4342 100644 --- a/src/commands/configure.gateway-auth.ts +++ b/src/commands/configure.gateway-auth.ts @@ -133,7 +133,8 @@ export async function promptAuthConfig( prompter, allowKeep: true, ignoreAllowlist: true, - includeProviderPluginSetups: true, + includeProviderPluginSetups: false, + loadCatalog: false, preferredProvider, workspaceDir: resolveDefaultAgentWorkspaceDir(), runtime, @@ -176,6 +177,7 @@ export async function promptAuthConfig( initialSelections: modelAllowlist?.initialSelections, message: modelAllowlist?.message, preferredProvider, + loadCatalog: false, }); if (allowlistSelection.models) { next = applyModelFallbacksFromSelection(next, allowlistSelection.models, { diff --git a/src/commands/model-picker.test.ts b/src/commands/model-picker.test.ts index 93f63f95a16..e654864f876 100644 --- a/src/commands/model-picker.test.ts +++ b/src/commands/model-picker.test.ts @@ -233,6 +233,87 @@ describe("promptDefaultModel", () => { ); }); + it("keeps current preferred-provider models cold until browsing is requested", async () => { + const select = vi.fn(async (params) => params.initialValue as never); + const prompter = makePrompter({ select }); + const config = { + agents: { + defaults: { + model: "openai-codex/gpt-5.5", + }, + }, + } as OpenClawConfig; + + const result = await promptDefaultModel({ + config, + prompter, + allowKeep: true, + includeManual: true, + ignoreAllowlist: true, + preferredProvider: "openai-codex", + browseCatalogOnDemand: true, + }); + + expect(result).toEqual({}); + expect(loadModelCatalog).not.toHaveBeenCalled(); + expect(select.mock.calls[0]?.[0]).toMatchObject({ + searchable: false, + initialValue: "__keep__", + }); + expect(select.mock.calls[0]?.[0]?.options).toEqual([ + expect.objectContaining({ value: "__keep__" }), + expect.objectContaining({ value: "__manual__" }), + expect.objectContaining({ value: "__browse__" }), + ]); + }); + + it("loads the full model catalog when the user chooses to browse", async () => { + loadModelCatalog.mockResolvedValue([ + { + provider: "openai-codex", + id: "gpt-5.5", + name: "GPT-5.5", + }, + { + provider: "openai-codex", + id: "gpt-5.5-pro", + name: "GPT-5.5 Pro", + }, + ]); + const select = vi + .fn() + .mockResolvedValueOnce("__browse__") + .mockImplementationOnce(async (params) => { + const option = params.options.find( + (entry: { value: string }) => entry.value === "openai-codex/gpt-5.5-pro", + ); + return option?.value ?? params.initialValue; + }); + const prompter = makePrompter({ select }); + const config = { + agents: { + defaults: { + model: "openai-codex/gpt-5.5", + }, + }, + } as OpenClawConfig; + + const result = await promptDefaultModel({ + config, + prompter, + allowKeep: true, + includeManual: true, + ignoreAllowlist: true, + preferredProvider: "openai-codex", + browseCatalogOnDemand: true, + }); + + expect(result.model).toBe("openai-codex/gpt-5.5-pro"); + expect(loadModelCatalog).toHaveBeenCalledOnce(); + expect(select).toHaveBeenCalledTimes(2); + expect(select.mock.calls[1]?.[0]?.searchable).toBe(true); + }); + it("supports configuring vLLM during setup", async () => { loadModelCatalog.mockResolvedValue([ { @@ -360,6 +441,40 @@ describe("promptDefaultModel", () => { expect.arrayContaining([expect.objectContaining({ value: "legacy-entry" })]), ); }); + + it("keeps skip-auth model selection cold when catalog loading is disabled", async () => { + const select = vi.fn(async (params) => params.initialValue as never); + const prompter = makePrompter({ select }); + const config = { + agents: { + defaults: { + model: "openai/gpt-5.5", + }, + }, + } as OpenClawConfig; + + const result = await promptDefaultModel({ + config, + prompter, + allowKeep: true, + includeManual: true, + ignoreAllowlist: true, + includeProviderPluginSetups: true, + loadCatalog: false, + agentDir: "/tmp/openclaw-agent", + runtime: {} as never, + }); + + expect(result).toEqual({}); + expect(loadModelCatalog).not.toHaveBeenCalled(); + expect(resolveProviderModelPickerEntries).not.toHaveBeenCalled(); + expect(providerModelPickerContributionRuntime.resolve).not.toHaveBeenCalled(); + expect(select.mock.calls[0]?.[0]?.options).toEqual([ + expect.objectContaining({ value: "__keep__" }), + expect.objectContaining({ value: "__manual__" }), + expect.objectContaining({ value: "openai/gpt-5.5" }), + ]); + }); }); describe("promptModelAllowlist", () => { @@ -607,6 +722,64 @@ describe("promptModelAllowlist", () => { scopeKeys: ["openai/gpt-5.5", "openai/gpt-5.4"], }); }); + + it("uses configured provider-scoped seeds without loading the full catalog", async () => { + const multiselect = vi.fn(async (params) => params.initialValues ?? []); + const prompter = makePrompter({ multiselect }); + const config = { + agents: { + defaults: { + model: "openai-codex/gpt-5.5", + }, + }, + } as OpenClawConfig; + + const result = await promptModelAllowlist({ + config, + prompter, + preferredProvider: "openai-codex", + loadCatalog: false, + }); + + expect(loadModelCatalog).not.toHaveBeenCalled(); + expect(multiselect.mock.calls[0]?.[0]?.options).toEqual([ + expect.objectContaining({ value: "openai-codex/gpt-5.5" }), + ]); + expect(multiselect.mock.calls[0]?.[0]?.initialValues).toEqual(["openai-codex/gpt-5.5"]); + expect(result).toEqual({ + models: ["openai-codex/gpt-5.5"], + scopeKeys: ["openai-codex/gpt-5.5"], + }); + }); + + it("uses explicit allowed model keys without loading the full catalog", async () => { + const multiselect = createSelectAllMultiselect(); + const prompter = makePrompter({ multiselect }); + const config = { + agents: { + defaults: { + model: "openai-codex/gpt-5.5", + }, + }, + } as OpenClawConfig; + + const result = await promptModelAllowlist({ + config, + prompter, + allowedKeys: ["openai-codex/gpt-5.5", "openai-codex/gpt-5.4"], + preferredProvider: "openai-codex", + }); + + expect(loadModelCatalog).not.toHaveBeenCalled(); + expect( + multiselect.mock.calls[0]?.[0]?.options.map((option: { value: string }) => option.value), + ).toEqual(["openai-codex/gpt-5.5", "openai-codex/gpt-5.4"]); + expect(multiselect.mock.calls[0]?.[0]?.initialValues).toEqual(["openai-codex/gpt-5.5"]); + expect(result).toEqual({ + models: ["openai-codex/gpt-5.5", "openai-codex/gpt-5.4"], + scopeKeys: ["openai-codex/gpt-5.5", "openai-codex/gpt-5.4"], + }); + }); }); describe("runtime model picker visibility", () => { diff --git a/src/docker-build-cache.test.ts b/src/docker-build-cache.test.ts index 3cfc5b01d10..9854c135f9a 100644 --- a/src/docker-build-cache.test.ts +++ b/src/docker-build-cache.test.ts @@ -116,7 +116,7 @@ describe("docker build cache layout", () => { /^COPY(?:\s+--chown=\S+)?\s+scripts\/postinstall-bundled-plugins\.mjs scripts\/preinstall-package-manager-warning\.mjs scripts\/npm-runner\.mjs scripts\/windows-cmd-helpers\.mjs \.\/scripts\/$/m, ); expectPatternAfterInstall( - /^COPY(?:\s+--chown=\S+)?\s+tsconfig\.json tsconfig\.plugin-sdk\.dts\.json tsdown\.config\.ts vitest\.config\.ts openclaw\.mjs \.\/$/m, + /^COPY(?:\s+--chown=\S+)?\s+\.oxlintrc\.json tsconfig\.json tsconfig\.plugin-sdk\.dts\.json tsconfig\.oxlint\*\.json tsdown\.config\.ts vitest\.config\.ts openclaw\.mjs \.\/$/m, ); expectPatternAfterInstall(/^COPY(?:\s+--chown=\S+)?\s+src \.\/src$/m); expectPatternAfterInstall(/^COPY(?:\s+--chown=\S+)?\s+test \.\/test$/m); diff --git a/src/flows/model-picker.ts b/src/flows/model-picker.ts index bc3e7d7274e..2301de4e9ff 100644 --- a/src/flows/model-picker.ts +++ b/src/flows/model-picker.ts @@ -33,6 +33,7 @@ export { applyPrimaryModel } from "../plugins/provider-model-primary.js"; const KEEP_VALUE = "__keep__"; const MANUAL_VALUE = "__manual__"; +const BROWSE_VALUE = "__browse__"; const PROVIDER_FILTER_THRESHOLD = 30; // Internal router models are valid defaults during auth/setup but not manual API targets. @@ -45,6 +46,8 @@ export type PromptDefaultModelParams = { includeManual?: boolean; includeProviderPluginSetups?: boolean; ignoreAllowlist?: boolean; + loadCatalog?: boolean; + browseCatalogOnDemand?: boolean; preferredProvider?: string; agentDir?: string; workspaceDir?: string; @@ -229,6 +232,45 @@ function addModelSelectOption(params: { params.seen.add(key); } +function splitModelKey(key: string): { provider: string; id: string } | undefined { + const slashIndex = key.indexOf("/"); + if (slashIndex <= 0 || slashIndex >= key.length - 1) { + return undefined; + } + return { + provider: key.slice(0, slashIndex), + id: key.slice(slashIndex + 1), + }; +} + +function addModelKeySelectOption(params: { + key: string; + options: WizardSelectOption[]; + seen: Set; + aliasIndex: ReturnType; + hasAuth: (provider: string) => boolean; + fallbackHint: string; +}) { + const entry = splitModelKey(params.key); + if (!entry) { + return; + } + const before = params.seen.size; + addModelSelectOption({ + entry, + options: params.options, + seen: params.seen, + aliasIndex: params.aliasIndex, + hasAuth: params.hasAuth, + }); + if (params.seen.size > before) { + const option = params.options.at(-1); + if (option && !option.hint) { + option.hint = params.fallbackHint; + } + } +} + function createPreferredProviderMatcher(params: { preferredProvider: string; cfg: OpenClawConfig; @@ -467,24 +509,123 @@ export async function promptDefaultModel( const allowKeep = params.allowKeep ?? true; const includeManual = params.includeManual ?? true; const includeProviderPluginSetups = params.includeProviderPluginSetups ?? false; + const loadCatalog = params.loadCatalog ?? true; + const browseCatalogOnDemand = params.browseCatalogOnDemand ?? false; const ignoreAllowlist = params.ignoreAllowlist ?? false; const preferredProviderRaw = normalizeOptionalString(params.preferredProvider); const preferredProvider = preferredProviderRaw ? normalizeProviderId(preferredProviderRaw) : undefined; const configuredRaw = resolveConfiguredModelRaw(cfg); + const useStaticModelNormalization = !loadCatalog || browseCatalogOnDemand; const resolved = resolveConfiguredModelRef({ cfg, defaultProvider: DEFAULT_PROVIDER, defaultModel: DEFAULT_MODEL, + allowPluginNormalization: useStaticModelNormalization ? false : undefined, }); const resolvedKey = modelKey(resolved.provider, resolved.model); const configuredKey = configuredRaw ? resolvedKey : ""; + if ( + loadCatalog && + browseCatalogOnDemand && + preferredProvider && + allowKeep && + normalizeProviderId(resolved.provider) === preferredProvider + ) { + const options: WizardSelectOption[] = [ + { + value: KEEP_VALUE, + label: configuredRaw + ? `Keep current (${configuredRaw})` + : `Keep current (default: ${resolvedKey})`, + hint: + configuredRaw && configuredRaw !== resolvedKey ? `resolves to ${resolvedKey}` : undefined, + }, + ]; + if (includeManual) { + options.push({ value: MANUAL_VALUE, label: "Enter model manually" }); + } + options.push({ + value: BROWSE_VALUE, + label: "Browse all models", + hint: "loads provider catalogs", + }); + + const selection = await params.prompter.select({ + message: params.message ?? "Default model", + options, + initialValue: KEEP_VALUE, + searchable: false, + }); + if (selection === KEEP_VALUE) { + return {}; + } + if (selection === MANUAL_VALUE) { + return promptManualModel({ + prompter: params.prompter, + allowBlank: false, + initialValue: configuredRaw || resolvedKey || undefined, + }); + } + if (selection !== BROWSE_VALUE) { + return { model: selection }; + } + } + + if (!loadCatalog) { + const options: WizardSelectOption[] = []; + if (allowKeep) { + options.push({ + value: KEEP_VALUE, + label: configuredRaw + ? `Keep current (${configuredRaw})` + : `Keep current (default: ${resolvedKey})`, + hint: + configuredRaw && configuredRaw !== resolvedKey ? `resolves to ${resolvedKey}` : undefined, + }); + } + if (includeManual) { + options.push({ value: MANUAL_VALUE, label: "Enter model manually" }); + } + if (configuredKey && !options.some((option) => option.value === configuredKey)) { + options.push({ + value: configuredKey, + label: configuredKey, + hint: "current", + }); + } + if (options.length === 0) { + return promptManualModel({ + prompter: params.prompter, + allowBlank: allowKeep, + initialValue: configuredRaw || resolvedKey || undefined, + }); + } + const selection = await params.prompter.select({ + message: params.message ?? "Default model", + options, + initialValue: allowKeep ? KEEP_VALUE : configuredKey || MANUAL_VALUE, + searchable: false, + }); + if (selection === KEEP_VALUE) { + return {}; + } + if (selection === MANUAL_VALUE) { + return promptManualModel({ + prompter: params.prompter, + allowBlank: false, + initialValue: configuredRaw || resolvedKey || undefined, + }); + } + return { model: selection }; + } + const catalogProgress = params.prompter.progress("Loading available models"); let catalog: Awaited>; try { - catalog = await loadModelCatalog({ config: cfg, useCache: false }); + catalog = await loadModelCatalog({ config: cfg }); } finally { catalogProgress.stop(); } @@ -647,9 +788,11 @@ export async function promptModelAllowlist(params: { allowedKeys?: string[]; initialSelections?: string[]; preferredProvider?: string; + loadCatalog?: boolean; }): Promise { const cfg = params.config; const existingKeys = resolveConfiguredModelKeys(cfg); + const configuredRaw = resolveConfiguredModelRaw(cfg); const allowedKeys = normalizeModelKeys(params.allowedKeys ?? []); const allowedKeySet = allowedKeys.length > 0 ? new Set(allowedKeys) : null; const preferredProviderRaw = normalizeOptionalString(params.preferredProvider); @@ -685,11 +828,76 @@ export async function promptModelAllowlist(params: { ...fallbackKeys, ...(params.initialSelections ?? []), ]); + const hasRealSeed = + existingKeys.length > 0 || + fallbackKeys.length > 0 || + (params.initialSelections?.length ?? 0) > 0 || + configuredRaw.length > 0; + const hasAuth = createProviderAuthChecker({ cfg, agentDir: params.agentDir }); + const matchesPreferredProvider = preferredProvider + ? createPreferredProviderMatcher({ + preferredProvider, + cfg, + }) + : undefined; + const loadCatalog = params.loadCatalog ?? true; + + const scopedFastKeys = + allowedKeys.length > 0 + ? allowedKeys + : !loadCatalog && preferredProvider && hasRealSeed + ? initialSeeds.filter((key) => { + const entry = splitModelKey(key); + return entry ? matchesPreferredProvider?.(entry.provider) === true : false; + }) + : []; + if (scopedFastKeys.length > 0) { + const scopeKeys = allowedKeys.length > 0 ? allowedKeys : scopedFastKeys; + const scopeKeySet = new Set(scopeKeys); + const initialKeys = normalizeModelKeys(initialSeeds.filter((key) => scopeKeySet.has(key))); + const options: WizardSelectOption[] = []; + const seen = new Set(); + for (const key of scopeKeys) { + addModelKeySelectOption({ + key, + options, + seen, + aliasIndex, + hasAuth, + fallbackHint: allowedKeys.length > 0 ? "allowed" : "configured", + }); + } + if (options.length === 0) { + return {}; + } + const selection = await params.prompter.multiselect({ + message: params.message ?? "Models in /model picker (multi-select)", + options, + initialValues: initialKeys.length > 0 ? initialKeys : undefined, + searchable: true, + }); + const selected = normalizeModelKeys(selection); + if (selected.length > 0) { + return { models: selected, scopeKeys }; + } + const confirmScopedClear = await params.prompter.confirm({ + message: "Remove these provider models from the /model picker?", + initialValue: false, + }); + if (!confirmScopedClear) { + return {}; + } + return { models: [], scopeKeys }; + } + + if (!loadCatalog) { + return {}; + } const allowlistProgress = params.prompter.progress("Loading available models"); let catalog: Awaited>; try { - catalog = await loadModelCatalog({ config: cfg, useCache: false }); + catalog = await loadModelCatalog({ config: cfg }); } finally { allowlistProgress.stop(); } @@ -713,14 +921,6 @@ export async function promptModelAllowlist(params: { return { models: normalizeModelKeys(parsed) }; } - const hasAuth = createProviderAuthChecker({ cfg, agentDir: params.agentDir }); - const matchesPreferredProvider = preferredProvider - ? createPreferredProviderMatcher({ - preferredProvider, - cfg, - }) - : undefined; - const options: WizardSelectOption[] = []; const seen = new Set(); const allowedCatalog = ( diff --git a/src/plugins/contracts/auth-choice.contract.test.ts b/src/plugins/contracts/auth-choice.contract.test.ts index 89aa39c94dc..84bb4f0a477 100644 --- a/src/plugins/contracts/auth-choice.contract.test.ts +++ b/src/plugins/contracts/auth-choice.contract.test.ts @@ -11,6 +11,7 @@ type ResolveProviderPluginChoice = type RunProviderModelSelectedHook = typeof import("../../plugins/provider-auth-choice.runtime.js").runProviderModelSelectedHook; const resolvePluginProvidersMock = vi.hoisted(() => vi.fn(() => [])); +const resolvePluginSetupProviderMock = vi.hoisted(() => vi.fn(() => undefined)); const resolveProviderPluginChoiceMock = vi.hoisted(() => vi.fn()); const runProviderModelSelectedHookMock = vi.hoisted(() => vi.fn(async () => {}), @@ -19,6 +20,7 @@ const runAuthMethodMock = vi.hoisted(() => vi.fn(async () => ({ profiles: [] })) vi.mock("../../plugins/provider-auth-choice.runtime.js", () => ({ resolvePluginProviders: resolvePluginProvidersMock, + resolvePluginSetupProvider: resolvePluginSetupProviderMock, resolveProviderPluginChoice: resolveProviderPluginChoiceMock, runProviderModelSelectedHook: runProviderModelSelectedHookMock, })); diff --git a/src/plugins/provider-auth-choice.runtime.ts b/src/plugins/provider-auth-choice.runtime.ts index 3660ac189ae..1faf872efbe 100644 --- a/src/plugins/provider-auth-choice.runtime.ts +++ b/src/plugins/provider-auth-choice.runtime.ts @@ -3,12 +3,14 @@ import { runProviderModelSelectedHook as runProviderModelSelectedHookImpl, } from "./provider-wizard.js"; import { resolvePluginProviders as resolvePluginProvidersImpl } from "./providers.runtime.js"; +import { resolvePluginSetupProvider as resolvePluginSetupProviderImpl } from "./setup-registry.js"; type ResolveProviderPluginChoice = typeof import("./provider-wizard.js").resolveProviderPluginChoice; type RunProviderModelSelectedHook = typeof import("./provider-wizard.js").runProviderModelSelectedHook; type ResolvePluginProviders = typeof import("./providers.runtime.js").resolvePluginProviders; +type ResolvePluginSetupProvider = typeof import("./setup-registry.js").resolvePluginSetupProvider; export function resolveProviderPluginChoice( ...args: Parameters @@ -27,3 +29,9 @@ export function resolvePluginProviders( ): ReturnType { return resolvePluginProvidersImpl(...args); } + +export function resolvePluginSetupProvider( + ...args: Parameters +): ReturnType { + return resolvePluginSetupProviderImpl(...args); +} diff --git a/src/plugins/provider-auth-choice.ts b/src/plugins/provider-auth-choice.ts index 36d554f8eb9..30d3e7cbb8e 100644 --- a/src/plugins/provider-auth-choice.ts +++ b/src/plugins/provider-auth-choice.ts @@ -17,11 +17,15 @@ import { pickAuthMethod, resolveProviderMatch, } from "./provider-auth-choice-helpers.js"; +import { + resolveManifestProviderAuthChoice, + type ProviderAuthChoiceMetadata, +} from "./provider-auth-choices.js"; import { applyAuthProfileConfig } from "./provider-auth-helpers.js"; import { resolveProviderInstallCatalogEntry } from "./provider-install-catalog.js"; import { createVpsAwareOAuthHandlers } from "./provider-oauth-flow.js"; import { isRemoteEnvironment, openUrl } from "./setup-browser.js"; -import type { ProviderAuthMethod, ProviderAuthOptionBag } from "./types.js"; +import type { ProviderAuthMethod, ProviderAuthOptionBag, ProviderPlugin } from "./types.js"; export type ApplyProviderAuthChoiceParams = { authChoice: string; @@ -154,6 +158,24 @@ async function loadPluginProviderRuntime() { return await providerAuthChoiceDeps.loadPluginProviderRuntime(); } +function resolveManifestAuthChoiceScope(params: { + authChoice: string; + config: OpenClawConfig; + workspaceDir: string; + env?: NodeJS.ProcessEnv; +}): ProviderAuthChoiceMetadata | undefined { + return resolveManifestProviderAuthChoice(params.authChoice, { + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + includeUntrustedWorkspacePlugins: false, + }); +} + +function withProviderPluginId(provider: ProviderPlugin, pluginId: string): ProviderPlugin { + return provider.pluginId === pluginId ? provider : { ...provider, pluginId }; +} + export const __testing = { resetDepsForTest(): void { providerAuthChoiceDeps = defaultProviderAuthChoiceDeps; @@ -256,8 +278,18 @@ export async function applyAuthChoiceLoadedPluginProvider( resolveAgentWorkspaceDir(params.config, agentId) ?? resolveDefaultAgentWorkspaceDir(); let nextConfig = params.config; let enabledConfig = params.config; - const { resolvePluginProviders, resolveProviderPluginChoice, runProviderModelSelectedHook } = - await loadPluginProviderRuntime(); + const { + resolvePluginProviders, + resolvePluginSetupProvider, + resolveProviderPluginChoice, + runProviderModelSelectedHook, + } = await loadPluginProviderRuntime(); + const manifestAuthChoice = resolveManifestAuthChoiceScope({ + authChoice: params.authChoice, + config: nextConfig, + workspaceDir, + env: params.env, + }); const installCatalogEntry = resolveProviderInstallCatalogEntry(params.authChoice, { config: nextConfig, workspaceDir, @@ -277,16 +309,43 @@ export async function applyAuthChoiceLoadedPluginProvider( enabledConfig = enableResult.config; } - let providers = resolvePluginProviders({ - config: enabledConfig, - workspaceDir, - env: params.env, - mode: "setup", - }); + const resolveScopedRuntimeProviders = (config: OpenClawConfig): ProviderPlugin[] => + resolvePluginProviders({ + config, + workspaceDir, + env: params.env, + mode: "setup", + ...(manifestAuthChoice + ? { + onlyPluginIds: [manifestAuthChoice.pluginId], + providerRefs: [manifestAuthChoice.providerId], + } + : {}), + }); + + const setupProvider = manifestAuthChoice + ? resolvePluginSetupProvider({ + provider: manifestAuthChoice.providerId, + config: enabledConfig, + workspaceDir, + env: params.env, + pluginIds: [manifestAuthChoice.pluginId], + }) + : undefined; + let providers = setupProvider + ? [withProviderPluginId(setupProvider, manifestAuthChoice!.pluginId)] + : resolveScopedRuntimeProviders(enabledConfig); let resolved = resolveProviderPluginChoice({ providers, choice: params.authChoice, }); + if (!resolved && setupProvider) { + providers = resolveScopedRuntimeProviders(enabledConfig); + resolved = resolveProviderPluginChoice({ + providers, + choice: params.authChoice, + }); + } if (!resolved && installCatalogEntry) { const [{ ensureOnboardingPluginInstalled }, { clearPluginDiscoveryCache }] = await Promise.all([ import("../commands/onboarding-plugin-install.js"), @@ -308,12 +367,7 @@ export async function applyAuthChoiceLoadedPluginProvider( } nextConfig = installResult.cfg; clearPluginDiscoveryCache(); - providers = resolvePluginProviders({ - config: nextConfig, - workspaceDir, - env: params.env, - mode: "setup", - }); + providers = resolveScopedRuntimeProviders(nextConfig); resolved = resolveProviderPluginChoice({ providers, choice: params.authChoice, diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index 9f996af4c54..86d2db022ea 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -1766,6 +1766,8 @@ describe("provider-runtime", () => { cache: false, }), ); + expect(resolveCatalogHookProviderPluginIdsMock).toHaveBeenCalledTimes(1); + expect(resolvePluginProvidersMock).toHaveBeenCalledTimes(1); }); it("does not stack-overflow when provider hook resolution reenters the same plugin load", () => { diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index 87180cf72c3..4c1ed4b5d4e 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -14,9 +14,8 @@ import { sanitizeForLog } from "../terminal/ansi.js"; import { resolvePluginDiscoveryProvidersRuntime } from "./provider-discovery.runtime.js"; import { __testing as providerHookRuntimeTesting, - clearProviderRuntimeHookCache, + clearProviderRuntimeHookCache as clearProviderHookRuntimeCache, prepareProviderExtraParams, - resetProviderRuntimeHookCacheForTest, resolveProviderAuthProfileId, resolveProviderExtraParamsForTransport, resolveProviderFollowupFallbackRoute, @@ -34,6 +33,7 @@ import { resolveExternalAuthProfileProviderPluginIds, resolveOwningPluginIdsForProvider, } from "./providers.js"; +import { resolvePluginCacheInputs } from "./roots.js"; import { getActivePluginRegistryWorkspaceDirFromState } from "./runtime-state.js"; import { resolveRuntimeTextTransforms } from "./text-transforms.runtime.js"; import type { @@ -86,6 +86,14 @@ import type { const log = createSubsystemLogger("plugins/provider-runtime"); const warnedExternalAuthFallbackPluginIds = new Set(); let catalogHookProvidersCache = new WeakMap>(); +let catalogHookProviderIdCacheWithoutConfig = new WeakMap< + NodeJS.ProcessEnv, + Map +>(); +let catalogHookProviderIdCacheByConfig = new WeakMap< + OpenClawConfig, + WeakMap> +>(); function matchesProviderPluginRef(provider: ProviderPlugin, providerId: string): boolean { const normalized = normalizeProviderId(providerId); @@ -132,13 +140,95 @@ function resetCatalogHookProvidersCacheForTest(): void { catalogHookProvidersCache = new WeakMap>(); } +function clearCatalogHookProviderIdCache(): void { + catalogHookProviderIdCacheWithoutConfig = new WeakMap>(); + catalogHookProviderIdCacheByConfig = new WeakMap< + OpenClawConfig, + WeakMap> + >(); +} + +function resolveCatalogHookProviderIdCacheBucket(params: { + config?: OpenClawConfig; + env: NodeJS.ProcessEnv; +}): Map { + if (!params.config) { + let bucket = catalogHookProviderIdCacheWithoutConfig.get(params.env); + if (!bucket) { + bucket = new Map(); + catalogHookProviderIdCacheWithoutConfig.set(params.env, bucket); + } + return bucket; + } + + let envBuckets = catalogHookProviderIdCacheByConfig.get(params.config); + if (!envBuckets) { + envBuckets = new WeakMap>(); + catalogHookProviderIdCacheByConfig.set(params.config, envBuckets); + } + let bucket = envBuckets.get(params.env); + if (!bucket) { + bucket = new Map(); + envBuckets.set(params.env, bucket); + } + return bucket; +} + +function buildCatalogHookProviderIdCacheKey(params: { + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): string { + const { roots } = resolvePluginCacheInputs({ + workspaceDir: params.workspaceDir, + env: params.env, + }); + return `${roots.workspace ?? ""}::${roots.global}::${roots.stock ?? ""}::${JSON.stringify(params.config ?? null)}`; +} + +function resolveCachedCatalogHookProviderPluginIds(params: { + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): string[] { + const env = params.env ?? process.env; + const bucket = resolveCatalogHookProviderIdCacheBucket({ + config: params.config, + env, + }); + const key = buildCatalogHookProviderIdCacheKey({ + config: params.config, + workspaceDir: params.workspaceDir, + env, + }); + const cached = bucket.get(key); + if (cached) { + return cached; + } + const resolved = resolveCatalogHookProviderPluginIds({ + config: params.config, + workspaceDir: params.workspaceDir, + env, + }); + bucket.set(key, resolved); + return resolved; +} + +export function clearProviderRuntimeHookCache(): void { + resetCatalogHookProvidersCacheForTest(); + clearCatalogHookProviderIdCache(); + clearProviderHookRuntimeCache(); +} + +export function resetProviderRuntimeHookCacheForTest(): void { + clearProviderRuntimeHookCache(); +} + export { - clearProviderRuntimeHookCache, prepareProviderExtraParams, resolveProviderAuthProfileId, resolveProviderExtraParamsForTransport, resolveProviderFollowupFallbackRoute, - resetProviderRuntimeHookCacheForTest, resolveProviderRuntimePlugin, wrapProviderStreamFn, }; @@ -147,6 +237,7 @@ export const __testing = { ...providerHookRuntimeTesting, resetExternalAuthFallbackWarningCacheForTest, resetCatalogHookProvidersCacheForTest, + resetProviderRuntimeHookCacheForTest, } as const; function resolveProviderPluginsForCatalogHooks(params: { @@ -169,7 +260,7 @@ function resolveProviderPluginsForCatalogHooks(params: { if (cached) { return cached; } - const onlyPluginIds = resolveCatalogHookProviderPluginIds({ + const onlyPluginIds = resolveCachedCatalogHookProviderPluginIds({ config: params.config, workspaceDir, env, diff --git a/src/plugins/provider-wizard.ts b/src/plugins/provider-wizard.ts index 49fd178014a..76ac80a5826 100644 --- a/src/plugins/provider-wizard.ts +++ b/src/plugins/provider-wizard.ts @@ -7,6 +7,7 @@ import { } from "../shared/string-coerce.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { resolvePluginProviders } from "./providers.runtime.js"; +import { resolvePluginSetupProvider } from "./setup-registry.js"; import type { ProviderAuthMethod, ProviderPlugin, @@ -293,12 +294,19 @@ export async function runProviderModelSelectedHook(params: { return; } - const providers = resolveProviderWizardProviders({ + const setupProvider = resolvePluginSetupProvider({ + provider: selectedProviderId, config: params.config, workspaceDir: params.workspaceDir, env: params.env, }); - const provider = providers.find((entry) => normalizeProviderId(entry.id) === selectedProviderId); + const provider = + setupProvider ?? + resolveProviderWizardProviders({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }).find((entry) => normalizeProviderId(entry.id) === selectedProviderId); if (!provider?.onModelSelected) { return; } diff --git a/src/plugins/setup-registry.ts b/src/plugins/setup-registry.ts index d405e5621ae..ac9ed08ff32 100644 --- a/src/plugins/setup-registry.ts +++ b/src/plugins/setup-registry.ts @@ -153,6 +153,7 @@ function setCachedSetupValue(cache: Map, key: string, value: T): v } function buildSetupRegistryCacheKey(params: { + config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; pluginIds?: readonly string[]; @@ -160,18 +161,22 @@ function buildSetupRegistryCacheKey(params: { const { roots, loadPaths } = resolvePluginCacheInputs({ workspaceDir: params.workspaceDir, env: params.env, + loadPaths: params.config?.plugins?.load?.paths, }); return JSON.stringify({ roots, loadPaths, + hasConfig: Boolean(params.config), pluginIds: params.pluginIds ? [...new Set(params.pluginIds)].toSorted() : null, }); } function buildSetupProviderCacheKey(params: { provider: string; + config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; + pluginIds?: readonly string[]; }): string { return JSON.stringify({ provider: normalizeProviderId(params.provider), @@ -181,6 +186,7 @@ function buildSetupProviderCacheKey(params: { function buildSetupCliBackendCacheKey(params: { backend: string; + config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; }): string { @@ -493,12 +499,14 @@ function pushSetupDescriptorDriftDiagnostics(params: { } export function resolvePluginSetupRegistry(params?: { + config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; pluginIds?: readonly string[]; }): PluginSetupRegistry { const env = params?.env ?? process.env; const cacheKey = buildSetupRegistryCacheKey({ + config: params?.config, workspaceDir: params?.workspaceDir, env, pluginIds: params?.pluginIds, @@ -532,6 +540,7 @@ export function resolvePluginSetupRegistry(params?: { const cliBackendKeys = new Set(); const manifestRegistry = loadSetupManifestRegistry({ + config: params?.config, workspaceDir: params?.workspaceDir, env, pluginIds: params?.pluginIds, @@ -628,8 +637,10 @@ export function resolvePluginSetupRegistry(params?: { export function resolvePluginSetupProvider(params: { provider: string; + config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; + pluginIds?: readonly string[]; }): ProviderPlugin | undefined { const cacheKey = buildSetupProviderCacheKey(params); const cached = getCachedSetupValue(setupProviderCache, cacheKey); @@ -640,8 +651,10 @@ export function resolvePluginSetupProvider(params: { const env = params.env ?? process.env; const normalizedProvider = normalizeProviderId(params.provider); const manifestRegistry = loadSetupManifestRegistry({ + config: params.config, workspaceDir: params.workspaceDir, env, + pluginIds: params.pluginIds, }); const record = findUniqueSetupManifestOwner({ registry: manifestRegistry, @@ -697,6 +710,7 @@ export function resolvePluginSetupProvider(params: { export function resolvePluginSetupCliBackend(params: { backend: string; + config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; }): SetupCliBackendEntry | undefined { @@ -713,6 +727,7 @@ export function resolvePluginSetupCliBackend(params: { // plugin setup module. This avoids booting every setup-api just to find one // backend owner. const manifestRegistry = loadSetupManifestRegistry({ + config: params.config, workspaceDir: params.workspaceDir, env, }); @@ -786,6 +801,7 @@ export function runPluginSetupConfigMigrations(params: { } for (const entry of resolvePluginSetupRegistry({ + config: params.config, workspaceDir: params.workspaceDir, env: params.env, pluginIds, @@ -812,6 +828,7 @@ export function resolvePluginSetupAutoEnableReasons(params: { const seen = new Set(); for (const entry of resolvePluginSetupRegistry({ + config: params.config, workspaceDir: params.workspaceDir, env, pluginIds: params.pluginIds, diff --git a/src/wizard/setup.test.ts b/src/wizard/setup.test.ts index 088aedee456..65b9c58c26f 100644 --- a/src/wizard/setup.test.ts +++ b/src/wizard/setup.test.ts @@ -14,6 +14,10 @@ type ResolveProviderPluginChoice = typeof import("../plugins/provider-auth-choice.runtime.js").resolveProviderPluginChoice; type ResolvePluginProvidersRuntime = typeof import("../plugins/provider-auth-choice.runtime.js").resolvePluginProviders; +type ResolvePluginSetupProvider = + typeof import("../plugins/provider-auth-choice.runtime.js").resolvePluginSetupProvider; +type ResolveManifestProviderAuthChoice = + typeof import("../plugins/provider-auth-choices.js").resolveManifestProviderAuthChoice; type PromptDefaultModel = typeof import("../commands/model-picker.js").promptDefaultModel; type ApplyAuthChoice = typeof import("../commands/auth-choice.js").applyAuthChoice; @@ -23,6 +27,12 @@ const applyAuthChoice = vi.hoisted(() => vi.fn(async (args) => ({ config: args.config })), ); const resolvePreferredProviderForAuthChoice = vi.hoisted(() => vi.fn(async () => "demo-provider")); +const resolveManifestProviderAuthChoice = vi.hoisted(() => + vi.fn(() => undefined), +); +const resolvePluginSetupProvider = vi.hoisted(() => + vi.fn(() => undefined), +); const resolveProviderPluginChoice = vi.hoisted(() => vi.fn(() => null), ); @@ -118,13 +128,18 @@ const readConfigFileSnapshot = vi.hoisted(() => legacyIssues: [] as Array<{ path: string; message: string }>, })), ); +const createConfigIO = vi.hoisted(() => + vi.fn(() => ({ + readConfigFileSnapshot, + })), +); const ensureSystemdUserLingerInteractive = vi.hoisted(() => vi.fn(async () => {})); const isSystemdUserServiceAvailable = vi.hoisted(() => vi.fn(async () => true)); const ensureControlUiAssetsBuilt = vi.hoisted(() => vi.fn(async () => ({ ok: true }))); const runTui = vi.hoisted(() => vi.fn(async (_options: unknown) => {})); const setupWizardShellCompletion = vi.hoisted(() => vi.fn(async () => {})); const probeGatewayReachable = vi.hoisted(() => vi.fn(async () => ({ ok: true }))); -const buildPluginCompatibilityNotices = vi.hoisted(() => +const buildPluginCompatibilitySnapshotNotices = vi.hoisted(() => vi.fn((): PluginCompatibilityNotice[] => []), ); const formatPluginCompatibilityNotice = vi.hoisted(() => @@ -161,6 +176,14 @@ vi.mock("../commands/auth-choice.js", () => ({ warnIfModelConfigLooksOff, })); +vi.mock("../plugins/provider-auth-choices.js", () => ({ + resolveManifestProviderAuthChoice, +})); + +vi.mock("../plugins/setup-registry.js", () => ({ + resolvePluginSetupProvider, +})); + vi.mock("../plugins/provider-auth-choice.runtime.js", () => ({ resolveProviderPluginChoice, resolvePluginProviders: resolvePluginProvidersRuntime, @@ -185,8 +208,8 @@ vi.mock("../commands/onboard-hooks.js", () => ({ vi.mock("../config/config.js", () => ({ DEFAULT_GATEWAY_PORT: 18789, + createConfigIO, resolveGatewayPort, - readConfigFileSnapshot, writeConfigFile, })); @@ -228,7 +251,7 @@ vi.mock("../infra/control-ui-assets.js", () => ({ })); vi.mock("../plugins/status.js", () => ({ - buildPluginCompatibilityNotices, + buildPluginCompatibilitySnapshotNotices, formatPluginCompatibilityNotice, })); @@ -405,6 +428,7 @@ describe("runSetupWizard", () => { const multiselect: WizardPrompter["multiselect"] = vi.fn(async () => []); const prompter = buildWizardPrompter({ select, multiselect }); const runtime = createRuntime({ throwsOnExit: true }); + createConfigIO.mockClear(); ensureAuthProfileStore.mockClear(); await runSetupWizard( @@ -423,6 +447,7 @@ describe("runSetupWizard", () => { prompter, ); + expect(createConfigIO).toHaveBeenCalledWith({ pluginValidation: "skip" }); expect(select).not.toHaveBeenCalled(); expect(ensureAuthProfileStore).not.toHaveBeenCalled(); expect(setupChannels).not.toHaveBeenCalled(); @@ -623,6 +648,7 @@ describe("runSetupWizard", () => { it("prompts for a model during explicit interactive Ollama setup", async () => { promptDefaultModel.mockClear(); + warnIfModelConfigLooksOff.mockClear(); resolveProviderPluginChoice.mockReturnValue({ provider: { id: "ollama", @@ -671,8 +697,14 @@ describe("runSetupWizard", () => { expect(promptDefaultModel).toHaveBeenCalledWith( expect.objectContaining({ allowKeep: false, + browseCatalogOnDemand: true, }), ); + expect(warnIfModelConfigLooksOff).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ validateCatalog: false }), + ); }); it("re-prompts for auth when applyAuthChoice requests retry selection", async () => { @@ -744,7 +776,7 @@ describe("runSetupWizard", () => { }); it("shows plugin compatibility notices for an existing valid config", async () => { - buildPluginCompatibilityNotices.mockReturnValue([ + buildPluginCompatibilitySnapshotNotices.mockReturnValue([ { pluginId: "legacy-plugin", code: "legacy-before-agent-start", @@ -945,4 +977,59 @@ describe("runSetupWizard", () => { ), ).toBe(true); }); + + it("uses manifest setup metadata for post-auth model policy without loading provider runtime", async () => { + promptDefaultModel.mockClear(); + resolvePluginProvidersRuntime.mockClear(); + resolveManifestProviderAuthChoice.mockReturnValue({ + pluginId: "openai", + providerId: "openai-codex", + methodId: "oauth", + choiceId: "openai-codex", + choiceLabel: "OpenAI Codex Browser Login", + }); + resolvePluginSetupProvider.mockReturnValue({ + id: "openai-codex", + label: "OpenAI Codex", + auth: [ + { + id: "oauth", + label: "OpenAI Codex Browser Login", + kind: "oauth", + wizard: { + modelSelection: { + allowKeepCurrent: false, + }, + }, + run: vi.fn(async () => ({ profiles: [] })), + }, + ], + }); + promptAuthChoiceGrouped.mockResolvedValueOnce("openai-codex"); + const prompter = buildWizardPrompter({}); + const runtime = createRuntime(); + + await runSetupWizard( + { + acceptRisk: true, + flow: "quickstart", + installDaemon: false, + skipSkills: true, + skipSearch: true, + skipHealth: true, + skipUi: true, + }, + runtime, + prompter, + ); + + expect(resolvePluginSetupProvider).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "openai-codex", + pluginIds: ["openai"], + }), + ); + expect(resolvePluginProvidersRuntime).not.toHaveBeenCalled(); + expect(promptDefaultModel).toHaveBeenCalledWith(expect.objectContaining({ allowKeep: false })); + }); }); diff --git a/src/wizard/setup.ts b/src/wizard/setup.ts index 8058a539de9..48761819632 100644 --- a/src/wizard/setup.ts +++ b/src/wizard/setup.ts @@ -1,3 +1,4 @@ +import { normalizeProviderId } from "../agents/provider-id.js"; import { formatCliCommand } from "../cli/command-format.js"; import { commitConfigWriteWithPendingPluginInstalls } from "../cli/plugins-install-record-commit.js"; import type { @@ -7,12 +8,12 @@ import type { OnboardOptions, ResetScope, } from "../commands/onboard-types.js"; -import { readConfigFileSnapshot, resolveGatewayPort, writeConfigFile } from "../config/config.js"; +import { createConfigIO, resolveGatewayPort, writeConfigFile } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeSecretInputString } from "../config/types.secrets.js"; import { formatErrorMessage } from "../infra/errors.js"; import { - buildPluginCompatibilityNotices, + buildPluginCompatibilitySnapshotNotices, formatPluginCompatibilityNotice, } from "../plugins/status.js"; import type { RuntimeEnv } from "../runtime.js"; @@ -61,6 +62,10 @@ async function writeWizardConfigFile(config: OpenClawConfig): Promise normalizeProviderId(method.id) === normalizeProviderId(manifestChoice.methodId), + ); + const setupPolicy = + setupMethod?.wizard?.modelSelection ?? setupProvider?.wizard?.setup?.modelSelection; + return { + preferredProvider, + promptWhenAuthChoiceProvided: setupPolicy?.promptWhenAuthChoiceProvided === true, + allowKeepCurrent: setupPolicy?.allowKeepCurrent ?? true, + }; + } + const { resolvePluginProviders, resolveProviderPluginChoice } = await import("../plugins/provider-auth-choice.runtime.js"); const providers = resolvePluginProviders({ @@ -146,7 +180,7 @@ export async function runSetupWizard( await prompter.intro("OpenClaw setup"); await requireRiskAcknowledgement({ opts, prompter }); - const snapshot = await readConfigFileSnapshot(); + const snapshot = await readSetupConfigFileSnapshot(); let baseConfig: OpenClawConfig = snapshot.valid ? snapshot.exists ? (snapshot.sourceConfig ?? snapshot.config) @@ -173,7 +207,7 @@ export async function runSetupWizard( } const compatibilityNotices = snapshot.valid - ? buildPluginCompatibilityNotices({ config: baseConfig }) + ? buildPluginCompatibilitySnapshotNotices({ config: baseConfig }) : []; if (compatibilityNotices.length > 0) { await prompter.note( @@ -557,7 +591,8 @@ export async function runSetupWizard( prompter, allowKeep: true, ignoreAllowlist: true, - includeProviderPluginSetups: true, + includeProviderPluginSetups: false, + loadCatalog: false, workspaceDir, runtime, }); @@ -569,7 +604,7 @@ export async function runSetupWizard( } const { warnIfModelConfigLooksOff } = await loadAuthChoiceModule(); - await warnIfModelConfigLooksOff(nextConfig, prompter); + await warnIfModelConfigLooksOff(nextConfig, prompter, { validateCatalog: false }); } break; } @@ -616,6 +651,7 @@ export async function runSetupWizard( ignoreAllowlist: true, includeProviderPluginSetups: true, preferredProvider: authChoiceModelSelectionPolicy?.preferredProvider, + browseCatalogOnDemand: true, workspaceDir, runtime, }); @@ -627,7 +663,7 @@ export async function runSetupWizard( } } - await warnIfModelConfigLooksOff(nextConfig, prompter); + await warnIfModelConfigLooksOff(nextConfig, prompter, { validateCatalog: false }); break; } diff --git a/test/scripts/parallels-smoke-model.test.ts b/test/scripts/parallels-smoke-model.test.ts new file mode 100644 index 00000000000..ca176796ad2 --- /dev/null +++ b/test/scripts/parallels-smoke-model.test.ts @@ -0,0 +1,43 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const OS_SCRIPT_PATHS = [ + "scripts/e2e/parallels-linux-smoke.sh", + "scripts/e2e/parallels-macos-smoke.sh", + "scripts/e2e/parallels-windows-smoke.sh", +]; +const NPM_UPDATE_SCRIPT_PATH = "scripts/e2e/parallels-npm-update-smoke.sh"; + +describe("Parallels smoke model selection", () => { + it("keeps the OpenAI smoke lane on the stable direct API model by default", () => { + for (const scriptPath of [...OS_SCRIPT_PATHS, NPM_UPDATE_SCRIPT_PATH]) { + const script = readFileSync(scriptPath, "utf8"); + + expect(script, scriptPath).toContain( + 'MODEL_ID="${OPENCLAW_PARALLELS_OPENAI_MODEL:-openai/gpt-5.5}"', + ); + expect(script, scriptPath).toContain("--model "); + expect(script, scriptPath).toContain("MODEL_ID_EXPLICIT=1"); + } + }); + + it("seeds agent workspace state before OS smoke agent turns", () => { + for (const scriptPath of OS_SCRIPT_PATHS) { + const script = readFileSync(scriptPath, "utf8"); + + expect(script, scriptPath).toContain("workspace-state.json"); + expect(script, scriptPath).toContain("IDENTITY.md"); + expect(script, scriptPath).toContain("BOOTSTRAP.md"); + expect(script, scriptPath).toContain("--session-id parallels-"); + expect(script, scriptPath).toContain("agents.defaults.skipBootstrap true --strict-json"); + } + }); + + it("passes aggregate model overrides into each OS fresh lane", () => { + const script = readFileSync(NPM_UPDATE_SCRIPT_PATH, "utf8"); + + expect(script).toMatch(/parallels-macos-smoke\.sh"[\s\S]*?--model "\$MODEL_ID"/); + expect(script).toMatch(/parallels-windows-smoke\.sh"[\s\S]*?--model "\$MODEL_ID"/); + expect(script).toMatch(/parallels-linux-smoke\.sh"[\s\S]*?--model "\$MODEL_ID"/); + }); +}); diff --git a/ui/src/ui/chat/session-controls.ts b/ui/src/ui/chat/session-controls.ts index a1d3062b049..2cd5179d3b1 100644 --- a/ui/src/ui/chat/session-controls.ts +++ b/ui/src/ui/chat/session-controls.ts @@ -6,6 +6,8 @@ import { resolveChatModelOverrideValue, resolveChatModelSelectState, } from "../chat-model-select-state.ts"; +import { refreshVisibleToolsEffectiveForCurrentSession } from "../controllers/agents.ts"; +import { loadSessions } from "../controllers/sessions.ts"; import { pushUniqueTrimmedSelectOption } from "../select-options.ts"; import { parseAgentSessionKey } from "../session-key.ts"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "../string-coerce.ts"; @@ -70,7 +72,6 @@ export function renderChatSessionSelect( } async function refreshSessionOptions(state: AppViewState) { - const { loadSessions } = await import("../controllers/sessions.ts"); await loadSessions(state as unknown as Parameters[0], { activeMinutes: 0, limit: 0, @@ -80,8 +81,6 @@ async function refreshSessionOptions(state: AppViewState) { } async function refreshVisibleToolsEffectiveForCurrentSessionLazy(state: AppViewState) { - const { refreshVisibleToolsEffectiveForCurrentSession } = - await import("../controllers/agents.ts"); return refreshVisibleToolsEffectiveForCurrentSession(state); }