diff --git a/.github/workflows/openclaw-release-telegram-qa.yml b/.github/workflows/openclaw-release-telegram-qa.yml index 3179ef4e5435..09fb14f0d11b 100644 --- a/.github/workflows/openclaw-release-telegram-qa.yml +++ b/.github/workflows/openclaw-release-telegram-qa.yml @@ -1505,9 +1505,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" @@ -1515,7 +1525,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" @@ -1550,6 +1560,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" @@ -1615,6 +1628,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 @@ -1624,6 +1638,8 @@ jobs: chmod 0600 "$launcher_stage_file" trap 'rm -f "$launcher_stage_file"' EXIT export launcher_stage_file + export -n BASHOPTS SHELLOPTS + unset BASH_ENV ENV /usr/bin/unshare \ --mount \ --fork \ @@ -1776,8 +1792,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" \ @@ -1814,6 +1834,7 @@ jobs: unset \ CANDIDATE_ROOT \ CANDIDATE_ARTIFACTS_DIR \ + CANONICAL_CONFIG_PATH \ EVIDENCE_ROOT \ NODE_BIN \ PRELOAD_PATH \ @@ -1855,9 +1876,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.test.ts b/extensions/qa-lab/src/gateway-child.test.ts index 04408c24e7b0..0552929ad852 100644 --- a/extensions/qa-lab/src/gateway-child.test.ts +++ b/extensions/qa-lab/src/gateway-child.test.ts @@ -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); `, @@ -1441,16 +1460,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 () => { @@ -1486,7 +1517,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 b632f7f06d6c..e766af2dd063 100644 --- a/extensions/qa-lab/src/gateway-child.ts +++ b/extensions/qa-lab/src/gateway-child.ts @@ -67,7 +67,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"; @@ -189,6 +193,7 @@ function createQaPackagedMockApiKey(): string { async function stageQaPackagedMockAuthProfiles(params: { command: QaGatewayChildCommand; + configPath: string; cwd: string; env: NodeJS.ProcessEnv; providers: readonly string[]; @@ -210,7 +215,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) { @@ -283,6 +288,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({ @@ -365,7 +371,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, @@ -391,6 +399,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; @@ -588,13 +597,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 a44b2261ae3f..fccbd047929e 100644 --- a/test/scripts/openclaw-release-telegram-qa-workflow.test.ts +++ b/test/scripts/openclaw-release-telegram-qa-workflow.test.ts @@ -872,23 +872,32 @@ 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("strips inherited Bash startup state before the first isolated child", () => { 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 stripOptions = "export -n BASHOPTS SHELLOPTS"; + const stripStartupFiles = "unset BASH_ENV ENV"; + const isolatedSpawn = "/usr/bin/unshare"; const candidateExec = 'exec "$runtime_node_bin" "${runtime_node_args[@]}"'; - expect(launcher.match(/export PS1=/gu)).toHaveLength(1); + expect(launcher).not.toContain("export PS1="); + expect(launcher.indexOf(stripOptions)).toBeGreaterThan(-1); + expect(launcher.indexOf(stripStartupFiles)).toBeGreaterThan(launcher.indexOf(stripOptions)); + expect(launcher.indexOf(isolatedSpawn)).toBeGreaterThan(launcher.indexOf(stripStartupFiles)); expect(launcher.indexOf(verification)).toBeGreaterThan(-1); - expect(launcher.indexOf(ps1Export)).toBeGreaterThan(launcher.indexOf(verification)); - expect(launcher.indexOf(candidateExec)).toBeGreaterThan(launcher.indexOf(ps1Export)); + expect(launcher.indexOf(candidateExec)).toBeGreaterThan(launcher.indexOf(verification)); expect(launcher.match(/exec "\$runtime_node_bin"/gu)).toHaveLength(1); + expect(launcher.match(/\/bin\/bash -ceu/gu)).toHaveLength(2); expect(launcher).toContain('grep -Ev "^(PWD|SHLVL|_)$"'); });