Secrets: make runtime activation auth loads read-only

This commit is contained in:
joshavant
2026-02-24 13:19:02 -06:00
committed by Peter Steinberger
parent 3dbb6be270
commit 8e33ebe471
7 changed files with 191 additions and 23 deletions

View File

@@ -1,4 +1,7 @@
import fs from "node:fs/promises";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { resolveMainSessionKeyFromConfig } from "../config/sessions.js";
import { drainSystemEvents } from "../infra/system-events.js";
import {
connectOk,
installGatewayTestHooks,
@@ -170,11 +173,13 @@ describe("gateway hot reload", () => {
let prevSkipChannels: string | undefined;
let prevSkipGmail: string | undefined;
let prevSkipProviders: string | undefined;
let prevOpenAiApiKey: string | undefined;
beforeEach(() => {
prevSkipChannels = process.env.OPENCLAW_SKIP_CHANNELS;
prevSkipGmail = process.env.OPENCLAW_SKIP_GMAIL_WATCHER;
prevSkipProviders = process.env.OPENCLAW_SKIP_PROVIDERS;
prevOpenAiApiKey = process.env.OPENAI_API_KEY;
process.env.OPENCLAW_SKIP_CHANNELS = "0";
delete process.env.OPENCLAW_SKIP_GMAIL_WATCHER;
delete process.env.OPENCLAW_SKIP_PROVIDERS;
@@ -196,8 +201,39 @@ describe("gateway hot reload", () => {
} else {
process.env.OPENCLAW_SKIP_PROVIDERS = prevSkipProviders;
}
if (prevOpenAiApiKey === undefined) {
delete process.env.OPENAI_API_KEY;
} else {
process.env.OPENAI_API_KEY = prevOpenAiApiKey;
}
});
async function writeEnvRefConfig() {
const configPath = process.env.OPENCLAW_CONFIG_PATH;
if (!configPath) {
throw new Error("OPENCLAW_CONFIG_PATH is not set");
}
await fs.writeFile(
configPath,
`${JSON.stringify(
{
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: { source: "env", id: "OPENAI_API_KEY" },
models: [],
},
},
},
},
null,
2,
)}\n`,
"utf8",
);
}
it("applies hot reload actions and emits restart signal", async () => {
await withGatewayServer(async () => {
const onHotReload = hoisted.getOnHotReload();
@@ -302,6 +338,70 @@ describe("gateway hot reload", () => {
expect(signalSpy).toHaveBeenCalledTimes(1);
});
});
it("fails startup when required secret refs are unresolved", async () => {
await writeEnvRefConfig();
delete process.env.OPENAI_API_KEY;
await expect(withGatewayServer(async () => {})).rejects.toThrow(
"Startup failed: required secrets are unavailable",
);
});
it("emits one-shot degraded and recovered system events during secret reload transitions", async () => {
await writeEnvRefConfig();
process.env.OPENAI_API_KEY = "sk-startup";
await withGatewayServer(async () => {
const onHotReload = hoisted.getOnHotReload();
expect(onHotReload).toBeTypeOf("function");
const sessionKey = resolveMainSessionKeyFromConfig();
const plan = {
changedPaths: ["models.providers.openai.apiKey"],
restartGateway: false,
restartReasons: [],
hotReasons: ["models.providers.openai.apiKey"],
reloadHooks: false,
restartGmailWatcher: false,
restartBrowserControl: false,
restartCron: false,
restartHeartbeat: false,
restartChannels: new Set(),
noopPaths: [],
};
const nextConfig = {
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: { source: "env", id: "OPENAI_API_KEY" },
models: [],
},
},
},
};
delete process.env.OPENAI_API_KEY;
await expect(onHotReload?.(plan, nextConfig)).rejects.toThrow(
'Environment variable "OPENAI_API_KEY" is missing or empty.',
);
const degradedEvents = drainSystemEvents(sessionKey);
expect(degradedEvents.some((event) => event.includes("[SECRETS_RELOADER_DEGRADED]"))).toBe(
true,
);
await expect(onHotReload?.(plan, nextConfig)).rejects.toThrow(
'Environment variable "OPENAI_API_KEY" is missing or empty.',
);
expect(drainSystemEvents(sessionKey)).toEqual([]);
process.env.OPENAI_API_KEY = "sk-recovered";
await expect(onHotReload?.(plan, nextConfig)).resolves.toBeUndefined();
const recoveredEvents = drainSystemEvents(sessionKey);
expect(recoveredEvents.some((event) => event.includes("[SECRETS_RELOADER_RECOVERED]"))).toBe(
true,
);
});
});
});
describe("gateway agents", () => {