Skip to content

Commit 1c200ca

Browse files
authored
follow-up: align ingress, atomic paths, and channel tests with credential semantics (#33733)
Merged via squash. Prepared head SHA: c290c2a Co-authored-by: joshavant <[email protected]> Co-authored-by: joshavant <[email protected]> Reviewed-by: @joshavant
1 parent 6842877 commit 1c200ca

36 files changed

Lines changed: 1128 additions & 217 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
1818
- Build/lazy runtime boundaries: replace ineffective dynamic import sites with dedicated lazy runtime boundaries across Slack slash handling, Telegram audit, CLI send deps, memory fallback, and outbound delivery paths while preserving behavior. (#33690) thanks @gumadeiras.
1919
- Config/heartbeat legacy-path handling: auto-migrate top-level `heartbeat` into `agents.defaults.heartbeat` (with merge semantics that preserve explicit defaults), and keep startup failures on non-migratable legacy entries in the detailed invalid-config path instead of generic migration-failed errors. (#32706) thanks @xiwan.
2020
- Security/auth labels: remove token and API-key snippets from user-facing auth status labels so `/status` and `/models` do not expose credential fragments. (#33262) thanks @cu1ch3n.
21+
- Auth/credential semantics: align profile eligibility + probe diagnostics with SecretRef/expiry rules and harden browser download atomic writes. (#33733) thanks @joshavant.
2122
- Security/audit denyCommands guidance: suggest likely exact node command IDs for unknown `gateway.nodes.denyCommands` entries so ineffective denylist entries are easier to correct. (#29713) thanks @liquidhorizon88-bot.
2223
- Docs/security hardening guidance: document Docker `DOCKER-USER` + UFW policy and add cross-linking from Docker install docs for VPS/public-host setups. (#27613) thanks @dorukardahan.
2324
- Docs/security threat-model links: replace relative `.md` links with Mintlify-compatible root-relative routes in security docs to prevent broken internal navigation. (#27698) thanks @clawdoo.

docs/auth-credential-semantics.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Auth Credential Semantics
2+
3+
This document defines the canonical credential eligibility and resolution semantics used across:
4+
5+
- `resolveAuthProfileOrder`
6+
- `resolveApiKeyForProfile`
7+
- `models status --probe`
8+
- `doctor-auth`
9+
10+
The goal is to keep selection-time and runtime behavior aligned.
11+
12+
## Stable Reason Codes
13+
14+
- `ok`
15+
- `missing_credential`
16+
- `invalid_expires`
17+
- `expired`
18+
- `unresolved_ref`
19+
20+
## Token Credentials
21+
22+
Token credentials (`type: "token"`) support inline `token` and/or `tokenRef`.
23+
24+
### Eligibility rules
25+
26+
1. A token profile is ineligible when both `token` and `tokenRef` are absent.
27+
2. `expires` is optional.
28+
3. If `expires` is present, it must be a finite number greater than `0`.
29+
4. If `expires` is invalid (`NaN`, `0`, negative, non-finite, or wrong type), the profile is ineligible with `invalid_expires`.
30+
5. If `expires` is in the past, the profile is ineligible with `expired`.
31+
6. `tokenRef` does not bypass `expires` validation.
32+
33+
### Resolution rules
34+
35+
1. Resolver semantics match eligibility semantics for `expires`.
36+
2. For eligible profiles, token material may be resolved from inline value or `tokenRef`.
37+
3. Unresolvable refs produce `unresolved_ref` in `models status --probe` output.
38+
39+
## Legacy-Compatible Messaging
40+
41+
For script compatibility, probe errors keep this first line unchanged:
42+
43+
`Auth profile credentials are missing or expired.`
44+
45+
Human-friendly detail and stable reason codes may be added on subsequent lines.

docs/docs.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1182,6 +1182,7 @@
11821182
"gateway/configuration-reference",
11831183
"gateway/configuration-examples",
11841184
"gateway/authentication",
1185+
"auth-credential-semantics",
11851186
"gateway/secrets",
11861187
"gateway/secrets-plan-contract",
11871188
"gateway/trusted-proxy-auth",

docs/gateway/authentication.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ flows are also supported when they match your provider account model.
1515
See [/concepts/oauth](/concepts/oauth) for the full OAuth flow and storage
1616
layout.
1717
For SecretRef-based auth (`env`/`file`/`exec` providers), see [Secrets Management](/gateway/secrets).
18+
For credential eligibility/reason-code rules used by `models status --probe`, see
19+
[Auth Credential Semantics](/auth-credential-semantics).
1820

1921
## Recommended setup (API key, any provider)
2022

extensions/feishu/src/targets.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,11 @@ export function formatFeishuTarget(id: string, type?: FeishuIdType): string {
6666
export function resolveReceiveIdType(id: string): "chat_id" | "open_id" | "user_id" {
6767
const trimmed = id.trim();
6868
const lowered = trimmed.toLowerCase();
69-
if (lowered.startsWith("chat:") || lowered.startsWith("group:")) {
69+
if (
70+
lowered.startsWith("chat:") ||
71+
lowered.startsWith("group:") ||
72+
lowered.startsWith("channel:")
73+
) {
7074
return "chat_id";
7175
}
7276
if (lowered.startsWith("open_id:")) {

extensions/msteams/src/monitor.lifecycle.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ const expressControl = vi.hoisted(() => ({
1717

1818
vi.mock("openclaw/plugin-sdk", () => ({
1919
DEFAULT_WEBHOOK_MAX_BODY_BYTES: 1024 * 1024,
20+
normalizeSecretInputString: (value: unknown) =>
21+
typeof value === "string" && value.trim() ? value.trim() : undefined,
22+
hasConfiguredSecretInput: (value: unknown) =>
23+
typeof value === "string" && value.trim().length > 0,
24+
normalizeResolvedSecretInputString: (params: { value?: unknown }) =>
25+
typeof params?.value === "string" && params.value.trim() ? params.value.trim() : undefined,
2026
keepHttpServerTaskAlive: vi.fn(
2127
async (params: { abortSignal?: AbortSignal; onAbort?: () => Promise<void> | void }) => {
2228
await new Promise<void>((resolve) => {

src/agents/anthropic.setup-token.live.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ function listSetupTokenProfiles(store: {
5151
if (normalizeProviderId(cred.provider) !== "anthropic") {
5252
return false;
5353
}
54-
return isSetupToken(cred.token);
54+
return isSetupToken(cred.token ?? "");
5555
})
5656
.map(([id]) => id);
5757
}

src/agents/auth-health.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ describe("buildAuthHealthSummary", () => {
99
const now = 1_700_000_000_000;
1010
const profileStatuses = (summary: ReturnType<typeof buildAuthHealthSummary>) =>
1111
Object.fromEntries(summary.profiles.map((profile) => [profile.profileId, profile.status]));
12+
const profileReasonCodes = (summary: ReturnType<typeof buildAuthHealthSummary>) =>
13+
Object.fromEntries(summary.profiles.map((profile) => [profile.profileId, profile.reasonCode]));
1214

1315
afterEach(() => {
1416
vi.restoreAllMocks();
@@ -89,6 +91,31 @@ describe("buildAuthHealthSummary", () => {
8991

9092
expect(statuses["google:no-refresh"]).toBe("expired");
9193
});
94+
95+
it("marks token profiles with invalid expires as missing with reason code", () => {
96+
vi.spyOn(Date, "now").mockReturnValue(now);
97+
const store = {
98+
version: 1,
99+
profiles: {
100+
"github-copilot:invalid-expires": {
101+
type: "token" as const,
102+
provider: "github-copilot",
103+
token: "gh-token",
104+
expires: 0,
105+
},
106+
},
107+
};
108+
109+
const summary = buildAuthHealthSummary({
110+
store,
111+
warnAfterMs: DEFAULT_OAUTH_WARN_MS,
112+
});
113+
const statuses = profileStatuses(summary);
114+
const reasonCodes = profileReasonCodes(summary);
115+
116+
expect(statuses["github-copilot:invalid-expires"]).toBe("missing");
117+
expect(reasonCodes["github-copilot:invalid-expires"]).toBe("invalid_expires");
118+
});
92119
});
93120

94121
describe("formatRemainingShort", () => {

src/agents/auth-health.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import type { OpenClawConfig } from "../config/config.js";
22
import {
3+
type AuthCredentialReasonCode,
34
type AuthProfileCredential,
45
type AuthProfileStore,
56
resolveAuthProfileDisplayLabel,
67
} from "./auth-profiles.js";
8+
import {
9+
evaluateStoredCredentialEligibility,
10+
resolveTokenExpiryState,
11+
} from "./auth-profiles/credential-state.js";
712

813
export type AuthProfileSource = "store";
914

@@ -14,6 +19,7 @@ export type AuthProfileHealth = {
1419
provider: string;
1520
type: "oauth" | "token" | "api_key";
1621
status: AuthProfileHealthStatus;
22+
reasonCode?: AuthCredentialReasonCode;
1723
expiresAt?: number;
1824
remainingMs?: number;
1925
source: AuthProfileSource;
@@ -113,11 +119,26 @@ function buildProfileHealth(params: {
113119
}
114120

115121
if (credential.type === "token") {
116-
const expiresAt =
117-
typeof credential.expires === "number" && Number.isFinite(credential.expires)
118-
? credential.expires
119-
: undefined;
120-
if (!expiresAt || expiresAt <= 0) {
122+
const eligibility = evaluateStoredCredentialEligibility({
123+
credential,
124+
now,
125+
});
126+
if (!eligibility.eligible) {
127+
const status: AuthProfileHealthStatus =
128+
eligibility.reasonCode === "expired" ? "expired" : "missing";
129+
return {
130+
profileId,
131+
provider: credential.provider,
132+
type: "token",
133+
status,
134+
reasonCode: eligibility.reasonCode,
135+
source,
136+
label,
137+
};
138+
}
139+
const expiryState = resolveTokenExpiryState(credential.expires, now);
140+
const expiresAt = expiryState === "valid" ? credential.expires : undefined;
141+
if (!expiresAt) {
121142
return {
122143
profileId,
123144
provider: credential.provider,
@@ -133,6 +154,7 @@ function buildProfileHealth(params: {
133154
provider: credential.provider,
134155
type: "token",
135156
status,
157+
reasonCode: status === "expired" ? "expired" : undefined,
136158
expiresAt,
137159
remainingMs,
138160
source,

src/agents/auth-profiles.resolve-auth-profile-order.uses-stored-profiles-no-config-exists.test.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ describe("resolveAuthProfileOrder", () => {
1212
function resolveMinimaxOrderWithProfile(profile: {
1313
type: "token";
1414
provider: "minimax";
15-
token: string;
15+
token?: string;
16+
tokenRef?: { source: "env" | "file" | "exec"; provider: string; id: string };
1617
expires?: number;
1718
}) {
1819
return resolveAuthProfileOrder({
@@ -189,10 +190,79 @@ describe("resolveAuthProfileOrder", () => {
189190
expires: Date.now() - 1000,
190191
},
191192
},
193+
{
194+
caseName: "drops token profiles with invalid expires metadata",
195+
profile: {
196+
type: "token" as const,
197+
provider: "minimax" as const,
198+
token: "sk-minimax",
199+
expires: 0,
200+
},
201+
},
192202
])("$caseName", ({ profile }) => {
193203
const order = resolveMinimaxOrderWithProfile(profile);
194204
expect(order).toEqual([]);
195205
});
206+
it("keeps api_key profiles backed by keyRef when plaintext key is absent", () => {
207+
const order = resolveAuthProfileOrder({
208+
cfg: {
209+
auth: {
210+
order: {
211+
anthropic: ["anthropic:default"],
212+
},
213+
},
214+
},
215+
store: {
216+
version: 1,
217+
profiles: {
218+
"anthropic:default": {
219+
type: "api_key",
220+
provider: "anthropic",
221+
keyRef: {
222+
source: "exec",
223+
provider: "vault_local",
224+
id: "anthropic/default",
225+
},
226+
},
227+
},
228+
},
229+
provider: "anthropic",
230+
});
231+
expect(order).toEqual(["anthropic:default"]);
232+
});
233+
it("keeps token profiles backed by tokenRef when expires is absent", () => {
234+
const order = resolveMinimaxOrderWithProfile({
235+
type: "token",
236+
provider: "minimax",
237+
tokenRef: {
238+
source: "exec",
239+
provider: "keychain",
240+
id: "minimax/default",
241+
},
242+
});
243+
expect(order).toEqual(["minimax:default"]);
244+
});
245+
it("drops tokenRef profiles when expires is invalid", () => {
246+
const order = resolveMinimaxOrderWithProfile({
247+
type: "token",
248+
provider: "minimax",
249+
tokenRef: {
250+
source: "exec",
251+
provider: "keychain",
252+
id: "minimax/default",
253+
},
254+
expires: 0,
255+
});
256+
expect(order).toEqual([]);
257+
});
258+
it("keeps token profiles with inline token when no expires is set", () => {
259+
const order = resolveMinimaxOrderWithProfile({
260+
type: "token",
261+
provider: "minimax",
262+
token: "sk-minimax",
263+
});
264+
expect(order).toEqual(["minimax:default"]);
265+
});
196266
it("keeps oauth profiles that can refresh", () => {
197267
const order = resolveAuthProfileOrder({
198268
cfg: {

0 commit comments

Comments
 (0)