fix(qa): isolate packaged mock auth bootstrap (#126247)

* fix(qa): isolate packaged mock auth config

Punchcard-Session: frost-orchard-lantern-ze
(cherry picked from commit 648bd40a4f)

* fix(qa): scrub inherited shell startup env

* fix(qa): block exported Bash functions
This commit is contained in:
Vincent Koc
2026-08-21 06:51:34 +08:00
committed by GitHub
parent 40e3ab8784
commit fa71a6f27b
6 changed files with 192 additions and 40 deletions
@@ -1530,9 +1530,19 @@ jobs:
"$RUNTIME_ROOT"/tmp/openclaw-"$RUNNER_UID"/openclaw-qa-suite-*) ;;
*) echo "SUT temp root escaped the workflow runtime root." >&2; exit 1 ;;
esac
requested_config_path="${OPENCLAW_CONFIG_PATH:?}"
config_path="${temp_root}/openclaw.json"
[[ -f "$config_path" && ! -L "$config_path" ]]
[[ "$(realpath -e "$config_path")" == "$config_path" ]]
case "$requested_config_path" in
"$config_path") ;;
"${temp_root}/state/qa-auth-bootstrap/openclaw.json")
[[ "${1:-}" == "models" && "${2:-}" == "auth" ]]
[[ -f "$requested_config_path" && ! -L "$requested_config_path" ]]
[[ "$(realpath -e "$requested_config_path")" == "$requested_config_path" ]]
;;
*) echo "SUT config path escaped the canonical or auth-bootstrap roots." >&2; exit 1 ;;
esac
capture_live_model_config "$config_path"
export OPENCLAW_QA_TEMP_ROOT="$temp_root"
@@ -1540,7 +1550,7 @@ jobs:
export OPENCLAW_HOME="$HOME"
export OPENCLAW_STATE_DIR="${temp_root}/state"
export OPENCLAW_OAUTH_DIR="${OPENCLAW_STATE_DIR}/credentials"
export OPENCLAW_CONFIG_PATH="$config_path"
export OPENCLAW_CONFIG_PATH="$requested_config_path"
export XDG_CACHE_HOME="${temp_root}/xdg-cache"
export XDG_CONFIG_HOME="${temp_root}/xdg-config"
export XDG_DATA_HOME="${temp_root}/xdg-data"
@@ -1575,6 +1585,9 @@ jobs:
chown -R "$SUT_UID:$SUT_GID" "$path"
chmod -R u=rwX,go= "$path"
done
if [[ "$requested_config_path" != "$config_path" ]]; then
[[ "$(stat -c '%F:%a:%u:%g' "$requested_config_path")" == "regular file:600:${SUT_UID}:${SUT_GID}" ]]
fi
if [[ -n "${OPENCLAW_BUNDLED_PLUGINS_DIR:-}" ]]; then
chown -R root:root "$OPENCLAW_BUNDLED_PLUGINS_DIR"
chmod -R a+rX,go-w "$OPENCLAW_BUNDLED_PLUGINS_DIR"
@@ -1640,6 +1653,7 @@ jobs:
export SUT_UID SUT_GID RUNNER_UID RUNNER_GID RUNNER_HOME RUNNER_TEMP_DIR
export CANDIDATE_ROOT CANDIDATE_ARTIFACTS_DIR RUNTIME_ROOT NODE_BIN
export PRELOAD_PATH RUNNER_SENTINEL TRUSTED_WORKSPACE EVIDENCE_ROOT
export CANONICAL_CONFIG_PATH="$config_path"
export boundary_mode generation command_file identity_file sandbox_file
export command_sha256 expected_env_keys_b64 sandbox_payload_b64
@@ -1801,8 +1815,12 @@ jobs:
runtime_stage=verify-runtime-files
[[ -r "$CANDIDATE_ROOT/dist/index.js" &&
! -w "$CANDIDATE_ROOT/dist/index.js" &&
-r "${OPENCLAW_CONFIG_PATH:?}" &&
! -w "$OPENCLAW_CONFIG_PATH" ]]
-r "${CANONICAL_CONFIG_PATH:?}" &&
! -w "$CANONICAL_CONFIG_PATH" ]]
if [[ "$OPENCLAW_CONFIG_PATH" != "$CANONICAL_CONFIG_PATH" ]]; then
[[ "${1:-}" == "models" && "${2:-}" == "auth" &&
-r "$OPENCLAW_CONFIG_PATH" && -w "$OPENCLAW_CONFIG_PATH" ]]
fi
[[ -d "${CANDIDATE_ARTIFACTS_DIR:?}" && -r "$CANDIDATE_ARTIFACTS_DIR" && -x "$CANDIDATE_ARTIFACTS_DIR" && ! -w "$CANDIDATE_ARTIFACTS_DIR" ]]
for writable_path in \
"${OPENCLAW_QA_TEMP_ROOT:?}/workspace" \
@@ -1839,6 +1857,7 @@ jobs:
unset \
CANDIDATE_ROOT \
CANDIDATE_ARTIFACTS_DIR \
CANONICAL_CONFIG_PATH \
EVIDENCE_ROOT \
NODE_BIN \
PRELOAD_PATH \
@@ -1880,9 +1899,6 @@ jobs:
else
runtime_node_args=("$runtime_candidate_root/dist/index.js" "$@")
fi
# Login Bash reads /etc/bash.bashrc with inherited nounset.
# Add PS1 only after the attested inbound env-key comparison.
export PS1=
runtime_stage=exec-runtime
exec "$runtime_node_bin" "${runtime_node_args[@]}"
'\'' openclaw-sut "$@"
+16 -4
View File
@@ -17,19 +17,29 @@ import {
import { listMockCodexModelInfos } from "./providers/shared/mock-model-config.js";
import type { RuntimeId } from "./runtime-parity.js";
const QA_GATEWAY_CHILD_BLOCKED_SECRET_ENV_VARS = Object.freeze([
const QA_GATEWAY_CHILD_BLOCKED_ENV_VARS = Object.freeze([
"BASH_ENV",
"BASHOPTS",
"ENV",
"OPENCLAW_QA_CONVEX_SECRET_CI",
"OPENCLAW_QA_CONVEX_SECRET_MAINTAINER",
"OPENCLAW_QA_SUT_FORBIDDEN_SENTINEL",
"OPENCLAW_QA_TELEGRAM_GROUP_ID",
"OPENCLAW_QA_TELEGRAM_DRIVER_BOT_TOKEN",
"OPENCLAW_QA_TELEGRAM_SUT_BOT_TOKEN",
"SHELLOPTS",
]);
function scrubQaGatewayChildSecretEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
for (const envKey of QA_GATEWAY_CHILD_BLOCKED_SECRET_ENV_VARS) {
function scrubQaGatewayChildEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
for (const envKey of QA_GATEWAY_CHILD_BLOCKED_ENV_VARS) {
delete env[envKey];
}
// Bash imports exported functions before the launcher can apply its allowlist.
for (const envKey of Object.keys(env)) {
if (envKey.startsWith("BASH_FUNC_")) {
delete env[envKey];
}
}
return env;
}
@@ -112,10 +122,12 @@ export function buildQaRuntimeEnv(params: {
delete normalizedEnv.OPENCLAW_SKIP_CHANNELS;
delete normalizedEnv.OPENCLAW_SKIP_PROVIDERS;
Object.assign(normalizedEnv, params.runtimeEnvPatch);
// Parent shell startup controls must be removed after caller patches so no
// launcher or runtime child can import them before its own allowlist runs.
normalizedEnv.OPENCLAW_BUILD_PRIVATE_QA = "1";
delete normalizedEnv[QA_LIVE_ANTHROPIC_SETUP_TOKEN_ENV];
delete normalizedEnv[QA_LIVE_SETUP_TOKEN_VALUE_ENV];
return scrubQaGatewayChildSecretEnv(scrubQaGatewayChildTestRunnerEnv(normalizedEnv));
return scrubQaGatewayChildEnv(scrubQaGatewayChildTestRunnerEnv(normalizedEnv));
}
export async function stageQaCodexMockModelCatalog(params: {
+100 -6
View File
@@ -1,4 +1,4 @@
import { spawn } from "node:child_process";
import { spawn, spawnSync } from "node:child_process";
// Qa Lab tests cover gateway child plugin behavior.
import { EventEmitter, once } from "node:events";
import { lstat, mkdir, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises";
@@ -160,22 +160,32 @@ if (!recordPath || !configPath || !stateDir) {
throw new Error("missing fixture environment");
}
const record = (value) => fs.appendFileSync(recordPath, JSON.stringify(value) + "\\n");
const authDbPath = path.join(stateDir, "agents", "qa", "agent", "openclaw-agent.sqlite");
if (args[0] === "models") {
let stdin = "";
process.stdin.setEncoding("utf8");
for await (const chunk of process.stdin) stdin += chunk;
const provider = args[args.indexOf("--provider") + 1];
const configStat = fs.lstatSync(configPath);
record({
kind: "auth",
args,
stdin,
dbExists: fs.existsSync(path.join(stateDir, "agents", "qa", "agent", "openclaw-agent.sqlite")),
authDbPath,
dbExists: fs.existsSync(authDbPath),
configPath,
configMode: configStat.mode & 0o777,
configRegular: configStat.isFile(),
configSymlink: configStat.isSymbolicLink(),
stateDir,
env: {
OPENCLAW_CLI: process.env.OPENCLAW_CLI,
OPENCLAW_CONFIG_PATH: configPath,
OPENCLAW_STATE_DIR: stateDir,
},
});
fs.mkdirSync(path.dirname(authDbPath), { recursive: true });
fs.writeFileSync(authDbPath, "fixture auth");
if (process.env.QA_FAIL_PROVIDER === provider) {
process.stderr.write("Authorization: Bearer " + stdin.trim());
process.exit(9);
@@ -186,7 +196,16 @@ if (args[0] === "models") {
process.exit(0);
}
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
record({ kind: "gateway", args, fixtureProfiles: config.fixtureProfiles });
record({
kind: "gateway",
args,
authDbPath,
dbExists: fs.existsSync(authDbPath),
configPath,
authProfileIds: Object.keys(config.auth?.profiles ?? {}),
fixtureProfiles: config.fixtureProfiles,
stateDir,
});
process.stderr.write("fixture gateway exit");
process.exit(17);
`,
@@ -793,6 +812,7 @@ describe("buildQaRuntimeEnv", () => {
OPENCLAW_QA_TELEGRAM_GROUP_ID: "-1001234567890",
OPENCLAW_QA_TELEGRAM_DRIVER_BOT_TOKEN: "driver-token",
OPENCLAW_QA_TELEGRAM_SUT_BOT_TOKEN: "sut-token",
"BASH_FUNC_sudo%%": "() { printf imported; }",
},
});
@@ -804,8 +824,64 @@ describe("buildQaRuntimeEnv", () => {
expect(env.OPENCLAW_QA_TELEGRAM_GROUP_ID).toBeUndefined();
expect(env.OPENCLAW_QA_TELEGRAM_DRIVER_BOT_TOKEN).toBeUndefined();
expect(env.OPENCLAW_QA_TELEGRAM_SUT_BOT_TOKEN).toBeUndefined();
expect(env["BASH_FUNC_sudo%%"]).toBeUndefined();
});
it.runIf(process.platform === "linux")(
"scrubs inherited shell startup env before the workflow allowlist runs",
async () => {
const tempRoot = await tempDirs.makeTempDir("qa-shell-startup-env-");
const markerPath = path.join(tempRoot, "bash-env-ran");
const functionMarkerPath = path.join(tempRoot, "bash-function-ran");
const bashEnvPath = path.join(tempRoot, "malicious-bash-env");
const allowlistProbePath = path.join(tempRoot, "allowlist-probe.sh");
await writeFile(bashEnvPath, `printf 'ran' > ${JSON.stringify(markerPath)}\n`, "utf8");
await writeFile(
allowlistProbePath,
`
set -Eeuo pipefail
for key in BASH_ENV BASHOPTS ENV SHELLOPTS; do
! compgen -e | grep -Fxq "$key"
done
declare -A keep_env=([SAFE_VALUE]=1)
while IFS= read -r key; do
if [[ -z "\${keep_env[$key]+x}" ]]; then
unset "$key"
fi
done < <(compgen -e)
printf '%s' "\${SAFE_VALUE:?}"
`,
"utf8",
);
const env = buildQaRuntimeEnv({
...createParams({ SAFE_VALUE: "base" }),
runtimeEnvPatch: {
SAFE_VALUE: "allowlist-survived",
BASH_ENV: bashEnvPath,
BASHOPTS: "checkwinsize",
ENV: bashEnvPath,
SHELLOPTS: "braceexpand",
"BASH_FUNC_compgen%%": `() { printf 'ran' > ${JSON.stringify(functionMarkerPath)}; builtin compgen "$@"; }`,
},
});
for (const key of ["BASH_ENV", "BASHOPTS", "ENV", "SHELLOPTS"]) {
expect(env[key]).toBeUndefined();
}
expect(env["BASH_FUNC_compgen%%"]).toBeUndefined();
const result = spawnSync("/bin/bash", ["--noprofile", "--norc", allowlistProbePath], {
encoding: "utf8",
env,
});
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toBe("allowlist-survived");
await expect(readFile(markerPath, "utf8")).rejects.toMatchObject({ code: "ENOENT" });
await expect(readFile(functionMarkerPath, "utf8")).rejects.toMatchObject({ code: "ENOENT" });
},
);
it("re-scrubs blocked credentials in the spawned gateway child env", async () => {
const tempParent = await tempDirs.makeTempDir("qa-gateway-env-scrub-");
qaTempPathState.preferredTmpDir = tempParent;
@@ -1442,16 +1518,28 @@ describe("buildQaRuntimeEnv", () => {
],
]);
for (const record of authRecords) {
expect(record.dbExists).toBe(false);
expect(record.stdin).toMatch(/^sk-qa-mock-[a-f0-9]{32}\n$/u);
expect(record.env).toMatchObject({
OPENCLAW_CLI: "1",
});
expect(record.configMode).toBe(0o600);
expect(record.configRegular).toBe(true);
expect(record.configSymlink).toBe(false);
}
expect(authRecords.map((record) => record.dbExists)).toEqual([false, true]);
const authConfigPaths = authRecords.map((record) => String(record.configPath));
expect(new Set(authConfigPaths).size).toBe(1);
expect(authConfigPaths[0]).toBe(
path.join(String(authRecords[0]?.stateDir), "qa-auth-bootstrap", "openclaw.json"),
);
expect(records.at(-1)).toMatchObject({
kind: "gateway",
fixtureProfiles: ["openai", "anthropic"],
authProfileIds: ["qa-mock-openai", "qa-mock-anthropic"],
dbExists: true,
});
expect(records.at(-1)?.configPath).not.toBe(authConfigPaths[0]);
expect(records.at(-1)?.fixtureProfiles).toBeUndefined();
expect(new Set(records.map((record) => record.authDbPath)).size).toBe(1);
});
it("blocks packaged gateway spawn when candidate auth bootstrap fails", async () => {
@@ -1487,7 +1575,13 @@ describe("buildQaRuntimeEnv", () => {
);
const records = await readJsonLines(recordPath);
expect(records).toHaveLength(1);
expect(records[0]).toMatchObject({ kind: "auth", dbExists: false });
expect(records[0]).toMatchObject({
kind: "auth",
dbExists: false,
configMode: 0o600,
configRegular: true,
configSymlink: false,
});
const submittedKey = String(records[0]?.stdin).trim();
expect(submittedKey).toMatch(/^sk-qa-mock-[a-f0-9]{32}$/u);
expect(error.message).not.toContain(submittedKey);
+29 -4
View File
@@ -68,7 +68,11 @@ import {
stageQaLiveApiKeyProfiles,
stageQaLiveAnthropicSetupToken,
} from "./providers/live-frontier/auth.js";
import { buildQaMockProfileId, stageQaMockAuthProfiles } from "./providers/shared/mock-auth.js";
import {
applyQaMockAuthProfileConfig,
buildQaMockProfileId,
stageQaMockAuthProfiles,
} from "./providers/shared/mock-auth.js";
import { seedQaAgentWorkspace } from "./qa-agent-workspace.js";
import { buildQaGatewayConfig, type QaThinkingLevel } from "./qa-gateway-config.js";
import type { QaTransportAdapter } from "./qa-transport.js";
@@ -175,6 +179,7 @@ function createQaPackagedMockApiKey(): string {
async function stageQaPackagedMockAuthProfiles(params: {
command: QaGatewayChildCommand;
configPath: string;
cwd: string;
env: NodeJS.ProcessEnv;
providers: readonly string[];
@@ -196,7 +201,7 @@ async function stageQaPackagedMockAuthProfiles(params: {
buildQaMockProfileId(provider),
],
cwd: params.command.cwd ?? params.cwd,
env: params.env,
env: { ...params.env, OPENCLAW_CONFIG_PATH: params.configPath },
stdin: `${createQaPackagedMockApiKey()}\n`,
});
} catch (error) {
@@ -270,6 +275,7 @@ export async function startQaGatewayChild(params: {
const xdgDataHome = path.join(tempRoot, "xdg-data");
const xdgCacheHome = path.join(tempRoot, "xdg-cache");
const configPath = path.join(tempRoot, "openclaw.json");
const packagedAuthConfigPath = path.join(stateDir, "qa-auth-bootstrap", "openclaw.json");
const gatewayToken = `qa-suite-${randomUUID()}`;
const transport = params.transport ?? createQaGatewayEmptyTransport();
await seedQaAgentWorkspace({
@@ -352,7 +358,9 @@ export async function startQaGatewayChild(params: {
});
const mockAuthProviders = getQaProvider(providerMode).mockAuthProviders;
if (mockAuthProviders && mockAuthProviders.length > 0) {
if (!usesPackagedCandidate) {
if (usesPackagedCandidate) {
cfg = applyQaMockAuthProfileConfig({ cfg, providers: mockAuthProviders });
} else {
cfg = await stageQaMockAuthProfiles({
cfg,
stateDir,
@@ -378,6 +386,7 @@ export async function startQaGatewayChild(params: {
let cfg!: OpenClawConfig;
let getChildFailure: (() => QaChildFailure | null) | null = null;
let env: NodeJS.ProcessEnv | null = null;
let packagedMockAuthStaged = false;
let migrationConvergenceRestartUsed = false;
let reuseStartupLaunchState = false;
@@ -576,13 +585,29 @@ export async function startQaGatewayChild(params: {
mode: 0o600,
});
const mockAuthProviders = getQaProvider(providerMode).mockAuthProviders;
if (usesPackagedCandidate && gatewayCommand && mockAuthProviders?.length) {
if (
usesPackagedCandidate &&
gatewayCommand &&
mockAuthProviders?.length &&
!packagedMockAuthStaged
) {
const canonicalConfig = await fs.readFile(configPath);
await fs.mkdir(path.dirname(packagedAuthConfigPath), { recursive: true, mode: 0o700 });
await fs.writeFile(packagedAuthConfigPath, canonicalConfig, {
flag: "wx",
mode: 0o600,
});
await stageQaPackagedMockAuthProfiles({
command: gatewayCommand,
configPath: packagedAuthConfigPath,
cwd: gatewayCwd,
env,
providers: mockAuthProviders,
});
if (!canonicalConfig.equals(await fs.readFile(configPath))) {
throw new Error("installed package mock auth bootstrap mutated canonical config");
}
packagedMockAuthStaged = true;
}
}
if (!env) {
@@ -14,6 +14,22 @@ export function buildQaMockProfileId(provider: string): string {
return `qa-mock-${provider}`;
}
export function applyQaMockAuthProfileConfig(params: {
cfg: OpenClawConfig;
providers?: readonly string[];
}): OpenClawConfig {
let next = params.cfg;
for (const provider of uniqueStrings(params.providers ?? QA_MOCK_AUTH_PROVIDERS)) {
next = applyAuthProfileConfig(next, {
profileId: buildQaMockProfileId(provider),
provider,
mode: "api_key",
displayName: `QA mock ${provider} credential`,
});
}
return next;
}
/**
* In mock provider modes the qa suite runs against an embedded mock server
* instead of a real provider API. The mock does not validate credentials, but
@@ -41,7 +57,6 @@ export async function stageQaMockAuthProfiles(params: {
}): Promise<OpenClawConfig> {
const agentIds = uniqueStrings(params.agentIds ?? QA_MOCK_AUTH_AGENT_IDS);
const providers = uniqueStrings(params.providers ?? QA_MOCK_AUTH_PROVIDERS);
let next = params.cfg;
for (const agentId of agentIds) {
await writeQaAuthProfiles({
agentId,
@@ -59,13 +74,5 @@ export async function stageQaMockAuthProfiles(params: {
stateDir: params.stateDir,
});
}
for (const provider of providers) {
next = applyAuthProfileConfig(next, {
profileId: buildQaMockProfileId(provider),
provider,
mode: "api_key",
displayName: `QA mock ${provider} credential`,
});
}
return next;
return applyQaMockAuthProfileConfig({ cfg: params.cfg, providers });
}
@@ -925,24 +925,22 @@ describe("release Telegram QA workflow", () => {
);
expect(createSut).not.toContain('chmod 0711 "$temp_root"');
expect(createSut).not.toContain('chmod 1777 "$temp_root"');
expect(createSut).toContain('"${temp_root}/state/qa-auth-bootstrap/openclaw.json")');
expect(createSut).toContain(
'"$(stat -c \'%F:%a:%u:%g\' "$requested_config_path")" == "regular file:600:${SUT_UID}:${SUT_GID}"',
);
});
it("adds an empty PS1 only after attested runtime environment verification", () => {
it("does not defer Bash startup cleanup to the privileged launcher", () => {
const createSut = requireRun(
"run_telegram",
"Create isolated Telegram SUT identity and launcher",
);
const launcher = extractHereDocument(createSut, "LAUNCHER");
const verification = '[[ "$actual_env_keys_b64" == "$runtime_expected_env_keys_b64" ]]';
const ps1Export = "export PS1=";
const candidateExec = 'exec "$runtime_node_bin" "${runtime_node_args[@]}"';
expect(launcher.match(/export PS1=/gu)).toHaveLength(1);
expect(launcher.indexOf(verification)).toBeGreaterThan(-1);
expect(launcher.indexOf(ps1Export)).toBeGreaterThan(launcher.indexOf(verification));
expect(launcher.indexOf(candidateExec)).toBeGreaterThan(launcher.indexOf(ps1Export));
expect(launcher.match(/exec "\$runtime_node_bin"/gu)).toHaveLength(1);
expect(launcher).toContain('grep -Ev "^(PWD|SHLVL|_)$"');
expect(launcher).not.toContain("export PS1=");
expect(launcher).not.toContain("export -n BASHOPTS SHELLOPTS");
expect(launcher).not.toContain("unset BASH_ENV ENV");
});
it("mounts an isolated SUT-owned tmp without exposing the host tmp tree", () => {