mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
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:
@@ -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.
|
||||
|
||||
45
docs/auth-credential-semantics.md
Normal file
45
docs/auth-credential-semantics.md
Normal 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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:")) {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
77
src/agents/auth-profiles/credential-state.test.ts
Normal file
77
src/agents/auth-profiles/credential-state.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
74
src/agents/auth-profiles/credential-state.ts
Normal file
74
src/agents/auth-profiles/credential-state.ts
Normal 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" };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
114
src/auto-reply/reply/directive-handling.auth.test.ts
Normal file
114
src/auto-reply/reply/directive-handling.auth.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
)
|
||||
|
||||
166
src/commands/models/list.probe.targets.test.ts
Normal file
166
src/commands/models/list.probe.targets.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 } =
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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"'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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" } })),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user