fix: stabilize docker live and docker e2e harnesses

This commit is contained in:
Peter Steinberger
2026-04-05 21:59:32 +01:00
parent 8a43223014
commit 58f95b8000
10 changed files with 139 additions and 61 deletions

View File

@@ -442,6 +442,10 @@ The live-model Docker runners also bind-mount only the needed CLI auth homes (or
The live-model Docker runners also bind-mount the current checkout read-only and
stage it into a temporary workdir inside the container. This keeps the runtime
image slim while still running Vitest against your exact local source/config.
The staging step skips large local-only caches and app build outputs such as
`.pnpm-store`, `.worktrees`, `__openclaw_vitest__`, and app-local `.build` or
Gradle output directories so Docker live runs do not spend minutes copying
machine-specific artifacts.
They also set `OPENCLAW_SKIP_CHANNELS=1` so gateway live probes do not start
real Telegram/Discord/etc. channel workers inside the container.
`test:docker:live-models` still runs `pnpm test:live`, so pass through
@@ -479,8 +483,8 @@ Useful env vars:
- `OPENCLAW_PROFILE_FILE=...` (default: `~/.profile`) mounted to `/home/node/.profile` and sourced before running tests
- `OPENCLAW_DOCKER_CLI_TOOLS_DIR=...` (default: `~/.cache/openclaw/docker-cli-tools`) mounted to `/home/node/.npm-global` for cached CLI installs inside Docker
- External CLI auth dirs/files under `$HOME` are mounted read-only under `/host-auth...`, then copied into `/home/node/...` before tests start
- Default dirs: `.codex`, `.minimax`
- Default files: `.claude.json`, `~/.claude/.credentials.json`, `~/.claude/settings.json`, `~/.claude/settings.local.json`
- Default dirs: `.minimax`
- Default files: `~/.codex/auth.json`, `~/.codex/config.toml`, `.claude.json`, `~/.claude/.credentials.json`, `~/.claude/settings.json`, `~/.claude/settings.local.json`
- Narrowed provider runs mount only the needed dirs/files inferred from `OPENCLAW_LIVE_PROVIDERS` / `OPENCLAW_LIVE_GATEWAY_PROVIDERS`
- Override manually with `OPENCLAW_DOCKER_AUTH_DIRS=all`, `OPENCLAW_DOCKER_AUTH_DIRS=none`, or a comma list like `OPENCLAW_DOCKER_AUTH_DIRS=.claude,.codex`
- `OPENCLAW_LIVE_GATEWAY_MODELS=...` / `OPENCLAW_LIVE_MODELS=...` to narrow the run

View File

@@ -88,16 +88,43 @@ config.models = {
},
},
};
config.gateway = {
...(config.gateway || {}),
controlUi: {
...(config.gateway?.controlUi || {}),
enabled: false,
},
mode: "local",
bind: "lan",
auth: {
...(config.gateway?.auth || {}),
mode: "token",
token: process.env.OPENCLAW_GATEWAY_TOKEN,
},
http: {
...(config.gateway?.http || {}),
endpoints: {
...(config.gateway?.http?.endpoints || {}),
chatCompletions: {
...(config.gateway?.http?.endpoints?.chatCompletions || {}),
enabled: true,
},
},
},
};
config.agents = {
...(config.agents || {}),
defaults: {
...(config.agents?.defaults || {}),
model: {
...(config.agents?.defaults?.model || {}),
primary: process.env.OPENCLAW_OPENWEBUI_MODEL,
},
},
};
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
NODE
node "$entry" config set gateway.controlUi.enabled false >/dev/null
node "$entry" config set gateway.mode local >/dev/null
node "$entry" config set gateway.bind lan >/dev/null
node "$entry" config set gateway.auth.mode token >/dev/null
node "$entry" config set gateway.auth.token "$OPENCLAW_GATEWAY_TOKEN" >/dev/null
node "$entry" config set gateway.http.endpoints.chatCompletions.enabled true --strict-json >/dev/null
node "$entry" config set agents.defaults.model.primary "$OPENCLAW_OPENWEBUI_MODEL" >/dev/null
exec node "$entry" gateway --port '"$PORT"' --bind lan --allow-unconfigured > /tmp/openwebui-gateway.log 2>&1
'

View File

@@ -1,7 +1,9 @@
#!/usr/bin/env bash
OPENCLAW_DOCKER_LIVE_AUTH_ALL=(.codex .minimax)
OPENCLAW_DOCKER_LIVE_AUTH_ALL=(.minimax)
OPENCLAW_DOCKER_LIVE_AUTH_FILES_ALL=(
.codex/auth.json
.codex/config.toml
.claude.json
.claude/.credentials.json
.claude/settings.json
@@ -27,9 +29,6 @@ openclaw_live_should_include_auth_dir_for_provider() {
local provider
provider="$(openclaw_live_trim "${1:-}")"
case "$provider" in
openai-codex)
printf '%s\n' ".codex"
;;
minimax | minimax-portal)
printf '%s\n' ".minimax"
;;
@@ -40,6 +39,10 @@ openclaw_live_should_include_auth_file_for_provider() {
local provider
provider="$(openclaw_live_trim "${1:-}")"
case "$provider" in
openai-codex)
printf '%s\n' ".codex/auth.json"
printf '%s\n' ".codex/config.toml"
;;
anthropic)
printf '%s\n' ".claude.json"
printf '%s\n' ".claude/.credentials.json"

