fix(qa): isolate packaged mock auth config

Punchcard-Session: frost-orchard-lantern-ze
This commit is contained in:
Vincent Koc
2026-08-19 00:40:18 -07:00
parent 0151ef17b3
commit 648bd40a4f
5 changed files with 126 additions and 30 deletions
@@ -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 "$@"
+42 -5
View File
@@ -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);
+29 -4
View File
@@ -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) {
@@ -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 });
}
@@ -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|_)$"');
});