diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256
index 095a1976c442..62e4e571e8b4 100644
--- a/docs/.generated/plugin-sdk-api-baseline.sha256
+++ b/docs/.generated/plugin-sdk-api-baseline.sha256
@@ -68,8 +68,8 @@ cc0a77137b304b27a313791aa30e43efb4acc254da7590bd47493d81e7104fba module/channel
7521b7b935944c2ca5fdd8171ba921f134b68e6f29e2c9409e4e6290ce667de1 module/channel-route
a676d58af7bc732dbee41157d4e5c9efba4d429ae6502224de0d8ee2d4c23220 module/channel-runtime
bba5540be7cf9613a163663decdb2affe2af9bbd3ad7914989ab186f9c2abec1 module/channel-runtime-context
-54cdfc60ea96f55453fdccdb1241d8c988aeafe033b9e0cea0f3ecaa13d2e08a module/channel-secret-basic-runtime
-c78711a2466fca83ae2e68725e61e3a1a4e7dd25abce74d30a943c1be364f704 module/channel-secret-runtime
+17cec26bc71fc43a066049ef63f95bf29737113c26ab13689ceff602b9aa11d6 module/channel-secret-basic-runtime
+0ceb4378709eb2d92a62a275f87fa04e18f77df8f942a9a0acef81019ebc1e24 module/channel-secret-runtime
96e38ca99edb7d39839a7675939d5b8c199f8625502cae15e66b4bc7885922a7 module/channel-secret-tts-runtime
7c90157a95bc0523fc66b1f78ce140f7ec7dbf809dfa3a480244efc01f972754 module/channel-send-result
e705b97daac4fbd7f6aeb2e515b1cdaa1db342684644eaef8197767b2595826e module/channel-setup
diff --git a/docs/cli/doctor.md b/docs/cli/doctor.md
index 9093c60e7f0c..59c7f16e345c 100644
--- a/docs/cli/doctor.md
+++ b/docs/cli/doctor.md
@@ -10,7 +10,7 @@ title: "Doctor"
Health checks and quick fixes for the gateway, channels, plugins, skills, model routing, local state, and config migrations. Use it whenever something is not behaving as expected and you want one command to explain what is wrong.
-When Gateway status reports SecretRef owners isolated during cold startup, doctor prints a **Secret runtime degradation** warning with every cold owner, affected config path, redacted reason, and the `openclaw secrets reload` retry command.
+When Gateway status reports degraded SecretRef owners, doctor prints a **Secret runtime degradation** warning with every cold or stale owner, affected config path, redacted reason, and the `openclaw secrets reload` retry command.
Related:
diff --git a/docs/gateway/secrets.md b/docs/gateway/secrets.md
index eb96615d560e..e3466465b8e0 100644
--- a/docs/gateway/secrets.md
+++ b/docs/gateway/secrets.md
@@ -22,15 +22,13 @@ Plaintext credentials remain agent-readable if they sit in files the agent can i
- Secrets resolve into an in-memory runtime snapshot, eagerly during activation, not lazily on request paths.
- Cold Gateway startup isolates unavailable SecretRefs to a known non-Gateway owner when that owner supports isolation. Today this covers model providers, the built-in TTS capability, web search and fetch providers, and webhook routes. The Gateway starts, records that owner as configured-unavailable, and emits a redacted `SECRETS_OWNER_UNAVAILABLE` warning. Gateway ingress auth, structurally invalid refs or resolved values, and refs whose runtime owner is not yet mapped still fail startup.
-- Reload is an atomic swap: full success, or keep the last-known-good snapshot.
+- Reload validates each mapped owner independently, then publishes one atomic snapshot. Healthy owners refresh; an unchanged failed owner keeps its last-known-good value and becomes stale, while a changed or new failed owner becomes cold.
- Policy violations (for example an OAuth-mode auth profile combined with SecretRef input) fail activation before the runtime swap.
- Runtime requests read only the active in-memory snapshot. Model-provider SecretRef credentials pass through auth storage and stream options as process-local sentinels until egress. Outbound delivery paths (Discord reply/thread delivery, Telegram action sends) also read that snapshot and do not re-resolve refs per send.
This keeps secret-provider outages off hot request paths.
-
-Target policy for the SecretRef ownership-isolation migration: failures isolate to the smallest known owner. Only unavailable Gateway ingress protection, structurally invalid config, or unknown ownership will block startup; other affected capabilities, accounts, or routes will become configured-unavailable with typed redacted diagnostics and no implicit credential fallback. Reload will retain last-known-good only for an unchanged ref and provider, while a changed unresolved ref will make that owner cold. Doctor and structured Gateway warnings will identify degraded owners. This migration is not fully implemented; the current activation rules on this page remain in effect.
-
+Gateway ingress protection, structurally invalid config or resolved values, policy violations, and unknown ownership still fail closed. Isolated owners never fall through to a lower-precedence credential source.
## Egress-time injection (sentinels)
@@ -592,16 +590,16 @@ Secret activation runs on:
- Config reload hot-apply path
- Config reload restart-check path
- Manual reload via `secrets.reload`
-- Gateway config write RPC preflight (`config.set` / `config.apply` / `config.patch`), checking active-surface SecretRef resolvability within the submitted config payload before persisting edits
+- Gateway config write RPC preflight (`config.set` / `config.apply` / `config.patch`), validating active-surface SecretRefs within the submitted config payload before persisting edits
Activation contract:
- Success swaps the snapshot atomically.
- Startup failure aborts gateway startup.
- During cold startup, a resolution failure for a mapped non-Gateway owner may publish the snapshot with that exact owner configured-unavailable. Requests for the owner fail with `SECRET_SURFACE_UNAVAILABLE`; model-provider owners do not fall back to environment or auth-profile credentials after an explicit ref fails.
-- Reload, restart-check, and write preflight remain strict. They keep the active last-known-good snapshot rather than publishing new degraded owners.
-- Runtime reload failure keeps the last-known-good snapshot.
-- Write-RPC preflight failure rejects the submitted config; both disk config and the active runtime snapshot stay unchanged.
+- Reload and restart-check isolate mapped non-Gateway owners. Unchanged refs with unchanged provider definitions retain their exact last-known-good values as stale; changed or newly configured unresolved refs publish cold for only that owner.
+- `config.set`, `config.apply`, and `config.patch` accept syntactically valid unresolved refs for isolatable owners and return a redacted `degradedSecretOwners` report. Gateway ingress auth, structurally invalid config or resolved values, policy violations, and unknown owners still reject before disk mutation.
+- Healthy sibling owners resolve and publish normally even when another owner is cold or stale.
- Providing an explicit per-call channel token to an outbound helper/tool call does not trigger SecretRef activation; activation points remain startup, reload, and explicit `secrets.reload`.
## Degraded and recovered signals
@@ -613,12 +611,12 @@ When reload-time activation fails after a healthy state, OpenClaw enters degrade
Behavior:
-- Degraded: runtime keeps the last-known-good snapshot.
+- Degraded: healthy owners refresh, stale owners keep last-known-good, and cold owners remain unavailable.
- Recovered: emitted once after the next successful activation.
- Repeated failures while already degraded log warnings but do not re-emit the event.
- Startup fail-fast never emits a degraded event, because runtime never became active.
- Startup and reload failures emit a structured `SECRETS_DEGRADED` warning for each affected owner. The warning includes the owner kind and id, a redacted reason, `cold` or `stale` state, and the `openclaw secrets reload` retry hint. It never includes resolved values or SecretRef ids.
-- `openclaw doctor` lists owners isolated during cold startup with their affected config paths, redacted reason, and retry guidance.
+- `openclaw doctor` lists cold and stale owners with their affected config paths, redacted reason, and retry guidance.
## Command-path resolution
diff --git a/extensions/msteams/src/secret-contract.ts b/extensions/msteams/src/secret-contract.ts
index ef174a3271a5..ec95ad029869 100644
--- a/extensions/msteams/src/secret-contract.ts
+++ b/extensions/msteams/src/secret-contract.ts
@@ -34,6 +34,7 @@ export function collectRuntimeConfigAssignments(params: {
ownerId: "msteams:default",
requiredForGateway: false,
disposition: "isolate",
+ contract: msteams,
},
apply: (value) => {
msteams.appPassword = value;
diff --git a/src/commands/doctor-gateway-health.test.ts b/src/commands/doctor-gateway-health.test.ts
index 3db07dacdb3c..bbeb6a730c22 100644
--- a/src/commands/doctor-gateway-health.test.ts
+++ b/src/commands/doctor-gateway-health.test.ts
@@ -126,6 +126,7 @@ describe("checkGatewayHealth", () => {
ownerKind: "capability",
ownerId: "tts",
state: "unavailable",
+ degradationState: "stale",
paths: ["messages.tts.providers.elevenlabs.apiKey"],
reason: "secret provider policy denied resolution",
},
@@ -147,7 +148,7 @@ describe("checkGatewayHealth", () => {
[
"- cold account:discord:ops (channels.discord.accounts.ops.token): secret resolution failed",
" Retry: openclaw secrets reload",
- "- cold capability:tts (messages.tts.providers.elevenlabs.apiKey): secret provider policy denied resolution",
+ "- stale capability:tts (messages.tts.providers.elevenlabs.apiKey): secret provider policy denied resolution",
" Retry: openclaw secrets reload",
"- cold capability:web-fetch:firecrawl (plugins.entries.firecrawl.config.webFetch.apiKey): resolved secret value was invalid",
" Retry: openclaw secrets reload",
diff --git a/src/commands/doctor-gateway-health.ts b/src/commands/doctor-gateway-health.ts
index 2695b1dc6f51..0bd8a8c0a1c9 100644
--- a/src/commands/doctor-gateway-health.ts
+++ b/src/commands/doctor-gateway-health.ts
@@ -92,7 +92,7 @@ export async function checkGatewayHealth(params: {
status.degradedSecretOwners
.map(
(owner) =>
- `- cold ${owner.ownerKind}:${owner.ownerId} (${owner.paths.join(", ")}): ${redactSecretDegradationReason(owner.reason)}` +
+ `- ${owner.degradationState ?? "cold"} ${owner.ownerKind}:${owner.ownerId} (${owner.paths.join(", ")}): ${redactSecretDegradationReason(owner.reason)}` +
"\n Retry: openclaw secrets reload",
)
.join("\n"),
diff --git a/src/commands/status-overview-rows.ts b/src/commands/status-overview-rows.ts
index d08115d3029a..5c32e0477614 100644
--- a/src/commands/status-overview-rows.ts
+++ b/src/commands/status-overview-rows.ts
@@ -110,7 +110,7 @@ export function buildStatusCommandOverviewRows(
const degradedSecretsValue =
degradedSecretOwners.length > 0
? params.warn(
- `${degradedSecretOwners.length} unavailable · ${degradedSecretOwners
+ `${degradedSecretOwners.length} degraded · ${degradedSecretOwners
.map((owner) => `${owner.ownerKind}:${owner.ownerId}`)
.join(", ")}`,
)
diff --git a/src/commands/status.summary.test.ts b/src/commands/status.summary.test.ts
index 32e0ca51dc0c..0a8b645a29ef 100644
--- a/src/commands/status.summary.test.ts
+++ b/src/commands/status.summary.test.ts
@@ -284,6 +284,7 @@ describe("getStatusSummary", () => {
ownerKind: "account",
ownerId: "discord:ops",
state: "unavailable",
+ degradationState: "cold",
paths: ["channels.discord.accounts.ops.token"],
refKeys: ["env:default:PRIVATE_REF_ID"],
reason: "provider SecretRef is unresolved (env:default:PRIVATE_REF_ID)",
@@ -297,6 +298,7 @@ describe("getStatusSummary", () => {
ownerKind: "account",
ownerId: "discord:ops",
state: "unavailable",
+ degradationState: "cold",
paths: ["channels.discord.accounts.ops.token"],
reason: "secret resolution failed",
},
diff --git a/src/commands/status.summary.ts b/src/commands/status.summary.ts
index a92c05e0db4d..f80c527a9647 100644
--- a/src/commands/status.summary.ts
+++ b/src/commands/status.summary.ts
@@ -573,12 +573,13 @@ export async function getStatusSummary(
channelSummary,
queuedSystemEvents,
degradedSecretOwners: listActiveDegradedSecretOwners().map(
- ({ ownerKind, ownerId, state, paths: ownerPaths, reason }) => {
+ ({ ownerKind, ownerId, state, degradationState, paths: ownerPaths, reason }) => {
const redactedReason: string = redactSecretDegradationReason(reason);
return {
ownerKind,
ownerId,
state,
+ degradationState: degradationState ?? "cold",
paths: ownerPaths,
reason: redactedReason,
};
diff --git a/src/commands/status.types.ts b/src/commands/status.types.ts
index 8708c39d2742..0485c8d6c279 100644
--- a/src/commands/status.types.ts
+++ b/src/commands/status.types.ts
@@ -70,6 +70,7 @@ export type StatusSummary = {
ownerKind: "account" | "capability" | "gateway" | "provider" | "route";
ownerId: string;
state: "unavailable";
+ degradationState?: "cold" | "stale";
paths: string[];
reason: string;
}>;
diff --git a/src/config/runtime-snapshot.ts b/src/config/runtime-snapshot.ts
index 4aadd1385c4c..9917a1089833 100644
--- a/src/config/runtime-snapshot.ts
+++ b/src/config/runtime-snapshot.ts
@@ -178,6 +178,22 @@ export function setAppliedRuntimeConfigSnapshot(
runtimeConfigAppliedHash = hashRuntimeConfigValue(sourceConfig);
}
+/** Publish a newer canonical source without changing the active runtime object. */
+export function setRuntimeConfigSourceSnapshotIfCurrent(params: {
+ expectedRevision: number;
+ sourceConfig: OpenClawConfig;
+}): boolean {
+ if (
+ !runtimeConfigSnapshot ||
+ !runtimeConfigSnapshotMetadata ||
+ runtimeConfigSnapshotMetadata.revision !== params.expectedRevision
+ ) {
+ return false;
+ }
+ setRuntimeConfigSnapshot(runtimeConfigSnapshot, params.sourceConfig);
+ return true;
+}
+
export function resetConfigRuntimeState(): void {
runtimeConfigSnapshot = null;
runtimeConfigSourceSnapshot = null;
diff --git a/src/gateway/server-methods/config.shared-auth.test.ts b/src/gateway/server-methods/config.shared-auth.test.ts
index 2934464edfea..c1b7e185b668 100644
--- a/src/gateway/server-methods/config.shared-auth.test.ts
+++ b/src/gateway/server-methods/config.shared-auth.test.ts
@@ -284,7 +284,7 @@ describe("config shared auth disconnects", () => {
);
});
- it("rejects unresolved TTS SecretRefs before config.set writes", async () => {
+ it("accepts an unresolved isolatable TTS SecretRef and reports the cold owner", async () => {
const submittedConfig: OpenClawConfig = {
messages: {
tts: {
@@ -297,9 +297,20 @@ describe("config shared auth disconnects", () => {
},
};
mockPreviousConfig({});
- prepareSecretsRuntimeSnapshotMock.mockRejectedValueOnce(
- new Error('Environment variable "ELEVENLABS_API_KEY" is missing or empty.'),
- );
+ prepareSecretsRuntimeSnapshotMock.mockResolvedValueOnce({
+ config: submittedConfig,
+ degradedOwners: [
+ {
+ ownerKind: "capability",
+ ownerId: "tts",
+ state: "unavailable",
+ degradationState: "cold",
+ paths: ["messages.tts.providers.elevenlabs.apiKey"],
+ refKeys: ["env:default:ELEVENLABS_API_KEY"],
+ reason: "secret reference was not found",
+ },
+ ],
+ });
const { options, respond } = createConfigHandlerHarness({
method: "config.set",
params: {
@@ -317,14 +328,76 @@ describe("config shared auth disconnects", () => {
expect(prepareSecretsRuntimeSnapshotMock).toHaveBeenCalledWith({
config: submittedConfig,
includeAuthStoreRefs: false,
+ allowUnavailableSecretOwners: true,
});
+ expect(writeConfigFileMock).toHaveBeenCalled();
+ expect(respond).toHaveBeenCalledWith(
+ true,
+ expect.objectContaining({
+ degradedSecretOwners: [
+ expect.objectContaining({
+ ownerKind: "capability",
+ ownerId: "tts",
+ state: "cold",
+ reason: "secret reference was not found",
+ }),
+ ],
+ }),
+ undefined,
+ );
+ });
+
+ it.each([
+ "secret provider policy denied resolution",
+ "secret provider response violated its contract",
+ "resolved secret value was invalid",
+ "secret reference is not allowed for this provider",
+ ])("rejects non-retryable SecretRef degradation before config writes: %s", async (reason) => {
+ const submittedConfig: OpenClawConfig = {
+ messages: {
+ tts: {
+ providers: {
+ elevenlabs: {
+ apiKey: { source: "env", provider: "default", id: "ELEVENLABS_API_KEY" },
+ },
+ },
+ },
+ },
+ };
+ mockPreviousConfig({});
+ prepareSecretsRuntimeSnapshotMock.mockResolvedValueOnce({
+ config: submittedConfig,
+ degradedOwners: [
+ {
+ ownerKind: "capability",
+ ownerId: "tts",
+ state: "unavailable",
+ degradationState: "cold",
+ paths: ["messages.tts.providers.elevenlabs.apiKey"],
+ refKeys: ["env:default:ELEVENLABS_API_KEY"],
+ reason,
+ },
+ ],
+ });
+ const { options, respond } = createConfigHandlerHarness({
+ method: "config.set",
+ params: {
+ raw: JSON.stringify(submittedConfig),
+ baseHash: "base-hash",
+ },
+ });
+
+ await expectDefined(
+ configHandlers["config.set"],
+ 'configHandlers["config.set"] test invariant',
+ )(options);
+ await flushConfigHandlerMicrotasks();
+
expect(writeConfigFileMock).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith(
false,
undefined,
- expect.objectContaining({
- message: expect.stringContaining("active SecretRef resolution failed"),
- }),
+ expect.objectContaining({ message: expect.stringContaining(reason) }),
);
});
diff --git a/src/gateway/server-methods/config.ts b/src/gateway/server-methods/config.ts
index bfe1e1ae5513..f72f2de49672 100644
--- a/src/gateway/server-methods/config.ts
+++ b/src/gateway/server-methods/config.ts
@@ -40,6 +40,10 @@ import {
import { isBuiltInModelProviderOverlayId } from "../../config/zod-schema.core.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { isPlainObject } from "../../infra/plain-object.js";
+import {
+ isRetryableSecretDegradationReason,
+ redactSecretDegradationReason,
+} from "../../secrets/runtime-degraded-state.js";
import {
prepareSecretsRuntimeSnapshot,
type PreparedSecretsRuntimeSnapshot,
@@ -492,10 +496,18 @@ async function ensureResolvableSecretRefsOrRespond(params: {
respond: RespondFn;
}): Promise {
try {
- return await prepareSecretsRuntimeSnapshot({
+ const snapshot = await prepareSecretsRuntimeSnapshot({
config: params.config,
includeAuthStoreRefs: false,
+ allowUnavailableSecretOwners: true,
});
+ for (const owner of snapshot.degradedOwners ?? []) {
+ const reason = redactSecretDegradationReason(owner.reason);
+ if (!isRetryableSecretDegradationReason(reason)) {
+ throw new Error(reason);
+ }
+ }
+ return snapshot;
} catch (error) {
const details = formatErrorMessage(error);
params.respond(
@@ -510,6 +522,21 @@ async function ensureResolvableSecretRefsOrRespond(params: {
}
}
+function listPreparedSecretDegradations(snapshot: PreparedSecretsRuntimeSnapshot) {
+ return (snapshot.degradedOwners ?? []).map((owner) => ({
+ ownerKind: owner.ownerKind,
+ ownerId: owner.ownerId,
+ state: owner.degradationState ?? "cold",
+ paths: [...owner.paths],
+ reason: redactSecretDegradationReason(owner.reason),
+ }));
+}
+
+function preparedSecretDegradationPayload(snapshot: PreparedSecretsRuntimeSnapshot) {
+ const degradedSecretOwners = listPreparedSecretDegradations(snapshot);
+ return degradedSecretOwners.length > 0 ? { degradedSecretOwners } : {};
+}
+
export function clearConfigSchemaResponseCacheForTests() {
configSchemaResponseCache = null;
}
@@ -532,6 +559,7 @@ async function respondWithConfigRestartWrite(params: {
context: GatewayRequestContext | undefined;
respond: RespondFn;
uiHints: ConfigRedactionHints;
+ preparedSecretsSnapshot: PreparedSecretsRuntimeSnapshot;
}): Promise {
clearConfigSchemaResponseCache();
const { payload, sentinelPersisted, restart } = await resolveGatewayConfigRestartWriteResult({
@@ -553,6 +581,7 @@ async function respondWithConfigRestartWrite(params: {
// persisted bytes, so writers can adopt it without a reload.
...(params.writeResult.hash ? { hash: params.writeResult.hash } : {}),
config: redactConfigObject(params.writeResult.config, params.uiHints),
+ ...preparedSecretDegradationPayload(params.preparedSecretsSnapshot),
restart,
sentinel: {
persisted: sentinelPersisted,
@@ -692,7 +721,11 @@ export const configHandlers: GatewayRequestHandlers = {
if (!parsed) {
return;
}
- if (!(await ensureResolvableSecretRefsOrRespond({ config: parsed.config, respond }))) {
+ const preparedSecretsSnapshot = await ensureResolvableSecretRefsOrRespond({
+ config: parsed.config,
+ respond,
+ });
+ if (!preparedSecretsSnapshot) {
return;
}
const writeResult = await commitGatewayConfigWrite({
@@ -711,6 +744,7 @@ export const configHandlers: GatewayRequestHandlers = {
// persisted bytes, so writers can adopt it without a reload.
...(writeResult.hash ? { hash: writeResult.hash } : {}),
config: redactConfigObject(writeResult.config, parsed.schema.uiHints),
+ ...preparedSecretDegradationPayload(preparedSecretsSnapshot),
},
undefined,
);
@@ -888,6 +922,7 @@ export const configHandlers: GatewayRequestHandlers = {
context,
respond,
uiHints: schemaPatch.uiHints,
+ preparedSecretsSnapshot,
});
},
"config.apply": async ({ params, respond, client, context }) => {
@@ -939,6 +974,7 @@ export const configHandlers: GatewayRequestHandlers = {
context,
respond,
uiHints: parsed.schema.uiHints,
+ preparedSecretsSnapshot,
});
},
"config.openFile": async ({ params, respond, context }) => {
diff --git a/src/gateway/server-reload-handlers.test.ts b/src/gateway/server-reload-handlers.test.ts
index 2024e7a23798..1eefa1b66601 100644
--- a/src/gateway/server-reload-handlers.test.ts
+++ b/src/gateway/server-reload-handlers.test.ts
@@ -46,6 +46,7 @@ import { createEmptyRuntimeWebToolsMetadata } from "../secrets/runtime-fast-path
import { classifySecretOwnerDegradationState } from "../secrets/runtime-owner-assignments.js";
import {
activateSecretsRuntimeSnapshot,
+ activateSecretsRuntimeSnapshotIfCurrent,
clearSecretsRuntimeSnapshot,
getActiveSecretsRuntimeSnapshot,
getActiveSecretsRuntimeSnapshotRevision,
@@ -521,7 +522,7 @@ function createManagedRestartSequenceHarness(
recordReloadError = undefined;
}),
};
- const activateRuntimeSecrets = vi.fn(async (config: OpenClawConfig) => {
+ const activateRuntimeSecrets = vi.fn(async (config: OpenClawConfig, _params: unknown) => {
const secretInputs = [
config.gateway?.auth?.token,
config.models?.providers?.test?.apiKey,
@@ -3878,7 +3879,7 @@ describe("gateway Gmail hot reload handlers", () => {
expect(clearGmailRestartAbortController).toHaveBeenCalledWith(abortController);
});
- it("commits runtime secrets for managed no-op config reloads", async () => {
+ it("retries managed no-op reloads without publishing superseded secret failures", async () => {
vi.useFakeTimers();
const writeListenerRef: { current: ((event: ConfigWriteNotification) => void) | null } = {
current: null,
@@ -3891,14 +3892,36 @@ describe("gateway Gmail hot reload handlers", () => {
gateway: { reload: { debounceMs: 0 } },
messages: { visibleReplies: "message_tool" },
};
- const activateRuntimeSecrets = vi.fn(async (config: OpenClawConfig, _params: unknown) => ({
+ const snapshot = (config: OpenClawConfig): PreparedSecretsRuntimeSnapshot => ({
sourceConfig: config,
config,
authStores: [],
authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(),
warnings: [],
- webTools: {},
- }));
+ webTools: createEmptyRuntimeWebToolsMetadata(),
+ });
+ const failurePublicationEligibility: boolean[] = [];
+ let preparationAttempt = 0;
+ const activateRuntimeSecrets = vi.fn(
+ async (
+ config: OpenClawConfig,
+ activation: { canPublishFailureAsDegraded?: () => boolean },
+ ) => {
+ const attempt = preparationAttempt++;
+ if (attempt === 0) {
+ failurePublicationEligibility.push(activation.canPublishFailureAsDegraded?.() ?? false);
+ activateSecretsRuntimeSnapshot(snapshot(initialConfig));
+ failurePublicationEligibility.push(activation.canPublishFailureAsDegraded?.() ?? true);
+ throw new Error("superseded secret preparation failure");
+ }
+ if (attempt === 1) {
+ queueMicrotask(() => {
+ queueMicrotask(() => activateSecretsRuntimeSnapshot(snapshot(initialConfig)));
+ });
+ }
+ return snapshot(config);
+ },
+ );
const heartbeatRunner = { stop: vi.fn(), updateConfig: vi.fn() };
const acceptTerminalConfig = vi.fn();
const commitTerminalConfig = vi.fn();
@@ -3984,7 +4007,7 @@ describe("gateway Gmail hot reload handlers", () => {
});
await vi.runAllTimersAsync();
- expect(activateRuntimeSecrets).toHaveBeenCalledTimes(1);
+ expect(activateRuntimeSecrets).toHaveBeenCalledTimes(3);
expect(activateRuntimeSecrets).toHaveBeenCalledWith(nextConfig, {
reason: "reload",
activate: false,
@@ -3992,6 +4015,7 @@ describe("gateway Gmail hot reload handlers", () => {
canPublishFailureAsDegraded: expect.any(Function),
includeAuthStoreRefs: undefined,
});
+ expect(failurePublicationEligibility).toEqual([true, false]);
expect(getActiveSecretsRuntimeSnapshot()?.sourceConfig).toEqual(nextConfig);
expect(acceptTerminalConfig).toHaveBeenCalledWith({
retireRejectedRestart: true,
@@ -4021,8 +4045,14 @@ describe("gateway Gmail hot reload handlers", () => {
gateway: { reload: { debounceMs: 0 } },
messages: { tts: { providers: { elevenlabs: { apiKey: String(42) } } } },
};
+ const ttsContractDigest = "tts-source-only-contract";
const initialSourceConfig = sourceConfig(firstRef);
const nextSourceConfig = sourceConfig(secondRef);
+ const activeWarning = {
+ code: "SECRETS_OWNER_UNAVAILABLE" as const,
+ path: "messages.tts.providers.elevenlabs.apiKey",
+ message: "Text-to-speech remains unavailable.",
+ };
activateSecretsRuntimeSnapshot({
sourceConfig: initialSourceConfig,
config: runtimeConfig,
@@ -4043,7 +4073,7 @@ describe("gateway Gmail hot reload handlers", () => {
},
],
authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(),
- warnings: [],
+ warnings: [activeWarning],
degradedOwners: [
{
ownerKind: "capability",
@@ -4067,6 +4097,7 @@ describe("gateway Gmail hot reload handlers", () => {
ownerKind: "capability",
ownerId: "tts",
refKeys: ["env:default:TTS_FIRST"],
+ contractDigest: ttsContractDigest,
},
{
ownerKind: "account",
@@ -4105,6 +4136,7 @@ describe("gateway Gmail hot reload handlers", () => {
ownerKind: "capability" as const,
ownerId: "tts",
refKeys: ["env:default:TTS_SECOND"],
+ contractDigest: ttsContractDigest,
},
{
ownerKind: "account" as const,
@@ -4205,11 +4237,13 @@ describe("gateway Gmail hot reload handlers", () => {
expect(activateRuntimeSecrets).not.toHaveBeenCalled();
expect(getActiveSecretsRuntimeSnapshot()?.sourceConfig).toEqual(unrelatedSourceConfig);
+ expect(getActiveSecretsRuntimeSnapshot()?.warnings).toEqual([activeWarning]);
expect(getActiveSecretsRuntimeSnapshot()?.secretOwners).toEqual([
{
ownerKind: "capability",
ownerId: "tts",
refKeys: ["env:default:TTS_FIRST"],
+ contractDigest: ttsContractDigest,
},
{
ownerKind: "account",
@@ -4241,6 +4275,7 @@ describe("gateway Gmail hot reload handlers", () => {
ownerKind: "capability",
ownerId: "tts",
refKeys: ["env:default:TTS_SECOND"],
+ contractDigest: ttsContractDigest,
},
{
ownerKind: "account",
@@ -4260,6 +4295,7 @@ describe("gateway Gmail hot reload handlers", () => {
ownerId: "tts",
refs: [secondRef],
config: nextSourceConfig,
+ contractDigest: ttsContractDigest,
}),
).toBe("stale");
@@ -4274,6 +4310,7 @@ describe("gateway Gmail hot reload handlers", () => {
ownerKind: "capability" as const,
ownerId: "tts",
refKeys: ["env:default:TTS_THIRD"],
+ contractDigest: ttsContractDigest,
},
],
webTools: createEmptyRuntimeWebToolsMetadata(),
@@ -4295,6 +4332,7 @@ describe("gateway Gmail hot reload handlers", () => {
ownerKind: "capability",
ownerId: "tts",
refKeys: ["env:default:TTS_SECOND"],
+ contractDigest: ttsContractDigest,
},
{
ownerKind: "account",
@@ -4325,6 +4363,7 @@ describe("gateway Gmail hot reload handlers", () => {
ownerKind: "capability" as const,
ownerId: "tts",
refKeys: ["env:default:TTS_THIRD"],
+ contractDigest: ttsContractDigest,
},
],
webTools: createEmptyRuntimeWebToolsMetadata(),
@@ -5042,10 +5081,10 @@ describe("gateway Gmail hot reload handlers", () => {
if (!originalActivateRuntimeSecrets) {
throw new Error("Expected managed secrets activation implementation");
}
- let secretsPreparationCount = 0;
+ let restartCheckCount = 0;
harness.activateRuntimeSecrets.mockImplementation(async (...args) => {
- secretsPreparationCount += 1;
- if (secretsPreparationCount === 2) {
+ const activationParams = args[1] as { reason?: string } | undefined;
+ if (activationParams?.reason === "restart-check" && ++restartCheckCount === 2) {
recordEmissionPreflightStarted?.();
await emissionPreflightGate;
}
@@ -5060,43 +5099,24 @@ describe("gateway Gmail hot reload handlers", () => {
try {
const deferredPromotion = harness.nextPromotion();
harness.writeConfig(harness.deferredConfig, "deferred-a", 1);
- await vi.advanceTimersByTimeAsync(0);
+ const deferredAdvance = vi.advanceTimersByTimeAsync(0);
await expect(deferredPromotion).resolves.toBe("deferred-a");
+ await deferredAdvance;
hoisted.activeTaskBlockers.length = 0;
- await vi.advanceTimersByTimeAsync(500);
+ const emissionAdvance = vi.advanceTimersByTimeAsync(500);
await emissionPreflightStarted;
- const replacementError = harness.nextReloadError();
harness.writeConfig(harness.invalidConfig, "invalid-b", 2);
- await vi.advanceTimersByTimeAsync(0);
- await replacementError;
expect(harness.requestRecoveryRestart).not.toHaveBeenCalled();
+ // The write listener supersedes the blocked preflight synchronously. Release it before
+ // draining fake timers so Vitest does not need to nest timer advances around the gate.
releaseEmissionPreflight();
+ await emissionAdvance;
await vi.advanceTimersByTimeAsync(0);
expect(harness.requestRecoveryRestart).not.toHaveBeenCalled();
expect(hoisted.markRestartAbortedMainSessions).not.toHaveBeenCalled();
-
- const revertPromotion = harness.nextPromotion();
- harness.writeConfig(harness.deferredConfig, "accepted-revert-a", 3);
- await vi.advanceTimersByTimeAsync(0);
- await expect(revertPromotion).resolves.toBe("accepted-revert-a");
- await vi.advanceTimersByTimeAsync(0);
-
- const deferredPlan = buildGatewayReloadPlan(
- diffConfigPaths(harness.initialConfig, harness.deferredConfig),
- );
- expect(harness.activateRuntimeSecrets).toHaveBeenCalledWith(harness.deferredConfig, {
- reason: "restart-check",
- activate: false,
- publishFailureAsDegraded: true,
- canPublishFailureAsDegraded: expect.any(Function),
- });
- await vi.advanceTimersByTimeAsync(500);
- expect(harness.requestRecoveryRestart.mock.calls).toEqual([
- [`config reload: ${deferredPlan.restartReasons.join(", ")}`, undefined],
- ]);
} finally {
releaseEmissionPreflight();
hoisted.activeTaskBlockers.length = 0;
@@ -5244,7 +5264,13 @@ describe("gateway Gmail hot reload handlers", () => {
vi.fn(async (config: OpenClawConfig) => {
preparationCount += 1;
if (preparationCount === 1) {
- activateSecretsRuntimeSnapshot(refreshedSnapshot);
+ expect(
+ activateSecretsRuntimeSnapshotIfCurrent(
+ refreshedSnapshot,
+ getActiveSecretsRuntimeSnapshotRevision(),
+ { preserveActivationLineage: true },
+ ),
+ ).toBe(true);
}
return {
sourceConfig: config,
diff --git a/src/gateway/server-reload-handlers.ts b/src/gateway/server-reload-handlers.ts
index 59871d65074d..49d55b890e15 100644
--- a/src/gateway/server-reload-handlers.ts
+++ b/src/gateway/server-reload-handlers.ts
@@ -14,7 +14,11 @@ import { getChannelPlugin } from "../channels/plugins/index.js";
import type { CliDeps } from "../cli/deps.types.js";
import { isRestartEnabled } from "../config/commands.flags.js";
import { getConfigValueAtPath } from "../config/config-paths.js";
-import { getRuntimeConfigSourceSnapshot, setRuntimeConfigAppliedHash } from "../config/config.js";
+import {
+ getRuntimeConfigSnapshotMetadata,
+ getRuntimeConfigSourceSnapshot,
+ setRuntimeConfigAppliedHash,
+} from "../config/config.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { isSecretRef } from "../config/types.secrets.js";
import { isTruthyEnvValue } from "../infra/env.js";
@@ -39,7 +43,10 @@ import {
clearSecretsRuntimeSnapshot,
getActiveSecretsRuntimeSnapshot,
getActiveSecretsRuntimeSnapshotRevision,
+ hasActiveSecretsRuntimeSnapshotLineage,
hasSameSecretReloadContract,
+ restoreSecretsRuntimeSourceSnapshotIfLineageCurrent,
+ setSecretsRuntimeSourceSnapshotIfCurrent,
type PreparedSecretsRuntimeSnapshot,
} from "../secrets/runtime-state.js";
import { getInspectableActiveTaskRestartBlockers } from "../tasks/task-registry.maintenance.js";
@@ -81,7 +88,7 @@ import {
type SharedGatewaySessionGenerationState,
} from "./server-shared-auth-generation.js";
import {
- publishRuntimeSecretsRecovery,
+ publishRuntimeSecretsStateTransition,
type ActivateRuntimeSecrets,
} from "./server-startup-config.js";
import { resolveHookClientIpConfig } from "./server/hook-client-ip-config.js";
@@ -94,6 +101,22 @@ import type { HookClientIpConfig } from "./server/hooks-request-handler.js";
// increments the generation so a new lifecycle never clears an abort intended for a
// previous lifecycle's deferred reload.
let currentReloadGeneration = 0;
+
+type RuntimeSecretsPreflightParams = Omit<
+ Parameters[1],
+ "activate" | "canPublishFailureAsDegraded"
+>;
+
+type CurrentRuntimeSecretsPreparation = {
+ snapshot: PreparedSecretsRuntimeSnapshot;
+ expectedRevision: number;
+};
+
+function isRuntimeSecretsPreparationCurrent(
+ preparation: CurrentRuntimeSecretsPreparation,
+): boolean {
+ return getActiveSecretsRuntimeSnapshotRevision() === preparation.expectedRevision;
+}
let abortGeneration: number | undefined = undefined;
const RESTART_EMISSION_RETRY_MS = 1_000;
@@ -1643,6 +1666,39 @@ export function startManagedGatewayConfigReloader(
params.restartRecoveryAvailable !== false && params.requestRecoveryRestart !== undefined;
let stopped = false;
+ const tryPrepareRuntimeSecrets = async (
+ config: OpenClawConfig,
+ transactionOwnership: GatewayConfigReloadTransactionOwnership,
+ activationParams: RuntimeSecretsPreflightParams,
+ ): Promise => {
+ if (!transactionOwnership.isCurrent()) {
+ throw new GatewayConfigReloadSupersededError();
+ }
+ const expectedRevision = getActiveSecretsRuntimeSnapshotRevision();
+ try {
+ const snapshot = await params.activateRuntimeSecrets(config, {
+ ...activationParams,
+ activate: false,
+ canPublishFailureAsDegraded: () =>
+ transactionOwnership.isCurrent() &&
+ getActiveSecretsRuntimeSnapshotRevision() === expectedRevision,
+ });
+ if (!transactionOwnership.isCurrent()) {
+ throw new GatewayConfigReloadSupersededError();
+ }
+ return getActiveSecretsRuntimeSnapshotRevision() === expectedRevision
+ ? { snapshot, expectedRevision }
+ : null;
+ } catch (error) {
+ if (!transactionOwnership.isCurrent()) {
+ throw new GatewayConfigReloadSupersededError();
+ }
+ if (getActiveSecretsRuntimeSnapshotRevision() !== expectedRevision) {
+ return null;
+ }
+ throw error;
+ }
+ };
let activeGmailRestartAbortController: GatewayGmailRestartAbortController | null = null;
const abortActiveGmailRestart = () => {
activeGmailRestartAbortController?.abort();
@@ -1730,39 +1786,37 @@ export function startManagedGatewayConfigReloader(
try {
for (;;) {
assertCurrent();
- const previousSnapshotRevision = getActiveSecretsRuntimeSnapshotRevision();
const ownership = captureSharedGatewaySessionGenerationOwnership(
params.sharedGatewaySessionGenerationState,
);
const previousRequired = params.sharedGatewaySessionGenerationState.required;
- const prepared = await params.activateRuntimeSecrets(
+ const prepared = await tryPrepareRuntimeSecrets(
prepareRuntimeCandidate(nextConfig, sourceConfig, transactionOwnership),
+ transactionOwnership,
{
reason: "restart-check",
- activate: false,
publishFailureAsDegraded: true,
- canPublishFailureAsDegraded: transactionOwnership.isCurrent,
...(transactionOwnership.runtimeEnv
? { env: transactionOwnership.runtimeEnv.env }
: {}),
},
);
assertCurrent();
- const snapshotChanged =
- getActiveSecretsRuntimeSnapshotRevision() !== previousSnapshotRevision;
const generationChanged = !isSharedGatewaySessionGenerationOwnershipCurrent(
params.sharedGatewaySessionGenerationState,
ownership,
);
- if (snapshotChanged || generationChanged) {
+ if (!prepared || !isRuntimeSecretsPreparationCurrent(prepared) || generationChanged) {
continue;
}
preparation = {
ownership,
previousRequired,
previousCurrent: ownership.generation,
- nextGeneration: params.resolveSharedGatewaySessionGenerationForConfig(prepared.config),
- runtimeConfig: prepared.config,
+ nextGeneration: params.resolveSharedGatewaySessionGenerationForConfig(
+ prepared.snapshot.config,
+ ),
+ runtimeConfig: prepared.snapshot.config,
};
break;
}
@@ -1804,20 +1858,23 @@ export function startManagedGatewayConfigReloader(
...restartOptions,
debtConfig: sourceConfig,
prepareRuntimeConfig: async () => {
- const prepared = await params.activateRuntimeSecrets(
- prepareRuntimeCandidate(preparedRuntimeConfig, sourceConfig, transactionOwnership),
- {
- reason: "restart-check",
- activate: false,
- publishFailureAsDegraded: true,
- canPublishFailureAsDegraded: transactionOwnership.isCurrent,
- ...(transactionOwnership.runtimeEnv
- ? { env: transactionOwnership.runtimeEnv.env }
- : {}),
- },
- );
- assertCurrent();
- return prepared.config;
+ for (;;) {
+ const prepared = await tryPrepareRuntimeSecrets(
+ prepareRuntimeCandidate(preparedRuntimeConfig, sourceConfig, transactionOwnership),
+ transactionOwnership,
+ {
+ reason: "restart-check",
+ publishFailureAsDegraded: true,
+ ...(transactionOwnership.runtimeEnv
+ ? { env: transactionOwnership.runtimeEnv.env }
+ : {}),
+ },
+ );
+ assertCurrent();
+ if (prepared && isRuntimeSecretsPreparationCurrent(prepared)) {
+ return prepared.snapshot.config;
+ }
+ }
},
});
if (restartTransaction.status === "recovery-pending") {
@@ -1883,19 +1940,23 @@ export function startManagedGatewayConfigReloader(
runtimeConfig: prepareRuntimeCandidate(nextConfig, sourceConfig, transactionOwnership),
sourceConfig,
prepareRuntimeConfig: async () => {
- const prepared = await params.activateRuntimeSecrets(
- prepareRuntimeCandidate(nextConfig, sourceConfig, transactionOwnership),
- {
- reason: "restart-check",
- activate: false,
- publishFailureAsDegraded: true,
- canPublishFailureAsDegraded: transactionOwnership.isCurrent,
- ...(transactionOwnership.runtimeEnv
- ? { env: transactionOwnership.runtimeEnv.env }
- : {}),
- },
- );
- return prepared.config;
+ for (;;) {
+ const prepared = await tryPrepareRuntimeSecrets(
+ prepareRuntimeCandidate(nextConfig, sourceConfig, transactionOwnership),
+ transactionOwnership,
+ {
+ reason: "restart-check",
+ publishFailureAsDegraded: true,
+ ...(transactionOwnership.runtimeEnv
+ ? { env: transactionOwnership.runtimeEnv.env }
+ : {}),
+ },
+ );
+ assertCurrent();
+ if (prepared && isRuntimeSecretsPreparationCurrent(prepared)) {
+ return prepared.snapshot.config;
+ }
+ }
},
});
let rollbackSource: (() => Promise) | undefined;
@@ -1969,56 +2030,138 @@ export function startManagedGatewayConfigReloader(
onConfigApplied: (_plan, nextConfig) => params.commitTerminalConfig(nextConfig),
onConfigRevisionApplied: publishAppliedConfigHash,
onEffectiveConfigUnchanged: async (nextConfig, transactionOwnership, sourceConfig) => {
- if (!transactionOwnership.isCurrent()) {
- throw new GatewayConfigReloadSupersededError();
- }
- const previousRuntimeSourceConfig = getRuntimeConfigSourceSnapshot();
- const previousSecretsSnapshot = getActiveSecretsRuntimeSnapshot();
- const previousSecretsRevision = getActiveSecretsRuntimeSnapshotRevision();
- const nextSecretsSourceConfig = prepareRuntimeCandidate(
- nextConfig,
- sourceConfig,
- transactionOwnership,
- );
- if (
- previousRuntimeSourceConfig &&
- previousSecretsSnapshot &&
- hasSameSecretReloadContract(previousSecretsSnapshot.sourceConfig, nextSecretsSourceConfig)
- ) {
- const sourceOnlySnapshot = {
- ...previousSecretsSnapshot,
- sourceConfig: nextSecretsSourceConfig,
- warnings: [],
- };
- if (!isDeepStrictEqual(sourceOnlySnapshot.config, nextConfig)) {
+ for (;;) {
+ if (!transactionOwnership.isCurrent()) {
+ throw new GatewayConfigReloadSupersededError();
+ }
+ const previousRuntimeSourceConfig = getRuntimeConfigSourceSnapshot();
+ const previousSecretsSnapshot = getActiveSecretsRuntimeSnapshot();
+ const previousSecretsRevision = getActiveSecretsRuntimeSnapshotRevision();
+ const previousRuntimeMetadata = getRuntimeConfigSnapshotMetadata();
+ const nextSecretsSourceConfig = prepareRuntimeCandidate(
+ nextConfig,
+ sourceConfig,
+ transactionOwnership,
+ );
+ if (
+ previousRuntimeMetadata &&
+ previousRuntimeSourceConfig &&
+ previousSecretsSnapshot &&
+ hasSameSecretReloadContract(previousSecretsSnapshot.sourceConfig, nextSecretsSourceConfig)
+ ) {
+ const sourceOnlySnapshot = {
+ ...previousSecretsSnapshot,
+ sourceConfig: nextSecretsSourceConfig,
+ };
+ if (!isDeepStrictEqual(sourceOnlySnapshot.config, nextConfig)) {
+ throw new GatewayConfigReloadSupersededError();
+ }
+ if (!transactionOwnership.isCurrent()) {
+ throw new GatewayConfigReloadSupersededError();
+ }
+ if (
+ !setSecretsRuntimeSourceSnapshotIfCurrent({
+ expectedSecretsRevision: previousSecretsRevision,
+ expectedRuntimeConfigRevision: previousRuntimeMetadata.revision,
+ runtimeSourceConfig: sourceConfig,
+ secretsSourceConfig: nextSecretsSourceConfig,
+ })
+ ) {
+ continue;
+ }
+ const committedSecretsRevision = getActiveSecretsRuntimeSnapshotRevision();
+ const rollbackPublishedSource = async () => {
+ if (
+ !restoreSecretsRuntimeSourceSnapshotIfLineageCurrent({
+ expectedLineageRevision: committedSecretsRevision,
+ runtimeSourceConfig: previousRuntimeSourceConfig,
+ secretsSourceConfig: previousSecretsSnapshot.sourceConfig,
+ })
+ ) {
+ throw new GatewayConfigReloadSupersededError();
+ }
+ };
+ if (!transactionOwnership.isCurrent()) {
+ await rollbackPublishedSource();
+ throw new GatewayConfigReloadSupersededError();
+ }
+ return {
+ rollback: rollbackPublishedSource,
+ commit: () =>
+ publishRuntimeSecretsStateTransition(
+ params.activateRuntimeSecrets,
+ sourceOnlySnapshot,
+ {
+ sourceOnly: true,
+ expectedRevision: committedSecretsRevision,
+ },
+ ),
+ };
+ }
+ const preparation = await tryPrepareRuntimeSecrets(
+ nextSecretsSourceConfig,
+ transactionOwnership,
+ {
+ reason: "reload",
+ publishFailureAsDegraded: true,
+ ...(transactionOwnership.runtimeEnv
+ ? { env: transactionOwnership.runtimeEnv.env }
+ : {}),
+ includeAuthStoreRefs: true,
+ },
+ );
+ if (!previousRuntimeMetadata || !transactionOwnership.isCurrent()) {
+ throw new GatewayConfigReloadSupersededError();
+ }
+ if (getRuntimeConfigSnapshotMetadata()?.revision !== previousRuntimeMetadata.revision) {
+ if (hasActiveSecretsRuntimeSnapshotLineage(previousSecretsRevision)) {
+ continue;
+ }
+ throw new GatewayConfigReloadSupersededError();
+ }
+ if (
+ !preparation ||
+ preparation.expectedRevision !== previousSecretsRevision ||
+ !isRuntimeSecretsPreparationCurrent(preparation)
+ ) {
+ continue;
+ }
+ const preparedSecrets = preparation.snapshot;
+ if (!transactionOwnership.isCurrent()) {
+ throw new GatewayConfigReloadSupersededError();
+ }
+ if (!isDeepStrictEqual(preparedSecrets.config, nextConfig)) {
+ throw new GatewayConfigReloadSupersededError();
+ }
+ if (!previousRuntimeSourceConfig || !previousSecretsSnapshot) {
throw new GatewayConfigReloadSupersededError();
}
const activateIfCurrent = params.activateRuntimeSecrets.activatePreparedSnapshotIfCurrent;
const activated = activateIfCurrent
? await activateIfCurrent(
- sourceOnlySnapshot,
+ preparedSecrets,
previousSecretsRevision,
{
reason: "reload",
activate: true,
- publishRecovery: false,
+ deferStatePublication: true,
runtimeSourceConfig: sourceConfig,
},
undefined,
transactionOwnership.isCurrent,
)
: (await activateSecretsRuntimeSnapshotIfCurrent(
- sourceOnlySnapshot,
+ preparedSecrets,
previousSecretsRevision,
{
canActivate: transactionOwnership.isCurrent,
runtimeSourceConfig: sourceConfig,
},
))
- ? sourceOnlySnapshot
+ ? preparedSecrets
: null;
if (!activated) {
- throw new GatewayConfigReloadSupersededError();
+ continue;
}
const committedSecretsRevision = getActiveSecretsRuntimeSnapshotRevision();
const rollbackPublishedSource = async () => {
@@ -2040,92 +2183,31 @@ export function startManagedGatewayConfigReloader(
return {
rollback: rollbackPublishedSource,
commit: () =>
- publishRuntimeSecretsRecovery(params.activateRuntimeSecrets, activated, {
- sourceOnly: true,
- }),
+ publishRuntimeSecretsStateTransition(params.activateRuntimeSecrets, activated),
};
}
- const preparedSecrets = await params.activateRuntimeSecrets(nextSecretsSourceConfig, {
- reason: "reload",
- activate: false,
- publishFailureAsDegraded: true,
- canPublishFailureAsDegraded: transactionOwnership.isCurrent,
- ...(transactionOwnership.runtimeEnv ? { env: transactionOwnership.runtimeEnv.env } : {}),
- includeAuthStoreRefs: true,
- });
- if (!transactionOwnership.isCurrent()) {
- throw new GatewayConfigReloadSupersededError();
- }
- if (!isDeepStrictEqual(preparedSecrets.config, nextConfig)) {
- throw new GatewayConfigReloadSupersededError();
- }
- if (!previousRuntimeSourceConfig || !previousSecretsSnapshot) {
- throw new GatewayConfigReloadSupersededError();
- }
- const activateIfCurrent = params.activateRuntimeSecrets.activatePreparedSnapshotIfCurrent;
- const activated = activateIfCurrent
- ? await activateIfCurrent(
- preparedSecrets,
- previousSecretsRevision,
- {
- reason: "reload",
- activate: true,
- publishRecovery: false,
- runtimeSourceConfig: sourceConfig,
- },
- undefined,
- transactionOwnership.isCurrent,
- )
- : (await activateSecretsRuntimeSnapshotIfCurrent(preparedSecrets, previousSecretsRevision, {
- canActivate: transactionOwnership.isCurrent,
- runtimeSourceConfig: sourceConfig,
- }))
- ? preparedSecrets
- : null;
- if (!activated) {
- throw new GatewayConfigReloadSupersededError();
- }
- const committedSecretsRevision = getActiveSecretsRuntimeSnapshotRevision();
- const rollbackPublishedSource = async () => {
- if (
- !(await restoreSecretsRuntimeSnapshotIfCurrent(
- previousSecretsSnapshot,
- committedSecretsRevision,
- activated,
- { runtimeSourceConfig: previousRuntimeSourceConfig },
- ))
- ) {
- throw new GatewayConfigReloadSupersededError();
- }
- };
- if (!transactionOwnership.isCurrent()) {
- await rollbackPublishedSource();
- throw new GatewayConfigReloadSupersededError();
- }
- return {
- rollback: rollbackPublishedSource,
- commit: () => publishRuntimeSecretsRecovery(params.activateRuntimeSecrets, activated),
- };
},
onNoopConfigCommit: async (plan, nextConfig, transactionOwnership, sourceConfig) => {
for (;;) {
if (!transactionOwnership.isCurrent()) {
throw new GatewayConfigReloadSupersededError();
}
- const previousSnapshotRevision = getActiveSecretsRuntimeSnapshotRevision();
- const prepared = await params.activateRuntimeSecrets(
+ const preparation = await tryPrepareRuntimeSecrets(
prepareRuntimeCandidate(nextConfig, sourceConfig, transactionOwnership),
+ transactionOwnership,
{
reason: "reload",
- activate: false,
publishFailureAsDegraded: true,
- canPublishFailureAsDegraded: transactionOwnership.isCurrent,
...(transactionOwnership.runtimeEnv
? { env: transactionOwnership.runtimeEnv.env }
: {}),
includeAuthStoreRefs: transactionOwnership.runtimeRefresh?.includeAuthStoreRefs,
},
);
+ if (!preparation || !isRuntimeSecretsPreparationCurrent(preparation)) {
+ continue;
+ }
+ const { expectedRevision: previousSnapshotRevision, snapshot: prepared } = preparation;
if (!transactionOwnership.isCurrent()) {
throw new GatewayConfigReloadSupersededError();
}
@@ -2167,19 +2249,26 @@ export function startManagedGatewayConfigReloader(
params.sharedGatewaySessionGenerationState,
);
const previousSharedGatewaySessionGeneration = previousGenerationOwnership.generation;
- const prepared = await params.activateRuntimeSecrets(
+ const preparation = await tryPrepareRuntimeSecrets(
prepareRuntimeCandidate(nextConfig, sourceConfig, transactionOwnership),
+ transactionOwnership,
{
reason: "reload",
- activate: false,
publishFailureAsDegraded: true,
- canPublishFailureAsDegraded: transactionOwnership.isCurrent,
...(transactionOwnership.runtimeEnv
? { env: transactionOwnership.runtimeEnv.env }
: {}),
includeAuthStoreRefs: transactionOwnership.runtimeRefresh?.includeAuthStoreRefs,
},
);
+ if (
+ !preparation ||
+ preparation.expectedRevision !== previousSnapshotRevision ||
+ !isRuntimeSecretsPreparationCurrent(preparation)
+ ) {
+ continue;
+ }
+ const prepared = preparation.snapshot;
if (!transactionOwnership.isCurrent()) {
throw new GatewayConfigReloadSupersededError();
}
@@ -2204,22 +2293,25 @@ export function startManagedGatewayConfigReloader(
: {}),
sourceConfig,
prepareRestartRuntimeConfig: async () => {
- const restartPrepared = await params.activateRuntimeSecrets(
- prepareRuntimeCandidate(prepared.config, sourceConfig, transactionOwnership),
- {
- reason: "restart-check",
- activate: false,
- publishFailureAsDegraded: true,
- canPublishFailureAsDegraded: transactionOwnership.isCurrent,
- ...(transactionOwnership.runtimeEnv
- ? { env: transactionOwnership.runtimeEnv.env }
- : {}),
- },
- );
- if (!transactionOwnership.isCurrent()) {
- throw new GatewayConfigReloadSupersededError();
+ for (;;) {
+ const restartPrepared = await tryPrepareRuntimeSecrets(
+ prepareRuntimeCandidate(prepared.config, sourceConfig, transactionOwnership),
+ transactionOwnership,
+ {
+ reason: "restart-check",
+ publishFailureAsDegraded: true,
+ ...(transactionOwnership.runtimeEnv
+ ? { env: transactionOwnership.runtimeEnv.env }
+ : {}),
+ },
+ );
+ if (!transactionOwnership.isCurrent()) {
+ throw new GatewayConfigReloadSupersededError();
+ }
+ if (restartPrepared && isRuntimeSecretsPreparationCurrent(restartPrepared)) {
+ return restartPrepared.snapshot.config;
+ }
}
- return restartPrepared.config;
},
publish: async (commit, isCommitted) => {
const claimGenerationOwnership = () => {
diff --git a/src/gateway/server-startup-config-helpers.ts b/src/gateway/server-startup-config-helpers.ts
new file mode 100644
index 000000000000..87fcbdc4d43b
--- /dev/null
+++ b/src/gateway/server-startup-config-helpers.ts
@@ -0,0 +1,204 @@
+// Shared validation, auth-surface, and config-load helpers for Gateway startup.
+import {
+ formatInvalidConfigRecoveryHint,
+ formatPluginPackagingRuntimeOutputRecoveryHint,
+} from "../cli/config-recovery-hints.js";
+import { createInvalidConfigError } from "../config/io.invalid-config.js";
+import {
+ type ReadConfigFileSnapshotWithPluginMetadataResult,
+ readConfigFileSnapshotWithPluginMetadata,
+} from "../config/io.js";
+import { formatConfigIssueLines } from "../config/issue-format.js";
+import { isNixMode } from "../config/paths.js";
+import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
+import { isPluginPackagingRuntimeOutputInvalidConfigSnapshot } from "../config/recovery-policy.js";
+import type { GatewayAuthConfig, GatewayTailscaleConfig } from "../config/types.gateway.js";
+import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.openclaw.js";
+import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
+import {
+ GATEWAY_AUTH_SURFACE_PATHS,
+ evaluateGatewayAuthSurfaceStates,
+} from "../secrets/runtime-gateway-auth-surfaces.js";
+import { resolveGatewayAuth } from "./auth.js";
+import { assertGatewayAuthNotKnownWeak } from "./known-weak-gateway-secrets.js";
+import { mergeGatewayAuthConfig, mergeGatewayTailscaleConfig } from "./startup-auth.js";
+
+export type GatewayStartupLog = {
+ info: (message: string) => void;
+ warn: (message: string, meta?: Record) => void;
+ error?: (message: string) => void;
+};
+
+export type GatewayStartupConfigMeasure = (
+ name: string,
+ run: () => T | Promise,
+ options?: { omitErrorMessage?: boolean },
+) => Promise;
+
+export type GatewayStartupConfigSnapshotLoadResult = {
+ snapshot: ConfigFileSnapshot;
+ wroteConfig: boolean;
+ pluginMetadataSnapshot?: PluginMetadataSnapshot;
+};
+
+/** Throw a formatted startup error when the loaded config snapshot is invalid. */
+export function assertValidGatewayStartupConfigSnapshot(
+ snapshot: ConfigFileSnapshot,
+ options: { includeDoctorHint?: boolean } = {},
+): void {
+ if (snapshot.valid) {
+ return;
+ }
+ const issues =
+ snapshot.issues.length > 0
+ ? formatConfigIssueLines(snapshot.issues, "", { normalizeRoot: true }).join("\n")
+ : "Unknown validation issue.";
+ const recoveryHint =
+ options.includeDoctorHint && isPluginPackagingRuntimeOutputInvalidConfigSnapshot(snapshot)
+ ? `\n${formatPluginPackagingRuntimeOutputRecoveryHint()}`
+ : options.includeDoctorHint
+ ? `\n${formatInvalidConfigRecoveryHint()}`
+ : "";
+ throw createInvalidConfigError(snapshot.path, `${issues}${recoveryHint}`, {
+ recovery: isPluginPackagingRuntimeOutputInvalidConfigSnapshot(snapshot) ? "manual" : "doctor",
+ });
+}
+
+function withRuntimeConfig(
+ snapshot: ConfigFileSnapshot,
+ runtimeConfig: OpenClawConfig,
+): ConfigFileSnapshot {
+ return {
+ ...snapshot,
+ runtimeConfig,
+ config: runtimeConfig,
+ };
+}
+
+/** Load and validate the config snapshot, applying runtime-only plugin auto-enable changes. */
+export async function loadGatewayStartupConfigSnapshot(params: {
+ minimalTestGateway: boolean;
+ log: GatewayStartupLog;
+ measure?: GatewayStartupConfigMeasure;
+ initialSnapshotRead?: ReadConfigFileSnapshotWithPluginMetadataResult;
+}): Promise {
+ const measure = params.measure ?? (async (_name, run) => await run());
+ const snapshotRead =
+ params.initialSnapshotRead ??
+ (await measure("config.snapshot.read", () =>
+ readConfigFileSnapshotWithPluginMetadata({ measure }),
+ ));
+ const configSnapshot = snapshotRead.snapshot;
+ const pluginMetadataSnapshot = snapshotRead.pluginMetadataSnapshot;
+ const wroteConfig = false;
+ if (configSnapshot.legacyIssues.length > 0 && isNixMode) {
+ throw createInvalidConfigError(
+ configSnapshot.path,
+ "Legacy config entries detected while running in Nix mode. Update your Nix config to the latest schema and restart.",
+ { recovery: "manual" },
+ );
+ }
+ if (configSnapshot.exists) {
+ assertValidGatewayStartupConfigSnapshot(configSnapshot, { includeDoctorHint: true });
+ }
+
+ const autoEnable = params.minimalTestGateway
+ ? { config: configSnapshot.config, changes: [] as string[] }
+ : await measure("config.snapshot.auto-enable", () =>
+ applyPluginAutoEnable({
+ config: configSnapshot.sourceConfig,
+ env: process.env,
+ ...(pluginMetadataSnapshot?.manifestRegistry
+ ? { manifestRegistry: pluginMetadataSnapshot.manifestRegistry }
+ : {}),
+ discovery: pluginMetadataSnapshot?.discovery,
+ }),
+ );
+ if (autoEnable.changes.length === 0) {
+ return {
+ snapshot: configSnapshot,
+ wroteConfig,
+ ...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}),
+ };
+ }
+
+ params.log.info(
+ `gateway: auto-enabled plugins for this runtime without writing config:\n${autoEnable.changes.map((entry) => `- ${entry}`).join("\n")}`,
+ );
+ return {
+ snapshot: withRuntimeConfig(configSnapshot, autoEnable.config),
+ wroteConfig,
+ ...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}),
+ };
+}
+
+export function hasActiveGatewayAuthSecretRef(config: OpenClawConfig): boolean {
+ const states = evaluateGatewayAuthSurfaceStates({
+ config,
+ defaults: config.secrets?.defaults,
+ env: process.env,
+ });
+ return GATEWAY_AUTH_SURFACE_PATHS.some((path) => {
+ const state = states[path];
+ return state.hasSecretRef && state.active;
+ });
+}
+
+export function assertRuntimeGatewayAuthNotKnownWeak(config: OpenClawConfig): void {
+ assertGatewayAuthNotKnownWeak(
+ resolveGatewayAuth({
+ authConfig: config.gateway?.auth,
+ env: process.env,
+ tailscaleMode: config.gateway?.tailscale?.mode ?? "off",
+ }),
+ );
+}
+
+export function logGatewayAuthSurfaceDiagnostics(
+ prepared: {
+ sourceConfig: OpenClawConfig;
+ warnings: Array<{ code: string; path: string; message: string }>;
+ },
+ logSecrets: GatewayStartupLog,
+): void {
+ const states = evaluateGatewayAuthSurfaceStates({
+ config: prepared.sourceConfig,
+ defaults: prepared.sourceConfig.secrets?.defaults,
+ env: process.env,
+ });
+ const inactiveWarnings = new Map();
+ for (const warning of prepared.warnings) {
+ if (warning.code !== "SECRETS_REF_IGNORED_INACTIVE_SURFACE") {
+ continue;
+ }
+ inactiveWarnings.set(warning.path, warning.message);
+ }
+ for (const path of GATEWAY_AUTH_SURFACE_PATHS) {
+ const state = states[path];
+ if (!state.hasSecretRef) {
+ continue;
+ }
+ const stateLabel = state.active ? "active" : "inactive";
+ const inactiveDetails =
+ !state.active && inactiveWarnings.get(path) ? inactiveWarnings.get(path) : undefined;
+ const details = inactiveDetails ?? state.reason;
+ logSecrets.info(`[SECRETS_GATEWAY_AUTH_SURFACE] ${path} is ${stateLabel}. ${details}`);
+ }
+}
+
+export function applyGatewayAuthOverridesForStartupPreflight(
+ config: OpenClawConfig,
+ overrides: { auth?: GatewayAuthConfig; tailscale?: GatewayTailscaleConfig },
+): OpenClawConfig {
+ if (!overrides.auth && !overrides.tailscale) {
+ return config;
+ }
+ return {
+ ...config,
+ gateway: {
+ ...config.gateway,
+ auth: mergeGatewayAuthConfig(config.gateway?.auth, overrides.auth),
+ tailscale: mergeGatewayTailscaleConfig(config.gateway?.tailscale, overrides.tailscale),
+ },
+ };
+}
diff --git a/src/gateway/server-startup-config.secrets.test.ts b/src/gateway/server-startup-config.secrets.test.ts
index 0c671f848fce..3a8cc19a2fe0 100644
--- a/src/gateway/server-startup-config.secrets.test.ts
+++ b/src/gateway/server-startup-config.secrets.test.ts
@@ -15,8 +15,10 @@ import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.js";
import { measureDiagnosticsTimelineSpan } from "../infra/diagnostics-timeline.js";
import { providerResolutionError, refResolutionError } from "../secrets/resolve-errors.js";
import { associateSecretResolutionErrorOwners } from "../secrets/runtime-degraded-state.js";
+import { activateProviderAuthRuntimeSnapshot } from "../secrets/runtime-provider-auth-activation.js";
import {
activateSecretsRuntimeSnapshotState,
+ activateSecretsRuntimeSnapshotStateIfCurrent,
clearSecretsRuntimeSnapshot,
getActiveSecretsRuntimeSnapshot,
getActiveSecretsRuntimeSnapshotRevision,
@@ -25,7 +27,7 @@ import type { PreparedSecretsRuntimeSnapshot, SecretResolverWarning } from "../s
import {
createRuntimeSecretsActivator,
prepareGatewayStartupConfig,
- publishRuntimeSecretsRecovery,
+ publishRuntimeSecretsStateTransition,
} from "./server-startup-config.js";
import { buildTestConfigSnapshot } from "./test-helpers.config-snapshots.js";
@@ -415,7 +417,9 @@ describe("gateway startup config secret preflight", () => {
const candidate = preparedSnapshotWithGatewayToken(initial.sourceConfig, "candidate-token");
const activateRuntimeSecretsSnapshot = vi.fn(activateSecretsRuntimeSnapshotForTest);
const activateRuntimeSecrets = runtimeSecretsActivatorForTest({
- prepareRuntimeSecretsSnapshot: vi.fn(async ({ config }) => preparedSnapshot(config)),
+ prepareRuntimeSecretsSnapshot: vi.fn(async ({ config: preparedConfig }) =>
+ preparedSnapshot(preparedConfig),
+ ),
activateRuntimeSecretsSnapshot,
});
activateSecretsRuntimeSnapshotForTest(initial);
@@ -440,6 +444,763 @@ describe("gateway startup config secret preflight", () => {
expect(activateRuntimeSecretsSnapshot).toHaveBeenCalledOnce();
});
+ it("signals degradation for a snapshot activated by an external CAS owner", async () => {
+ const initial = preparedSnapshot(
+ gatewayTokenConfig(
+ asConfig({
+ models: {
+ providers: {
+ openai: {
+ apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
+ models: [],
+ },
+ },
+ },
+ }),
+ ),
+ );
+ const candidate = {
+ ...preparedSnapshotWithGatewayToken(initial.sourceConfig, "candidate-token"),
+ degradedOwners: [
+ {
+ ownerKind: "provider" as const,
+ ownerId: "openai",
+ state: "unavailable" as const,
+ degradationState: "stale" as const,
+ paths: ["models.providers.openai.apiKey"],
+ refKeys: ["env:default:OPENAI_API_KEY"],
+ reason: "secret reference was not found",
+ },
+ ],
+ };
+ const emitStateEvent = vi.fn();
+ const logSecrets = mockLogSecretsForTest();
+ const activateRuntimeSecretsSnapshot = vi.fn();
+ runtimeSecretsActivatorForTest({
+ prepareRuntimeSecretsSnapshot: vi.fn(async ({ config: preparedConfig }) =>
+ preparedSnapshot(preparedConfig),
+ ),
+ activateRuntimeSecretsSnapshot,
+ emitStateEvent,
+ logSecrets,
+ });
+ activateSecretsRuntimeSnapshotForTest(initial);
+ const expectedRevision = getActiveSecretsRuntimeSnapshotRevision();
+ const activateSnapshotIfCurrent = vi.fn(() => {
+ activateSecretsRuntimeSnapshotForTest(candidate);
+ return true;
+ });
+
+ await expect(
+ activateProviderAuthRuntimeSnapshot({
+ snapshot: candidate,
+ expectedRevision,
+ activateSnapshotIfCurrent,
+ }),
+ ).resolves.toBe(true);
+
+ expect(activateSnapshotIfCurrent).toHaveBeenCalledOnce();
+ expect(activateRuntimeSecretsSnapshot).not.toHaveBeenCalled();
+ expect(emitStateEvent).toHaveBeenCalledWith(
+ "SECRETS_RELOADER_DEGRADED",
+ "Secret resolution degraded one or more owners; healthy owners were refreshed.",
+ candidate.config,
+ );
+ expect(logSecrets.warn).toHaveBeenCalledWith(
+ expect.stringContaining("[SECRETS_DEGRADED] stale provider:openai"),
+ expect.objectContaining({ event: "secrets.degraded", state: "stale" }),
+ );
+ });
+
+ it("does not recover an unrelated reload failure during provider-auth publication", async () => {
+ const config = gatewayTokenConfig({});
+ const initial = preparedSnapshot(config);
+ const candidate = preparedSnapshot(config);
+ const failure = new Error("gateway secret unavailable");
+ associateSecretResolutionErrorOwners(failure, [
+ {
+ ownerKind: "gateway",
+ ownerId: "ingress-auth",
+ state: "unavailable",
+ paths: ["gateway.auth.token"],
+ refKeys: ["env:default:GATEWAY_TOKEN"],
+ reason: "secret reference was not found",
+ degradationState: "cold",
+ failureMatched: true,
+ source: "config",
+ },
+ ]);
+ const emitStateEvent = vi.fn();
+ const activateRuntimeSecrets = runtimeSecretsActivatorForTest({
+ emitStateEvent,
+ prepareRuntimeSecretsSnapshot: vi.fn(async () => {
+ throw failure;
+ }),
+ activateRuntimeSecretsSnapshot: activateSecretsRuntimeSnapshotForTest,
+ });
+ activateSecretsRuntimeSnapshotForTest(initial);
+
+ await expect(
+ activateRuntimeSecrets(config, {
+ reason: "reload",
+ activate: false,
+ publishFailureAsDegraded: true,
+ }),
+ ).rejects.toThrow(failure.message);
+ const expectedRevision = getActiveSecretsRuntimeSnapshotRevision();
+ await expect(
+ activateProviderAuthRuntimeSnapshot({
+ snapshot: candidate,
+ expectedRevision,
+ activateSnapshotIfCurrent: () => {
+ activateSecretsRuntimeSnapshotForTest(candidate);
+ return true;
+ },
+ }),
+ ).resolves.toBe(true);
+
+ expect(emitStateEvent.mock.calls.map((call) => call[0])).toEqual(["SECRETS_RELOADER_DEGRADED"]);
+ });
+
+ it("promotes provider-auth degradation when a later full reload fails", async () => {
+ const config = gatewayTokenConfig(
+ asConfig({ models: { providers: { openai: { apiKey: "fixture", models: [] } } } }),
+ );
+ const initial = preparedSnapshot(config);
+ const providerDegraded = {
+ ...preparedSnapshot(config),
+ degradedOwners: [
+ {
+ ownerKind: "provider" as const,
+ ownerId: "openai",
+ state: "unavailable" as const,
+ paths: ["models.providers.openai.apiKey"],
+ refKeys: ["env:default:OPENAI_API_KEY"],
+ reason: "secret provider failed" as const,
+ degradationState: "stale" as const,
+ },
+ ],
+ };
+ const failure = new Error("gateway secret unavailable");
+ associateSecretResolutionErrorOwners(failure, [
+ {
+ ownerKind: "gateway",
+ ownerId: "ingress-auth",
+ state: "unavailable",
+ paths: ["gateway.auth.token"],
+ refKeys: ["env:default:GATEWAY_TOKEN"],
+ reason: "secret reference was not found",
+ degradationState: "cold",
+ failureMatched: true,
+ source: "config",
+ },
+ ]);
+ const emitStateEvent = vi.fn();
+ const activateRuntimeSecrets = runtimeSecretsActivatorForTest({
+ emitStateEvent,
+ prepareRuntimeSecretsSnapshot: vi.fn(async () => {
+ throw failure;
+ }),
+ activateRuntimeSecretsSnapshot: activateSecretsRuntimeSnapshotForTest,
+ });
+ activateSecretsRuntimeSnapshotForTest(initial);
+
+ await activateProviderAuthRuntimeSnapshot({
+ snapshot: providerDegraded,
+ expectedRevision: getActiveSecretsRuntimeSnapshotRevision(),
+ activateSnapshotIfCurrent: () => {
+ activateSecretsRuntimeSnapshotForTest(providerDegraded);
+ return true;
+ },
+ });
+ await expect(
+ activateRuntimeSecrets(config, {
+ reason: "reload",
+ activate: false,
+ publishFailureAsDegraded: true,
+ }),
+ ).rejects.toThrow(failure.message);
+ const recovered = preparedSnapshot(config);
+ await activateProviderAuthRuntimeSnapshot({
+ snapshot: recovered,
+ expectedRevision: getActiveSecretsRuntimeSnapshotRevision(),
+ activateSnapshotIfCurrent: () => {
+ activateSecretsRuntimeSnapshotForTest(recovered);
+ return true;
+ },
+ });
+
+ expect(emitStateEvent.mock.calls.map((call) => call[0])).toEqual(["SECRETS_RELOADER_DEGRADED"]);
+ });
+
+ it("does not publish web-tool degradation as provider-auth state", async () => {
+ const config = gatewayTokenConfig({});
+ const candidate = {
+ ...preparedSnapshot(config),
+ degradedOwners: [
+ {
+ ownerKind: "provider" as const,
+ ownerId: "web-search:external",
+ state: "unavailable" as const,
+ paths: ["plugins.entries.external.config.webSearch.apiKey"],
+ refKeys: ["env:default:EXTERNAL_SEARCH_REF"],
+ reason: "secret provider failed" as const,
+ degradationState: "stale" as const,
+ },
+ ],
+ };
+ const emitStateEvent = vi.fn();
+ runtimeSecretsActivatorForTest({
+ emitStateEvent,
+ prepareRuntimeSecretsSnapshot: vi.fn(),
+ activateRuntimeSecretsSnapshot: activateSecretsRuntimeSnapshotForTest,
+ });
+ activateSecretsRuntimeSnapshotForTest(candidate);
+
+ await expect(
+ activateProviderAuthRuntimeSnapshot({
+ snapshot: candidate,
+ expectedRevision: getActiveSecretsRuntimeSnapshotRevision(),
+ activateSnapshotIfCurrent: () => true,
+ }),
+ ).resolves.toBe(true);
+
+ expect(emitStateEvent).not.toHaveBeenCalled();
+ });
+
+ it("recovers provider-only degradation from a full reload through auth refresh", async () => {
+ const config = gatewayTokenConfig(
+ asConfig({ models: { providers: { openai: { apiKey: "fixture", models: [] } } } }),
+ );
+ const initial = preparedSnapshot(config);
+ const providerDegraded = {
+ ...preparedSnapshot(config),
+ degradedOwners: [
+ {
+ ownerKind: "provider" as const,
+ ownerId: "openai",
+ state: "unavailable" as const,
+ paths: ["models.providers.openai.apiKey"],
+ refKeys: ["env:default:OPENAI_API_KEY"],
+ reason: "secret provider failed" as const,
+ degradationState: "stale" as const,
+ },
+ ],
+ };
+ const emitStateEvent = vi.fn();
+ const activateRuntimeSecrets = runtimeSecretsActivatorForTest({
+ emitStateEvent,
+ prepareRuntimeSecretsSnapshot: vi.fn(async () => providerDegraded),
+ activateRuntimeSecretsSnapshot: activateSecretsRuntimeSnapshotForTest,
+ });
+ activateSecretsRuntimeSnapshotForTest(initial);
+
+ await activateRuntimeSecrets(config, { reason: "reload", activate: true });
+ const recovered = preparedSnapshot(config);
+ await activateProviderAuthRuntimeSnapshot({
+ snapshot: recovered,
+ expectedRevision: getActiveSecretsRuntimeSnapshotRevision(),
+ activateSnapshotIfCurrent: () => {
+ activateSecretsRuntimeSnapshotForTest(recovered);
+ return true;
+ },
+ });
+
+ expect(emitStateEvent.mock.calls.map((call) => call[0])).toEqual([
+ "SECRETS_RELOADER_DEGRADED",
+ "SECRETS_RELOADER_RECOVERED",
+ ]);
+ });
+
+ it("narrows full degradation when a committed reload leaves only provider owners", async () => {
+ const config = gatewayTokenConfig(
+ asConfig({ models: { providers: { openai: { apiKey: "fixture", models: [] } } } }),
+ );
+ const initial = preparedSnapshot(config);
+ const providerDegraded = {
+ ...preparedSnapshot(config),
+ degradedOwners: [
+ {
+ ownerKind: "provider" as const,
+ ownerId: "openai",
+ state: "unavailable" as const,
+ paths: ["models.providers.openai.apiKey"],
+ refKeys: ["env:default:OPENAI_API_KEY"],
+ reason: "secret provider failed" as const,
+ degradationState: "stale" as const,
+ },
+ ],
+ };
+ const fullFailure = new Error("gateway secret unavailable");
+ associateSecretResolutionErrorOwners(fullFailure, [
+ {
+ ownerKind: "gateway",
+ ownerId: "ingress-auth",
+ state: "unavailable",
+ paths: ["gateway.auth.token"],
+ refKeys: ["env:default:GATEWAY_TOKEN"],
+ reason: "secret reference was not found",
+ degradationState: "cold",
+ failureMatched: true,
+ source: "config",
+ },
+ ]);
+ const emitStateEvent = vi.fn();
+ const prepareRuntimeSecretsSnapshot = vi
+ .fn()
+ .mockRejectedValueOnce(fullFailure)
+ .mockResolvedValueOnce(providerDegraded);
+ const activateRuntimeSecrets = runtimeSecretsActivatorForTest({
+ emitStateEvent,
+ prepareRuntimeSecretsSnapshot,
+ activateRuntimeSecretsSnapshot: activateSecretsRuntimeSnapshotForTest,
+ });
+ activateSecretsRuntimeSnapshotForTest(initial);
+
+ await expect(
+ activateRuntimeSecrets(config, {
+ reason: "reload",
+ activate: false,
+ publishFailureAsDegraded: true,
+ }),
+ ).rejects.toThrow(fullFailure.message);
+ await activateRuntimeSecrets(config, { reason: "reload", activate: true });
+ const recovered = preparedSnapshot(config);
+ await activateProviderAuthRuntimeSnapshot({
+ snapshot: recovered,
+ expectedRevision: getActiveSecretsRuntimeSnapshotRevision(),
+ activateSnapshotIfCurrent: () => {
+ activateSecretsRuntimeSnapshotForTest(recovered);
+ return true;
+ },
+ });
+
+ expect(emitStateEvent.mock.calls.map((call) => call[0])).toEqual([
+ "SECRETS_RELOADER_DEGRADED",
+ "SECRETS_RELOADER_RECOVERED",
+ ]);
+ });
+
+ it("publishes prepared degradation only after the reload transaction commits", async () => {
+ const initial = preparedSnapshot(gatewayTokenConfig({}));
+ const degradedSnapshot = (token: string): PreparedSecretsRuntimeSnapshot => ({
+ ...preparedSnapshotWithGatewayToken(initial.sourceConfig, token),
+ warnings: [
+ {
+ code: "SECRETS_OWNER_UNAVAILABLE",
+ path: "models.providers.openai.apiKey",
+ message: "Secret owner provider:openai is using last-known-good.",
+ },
+ ],
+ degradedOwners: [
+ {
+ ownerKind: "provider",
+ ownerId: "openai",
+ state: "unavailable",
+ degradationState: "stale",
+ paths: ["models.providers.openai.apiKey"],
+ refKeys: ["env:default:OPENAI_API_KEY"],
+ reason: "secret reference was not found",
+ },
+ ],
+ });
+ const rolledBackCandidate = degradedSnapshot("rolled-back-token");
+ const committedCandidate = degradedSnapshot("committed-token");
+ const emitStateEvent = vi.fn();
+ const logSecrets = mockLogSecretsForTest();
+ const activateRuntimeSecrets = runtimeSecretsActivatorForTest({
+ prepareRuntimeSecretsSnapshot: vi.fn(async ({ config }) => preparedSnapshot(config)),
+ activateRuntimeSecretsSnapshot: activateSecretsRuntimeSnapshotForTest,
+ emitStateEvent,
+ logSecrets,
+ });
+ activateSecretsRuntimeSnapshotForTest(initial);
+
+ await expect(
+ activateRuntimeSecrets.activatePreparedSnapshotIfCurrent?.(
+ rolledBackCandidate,
+ getActiveSecretsRuntimeSnapshotRevision(),
+ { reason: "reload", activate: true, deferStatePublication: true },
+ ),
+ ).resolves.toBe(rolledBackCandidate);
+ expect(emitStateEvent).not.toHaveBeenCalled();
+ expect(logSecrets.warn).not.toHaveBeenCalled();
+
+ activateSecretsRuntimeSnapshotForTest(initial);
+ await expect(
+ activateRuntimeSecrets.activatePreparedSnapshotIfCurrent?.(
+ committedCandidate,
+ getActiveSecretsRuntimeSnapshotRevision(),
+ { reason: "reload", activate: true, deferStatePublication: true },
+ ),
+ ).resolves.toBe(committedCandidate);
+ expect(emitStateEvent).not.toHaveBeenCalled();
+ expect(logSecrets.warn).not.toHaveBeenCalled();
+
+ publishRuntimeSecretsStateTransition(activateRuntimeSecrets, rolledBackCandidate);
+ expect(emitStateEvent).not.toHaveBeenCalled();
+ expect(logSecrets.warn).not.toHaveBeenCalled();
+
+ publishRuntimeSecretsStateTransition(activateRuntimeSecrets, committedCandidate);
+ expect(emitStateEvent).toHaveBeenCalledOnce();
+ expect(emitStateEvent).toHaveBeenCalledWith(
+ "SECRETS_RELOADER_DEGRADED",
+ "Secret resolution degraded one or more owners; healthy owners were refreshed.",
+ committedCandidate.config,
+ );
+ expect(logSecrets.warn).toHaveBeenCalledTimes(2);
+ expect(logSecrets.warn).toHaveBeenCalledWith(
+ "[SECRETS_OWNER_UNAVAILABLE] Secret owner provider:openai is using last-known-good.",
+ );
+ expect(logSecrets.warn).toHaveBeenCalledWith(
+ expect.stringContaining("[SECRETS_DEGRADED] stale provider:openai"),
+ expect.objectContaining({ event: "secrets.degraded", state: "stale" }),
+ );
+ });
+
+ it("publishes deferred degradation after a provider-auth descendant activation", async () => {
+ const config = gatewayTokenConfig(
+ asConfig({ models: { providers: { openai: { apiKey: "fixture", models: [] } } } }),
+ );
+ const initial = preparedSnapshot(config);
+ const degraded = {
+ ...preparedSnapshot(initial.sourceConfig),
+ degradedOwners: [
+ {
+ ownerKind: "capability" as const,
+ ownerId: "tts",
+ state: "unavailable" as const,
+ degradationState: "cold" as const,
+ paths: ["messages.tts.providers.elevenlabs.apiKey"],
+ refKeys: ["env:default:ELEVENLABS_API_KEY"],
+ reason: "secret reference was not found" as const,
+ },
+ ],
+ };
+ const emitStateEvent = vi.fn();
+ const activateRuntimeSecrets = runtimeSecretsActivatorForTest({
+ emitStateEvent,
+ prepareRuntimeSecretsSnapshot: vi.fn(async ({ config: preparedConfig }) =>
+ preparedSnapshot(preparedConfig),
+ ),
+ activateRuntimeSecretsSnapshot: activateSecretsRuntimeSnapshotForTest,
+ });
+ activateSecretsRuntimeSnapshotForTest(initial);
+ await expect(
+ activateRuntimeSecrets.activatePreparedSnapshotIfCurrent?.(
+ degraded,
+ getActiveSecretsRuntimeSnapshotRevision(),
+ { reason: "reload", activate: true, deferStatePublication: true },
+ ),
+ ).resolves.toBe(degraded);
+ const outerRevision = getActiveSecretsRuntimeSnapshotRevision();
+ const descendant: PreparedSecretsRuntimeSnapshot = structuredClone(degraded);
+ descendant.degradedOwners?.push({
+ ownerKind: "provider",
+ ownerId: "openai",
+ state: "unavailable",
+ degradationState: "stale",
+ paths: ["models.providers.openai.apiKey"],
+ refKeys: ["env:default:OPENAI_API_KEY"],
+ reason: "secret reference was not found",
+ });
+
+ await expect(
+ activateProviderAuthRuntimeSnapshot({
+ snapshot: descendant,
+ expectedRevision: outerRevision,
+ activateSnapshotIfCurrent: () =>
+ activateSecretsRuntimeSnapshotStateIfCurrent({
+ snapshot: descendant,
+ expectedRevision: outerRevision,
+ refreshContext: null,
+ refreshHandler: null,
+ preserveActivationLineage: true,
+ }),
+ }),
+ ).resolves.toBe(true);
+ expect(emitStateEvent).not.toHaveBeenCalled();
+
+ publishRuntimeSecretsStateTransition(activateRuntimeSecrets, degraded);
+ expect(emitStateEvent.mock.calls.map((call) => call[0])).toEqual(["SECRETS_RELOADER_DEGRADED"]);
+ });
+
+ it("does not publish stale degradation after a provider-auth descendant recovers", async () => {
+ const config = gatewayTokenConfig(
+ asConfig({ models: { providers: { openai: { apiKey: "fixture", models: [] } } } }),
+ );
+ const initial = preparedSnapshot(config);
+ const degraded = {
+ ...preparedSnapshot(config),
+ degradedOwners: [
+ {
+ ownerKind: "provider" as const,
+ ownerId: "openai",
+ state: "unavailable" as const,
+ degradationState: "stale" as const,
+ paths: ["models.providers.openai.apiKey"],
+ refKeys: ["env:default:OPENAI_API_KEY"],
+ reason: "secret reference was not found" as const,
+ },
+ ],
+ };
+ const emitStateEvent = vi.fn();
+ const activateRuntimeSecrets = runtimeSecretsActivatorForTest({
+ emitStateEvent,
+ prepareRuntimeSecretsSnapshot: vi.fn(async ({ config: candidate }) =>
+ preparedSnapshot(candidate),
+ ),
+ activateRuntimeSecretsSnapshot: activateSecretsRuntimeSnapshotForTest,
+ });
+ activateSecretsRuntimeSnapshotForTest(initial);
+ await expect(
+ activateRuntimeSecrets.activatePreparedSnapshotIfCurrent?.(
+ degraded,
+ getActiveSecretsRuntimeSnapshotRevision(),
+ { reason: "reload", activate: true, deferStatePublication: true },
+ ),
+ ).resolves.toBe(degraded);
+ const outerRevision = getActiveSecretsRuntimeSnapshotRevision();
+ const recovered = preparedSnapshot(config);
+
+ await expect(
+ activateProviderAuthRuntimeSnapshot({
+ snapshot: recovered,
+ expectedRevision: outerRevision,
+ activateSnapshotIfCurrent: () =>
+ activateSecretsRuntimeSnapshotStateIfCurrent({
+ snapshot: recovered,
+ expectedRevision: outerRevision,
+ refreshContext: null,
+ refreshHandler: null,
+ preserveActivationLineage: true,
+ }),
+ }),
+ ).resolves.toBe(true);
+
+ publishRuntimeSecretsStateTransition(activateRuntimeSecrets, degraded);
+ expect(emitStateEvent).not.toHaveBeenCalled();
+ });
+
+ it("recovers prior full degradation when a deferred degraded snapshot is healed", async () => {
+ const config = gatewayTokenConfig(
+ asConfig({ models: { providers: { openai: { apiKey: "fixture", models: [] } } } }),
+ );
+ const initial = preparedSnapshot(config);
+ const fullDegraded = {
+ ...preparedSnapshot(config),
+ degradedOwners: [
+ {
+ ownerKind: "capability" as const,
+ ownerId: "tts",
+ state: "unavailable" as const,
+ degradationState: "cold" as const,
+ paths: ["messages.tts.providers.elevenlabs.apiKey"],
+ refKeys: ["env:default:ELEVENLABS_API_KEY"],
+ reason: "secret reference was not found" as const,
+ },
+ ],
+ };
+ const providerDegraded = {
+ ...preparedSnapshot(config),
+ degradedOwners: [
+ {
+ ownerKind: "provider" as const,
+ ownerId: "openai",
+ state: "unavailable" as const,
+ degradationState: "stale" as const,
+ paths: ["models.providers.openai.apiKey"],
+ refKeys: ["env:default:OPENAI_API_KEY"],
+ reason: "secret reference was not found" as const,
+ },
+ ],
+ };
+ const emitStateEvent = vi.fn();
+ const activateRuntimeSecrets = runtimeSecretsActivatorForTest({
+ emitStateEvent,
+ prepareRuntimeSecretsSnapshot: vi.fn(async ({ config: candidate }) =>
+ preparedSnapshot(candidate),
+ ),
+ activateRuntimeSecretsSnapshot: activateSecretsRuntimeSnapshotForTest,
+ });
+ activateSecretsRuntimeSnapshotForTest(initial);
+ await activateRuntimeSecrets.activatePreparedSnapshot?.(fullDegraded, {
+ reason: "reload",
+ activate: true,
+ });
+ await expect(
+ activateRuntimeSecrets.activatePreparedSnapshotIfCurrent?.(
+ providerDegraded,
+ getActiveSecretsRuntimeSnapshotRevision(),
+ { reason: "reload", activate: true, deferStatePublication: true },
+ ),
+ ).resolves.toBe(providerDegraded);
+ const outerRevision = getActiveSecretsRuntimeSnapshotRevision();
+ const recovered = preparedSnapshot(config);
+
+ await expect(
+ activateProviderAuthRuntimeSnapshot({
+ snapshot: recovered,
+ expectedRevision: outerRevision,
+ activateSnapshotIfCurrent: () =>
+ activateSecretsRuntimeSnapshotStateIfCurrent({
+ snapshot: recovered,
+ expectedRevision: outerRevision,
+ refreshContext: null,
+ refreshHandler: null,
+ preserveActivationLineage: true,
+ }),
+ }),
+ ).resolves.toBe(true);
+ expect(emitStateEvent.mock.calls.map((call) => call[0])).toEqual(["SECRETS_RELOADER_DEGRADED"]);
+
+ publishRuntimeSecretsStateTransition(activateRuntimeSecrets, providerDegraded);
+ expect(emitStateEvent.mock.calls.map((call) => call[0])).toEqual([
+ "SECRETS_RELOADER_DEGRADED",
+ "SECRETS_RELOADER_RECOVERED",
+ ]);
+ });
+
+ it("publishes deferred recovery after a provider-auth descendant activation", async () => {
+ const config = gatewayTokenConfig(
+ asConfig({ models: { providers: { openai: { apiKey: "fixture", models: [] } } } }),
+ );
+ const initial = preparedSnapshot(config);
+ const degraded = {
+ ...preparedSnapshot(initial.sourceConfig),
+ degradedOwners: [
+ {
+ ownerKind: "provider" as const,
+ ownerId: "openai",
+ state: "unavailable" as const,
+ degradationState: "stale" as const,
+ paths: ["models.providers.openai.apiKey"],
+ refKeys: ["env:default:OPENAI_API_KEY"],
+ reason: "secret reference was not found" as const,
+ },
+ ],
+ };
+ const recovered = preparedSnapshot(config);
+ const emitStateEvent = vi.fn();
+ const activateRuntimeSecrets = runtimeSecretsActivatorForTest({
+ emitStateEvent,
+ prepareRuntimeSecretsSnapshot: vi.fn(async ({ config: preparedConfig }) =>
+ preparedSnapshot(preparedConfig),
+ ),
+ activateRuntimeSecretsSnapshot: activateSecretsRuntimeSnapshotForTest,
+ });
+ activateSecretsRuntimeSnapshotForTest(initial);
+ await activateRuntimeSecrets.activatePreparedSnapshot?.(degraded, {
+ reason: "reload",
+ activate: true,
+ });
+ await expect(
+ activateRuntimeSecrets.activatePreparedSnapshotIfCurrent?.(
+ recovered,
+ getActiveSecretsRuntimeSnapshotRevision(),
+ { reason: "reload", activate: true, deferStatePublication: true },
+ ),
+ ).resolves.toBe(recovered);
+ const outerRevision = getActiveSecretsRuntimeSnapshotRevision();
+ const descendant = structuredClone(recovered);
+
+ await expect(
+ activateProviderAuthRuntimeSnapshot({
+ snapshot: descendant,
+ expectedRevision: outerRevision,
+ activateSnapshotIfCurrent: () =>
+ activateSecretsRuntimeSnapshotStateIfCurrent({
+ snapshot: descendant,
+ expectedRevision: outerRevision,
+ refreshContext: null,
+ refreshHandler: null,
+ preserveActivationLineage: true,
+ }),
+ }),
+ ).resolves.toBe(true);
+ expect(emitStateEvent.mock.calls.map((call) => call[0])).toEqual(["SECRETS_RELOADER_DEGRADED"]);
+
+ publishRuntimeSecretsStateTransition(activateRuntimeSecrets, recovered);
+ expect(emitStateEvent.mock.calls.map((call) => call[0])).toEqual([
+ "SECRETS_RELOADER_DEGRADED",
+ "SECRETS_RELOADER_RECOVERED",
+ ]);
+ });
+
+ it("publishes source-only recovery after a provider-auth descendant activation", async () => {
+ const stableConfig = gatewayTokenConfig({
+ models: {
+ providers: {
+ openai: {
+ baseUrl: "https://api.openai.com/v1",
+ apiKey: { source: "env", provider: "default", id: "OPENAI_STABLE" },
+ models: [],
+ },
+ },
+ },
+ });
+ const failedConfig = structuredClone(stableConfig);
+ failedConfig.models!.providers!.openai!.apiKey = {
+ source: "env",
+ provider: "default",
+ id: "OPENAI_CHANGED",
+ };
+ const failure = new Error("provider secret unavailable");
+ associateSecretResolutionErrorOwners(failure, [
+ {
+ ownerKind: "provider",
+ ownerId: "openai",
+ state: "unavailable",
+ paths: ["models.providers.openai.apiKey"],
+ refKeys: ["env:default:OPENAI_CHANGED"],
+ reason: "secret reference was not found",
+ degradationState: "cold",
+ failureMatched: true,
+ source: "config",
+ },
+ ]);
+ const emitStateEvent = vi.fn();
+ const activateRuntimeSecrets = runtimeSecretsActivatorForTest({
+ emitStateEvent,
+ prepareRuntimeSecretsSnapshot: vi.fn(async () => {
+ throw failure;
+ }),
+ activateRuntimeSecretsSnapshot: activateSecretsRuntimeSnapshotForTest,
+ });
+ const initial = preparedSnapshot(stableConfig);
+ activateSecretsRuntimeSnapshotForTest(initial);
+ await expect(
+ activateRuntimeSecrets(failedConfig, {
+ reason: "reload",
+ activate: false,
+ publishFailureAsDegraded: true,
+ }),
+ ).rejects.toBe(failure);
+
+ const sourceOnly = preparedSnapshot(stableConfig);
+ activateSecretsRuntimeSnapshotForTest(sourceOnly);
+ const committedRevision = getActiveSecretsRuntimeSnapshotRevision();
+ const descendant = structuredClone(sourceOnly);
+ expect(
+ activateSecretsRuntimeSnapshotStateIfCurrent({
+ snapshot: descendant,
+ expectedRevision: committedRevision,
+ refreshContext: null,
+ refreshHandler: null,
+ preserveActivationLineage: true,
+ }),
+ ).toBe(true);
+
+ publishRuntimeSecretsStateTransition(activateRuntimeSecrets, sourceOnly, {
+ sourceOnly: true,
+ expectedRevision: committedRevision,
+ });
+ expect(emitStateEvent.mock.calls.map((call) => call[0])).toEqual([
+ "SECRETS_RELOADER_DEGRADED",
+ "SECRETS_RELOADER_RECOVERED",
+ ]);
+ });
+
it("rejects a managed reload prepared before an OAuth credential mutation", async () => {
const agentDir = "/tmp/openclaw-managed-auth-store-cas";
const initial = preparedSnapshot(gatewayTokenConfig({}));
@@ -827,7 +1588,7 @@ describe("gateway startup config secret preflight", () => {
).rejects.toThrow(missingSecretError.message);
expect(prepareRuntimeSecretsSnapshot).toHaveBeenCalledWith(
- expect.objectContaining({ allowUnavailableSecretOwners: false }),
+ expect.objectContaining({ allowUnavailableSecretOwners: true }),
);
expect(activateRuntimeSecretsSnapshot).not.toHaveBeenCalled();
expect(logSecrets.warn).not.toHaveBeenCalledWith(
@@ -839,7 +1600,7 @@ describe("gateway startup config secret preflight", () => {
);
it.each(["reload", "restart-check"] as const)(
- "publishes the owner when a resolved secret value is invalid during %s",
+ "rejects invalid resolved values without publishing degradation during %s",
async (reason) => {
activateSecretsRuntimeSnapshotForTest(preparedSnapshot(gatewayTokenConfig({})));
const invalidSecretError = new Error(
@@ -877,24 +1638,8 @@ describe("gateway startup config secret preflight", () => {
}),
).rejects.toThrow(invalidSecretError.message);
- expect(logSecrets.warn).toHaveBeenCalledWith(
- "[SECRETS_DEGRADED] stale capability:tts: resolved secret value was invalid. " +
- "Retry: openclaw secrets reload.",
- {
- event: "secrets.degraded",
- ownerKind: "capability",
- ownerId: "tts",
- reason: "resolved secret value was invalid",
- state: "stale",
- retryHint: "openclaw secrets reload",
- },
- );
- expect(JSON.stringify(logSecrets.warn.mock.calls)).not.toContain("/private/value");
- expect(emitStateEvent).toHaveBeenCalledWith(
- "SECRETS_RELOADER_DEGRADED",
- "Secret resolution failed; runtime remains on the last-known-good snapshot.",
- expect.anything(),
- );
+ expect(logSecrets.warn).not.toHaveBeenCalled();
+ expect(emitStateEvent).not.toHaveBeenCalled();
},
);
@@ -1311,7 +2056,7 @@ describe("gateway startup config secret preflight", () => {
activateRuntimeSecrets.activatePreparedSnapshotIfCurrent?.(sourceOnly, sourceOnlyRevision, {
reason: "reload",
activate: true,
- publishRecovery: false,
+ deferStatePublication: true,
}),
).resolves.toMatchObject({ config: sourceConfig });
expect(emitStateEvent.mock.calls.map((call) => call[0])).toEqual([
@@ -1327,7 +2072,7 @@ describe("gateway startup config secret preflight", () => {
publishFailureAsDegraded: true,
}),
).rejects.toThrow(missingSecretError.message);
- publishRuntimeSecretsRecovery(activateRuntimeSecrets, sourceOnly);
+ publishRuntimeSecretsStateTransition(activateRuntimeSecrets, sourceOnly);
expect(emitStateEvent.mock.calls.map((call) => call[0])).toEqual([
"SECRETS_RELOADER_DEGRADED",
"SECRETS_RELOADER_RECOVERED",
@@ -1388,11 +2133,13 @@ describe("gateway startup config secret preflight", () => {
{
reason: "reload",
activate: true,
- publishRecovery: false,
+ deferStatePublication: true,
},
),
).resolves.toMatchObject({ sourceConfig });
- publishRuntimeSecretsRecovery(activateRuntimeSecrets, revertedSnapshot, { sourceOnly: true });
+ publishRuntimeSecretsStateTransition(activateRuntimeSecrets, revertedSnapshot, {
+ sourceOnly: true,
+ });
expect(emitStateEvent.mock.calls.map((call) => call[0]).slice(-2)).toEqual([
"SECRETS_RELOADER_DEGRADED",
"SECRETS_RELOADER_RECOVERED",
@@ -1433,10 +2180,10 @@ describe("gateway startup config secret preflight", () => {
activateRuntimeSecrets.activatePreparedSnapshotIfCurrent?.(
unrelatedRevertedSnapshot,
getActiveSecretsRuntimeSnapshotRevision(),
- { reason: "reload", activate: true, publishRecovery: false },
+ { reason: "reload", activate: true, deferStatePublication: true },
),
).resolves.toMatchObject({ sourceConfig });
- publishRuntimeSecretsRecovery(activateRuntimeSecrets, unrelatedRevertedSnapshot, {
+ publishRuntimeSecretsStateTransition(activateRuntimeSecrets, unrelatedRevertedSnapshot, {
sourceOnly: true,
});
expect(emitStateEvent.mock.calls.map((call) => call[0]).slice(-2)).toEqual([
@@ -1460,11 +2207,13 @@ describe("gateway startup config secret preflight", () => {
{
reason: "reload",
activate: true,
- publishRecovery: false,
+ deferStatePublication: true,
},
),
).resolves.toMatchObject({ sourceConfig });
- publishRuntimeSecretsRecovery(activateRuntimeSecrets, unchangedSnapshot, { sourceOnly: true });
+ publishRuntimeSecretsStateTransition(activateRuntimeSecrets, unchangedSnapshot, {
+ sourceOnly: true,
+ });
expect(emitStateEvent.mock.calls.map((call) => call[0]).slice(-2)).toEqual([
"SECRETS_RELOADER_RECOVERED",
"SECRETS_RELOADER_DEGRADED",
@@ -1549,10 +2298,12 @@ describe("gateway startup config secret preflight", () => {
activateRuntimeSecrets.activatePreparedSnapshotIfCurrent?.(
revertedSnapshot,
getActiveSecretsRuntimeSnapshotRevision(),
- { reason: "reload", activate: true, publishRecovery: false },
+ { reason: "reload", activate: true, deferStatePublication: true },
),
).resolves.toBe(revertedSnapshot);
- publishRuntimeSecretsRecovery(activateRuntimeSecrets, revertedSnapshot, { sourceOnly: true });
+ publishRuntimeSecretsStateTransition(activateRuntimeSecrets, revertedSnapshot, {
+ sourceOnly: true,
+ });
expect(emitStateEvent.mock.calls.map((call) => call[0])).toEqual(["SECRETS_RELOADER_DEGRADED"]);
const fullyResolvedSnapshot = preparedSnapshot(stableConfig);
@@ -1560,10 +2311,10 @@ describe("gateway startup config secret preflight", () => {
activateRuntimeSecrets.activatePreparedSnapshotIfCurrent?.(
fullyResolvedSnapshot,
getActiveSecretsRuntimeSnapshotRevision(),
- { reason: "reload", activate: true, publishRecovery: false },
+ { reason: "reload", activate: true, deferStatePublication: true },
),
).resolves.toBe(fullyResolvedSnapshot);
- publishRuntimeSecretsRecovery(activateRuntimeSecrets, fullyResolvedSnapshot);
+ publishRuntimeSecretsStateTransition(activateRuntimeSecrets, fullyResolvedSnapshot);
expect(emitStateEvent.mock.calls.map((call) => call[0])).toEqual([
"SECRETS_RELOADER_DEGRADED",
"SECRETS_RELOADER_RECOVERED",
@@ -1591,10 +2342,10 @@ describe("gateway startup config secret preflight", () => {
activateRuntimeSecrets.activatePreparedSnapshotIfCurrent?.(
secondRevertedSnapshot,
getActiveSecretsRuntimeSnapshotRevision(),
- { reason: "reload", activate: true, publishRecovery: false },
+ { reason: "reload", activate: true, deferStatePublication: true },
),
).resolves.toBe(secondRevertedSnapshot);
- publishRuntimeSecretsRecovery(activateRuntimeSecrets, secondRevertedSnapshot, {
+ publishRuntimeSecretsStateTransition(activateRuntimeSecrets, secondRevertedSnapshot, {
sourceOnly: true,
});
expect(emitStateEvent.mock.calls.map((call) => call[0])).toEqual([
diff --git a/src/gateway/server-startup-config.ts b/src/gateway/server-startup-config.ts
index c2a56b5dd5f6..1fe9bfd51a43 100644
--- a/src/gateway/server-startup-config.ts
+++ b/src/gateway/server-startup-config.ts
@@ -1,19 +1,6 @@
// Gateway startup config loads, repairs, validates, and activates runtime config
// plus secrets snapshots before the server exposes user-facing surfaces.
import { isDeepStrictEqual } from "node:util";
-import {
- formatInvalidConfigRecoveryHint,
- formatPluginPackagingRuntimeOutputRecoveryHint,
-} from "../cli/config-recovery-hints.js";
-import { createInvalidConfigError } from "../config/io.invalid-config.js";
-import {
- type ReadConfigFileSnapshotWithPluginMetadataResult,
- readConfigFileSnapshotWithPluginMetadata,
-} from "../config/io.js";
-import { formatConfigIssueLines } from "../config/issue-format.js";
-import { isNixMode } from "../config/paths.js";
-import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
-import { isPluginPackagingRuntimeOutputInvalidConfigSnapshot } from "../config/recovery-policy.js";
import { applyConfigOverrides } from "../config/runtime-overrides.js";
import type { GatewayAuthConfig, GatewayTailscaleConfig } from "../config/types.gateway.js";
import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.openclaw.js";
@@ -22,43 +9,50 @@ import type { PluginManifestRegistry } from "../plugins/manifest-registry.js";
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
import {
classifySecretResolutionErrorDegradations,
+ isRetryableSecretDegradationReason,
listSecretResolutionErrorOwners,
redactSecretDegradationReason,
SECRET_DEGRADATION_RETRY_HINT,
type SecretDegradation,
} from "../secrets/runtime-degraded-state.js";
import { prepareSecretsRuntimeFastPathSnapshot } from "../secrets/runtime-fast-path.js";
+import { registerProviderAuthRuntimeSnapshotActivationOwner } from "../secrets/runtime-provider-auth-activation.js";
import {
- GATEWAY_AUTH_SURFACE_PATHS,
- evaluateGatewayAuthSurfaceStates,
-} from "../secrets/runtime-gateway-auth-surfaces.js";
+ listProviderAuthDegradedOwners,
+ preparedDegradationSupportsSourceOnlyRecovery,
+ resolvePreparedSecretsStateScope,
+ type SecretsStateScope,
+} from "../secrets/runtime-provider-auth-scope.js";
import {
activateSecretsRuntimeSnapshotState,
graftActiveSecretsRuntimeAuthState,
getActiveSecretsRuntimeSnapshot,
getActiveSecretsRuntimeSnapshotRevision,
+ hasActiveSecretsRuntimeSnapshotLineage,
hasSameSecretReloadContract,
hasCurrentAuthStoreCredentialsRevision,
} from "../secrets/runtime-state.js";
+import { logRuntimeSecretWarnings } from "../secrets/runtime-warning-log.js";
import { createLazyPromise } from "../shared/lazy-runtime.js";
-import { resolveGatewayAuth } from "./auth.js";
-import { assertGatewayAuthNotKnownWeak } from "./known-weak-gateway-secrets.js";
import type { ChannelAutostartSuppression } from "./server-channels.js";
+import {
+ applyGatewayAuthOverridesForStartupPreflight,
+ assertRuntimeGatewayAuthNotKnownWeak,
+ assertValidGatewayStartupConfigSnapshot,
+ hasActiveGatewayAuthSecretRef,
+ logGatewayAuthSurfaceDiagnostics,
+ type GatewayStartupConfigMeasure,
+ type GatewayStartupLog,
+} from "./server-startup-config-helpers.js";
+export {
+ loadGatewayStartupConfigSnapshot,
+ type GatewayStartupConfigSnapshotLoadResult,
+} from "./server-startup-config-helpers.js";
import {
resolveGatewayStartupSecretProjection,
resolveGatewayStartupSourceConfig,
} from "./server-startup-secret-surfaces.js";
-import {
- ensureGatewayStartupAuth,
- mergeGatewayAuthConfig,
- mergeGatewayTailscaleConfig,
-} from "./startup-auth.js";
-
-type GatewayStartupLog = {
- info: (message: string) => void;
- warn: (message: string, meta?: Record) => void;
- error?: (message: string) => void;
-};
+import { ensureGatewayStartupAuth } from "./startup-auth.js";
type GatewaySecretsStateEventCode = "SECRETS_RELOADER_DEGRADED" | "SECRETS_RELOADER_RECOVERED";
@@ -79,10 +73,16 @@ type RuntimeSecretsActivationParams = {
includeAuthStoreRefs?: boolean;
/** Raw config source paired with an otherwise fully activated prepared snapshot. */
runtimeSourceConfig?: OpenClawConfig;
- /** Defer recovery until a larger transaction can no longer roll activation back. */
- publishRecovery?: boolean;
+ /** Defer degradation/recovery publication until a larger transaction can no longer roll back. */
+ deferStatePublication?: boolean;
};
+type DeferredSecretsStateTransition = {
+ activationRevision: number;
+ reason: RuntimeSecretsActivationParams["reason"];
+ activationScope: SecretsStateScope;
+} & ({ kind: "degraded" } | { kind: "recovered"; degradationGeneration: number });
+
/** Gateway startup hook that prepares secrets and optionally activates the prepared snapshot. */
export type ActivateRuntimeSecrets = ((
config: OpenClawConfig,
@@ -101,31 +101,23 @@ export type ActivateRuntimeSecrets = ((
) => Promise;
};
-const runtimeSecretsRecoveryPublishers = new WeakMap<
+const runtimeSecretsStatePublishers = new WeakMap<
ActivateRuntimeSecrets,
- (snapshot: PreparedRuntimeSecretsSnapshot, options?: { sourceOnly?: boolean }) => void
+ (
+ snapshot: PreparedRuntimeSecretsSnapshot,
+ options?: { sourceOnly?: boolean; expectedRevision?: number },
+ ) => void
>();
-/** Publishes recovery after a prepared source-only snapshot wins its commit CAS. */
-export function publishRuntimeSecretsRecovery(
+/** Publishes a deferred degradation or recovery after the prepared snapshot wins its commit CAS. */
+export function publishRuntimeSecretsStateTransition(
activateRuntimeSecrets: ActivateRuntimeSecrets,
snapshot: PreparedRuntimeSecretsSnapshot,
- options?: { sourceOnly?: boolean },
+ options?: { sourceOnly?: boolean; expectedRevision?: number },
): void {
- runtimeSecretsRecoveryPublishers.get(activateRuntimeSecrets)?.(snapshot, options);
+ runtimeSecretsStatePublishers.get(activateRuntimeSecrets)?.(snapshot, options);
}
-type GatewayStartupConfigOverrides = {
- auth?: GatewayAuthConfig;
- tailscale?: GatewayTailscaleConfig;
-};
-
-type GatewayStartupConfigMeasure = (
- name: string,
- run: () => T | Promise,
- options?: { omitErrorMessage?: boolean },
-) => Promise;
-
function logSecretDegradation(log: GatewayStartupLog, degradation: SecretDegradation): void {
const reason = redactSecretDegradationReason(degradation.reason);
log.warn(
@@ -142,81 +134,6 @@ function logSecretDegradation(log: GatewayStartupLog, degradation: SecretDegrada
);
}
-/** Config snapshot plus optional plugin metadata loaded before Gateway startup auth. */
-export type GatewayStartupConfigSnapshotLoadResult = {
- snapshot: ConfigFileSnapshot;
- wroteConfig: boolean;
- pluginMetadataSnapshot?: PluginMetadataSnapshot;
-};
-
-/** Load and validate the config snapshot, applying runtime-only plugin auto-enable changes. */
-export async function loadGatewayStartupConfigSnapshot(params: {
- minimalTestGateway: boolean;
- log: GatewayStartupLog;
- measure?: GatewayStartupConfigMeasure;
- initialSnapshotRead?: ReadConfigFileSnapshotWithPluginMetadataResult;
-}): Promise {
- const measure = params.measure ?? (async (_name, run) => await run());
- const snapshotRead =
- params.initialSnapshotRead ??
- (await measure("config.snapshot.read", () =>
- readConfigFileSnapshotWithPluginMetadata({ measure }),
- ));
- const configSnapshot = snapshotRead.snapshot;
- const pluginMetadataSnapshot = snapshotRead.pluginMetadataSnapshot;
- const wroteConfig = false;
- if (configSnapshot.legacyIssues.length > 0 && isNixMode) {
- throw createInvalidConfigError(
- configSnapshot.path,
- "Legacy config entries detected while running in Nix mode. Update your Nix config to the latest schema and restart.",
- { recovery: "manual" },
- );
- }
- if (configSnapshot.exists) {
- assertValidGatewayStartupConfigSnapshot(configSnapshot, { includeDoctorHint: true });
- }
-
- const autoEnable = params.minimalTestGateway
- ? { config: configSnapshot.config, changes: [] as string[] }
- : await measure("config.snapshot.auto-enable", () =>
- applyPluginAutoEnable({
- config: configSnapshot.sourceConfig,
- env: process.env,
- ...(pluginMetadataSnapshot?.manifestRegistry
- ? { manifestRegistry: pluginMetadataSnapshot.manifestRegistry }
- : {}),
- discovery: pluginMetadataSnapshot?.discovery,
- }),
- );
- if (autoEnable.changes.length === 0) {
- return {
- snapshot: configSnapshot,
- wroteConfig,
- ...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}),
- };
- }
-
- params.log.info(
- `gateway: auto-enabled plugins for this runtime without writing config:\n${autoEnable.changes.map((entry) => `- ${entry}`).join("\n")}`,
- );
- return {
- snapshot: withRuntimeConfig(configSnapshot, autoEnable.config),
- wroteConfig,
- ...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}),
- };
-}
-
-function withRuntimeConfig(
- snapshot: ConfigFileSnapshot,
- runtimeConfig: OpenClawConfig,
-): ConfigFileSnapshot {
- return {
- ...snapshot,
- runtimeConfig,
- config: runtimeConfig,
- };
-}
-
/** Create the serialized secrets activation function used by startup and reload paths. */
export function createRuntimeSecretsActivator(params: {
logSecrets: GatewayStartupLog;
@@ -231,11 +148,14 @@ export function createRuntimeSecretsActivator(params: {
pluginMetadataSnapshot?: Pick;
channelAutostartSuppression?: ChannelAutostartSuppression | null;
}): ActivateRuntimeSecrets {
+ let secretsDegraded = false;
let degradationGeneration = 0;
let activeDegradationGeneration: number | null = null;
let activeDegradationConfig: OpenClawConfig | null = null;
let activeDegradationSupportsSourceOnlyRecovery = false;
- const deferredRecoveryGenerations = new WeakMap