mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:50:43 +00:00
Merge branch 'main' of https://github.com/openclaw/openclaw
* '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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 &
|
||||
|
||||
@@ -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() {
|
||||
|
||||
165
scripts/e2e/update-channel-switch-docker.sh
Executable file
165
scripts/e2e/update-channel-switch-docker.sh
Executable 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"
|
||||
'
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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());
|
||||
|
||||
74
src/commands/auth-choice.model-check.test.ts
Normal file
74
src/commands/auth-choice.model-check.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -624,6 +624,7 @@ describe("applyAuthChoice", () => {
|
||||
providerAuthChoiceTesting.setDepsForTest({
|
||||
loadPluginProviderRuntime: async () => ({
|
||||
resolvePluginProviders,
|
||||
resolvePluginSetupProvider: () => undefined,
|
||||
resolveProviderPluginChoice,
|
||||
runProviderModelSelectedHook,
|
||||
}),
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
43
test/scripts/parallels-smoke-model.test.ts
Normal file
43
test/scripts/parallels-smoke-model.test.ts
Normal 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"/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user