fix(gateway): recover credential-file accounts after secrets reload (#126999)

* fix(gateway): recover credential-file accounts on secrets reload

Preserve independently discovered credential-file degradation across runtime snapshot refreshes, and re-inspect only the affected account when secrets are reloaded. Healthy sibling accounts remain running while status and doctor retain exact-owner diagnostics until recovery or teardown.

* test(gateway): prove credential-file reload recovery

* test(gateway): assert redacted reload error code
This commit is contained in:
Peter Steinberger
2026-08-20 23:00:49 -07:00
committed by GitHub
parent 9feb1db00d
commit 2ecd342fbb
14 changed files with 1093 additions and 454 deletions
+29 -8
View File
@@ -2,7 +2,11 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { SESSION_TOTAL_TOKENS_VERSION } from "../config/sessions/types.js";
import { setActiveDegradedPlugins } from "../plugins/runtime-degraded-state.js";
import { setActiveDegradedSecretOwners } from "../secrets/runtime-degraded-state.js";
import {
clearActiveCredentialDegradedOwner,
setActiveCredentialDegradedOwner,
setActiveDegradedSecretOwners,
} from "../secrets/runtime-degraded-state.js";
import type { TaskAuditFinding } from "../tasks/task-registry.audit.js";
import type { TaskRecord, TaskRegistrySummary } from "../tasks/task-registry.types.js";
import { normalizeSessionDeliveryState } from "../utils/delivery-context.shared.js";
@@ -219,6 +223,7 @@ describe("getStatusSummary", () => {
beforeEach(() => {
vi.clearAllMocks();
setActiveDegradedPlugins([]);
clearActiveCredentialDegradedOwner("account", "telegram:work");
setActiveDegradedSecretOwners([]);
statusSummaryMocks.taskRegistrySummary = {
total: 0,
@@ -406,28 +411,44 @@ describe("getStatusSummary", () => {
});
});
it("reports degraded SecretRef owners without exposing ref identifiers", async () => {
it("reports stale snapshot and cold credential owners without exposing ref identifiers", async () => {
setActiveDegradedSecretOwners([
{
ownerKind: "account",
ownerId: "discord:ops",
ownerKind: "provider",
ownerId: "openai",
state: "unavailable",
degradationState: "cold",
paths: ["channels.discord.accounts.ops.token"],
degradationState: "stale",
paths: ["models.providers.openai.apiKey"],
refKeys: ["env:default:PRIVATE_REF_ID"],
reason: "provider SecretRef is unresolved (env:default:PRIVATE_REF_ID)",
},
]);
setActiveCredentialDegradedOwner({
ownerKind: "account",
ownerId: "telegram:work",
state: "unavailable",
paths: ["channels.telegram.accounts.work.tokenFile"],
refKeys: [],
reason: "credential failure includes PRIVATE_REF_ID",
});
const summary = await getStatusSummary();
expect(summary.degradedSecretOwners).toEqual([
{
ownerKind: "provider",
ownerId: "openai",
state: "unavailable",
degradationState: "stale",
paths: ["models.providers.openai.apiKey"],
reason: "secret resolution failed",
},
{
ownerKind: "account",
ownerId: "discord:ops",
ownerId: "telegram:work",
state: "unavailable",
degradationState: "cold",
paths: ["channels.discord.accounts.ops.token"],
paths: ["channels.telegram.accounts.work.tokenFile"],
reason: "secret resolution failed",
},
]);
+113 -33
View File
@@ -33,6 +33,10 @@ import {
claimAgentRunDelegatedAuthority,
releaseAgentRunDelegatedAuthority,
} from "../infra/agent-run-registry.js";
import {
setActiveCredentialDegradedOwner,
type DegradedSecretOwner,
} from "../secrets/runtime-degraded-state.js";
import {
activateSecretsRuntimeSnapshot,
clearSecretsRuntimeSnapshot,
@@ -47,6 +51,10 @@ import {
import { createAgentRuntimeApprovalAuthorityValidator } from "./agent-runtime-identity-token.js";
import type { GatewayReloadPlan } from "./config-reload.js";
import { createGatewayAuxHandlers } from "./server-aux-handlers.js";
import {
registerGatewaySecretCredentialReloadCases,
type CredentialReloadHarnessOptions,
} from "./server-secrets-reload.test-support.js";
import { enforceSharedGatewaySessionGenerationForConfigWrite } from "./server-shared-auth-generation.js";
import { createWorkerSessionPlacementStore } from "./worker-environments/placement-store.js";
@@ -185,7 +193,8 @@ async function invokeSecretStoreSet(params: {
type RespondCall = [boolean, unknown, { message?: string } | undefined];
type GatewayAuxHandlerParams = Parameters<typeof createGatewayAuxHandlers>[0];
type ChannelName = Parameters<GatewayAuxHandlerParams["startChannel"]>[0];
type GatewayChannelManager = GatewayAuxHandlerParams["channelManager"];
type ChannelName = Parameters<GatewayChannelManager["startChannel"]>[0];
function firstRespondCall(respond: ReturnType<typeof vi.fn>): RespondCall {
const call = respond.mock.calls[0];
@@ -208,8 +217,10 @@ type SecretsReloadHarnessParams = {
sharedGatewaySessionGenerationState?: GatewayAuxHandlerParams["sharedGatewaySessionGenerationState"];
resolveSharedGatewaySessionGenerationForConfig?: GatewayAuxHandlerParams["resolveSharedGatewaySessionGenerationForConfig"];
clients?: GatewayAuxHandlerParams["clients"];
startChannel?: GatewayAuxHandlerParams["startChannel"];
stopChannel?: GatewayAuxHandlerParams["stopChannel"];
startChannel?: GatewayChannelManager["startChannel"];
stopChannel?: GatewayChannelManager["stopChannel"];
isManuallyStopped?: (channel: ChannelName, accountId: string) => boolean;
resolveRuntimeAccountId?: (channel: ChannelName, accountId: string) => string | undefined;
getChannelAutostartSuppression?: GatewayAuxHandlerParams["getChannelAutostartSuppression"];
logChannelsInfo?: GatewayAuxHandlerParams["logChannels"]["info"];
respond?: ReturnType<typeof vi.fn>;
@@ -232,8 +243,13 @@ function createSecretsReloadHarness(params: SecretsReloadHarnessParams) {
resolveSharedGatewaySessionGenerationForConfig:
params.resolveSharedGatewaySessionGenerationForConfig ?? (() => undefined),
clients: params.clients ?? [],
startChannel: params.startChannel ?? (async () => {}),
stopChannel: params.stopChannel ?? (async () => {}),
channelManager: {
startChannel: params.startChannel ?? (async () => {}),
stopChannel: params.stopChannel ?? (async () => {}),
isManuallyStopped: params.isManuallyStopped ?? (() => false),
resolveRuntimeAccountId:
params.resolveRuntimeAccountId ?? ((_channel, accountId) => accountId),
},
getChannelAutostartSuppression: params.getChannelAutostartSuppression,
logChannels: { info: params.logChannelsInfo ?? vi.fn() },
onApprovalLifecycle: params.onApprovalLifecycle,
@@ -267,6 +283,42 @@ function createSecretsReloadHarnessWithChannelMocks(
};
}
function createCredentialReloadHarness(options: CredentialReloadHarnessOptions = {}) {
const ownerAccountId = options.ownerAccountId ?? "ops";
const owner: DegradedSecretOwner = {
ownerKind: "account",
ownerId: `slack:${ownerAccountId}`,
state: "unavailable",
paths: ["env.SERVICE_ACCOUNT_FILE"],
refKeys: [],
reason: "credential file is unavailable",
};
const config = slackConfig("unchanged-secret");
activateSnapshot(config);
setActiveCredentialDegradedOwner(owner);
const startChannel = vi.fn().mockImplementation(async () => {
if (options.createFailure) {
throw options.createFailure(owner);
}
});
const stopChannel = vi.fn().mockResolvedValue(undefined);
const isManuallyStopped = vi.fn(() => options.manualStop ?? false);
return {
...createSecretsReloadHarness({
activateRuntimeSecrets: mockResolvedSecrets(config),
buildReloadPlan: () => createReloadPlan(),
startChannel,
stopChannel,
isManuallyStopped,
resolveRuntimeAccountId: () => options.runtimeAccountId ?? ownerAccountId,
}),
owner,
startChannel,
stopChannel,
isManuallyStopped,
};
}
// Other gateway test helpers (e.g. test-helpers.mocks.ts, test-helpers.server.ts)
// set OPENCLAW_SKIP_CHANNELS / OPENCLAW_SKIP_PROVIDERS at module load. When a
// shared vitest worker imports those helpers before this file's tests run,
@@ -551,18 +603,13 @@ describe("gateway aux handlers", () => {
expect(respond).toHaveBeenCalledWith(true, { ok: true, warningCount: 0 });
});
it("restarts the whole channel when a secret change is scoped to one account", async () => {
// secrets.reload has no per-account restart path — account-scoped plan
// entries must still produce a channel restart so rotated credentials
// are applied.
it("restarts only the changed account when a secret change is account-scoped", async () => {
const buildReloadPlan = () =>
createReloadPlan({
restartChannels: new Set(),
restartChannelAccounts: new Map([["slack", new Set(["ops"])]]),
});
activateSnapshot(slackConfig("old-slack-secret"));
const prepared = createSnapshot(slackConfig("new-slack-secret"));
const activateRuntimeSecrets = vi.fn().mockResolvedValue(prepared);
const activateRuntimeSecrets = mockResolvedSecrets(slackConfig("new-slack-secret"));
const { reload, respond, startChannel, stopChannel } =
createSecretsReloadHarnessWithChannelMocks({
activateRuntimeSecrets,
@@ -571,11 +618,30 @@ describe("gateway aux handlers", () => {
await reload();
expect(stopChannel.mock.calls.map(([ch]) => ch)).toEqual(["slack"]);
expect(startChannel.mock.calls.map(([ch]) => ch)).toEqual(["slack"]);
expect(stopChannel.mock.calls).toEqual([["slack", "ops", { manual: false }]]);
expect(startChannel.mock.calls).toEqual([["slack", "ops", { preserveManualStop: true }]]);
expect(respond).toHaveBeenCalledWith(true, { ok: true, warningCount: 0 });
});
registerGatewaySecretCredentialReloadCases(createCredentialReloadHarness);
it("does not restart account targets already covered by a whole-channel target", async () => {
activateSnapshot(slackConfig("old-secret"));
const { reload, startChannel, stopChannel } = createSecretsReloadHarnessWithChannelMocks({
activateRuntimeSecrets: mockResolvedSecrets(slackConfig("new-secret")),
buildReloadPlan: () =>
createReloadPlan({
restartChannels: new Set(["slack"]),
restartChannelAccounts: new Map([["slack", new Set(["ops"])]]),
}),
});
await reload();
expect(stopChannel.mock.calls).toEqual([["slack"]]);
expect(startChannel.mock.calls).toEqual([["slack"]]);
});
it("coalesces concurrent secrets.reload calls so channels are not restarted twice", async () => {
const buildReloadPlan = buildRestartChannelsPlan("slack");
activateSnapshot(slackConfig("old-slack-secret"));
@@ -731,9 +797,15 @@ describe("gateway aux handlers", () => {
expect(firstRespondCall(respond)[0]).toBe(true);
});
it("rolls back stopped channels when a later restart fails", async () => {
it("rolls back only exact stopped accounts when a later account restart fails", async () => {
const authAgentDir = "/tmp/openclaw-secrets-reload-concurrent-oauth";
const buildReloadPlan = buildRestartChannelsPlan("slack", "zalo");
const buildReloadPlan = () =>
createReloadPlan({
restartChannelAccounts: new Map([
["slack", new Set(["ops"])],
["zalo", new Set(["alerts"])],
]),
});
activateSnapshot(slackZaloConfig("old-slack-secret", "old-zalo-secret"));
const activateRuntimeSecrets = mockResolvedSecrets(
slackZaloConfig("new-slack-secret", "new-zalo-secret"),
@@ -779,21 +851,30 @@ describe("gateway aux handlers", () => {
await reload();
expect(stopChannel.mock.calls).toEqual([["slack"], ["zalo"], ["slack"]]);
expect(startChannel.mock.calls).toEqual([["slack"], ["zalo"], ["slack"], ["zalo"]]);
expect(stopChannel.mock.calls).toEqual([
["slack", "ops", { manual: false }],
["zalo", "alerts", { manual: false }],
["slack", "ops", { manual: false }],
]);
expect(startChannel.mock.calls).toEqual([
["slack", "ops", { preserveManualStop: true }],
["zalo", "alerts", { preserveManualStop: true }],
["slack", "ops", { preserveManualStop: true }],
["zalo", "alerts", { preserveManualStop: true }],
]);
expect(
logChannelsInfo.mock.calls.some(([msg]) =>
String(msg).startsWith("failed to restart zalo channel after secrets reload"),
String(msg).startsWith("failed to restart zalo account alerts after secrets reload"),
),
).toBe(true);
expect(
logChannelsInfo.mock.calls.some(([msg]) =>
String(msg).startsWith("rolling back slack channel after secrets reload failure"),
String(msg).startsWith("rolling back slack account ops after secrets reload failure"),
),
).toBe(true);
expect(
logChannelsInfo.mock.calls.some(([msg]) =>
String(msg).startsWith("rolling back zalo channel after secrets reload failure"),
String(msg).startsWith("rolling back zalo account alerts after secrets reload failure"),
),
).toBe(true);
// The handler surfaces the partial-failure so the caller can retry/alert
@@ -815,19 +896,13 @@ describe("gateway aux handlers", () => {
).toMatchObject({ access: "access-new", refresh: "refresh-new" });
});
it("does not roll back over a snapshot published after secrets.reload activation", async () => {
const buildReloadPlan = buildRestartChannelsPlan("slack");
it("fences account-scoped rollback when a newer snapshot and generation supersede reload", async () => {
const buildReloadPlan = () =>
createReloadPlan({ restartChannelAccounts: new Map([["slack", new Set(["ops"])]]) });
activateSnapshot(slackConfig("old-slack-secret"));
const prepared = createSnapshot(slackConfig("reload-secret"));
const concurrent = createSnapshot(slackConfig("concurrent-secret"));
const activateRuntimeSecrets = vi.fn(
async (
_config: OpenClawConfig,
_activationParams: Parameters<GatewayAuxHandlerParams["activateRuntimeSecrets"]>[1],
) => {
return prepared;
},
);
const activateRuntimeSecrets = vi.fn(async () => prepared);
const sharedGatewaySessionGenerationState = {
current: "gen-old" as string | undefined,
required: "gen-old" as string | undefined | null,
@@ -841,19 +916,24 @@ describe("gateway aux handlers", () => {
})
.mockResolvedValue(undefined);
const stopChannel = vi.fn().mockResolvedValue(undefined);
const { reload, respond } = createSecretsReloadHarness({
activateRuntimeSecrets,
buildReloadPlan,
sharedGatewaySessionGenerationState,
resolveSharedGatewaySessionGenerationForConfig: () => "gen-reload",
startChannel,
stopChannel: vi.fn().mockResolvedValue(undefined),
stopChannel,
});
await reload();
expect(firstRespondCall(respond)[0]).toBe(false);
expect(startChannel).toHaveBeenCalledTimes(2);
expect(stopChannel.mock.calls).toEqual([["slack", "ops", { manual: false }]]);
expect(startChannel.mock.calls).toEqual([
["slack", "ops", { preserveManualStop: true }],
["slack", "ops", { preserveManualStop: true }],
]);
expect(getActiveSecretsRuntimeSnapshot()?.config).toEqual(slackConfig("concurrent-secret"));
expect(sharedGatewaySessionGenerationState).toEqual({
current: "gen-concurrent",
+16 -377
View File
@@ -1,13 +1,11 @@
// Gateway auxiliary method handlers.
// Wires reload, secrets, exec approval, and plugin approval RPC handlers.
import { randomUUID } from "node:crypto";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
type AgentRunDelegatedAuthority,
registerAgentRunDelegatedAuthorityClosedHandler,
} from "../infra/agent-run-registry.js";
import type { ChannelApprovalKind } from "../infra/approval-types.js";
import { isTruthyEnvValue } from "../infra/env.js";
import { createExecApprovalForwarder } from "../infra/exec-approval-forwarder.js";
import {
type ExecApprovalDecision,
@@ -24,21 +22,10 @@ import {
resolveCommandSecretsFromActiveRuntimeSnapshot,
type CommandSecretAssignment,
} from "../secrets/runtime-command-secrets.js";
import {
getActiveSecretsRuntimeSnapshotState,
getActiveSecretsRuntimeSnapshotRevisionState,
type PreparedSecretsRuntimeSnapshot,
} from "../secrets/runtime-state.js";
import { createLazyPromise } from "../shared/lazy-runtime.js";
import type { AgentRuntimeDelegatedAuthority } from "./agent-runtime-identity-token.js";
import { resolveApprovalSessionAudienceWithFallback } from "./approval-session-audience.js";
import type { ChatAbortControllerEntry } from "./chat-abort.js";
import { diffConfigPaths } from "./config-diff.js";
import {
buildGatewayReloadPlan,
type ChannelKind,
type GatewayReloadPlan,
} from "./config-reload-plan.js";
import {
createExecApprovalIosPushDelivery,
createPluginApprovalIosPushDelivery,
@@ -53,7 +40,6 @@ import {
pruneTerminalOperatorApprovals,
} from "./operator-approval-store.js";
import { QuestionManager } from "./question-manager.js";
import type { ChannelAutostartSuppression } from "./server-channels.js";
import { publishAppliedApprovalResolution } from "./server-methods/approval-publication.js";
import {
cancelAgentRuntimeBoundApprovals,
@@ -62,17 +48,9 @@ import {
} from "./server-methods/approval-run-cancellation.js";
import type { GatewayRequestContext } from "./server-methods/types.js";
import {
captureSharedGatewaySessionGenerationOwnership,
claimSharedGatewaySessionGenerationIfOwned,
disconnectStaleSharedGatewayAuthClients,
finalizeOwnedSharedGatewaySessionGeneration,
isSharedGatewaySessionGenerationOwnershipCurrent,
replaceOwnedSharedGatewaySessionGenerationState,
type SharedGatewayAuthClient,
type SharedGatewaySessionGenerationOwnership,
type SharedGatewaySessionGenerationState,
} from "./server-shared-auth-generation.js";
import type { ActivateRuntimeSecrets } from "./server-startup-config.js";
createGatewaySecretsReloader,
type GatewaySecretsReloaderParams,
} from "./server-secrets-reload.js";
import type { WorkerSessionTurnClaim } from "./worker-environments/placement-record.js";
type GatewayAuxHandlerLogger = {
@@ -81,63 +59,19 @@ type GatewayAuxHandlerLogger = {
debug?: (message: string) => void;
};
type ReloadSecretsResult = {
warningCount: number;
};
async function activateSecretsRuntimeSnapshotIfCurrent(
snapshot: PreparedSecretsRuntimeSnapshot,
expectedRevision: number,
options?: {
canActivate?: () => boolean;
onActivated?: () => void;
},
): Promise<number | null> {
const runtime = await import("../secrets/runtime.js");
if (options?.canActivate && !options.canActivate()) {
return null;
}
if (!runtime.activateSecretsRuntimeSnapshotIfCurrent(snapshot, expectedRevision)) {
return null;
}
options?.onActivated?.();
return runtime.getActiveSecretsRuntimeSnapshotRevision();
}
async function restoreSecretsRuntimeSnapshotIfCurrent(
snapshot: PreparedSecretsRuntimeSnapshot,
expectedRevision: number,
ownedSnapshot: PreparedSecretsRuntimeSnapshot,
options?: { onActivated?: () => void },
): Promise<number | null> {
const runtime = await import("../secrets/runtime.js");
if (!runtime.restoreSecretsRuntimeSnapshotIfCurrent(snapshot, expectedRevision, ownedSnapshot)) {
return null;
}
options?.onActivated?.();
return runtime.getActiveSecretsRuntimeSnapshotRevision();
}
/** Create auxiliary gateway handlers that are not part of the core descriptor set. */
export function createGatewayAuxHandlers(params: {
log: GatewayAuxHandlerLogger;
activateRuntimeSecrets: ActivateRuntimeSecrets;
buildReloadPlan?: (changedPaths: string[]) => GatewayReloadPlan;
sharedGatewaySessionGenerationState: SharedGatewaySessionGenerationState;
resolveSharedGatewaySessionGenerationForConfig: (config: OpenClawConfig) => string | undefined;
clients: Iterable<SharedGatewayAuthClient>;
startChannel: (name: ChannelKind) => Promise<void>;
stopChannel: (name: ChannelKind) => Promise<void>;
getChannelAutostartSuppression?: () => ChannelAutostartSuppression | null;
logChannels: { info: (msg: string) => void };
onApprovalLifecycle?: (event: OperatorApprovalLifecycleEvent) => void;
onAgentRunAuthorityClosed?: (authority: AgentRunDelegatedAuthority) => void;
validateAgentRuntimeDelegatedAuthority?: (authority: AgentRuntimeDelegatedAuthority) => boolean;
chatAbortControllers?: Map<string, ChatAbortControllerEntry>;
registerWorkerTurnClaimClosedHandler?: (
handler: (claim: WorkerSessionTurnClaim) => void,
) => () => void;
}) {
export function createGatewayAuxHandlers(
params: GatewaySecretsReloaderParams & {
log: GatewayAuxHandlerLogger;
onApprovalLifecycle?: (event: OperatorApprovalLifecycleEvent) => void;
onAgentRunAuthorityClosed?: (authority: AgentRunDelegatedAuthority) => void;
validateAgentRuntimeDelegatedAuthority?: (authority: AgentRuntimeDelegatedAuthority) => boolean;
chatAbortControllers?: Map<string, ChatAbortControllerEntry>;
registerWorkerTurnClaimClosedHandler?: (
handler: (claim: WorkerSessionTurnClaim) => void,
) => () => void;
},
) {
// Both approval kinds share one durable first-answer-wins registry and
// Gateway-lifetime epoch while retaining separate in-process waiter maps.
// A newly constructed Gateway cannot resume the prior lifetime's waiters.
@@ -198,7 +132,6 @@ export function createGatewayAuxHandlers(params: {
),
{ cacheRejections: true },
);
const buildReloadPlan = params.buildReloadPlan ?? buildGatewayReloadPlan;
const pluginApprovalManager = createApprovalManager<PluginApprovalRequestPayload>(
"plugin",
resolveCanonicalPluginApprovalRequestAllowedDecisions,
@@ -344,305 +277,11 @@ export function createGatewayAuxHandlers(params: {
),
{ cacheRejections: true },
);
// Serialize the entire `secrets.reload` path (activation + channel restart)
// so concurrent callers cannot overlap the stop/start loop and so the
// "before" snapshot used for the reload-plan diff is always the snapshot
// replaced by this call's activation, not one captured by a prior caller.
let reloadInFlight: Promise<ReloadSecretsResult> | null = null;
const runExclusiveReload = (
fn: () => Promise<ReloadSecretsResult>,
options: { joinInFlight?: boolean } = {},
): Promise<ReloadSecretsResult> => {
if (reloadInFlight) {
if (options.joinInFlight !== false) {
return reloadInFlight;
}
const precedingReload = reloadInFlight;
return precedingReload.catch(() => undefined).then(() => runExclusiveReload(fn, options));
}
const run = (async () => {
try {
return await fn();
} finally {
reloadInFlight = null;
}
})();
reloadInFlight = run;
return run;
};
const loadSecretsHandlers = createLazyPromise(
() =>
import("./server-methods/secrets.js").then(({ createSecretsHandlers }) =>
createSecretsHandlers({
reloadSecrets: (reloadOptions) =>
runExclusiveReload(async () => {
let transaction:
| {
previousSnapshot: PreparedSecretsRuntimeSnapshot;
previousSharedGatewaySessionGeneration: string | undefined;
previousSharedGatewaySessionGenerationRequired: string | undefined | null;
prepared: PreparedSecretsRuntimeSnapshot;
plan: GatewayReloadPlan;
nextSharedGatewaySessionGeneration: string | undefined;
sharedGatewaySessionGenerationChanged: boolean;
generationOwnership: SharedGatewaySessionGenerationOwnership;
publishedSnapshotRevision: number;
}
| undefined;
const stoppedChannels: ChannelKind[] = [];
const restartedChannels = new Set<ChannelKind>();
try {
for (;;) {
const previousSnapshot = getActiveSecretsRuntimeSnapshotState();
if (!previousSnapshot) {
throw new Error("Secrets runtime snapshot is not active.");
}
const previousSnapshotRevision = getActiveSecretsRuntimeSnapshotRevisionState();
const previousGenerationOwnership =
captureSharedGatewaySessionGenerationOwnership(
params.sharedGatewaySessionGenerationState,
);
// Snapshot both generation fields with the candidate revision.
// A stale preparation retries all three owners together.
const previousSharedGatewaySessionGeneration =
previousGenerationOwnership.generation;
const previousSharedGatewaySessionGenerationRequired =
params.sharedGatewaySessionGenerationState.required;
const prepared = await params.activateRuntimeSecrets(
previousSnapshot.sourceConfig,
{
reason: "reload",
activate: false,
publishFailureAsDegraded: true,
forceColdRefKeys: reloadOptions?.forceColdRefKeys,
canPublishFailureAsDegraded: () =>
getActiveSecretsRuntimeSnapshotRevisionState() === previousSnapshotRevision,
},
);
const plan = buildReloadPlan(
diffConfigPaths(previousSnapshot.config, prepared.config),
);
const nextSharedGatewaySessionGeneration =
params.resolveSharedGatewaySessionGenerationForConfig(prepared.config);
let publishedSnapshotRevision: number | null = null;
let generationOwnership: SharedGatewaySessionGenerationOwnership | null = null;
const activateIfCurrent =
params.activateRuntimeSecrets.activatePreparedSnapshotIfCurrent;
if (activateIfCurrent) {
const activated = await activateIfCurrent(
prepared,
previousSnapshotRevision,
{
reason: "reload",
activate: true,
},
async () => {
publishedSnapshotRevision = getActiveSecretsRuntimeSnapshotRevisionState();
generationOwnership = claimSharedGatewaySessionGenerationIfOwned(
params.sharedGatewaySessionGenerationState,
previousGenerationOwnership,
nextSharedGatewaySessionGeneration,
);
},
() =>
isSharedGatewaySessionGenerationOwnershipCurrent(
params.sharedGatewaySessionGenerationState,
previousGenerationOwnership,
),
);
if (!activated) {
continue;
}
} else {
publishedSnapshotRevision = await activateSecretsRuntimeSnapshotIfCurrent(
prepared,
previousSnapshotRevision,
{
canActivate: () =>
isSharedGatewaySessionGenerationOwnershipCurrent(
params.sharedGatewaySessionGenerationState,
previousGenerationOwnership,
),
onActivated: () => {
generationOwnership = claimSharedGatewaySessionGenerationIfOwned(
params.sharedGatewaySessionGenerationState,
previousGenerationOwnership,
nextSharedGatewaySessionGeneration,
);
},
},
);
if (publishedSnapshotRevision === null) {
continue;
}
}
if (publishedSnapshotRevision === null || generationOwnership === null) {
throw new Error("Secrets runtime activation did not publish ownership.");
}
transaction = {
previousSnapshot,
previousSharedGatewaySessionGeneration,
previousSharedGatewaySessionGenerationRequired,
prepared,
plan,
nextSharedGatewaySessionGeneration,
sharedGatewaySessionGenerationChanged:
previousSharedGatewaySessionGeneration !== nextSharedGatewaySessionGeneration,
generationOwnership,
publishedSnapshotRevision,
};
if (
!isSharedGatewaySessionGenerationOwnershipCurrent(
params.sharedGatewaySessionGenerationState,
generationOwnership,
)
) {
throw new Error("secrets.reload was superseded by a newer config write");
}
break;
}
const {
prepared,
plan,
generationOwnership,
nextSharedGatewaySessionGeneration,
sharedGatewaySessionGenerationChanged,
} = transaction;
if (sharedGatewaySessionGenerationChanged) {
disconnectStaleSharedGatewayAuthClients({
clients: params.clients,
expectedGeneration: nextSharedGatewaySessionGeneration,
});
}
// Account-scoped changes restart their whole channel here:
// secrets.reload has no per-account restart path, and a missed
// restart would leave rotated credentials unapplied.
const channelsToRestart = new Set<ChannelKind>([
...plan.restartChannels,
...(plan.restartChannelAccounts?.keys() ?? []),
]);
if (channelsToRestart.size > 0) {
const restartChannels = [...channelsToRestart];
if (
isTruthyEnvValue(process.env.OPENCLAW_SKIP_CHANNELS) ||
isTruthyEnvValue(process.env.OPENCLAW_SKIP_PROVIDERS)
) {
throw new Error(
`secrets.reload requires restarting channels: ${restartChannels.join(", ")}`,
);
}
if (params.getChannelAutostartSuppression?.()) {
throw new Error(
`secrets.reload requires restarting channels but channel autostart is suppressed by crash-loop breaker: ${restartChannels.join(", ")}`,
);
}
const restartFailures: ChannelKind[] = [];
for (const channel of restartChannels) {
if (
!isSharedGatewaySessionGenerationOwnershipCurrent(
params.sharedGatewaySessionGenerationState,
generationOwnership,
)
) {
throw new Error("secrets.reload was superseded by a newer config write");
}
params.logChannels.info(`restarting ${channel} channel after secrets reload`);
// Track for rollback before awaiting stopChannel: if stopChannel
// throws after partially stopping the channel, still attempt recovery.
stoppedChannels.push(channel);
try {
await params.stopChannel(channel);
if (
!isSharedGatewaySessionGenerationOwnershipCurrent(
params.sharedGatewaySessionGenerationState,
generationOwnership,
)
) {
throw new Error("secrets.reload was superseded by a newer config write");
}
await params.startChannel(channel);
restartedChannels.add(channel);
if (
!isSharedGatewaySessionGenerationOwnershipCurrent(
params.sharedGatewaySessionGenerationState,
generationOwnership,
)
) {
throw new Error("secrets.reload was superseded by a newer config write");
}
} catch {
params.logChannels.info(
`failed to restart ${channel} channel after secrets reload`,
);
restartFailures.push(channel);
}
}
if (restartFailures.length > 0) {
throw new Error(
`failed to restart channels after secrets reload: ${restartFailures.join(", ")}`,
);
}
}
if (
!finalizeOwnedSharedGatewaySessionGeneration(
params.sharedGatewaySessionGenerationState,
generationOwnership,
)
) {
throw new Error("secrets.reload was superseded by a newer config write");
}
return { warningCount: prepared.warnings.length };
} catch (err) {
let generationRestored = false;
if (transaction) {
const failedTransaction = transaction;
await restoreSecretsRuntimeSnapshotIfCurrent(
failedTransaction.previousSnapshot,
failedTransaction.publishedSnapshotRevision,
failedTransaction.prepared,
{
onActivated: () => {
generationRestored = replaceOwnedSharedGatewaySessionGenerationState(
params.sharedGatewaySessionGenerationState,
failedTransaction.generationOwnership,
{
current: failedTransaction.previousSharedGatewaySessionGeneration,
required:
failedTransaction.previousSharedGatewaySessionGenerationRequired,
},
);
},
},
);
}
if (generationRestored && transaction) {
if (transaction.sharedGatewaySessionGenerationChanged) {
disconnectStaleSharedGatewayAuthClients({
clients: params.clients,
expectedGeneration: transaction.previousSharedGatewaySessionGeneration,
});
}
}
// Generation ownership fences state rollback, not liveness.
// Restart stopped channels against whichever runtime is current now.
for (const channel of stoppedChannels) {
params.logChannels.info(
`rolling back ${channel} channel after secrets reload failure`,
);
try {
if (restartedChannels.has(channel)) {
await params.stopChannel(channel);
}
await params.startChannel(channel);
} catch {
params.logChannels.info(
`failed to roll back ${channel} channel after secrets reload`,
);
}
}
throw err;
}
}, reloadOptions),
reloadSecrets: createGatewaySecretsReloader(params),
log: params.log,
resolveSecrets: async ({
allowedPaths,
+121 -24
View File
@@ -1,8 +1,11 @@
/**
* Server channel lifecycle tests.
*/
import fs from "node:fs";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createDeferred } from "../../test/helpers/promise.js";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import { ChannelIngressUnavailableError } from "../channels/message/ingress-unavailable.js";
import type {
ChannelAccountLinkState,
@@ -15,6 +18,7 @@ import type {
} from "../channels/plugins/types.public.js";
import { formatGatewayChannelsStatusLines } from "../commands/channels/status.runtime.js";
import type { GatewayNativeApprovalRuntime } from "../infra/approval-gateway-runtime.types.js";
import { tryReadSecretFileSync } from "../infra/secret-file.js";
import {
createSubsystemLogger,
type SubsystemLogger,
@@ -34,6 +38,7 @@ import {
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";
import {
clearActiveCredentialDegradedOwner,
listActiveDegradedSecretOwners,
setActiveDegradedSecretOwners,
} from "../secrets/runtime-degraded-state.js";
@@ -102,6 +107,7 @@ const CHANNEL_APPROVAL_GATEWAY_RUNTIME_CONTEXT_CAPABILITY = "approval.gateway";
type ApprovalGatewayRequestRuntime = Pick<GatewayNativeApprovalRuntime, "request">;
const createdManagers: Array<{ manager: ChannelManager; channelIds: ChannelId[] }> = [];
const channelTempDirs = useAutoCleanupTempDirTracker(afterEach);
function healthOf(account: ChannelAccountSnapshot | undefined) {
return evaluateChannelHealth(account ?? {}, {
@@ -315,6 +321,9 @@ describe("server-channels auto restart", () => {
hoisted.sleepWithAbort.mockClear();
hoisted.startChannelApprovalHandlerBootstrap.mockReset();
hoisted.startChannelApprovalHandlerBootstrap.mockResolvedValue(async () => {});
for (const owner of listActiveDegradedSecretOwners()) {
clearActiveCredentialDegradedOwner(owner.ownerKind, owner.ownerId);
}
setActiveDegradedSecretOwners([]);
});
@@ -330,6 +339,9 @@ describe("server-channels auto restart", () => {
vi.clearAllTimers();
vi.useRealTimers();
resetGatewayWorkAdmission();
for (const owner of listActiveDegradedSecretOwners()) {
clearActiveCredentialDegradedOwner(owner.ownerKind, owner.ownerId);
}
setActiveDegradedSecretOwners([]);
setActivePluginRegistry(previousRegistry ?? createEmptyPluginRegistry());
});
@@ -2724,7 +2736,8 @@ describe("server-channels auto restart", () => {
});
it("keeps one file-credential account cold and recovers it without restarting siblings", async () => {
let broken = true;
const credentialPath = path.join(channelTempDirs.make("openclaw-channel-credential-"), "token");
const credentialConfigPath = "channels.telegram.accounts.broken.tokenFile";
const startAccount = vi.fn(
async ({ abortSignal }: ChannelGatewayContext<TestAccount>) =>
await new Promise<void>((resolve) => {
@@ -2733,56 +2746,70 @@ describe("server-channels auto restart", () => {
);
installTestRegistry(
createTestPlugin({
id: "discord",
id: "telegram",
listAccountIds: () => ["broken", "healthy"],
resolveAccount: (_cfg, accountId) => ({
enabled: true,
configured: true,
...(accountId === "broken" && broken
? {
credentialDiagnostics: [
resolveAccount: (_cfg, accountId) => {
const credential =
accountId === "broken"
? tryReadSecretFileSync(
credentialPath,
"Telegram bot token",
{},
{
code: "CREDENTIAL_FILE_UNAVAILABLE" as const,
path: "channels.discord.accounts.broken.tokenFile",
reason: "not-found",
configPath: credentialConfigPath,
},
],
}
: {}),
}),
)
: { status: "available" as const, value: "healthy-token" };
return {
enabled: true,
configured: true,
...(credential.status === "configured_unavailable"
? { credentialDiagnostics: [credential.diagnostic] }
: {}),
};
},
startAccount,
}),
);
const manager = createManager({ channelIds: ["discord"] });
const manager = createManager({ channelIds: ["telegram"] });
await expect(manager.startChannels()).resolves.toBeUndefined();
expect(startAccount.mock.calls.map(([context]) => context.accountId)).toEqual(["healthy"]);
expect(manager.getRuntimeSnapshot().channelAccounts.discord?.broken).toMatchObject({
expect(manager.getRuntimeSnapshot().channelAccounts.telegram?.broken).toMatchObject({
configured: true,
running: false,
lastError:
"Secret owner account:discord:broken is configured but unavailable (credential file is unavailable).",
"Secret owner account:telegram:broken is configured but unavailable (credential file is unavailable).",
});
expect(listActiveDegradedSecretOwners()).toContainEqual(
expect.objectContaining({
ownerId: "discord:broken",
paths: ["channels.discord.accounts.broken.tokenFile"],
ownerId: "telegram:broken",
paths: [credentialConfigPath],
refKeys: [],
}),
);
broken = false;
await manager.startChannel("discord", "broken");
await expect(manager.startChannel("telegram", "broken")).rejects.toMatchObject({
code: "SECRET_SURFACE_UNAVAILABLE",
ownerId: "telegram:broken",
});
expect(startAccount.mock.calls.map(([context]) => context.accountId)).toEqual(["healthy"]);
expect(listActiveDegradedSecretOwners()).toContainEqual(
expect.objectContaining({ ownerId: "telegram:broken" }),
);
fs.writeFileSync(credentialPath, "repaired-token", { mode: 0o600 });
await manager.startChannel("telegram", "broken");
expect(startAccount.mock.calls.map(([context]) => context.accountId)).toEqual([
"healthy",
"broken",
]);
expect(listActiveDegradedSecretOwners()).not.toContainEqual(
expect.objectContaining({ ownerId: "discord:broken" }),
expect.objectContaining({ ownerId: "telegram:broken" }),
);
await manager.stopChannel("discord");
await manager.stopChannel("telegram");
});
it("uses fallback logger and runtime when a channel is missing startup wiring", async () => {
@@ -3023,6 +3050,76 @@ describe("server-channels auto restart", () => {
await manager.stopChannel("discord");
});
it("retires only the credential owner for an evicted channel account", async () => {
let accountIds = ["removed", "retained"];
installTestRegistry(
createTestPlugin({
listAccountIds: () => accountIds,
startAccount: async () => {},
resolveAccount: (_cfg, accountId) => ({
enabled: true,
configured: true,
credentialDiagnostics: [
{
code: "CREDENTIAL_FILE_UNAVAILABLE" as const,
path: `channels.discord.accounts.${accountId}.tokenFile`,
reason: "not-found",
},
],
}),
}),
);
const manager = createManager();
await manager.startChannels();
expect(listActiveDegradedSecretOwners().map((owner) => owner.ownerId)).toEqual([
"discord:removed",
"discord:retained",
]);
accountIds = ["retained"];
await expect(manager.startChannel("discord")).rejects.toMatchObject({
ownerId: "discord:retained",
});
expect(listActiveDegradedSecretOwners().map((owner) => owner.ownerId)).toEqual([
"discord:retained",
]);
});
it("resolves only an unambiguous authoritative runtime account for a normalized owner", async () => {
let accountIds = ["Ops Team"];
installTestRegistry(
createTestPlugin({
id: "line",
listAccountIds: () => accountIds,
startAccount: async () => {},
resolveAccount: (_cfg, accountId) => ({
enabled: true,
configured: true,
credentialDiagnostics: [
{
code: "CREDENTIAL_FILE_UNAVAILABLE" as const,
path: `channels.line.accounts.${accountId}.channelAccessTokenFile`,
reason: "not-found",
},
],
}),
}),
);
const manager = createManager({ channelIds: ["line"] });
await manager.startChannels();
expect(manager.resolveRuntimeAccountId("line", "ops-team")).toBe("Ops Team");
expect(manager.resolveRuntimeAccountId("line", "missing")).toBeUndefined();
accountIds = ["Ops Team", "ops-team"];
await manager.startChannels();
expect(manager.resolveRuntimeAccountId("line", "ops-team")).toBeUndefined();
});
it("reuses plugin account resolution for health monitor overrides", () => {
installTestRegistry(
createTestPlugin({
+10 -1
View File
@@ -283,7 +283,9 @@ export type ChannelManager = {
};
// Channel docking: lifecycle hooks (`plugin.gateway`) flow through this manager.
export function createChannelManager(opts: ChannelManagerOptions): ChannelManager {
export function createChannelManager(opts: ChannelManagerOptions): ChannelManager & {
resolveRuntimeAccountId: (channelId: ChannelId, accountId: string) => string | undefined;
} {
const {
getRuntimeConfig,
channelLogs,
@@ -490,6 +492,7 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
continue;
}
store.runtimes.delete(id);
clearActiveCredentialDegradedOwner("account", restartKey(channelId, normalizeAccountId(id)));
store.pluginCommandCatalogOwners.delete(id);
restarts.delete(restartKey(channelId, id));
manuallyStopped.delete(restartKey(channelId, id));
@@ -1429,6 +1432,12 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
ambientAutostartSuppressedChannelIds.has(channelId),
markChannelLoggedOut,
isManuallyStopped: isManuallyStoppedFlag,
resolveRuntimeAccountId: (channelId, accountId) => {
const matches = [...(channelStores.get(channelId)?.runtimes.keys() ?? [])].filter(
(id) => normalizeAccountId(id) === accountId,
);
return matches.length === 1 ? matches[0] : undefined;
},
isAutoRestartScheduled,
resetRestartAttempts,
isHealthMonitorEnabled,
+1 -4
View File
@@ -127,8 +127,6 @@ export async function startGatewayCoreRuntime(input: {
readinessEventLoopHealth,
workerDispatchAuthority,
clients,
startChannel,
stopChannel,
sharedGatewaySessionGenerationState,
resolveSharedGatewaySessionGenerationForConfig,
sessionMessageSubscribers,
@@ -339,8 +337,7 @@ export async function startGatewayCoreRuntime(input: {
sharedGatewaySessionGenerationState,
resolveSharedGatewaySessionGenerationForConfig,
clients,
startChannel,
stopChannel,
channelManager,
getChannelAutostartSuppression: channelManager.getAutostartSuppression,
logChannels,
registerWorkerTurnClaimClosedHandler: workerEnvironmentStartup?.placementStore
@@ -0,0 +1,103 @@
// Additional credential-owner lifecycle cases registered in the existing auxiliary-handler suite.
import { expect, it, vi } from "vitest";
import {
listActiveDegradedSecretOwners,
SecretSurfaceUnavailableError,
type DegradedSecretOwner,
} from "../secrets/runtime-degraded-state.js";
export type CredentialReloadHarnessOptions = {
ownerAccountId?: string;
runtimeAccountId?: string;
manualStop?: boolean;
createFailure?: (owner: DegradedSecretOwner) => Error;
};
type CredentialReloadHarness = {
owner: DegradedSecretOwner;
reload: () => Promise<void>;
respond: ReturnType<typeof vi.fn>;
startChannel: ReturnType<typeof vi.fn>;
stopChannel: ReturnType<typeof vi.fn>;
isManuallyStopped: ReturnType<typeof vi.fn>;
};
/** Registers focused account recovery cases against the existing RPC owner and shared fixture. */
export function registerGatewaySecretCredentialReloadCases(
createHarness: (options?: CredentialReloadHarnessOptions) => CredentialReloadHarness,
): void {
it("reinspects a previously degraded account without stopping healthy siblings when config is unchanged", async () => {
const { reload, respond, startChannel, stopChannel } = createHarness();
await reload();
expect(stopChannel).not.toHaveBeenCalled();
expect(startChannel.mock.calls).toEqual([["slack", "ops", { preserveManualStop: true }]]);
expect(respond).toHaveBeenCalledWith(true, { ok: true, warningCount: 0 });
});
it("accepts only the same trusted credential owner remaining degraded after reinspection", async () => {
const { owner, reload, respond, startChannel, stopChannel } = createHarness({
createFailure: (degradedOwner) => new SecretSurfaceUnavailableError(degradedOwner),
});
await reload();
expect(stopChannel).not.toHaveBeenCalled();
expect(startChannel.mock.calls).toEqual([["slack", "ops", { preserveManualStop: true }]]);
expect(listActiveDegradedSecretOwners()).toContainEqual(expect.objectContaining(owner));
expect(respond).toHaveBeenCalledWith(true, { ok: true, warningCount: 0 });
});
it.each(["forged", "different-owner"] as const)(
"rejects a %s unavailable error instead of treating account recovery as degraded success",
async (failureKind) => {
const { owner, reload, respond, stopChannel } = createHarness({
createFailure: (degradedOwner) =>
failureKind === "different-owner"
? new SecretSurfaceUnavailableError({ ...degradedOwner, ownerId: "slack:other" })
: Object.setPrototypeOf(
Object.assign(new Error("forged unavailable error"), {
name: "SecretSurfaceUnavailableError",
code: "SECRET_SURFACE_UNAVAILABLE",
ownerKind: "account",
ownerId: degradedOwner.ownerId,
}),
SecretSurfaceUnavailableError.prototype,
),
});
await reload();
expect(respond.mock.calls[0]?.[0]).toBe(false);
expect(stopChannel.mock.calls).toEqual([["slack", "ops", { manual: false }]]);
expect(listActiveDegradedSecretOwners()).toContainEqual(expect.objectContaining(owner));
},
);
it("does not reinspect a degraded account that an operator manually stopped", async () => {
const { reload, respond, startChannel, stopChannel } = createHarness({ manualStop: true });
await reload();
expect(stopChannel).not.toHaveBeenCalled();
expect(startChannel).not.toHaveBeenCalled();
expect(listActiveDegradedSecretOwners()).toContainEqual(
expect.objectContaining({ ownerId: "slack:ops" }),
);
expect(respond).toHaveBeenCalledWith(true, { ok: true, warningCount: 0 });
});
it("recovers the authoritative raw account identity instead of its normalized owner suffix", async () => {
const { reload, startChannel, stopChannel, isManuallyStopped } = createHarness({
ownerAccountId: "ops-team",
runtimeAccountId: "Ops Team",
});
await reload();
expect(isManuallyStopped).toHaveBeenCalledWith("slack", "Ops Team");
expect(stopChannel).not.toHaveBeenCalled();
expect(startChannel.mock.calls).toEqual([["slack", "Ops Team", { preserveManualStop: true }]]);
});
}
+409
View File
@@ -0,0 +1,409 @@
// Owns serialized secrets snapshot replacement and exact channel-account lifecycle recovery.
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { isTruthyEnvValue } from "../infra/env.js";
import {
isTrustedSecretSurfaceUnavailableError,
listActiveCredentialDegradedOwners,
type DegradedSecretOwner,
} from "../secrets/runtime-degraded-state.js";
import {
getActiveSecretsRuntimeSnapshotRevisionState,
getActiveSecretsRuntimeSnapshotState,
type PreparedSecretsRuntimeSnapshot,
} from "../secrets/runtime-state.js";
import { diffConfigPaths } from "./config-diff.js";
import {
buildGatewayReloadPlan,
type ChannelKind,
type GatewayReloadPlan,
} from "./config-reload-plan.js";
import type { ChannelAutostartSuppression, createChannelManager } from "./server-channels.js";
import {
captureSharedGatewaySessionGenerationOwnership,
claimSharedGatewaySessionGenerationIfOwned,
disconnectStaleSharedGatewayAuthClients,
finalizeOwnedSharedGatewaySessionGeneration,
isSharedGatewaySessionGenerationOwnershipCurrent,
replaceOwnedSharedGatewaySessionGenerationState,
type SharedGatewayAuthClient,
type SharedGatewaySessionGenerationOwnership,
type SharedGatewaySessionGenerationState,
} from "./server-shared-auth-generation.js";
import type { ActivateRuntimeSecrets } from "./server-startup-config.js";
type ReloadSecretsResult = { warningCount: number };
type ReloadSecretsOptions = { forceColdRefKeys?: ReadonlySet<string>; joinInFlight?: boolean };
type ReloadChannelTarget = {
channel: ChannelKind;
accountId?: string;
credentialOwnerId?: string;
inspectOnly?: boolean;
};
export type GatewaySecretsReloaderParams = {
activateRuntimeSecrets: ActivateRuntimeSecrets;
buildReloadPlan?: (changedPaths: string[]) => GatewayReloadPlan;
sharedGatewaySessionGenerationState: SharedGatewaySessionGenerationState;
resolveSharedGatewaySessionGenerationForConfig: (config: OpenClawConfig) => string | undefined;
clients: Iterable<SharedGatewayAuthClient>;
channelManager: Pick<
ReturnType<typeof createChannelManager>,
"startChannel" | "stopChannel" | "isManuallyStopped" | "resolveRuntimeAccountId"
>;
getChannelAutostartSuppression?: () => ChannelAutostartSuppression | null;
logChannels: { info: (message: string) => void };
};
async function activateSnapshotIfCurrent(
snapshot: PreparedSecretsRuntimeSnapshot,
expectedRevision: number,
options: { canActivate: () => boolean; onActivated: () => void },
): Promise<number | null> {
const runtime = await import("../secrets/runtime.js");
if (
!options.canActivate() ||
!runtime.activateSecretsRuntimeSnapshotIfCurrent(snapshot, expectedRevision)
) {
return null;
}
options.onActivated();
return runtime.getActiveSecretsRuntimeSnapshotRevision();
}
async function restoreSnapshotIfCurrent(
snapshot: PreparedSecretsRuntimeSnapshot,
expectedRevision: number,
ownedSnapshot: PreparedSecretsRuntimeSnapshot,
onActivated: () => void,
): Promise<void> {
const runtime = await import("../secrets/runtime.js");
if (runtime.restoreSecretsRuntimeSnapshotIfCurrent(snapshot, expectedRevision, ownedSnapshot)) {
onActivated();
}
}
/** Keeps snapshot CAS, generation ownership, and exact account recovery in one transaction. */
export function createGatewaySecretsReloader(params: GatewaySecretsReloaderParams) {
const buildReloadPlan = params.buildReloadPlan ?? buildGatewayReloadPlan;
const manager = params.channelManager;
let reloadInFlight: Promise<ReloadSecretsResult> | null = null;
const runExclusiveReload = (
fn: () => Promise<ReloadSecretsResult>,
options: ReloadSecretsOptions = {},
): Promise<ReloadSecretsResult> => {
if (reloadInFlight) {
return options.joinInFlight === false
? reloadInFlight.catch(() => undefined).then(() => runExclusiveReload(fn, options))
: reloadInFlight;
}
const run = (async () => {
try {
return await fn();
} finally {
reloadInFlight = null;
}
})();
reloadInFlight = run;
return run;
};
return (reloadOptions?: ReloadSecretsOptions) =>
runExclusiveReload(async () => {
let transaction:
| {
previousSnapshot: PreparedSecretsRuntimeSnapshot;
previousGeneration: string | undefined;
previousRequiredGeneration: string | undefined | null;
prepared: PreparedSecretsRuntimeSnapshot;
plan: GatewayReloadPlan;
credentialOwners: DegradedSecretOwner[];
nextGeneration: string | undefined;
generationChanged: boolean;
generationOwnership: SharedGatewaySessionGenerationOwnership;
publishedSnapshotRevision: number;
}
| undefined;
const touchedTargets: Array<{ target: ReloadChannelTarget; restarted: boolean }> = [];
const startTarget = ({ channel, accountId }: ReloadChannelTarget) =>
accountId
? manager.startChannel(channel, accountId, { preserveManualStop: true })
: manager.startChannel(channel);
const stopTarget = ({ channel, accountId }: ReloadChannelTarget) =>
accountId
? manager.stopChannel(channel, accountId, { manual: false })
: manager.stopChannel(channel);
try {
for (;;) {
const previousSnapshot = getActiveSecretsRuntimeSnapshotState();
if (!previousSnapshot) {
throw new Error("Secrets runtime snapshot is not active.");
}
const previousRevision = getActiveSecretsRuntimeSnapshotRevisionState();
const previousOwnership = captureSharedGatewaySessionGenerationOwnership(
params.sharedGatewaySessionGenerationState,
);
const previousGeneration = previousOwnership.generation;
const previousRequiredGeneration = params.sharedGatewaySessionGenerationState.required;
const prepared = await params.activateRuntimeSecrets(previousSnapshot.sourceConfig, {
reason: "reload",
activate: false,
publishFailureAsDegraded: true,
forceColdRefKeys: reloadOptions?.forceColdRefKeys,
canPublishFailureAsDegraded: () =>
getActiveSecretsRuntimeSnapshotRevisionState() === previousRevision,
});
const plan = buildReloadPlan(diffConfigPaths(previousSnapshot.config, prepared.config));
const nextGeneration = params.resolveSharedGatewaySessionGenerationForConfig(
prepared.config,
);
// File diagnostics have channel-owned lifetimes; capture each CAS attempt
// immediately before publication so a superseded attempt cannot reuse owners.
const credentialOwners = listActiveCredentialDegradedOwners();
let publishedSnapshotRevision: number | null = null;
let generationOwnership: SharedGatewaySessionGenerationOwnership | null = null;
const claimGeneration = () => {
publishedSnapshotRevision = getActiveSecretsRuntimeSnapshotRevisionState();
generationOwnership = claimSharedGatewaySessionGenerationIfOwned(
params.sharedGatewaySessionGenerationState,
previousOwnership,
nextGeneration,
);
};
const ownsPreviousGeneration = () =>
isSharedGatewaySessionGenerationOwnershipCurrent(
params.sharedGatewaySessionGenerationState,
previousOwnership,
);
const activateIfCurrent = params.activateRuntimeSecrets.activatePreparedSnapshotIfCurrent;
if (activateIfCurrent) {
const activated = await activateIfCurrent(
prepared,
previousRevision,
{ reason: "reload", activate: true },
claimGeneration,
ownsPreviousGeneration,
);
if (!activated) {
continue;
}
} else {
publishedSnapshotRevision = await activateSnapshotIfCurrent(
prepared,
previousRevision,
{
canActivate: ownsPreviousGeneration,
onActivated: claimGeneration,
},
);
if (publishedSnapshotRevision === null) {
continue;
}
}
if (publishedSnapshotRevision === null || generationOwnership === null) {
throw new Error("Secrets runtime activation did not publish ownership.");
}
transaction = {
previousSnapshot,
previousGeneration,
previousRequiredGeneration,
prepared,
plan,
credentialOwners,
nextGeneration,
generationChanged: previousGeneration !== nextGeneration,
generationOwnership,
publishedSnapshotRevision,
};
if (
!isSharedGatewaySessionGenerationOwnershipCurrent(
params.sharedGatewaySessionGenerationState,
generationOwnership,
)
) {
throw new Error("secrets.reload was superseded by a newer config write");
}
break;
}
const { prepared, plan, credentialOwners, generationOwnership, nextGeneration } =
transaction;
if (transaction.generationChanged) {
disconnectStaleSharedGatewayAuthClients({
clients: params.clients,
expectedGeneration: nextGeneration,
});
}
const targets: ReloadChannelTarget[] = [...plan.restartChannels].map((channel) => ({
channel,
}));
const accountTargets = new Map<string, ReloadChannelTarget>();
for (const [channel, accountIds] of plan.restartChannelAccounts ?? []) {
if (plan.restartChannels.has(channel)) {
continue;
}
for (const accountId of accountIds) {
const target = { channel, accountId };
accountTargets.set(`${channel}\0${accountId}`, target);
targets.push(target);
}
}
for (const owner of credentialOwners) {
if (owner.ownerKind !== "account") {
continue;
}
const separator = owner.ownerId.indexOf(":");
if (separator < 0) {
continue;
}
const channel: ChannelKind = owner.ownerId.slice(0, separator);
if (plan.restartChannels.has(channel)) {
continue;
}
const accountId = manager.resolveRuntimeAccountId(
channel,
owner.ownerId.slice(separator + 1),
);
if (!accountId || manager.isManuallyStopped(channel, accountId)) {
continue;
}
const key = `${channel}\0${accountId}`;
const existing = accountTargets.get(key);
if (existing) {
existing.credentialOwnerId = owner.ownerId;
continue;
}
const target = {
channel,
accountId,
credentialOwnerId: owner.ownerId,
inspectOnly: true,
};
accountTargets.set(key, target);
targets.push(target);
}
const restartTargets = targets.filter(
({ channel, accountId }) => !accountId || !manager.isManuallyStopped(channel, accountId),
);
if (restartTargets.length > 0) {
const restartChannels = [...new Set(restartTargets.map(({ channel }) => channel))];
if (
isTruthyEnvValue(process.env.OPENCLAW_SKIP_CHANNELS) ||
isTruthyEnvValue(process.env.OPENCLAW_SKIP_PROVIDERS)
) {
throw new Error(
`secrets.reload requires restarting channels: ${restartChannels.join(", ")}`,
);
}
if (params.getChannelAutostartSuppression?.()) {
throw new Error(
`secrets.reload requires restarting channels but channel autostart is suppressed by crash-loop breaker: ${restartChannels.join(", ")}`,
);
}
const failures: string[] = [];
for (const target of restartTargets) {
const { channel, accountId, credentialOwnerId, inspectOnly } = target;
const label = accountId ? `${channel} account ${accountId}` : `${channel} channel`;
const assertGenerationOwned = () => {
if (
!isSharedGatewaySessionGenerationOwnershipCurrent(
params.sharedGatewaySessionGenerationState,
generationOwnership,
)
) {
throw new Error("secrets.reload was superseded by a newer config write");
}
};
assertGenerationOwned();
params.logChannels.info(
`${inspectOnly ? "reinspecting" : "restarting"} ${label} after secrets reload`,
);
// A rejecting hook may have already changed its exact account lifetime.
const touched = { target, restarted: false };
touchedTargets.push(touched);
try {
if (!inspectOnly) {
await stopTarget(target);
assertGenerationOwned();
}
await startTarget(target);
touched.restarted = true;
assertGenerationOwned();
} catch (error) {
if (
credentialOwnerId &&
isTrustedSecretSurfaceUnavailableError(error) &&
error.ownerKind === "account" &&
error.ownerId === credentialOwnerId &&
listActiveCredentialDegradedOwners().some(
(owner) => owner.ownerKind === "account" && owner.ownerId === credentialOwnerId,
)
) {
touchedTargets.pop();
continue;
}
params.logChannels.info(`failed to restart ${label} after secrets reload`);
failures.push(accountId ? `${channel}:${accountId}` : channel);
}
}
if (failures.length > 0) {
throw new Error(
`failed to restart channels after secrets reload: ${failures.join(", ")}`,
);
}
}
if (
!finalizeOwnedSharedGatewaySessionGeneration(
params.sharedGatewaySessionGenerationState,
generationOwnership,
)
) {
throw new Error("secrets.reload was superseded by a newer config write");
}
return { warningCount: prepared.warnings.length };
} catch (error) {
let generationRestored = false;
if (transaction) {
const failedTransaction = transaction;
await restoreSnapshotIfCurrent(
failedTransaction.previousSnapshot,
failedTransaction.publishedSnapshotRevision,
failedTransaction.prepared,
() => {
generationRestored = replaceOwnedSharedGatewaySessionGenerationState(
params.sharedGatewaySessionGenerationState,
failedTransaction.generationOwnership,
{
current: failedTransaction.previousGeneration,
required: failedTransaction.previousRequiredGeneration,
},
);
},
);
}
if (generationRestored && transaction?.generationChanged) {
disconnectStaleSharedGatewayAuthClients({
clients: params.clients,
expectedGeneration: transaction.previousGeneration,
});
}
// Generation fences snapshot rollback, never exact-account liveness recovery.
for (const { target, restarted } of touchedTargets) {
const { channel, accountId, inspectOnly } = target;
const label = accountId ? `${channel} account ${accountId}` : `${channel} channel`;
params.logChannels.info(`rolling back ${label} after secrets reload failure`);
try {
if (restarted || inspectOnly) {
await stopTarget(target);
}
if (!inspectOnly) {
await startTarget(target);
}
} catch {
params.logChannels.info(`failed to roll back ${label} after secrets reload`);
}
}
throw error;
}
}, reloadOptions);
}
@@ -10,7 +10,10 @@ import { saveAuthProfileStore } from "../agents/auth-profiles/store.js";
import { resolveMemorySearchConfig } from "../agents/memory-search.js";
import { resolveApiKeyForProviderCore } from "../agents/model-auth.js";
import { resolveSandboxContext } from "../agents/sandbox/context.js";
import type { ChannelGatewayContext } from "../channels/plugins/types.adapters.js";
import type { ChannelAccountSnapshot, ChannelPlugin } from "../channels/plugins/types.public.js";
import type { OpenClawConfig } from "../config/config.js";
import { tryReadSecretFileSync } from "../infra/secret-file.js";
import { selectAgentSystemEvents } from "../infra/system-event-ownership.js";
import {
peekSystemEventEntries,
@@ -18,14 +21,19 @@ import {
resetSystemEventsForTest,
} from "../infra/system-events.js";
import { resolveAuthProfileSecretOwnerId } from "../secrets/runtime-auth-profile-owner.js";
import { setActiveDegradedSecretOwners } from "../secrets/runtime-degraded-state.js";
import {
listActiveDegradedSecretOwners,
setActiveDegradedSecretOwners,
} from "../secrets/runtime-degraded-state.js";
import { getActiveSecretsRuntimeSnapshot } from "../secrets/runtime.js";
import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js";
import { deleteTestEnvValue, withEnvAsync } from "../test-utils/env.js";
import {
connectWebchatClient,
getGatewayTestPort,
installGatewayTestHooks,
rpcReq,
setTestPluginRegistry,
startTestGatewayServer,
testState,
} from "./test-helpers.js";
@@ -182,6 +190,192 @@ describe("Gateway startup SecretRef owner isolation", () => {
});
});
it("recovers only a repaired credential-file account through secrets.reload without restarting sibling accounts", async () => {
await withEnvAsync(
{ OPENCLAW_SKIP_CHANNELS: undefined, OPENCLAW_SKIP_PROVIDERS: undefined },
async () => {
const credentialPath = path.join(tempDirs.make("openclaw-gateway-credential-"), "token");
const credentialConfigPath = "channels.telegram.accounts.broken.tokenFile";
const repairedToken = "repaired-test-token-never-public";
type TestAccount = {
accountId: string;
token?: string;
credentialDiagnostics?: Array<{
code: "CREDENTIAL_FILE_UNAVAILABLE";
path: string;
reason: string;
}>;
};
const startAccount = vi.fn(
async ({ abortSignal }: ChannelGatewayContext<TestAccount>) =>
await new Promise<void>((resolve) => {
abortSignal.addEventListener("abort", () => resolve(), { once: true });
}),
);
const plugin: ChannelPlugin<TestAccount> = {
...createChannelTestPluginBase({ id: "telegram" }),
config: {
listAccountIds: (config) => Object.keys(config.channels?.telegram?.accounts ?? {}),
resolveAccount: (config, accountId) => {
if (!accountId) {
throw new Error("Missing Telegram test account id");
}
const configured = config.channels?.telegram?.accounts?.[accountId];
if (!configured) {
throw new Error(`Missing Telegram test account ${accountId}`);
}
const credential = configured.tokenFile
? tryReadSecretFileSync(
configured.tokenFile,
"Telegram bot token",
{},
{
configPath: credentialConfigPath,
},
)
: { status: "available" as const, value: configured.botToken };
return {
accountId,
...(credential.status === "configured_unavailable"
? { credentialDiagnostics: [credential.diagnostic] }
: { token: typeof credential.value === "string" ? credential.value : undefined }),
};
},
},
gateway: { startAccount },
};
setTestPluginRegistry(
createTestRegistry([{ pluginId: "telegram", source: "test", plugin }]),
);
await writeConfig({
...baseConfig(),
gateway: { ...baseConfig().gateway, reload: { mode: "off" } },
channels: {
telegram: {
enabled: true,
healthMonitor: { enabled: false },
accounts: {
broken: { tokenFile: credentialPath },
healthy: { botToken: "healthy-test-token" },
stopped: { botToken: "stopped-test-token" },
},
},
},
});
const configPath = process.env.OPENCLAW_CONFIG_PATH;
if (!configPath) {
throw new Error("Gateway test did not configure a config file path");
}
const originalConfig = readFileSync(configPath);
const port = await getGatewayTestPort();
server = await startTestGatewayServer(port, { auth: { mode: "none" } });
const ws = await connectWebchatClient({ port, scopes: ["operator.admin"] });
try {
const brokenStart = await rpcReq(ws, "channels.start", {
channel: "telegram",
accountId: "broken",
});
expect(brokenStart.ok).toBe(false);
for (const accountId of ["healthy", "stopped"]) {
const started = await rpcReq<{ accountId: string; started: boolean }>(
ws,
"channels.start",
{ channel: "telegram", accountId },
);
expect(started.ok, JSON.stringify(started)).toBe(true);
expect(started.payload).toMatchObject({ accountId, started: true });
}
expect(startAccount).toHaveBeenCalledTimes(2);
expect(startAccount.mock.calls.map(([context]) => context.accountId)).toEqual([
"healthy",
"stopped",
]);
const healthyLifetime = startAccount.mock.calls[0]?.[0].abortSignal;
const stoppedLifetime = startAccount.mock.calls[1]?.[0].abortSignal;
expect(healthyLifetime?.aborted).toBe(false);
expect(stoppedLifetime?.aborted).toBe(false);
const before = await rpcReq<{
channelAccounts: Record<string, ChannelAccountSnapshot[]>;
}>(ws, "channels.status", { probe: false });
expect(before.ok, JSON.stringify(before)).toBe(true);
const initialAccounts = before.payload?.channelAccounts.telegram ?? [];
expect(initialAccounts).toEqual(
expect.arrayContaining([
expect.objectContaining({ accountId: "broken", configured: true, running: false }),
expect.objectContaining({ accountId: "healthy", running: true }),
expect.objectContaining({ accountId: "stopped", running: true }),
]),
);
expect(listActiveDegradedSecretOwners()).toContainEqual(
expect.objectContaining({
ownerId: "telegram:broken",
paths: [credentialConfigPath],
reason: "credential file is unavailable",
}),
);
expect(brokenStart.error).toMatchObject({ code: "UNAVAILABLE" });
const publicDiagnostics = JSON.stringify({
error: brokenStart.error,
accounts: initialAccounts,
});
expect(publicDiagnostics).not.toContain(credentialPath);
expect(publicDiagnostics).not.toContain(repairedToken);
const stopped = await rpcReq<{ accountId: string; stopped: boolean }>(
ws,
"channels.stop",
{
channel: "telegram",
accountId: "stopped",
},
);
expect(stopped.ok, JSON.stringify(stopped)).toBe(true);
expect(stopped.payload).toMatchObject({ accountId: "stopped", stopped: true });
expect(stoppedLifetime?.aborted).toBe(true);
expect(healthyLifetime?.aborted).toBe(false);
writeFileSync(credentialPath, repairedToken, { mode: 0o600 });
expect(readFileSync(configPath)).toEqual(originalConfig);
const reload = await rpcReq<{ warningCount: number }>(ws, "secrets.reload", {});
expect(reload.ok, JSON.stringify(reload)).toBe(true);
expect(reload.payload).toMatchObject({ warningCount: 0 });
expect(startAccount.mock.calls.map(([context]) => context.accountId)).toEqual([
"healthy",
"stopped",
"broken",
]);
expect(startAccount.mock.calls[2]?.[0].account.token).toBe(repairedToken);
expect(healthyLifetime?.aborted).toBe(false);
expect(stoppedLifetime?.aborted).toBe(true);
expect(listActiveDegradedSecretOwners()).not.toContainEqual(
expect.objectContaining({ ownerId: "telegram:broken" }),
);
const after = await rpcReq<{ channelAccounts: Record<string, ChannelAccountSnapshot[]> }>(
ws,
"channels.status",
{ probe: false },
);
expect(after.ok, JSON.stringify(after)).toBe(true);
expect(after.payload?.channelAccounts.telegram).toEqual(
expect.arrayContaining([
expect.objectContaining({ accountId: "broken", running: true }),
expect.objectContaining({ accountId: "healthy", running: true }),
expect.objectContaining({ accountId: "stopped", running: false }),
]),
);
expect(JSON.stringify(after.payload)).not.toContain(repairedToken);
expect(JSON.stringify(after.payload)).not.toContain(credentialPath);
expect(readFileSync(configPath)).toEqual(originalConfig);
} finally {
ws.close();
}
},
);
});
it("reaches /readyz while isolating every optional owner family", async () => {
await withEnvAsync(
{
@@ -13,6 +13,7 @@ import {
} from "./runtime-degraded-state.js";
afterEach(() => {
clearActiveCredentialDegradedOwner("account", "telegram:work");
setActiveDegradedSecretOwners([]);
});
@@ -131,6 +132,27 @@ describe("runtime degraded SecretRef owners", () => {
"telegram:work",
]);
setActiveDegradedSecretOwners([
{
ownerKind: "provider",
ownerId: "openai",
state: "unavailable",
degradationState: "stale",
paths: ["models.providers.openai.apiKey"],
refKeys: ["env:default:OPENAI_API_KEY"],
reason: "secret provider failed",
},
]);
expect(listActiveDegradedSecretOwners().map((owner) => owner.ownerId)).toEqual([
"openai",
"telegram:work",
]);
expect(() => assertSecretOwnerAvailable("provider", "openai")).not.toThrow();
expect(() => assertSecretOwnerAvailable("account", "telegram:work")).toThrow(
SecretSurfaceUnavailableError,
);
clearActiveCredentialDegradedOwner("account", "telegram:work");
expect(listActiveDegradedSecretOwners().map((owner) => owner.ownerId)).toEqual(["openai"]);
+12 -6
View File
@@ -182,14 +182,23 @@ function cloneResolutionErrorOwner(owner: SecretResolutionErrorOwner): SecretRes
/** Publishes the degraded-owner snapshot at the same edge as runtime config activation. */
export function setActiveDegradedSecretOwners(owners: readonly DegradedSecretOwner[]): void {
activeDegradedOwners = owners.map(cloneOwner);
activeCredentialDegradedOwners.clear();
}
/** Publishes or clears one runtime-discovered channel credential owner. */
/** Publishes one runtime-discovered channel credential owner. */
export function setActiveCredentialDegradedOwner(owner: DegradedSecretOwner): void {
activeCredentialDegradedOwners.set(ownerKey(owner.ownerKind, owner.ownerId), cloneOwner(owner));
}
/** Lists credential owners independently of the replaceable SecretRef snapshot. */
export function listActiveCredentialDegradedOwners(): DegradedSecretOwner[] {
return Array.from(activeCredentialDegradedOwners.values(), cloneOwner);
}
/** Clears credential-owner state only when its owning secrets runtime is torn down. */
export function clearActiveCredentialDegradedOwners(): void {
activeCredentialDegradedOwners.clear();
}
/** Clears one runtime-discovered channel credential owner before re-inspection. */
export function clearActiveCredentialDegradedOwner(
ownerKind: DegradedSecretOwner["ownerKind"],
@@ -200,10 +209,7 @@ export function clearActiveCredentialDegradedOwner(
/** Returns the active degraded-owner snapshot without exposing mutable registry state. */
export function listActiveDegradedSecretOwners(): DegradedSecretOwner[] {
return [
...activeDegradedOwners.map(cloneOwner),
...Array.from(activeCredentialDegradedOwners.values(), cloneOwner),
];
return [...activeDegradedOwners.map(cloneOwner), ...listActiveCredentialDegradedOwners()];
}
/** Associates a strict activation failure with the owners it prevented from refreshing. */
@@ -7,6 +7,10 @@ import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import type { OpenClawConfig } from "../config/config.js";
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import {
listActiveDegradedSecretOwners,
setActiveCredentialDegradedOwner,
} from "./runtime-degraded-state.js";
import { asConfig, setupSecretsRuntimeSnapshotTestHooks } from "./runtime.test-support.ts";
function createOpenAiFileModelsConfig(): NonNullable<OpenClawConfig["models"]> {
@@ -208,6 +212,14 @@ describe("secrets runtime provider and media surfaces", () => {
const { getRuntimeConfigSourceSnapshot, getRuntimeConfigSnapshot, setRuntimeConfigSnapshot } =
await import("../config/runtime-snapshot.js");
activateSecretsRuntimeSnapshot(initial);
setActiveCredentialDegradedOwner({
ownerKind: "account",
ownerId: "telegram:work",
state: "unavailable",
paths: ["channels.telegram.accounts.work.tokenFile"],
refKeys: [],
reason: "credential file is unavailable",
});
const runtimeSourceConfig: OpenClawConfig = {
...initial.sourceConfig,
logging: { level: "debug" },
@@ -241,6 +253,9 @@ describe("secrets runtime provider and media surfaces", () => {
expect(active?.config.models?.providers?.openai?.apiKey).toBe("model-new");
expect(getRuntimeConfigSnapshot()).toEqual(active?.config);
expect(getRuntimeConfigSourceSnapshot()).toEqual(runtimeSourceConfig);
expect(listActiveDegradedSecretOwners()).toContainEqual(
expect.objectContaining({ ownerKind: "account", ownerId: "telegram:work" }),
);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
+45
View File
@@ -26,6 +26,10 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { SecretRef } from "../config/types.secrets.js";
import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js";
import { captureEnv } from "../test-utils/env.js";
import {
listActiveDegradedSecretOwners,
setActiveCredentialDegradedOwner,
} from "./runtime-degraded-state.js";
import {
activateSecretsRuntimeSnapshotState,
activateSecretsRuntimeSnapshotStateIfCurrent,
@@ -216,6 +220,47 @@ describe("secrets runtime state", () => {
expect(configSnapshot?.sourceConfig).toEqual(snapshot.sourceConfig);
});
it("preserves independent credential owners through snapshot replacement and rollback until teardown", () => {
const previous = preparedSnapshot({
degradedOwners: [
{
ownerKind: "provider",
ownerId: "openai",
state: "unavailable",
degradationState: "stale",
paths: ["models.providers.openai.apiKey"],
refKeys: ["env:default:OPENAI_API_KEY"],
reason: "secret provider failed",
},
],
});
activateSnapshot(previous);
setActiveCredentialDegradedOwner({
ownerKind: "account",
ownerId: "telegram:work",
state: "unavailable",
paths: ["channels.telegram.accounts.work.tokenFile"],
refKeys: [],
reason: "credential file is unavailable",
});
const candidate = preparedSnapshot({ config: { gateway: { port: 19_041 } } });
activateSnapshot(candidate);
expect(listActiveDegradedSecretOwners()).toMatchObject([
{ ownerKind: "account", ownerId: "telegram:work" },
]);
expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true);
expect(listActiveDegradedSecretOwners()).toMatchObject([
{ ownerKind: "provider", ownerId: "openai", degradationState: "stale" },
{ ownerKind: "account", ownerId: "telegram:work" },
]);
clearSecretsRuntimeSnapshotState();
expect(listActiveDegradedSecretOwners()).toEqual([]);
});
it("publishes distinct raw and overlay source snapshots without changing runtime auth", () => {
const secretRef = {
source: "env" as const,
+2
View File
@@ -33,6 +33,7 @@ import type { PluginOrigin } from "../plugins/plugin-origin.types.js";
import { isRecord } from "../utils.js";
import { secretRefKey } from "./ref-contract.js";
import {
clearActiveCredentialDegradedOwners,
setActiveDegradedSecretOwners,
type DegradedSecretOwner,
type SecretOwnerRefState,
@@ -1172,6 +1173,7 @@ export function clearSecretsRuntimeSnapshotState(): void {
activeRefreshContext = null;
clearActiveRuntimeWebToolsMetadata();
setActiveDegradedSecretOwners([]);
clearActiveCredentialDegradedOwners();
setRuntimeConfigSnapshotRefreshHandler(null);
clearRuntimeConfigSnapshot();
clearRuntimeAuthProfileStoreSnapshots();