From 1c200ca7ae3cd4a3e2861b1a32fc16b917630f09 Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Tue, 3 Mar 2026 20:29:46 -0600 Subject: [PATCH] follow-up: align ingress, atomic paths, and channel tests with credential semantics (#33733) Merged via squash. Prepared head SHA: c290c2ab6a3c3309adcbc4dc834f3c10d2ae1039 Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com> Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com> Reviewed-by: @joshavant --- CHANGELOG.md | 1 + docs/auth-credential-semantics.md | 45 +++ docs/docs.json | 1 + docs/gateway/authentication.md | 2 + extensions/feishu/src/targets.ts | 6 +- .../msteams/src/monitor.lifecycle.test.ts | 6 + src/agents/anthropic.setup-token.live.test.ts | 2 +- src/agents/auth-health.test.ts | 27 ++ src/agents/auth-health.ts | 32 +- ...s-stored-profiles-no-config-exists.test.ts | 72 +++- src/agents/auth-profiles.ts | 7 +- .../auth-profiles/credential-state.test.ts | 77 ++++ src/agents/auth-profiles/credential-state.ts | 74 ++++ src/agents/auth-profiles/oauth.test.ts | 101 +++++- src/agents/auth-profiles/oauth.ts | 14 +- src/agents/auth-profiles/order.ts | 102 +++--- src/agents/auth-profiles/types.ts | 2 +- ...ge-summary-current-model-provider.cases.ts | 5 +- .../reply/directive-handling.auth.test.ts | 114 ++++++ .../reply/directive-handling.auth.ts | 44 ++- src/browser/output-atomic.ts | 10 +- src/browser/pw-tools-core.downloads.ts | 8 +- ...-core.waits-next-download-saves-it.test.ts | 15 +- src/commands/doctor-auth.ts | 9 +- .../models/list.probe.targets.test.ts | 166 +++++++++ src/commands/models/list.probe.ts | 343 ++++++++++++------ ...messages-mentionpatterns-match.e2e.test.ts | 5 + ...ends-status-replies-responseprefix.test.ts | 2 + src/discord/monitor/monitor.test.ts | 9 +- src/discord/voice/manager.e2e.test.ts | 18 +- src/gateway/boot.test.ts | 5 +- src/gateway/server-methods/agent.test.ts | 1 + .../server-methods/agents-mutate.test.ts | 11 +- src/gateway/server-node-events.test.ts | 5 +- src/gateway/test-helpers.mocks.ts | 1 + src/telegram/bot-native-commands.test.ts | 7 +- 36 files changed, 1130 insertions(+), 219 deletions(-) create mode 100644 docs/auth-credential-semantics.md create mode 100644 src/agents/auth-profiles/credential-state.test.ts create mode 100644 src/agents/auth-profiles/credential-state.ts create mode 100644 src/auto-reply/reply/directive-handling.auth.test.ts create mode 100644 src/commands/models/list.probe.targets.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d7059b5175..c29db34273e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/auth-credential-semantics.md b/docs/auth-credential-semantics.md new file mode 100644 index 00000000000..17adb38f9ae --- /dev/null +++ b/docs/auth-credential-semantics.md @@ -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. diff --git a/docs/docs.json b/docs/docs.json index 4dfbf73684d..35e2f37a4a7 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -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", diff --git a/docs/gateway/authentication.md b/docs/gateway/authentication.md index a7b8d44c9cf..28314dd85a3 100644 --- a/docs/gateway/authentication.md +++ b/docs/gateway/authentication.md @@ -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) diff --git a/extensions/feishu/src/targets.ts b/extensions/feishu/src/targets.ts index cf16a5cb871..1ec68e258cb 100644 --- a/extensions/feishu/src/targets.ts +++ b/extensions/feishu/src/targets.ts @@ -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:")) { diff --git a/extensions/msteams/src/monitor.lifecycle.test.ts b/extensions/msteams/src/monitor.lifecycle.test.ts index 132718ce307..980b0871bc5 100644 --- a/extensions/msteams/src/monitor.lifecycle.test.ts +++ b/extensions/msteams/src/monitor.lifecycle.test.ts @@ -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 }) => { await new Promise((resolve) => { diff --git a/src/agents/anthropic.setup-token.live.test.ts b/src/agents/anthropic.setup-token.live.test.ts index 78a427c8128..54b52650af5 100644 --- a/src/agents/anthropic.setup-token.live.test.ts +++ b/src/agents/anthropic.setup-token.live.test.ts @@ -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); } diff --git a/src/agents/auth-health.test.ts b/src/agents/auth-health.test.ts index a6d5b80b8f8..4e2cc12cd82 100644 --- a/src/agents/auth-health.test.ts +++ b/src/agents/auth-health.test.ts @@ -9,6 +9,8 @@ describe("buildAuthHealthSummary", () => { const now = 1_700_000_000_000; const profileStatuses = (summary: ReturnType) => Object.fromEntries(summary.profiles.map((profile) => [profile.profileId, profile.status])); + const profileReasonCodes = (summary: ReturnType) => + 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", () => { diff --git a/src/agents/auth-health.ts b/src/agents/auth-health.ts index 13781618cfe..3876eb03f18 100644 --- a/src/agents/auth-health.ts +++ b/src/agents/auth-health.ts @@ -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, diff --git a/src/agents/auth-profiles.resolve-auth-profile-order.uses-stored-profiles-no-config-exists.test.ts b/src/agents/auth-profiles.resolve-auth-profile-order.uses-stored-profiles-no-config-exists.test.ts index c4e49dbe400..ec6f0f6c3b9 100644 --- a/src/agents/auth-profiles.resolve-auth-profile-order.uses-stored-profiles-no-config-exists.test.ts +++ b/src/agents/auth-profiles.resolve-auth-profile-order.uses-stored-profiles-no-config-exists.test.ts @@ -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: { diff --git a/src/agents/auth-profiles.ts b/src/agents/auth-profiles.ts index 7bf01847e55..b2822ca9690 100644 --- a/src/agents/auth-profiles.ts +++ b/src/agents/auth-profiles.ts @@ -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, diff --git a/src/agents/auth-profiles/credential-state.test.ts b/src/agents/auth-profiles/credential-state.test.ts new file mode 100644 index 00000000000..443519e5b0c --- /dev/null +++ b/src/agents/auth-profiles/credential-state.test.ts @@ -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" }); + }); +}); diff --git a/src/agents/auth-profiles/credential-state.ts b/src/agents/auth-profiles/credential-state.ts new file mode 100644 index 00000000000..9b2afcdfe2e --- /dev/null +++ b/src/agents/auth-profiles/credential-state.ts @@ -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" }; +} diff --git a/src/agents/auth-profiles/oauth.test.ts b/src/agents/auth-profiles/oauth.test.ts index e4c8c536c76..f5c29fe3c2a 100644 --- a/src/agents/auth-profiles/oauth.test.ts +++ b/src/agents/auth-profiles/oauth.test.ts @@ -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; diff --git a/src/agents/auth-profiles/oauth.ts b/src/agents/auth-profiles/oauth.ts index 7303a2ec0e0..27ecab8ad32 100644 --- a/src/agents/auth-profiles/oauth.ts +++ b/src/agents/auth-profiles/oauth.ts @@ -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 }); } diff --git a/src/agents/auth-profiles/order.ts b/src/agents/auth-profiles/order.ts index 48584d6e6f6..d653b7198cb 100644 --- a/src/agents/auth-profiles/order.ts +++ b/src/agents/auth-profiles/order.ts @@ -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: diff --git a/src/agents/auth-profiles/types.ts b/src/agents/auth-profiles/types.ts index 3c186350667..d01e7a07d68 100644 --- a/src/agents/auth-profiles/types.ts +++ b/src/agents/auth-profiles/types.ts @@ -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; diff --git a/src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.cases.ts b/src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.cases.ts index 051a2c213a1..1a738d5731f 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.cases.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.cases.ts @@ -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"); diff --git a/src/auto-reply/reply/directive-handling.auth.test.ts b/src/auto-reply/reply/directive-handling.auth.test.ts new file mode 100644 index 00000000000..04249b88795 --- /dev/null +++ b/src/auto-reply/reply/directive-handling.auth.test.ts @@ -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 | 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"); + }); +}); diff --git a/src/auto-reply/reply/directive-handling.auth.ts b/src/auto-reply/reply/directive-handling.auth.ts index 7d1af2acde9..dd33ed6ae73 100644 --- a/src/auto-reply/reply/directive-handling.auth.ts +++ b/src/auto-reply/reply/directive-handling.auth.ts @@ -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, diff --git a/src/browser/output-atomic.ts b/src/browser/output-atomic.ts index 4beaf3cae0a..541ad0901b6 100644 --- a/src/browser/output-atomic.ts +++ b/src/browser/output-atomic.ts @@ -15,8 +15,14 @@ export async function writeViaSiblingTempPath(params: { targetPath: string; writeTemp: (tempPath: string) => Promise; }): Promise { - 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 || diff --git a/src/browser/pw-tools-core.downloads.ts b/src/browser/pw-tools-core.downloads.ts index fc4902428a0..6024ee09f41 100644 --- a/src/browser/pw-tools-core.downloads.ts +++ b/src/browser/pw-tools-core.downloads.ts @@ -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); diff --git a/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts b/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts index fdc2a5dc1ab..d976f7d7fb8 100644 --- a/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts +++ b/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts @@ -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"); }); }, diff --git a/src/commands/doctor-auth.ts b/src/commands/doctor-auth.ts index f408dc43f93..56ba510f41d 100644 --- a/src/commands/doctor-auth.ts +++ b/src/commands/doctor-auth.ts @@ -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, }), ) diff --git a/src/commands/models/list.probe.targets.test.ts b/src/commands/models/list.probe.targets.test.ts new file mode 100644 index 00000000000..c3e754199a2 --- /dev/null +++ b/src/commands/models/list.probe.targets.test.ts @@ -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(); + 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"); + }); +}); diff --git a/src/commands/models/list.probe.ts b/src/commands/models/list.probe.ts index ef48564df88..433c005077d 100644 --- a/src/commands/models/list.probe.ts +++ b/src/commands/models/list.probe.ts @@ -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", }; } diff --git a/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.e2e.test.ts b/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.e2e.test.ts index 1de585a38dd..b85ec0c060d 100644 --- a/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.e2e.test.ts +++ b/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.e2e.test.ts @@ -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; 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"); diff --git a/src/discord/monitor.tool-result.sends-status-replies-responseprefix.test.ts b/src/discord/monitor.tool-result.sends-status-replies-responseprefix.test.ts index 99fa5c9ddcf..70d7fd53708 100644 --- a/src/discord/monitor.tool-result.sends-status-replies-responseprefix.test.ts +++ b/src/discord/monitor.tool-result.sends-status-replies-responseprefix.test.ts @@ -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"); }); diff --git a/src/discord/monitor/monitor.test.ts b/src/discord/monitor/monitor.test.ts index fc6899c96de..8a7f2dafbb0 100644 --- a/src/discord/monitor/monitor.test.ts +++ b/src/discord/monitor/monitor.test.ts @@ -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", () => { diff --git a/src/discord/voice/manager.e2e.test.ts b/src/discord/voice/manager.e2e.test.ts index 93ce4d744a2..3031b3d98cd 100644 --- a/src/discord/voice/manager.e2e.test.ts +++ b/src/discord/voice/manager.e2e.test.ts @@ -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); diff --git a/src/gateway/boot.test.ts b/src/gateway/boot.test.ts index 23ef28c7ce3..99271e4242b 100644 --- a/src/gateway/boot.test.ts +++ b/src/gateway/boot.test.ts @@ -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 } = diff --git a/src/gateway/server-methods/agent.test.ts b/src/gateway/server-methods/agent.test.ts index 35d547e71c9..d00da68b255 100644 --- a/src/gateway/server-methods/agent.test.ts +++ b/src/gateway/server-methods/agent.test.ts @@ -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 () => { diff --git a/src/gateway/server-methods/agents-mutate.test.ts b/src/gateway/server-methods/agents-mutate.test.ts index 646da63b340..66774715eb8 100644 --- a/src/gateway/server-methods/agents-mutate.test.ts +++ b/src/gateway/server-methods/agents-mutate.test.ts @@ -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"'), + }), ); }); diff --git a/src/gateway/server-node-events.test.ts b/src/gateway/server-node-events.test.ts index 206e3a90141..46b3689642d 100644 --- a/src/gateway/server-node-events.test.ts +++ b/src/gateway/server-node-events.test.ts @@ -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" } })), diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts index d41cdd56397..d8dfdcbbe84 100644 --- a/src/gateway/test-helpers.mocks.ts +++ b/src/gateway/test-helpers.mocks.ts @@ -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, diff --git a/src/telegram/bot-native-commands.test.ts b/src/telegram/bot-native-commands.test.ts index 080fb5b85ce..eea0937ad0e 100644 --- a/src/telegram/bot-native-commands.test.ts +++ b/src/telegram/bot-native-commands.test.ts @@ -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(