View File

@@ -0,0 +1,35 @@
#!/usr/bin/env bash
openclaw_live_stage_source_tree() {
local dest_dir="${1:?destination directory required}"
tar -C /src \
--exclude=.git \
--exclude=node_modules \
--exclude=dist \
--exclude=ui/dist \
--exclude=ui/node_modules \
--exclude=.pnpm-store \
--exclude=.tmp \
--exclude=.tmp-precommit-venv \
--exclude=.worktrees \
--exclude=__openclaw_vitest__ \
--exclude='apps/*/.build' \
--exclude='apps/*/*.bun-build' \
--exclude='apps/*/.gradle' \
--exclude='apps/*/.kotlin' \
--exclude='apps/*/build' \
-cf - . | tar -C "$dest_dir" -xf -
}
openclaw_live_link_runtime_tree() {
local dest_dir="${1:?destination directory required}"
ln -s /app/node_modules "$dest_dir/node_modules"
ln -s /app/dist "$dest_dir/dist"
if [ -d /app/dist-runtime/extensions ]; then
export OPENCLAW_BUNDLED_PLUGINS_DIR=/app/dist-runtime/extensions
elif [ -d /app/dist/extensions ]; then
export OPENCLAW_BUNDLED_PLUGINS_DIR=/app/dist/extensions
fi
}

View File

@@ -150,20 +150,9 @@ cleanup() {
rm -rf "$tmp_dir"
}
trap cleanup EXIT
tar -C /src \
--exclude=.git \
--exclude=node_modules \
--exclude=dist \
--exclude=ui/dist \
--exclude=ui/node_modules \
-cf - . | tar -C "$tmp_dir" -xf -
ln -s /app/node_modules "$tmp_dir/node_modules"
ln -s /app/dist "$tmp_dir/dist"
if [ -d /app/dist-runtime/extensions ]; then
export OPENCLAW_BUNDLED_PLUGINS_DIR=/app/dist-runtime/extensions
elif [ -d /app/dist/extensions ]; then
export OPENCLAW_BUNDLED_PLUGINS_DIR=/app/dist/extensions
fi
source /app/scripts/lib/live-docker-stage.sh
openclaw_live_stage_source_tree "$tmp_dir"
openclaw_live_link_runtime_tree "$tmp_dir"
cd "$tmp_dir"
export OPENCLAW_LIVE_ACP_BIND_AGENT_COMMAND="${OPENCLAW_LIVE_ACP_BIND_AGENT_COMMAND:-}"
pnpm test:live src/gateway/gateway-acp-bind.live.test.ts

View File

@@ -101,20 +101,9 @@ cleanup() {
rm -rf "$tmp_dir"
}
trap cleanup EXIT
tar -C /src \
--exclude=.git \
--exclude=node_modules \
--exclude=dist \
--exclude=ui/dist \
--exclude=ui/node_modules \
-cf - . | tar -C "$tmp_dir" -xf -
ln -s /app/node_modules "$tmp_dir/node_modules"
ln -s /app/dist "$tmp_dir/dist"
if [ -d /app/dist-runtime/extensions ]; then
export OPENCLAW_BUNDLED_PLUGINS_DIR=/app/dist-runtime/extensions
elif [ -d /app/dist/extensions ]; then
export OPENCLAW_BUNDLED_PLUGINS_DIR=/app/dist/extensions
fi
source /app/scripts/lib/live-docker-stage.sh
openclaw_live_stage_source_tree "$tmp_dir"
openclaw_live_link_runtime_tree "$tmp_dir"
cd "$tmp_dir"
pnpm test:live:gateway-profiles
EOF

View File

@@ -111,20 +111,9 @@ cleanup() {
rm -rf "$tmp_dir"
}
trap cleanup EXIT
tar -C /src \
--exclude=.git \
--exclude=node_modules \
--exclude=dist \
--exclude=ui/dist \
--exclude=ui/node_modules \
-cf - . | tar -C "$tmp_dir" -xf -
ln -s /app/node_modules "$tmp_dir/node_modules"
ln -s /app/dist "$tmp_dir/dist"
if [ -d /app/dist-runtime/extensions ]; then
export OPENCLAW_BUNDLED_PLUGINS_DIR=/app/dist-runtime/extensions
elif [ -d /app/dist/extensions ]; then
export OPENCLAW_BUNDLED_PLUGINS_DIR=/app/dist/extensions
fi
source /app/scripts/lib/live-docker-stage.sh
openclaw_live_stage_source_tree "$tmp_dir"
openclaw_live_link_runtime_tree "$tmp_dir"
cd "$tmp_dir"
pnpm test:live:models-profiles
EOF

