Skip to content

Commit 17c2ce0

Browse files
snowzlmbotsteipete
andauthored
fix(secrets): keep startup alive when TTS SecretRefs are missing (#101265)
* fix(secrets): degrade missing TTS SecretRefs at startup * test(secrets): keep non-activating startup strict * test(secrets): mark denied key fixture synthetic * test(secrets): use synthetic TTS key fixture * test(secrets): use neutral TTS key placeholder * test(secrets): isolate TTS key placeholder * test(secrets): shorten TTS ref fixture name * test(secrets): normalize synthetic credential fixtures * test(secrets): isolate optional redaction coverage * fix(secrets): preserve degraded TTS ref ownership * refactor(secrets): keep optional resolver internal * test(secrets): cover default provider alias misses * test(secrets): pin explicit provider ownership * style(secrets): format optional assignment imports * refactor(secrets): keep optional metadata private * style(secrets): restore collector file header * fix(secrets): isolate unavailable SecretRef owners Co-authored-by: snowzlmbot <[email protected]> * test(secrets): complete provider fixtures * style(status): avoid degraded path shadowing * style(secrets): satisfy runtime lint * refactor(secrets): keep error codes internal * fix(secrets): keep unowned assignments fail closed * fix(secrets): preserve provider resolution batching * fix(secrets): normalize stalled resolution errors * fix(secrets): reject provider limit violations --------- Co-authored-by: snowzlmbot <[email protected]> Co-authored-by: Peter Steinberger <[email protected]>
1 parent 9d6133a commit 17c2ce0

45 files changed

Lines changed: 2090 additions & 196 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/cli/doctor.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ title: "Doctor"
1010

1111
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.
1212

13+
When Gateway status reports SecretRef owners isolated during cold startup, doctor prints a **Secret owners unavailable** warning with every owner and affected config path.
14+
1315
Related:
1416

1517
- Troubleshooting: [Troubleshooting](/gateway/troubleshooting)

docs/cli/status.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ and `openclaw memory status --deep`.
7676

7777
## Secrets
7878

79+
- When the running Gateway isolated a known SecretRef owner during cold startup, status includes `degradedSecretOwners` in JSON and a **Degraded secrets** overview row in human output. Each entry names the owner, unavailable state, config paths, and redacted reason.
7980
- Read-only status surfaces (`status`, `status --json`, `status --all`)
8081
resolve supported SecretRefs for their targeted config paths when
8182
possible.

docs/gateway/secrets.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ Plaintext credentials remain agent-readable if they sit in files the agent can i
2121
## Runtime model
2222

2323
- Secrets resolve into an in-memory runtime snapshot, eagerly during activation, not lazily on request paths.
24-
- Startup fails fast when an effectively active SecretRef cannot be resolved.
24+
- Cold Gateway startup isolates unavailable SecretRefs to a known non-Gateway owner when that owner supports isolation. Today this covers model providers and the built-in TTS capability. 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.
2525
- Reload is an atomic swap: full success, or keep the last-known-good snapshot.
2626
- Policy violations (for example an OAuth-mode auth profile combined with SecretRef input) fail activation before the runtime swap.
2727
- 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.
@@ -598,6 +598,8 @@ Activation contract:
598598

599599
- Success swaps the snapshot atomically.
600600
- Startup failure aborts gateway startup.
601+
- 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.
602+
- Reload, restart-check, and write preflight remain strict. They keep the active last-known-good snapshot rather than publishing new degraded owners.
601603
- Runtime reload failure keeps the last-known-good snapshot.
602604
- Write-RPC preflight failure rejects the submitted config; both disk config and the active runtime snapshot stay unchanged.
603605
- 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`.

docs/reference/secretref-credential-surface.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ Notes:
135135
- In `openclaw.json`, SecretRefs must use structured objects such as `{"source":"env","provider":"default","id":"DISCORD_BOT_TOKEN"}`. Legacy `secretref-env:<ENV_VAR>` marker strings are rejected on SecretRef credential paths; run `openclaw doctor --fix` to migrate valid markers.
136136
- OAuth policy guard: `auth.profiles.<id>.mode = "oauth"` cannot be combined with SecretRef inputs for that profile. Startup/reload and auth-profile resolution fail fast when this policy is violated.
137137
- For SecretRef-managed model providers, generated `agents/*/agent/models.json` entries persist non-secret markers (not resolved secret values) for `apiKey`/header surfaces. Marker persistence is source-authoritative: OpenClaw writes markers from the active source config snapshot (pre-resolution), not from resolved runtime secret values.
138+
- Cold Gateway startup can isolate resolution failures for mapped owners. The initial mapped owners are `models.providers.*` and the built-in TTS capability (`messages.tts.providers.*`, `agents.list[].tts.providers.*`, and channel voice TTS provider keys). Startup keeps each failed owner's explicit refs in the runtime snapshot, reports the owner through status and doctor, and rejects requests for that owner without trying environment or profile credentials. Gateway ingress auth remains required. Structurally invalid refs or values and currently unmapped owners fail startup. Reload and config-write preflight remain strict and retain the last-known-good snapshot.
138139
- For web search: in explicit provider mode (`tools.web.search.provider` set), only the selected provider key is active. In auto mode (`tools.web.search.provider` unset), only the first provider key that resolves by precedence is active, and non-selected provider refs are treated as inactive until selected. Legacy `tools.web.search.*` provider paths still resolve during the compatibility window, but the canonical SecretRef surface is `plugins.entries.<plugin>.config.webSearch.*`.
139140

140141
## Unsupported credentials

docs/tools/tts.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -810,6 +810,16 @@ Reply -> TTS enabled?
810810
</ParamField>
811811
</Accordion>
812812

813+
Provider `apiKey` fields can be raw strings or SecretRefs. During cold Gateway
814+
startup, an unavailable TTS SecretRef marks the built-in TTS capability
815+
configured-unavailable instead of stopping the Gateway. `tts.speak` then returns
816+
`UNAVAILABLE` with reason `SECRET_SURFACE_UNAVAILABLE`, and no provider request is
817+
sent. Status and doctor list the degraded TTS owner and its config paths. The
818+
explicit refs remain in the runtime snapshot, so environment or profile
819+
credentials cannot silently select a different account. Reloads and config-write
820+
preflight remain strict and keep the last-known-good snapshot. Structurally
821+
invalid refs and resolved values still fail startup.
822+
813823
<Accordion title="Azure Speech">
814824
<ParamField path="apiKey" type="string">Env: `AZURE_SPEECH_KEY`, `AZURE_SPEECH_API_KEY`, or `SPEECH_KEY`.</ParamField>
815825
<ParamField path="region" type="string">Azure Speech region (e.g. `eastus`). Env: `AZURE_SPEECH_REGION` or `SPEECH_REGION`.</ParamField>

extensions/googlechat/src/secret-contract.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,15 @@ describe("googlechat secret contract", () => {
4141
context,
4242
});
4343

44+
expect(context.assignments).toMatchObject([
45+
{
46+
ownerKind: "unknown",
47+
ownerId: "channels.googlechat.accounts.work.serviceAccount",
48+
requiredForGateway: false,
49+
disposition: "fail-closed",
50+
},
51+
]);
52+
4453
const resolved = await resolveSecretRefValues(
4554
context.assignments.map((assignment) => assignment.ref),
4655
{

extensions/googlechat/src/secret-contract.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,10 @@ function collectGoogleChatAccountAssignment(params: {
9494
ref,
9595
path: `${params.path}.serviceAccount`,
9696
expected: "string-or-object",
97+
ownerKind: "unknown",
98+
ownerId: `${params.path}.serviceAccount`,
99+
requiredForGateway: false,
100+
disposition: "fail-closed",
97101
apply: (value) => {
98102
params.target.serviceAccount = value;
99103
},

packages/speech-core/runtime-api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Runtime speech API barrel for TTS preferences, synthesis, streaming, and test
22
// helpers used by speech-capable plugins.
3+
export { setSpeechRuntimeAvailabilityGuard } from "./src/runtime-availability.js";
34
export {
45
buildTtsSystemPromptHint,
56
getTtsMaxLength,
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/** Host-owned availability guard shared by every speech-core entrypoint. */
2+
3+
let assertRuntimeAvailable: (() => void) | undefined;
4+
5+
/** Installs the process-lifecycle availability guard owned by the OpenClaw host. */
6+
export function setSpeechRuntimeAvailabilityGuard(guard: (() => void) | undefined): void {
7+
assertRuntimeAvailable = guard;
8+
}
9+
10+
/** Throws the host's typed unavailable error when speech is configured cold. */
11+
export function assertSpeechRuntimeAvailable(): void {
12+
assertRuntimeAvailable?.();
13+
}
14+
15+
/** Returns false only when the installed host guard rejects speech execution. */
16+
export function isSpeechRuntimeAvailable(): boolean {
17+
try {
18+
assertSpeechRuntimeAvailable();
19+
return true;
20+
} catch {
21+
return false;
22+
}
23+
}

packages/speech-core/src/tts.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import {
4949
type VoiceModelProvider,
5050
type VoiceProviderCandidate,
5151
} from "../voice-models.js";
52+
import { assertSpeechRuntimeAvailable, isSpeechRuntimeAvailable } from "./runtime-availability.js";
5253
import {
5354
DEFAULT_TTS_TIMEOUT_MS,
5455
asProviderConfig,
@@ -1027,6 +1028,7 @@ export async function synthesizeSpeech(params: {
10271028
agentId?: string;
10281029
accountId?: string;
10291030
}): Promise<TtsSynthesisResult> {
1031+
assertSpeechRuntimeAvailable();
10301032
const setup = resolveTtsRequestSetup({
10311033
text: params.text,
10321034
cfg: params.cfg,
@@ -1177,6 +1179,7 @@ export async function streamSpeech(params: {
11771179
agentId?: string;
11781180
accountId?: string;
11791181
}): Promise<TtsSynthesisStreamResult> {
1182+
assertSpeechRuntimeAvailable();
11801183
const setup = resolveTtsRequestSetup({
11811184
text: params.text,
11821185
cfg: params.cfg,
@@ -1361,6 +1364,7 @@ export async function textToSpeechTelephony(params: {
13611364
overrides?: TtsDirectiveOverrides;
13621365
timeoutMs?: number;
13631366
}): Promise<TtsTelephonyResult> {
1367+
assertSpeechRuntimeAvailable();
13641368
const setup = resolveTtsRequestSetup({
13651369
text: params.text,
13661370
cfg: params.cfg,
@@ -1503,6 +1507,7 @@ export async function listSpeechVoices(params: {
15031507
apiKey?: string;
15041508
baseUrl?: string;
15051509
}): Promise<SpeechVoiceOption[]> {
1510+
assertSpeechRuntimeAvailable();
15061511
const cfg = params.cfg ? resolveTtsRuntimeConfig(params.cfg) : undefined;
15071512
const provider = canonicalizeSpeechProviderId(params.provider, cfg);
15081513
if (!provider) {
@@ -1546,6 +1551,9 @@ export async function maybeApplyTtsToPayload(params: {
15461551
agentId?: string;
15471552
accountId?: string;
15481553
}): Promise<ReplyPayload> {
1554+
if (!isSpeechRuntimeAvailable()) {
1555+
return params.payload;
1556+
}
15491557
if (params.payload.isCompactionNotice) {
15501558
return params.payload;
15511559
}

0 commit comments

Comments
 (0)