diff --git a/docs/help/testing.md b/docs/help/testing.md
index 58541cbc7ce..7c143d1dc8f 100644
--- a/docs/help/testing.md
+++ b/docs/help/testing.md
@@ -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
diff --git a/scripts/e2e/openwebui-docker.sh b/scripts/e2e/openwebui-docker.sh
index 2144faf3ee9..71f662a5fd3 100755
--- a/scripts/e2e/openwebui-docker.sh
+++ b/scripts/e2e/openwebui-docker.sh
@@ -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
'
diff --git a/scripts/lib/live-docker-auth.sh b/scripts/lib/live-docker-auth.sh
index 5eec7a5e0b7..2dbacee81df 100644
--- a/scripts/lib/live-docker-auth.sh
+++ b/scripts/lib/live-docker-auth.sh
@@ -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"
diff --git a/scripts/lib/live-docker-stage.sh b/scripts/lib/live-docker-stage.sh
new file mode 100644
index 00000000000..dba5a75f653
--- /dev/null
+++ b/scripts/lib/live-docker-stage.sh
@@ -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
+}
diff --git a/scripts/test-live-acp-bind-docker.sh b/scripts/test-live-acp-bind-docker.sh
index b9151d2f9de..c28149a3f38 100644
--- a/scripts/test-live-acp-bind-docker.sh
+++ b/scripts/test-live-acp-bind-docker.sh
@@ -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
diff --git a/scripts/test-live-gateway-models-docker.sh b/scripts/test-live-gateway-models-docker.sh
index 1e33aee03cc..c4119a1c7ee 100755
--- a/scripts/test-live-gateway-models-docker.sh
+++ b/scripts/test-live-gateway-models-docker.sh
@@ -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
diff --git a/scripts/test-live-models-docker.sh b/scripts/test-live-models-docker.sh
index 176c21ede5c..9b59bf4f8cc 100755
--- a/scripts/test-live-models-docker.sh
+++ b/scripts/test-live-models-docker.sh
@@ -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
diff --git a/src/gateway/gateway-models.profiles.live.test.ts b/src/gateway/gateway-models.profiles.live.test.ts
index 88083899f5d..91f54ee557b 100644
--- a/src/gateway/gateway-models.profiles.live.test.ts
+++ b/src/gateway/gateway-models.profiles.live.test.ts
@@ -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(
+ "Visible",
+ "google/gemini-2.5-flash",
+ ),
+ ).toBe("Visible");
expect(
maybeStripAssistantScaffoldingForLiveModel(
"hiddenVisible",
@@ -448,7 +455,7 @@ describe("maybeStripAssistantScaffoldingForLiveModel", () => {
"hiddenVisible",
"google/gemini-2.5-flash",
),
- ).toBe("hiddenVisible");
+ ).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;
diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts
index ec70dee1fcf..6e1f3fac718 100644
--- a/src/gateway/server.impl.ts
+++ b/src/gateway/server.impl.ts
@@ -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);
diff --git a/src/gateway/server.reload.test.ts b/src/gateway/server.reload.test.ts
index 007949a49e6..a6cfd58b2a8 100644
--- a/src/gateway/server.reload.test.ts
+++ b/src/gateway/server.reload.test.ts
@@ -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;