diff --git a/.github/workflows/openclaw-release-telegram-qa.yml b/.github/workflows/openclaw-release-telegram-qa.yml index f8427bec39d9..742e576393e7 100644 --- a/.github/workflows/openclaw-release-telegram-qa.yml +++ b/.github/workflows/openclaw-release-telegram-qa.yml @@ -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 "$@" diff --git a/extensions/qa-lab/src/gateway-child-env.ts b/extensions/qa-lab/src/gateway-child-env.ts index c67c857a1845..9e3f86b07ddf 100644 --- a/extensions/qa-lab/src/gateway-child-env.ts +++ b/extensions/qa-lab/src/gateway-child-env.ts @@ -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: { diff --git a/extensions/qa-lab/src/gateway-child.test.ts b/extensions/qa-lab/src/gateway-child.test.ts index 7412a5af0791..3665ce8fe508 100644 --- a/extensions/qa-lab/src/gateway-child.test.ts +++ b/extensions/qa-lab/src/gateway-child.test.ts @@ -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); diff --git a/extensions/qa-lab/src/gateway-child.ts b/extensions/qa-lab/src/gateway-child.ts index fac4e3b8fb5e..f11f375a3e62 100644 --- a/extensions/qa-lab/src/gateway-child.ts +++ b/extensions/qa-lab/src/gateway-child.ts @@ -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) { diff --git a/extensions/qa-lab/src/providers/shared/mock-auth.ts b/extensions/qa-lab/src/providers/shared/mock-auth.ts index 7d349e9df4e6..a17cb5282b30 100644 --- a/extensions/qa-lab/src/providers/shared/mock-auth.ts +++ b/extensions/qa-lab/src/providers/shared/mock-auth.ts @@ -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 { 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 }); } diff --git a/test/scripts/openclaw-release-telegram-qa-workflow.test.ts b/test/scripts/openclaw-release-telegram-qa-workflow.test.ts index 8d675e54b62d..2c7de69fe14b 100644 --- a/test/scripts/openclaw-release-telegram-qa-workflow.test.ts +++ b/test/scripts/openclaw-release-telegram-qa-workflow.test.ts @@ -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", () => {