View File

@@ -66,6 +66,7 @@ const GATEWAY_LIVE_HEARTBEAT_MS = Math.max(
toInt(process.env.OPENCLAW_LIVE_GATEWAY_HEARTBEAT_MS, 30_000),
);
const GATEWAY_LIVE_STRIP_SCAFFOLDING_MODEL_KEYS = new Set([
"google/gemini-2.5-flash",
"google/gemini-3-flash-preview",
"google/gemini-3-pro-preview",
"google/gemini-3.1-flash-lite-preview",
@@ -419,6 +420,12 @@ function shouldSkipEmptyResponseForLiveModel(params: {
describe("maybeStripAssistantScaffoldingForLiveModel", () => {
it("strips scaffolding for Gemini preview models with known transcript wrappers", () => {
expect(
maybeStripAssistantScaffoldingForLiveModel(
"<final>Visible</final>",
"google/gemini-2.5-flash",
),
).toBe("Visible");
expect(
maybeStripAssistantScaffoldingForLiveModel(
"<think>hidden</think>Visible",
@@ -448,7 +455,7 @@ describe("maybeStripAssistantScaffoldingForLiveModel", () => {
"<think>hidden</think>Visible",
"google/gemini-2.5-flash",
),
).toBe("<think>hidden</think>Visible");
).toBe("Visible");
});
it("strips scaffolding for known OpenAI transcript wrappers", () => {
@@ -1816,12 +1823,14 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
client.stop();
await server.close({ reason: "live test complete" });
await fs.rm(toolProbePath, { force: true });
await fs.rm(tempDir, { recursive: true, force: true });
// Give the filesystem a short retry window while agent/runtime teardown
// releases handles inside these temporary live-test directories.
await fs.rm(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
if (tempAgentDir) {
await fs.rm(tempAgentDir, { recursive: true, force: true });
await fs.rm(tempAgentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
}
if (tempStateDir) {
await fs.rm(tempStateDir, { recursive: true, force: true });
await fs.rm(tempStateDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
}
process.env.OPENCLAW_CONFIG_PATH = previous.configPath;

View File

@@ -32,7 +32,7 @@ import {
resolveControlUiRootSync,
} from "../infra/control-ui-assets.js";
import { isDiagnosticsEnabled } from "../infra/diagnostic-events.js";
import { logAcceptedEnvOption } from "../infra/env.js";
import { isTruthyEnvValue, logAcceptedEnvOption } from "../infra/env.js";
import { createExecApprovalForwarder } from "../infra/exec-approval-forwarder.js";
import { onHeartbeatEvent } from "../infra/heartbeat-events.js";
import { startHeartbeatRunner, type HeartbeatRunner } from "../infra/heartbeat-runner.js";
@@ -179,6 +179,20 @@ function getChannelRuntime() {
cachedChannelRuntime ??= createPluginRuntime().channel;
return cachedChannelRuntime;
}
function pruneSkippedStartupSecretSurfaces(config: OpenClawConfig): OpenClawConfig {
const skipChannels =
isTruthyEnvValue(process.env.OPENCLAW_SKIP_CHANNELS) ||
isTruthyEnvValue(process.env.OPENCLAW_SKIP_PROVIDERS);
if (!skipChannels || !config.channels) {
return config;
}
return {
...config,
channels: undefined,
};
}
const logHealth = log.child("health");
const logCron = log.child("cron");
const logReload = log.child("reload");
@@ -464,7 +478,9 @@ export async function startGatewayServer(
) =>
await runWithSecretsActivationLock(async () => {
try {
const prepared = await prepareSecretsRuntimeSnapshot({ config });
const prepared = await prepareSecretsRuntimeSnapshot({
config: pruneSkippedStartupSecretSurfaces(config),
});
if (params.activate) {
activateSecretsRuntimeSnapshot(prepared);
logGatewayAuthSurfaceDiagnostics(prepared);

View File

@@ -229,6 +229,16 @@ describe("gateway hot reload", () => {
});
}
async function writeChannelEnvRefConfig() {
await writeConfigFile({
channels: {
telegram: {
botToken: { source: "env", provider: "default", id: "TELEGRAM_BOT_TOKEN" },
},
},
});
}
async function writeConfigFile(config: unknown) {
const configPath = process.env.OPENCLAW_CONFIG_PATH;
if (!configPath) {
@@ -566,6 +576,13 @@ describe("gateway hot reload", () => {
);
});
it("allows startup when unresolved channel refs exist but channels are skipped", async () => {
await writeChannelEnvRefConfig();
delete process.env.TELEGRAM_BOT_TOKEN;
process.env.OPENCLAW_SKIP_CHANNELS = "1";
await expect(withGatewayServer(async () => {})).resolves.toBeUndefined();
});
it("fails startup when an active exec ref id contains traversal segments", async () => {
await writeGatewayTraversalExecRefConfig();
const previousGatewayAuth = testState.gatewayAuth;