* 'main' of https://github.com/openclaw/openclaw:
  test(models): stabilize provider index list mocks
  test(cli): cover lazy plugin inspect mocks
  fix(cli): lazy load plugin maintenance paths
  fix(models): keep cold catalog lookup registry indexed
  fix(models): avoid registry for configured list
  fix(cli): lazy load model commands
  fix(ui): remove ineffective dynamic imports
  test: type setup provider mocks
  fix(update): complete channel switch follow-up work
  test(parallels): harden smoke agent model setup
  fix: preserve provider-scoped model options
  fix: keep post-auth model policy cold
  docs: note faster onboarding auth setup
  test: cover setup provider auth selection
  refactor: keep openai setup auth lightweight
  fix: use setup providers for auth choices
  fix: scope provider auth runtime loading
  fix: keep onboarding setup paths cold
  fix: keep onboarding model prompts scoped
This commit is contained in:
Vincent Koc
2026-04-26 03:51:08 -07:00
34 changed files with 1411 additions and 187 deletions

View File

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

View File

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

View File

@@ -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 <provider/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.

View File

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

View File

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

View File

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

View File

@@ -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 <fresh|upgrade|both>
--provider <openai|anthropic|minimax>
Provider auth/model lane. Default: openai
--model <provider/model> Override the model used for the agent-turn smoke.
Default: openai/gpt-5.5 for the OpenAI lane
--api-key-env <var> Host env var name for provider API key.
Default: OPENAI_API_KEY for openai, ANTHROPIC_API_KEY for anthropic
--openai-api-key-env <var> 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 <<EOF
exec /usr/bin/env $(shell_quote "$API_KEY_ENV=$API_KEY_VALUE") openclaw agent \
--local \
--agent main \
--session-id parallels-linux-smoke \
--message $(shell_quote "Reply with exact ASCII text OK only.") \
--json
EOF
)"
}
phase_log_path() {

View File

@@ -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="18425"
HOST_PORT_EXPLICIT=0
@@ -52,7 +53,7 @@ TIMEOUT_UPDATE_DEV_S="${OPENCLAW_PARALLELS_MACOS_UPDATE_DEV_TIMEOUT_S:-1200}"
TIMEOUT_VERIFY_S=60
TIMEOUT_ONBOARD_S=180
TIMEOUT_GATEWAY_S=180
TIMEOUT_AGENT_S=240
TIMEOUT_AGENT_S="${OPENCLAW_PARALLELS_MACOS_AGENT_TIMEOUT_S:-240}"
TIMEOUT_PERMISSION_S=60
TIMEOUT_DASHBOARD_S=180
TIMEOUT_SNAPSHOT_S=360
@@ -142,6 +143,8 @@ Options:
both = run both lanes
--provider <openai|anthropic|minimax>
Provider auth/model lane. Default: openai
--model <provider/model> Override the model used for the agent-turn smoke.
Default: openai/gpt-5.5 for the OpenAI lane
--api-key-env <var> Host env var name for provider API key.
Default: OPENAI_API_KEY for openai, ANTHROPIC_API_KEY for anthropic
--openai-api-key-env <var> 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 <<EOF
export PATH=$(shell_quote "$GUEST_EXEC_PATH")
workspace="\${OPENCLAW_WORKSPACE_DIR:-\$HOME/.openclaw/workspace}"
mkdir -p "\$workspace/.openclaw"
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

View File

@@ -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 <openai|anthropic|minimax>
Provider auth/model lane. Default: openai
--model <provider/model> Override the model used for agent-turn smoke checks.
Default: openai/gpt-5.5 for the OpenAI lane
--api-key-env <var> Host env var name for provider API key.
Default: OPENAI_API_KEY for openai, ANTHROPIC_API_KEY for anthropic
--openai-api-key-env <var> 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 &

View File

@@ -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 <fresh|upgrade|both>
--provider <openai|anthropic|minimax>
Provider auth/model lane. Default: openai
--model <provider/model> Override the model used for the agent-turn smoke.
Default: openai/gpt-5.5 for the OpenAI lane
--api-key-env <var> Host env var name for provider API key.
Default: OPENAI_API_KEY for openai, ANTHROPIC_API_KEY for anthropic
--openai-api-key-env <var> 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() {

View File

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

View File

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

View File

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

View File

@@ -1339,54 +1339,30 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
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<void> {
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<void> {
}
}
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) {

View File

@@ -5,9 +5,11 @@ import { normalizeApiKeyTokenProviderAuthChoice } from "./auth-choice.apply.api-
const resolvePluginProviders = vi.hoisted(() =>
vi.fn<typeof import("../plugins/provider-auth-choice.runtime.js").resolvePluginProviders>(),
);
const resolvePluginSetupProvider = vi.hoisted(() => vi.fn(() => undefined));
vi.mock("../plugins/provider-auth-choice.runtime.js", () => ({
resolvePluginProviders,
resolvePluginSetupProvider,
}));
function createProvider(params: {

View File

@@ -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<ResolvePluginSetupProvider>(() => 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<ResolveManifestProviderAuthChoice>(() => 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());

View File

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

View File

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

View File

@@ -624,6 +624,7 @@ describe("applyAuthChoice", () => {
providerAuthChoiceTesting.setDepsForTest({
loadPluginProviderRuntime: async () => ({
resolvePluginProviders,
resolvePluginSetupProvider: () => undefined,
resolveProviderPluginChoice,
runProviderModelSelectedHook,
}),

View File

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

View File

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

View File

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

View File

@@ -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<string>;
aliasIndex: ReturnType<typeof buildModelAliasIndex>;
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<ReturnType<typeof loadModelCatalog>>;
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<PromptModelAllowlistResult> {
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<string>();
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<ReturnType<typeof loadModelCatalog>>;
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<string>();
const allowedCatalog = (

View File

@@ -11,6 +11,7 @@ type ResolveProviderPluginChoice =
type RunProviderModelSelectedHook =
typeof import("../../plugins/provider-auth-choice.runtime.js").runProviderModelSelectedHook;
const resolvePluginProvidersMock = vi.hoisted(() => vi.fn<ResolvePluginProviders>(() => []));
const resolvePluginSetupProviderMock = vi.hoisted(() => vi.fn(() => undefined));
const resolveProviderPluginChoiceMock = vi.hoisted(() => vi.fn<ResolveProviderPluginChoice>());
const runProviderModelSelectedHookMock = vi.hoisted(() =>
vi.fn<RunProviderModelSelectedHook>(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,
}));

View File

@@ -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<ResolveProviderPluginChoice>
@@ -27,3 +29,9 @@ export function resolvePluginProviders(
): ReturnType<ResolvePluginProviders> {
return resolvePluginProvidersImpl(...args);
}
export function resolvePluginSetupProvider(
...args: Parameters<ResolvePluginSetupProvider>
): ReturnType<ResolvePluginSetupProvider> {
return resolvePluginSetupProviderImpl(...args);
}

View File

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

View File

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

View File

@@ -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<string>();
let catalogHookProvidersCache = new WeakMap<NodeJS.ProcessEnv, Map<string, ProviderPlugin[]>>();
let catalogHookProviderIdCacheWithoutConfig = new WeakMap<
NodeJS.ProcessEnv,
Map<string, string[]>
>();
let catalogHookProviderIdCacheByConfig = new WeakMap<
OpenClawConfig,
WeakMap<NodeJS.ProcessEnv, Map<string, string[]>>
>();
function matchesProviderPluginRef(provider: ProviderPlugin, providerId: string): boolean {
const normalized = normalizeProviderId(providerId);
@@ -132,13 +140,95 @@ function resetCatalogHookProvidersCacheForTest(): void {
catalogHookProvidersCache = new WeakMap<NodeJS.ProcessEnv, Map<string, ProviderPlugin[]>>();
}
function clearCatalogHookProviderIdCache(): void {
catalogHookProviderIdCacheWithoutConfig = new WeakMap<NodeJS.ProcessEnv, Map<string, string[]>>();
catalogHookProviderIdCacheByConfig = new WeakMap<
OpenClawConfig,
WeakMap<NodeJS.ProcessEnv, Map<string, string[]>>
>();
}
function resolveCatalogHookProviderIdCacheBucket(params: {
config?: OpenClawConfig;
env: NodeJS.ProcessEnv;
}): Map<string, string[]> {
if (!params.config) {
let bucket = catalogHookProviderIdCacheWithoutConfig.get(params.env);
if (!bucket) {
bucket = new Map<string, string[]>();
catalogHookProviderIdCacheWithoutConfig.set(params.env, bucket);
}
return bucket;
}
let envBuckets = catalogHookProviderIdCacheByConfig.get(params.config);
if (!envBuckets) {
envBuckets = new WeakMap<NodeJS.ProcessEnv, Map<string, string[]>>();
catalogHookProviderIdCacheByConfig.set(params.config, envBuckets);
}
let bucket = envBuckets.get(params.env);
if (!bucket) {
bucket = new Map<string, string[]>();
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,

View File

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

View File

@@ -153,6 +153,7 @@ function setCachedSetupValue<T>(cache: Map<string, T>, 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<string>();
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<string>();
for (const entry of resolvePluginSetupRegistry({
config: params.config,
workspaceDir: params.workspaceDir,
env,
pluginIds: params.pluginIds,

View File

@@ -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<ApplyAuthChoice>(async (args) => ({ config: args.config })),
);
const resolvePreferredProviderForAuthChoice = vi.hoisted(() => vi.fn(async () => "demo-provider"));
const resolveManifestProviderAuthChoice = vi.hoisted(() =>
vi.fn<ResolveManifestProviderAuthChoice>(() => undefined),
);
const resolvePluginSetupProvider = vi.hoisted(() =>
vi.fn<ResolvePluginSetupProvider>(() => undefined),
);
const resolveProviderPluginChoice = vi.hoisted(() =>
vi.fn<ResolveProviderPluginChoice>(() => 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 }));
});
});

View File

@@ -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<OpenClawCo
return committed.config;
}
async function readSetupConfigFileSnapshot() {
return await createConfigIO({ pluginValidation: "skip" }).readConfigFileSnapshot();
}
async function resolveAuthChoiceModelSelectionPolicy(params: {
authChoice: string;
config: OpenClawConfig;
@@ -84,6 +89,35 @@ async function resolveAuthChoiceModelSelectionPolicy(params: {
env: params.env,
});
const [{ resolveManifestProviderAuthChoice }, { resolvePluginSetupProvider }] = await Promise.all(
[import("../plugins/provider-auth-choices.js"), import("../plugins/setup-registry.js")],
);
const manifestChoice = resolveManifestProviderAuthChoice(params.authChoice, {
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
includeUntrustedWorkspacePlugins: false,
});
if (manifestChoice) {
const setupProvider = resolvePluginSetupProvider({
provider: manifestChoice.providerId,
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
pluginIds: [manifestChoice.pluginId],
});
const setupMethod = setupProvider?.auth.find(
(method) => 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;
}

View File

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

View File

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