follow-up: align ingress, atomic paths, and channel tests with credential semantics (#33733)

Merged via squash.

Prepared head SHA: c290c2ab6a
Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com>
Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com>
Reviewed-by: @joshavant
This commit is contained in:
Josh Avant
2026-03-03 20:29:46 -06:00
committed by GitHub
parent 6842877b2e
commit 1c200ca7ae
36 changed files with 1130 additions and 219 deletions

View File

@@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
- 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.
- 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.
- 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.
- Auth/credential semantics: align profile eligibility + probe diagnostics with SecretRef/expiry rules and harden browser download atomic writes. (#33733) thanks @joshavant.
- 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.
- 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.
- 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.

View File

@@ -0,0 +1,45 @@
# Auth Credential Semantics
This document defines the canonical credential eligibility and resolution semantics used across:
- `resolveAuthProfileOrder`
- `resolveApiKeyForProfile`
- `models status --probe`
- `doctor-auth`
The goal is to keep selection-time and runtime behavior aligned.
## Stable Reason Codes
- `ok`
- `missing_credential`
- `invalid_expires`
- `expired`
- `unresolved_ref`
## Token Credentials
Token credentials (`type: "token"`) support inline `token` and/or `tokenRef`.
### Eligibility rules
1. A token profile is ineligible when both `token` and `tokenRef` are absent.
2. `expires` is optional.
3. If `expires` is present, it must be a finite number greater than `0`.
4. If `expires` is invalid (`NaN`, `0`, negative, non-finite, or wrong type), the profile is ineligible with `invalid_expires`.
5. If `expires` is in the past, the profile is ineligible with `expired`.
6. `tokenRef` does not bypass `expires` validation.
### Resolution rules
1. Resolver semantics match eligibility semantics for `expires`.
2. For eligible profiles, token material may be resolved from inline value or `tokenRef`.
3. Unresolvable refs produce `unresolved_ref` in `models status --probe` output.
## Legacy-Compatible Messaging
For script compatibility, probe errors keep this first line unchanged:
`Auth profile credentials are missing or expired.`
Human-friendly detail and stable reason codes may be added on subsequent lines.

View File

@@ -1182,6 +1182,7 @@
"gateway/configuration-reference",
"gateway/configuration-examples",
"gateway/authentication",
"auth-credential-semantics",
"gateway/secrets",
"gateway/secrets-plan-contract",
"gateway/trusted-proxy-auth",

View File

@@ -15,6 +15,8 @@ flows are also supported when they match your provider account model.
See [/concepts/oauth](/concepts/oauth) for the full OAuth flow and storage
layout.
For SecretRef-based auth (`env`/`file`/`exec` providers), see [Secrets Management](/gateway/secrets).
For credential eligibility/reason-code rules used by `models status --probe`, see
[Auth Credential Semantics](/auth-credential-semantics).
## Recommended setup (API key, any provider)

View File

@@ -66,7 +66,11 @@ export function formatFeishuTarget(id: string, type?: FeishuIdType): string {
export function resolveReceiveIdType(id: string): "chat_id" | "open_id" | "user_id" {
const trimmed = id.trim();
const lowered = trimmed.toLowerCase();
if (lowered.startsWith("chat:") || lowered.startsWith("group:")) {
if (
lowered.startsWith("chat:") ||
lowered.startsWith("group:") ||
lowered.startsWith("channel:")
) {
return "chat_id";
}
if (lowered.startsWith("open_id:")) {

View File

@@ -17,6 +17,12 @@ const expressControl = vi.hoisted(() => ({
vi.mock("openclaw/plugin-sdk", () => ({
DEFAULT_WEBHOOK_MAX_BODY_BYTES: 1024 * 1024,
normalizeSecretInputString: (value: unknown) =>
typeof value === "string" && value.trim() ? value.trim() : undefined,
hasConfiguredSecretInput: (value: unknown) =>
typeof value === "string" && value.trim().length > 0,
normalizeResolvedSecretInputString: (params: { value?: unknown }) =>
typeof params?.value === "string" && params.value.trim() ? params.value.trim() : undefined,
keepHttpServerTaskAlive: vi.fn(
async (params: { abortSignal?: AbortSignal; onAbort?: () => Promise<void> | void }) => {
await new Promise<void>((resolve) => {

View File

@@ -51,7 +51,7 @@ function listSetupTokenProfiles(store: {
if (normalizeProviderId(cred.provider) !== "anthropic") {
return false;
}
return isSetupToken(cred.token);
return isSetupToken(cred.token ?? "");
})
.map(([id]) => id);
}

View File

@@ -9,6 +9,8 @@ describe("buildAuthHealthSummary", () => {
const now = 1_700_000_000_000;
const profileStatuses = (summary: ReturnType<typeof buildAuthHealthSummary>) =>
Object.fromEntries(summary.profiles.map((profile) => [profile.profileId, profile.status]));
const profileReasonCodes = (summary: ReturnType<typeof buildAuthHealthSummary>) =>
Object.fromEntries(summary.profiles.map((profile) => [profile.profileId, profile.reasonCode]));
afterEach(() => {
vi.restoreAllMocks();
@@ -89,6 +91,31 @@ describe("buildAuthHealthSummary", () => {
expect(statuses["google:no-refresh"]).toBe("expired");
});
it("marks token profiles with invalid expires as missing with reason code", () => {
vi.spyOn(Date, "now").mockReturnValue(now);
const store = {
version: 1,
profiles: {
"github-copilot:invalid-expires": {
type: "token" as const,
provider: "github-copilot",
token: "gh-token",
expires: 0,
},
},
};
const summary = buildAuthHealthSummary({
store,
warnAfterMs: DEFAULT_OAUTH_WARN_MS,
});
const statuses = profileStatuses(summary);
const reasonCodes = profileReasonCodes(summary);
expect(statuses["github-copilot:invalid-expires"]).toBe("missing");
expect(reasonCodes["github-copilot:invalid-expires"]).toBe("invalid_expires");
});
});
describe("formatRemainingShort", () => {

View File

@@ -1,9 +1,14 @@
import type { OpenClawConfig } from "../config/config.js";
import {
type AuthCredentialReasonCode,
type AuthProfileCredential,
type AuthProfileStore,
resolveAuthProfileDisplayLabel,
} from "./auth-profiles.js";
import {
evaluateStoredCredentialEligibility,
resolveTokenExpiryState,
} from "./auth-profiles/credential-state.js";
export type AuthProfileSource = "store";
@@ -14,6 +19,7 @@ export type AuthProfileHealth = {
provider: string;
type: "oauth" | "token" | "api_key";
status: AuthProfileHealthStatus;
reasonCode?: AuthCredentialReasonCode;
expiresAt?: number;
remainingMs?: number;
source: AuthProfileSource;
@@ -113,11 +119,26 @@ function buildProfileHealth(params: {
}
if (credential.type === "token") {
const expiresAt =
typeof credential.expires === "number" && Number.isFinite(credential.expires)
? credential.expires
: undefined;
if (!expiresAt || expiresAt <= 0) {
const eligibility = evaluateStoredCredentialEligibility({
credential,
now,
});
if (!eligibility.eligible) {
const status: AuthProfileHealthStatus =
eligibility.reasonCode === "expired" ? "expired" : "missing";
return {
profileId,
provider: credential.provider,
type: "token",
status,
reasonCode: eligibility.reasonCode,
source,
label,
};
}
const expiryState = resolveTokenExpiryState(credential.expires, now);
const expiresAt = expiryState === "valid" ? credential.expires : undefined;
if (!expiresAt) {
return {
profileId,
provider: credential.provider,
@@ -133,6 +154,7 @@ function buildProfileHealth(params: {
provider: credential.provider,
type: "token",
status,
reasonCode: status === "expired" ? "expired" : undefined,
expiresAt,
remainingMs,
source,

View File

@@ -12,7 +12,8 @@ describe("resolveAuthProfileOrder", () => {
function resolveMinimaxOrderWithProfile(profile: {
type: "token";
provider: "minimax";
token: string;
token?: string;
tokenRef?: { source: "env" | "file" | "exec"; provider: string; id: string };
expires?: number;
}) {
return resolveAuthProfileOrder({
@@ -189,10 +190,79 @@ describe("resolveAuthProfileOrder", () => {
expires: Date.now() - 1000,
},
},
{
caseName: "drops token profiles with invalid expires metadata",
profile: {
type: "token" as const,
provider: "minimax" as const,
token: "sk-minimax",
expires: 0,
},
},
])("$caseName", ({ profile }) => {
const order = resolveMinimaxOrderWithProfile(profile);
expect(order).toEqual([]);
});
it("keeps api_key profiles backed by keyRef when plaintext key is absent", () => {
const order = resolveAuthProfileOrder({
cfg: {
auth: {
order: {
anthropic: ["anthropic:default"],
},
},
},
store: {
version: 1,
profiles: {
"anthropic:default": {
type: "api_key",
provider: "anthropic",
keyRef: {
source: "exec",
provider: "vault_local",
id: "anthropic/default",
},
},
},
},
provider: "anthropic",
});
expect(order).toEqual(["anthropic:default"]);
});
it("keeps token profiles backed by tokenRef when expires is absent", () => {
const order = resolveMinimaxOrderWithProfile({
type: "token",
provider: "minimax",
tokenRef: {
source: "exec",
provider: "keychain",
id: "minimax/default",
},
});
expect(order).toEqual(["minimax:default"]);
});
it("drops tokenRef profiles when expires is invalid", () => {
const order = resolveMinimaxOrderWithProfile({
type: "token",
provider: "minimax",
tokenRef: {
source: "exec",
provider: "keychain",
id: "minimax/default",
},
expires: 0,
});
expect(order).toEqual([]);
});
it("keeps token profiles with inline token when no expires is set", () => {
const order = resolveMinimaxOrderWithProfile({
type: "token",
provider: "minimax",
token: "sk-minimax",
});
expect(order).toEqual(["minimax:default"]);
});
it("keeps oauth profiles that can refresh", () => {
const order = resolveAuthProfileOrder({
cfg: {

View File

@@ -1,8 +1,13 @@
export { CLAUDE_CLI_PROFILE_ID, CODEX_CLI_PROFILE_ID } from "./auth-profiles/constants.js";
export type {
AuthCredentialReasonCode,
TokenExpiryState,
} from "./auth-profiles/credential-state.js";
export type { AuthProfileEligibilityReasonCode } from "./auth-profiles/order.js";
export { resolveAuthProfileDisplayLabel } from "./auth-profiles/display.js";
export { formatAuthDoctorHint } from "./auth-profiles/doctor.js";
export { resolveApiKeyForProfile } from "./auth-profiles/oauth.js";
export { resolveAuthProfileOrder } from "./auth-profiles/order.js";
export { resolveAuthProfileEligibility, resolveAuthProfileOrder } from "./auth-profiles/order.js";
export { resolveAuthStorePathForDisplay } from "./auth-profiles/paths.js";
export {
dedupeProfileIds,

View File

@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import {
evaluateStoredCredentialEligibility,
resolveTokenExpiryState,
} from "./credential-state.js";
describe("resolveTokenExpiryState", () => {
const now = 1_700_000_000_000;
it("treats undefined as missing", () => {
expect(resolveTokenExpiryState(undefined, now)).toBe("missing");
});
it("treats non-finite and non-positive values as invalid_expires", () => {
expect(resolveTokenExpiryState(0, now)).toBe("invalid_expires");
expect(resolveTokenExpiryState(-1, now)).toBe("invalid_expires");
expect(resolveTokenExpiryState(Number.NaN, now)).toBe("invalid_expires");
expect(resolveTokenExpiryState(Number.POSITIVE_INFINITY, now)).toBe("invalid_expires");
});
it("returns expired when expires is in the past", () => {
expect(resolveTokenExpiryState(now - 1, now)).toBe("expired");
});
it("returns valid when expires is in the future", () => {
expect(resolveTokenExpiryState(now + 1, now)).toBe("valid");
});
});
describe("evaluateStoredCredentialEligibility", () => {
const now = 1_700_000_000_000;
it("marks api_key with keyRef as eligible", () => {
const result = evaluateStoredCredentialEligibility({
credential: {
type: "api_key",
provider: "anthropic",
keyRef: {
source: "env",
provider: "default",
id: "ANTHROPIC_API_KEY",
},
},
now,
});
expect(result).toEqual({ eligible: true, reasonCode: "ok" });
});
it("marks tokenRef with missing expires as eligible", () => {
const result = evaluateStoredCredentialEligibility({
credential: {
type: "token",
provider: "github-copilot",
tokenRef: {
source: "env",
provider: "default",
id: "GITHUB_TOKEN",
},
},
now,
});
expect(result).toEqual({ eligible: true, reasonCode: "ok" });
});
it("marks token with invalid expires as ineligible", () => {
const result = evaluateStoredCredentialEligibility({
credential: {
type: "token",
provider: "github-copilot",
token: "tok",
expires: 0,
},
now,
});
expect(result).toEqual({ eligible: false, reasonCode: "invalid_expires" });
});
});

View File

@@ -0,0 +1,74 @@
import { coerceSecretRef, normalizeSecretInputString } from "../../config/types.secrets.js";
import type { AuthProfileCredential } from "./types.js";
export type AuthCredentialReasonCode =
| "ok"
| "missing_credential"
| "invalid_expires"
| "expired"
| "unresolved_ref";
export type TokenExpiryState = "missing" | "valid" | "expired" | "invalid_expires";
export function resolveTokenExpiryState(expires: unknown, now = Date.now()): TokenExpiryState {
if (expires === undefined) {
return "missing";
}
if (typeof expires !== "number") {
return "invalid_expires";
}
if (!Number.isFinite(expires) || expires <= 0) {
return "invalid_expires";
}
return now >= expires ? "expired" : "valid";
}
function hasConfiguredSecretRef(value: unknown): boolean {
return coerceSecretRef(value) !== null;
}
function hasConfiguredSecretString(value: unknown): boolean {
return normalizeSecretInputString(value) !== undefined;
}
export function evaluateStoredCredentialEligibility(params: {
credential: AuthProfileCredential;
now?: number;
}): { eligible: boolean; reasonCode: AuthCredentialReasonCode } {
const now = params.now ?? Date.now();
const credential = params.credential;
if (credential.type === "api_key") {
const hasKey = hasConfiguredSecretString(credential.key);
const hasKeyRef = hasConfiguredSecretRef(credential.keyRef);
if (!hasKey && !hasKeyRef) {
return { eligible: false, reasonCode: "missing_credential" };
}
return { eligible: true, reasonCode: "ok" };
}
if (credential.type === "token") {
const hasToken = hasConfiguredSecretString(credential.token);
const hasTokenRef = hasConfiguredSecretRef(credential.tokenRef);
if (!hasToken && !hasTokenRef) {
return { eligible: false, reasonCode: "missing_credential" };
}
const expiryState = resolveTokenExpiryState(credential.expires, now);
if (expiryState === "invalid_expires") {
return { eligible: false, reasonCode: "invalid_expires" };
}
if (expiryState === "expired") {
return { eligible: false, reasonCode: "expired" };
}
return { eligible: true, reasonCode: "ok" };
}
if (
normalizeSecretInputString(credential.access) === undefined &&
normalizeSecretInputString(credential.refresh) === undefined
) {
return { eligible: false, reasonCode: "missing_credential" };
}
return { eligible: true, reasonCode: "ok" };
}

View File

@@ -16,7 +16,7 @@ function cfgFor(profileId: string, provider: string, mode: "api_key" | "token" |
function tokenStore(params: {
profileId: string;
provider: string;
token: string;
token?: string;
expires?: number;
}): AuthProfileStore {
return {
@@ -132,6 +132,45 @@ describe("resolveApiKeyForProfile config compatibility", () => {
});
describe("resolveApiKeyForProfile token expiry handling", () => {
it("accepts token credentials when expires is undefined", async () => {
const profileId = "anthropic:token-no-expiry";
const result = await resolveWithConfig({
profileId,
provider: "anthropic",
mode: "token",
store: tokenStore({
profileId,
provider: "anthropic",
token: "tok-123",
}),
});
expect(result).toEqual({
apiKey: "tok-123",
provider: "anthropic",
email: undefined,
});
});
it("accepts token credentials when expires is in the future", async () => {
const profileId = "anthropic:token-valid-expiry";
const result = await resolveWithConfig({
profileId,
provider: "anthropic",
mode: "token",
store: tokenStore({
profileId,
provider: "anthropic",
token: "tok-123",
expires: Date.now() + 60_000,
}),
});
expect(result).toEqual({
apiKey: "tok-123",
provider: "anthropic",
email: undefined,
});
});
it("returns null for expired token credentials", async () => {
const profileId = "anthropic:token-expired";
const result = await resolveWithConfig({
@@ -148,7 +187,7 @@ describe("resolveApiKeyForProfile token expiry handling", () => {
expect(result).toBeNull();
});
it("accepts token credentials when expires is 0", async () => {
it("returns null for token credentials when expires is 0", async () => {
const profileId = "anthropic:token-no-expiry";
const result = await resolveWithConfig({
profileId,
@@ -161,11 +200,30 @@ describe("resolveApiKeyForProfile token expiry handling", () => {
expires: 0,
}),
});
expect(result).toEqual({
apiKey: "tok-123",
expect(result).toBeNull();
});
it("returns null for token credentials when expires is invalid (NaN)", async () => {
const profileId = "anthropic:token-invalid-expiry";
const store = tokenStore({
profileId,
provider: "anthropic",
email: undefined,
token: "tok-123",
});
store.profiles[profileId] = {
...store.profiles[profileId],
type: "token",
provider: "anthropic",
token: "tok-123",
expires: Number.NaN,
};
const result = await resolveWithConfig({
profileId,
provider: "anthropic",
mode: "token",
store,
});
expect(result).toBeNull();
});
});
@@ -237,6 +295,39 @@ describe("resolveApiKeyForProfile secret refs", () => {
}
});
it("resolves token tokenRef without inline token when expires is absent", async () => {
const profileId = "github-copilot:no-inline-token";
const previous = process.env.GITHUB_TOKEN;
process.env.GITHUB_TOKEN = "gh-ref-token";
try {
const result = await resolveApiKeyForProfile({
cfg: cfgFor(profileId, "github-copilot", "token"),
store: {
version: 1,
profiles: {
[profileId]: {
type: "token",
provider: "github-copilot",
tokenRef: { source: "env", provider: "default", id: "GITHUB_TOKEN" },
},
},
},
profileId,
});
expect(result).toEqual({
apiKey: "gh-ref-token",
provider: "github-copilot",
email: undefined,
});
} finally {
if (previous === undefined) {
delete process.env.GITHUB_TOKEN;
} else {
process.env.GITHUB_TOKEN = previous;
}
}
});
it("resolves inline ${ENV} api_key values", async () => {
const profileId = "openai:inline-env";
const previous = process.env.OPENAI_API_KEY;

View File

@@ -11,6 +11,7 @@ import { refreshQwenPortalCredentials } from "../../providers/qwen-portal-oauth.
import { resolveSecretRefString, type SecretRefResolveCache } from "../../secrets/resolve.js";
import { refreshChutesTokens } from "../chutes-oauth.js";
import { AUTH_STORE_LOCK_OPTIONS, log } from "./constants.js";
import { resolveTokenExpiryState } from "./credential-state.js";
import { formatAuthDoctorHint } from "./doctor.js";
import { ensureAuthStoreFile, resolveAuthStorePath } from "./paths.js";
import { suggestOAuthProfileIdForLegacyDefault } from "./repair.js";
@@ -86,12 +87,6 @@ function buildOAuthProfileResult(params: {
});
}
function isExpiredCredential(expires: number | undefined): boolean {
return (
typeof expires === "number" && Number.isFinite(expires) && expires > 0 && Date.now() >= expires
);
}
type ResolveApiKeyForProfileParams = {
cfg?: OpenClawConfig;
store: AuthProfileStore;
@@ -332,6 +327,10 @@ export async function resolveApiKeyForProfile(
return buildApiKeyProfileResult({ apiKey: key, provider: cred.provider, email: cred.email });
}
if (cred.type === "token") {
const expiryState = resolveTokenExpiryState(cred.expires);
if (expiryState === "expired" || expiryState === "invalid_expires") {
return null;
}
const token = await resolveProfileSecretString({
profileId,
provider: cred.provider,
@@ -346,9 +345,6 @@ export async function resolveApiKeyForProfile(
if (!token) {
return null;
}
if (isExpiredCredential(cred.expires)) {
return null;
}
return buildApiKeyProfileResult({ apiKey: token, provider: cred.provider, email: cred.email });
}

View File

@@ -4,6 +4,10 @@ import {
normalizeProviderId,
normalizeProviderIdForAuth,
} from "../model-selection.js";
import {
evaluateStoredCredentialEligibility,
type AuthCredentialReasonCode,
} from "./credential-state.js";
import { dedupeProfileIds, listProfilesForProvider } from "./profiles.js";
import type { AuthProfileStore } from "./types.js";
import {
@@ -12,6 +16,54 @@ import {
resolveProfileUnusableUntil,
} from "./usage.js";
export type AuthProfileEligibilityReasonCode =
| AuthCredentialReasonCode
| "profile_missing"
| "provider_mismatch"
| "mode_mismatch";
export type AuthProfileEligibility = {
eligible: boolean;
reasonCode: AuthProfileEligibilityReasonCode;
};
export function resolveAuthProfileEligibility(params: {
cfg?: OpenClawConfig;
store: AuthProfileStore;
provider: string;
profileId: string;
now?: number;
}): AuthProfileEligibility {
const providerAuthKey = normalizeProviderIdForAuth(params.provider);
const cred = params.store.profiles[params.profileId];
if (!cred) {
return { eligible: false, reasonCode: "profile_missing" };
}
if (normalizeProviderIdForAuth(cred.provider) !== providerAuthKey) {
return { eligible: false, reasonCode: "provider_mismatch" };
}
const profileConfig = params.cfg?.auth?.profiles?.[params.profileId];
if (profileConfig) {
if (normalizeProviderIdForAuth(profileConfig.provider) !== providerAuthKey) {
return { eligible: false, reasonCode: "provider_mismatch" };
}
if (profileConfig.mode !== cred.type) {
const oauthCompatible = profileConfig.mode === "oauth" && cred.type === "token";
if (!oauthCompatible) {
return { eligible: false, reasonCode: "mode_mismatch" };
}
}
}
const credentialEligibility = evaluateStoredCredentialEligibility({
credential: cred,
now: params.now,
});
return {
eligible: credentialEligibility.eligible,
reasonCode: credentialEligibility.reasonCode,
};
}
export function resolveAuthProfileOrder(params: {
cfg?: OpenClawConfig;
store: AuthProfileStore;
@@ -42,48 +94,14 @@ export function resolveAuthProfileOrder(params: {
return [];
}
const isValidProfile = (profileId: string): boolean => {
const cred = store.profiles[profileId];
if (!cred) {
return false;
}
if (normalizeProviderIdForAuth(cred.provider) !== providerAuthKey) {
return false;
}
const profileConfig = cfg?.auth?.profiles?.[profileId];
if (profileConfig) {
if (normalizeProviderIdForAuth(profileConfig.provider) !== providerAuthKey) {
return false;
}
if (profileConfig.mode !== cred.type) {
const oauthCompatible = profileConfig.mode === "oauth" && cred.type === "token";
if (!oauthCompatible) {
return false;
}
}
}
if (cred.type === "api_key") {
return Boolean(cred.key?.trim());
}
if (cred.type === "token") {
if (!cred.token?.trim()) {
return false;
}
if (
typeof cred.expires === "number" &&
Number.isFinite(cred.expires) &&
cred.expires > 0 &&
now >= cred.expires
) {
return false;
}
return true;
}
if (cred.type === "oauth") {
return Boolean(cred.access?.trim() || cred.refresh?.trim());
}
return false;
};
const isValidProfile = (profileId: string): boolean =>
resolveAuthProfileEligibility({
cfg,
store,
provider: providerAuthKey,
profileId,
now,
}).eligible;
let filtered = baseOrder.filter(isValidProfile);
// Repair config/store profile-id drift from older onboarding flows:

View File

@@ -19,7 +19,7 @@ export type TokenCredential = {
*/
type: "token";
provider: string;
token: string;
token?: string;
tokenRef?: SecretRef;
/** Optional expiry timestamp (ms since epoch). */
expires?: number;

View File

@@ -211,9 +211,8 @@ export function registerTriggerHandlingUsageSummaryCases(params: {
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("api-key");
expect(text).toMatch(/\u2026|\.{3}/);
expect(text).toContain("sk-tes");
expect(text).toContain("abcdef");
expect(text).not.toContain("sk-test");
expect(text).not.toContain("abcdef");
expect(text).not.toContain("1234567890abcdef");
expect(text).toContain("(anthropic:work)");
expect(text).not.toContain("mixed");

View File

@@ -0,0 +1,114 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AuthProfileStore } from "../../agents/auth-profiles.js";
import type { OpenClawConfig } from "../../config/config.js";
let mockStore: AuthProfileStore;
let mockOrder: string[];
vi.mock("../../agents/auth-health.js", () => ({
formatRemainingShort: () => "1h",
}));
vi.mock("../../agents/auth-profiles.js", () => ({
isProfileInCooldown: () => false,
resolveAuthProfileDisplayLabel: ({ profileId }: { profileId: string }) => profileId,
resolveAuthStorePathForDisplay: () => "/tmp/auth-profiles.json",
}));
vi.mock("../../agents/model-selection.js", () => ({
findNormalizedProviderValue: (
values: Record<string, unknown> | undefined,
provider: string,
): unknown => {
if (!values) {
return undefined;
}
return Object.entries(values).find(
([key]) => key.toLowerCase() === provider.toLowerCase(),
)?.[1];
},
normalizeProviderId: (provider: string) => provider.trim().toLowerCase(),
}));
vi.mock("../../agents/model-auth.js", () => ({
ensureAuthProfileStore: () => mockStore,
getCustomProviderApiKey: () => undefined,
resolveAuthProfileOrder: () => mockOrder,
resolveEnvApiKey: () => null,
}));
const { resolveAuthLabel } = await import("./directive-handling.auth.js");
describe("resolveAuthLabel ref-aware labels", () => {
beforeEach(() => {
mockStore = {
version: 1,
profiles: {},
};
mockOrder = [];
});
it("shows api-key (ref) for keyRef-only profiles in compact mode", async () => {
mockStore.profiles = {
"openai:default": {
type: "api_key",
provider: "openai",
keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
},
};
mockOrder = ["openai:default"];
const result = await resolveAuthLabel(
"openai",
{} as OpenClawConfig,
"/tmp/models.json",
undefined,
"compact",
);
expect(result.label).toBe("openai:default api-key (ref)");
});
it("shows token (ref) for tokenRef-only profiles in compact mode", async () => {
mockStore.profiles = {
"github-copilot:default": {
type: "token",
provider: "github-copilot",
tokenRef: { source: "env", provider: "default", id: "GITHUB_TOKEN" },
},
};
mockOrder = ["github-copilot:default"];
const result = await resolveAuthLabel(
"github-copilot",
{} as OpenClawConfig,
"/tmp/models.json",
undefined,
"compact",
);
expect(result.label).toBe("github-copilot:default token (ref)");
});
it("uses token:ref instead of token:missing in verbose mode", async () => {
mockStore.profiles = {
"github-copilot:default": {
type: "token",
provider: "github-copilot",
tokenRef: { source: "env", provider: "default", id: "GITHUB_TOKEN" },
},
};
mockOrder = ["github-copilot:default"];
const result = await resolveAuthLabel(
"github-copilot",
{} as OpenClawConfig,
"/tmp/models.json",
undefined,
"verbose",
);
expect(result.label).toContain("github-copilot:default=token:ref");
expect(result.label).not.toContain("token:missing");
});
});

View File

@@ -12,11 +12,27 @@ import {
} from "../../agents/model-auth.js";
import { findNormalizedProviderValue, normalizeProviderId } from "../../agents/model-selection.js";
import type { OpenClawConfig } from "../../config/config.js";
import { coerceSecretRef } from "../../config/types.secrets.js";
import { shortenHomePath } from "../../utils.js";
import { maskApiKey } from "../../utils/mask-api-key.js";
export type ModelAuthDetailMode = "compact" | "verbose";
function resolveStoredCredentialLabel(params: {
value: unknown;
refValue: unknown;
mode: ModelAuthDetailMode;
}): string {
const masked = maskApiKey(typeof params.value === "string" ? params.value : "");
if (masked !== "missing") {
return masked;
}
if (coerceSecretRef(params.refValue)) {
return params.mode === "compact" ? "(ref)" : "ref";
}
return "missing";
}
export const resolveAuthLabel = async (
provider: string,
cfg: OpenClawConfig,
@@ -57,12 +73,22 @@ export const resolveAuthLabel = async (
}
if (profile.type === "api_key") {
const keyLabel = resolveStoredCredentialLabel({
value: profile.key,
refValue: profile.keyRef,
mode,
});
return {
label: `${profileId} api-key ${maskApiKey(profile.key ?? "")}${more}`,
label: `${profileId} api-key ${keyLabel}${more}`,
source: "",
};
}
if (profile.type === "token") {
const tokenLabel = resolveStoredCredentialLabel({
value: profile.token,
refValue: profile.tokenRef,
mode,
});
const exp =
typeof profile.expires === "number" &&
Number.isFinite(profile.expires) &&
@@ -72,7 +98,7 @@ export const resolveAuthLabel = async (
: ` exp ${formatUntil(profile.expires)}`
: "";
return {
label: `${profileId} token ${maskApiKey(profile.token)}${exp}${more}`,
label: `${profileId} token ${tokenLabel}${exp}${more}`,
source: "",
};
}
@@ -118,10 +144,20 @@ export const resolveAuthLabel = async (
return `${profileId}=missing${suffix}`;
}
if (profile.type === "api_key") {
const keyLabel = resolveStoredCredentialLabel({
value: profile.key,
refValue: profile.keyRef,
mode,
});
const suffix = flags.length > 0 ? ` (${flags.join(", ")})` : "";
return `${profileId}=${maskApiKey(profile.key ?? "")}${suffix}`;
return `${profileId}=${keyLabel}${suffix}`;
}
if (profile.type === "token") {
const tokenLabel = resolveStoredCredentialLabel({
value: profile.token,
refValue: profile.tokenRef,
mode,
});
if (
typeof profile.expires === "number" &&
Number.isFinite(profile.expires) &&
@@ -130,7 +166,7 @@ export const resolveAuthLabel = async (
flags.push(profile.expires <= now ? "expired" : `exp ${formatUntil(profile.expires)}`);
}
const suffix = flags.length > 0 ? ` (${flags.join(", ")})` : "";
return `${profileId}=token:${maskApiKey(profile.token)}${suffix}`;
return `${profileId}=token:${tokenLabel}${suffix}`;
}
const display = resolveAuthProfileDisplayLabel({
cfg,

View File

@@ -15,8 +15,14 @@ export async function writeViaSiblingTempPath(params: {
targetPath: string;
writeTemp: (tempPath: string) => Promise<void>;
}): Promise<void> {
const rootDir = path.resolve(params.rootDir);
const targetPath = path.resolve(params.targetPath);
const rootDir = await fs
.realpath(path.resolve(params.rootDir))
.catch(() => path.resolve(params.rootDir));
const requestedTargetPath = path.resolve(params.targetPath);
const targetPath = await fs
.realpath(path.dirname(requestedTargetPath))
.then((realDir) => path.join(realDir, path.basename(requestedTargetPath)))
.catch(() => requestedTargetPath);
const relativeTargetPath = path.relative(rootDir, targetPath);
if (
!relativeTargetPath ||

View File

@@ -4,11 +4,7 @@ import path from "node:path";
import type { Page } from "playwright-core";
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
import { writeViaSiblingTempPath } from "./output-atomic.js";
import {
DEFAULT_DOWNLOAD_DIR,
DEFAULT_UPLOAD_DIR,
resolveStrictExistingPathsWithinRoot,
} from "./paths.js";
import { DEFAULT_UPLOAD_DIR, resolveStrictExistingPathsWithinRoot } from "./paths.js";
import {
ensurePageState,
getPageForTargetId,
@@ -96,7 +92,7 @@ async function saveDownloadPayload(download: DownloadPayload, outPath: string) {
await download.saveAs?.(resolvedOutPath);
} else {
await writeViaSiblingTempPath({
rootDir: DEFAULT_DOWNLOAD_DIR,
rootDir: path.dirname(resolvedOutPath),
targetPath: resolvedOutPath,
writeTemp: async (tempPath) => {
await download.saveAs?.(tempPath);

View File

@@ -87,7 +87,11 @@ describe("pw-tools-core", () => {
const savedPath = params.saveAs.mock.calls[0]?.[0];
expect(typeof savedPath).toBe("string");
expect(savedPath).not.toBe(params.targetPath);
expect(path.dirname(String(savedPath))).toBe(params.tempDir);
const [savedDirReal, tempDirReal] = await Promise.all([
fs.realpath(path.dirname(String(savedPath))).catch(() => path.dirname(String(savedPath))),
fs.realpath(params.tempDir).catch(() => params.tempDir),
]);
expect(savedDirReal).toBe(tempDirReal);
expect(path.basename(String(savedPath))).toContain(".openclaw-output-");
expect(path.basename(String(savedPath))).toContain(".part");
expect(await fs.readFile(params.targetPath, "utf8")).toBe(params.content);
@@ -120,7 +124,7 @@ describe("pw-tools-core", () => {
const res = await p;
await expectAtomicDownloadSave({ saveAs, targetPath, tempDir, content: "file-content" });
expect(res.path).toBe(targetPath);
await expect(fs.realpath(res.path)).resolves.toBe(await fs.realpath(targetPath));
});
});
it("clicks a ref and atomically finalizes explicit download paths", async () => {
@@ -156,7 +160,7 @@ describe("pw-tools-core", () => {
const res = await p;
await expectAtomicDownloadSave({ saveAs, targetPath, tempDir, content: "report-content" });
expect(res.path).toBe(targetPath);
await expect(fs.realpath(res.path)).resolves.toBe(await fs.realpath(targetPath));
});
});
@@ -188,9 +192,8 @@ describe("pw-tools-core", () => {
saveAs,
});
const res = await p;
expect(res.path).toBe(linkedPath);
expect(await fs.readFile(linkedPath, "utf8")).toBe("download-content");
await expect(p).rejects.toThrow(/alias escape blocked|Hardlinked path is not allowed/i);
expect(await fs.readFile(linkedPath, "utf8")).toBe("outside-before");
expect(await fs.readFile(outsidePath, "utf8")).toBe("outside-before");
});
},

View File

@@ -4,6 +4,7 @@ import {
formatRemainingShort,
} from "../agents/auth-health.js";
import {
type AuthCredentialReasonCode,
CLAUDE_CLI_PROFILE_ID,
CODEX_CLI_PROFILE_ID,
ensureAuthProfileStore,
@@ -203,6 +204,7 @@ type AuthIssue = {
profileId: string;
provider: string;
status: string;
reasonCode?: AuthCredentialReasonCode;
remainingMs?: number;
};
@@ -222,6 +224,9 @@ export function resolveUnusableProfileHint(params: {
}
function formatAuthIssueHint(issue: AuthIssue): string | null {
if (issue.reasonCode === "invalid_expires") {
return "Invalid token expires metadata. Set a future Unix ms timestamp or remove expires.";
}
if (issue.provider === "anthropic" && issue.profileId === CLAUDE_CLI_PROFILE_ID) {
return `Deprecated profile. Use ${formatCliCommand("openclaw models auth setup-token")} or ${formatCliCommand(
"openclaw configure",
@@ -239,7 +244,8 @@ function formatAuthIssueLine(issue: AuthIssue): string {
const remaining =
issue.remainingMs !== undefined ? ` (${formatRemainingShort(issue.remainingMs)})` : "";
const hint = formatAuthIssueHint(issue);
return `- ${issue.profileId}: ${issue.status}${remaining}${hint ? `${hint}` : ""}`;
const reason = issue.reasonCode ? ` [${issue.reasonCode}]` : "";
return `- ${issue.profileId}: ${issue.status}${reason}${remaining}${hint ? `${hint}` : ""}`;
}
export async function noteAuthProfileHealth(params: {
@@ -340,6 +346,7 @@ export async function noteAuthProfileHealth(params: {
profileId: issue.profileId,
provider: issue.provider,
status: issue.status,
reasonCode: issue.reasonCode,
remainingMs: issue.remainingMs,
}),
)

View File

@@ -0,0 +1,166 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AuthProfileStore } from "../../agents/auth-profiles.js";
import type { OpenClawConfig } from "../../config/config.js";
let mockStore: AuthProfileStore;
let mockAllowedProfiles: string[];
const resolveAuthProfileOrderMock = vi.fn(() => mockAllowedProfiles);
const resolveAuthProfileEligibilityMock = vi.fn(() => ({
eligible: false,
reasonCode: "invalid_expires" as const,
}));
const resolveSecretRefStringMock = vi.fn(async () => "resolved-secret");
vi.mock("../../agents/model-catalog.js", () => ({
loadModelCatalog: vi.fn(async () => []),
}));
vi.mock("../../secrets/resolve.js", () => ({
resolveSecretRefString: resolveSecretRefStringMock,
}));
vi.mock("../../agents/auth-profiles.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../agents/auth-profiles.js")>();
return {
...actual,
ensureAuthProfileStore: () => mockStore,
listProfilesForProvider: (_store: AuthProfileStore, provider: string) =>
Object.entries(mockStore.profiles)
.filter(
([, profile]) =>
typeof profile.provider === "string" && profile.provider.toLowerCase() === provider,
)
.map(([profileId]) => profileId),
resolveAuthProfileDisplayLabel: ({ profileId }: { profileId: string }) => profileId,
resolveAuthProfileOrder: resolveAuthProfileOrderMock,
resolveAuthProfileEligibility: resolveAuthProfileEligibilityMock,
};
});
const { buildProbeTargets } = await import("./list.probe.js");
describe("buildProbeTargets reason codes", () => {
beforeEach(() => {
mockStore = {
version: 1,
profiles: {
"anthropic:default": {
type: "token",
provider: "anthropic",
tokenRef: { source: "env", provider: "default", id: "ANTHROPIC_TOKEN" },
expires: 0,
},
},
order: {
anthropic: ["anthropic:default"],
},
};
mockAllowedProfiles = [];
resolveAuthProfileOrderMock.mockClear();
resolveAuthProfileEligibilityMock.mockClear();
resolveSecretRefStringMock.mockReset();
resolveSecretRefStringMock.mockResolvedValue("resolved-secret");
resolveAuthProfileEligibilityMock.mockReturnValue({
eligible: false,
reasonCode: "invalid_expires",
});
});
it("reports invalid_expires with a legacy-compatible first error line", async () => {
const plan = await buildProbeTargets({
cfg: {
auth: {
order: {
anthropic: ["anthropic:default"],
},
},
} as OpenClawConfig,
providers: ["anthropic"],
modelCandidates: ["anthropic/claude-sonnet-4-6"],
options: {
timeoutMs: 5_000,
concurrency: 1,
maxTokens: 16,
},
});
expect(plan.targets).toHaveLength(0);
expect(plan.results).toHaveLength(1);
expect(plan.results[0]?.reasonCode).toBe("invalid_expires");
expect(plan.results[0]?.error?.split("\n")[0]).toBe(
"Auth profile credentials are missing or expired.",
);
expect(plan.results[0]?.error).toContain("[invalid_expires]");
});
it("reports excluded_by_auth_order when profile id is not present in explicit order", async () => {
mockStore.order = {
anthropic: ["anthropic:work"],
};
const plan = await buildProbeTargets({
cfg: {
auth: {
order: {
anthropic: ["anthropic:work"],
},
},
} as OpenClawConfig,
providers: ["anthropic"],
modelCandidates: ["anthropic/claude-sonnet-4-6"],
options: {
timeoutMs: 5_000,
concurrency: 1,
maxTokens: 16,
},
});
expect(plan.targets).toHaveLength(0);
expect(plan.results).toHaveLength(1);
expect(plan.results[0]?.reasonCode).toBe("excluded_by_auth_order");
expect(plan.results[0]?.error).toBe("Excluded by auth.order for this provider.");
});
it("reports unresolved_ref when a ref-only profile cannot resolve its SecretRef", async () => {
mockStore = {
version: 1,
profiles: {
"anthropic:default": {
type: "token",
provider: "anthropic",
tokenRef: { source: "env", provider: "default", id: "MISSING_ANTHROPIC_TOKEN" },
},
},
order: {
anthropic: ["anthropic:default"],
},
};
mockAllowedProfiles = ["anthropic:default"];
resolveSecretRefStringMock.mockRejectedValueOnce(new Error("missing secret"));
const plan = await buildProbeTargets({
cfg: {
auth: {
order: {
anthropic: ["anthropic:default"],
},
},
} as OpenClawConfig,
providers: ["anthropic"],
modelCandidates: ["anthropic/claude-sonnet-4-6"],
options: {
timeoutMs: 5_000,
concurrency: 1,
maxTokens: 16,
},
});
expect(plan.targets).toHaveLength(0);
expect(plan.results).toHaveLength(1);
expect(plan.results[0]?.reasonCode).toBe("unresolved_ref");
expect(plan.results[0]?.error?.split("\n")[0]).toBe(
"Auth profile credentials are missing or expired.",
);
expect(plan.results[0]?.error).toContain("[unresolved_ref]");
expect(plan.results[0]?.error).toContain("env:default:MISSING_ANTHROPIC_TOKEN");
});
});

View File

@@ -3,9 +3,12 @@ import fs from "node:fs/promises";
import { resolveOpenClawAgentDir } from "../../agents/agent-paths.js";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import {
type AuthProfileCredential,
type AuthProfileEligibilityReasonCode,
ensureAuthProfileStore,
listProfilesForProvider,
resolveAuthProfileDisplayLabel,
resolveAuthProfileEligibility,
resolveAuthProfileOrder,
} from "../../agents/auth-profiles.js";
import { describeFailoverError } from "../../agents/failover-error.js";
@@ -23,6 +26,8 @@ import {
resolveSessionTranscriptPath,
resolveSessionTranscriptsDirForAgent,
} from "../../config/sessions/paths.js";
import { coerceSecretRef, normalizeSecretInputString } from "../../config/types.secrets.js";
import { type SecretRefResolveCache, resolveSecretRefString } from "../../secrets/resolve.js";
import { redactSecrets } from "../status-all/format.js";
import { DEFAULT_PROVIDER, formatMs } from "./shared.js";
@@ -38,6 +43,15 @@ export type AuthProbeStatus =
| "unknown"
| "no_model";
export type AuthProbeReasonCode =
| "excluded_by_auth_order"
| "missing_credential"
| "expired"
| "invalid_expires"
| "unresolved_ref"
| "ineligible_profile"
| "no_model";
export type AuthProbeResult = {
provider: string;
model?: string;
@@ -46,6 +60,7 @@ export type AuthProbeResult = {
source: "profile" | "env" | "models.json";
mode?: string;
status: AuthProbeStatus;
reasonCode?: AuthProbeReasonCode;
error?: string;
latencyMs?: number;
};
@@ -139,7 +154,91 @@ function selectProbeModel(params: {
return null;
}
function buildProbeTargets(params: {
function mapEligibilityReasonToProbeReasonCode(
reasonCode: AuthProfileEligibilityReasonCode,
): AuthProbeReasonCode {
if (reasonCode === "missing_credential") {
return "missing_credential";
}
if (reasonCode === "expired") {
return "expired";
}
if (reasonCode === "invalid_expires") {
return "invalid_expires";
}
if (reasonCode === "unresolved_ref") {
return "unresolved_ref";
}
return "ineligible_profile";
}
function formatMissingCredentialProbeError(reasonCode: AuthProbeReasonCode): string {
const legacyLine = "Auth profile credentials are missing or expired.";
if (reasonCode === "expired") {
return `${legacyLine}\n↳ Auth reason [expired]: token credentials are expired.`;
}
if (reasonCode === "invalid_expires") {
return `${legacyLine}\n↳ Auth reason [invalid_expires]: token expires must be a positive Unix ms timestamp.`;
}
if (reasonCode === "missing_credential") {
return `${legacyLine}\n↳ Auth reason [missing_credential]: no inline credential or SecretRef is configured.`;
}
if (reasonCode === "unresolved_ref") {
return `${legacyLine}\n↳ Auth reason [unresolved_ref]: configured SecretRef could not be resolved.`;
}
return `${legacyLine}\n↳ Auth reason [ineligible_profile]: profile is incompatible with provider config.`;
}
function resolveProbeSecretRef(profile: AuthProfileCredential, cfg: OpenClawConfig) {
const defaults = cfg.secrets?.defaults;
if (profile.type === "api_key") {
if (normalizeSecretInputString(profile.key) !== undefined) {
return null;
}
return coerceSecretRef(profile.keyRef, defaults);
}
if (profile.type === "token") {
if (normalizeSecretInputString(profile.token) !== undefined) {
return null;
}
return coerceSecretRef(profile.tokenRef, defaults);
}
return null;
}
function formatUnresolvedRefProbeError(refLabel: string): string {
const legacyLine = "Auth profile credentials are missing or expired.";
return `${legacyLine}\n↳ Auth reason [unresolved_ref]: could not resolve SecretRef "${refLabel}".`;
}
async function maybeResolveUnresolvedRefIssue(params: {
cfg: OpenClawConfig;
profile?: AuthProfileCredential;
cache: SecretRefResolveCache;
}): Promise<{ reasonCode: "unresolved_ref"; error: string } | null> {
if (!params.profile) {
return null;
}
const ref = resolveProbeSecretRef(params.profile, params.cfg);
if (!ref) {
return null;
}
try {
await resolveSecretRefString(ref, {
config: params.cfg,
env: process.env,
cache: params.cache,
});
return null;
} catch {
return {
reasonCode: "unresolved_ref",
error: formatUnresolvedRefProbeError(`${ref.source}:${ref.provider}:${ref.id}`),
};
}
}
export async function buildProbeTargets(params: {
cfg: OpenClawConfig;
providers: string[];
modelCandidates: string[];
@@ -150,133 +249,162 @@ function buildProbeTargets(params: {
const providerFilter = options.provider?.trim();
const providerFilterKey = providerFilter ? normalizeProviderId(providerFilter) : null;
const profileFilter = new Set((options.profileIds ?? []).map((id) => id.trim()).filter(Boolean));
const refResolveCache: SecretRefResolveCache = {};
const catalog = await loadModelCatalog({ config: cfg });
const candidates = buildCandidateMap(modelCandidates);
const targets: AuthProbeTarget[] = [];
const results: AuthProbeResult[] = [];
return loadModelCatalog({ config: cfg }).then((catalog) => {
const candidates = buildCandidateMap(modelCandidates);
const targets: AuthProbeTarget[] = [];
const results: AuthProbeResult[] = [];
for (const provider of providers) {
const providerKey = normalizeProviderId(provider);
if (providerFilterKey && providerKey !== providerFilterKey) {
continue;
}
for (const provider of providers) {
const providerKey = normalizeProviderId(provider);
if (providerFilterKey && providerKey !== providerFilterKey) {
continue;
}
const model = selectProbeModel({
provider: providerKey,
candidates,
catalog,
});
const model = selectProbeModel({
provider: providerKey,
candidates,
catalog,
});
const profileIds = listProfilesForProvider(store, providerKey);
const explicitOrder = (() => {
return (
findNormalizedProviderValue(store.order, providerKey) ??
findNormalizedProviderValue(cfg?.auth?.order, providerKey)
);
})();
const allowedProfiles =
explicitOrder && explicitOrder.length > 0
? new Set(resolveAuthProfileOrder({ cfg, store, provider: providerKey }))
: null;
const filteredProfiles = profileFilter.size
? profileIds.filter((id) => profileFilter.has(id))
: profileIds;
const profileIds = listProfilesForProvider(store, providerKey);
const explicitOrder = (() => {
return (
findNormalizedProviderValue(store.order, providerKey) ??
findNormalizedProviderValue(cfg?.auth?.order, providerKey)
);
})();
const allowedProfiles =
explicitOrder && explicitOrder.length > 0
? new Set(resolveAuthProfileOrder({ cfg, store, provider: providerKey }))
: null;
const filteredProfiles = profileFilter.size
? profileIds.filter((id) => profileFilter.has(id))
: profileIds;
if (filteredProfiles.length > 0) {
for (const profileId of filteredProfiles) {
const profile = store.profiles[profileId];
const mode = profile?.type;
const label = resolveAuthProfileDisplayLabel({ cfg, store, profileId });
if (explicitOrder && !explicitOrder.includes(profileId)) {
results.push({
provider: providerKey,
model: model ? `${model.provider}/${model.model}` : undefined,
profileId,
label,
source: "profile",
mode,
status: "unknown",
error: "Excluded by auth.order for this provider.",
});
continue;
}
if (allowedProfiles && !allowedProfiles.has(profileId)) {
results.push({
provider: providerKey,
model: model ? `${model.provider}/${model.model}` : undefined,
profileId,
label,
source: "profile",
mode,
status: "unknown",
error: "Auth profile credentials are missing or expired.",
});
continue;
}
if (!model) {
results.push({
provider: providerKey,
model: undefined,
profileId,
label,
source: "profile",
mode,
status: "no_model",
error: "No model available for probe",
});
continue;
}
targets.push({
if (filteredProfiles.length > 0) {
for (const profileId of filteredProfiles) {
const profile = store.profiles[profileId];
const mode = profile?.type;
const label = resolveAuthProfileDisplayLabel({ cfg, store, profileId });
if (explicitOrder && !explicitOrder.includes(profileId)) {
results.push({
provider: providerKey,
model,
profileId,
model: model ? `${model.provider}/${model.model}` : undefined,
label,
source: "profile",
mode,
status: "unknown",
reasonCode: "excluded_by_auth_order",
error: "Excluded by auth.order for this provider.",
});
continue;
}
if (allowedProfiles && !allowedProfiles.has(profileId)) {
const eligibility = resolveAuthProfileEligibility({
cfg,
store,
provider: providerKey,
profileId,
});
const reasonCode = mapEligibilityReasonToProbeReasonCode(eligibility.reasonCode);
results.push({
provider: providerKey,
model: model ? `${model.provider}/${model.model}` : undefined,
profileId,
label,
source: "profile",
mode,
status: "unknown",
reasonCode,
error: formatMissingCredentialProbeError(reasonCode),
});
continue;
}
continue;
}
if (profileFilter.size > 0) {
continue;
}
const envKey = resolveEnvApiKey(providerKey);
const customKey = getCustomProviderApiKey(cfg, providerKey);
if (!envKey && !customKey) {
continue;
}
const label = envKey ? "env" : "models.json";
const source = envKey ? "env" : "models.json";
const mode = envKey?.source.includes("OAUTH_TOKEN") ? "oauth" : "api_key";
if (!model) {
results.push({
provider: providerKey,
model: undefined,
label,
source,
mode,
status: "no_model",
error: "No model available for probe",
const unresolvedRefIssue = await maybeResolveUnresolvedRefIssue({
cfg,
profile,
cache: refResolveCache,
});
if (unresolvedRefIssue) {
results.push({
provider: providerKey,
model: model ? `${model.provider}/${model.model}` : undefined,
profileId,
label,
source: "profile",
mode,
status: "unknown",
reasonCode: unresolvedRefIssue.reasonCode,
error: unresolvedRefIssue.error,
});
continue;
}
if (!model) {
results.push({
provider: providerKey,
model: undefined,
profileId,
label,
source: "profile",
mode,
status: "no_model",
reasonCode: "no_model",
error: "No model available for probe",
});
continue;
}
targets.push({
provider: providerKey,
model,
profileId,
label,
source: "profile",
mode,
});
continue;
}
continue;
}
targets.push({
if (profileFilter.size > 0) {
continue;
}
const envKey = resolveEnvApiKey(providerKey);
const customKey = getCustomProviderApiKey(cfg, providerKey);
if (!envKey && !customKey) {
continue;
}
const label = envKey ? "env" : "models.json";
const source = envKey ? "env" : "models.json";
const mode = envKey?.source.includes("OAUTH_TOKEN") ? "oauth" : "api_key";
if (!model) {
results.push({
provider: providerKey,
model,
model: undefined,
label,
source,
mode,
status: "no_model",
reasonCode: "no_model",
error: "No model available for probe",
});
continue;
}
return { targets, results };
});
targets.push({
provider: providerKey,
model,
label,
source,
mode,
});
}
return { targets, results };
}
async function probeTarget(params: {
@@ -299,6 +427,7 @@ async function probeTarget(params: {
source: target.source,
mode: target.mode,
status: "no_model",
reasonCode: "no_model",
error: "No model available for probe",
};
}

View File

@@ -299,6 +299,7 @@ describe("discord tool result dispatch", () => {
client,
);
await vi.waitFor(() => expect(dispatchMock).toHaveBeenCalledTimes(1));
expect(dispatchMock).toHaveBeenCalledTimes(1);
expect(sendMock).toHaveBeenCalledTimes(1);
},
@@ -394,6 +395,7 @@ describe("discord tool result dispatch", () => {
client,
);
await vi.waitFor(() => expect(dispatchMock).toHaveBeenCalledTimes(1));
expect(dispatchMock).toHaveBeenCalledTimes(1);
const payload = dispatchMock.mock.calls[0]?.[0]?.ctx as Record<string, unknown>;
expect(payload.WasMentioned).toBe(true);
@@ -407,6 +409,7 @@ describe("discord tool result dispatch", () => {
const client = createThreadClient();
await handler(createThreadEvent("m4", threadChannel), client);
await vi.waitFor(() => expect(dispatchMock).toHaveBeenCalledTimes(1));
const capturedCtx = getCapturedCtx();
expect(capturedCtx?.SessionKey).toBe("agent:main:discord:channel:t1");
expect(capturedCtx?.ParentSessionKey).toBe("agent:main:discord:channel:p1");
@@ -471,6 +474,7 @@ describe("discord tool result dispatch", () => {
const client = createThreadClient({ fetchChannel, restGet });
await handler(createThreadEvent("m6"), client);
await vi.waitFor(() => expect(dispatchMock).toHaveBeenCalledTimes(1));
const capturedCtx = getCapturedCtx();
expect(capturedCtx?.SessionKey).toBe("agent:main:discord:channel:t1");
expect(capturedCtx?.ParentSessionKey).toBe("agent:main:discord:channel:forum-1");
@@ -497,6 +501,7 @@ describe("discord tool result dispatch", () => {
const client = createThreadClient();
await handler(createThreadEvent("m5", threadChannel), client);
await vi.waitFor(() => expect(dispatchMock).toHaveBeenCalledTimes(1));
const capturedCtx = getCapturedCtx();
expect(capturedCtx?.SessionKey).toBe("agent:support:discord:channel:t1");
expect(capturedCtx?.ParentSessionKey).toBe("agent:support:discord:channel:p1");

View File

@@ -158,6 +158,7 @@ describe("discord tool result dispatch", () => {
client,
);
await vi.waitFor(() => expect(dispatchMock).toHaveBeenCalledTimes(1));
expect(capturedCtx?.SessionKey).toBe("agent:main:discord:channel:c1");
});
@@ -181,6 +182,7 @@ describe("discord tool result dispatch", () => {
client,
);
await vi.waitFor(() => expect(dispatchMock).toHaveBeenCalledTimes(1));
expect(capturedBody).toContain("Ada (Ada#1234): hello");
});

View File

@@ -711,8 +711,13 @@ describe("presence-cache", () => {
});
describe("resolveDiscordPresenceUpdate", () => {
it("returns null when no presence config provided", () => {
expect(resolveDiscordPresenceUpdate({})).toBeNull();
it("returns default online presence when no presence config provided", () => {
expect(resolveDiscordPresenceUpdate({})).toEqual({
status: "online",
activities: [],
since: null,
afk: false,
});
});
it("returns status-only presence when activity is omitted", () => {

View File

@@ -212,14 +212,14 @@ describe("DiscordVoiceManager", () => {
const manager = createManager();
await manager.join({ guildId: "g1", channelId: "c1" });
await manager.join({ guildId: "g1", channelId: "c2" });
await manager.join({ guildId: "g1", channelId: "1001" });
await manager.join({ guildId: "g1", channelId: "1002" });
const oldDisconnected = oldConnection.handlers.get("disconnected");
expect(oldDisconnected).toBeTypeOf("function");
await oldDisconnected?.();
expectConnectedStatus(manager, "c2");
expectConnectedStatus(manager, "1002");
});
it("keeps the new session when an old destroyed handler fires", async () => {
@@ -229,14 +229,14 @@ describe("DiscordVoiceManager", () => {
const manager = createManager();
await manager.join({ guildId: "g1", channelId: "c1" });
await manager.join({ guildId: "g1", channelId: "c2" });
await manager.join({ guildId: "g1", channelId: "1001" });
await manager.join({ guildId: "g1", channelId: "1002" });
const oldDestroyed = oldConnection.handlers.get("destroyed");
expect(oldDestroyed).toBeTypeOf("function");
oldDestroyed?.();
expectConnectedStatus(manager, "c2");
expectConnectedStatus(manager, "1002");
});
it("removes voice listeners on leave", async () => {
@@ -244,7 +244,7 @@ describe("DiscordVoiceManager", () => {
joinVoiceChannelMock.mockReturnValueOnce(connection);
const manager = createManager();
await manager.join({ guildId: "g1", channelId: "c1" });
await manager.join({ guildId: "g1", channelId: "1001" });
await manager.leave({ guildId: "g1" });
const player = createAudioPlayerMock.mock.results[0]?.value;
@@ -262,7 +262,7 @@ describe("DiscordVoiceManager", () => {
},
});
await manager.join({ guildId: "g1", channelId: "c1" });
await manager.join({ guildId: "g1", channelId: "1001" });
expect(joinVoiceChannelMock).toHaveBeenCalledWith(
expect.objectContaining({
@@ -275,7 +275,7 @@ describe("DiscordVoiceManager", () => {
it("attempts rejoin after repeated decrypt failures", async () => {
const manager = createManager();
await manager.join({ guildId: "g1", channelId: "c1" });
await manager.join({ guildId: "g1", channelId: "1001" });
emitDecryptFailure(manager);
emitDecryptFailure(manager);

View File

@@ -6,7 +6,10 @@ import type { SessionScope } from "../config/sessions/types.js";
const agentCommand = vi.fn();
vi.mock("../commands/agent.js", () => ({ agentCommand }));
vi.mock("../commands/agent.js", () => ({
agentCommand,
agentCommandFromIngress: agentCommand,
}));
const { runBootOnce } = await import("./boot.js");
const { resolveAgentIdFromSessionKey, resolveAgentMainSessionKey, resolveMainSessionKey } =

View File

@@ -41,6 +41,7 @@ vi.mock("../../config/sessions.js", async () => {
vi.mock("../../commands/agent.js", () => ({
agentCommand: mocks.agentCommand,
agentCommandFromIngress: mocks.agentCommand,
}));
vi.mock("../../config/config.js", async () => {

View File

@@ -566,7 +566,7 @@ describe("agents.files.get/set symlink safety", () => {
},
);
it("allows in-workspace symlink targets for get/set", async () => {
it("allows in-workspace symlink reads but rejects writes through symlink aliases", async () => {
const workspace = "/workspace/test-agent";
const candidate = path.resolve(workspace, "AGENTS.md");
const target = path.resolve(workspace, "policies", "AGENTS.md");
@@ -626,12 +626,11 @@ describe("agents.files.get/set symlink safety", () => {
});
await setCall.promise;
expect(setCall.respond).toHaveBeenCalledWith(
true,
expect.objectContaining({
ok: true,
file: expect.objectContaining({ missing: false, content: "updated\n" }),
}),
false,
undefined,
expect.objectContaining({
message: expect.stringContaining('unsafe workspace file "AGENTS.md"'),
}),
);
});

View File

@@ -24,6 +24,8 @@ const buildSessionLookup = (
legacyKey: undefined,
});
const ingressAgentCommandMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
vi.mock("../infra/system-events.js", () => ({
enqueueSystemEvent: vi.fn(),
}));
@@ -31,7 +33,8 @@ vi.mock("../infra/heartbeat-wake.js", () => ({
requestHeartbeatNow: vi.fn(),
}));
vi.mock("../commands/agent.js", () => ({
agentCommand: vi.fn(),
agentCommand: ingressAgentCommandMock,
agentCommandFromIngress: ingressAgentCommandMock,
}));
vi.mock("../config/config.js", () => ({
loadConfig: vi.fn(() => ({ session: { mainKey: "agent:main:main" } })),

View File

@@ -581,6 +581,7 @@ vi.mock("../channels/web/index.js", async () => {
});
vi.mock("../commands/agent.js", () => ({
agentCommand,
agentCommandFromIngress: agentCommand,
}));
vi.mock("../auto-reply/reply.js", () => ({
getReplyFromConfig,

View File

@@ -115,7 +115,7 @@ describe("registerTelegramNativeCommands", () => {
});
});
it("truncates Telegram command registration to 100 commands", () => {
it("truncates Telegram command registration to 100 commands", async () => {
const cfg: OpenClawConfig = {
commands: { native: false },
};
@@ -141,10 +141,7 @@ describe("registerTelegramNativeCommands", () => {
nativeSkillsEnabled: false,
});
const registeredCommands = setMyCommands.mock.calls[0]?.[0] as Array<{
command: string;
description: string;
}>;
const registeredCommands = await waitForRegisteredCommands(setMyCommands);
expect(registeredCommands).toHaveLength(100);
expect(registeredCommands).toEqual(customCommands.slice(0, 100));
expect(runtimeLog).toHaveBeenCalledWith(