mirror of https://github.com/openclaw/openclaw.git
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:
parent
6842877b2e
commit
1c200ca7ae
|
|
@ -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.
|
- 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.
|
- 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.
|
- 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.
|
- 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 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.
|
- 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.
|
||||||
|
|
|
||||||
|
|
@ -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-reference",
|
||||||
"gateway/configuration-examples",
|
"gateway/configuration-examples",
|
||||||
"gateway/authentication",
|
"gateway/authentication",
|
||||||
|
"auth-credential-semantics",
|
||||||
"gateway/secrets",
|
"gateway/secrets",
|
||||||
"gateway/secrets-plan-contract",
|
"gateway/secrets-plan-contract",
|
||||||
"gateway/trusted-proxy-auth",
|
"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
|
See [/concepts/oauth](/concepts/oauth) for the full OAuth flow and storage
|
||||||
layout.
|
layout.
|
||||||
For SecretRef-based auth (`env`/`file`/`exec` providers), see [Secrets Management](/gateway/secrets).
|
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)
|
## 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" {
|
export function resolveReceiveIdType(id: string): "chat_id" | "open_id" | "user_id" {
|
||||||
const trimmed = id.trim();
|
const trimmed = id.trim();
|
||||||
const lowered = trimmed.toLowerCase();
|
const lowered = trimmed.toLowerCase();
|
||||||
if (lowered.startsWith("chat:") || lowered.startsWith("group:")) {
|
if (
|
||||||
|
lowered.startsWith("chat:") ||
|
||||||
|
lowered.startsWith("group:") ||
|
||||||
|
lowered.startsWith("channel:")
|
||||||
|
) {
|
||||||
return "chat_id";
|
return "chat_id";
|
||||||
}
|
}
|
||||||
if (lowered.startsWith("open_id:")) {
|
if (lowered.startsWith("open_id:")) {
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,12 @@ const expressControl = vi.hoisted(() => ({
|
||||||
|
|
||||||
vi.mock("openclaw/plugin-sdk", () => ({
|
vi.mock("openclaw/plugin-sdk", () => ({
|
||||||
DEFAULT_WEBHOOK_MAX_BODY_BYTES: 1024 * 1024,
|
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(
|
keepHttpServerTaskAlive: vi.fn(
|
||||||
async (params: { abortSignal?: AbortSignal; onAbort?: () => Promise<void> | void }) => {
|
async (params: { abortSignal?: AbortSignal; onAbort?: () => Promise<void> | void }) => {
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ function listSetupTokenProfiles(store: {
|
||||||
if (normalizeProviderId(cred.provider) !== "anthropic") {
|
if (normalizeProviderId(cred.provider) !== "anthropic") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return isSetupToken(cred.token);
|
return isSetupToken(cred.token ?? "");
|
||||||
})
|
})
|
||||||
.map(([id]) => id);
|
.map(([id]) => id);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ describe("buildAuthHealthSummary", () => {
|
||||||
const now = 1_700_000_000_000;
|
const now = 1_700_000_000_000;
|
||||||
const profileStatuses = (summary: ReturnType<typeof buildAuthHealthSummary>) =>
|
const profileStatuses = (summary: ReturnType<typeof buildAuthHealthSummary>) =>
|
||||||
Object.fromEntries(summary.profiles.map((profile) => [profile.profileId, profile.status]));
|
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(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
|
|
@ -89,6 +91,31 @@ describe("buildAuthHealthSummary", () => {
|
||||||
|
|
||||||
expect(statuses["google:no-refresh"]).toBe("expired");
|
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", () => {
|
describe("formatRemainingShort", () => {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,14 @@
|
||||||
import type { OpenClawConfig } from "../config/config.js";
|
import type { OpenClawConfig } from "../config/config.js";
|
||||||
import {
|
import {
|
||||||
|
type AuthCredentialReasonCode,
|
||||||
type AuthProfileCredential,
|
type AuthProfileCredential,
|
||||||
type AuthProfileStore,
|
type AuthProfileStore,
|
||||||
resolveAuthProfileDisplayLabel,
|
resolveAuthProfileDisplayLabel,
|
||||||
} from "./auth-profiles.js";
|
} from "./auth-profiles.js";
|
||||||
|
import {
|
||||||
|
evaluateStoredCredentialEligibility,
|
||||||
|
resolveTokenExpiryState,
|
||||||
|
} from "./auth-profiles/credential-state.js";
|
||||||
|
|
||||||
export type AuthProfileSource = "store";
|
export type AuthProfileSource = "store";
|
||||||
|
|
||||||
|
|
@ -14,6 +19,7 @@ export type AuthProfileHealth = {
|
||||||
provider: string;
|
provider: string;
|
||||||
type: "oauth" | "token" | "api_key";
|
type: "oauth" | "token" | "api_key";
|
||||||
status: AuthProfileHealthStatus;
|
status: AuthProfileHealthStatus;
|
||||||
|
reasonCode?: AuthCredentialReasonCode;
|
||||||
expiresAt?: number;
|
expiresAt?: number;
|
||||||
remainingMs?: number;
|
remainingMs?: number;
|
||||||
source: AuthProfileSource;
|
source: AuthProfileSource;
|
||||||
|
|
@ -113,11 +119,26 @@ function buildProfileHealth(params: {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (credential.type === "token") {
|
if (credential.type === "token") {
|
||||||
const expiresAt =
|
const eligibility = evaluateStoredCredentialEligibility({
|
||||||
typeof credential.expires === "number" && Number.isFinite(credential.expires)
|
credential,
|
||||||
? credential.expires
|
now,
|
||||||
: undefined;
|
});
|
||||||
if (!expiresAt || expiresAt <= 0) {
|
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 {
|
return {
|
||||||
profileId,
|
profileId,
|
||||||
provider: credential.provider,
|
provider: credential.provider,
|
||||||
|
|
@ -133,6 +154,7 @@ function buildProfileHealth(params: {
|
||||||
provider: credential.provider,
|
provider: credential.provider,
|
||||||
type: "token",
|
type: "token",
|
||||||
status,
|
status,
|
||||||
|
reasonCode: status === "expired" ? "expired" : undefined,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
remainingMs,
|
remainingMs,
|
||||||
source,
|
source,
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@ describe("resolveAuthProfileOrder", () => {
|
||||||
function resolveMinimaxOrderWithProfile(profile: {
|
function resolveMinimaxOrderWithProfile(profile: {
|
||||||
type: "token";
|
type: "token";
|
||||||
provider: "minimax";
|
provider: "minimax";
|
||||||
token: string;
|
token?: string;
|
||||||
|
tokenRef?: { source: "env" | "file" | "exec"; provider: string; id: string };
|
||||||
expires?: number;
|
expires?: number;
|
||||||
}) {
|
}) {
|
||||||
return resolveAuthProfileOrder({
|
return resolveAuthProfileOrder({
|
||||||
|
|
@ -189,10 +190,79 @@ describe("resolveAuthProfileOrder", () => {
|
||||||
expires: Date.now() - 1000,
|
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 }) => {
|
])("$caseName", ({ profile }) => {
|
||||||
const order = resolveMinimaxOrderWithProfile(profile);
|
const order = resolveMinimaxOrderWithProfile(profile);
|
||||||
expect(order).toEqual([]);
|
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", () => {
|
it("keeps oauth profiles that can refresh", () => {
|
||||||
const order = resolveAuthProfileOrder({
|
const order = resolveAuthProfileOrder({
|
||||||
cfg: {
|
cfg: {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,13 @@
|
||||||
export { CLAUDE_CLI_PROFILE_ID, CODEX_CLI_PROFILE_ID } from "./auth-profiles/constants.js";
|
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 { resolveAuthProfileDisplayLabel } from "./auth-profiles/display.js";
|
||||||
export { formatAuthDoctorHint } from "./auth-profiles/doctor.js";
|
export { formatAuthDoctorHint } from "./auth-profiles/doctor.js";
|
||||||
export { resolveApiKeyForProfile } from "./auth-profiles/oauth.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 { resolveAuthStorePathForDisplay } from "./auth-profiles/paths.js";
|
||||||
export {
|
export {
|
||||||
dedupeProfileIds,
|
dedupeProfileIds,
|
||||||
|
|
|
||||||
|
|
@ -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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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: {
|
function tokenStore(params: {
|
||||||
profileId: string;
|
profileId: string;
|
||||||
provider: string;
|
provider: string;
|
||||||
token: string;
|
token?: string;
|
||||||
expires?: number;
|
expires?: number;
|
||||||
}): AuthProfileStore {
|
}): AuthProfileStore {
|
||||||
return {
|
return {
|
||||||
|
|
@ -132,6 +132,45 @@ describe("resolveApiKeyForProfile config compatibility", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveApiKeyForProfile token expiry handling", () => {
|
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 () => {
|
it("returns null for expired token credentials", async () => {
|
||||||
const profileId = "anthropic:token-expired";
|
const profileId = "anthropic:token-expired";
|
||||||
const result = await resolveWithConfig({
|
const result = await resolveWithConfig({
|
||||||
|
|
@ -148,7 +187,7 @@ describe("resolveApiKeyForProfile token expiry handling", () => {
|
||||||
expect(result).toBeNull();
|
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 profileId = "anthropic:token-no-expiry";
|
||||||
const result = await resolveWithConfig({
|
const result = await resolveWithConfig({
|
||||||
profileId,
|
profileId,
|
||||||
|
|
@ -161,11 +200,30 @@ describe("resolveApiKeyForProfile token expiry handling", () => {
|
||||||
expires: 0,
|
expires: 0,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
expect(result).toEqual({
|
expect(result).toBeNull();
|
||||||
apiKey: "tok-123",
|
|
||||||
provider: "anthropic",
|
|
||||||
email: undefined,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns null for token credentials when expires is invalid (NaN)", async () => {
|
||||||
|
const profileId = "anthropic:token-invalid-expiry";
|
||||||
|
const store = tokenStore({
|
||||||
|
profileId,
|
||||||
|
provider: "anthropic",
|
||||||
|
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 () => {
|
it("resolves inline ${ENV} api_key values", async () => {
|
||||||
const profileId = "openai:inline-env";
|
const profileId = "openai:inline-env";
|
||||||
const previous = process.env.OPENAI_API_KEY;
|
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 { resolveSecretRefString, type SecretRefResolveCache } from "../../secrets/resolve.js";
|
||||||
import { refreshChutesTokens } from "../chutes-oauth.js";
|
import { refreshChutesTokens } from "../chutes-oauth.js";
|
||||||
import { AUTH_STORE_LOCK_OPTIONS, log } from "./constants.js";
|
import { AUTH_STORE_LOCK_OPTIONS, log } from "./constants.js";
|
||||||
|
import { resolveTokenExpiryState } from "./credential-state.js";
|
||||||
import { formatAuthDoctorHint } from "./doctor.js";
|
import { formatAuthDoctorHint } from "./doctor.js";
|
||||||
import { ensureAuthStoreFile, resolveAuthStorePath } from "./paths.js";
|
import { ensureAuthStoreFile, resolveAuthStorePath } from "./paths.js";
|
||||||
import { suggestOAuthProfileIdForLegacyDefault } from "./repair.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 = {
|
type ResolveApiKeyForProfileParams = {
|
||||||
cfg?: OpenClawConfig;
|
cfg?: OpenClawConfig;
|
||||||
store: AuthProfileStore;
|
store: AuthProfileStore;
|
||||||
|
|
@ -332,6 +327,10 @@ export async function resolveApiKeyForProfile(
|
||||||
return buildApiKeyProfileResult({ apiKey: key, provider: cred.provider, email: cred.email });
|
return buildApiKeyProfileResult({ apiKey: key, provider: cred.provider, email: cred.email });
|
||||||
}
|
}
|
||||||
if (cred.type === "token") {
|
if (cred.type === "token") {
|
||||||
|
const expiryState = resolveTokenExpiryState(cred.expires);
|
||||||
|
if (expiryState === "expired" || expiryState === "invalid_expires") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const token = await resolveProfileSecretString({
|
const token = await resolveProfileSecretString({
|
||||||
profileId,
|
profileId,
|
||||||
provider: cred.provider,
|
provider: cred.provider,
|
||||||
|
|
@ -346,9 +345,6 @@ export async function resolveApiKeyForProfile(
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (isExpiredCredential(cred.expires)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return buildApiKeyProfileResult({ apiKey: token, provider: cred.provider, email: cred.email });
|
return buildApiKeyProfileResult({ apiKey: token, provider: cred.provider, email: cred.email });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,10 @@ import {
|
||||||
normalizeProviderId,
|
normalizeProviderId,
|
||||||
normalizeProviderIdForAuth,
|
normalizeProviderIdForAuth,
|
||||||
} from "../model-selection.js";
|
} from "../model-selection.js";
|
||||||
|
import {
|
||||||
|
evaluateStoredCredentialEligibility,
|
||||||
|
type AuthCredentialReasonCode,
|
||||||
|
} from "./credential-state.js";
|
||||||
import { dedupeProfileIds, listProfilesForProvider } from "./profiles.js";
|
import { dedupeProfileIds, listProfilesForProvider } from "./profiles.js";
|
||||||
import type { AuthProfileStore } from "./types.js";
|
import type { AuthProfileStore } from "./types.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -12,6 +16,54 @@ import {
|
||||||
resolveProfileUnusableUntil,
|
resolveProfileUnusableUntil,
|
||||||
} from "./usage.js";
|
} 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: {
|
export function resolveAuthProfileOrder(params: {
|
||||||
cfg?: OpenClawConfig;
|
cfg?: OpenClawConfig;
|
||||||
store: AuthProfileStore;
|
store: AuthProfileStore;
|
||||||
|
|
@ -42,48 +94,14 @@ export function resolveAuthProfileOrder(params: {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const isValidProfile = (profileId: string): boolean => {
|
const isValidProfile = (profileId: string): boolean =>
|
||||||
const cred = store.profiles[profileId];
|
resolveAuthProfileEligibility({
|
||||||
if (!cred) {
|
cfg,
|
||||||
return false;
|
store,
|
||||||
}
|
provider: providerAuthKey,
|
||||||
if (normalizeProviderIdForAuth(cred.provider) !== providerAuthKey) {
|
profileId,
|
||||||
return false;
|
now,
|
||||||
}
|
}).eligible;
|
||||||
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;
|
|
||||||
};
|
|
||||||
let filtered = baseOrder.filter(isValidProfile);
|
let filtered = baseOrder.filter(isValidProfile);
|
||||||
|
|
||||||
// Repair config/store profile-id drift from older onboarding flows:
|
// Repair config/store profile-id drift from older onboarding flows:
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ export type TokenCredential = {
|
||||||
*/
|
*/
|
||||||
type: "token";
|
type: "token";
|
||||||
provider: string;
|
provider: string;
|
||||||
token: string;
|
token?: string;
|
||||||
tokenRef?: SecretRef;
|
tokenRef?: SecretRef;
|
||||||
/** Optional expiry timestamp (ms since epoch). */
|
/** Optional expiry timestamp (ms since epoch). */
|
||||||
expires?: number;
|
expires?: number;
|
||||||
|
|
|
||||||
|
|
@ -211,9 +211,8 @@ export function registerTriggerHandlingUsageSummaryCases(params: {
|
||||||
);
|
);
|
||||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
expect(text).toContain("api-key");
|
expect(text).toContain("api-key");
|
||||||
expect(text).toMatch(/\u2026|\.{3}/);
|
expect(text).not.toContain("sk-test");
|
||||||
expect(text).toContain("sk-tes");
|
expect(text).not.toContain("abcdef");
|
||||||
expect(text).toContain("abcdef");
|
|
||||||
expect(text).not.toContain("1234567890abcdef");
|
expect(text).not.toContain("1234567890abcdef");
|
||||||
expect(text).toContain("(anthropic:work)");
|
expect(text).toContain("(anthropic:work)");
|
||||||
expect(text).not.toContain("mixed");
|
expect(text).not.toContain("mixed");
|
||||||
|
|
|
||||||
|
|
@ -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";
|
} from "../../agents/model-auth.js";
|
||||||
import { findNormalizedProviderValue, normalizeProviderId } from "../../agents/model-selection.js";
|
import { findNormalizedProviderValue, normalizeProviderId } from "../../agents/model-selection.js";
|
||||||
import type { OpenClawConfig } from "../../config/config.js";
|
import type { OpenClawConfig } from "../../config/config.js";
|
||||||
|
import { coerceSecretRef } from "../../config/types.secrets.js";
|
||||||
import { shortenHomePath } from "../../utils.js";
|
import { shortenHomePath } from "../../utils.js";
|
||||||
import { maskApiKey } from "../../utils/mask-api-key.js";
|
import { maskApiKey } from "../../utils/mask-api-key.js";
|
||||||
|
|
||||||
export type ModelAuthDetailMode = "compact" | "verbose";
|
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 (
|
export const resolveAuthLabel = async (
|
||||||
provider: string,
|
provider: string,
|
||||||
cfg: OpenClawConfig,
|
cfg: OpenClawConfig,
|
||||||
|
|
@ -57,12 +73,22 @@ export const resolveAuthLabel = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
if (profile.type === "api_key") {
|
if (profile.type === "api_key") {
|
||||||
|
const keyLabel = resolveStoredCredentialLabel({
|
||||||
|
value: profile.key,
|
||||||
|
refValue: profile.keyRef,
|
||||||
|
mode,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
label: `${profileId} api-key ${maskApiKey(profile.key ?? "")}${more}`,
|
label: `${profileId} api-key ${keyLabel}${more}`,
|
||||||
source: "",
|
source: "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (profile.type === "token") {
|
if (profile.type === "token") {
|
||||||
|
const tokenLabel = resolveStoredCredentialLabel({
|
||||||
|
value: profile.token,
|
||||||
|
refValue: profile.tokenRef,
|
||||||
|
mode,
|
||||||
|
});
|
||||||
const exp =
|
const exp =
|
||||||
typeof profile.expires === "number" &&
|
typeof profile.expires === "number" &&
|
||||||
Number.isFinite(profile.expires) &&
|
Number.isFinite(profile.expires) &&
|
||||||
|
|
@ -72,7 +98,7 @@ export const resolveAuthLabel = async (
|
||||||
: ` exp ${formatUntil(profile.expires)}`
|
: ` exp ${formatUntil(profile.expires)}`
|
||||||
: "";
|
: "";
|
||||||
return {
|
return {
|
||||||
label: `${profileId} token ${maskApiKey(profile.token)}${exp}${more}`,
|
label: `${profileId} token ${tokenLabel}${exp}${more}`,
|
||||||
source: "",
|
source: "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -118,10 +144,20 @@ export const resolveAuthLabel = async (
|
||||||
return `${profileId}=missing${suffix}`;
|
return `${profileId}=missing${suffix}`;
|
||||||
}
|
}
|
||||||
if (profile.type === "api_key") {
|
if (profile.type === "api_key") {
|
||||||
|
const keyLabel = resolveStoredCredentialLabel({
|
||||||
|
value: profile.key,
|
||||||
|
refValue: profile.keyRef,
|
||||||
|
mode,
|
||||||
|
});
|
||||||
const suffix = flags.length > 0 ? ` (${flags.join(", ")})` : "";
|
const suffix = flags.length > 0 ? ` (${flags.join(", ")})` : "";
|
||||||
return `${profileId}=${maskApiKey(profile.key ?? "")}${suffix}`;
|
return `${profileId}=${keyLabel}${suffix}`;
|
||||||
}
|
}
|
||||||
if (profile.type === "token") {
|
if (profile.type === "token") {
|
||||||
|
const tokenLabel = resolveStoredCredentialLabel({
|
||||||
|
value: profile.token,
|
||||||
|
refValue: profile.tokenRef,
|
||||||
|
mode,
|
||||||
|
});
|
||||||
if (
|
if (
|
||||||
typeof profile.expires === "number" &&
|
typeof profile.expires === "number" &&
|
||||||
Number.isFinite(profile.expires) &&
|
Number.isFinite(profile.expires) &&
|
||||||
|
|
@ -130,7 +166,7 @@ export const resolveAuthLabel = async (
|
||||||
flags.push(profile.expires <= now ? "expired" : `exp ${formatUntil(profile.expires)}`);
|
flags.push(profile.expires <= now ? "expired" : `exp ${formatUntil(profile.expires)}`);
|
||||||
}
|
}
|
||||||
const suffix = flags.length > 0 ? ` (${flags.join(", ")})` : "";
|
const suffix = flags.length > 0 ? ` (${flags.join(", ")})` : "";
|
||||||
return `${profileId}=token:${maskApiKey(profile.token)}${suffix}`;
|
return `${profileId}=token:${tokenLabel}${suffix}`;
|
||||||
}
|
}
|
||||||
const display = resolveAuthProfileDisplayLabel({
|
const display = resolveAuthProfileDisplayLabel({
|
||||||
cfg,
|
cfg,
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,14 @@ export async function writeViaSiblingTempPath(params: {
|
||||||
targetPath: string;
|
targetPath: string;
|
||||||
writeTemp: (tempPath: string) => Promise<void>;
|
writeTemp: (tempPath: string) => Promise<void>;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const rootDir = path.resolve(params.rootDir);
|
const rootDir = await fs
|
||||||
const targetPath = path.resolve(params.targetPath);
|
.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);
|
const relativeTargetPath = path.relative(rootDir, targetPath);
|
||||||
if (
|
if (
|
||||||
!relativeTargetPath ||
|
!relativeTargetPath ||
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,7 @@ import path from "node:path";
|
||||||
import type { Page } from "playwright-core";
|
import type { Page } from "playwright-core";
|
||||||
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
|
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
|
||||||
import { writeViaSiblingTempPath } from "./output-atomic.js";
|
import { writeViaSiblingTempPath } from "./output-atomic.js";
|
||||||
import {
|
import { DEFAULT_UPLOAD_DIR, resolveStrictExistingPathsWithinRoot } from "./paths.js";
|
||||||
DEFAULT_DOWNLOAD_DIR,
|
|
||||||
DEFAULT_UPLOAD_DIR,
|
|
||||||
resolveStrictExistingPathsWithinRoot,
|
|
||||||
} from "./paths.js";
|
|
||||||
import {
|
import {
|
||||||
ensurePageState,
|
ensurePageState,
|
||||||
getPageForTargetId,
|
getPageForTargetId,
|
||||||
|
|
@ -96,7 +92,7 @@ async function saveDownloadPayload(download: DownloadPayload, outPath: string) {
|
||||||
await download.saveAs?.(resolvedOutPath);
|
await download.saveAs?.(resolvedOutPath);
|
||||||
} else {
|
} else {
|
||||||
await writeViaSiblingTempPath({
|
await writeViaSiblingTempPath({
|
||||||
rootDir: DEFAULT_DOWNLOAD_DIR,
|
rootDir: path.dirname(resolvedOutPath),
|
||||||
targetPath: resolvedOutPath,
|
targetPath: resolvedOutPath,
|
||||||
writeTemp: async (tempPath) => {
|
writeTemp: async (tempPath) => {
|
||||||
await download.saveAs?.(tempPath);
|
await download.saveAs?.(tempPath);
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,11 @@ describe("pw-tools-core", () => {
|
||||||
const savedPath = params.saveAs.mock.calls[0]?.[0];
|
const savedPath = params.saveAs.mock.calls[0]?.[0];
|
||||||
expect(typeof savedPath).toBe("string");
|
expect(typeof savedPath).toBe("string");
|
||||||
expect(savedPath).not.toBe(params.targetPath);
|
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(".openclaw-output-");
|
||||||
expect(path.basename(String(savedPath))).toContain(".part");
|
expect(path.basename(String(savedPath))).toContain(".part");
|
||||||
expect(await fs.readFile(params.targetPath, "utf8")).toBe(params.content);
|
expect(await fs.readFile(params.targetPath, "utf8")).toBe(params.content);
|
||||||
|
|
@ -120,7 +124,7 @@ describe("pw-tools-core", () => {
|
||||||
|
|
||||||
const res = await p;
|
const res = await p;
|
||||||
await expectAtomicDownloadSave({ saveAs, targetPath, tempDir, content: "file-content" });
|
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 () => {
|
it("clicks a ref and atomically finalizes explicit download paths", async () => {
|
||||||
|
|
@ -156,7 +160,7 @@ describe("pw-tools-core", () => {
|
||||||
|
|
||||||
const res = await p;
|
const res = await p;
|
||||||
await expectAtomicDownloadSave({ saveAs, targetPath, tempDir, content: "report-content" });
|
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,
|
saveAs,
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await p;
|
await expect(p).rejects.toThrow(/alias escape blocked|Hardlinked path is not allowed/i);
|
||||||
expect(res.path).toBe(linkedPath);
|
expect(await fs.readFile(linkedPath, "utf8")).toBe("outside-before");
|
||||||
expect(await fs.readFile(linkedPath, "utf8")).toBe("download-content");
|
|
||||||
expect(await fs.readFile(outsidePath, "utf8")).toBe("outside-before");
|
expect(await fs.readFile(outsidePath, "utf8")).toBe("outside-before");
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
formatRemainingShort,
|
formatRemainingShort,
|
||||||
} from "../agents/auth-health.js";
|
} from "../agents/auth-health.js";
|
||||||
import {
|
import {
|
||||||
|
type AuthCredentialReasonCode,
|
||||||
CLAUDE_CLI_PROFILE_ID,
|
CLAUDE_CLI_PROFILE_ID,
|
||||||
CODEX_CLI_PROFILE_ID,
|
CODEX_CLI_PROFILE_ID,
|
||||||
ensureAuthProfileStore,
|
ensureAuthProfileStore,
|
||||||
|
|
@ -203,6 +204,7 @@ type AuthIssue = {
|
||||||
profileId: string;
|
profileId: string;
|
||||||
provider: string;
|
provider: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
reasonCode?: AuthCredentialReasonCode;
|
||||||
remainingMs?: number;
|
remainingMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -222,6 +224,9 @@ export function resolveUnusableProfileHint(params: {
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatAuthIssueHint(issue: AuthIssue): string | null {
|
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) {
|
if (issue.provider === "anthropic" && issue.profileId === CLAUDE_CLI_PROFILE_ID) {
|
||||||
return `Deprecated profile. Use ${formatCliCommand("openclaw models auth setup-token")} or ${formatCliCommand(
|
return `Deprecated profile. Use ${formatCliCommand("openclaw models auth setup-token")} or ${formatCliCommand(
|
||||||
"openclaw configure",
|
"openclaw configure",
|
||||||
|
|
@ -239,7 +244,8 @@ function formatAuthIssueLine(issue: AuthIssue): string {
|
||||||
const remaining =
|
const remaining =
|
||||||
issue.remainingMs !== undefined ? ` (${formatRemainingShort(issue.remainingMs)})` : "";
|
issue.remainingMs !== undefined ? ` (${formatRemainingShort(issue.remainingMs)})` : "";
|
||||||
const hint = formatAuthIssueHint(issue);
|
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: {
|
export async function noteAuthProfileHealth(params: {
|
||||||
|
|
@ -340,6 +346,7 @@ export async function noteAuthProfileHealth(params: {
|
||||||
profileId: issue.profileId,
|
profileId: issue.profileId,
|
||||||
provider: issue.provider,
|
provider: issue.provider,
|
||||||
status: issue.status,
|
status: issue.status,
|
||||||
|
reasonCode: issue.reasonCode,
|
||||||
remainingMs: issue.remainingMs,
|
remainingMs: issue.remainingMs,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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 { resolveOpenClawAgentDir } from "../../agents/agent-paths.js";
|
||||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
||||||
import {
|
import {
|
||||||
|
type AuthProfileCredential,
|
||||||
|
type AuthProfileEligibilityReasonCode,
|
||||||
ensureAuthProfileStore,
|
ensureAuthProfileStore,
|
||||||
listProfilesForProvider,
|
listProfilesForProvider,
|
||||||
resolveAuthProfileDisplayLabel,
|
resolveAuthProfileDisplayLabel,
|
||||||
|
resolveAuthProfileEligibility,
|
||||||
resolveAuthProfileOrder,
|
resolveAuthProfileOrder,
|
||||||
} from "../../agents/auth-profiles.js";
|
} from "../../agents/auth-profiles.js";
|
||||||
import { describeFailoverError } from "../../agents/failover-error.js";
|
import { describeFailoverError } from "../../agents/failover-error.js";
|
||||||
|
|
@ -23,6 +26,8 @@ import {
|
||||||
resolveSessionTranscriptPath,
|
resolveSessionTranscriptPath,
|
||||||
resolveSessionTranscriptsDirForAgent,
|
resolveSessionTranscriptsDirForAgent,
|
||||||
} from "../../config/sessions/paths.js";
|
} 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 { redactSecrets } from "../status-all/format.js";
|
||||||
import { DEFAULT_PROVIDER, formatMs } from "./shared.js";
|
import { DEFAULT_PROVIDER, formatMs } from "./shared.js";
|
||||||
|
|
||||||
|
|
@ -38,6 +43,15 @@ export type AuthProbeStatus =
|
||||||
| "unknown"
|
| "unknown"
|
||||||
| "no_model";
|
| "no_model";
|
||||||
|
|
||||||
|
export type AuthProbeReasonCode =
|
||||||
|
| "excluded_by_auth_order"
|
||||||
|
| "missing_credential"
|
||||||
|
| "expired"
|
||||||
|
| "invalid_expires"
|
||||||
|
| "unresolved_ref"
|
||||||
|
| "ineligible_profile"
|
||||||
|
| "no_model";
|
||||||
|
|
||||||
export type AuthProbeResult = {
|
export type AuthProbeResult = {
|
||||||
provider: string;
|
provider: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
|
|
@ -46,6 +60,7 @@ export type AuthProbeResult = {
|
||||||
source: "profile" | "env" | "models.json";
|
source: "profile" | "env" | "models.json";
|
||||||
mode?: string;
|
mode?: string;
|
||||||
status: AuthProbeStatus;
|
status: AuthProbeStatus;
|
||||||
|
reasonCode?: AuthProbeReasonCode;
|
||||||
error?: string;
|
error?: string;
|
||||||
latencyMs?: number;
|
latencyMs?: number;
|
||||||
};
|
};
|
||||||
|
|
@ -139,7 +154,91 @@ function selectProbeModel(params: {
|
||||||
return null;
|
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;
|
cfg: OpenClawConfig;
|
||||||
providers: string[];
|
providers: string[];
|
||||||
modelCandidates: string[];
|
modelCandidates: string[];
|
||||||
|
|
@ -150,8 +249,8 @@ function buildProbeTargets(params: {
|
||||||
const providerFilter = options.provider?.trim();
|
const providerFilter = options.provider?.trim();
|
||||||
const providerFilterKey = providerFilter ? normalizeProviderId(providerFilter) : null;
|
const providerFilterKey = providerFilter ? normalizeProviderId(providerFilter) : null;
|
||||||
const profileFilter = new Set((options.profileIds ?? []).map((id) => id.trim()).filter(Boolean));
|
const profileFilter = new Set((options.profileIds ?? []).map((id) => id.trim()).filter(Boolean));
|
||||||
|
const refResolveCache: SecretRefResolveCache = {};
|
||||||
return loadModelCatalog({ config: cfg }).then((catalog) => {
|
const catalog = await loadModelCatalog({ config: cfg });
|
||||||
const candidates = buildCandidateMap(modelCandidates);
|
const candidates = buildCandidateMap(modelCandidates);
|
||||||
const targets: AuthProbeTarget[] = [];
|
const targets: AuthProbeTarget[] = [];
|
||||||
const results: AuthProbeResult[] = [];
|
const results: AuthProbeResult[] = [];
|
||||||
|
|
@ -191,17 +290,25 @@ function buildProbeTargets(params: {
|
||||||
if (explicitOrder && !explicitOrder.includes(profileId)) {
|
if (explicitOrder && !explicitOrder.includes(profileId)) {
|
||||||
results.push({
|
results.push({
|
||||||
provider: providerKey,
|
provider: providerKey,
|
||||||
model: model ? `${model.provider}/${model.model}` : undefined,
|
|
||||||
profileId,
|
profileId,
|
||||||
|
model: model ? `${model.provider}/${model.model}` : undefined,
|
||||||
label,
|
label,
|
||||||
source: "profile",
|
source: "profile",
|
||||||
mode,
|
mode,
|
||||||
status: "unknown",
|
status: "unknown",
|
||||||
|
reasonCode: "excluded_by_auth_order",
|
||||||
error: "Excluded by auth.order for this provider.",
|
error: "Excluded by auth.order for this provider.",
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (allowedProfiles && !allowedProfiles.has(profileId)) {
|
if (allowedProfiles && !allowedProfiles.has(profileId)) {
|
||||||
|
const eligibility = resolveAuthProfileEligibility({
|
||||||
|
cfg,
|
||||||
|
store,
|
||||||
|
provider: providerKey,
|
||||||
|
profileId,
|
||||||
|
});
|
||||||
|
const reasonCode = mapEligibilityReasonToProbeReasonCode(eligibility.reasonCode);
|
||||||
results.push({
|
results.push({
|
||||||
provider: providerKey,
|
provider: providerKey,
|
||||||
model: model ? `${model.provider}/${model.model}` : undefined,
|
model: model ? `${model.provider}/${model.model}` : undefined,
|
||||||
|
|
@ -210,7 +317,27 @@ function buildProbeTargets(params: {
|
||||||
source: "profile",
|
source: "profile",
|
||||||
mode,
|
mode,
|
||||||
status: "unknown",
|
status: "unknown",
|
||||||
error: "Auth profile credentials are missing or expired.",
|
reasonCode,
|
||||||
|
error: formatMissingCredentialProbeError(reasonCode),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -223,6 +350,7 @@ function buildProbeTargets(params: {
|
||||||
source: "profile",
|
source: "profile",
|
||||||
mode,
|
mode,
|
||||||
status: "no_model",
|
status: "no_model",
|
||||||
|
reasonCode: "no_model",
|
||||||
error: "No model available for probe",
|
error: "No model available for probe",
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -261,6 +389,7 @@ function buildProbeTargets(params: {
|
||||||
source,
|
source,
|
||||||
mode,
|
mode,
|
||||||
status: "no_model",
|
status: "no_model",
|
||||||
|
reasonCode: "no_model",
|
||||||
error: "No model available for probe",
|
error: "No model available for probe",
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -276,7 +405,6 @@ function buildProbeTargets(params: {
|
||||||
}
|
}
|
||||||
|
|
||||||
return { targets, results };
|
return { targets, results };
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function probeTarget(params: {
|
async function probeTarget(params: {
|
||||||
|
|
@ -299,6 +427,7 @@ async function probeTarget(params: {
|
||||||
source: target.source,
|
source: target.source,
|
||||||
mode: target.mode,
|
mode: target.mode,
|
||||||
status: "no_model",
|
status: "no_model",
|
||||||
|
reasonCode: "no_model",
|
||||||
error: "No model available for probe",
|
error: "No model available for probe",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -299,6 +299,7 @@ describe("discord tool result dispatch", () => {
|
||||||
client,
|
client,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(dispatchMock).toHaveBeenCalledTimes(1));
|
||||||
expect(dispatchMock).toHaveBeenCalledTimes(1);
|
expect(dispatchMock).toHaveBeenCalledTimes(1);
|
||||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||||
},
|
},
|
||||||
|
|
@ -394,6 +395,7 @@ describe("discord tool result dispatch", () => {
|
||||||
client,
|
client,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(dispatchMock).toHaveBeenCalledTimes(1));
|
||||||
expect(dispatchMock).toHaveBeenCalledTimes(1);
|
expect(dispatchMock).toHaveBeenCalledTimes(1);
|
||||||
const payload = dispatchMock.mock.calls[0]?.[0]?.ctx as Record<string, unknown>;
|
const payload = dispatchMock.mock.calls[0]?.[0]?.ctx as Record<string, unknown>;
|
||||||
expect(payload.WasMentioned).toBe(true);
|
expect(payload.WasMentioned).toBe(true);
|
||||||
|
|
@ -407,6 +409,7 @@ describe("discord tool result dispatch", () => {
|
||||||
const client = createThreadClient();
|
const client = createThreadClient();
|
||||||
await handler(createThreadEvent("m4", threadChannel), client);
|
await handler(createThreadEvent("m4", threadChannel), client);
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(dispatchMock).toHaveBeenCalledTimes(1));
|
||||||
const capturedCtx = getCapturedCtx();
|
const capturedCtx = getCapturedCtx();
|
||||||
expect(capturedCtx?.SessionKey).toBe("agent:main:discord:channel:t1");
|
expect(capturedCtx?.SessionKey).toBe("agent:main:discord:channel:t1");
|
||||||
expect(capturedCtx?.ParentSessionKey).toBe("agent:main:discord:channel:p1");
|
expect(capturedCtx?.ParentSessionKey).toBe("agent:main:discord:channel:p1");
|
||||||
|
|
@ -471,6 +474,7 @@ describe("discord tool result dispatch", () => {
|
||||||
const client = createThreadClient({ fetchChannel, restGet });
|
const client = createThreadClient({ fetchChannel, restGet });
|
||||||
await handler(createThreadEvent("m6"), client);
|
await handler(createThreadEvent("m6"), client);
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(dispatchMock).toHaveBeenCalledTimes(1));
|
||||||
const capturedCtx = getCapturedCtx();
|
const capturedCtx = getCapturedCtx();
|
||||||
expect(capturedCtx?.SessionKey).toBe("agent:main:discord:channel:t1");
|
expect(capturedCtx?.SessionKey).toBe("agent:main:discord:channel:t1");
|
||||||
expect(capturedCtx?.ParentSessionKey).toBe("agent:main:discord:channel:forum-1");
|
expect(capturedCtx?.ParentSessionKey).toBe("agent:main:discord:channel:forum-1");
|
||||||
|
|
@ -497,6 +501,7 @@ describe("discord tool result dispatch", () => {
|
||||||
const client = createThreadClient();
|
const client = createThreadClient();
|
||||||
await handler(createThreadEvent("m5", threadChannel), client);
|
await handler(createThreadEvent("m5", threadChannel), client);
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(dispatchMock).toHaveBeenCalledTimes(1));
|
||||||
const capturedCtx = getCapturedCtx();
|
const capturedCtx = getCapturedCtx();
|
||||||
expect(capturedCtx?.SessionKey).toBe("agent:support:discord:channel:t1");
|
expect(capturedCtx?.SessionKey).toBe("agent:support:discord:channel:t1");
|
||||||
expect(capturedCtx?.ParentSessionKey).toBe("agent:support:discord:channel:p1");
|
expect(capturedCtx?.ParentSessionKey).toBe("agent:support:discord:channel:p1");
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,7 @@ describe("discord tool result dispatch", () => {
|
||||||
client,
|
client,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(dispatchMock).toHaveBeenCalledTimes(1));
|
||||||
expect(capturedCtx?.SessionKey).toBe("agent:main:discord:channel:c1");
|
expect(capturedCtx?.SessionKey).toBe("agent:main:discord:channel:c1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -181,6 +182,7 @@ describe("discord tool result dispatch", () => {
|
||||||
client,
|
client,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(dispatchMock).toHaveBeenCalledTimes(1));
|
||||||
expect(capturedBody).toContain("Ada (Ada#1234): hello");
|
expect(capturedBody).toContain("Ada (Ada#1234): hello");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -711,8 +711,13 @@ describe("presence-cache", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveDiscordPresenceUpdate", () => {
|
describe("resolveDiscordPresenceUpdate", () => {
|
||||||
it("returns null when no presence config provided", () => {
|
it("returns default online presence when no presence config provided", () => {
|
||||||
expect(resolveDiscordPresenceUpdate({})).toBeNull();
|
expect(resolveDiscordPresenceUpdate({})).toEqual({
|
||||||
|
status: "online",
|
||||||
|
activities: [],
|
||||||
|
since: null,
|
||||||
|
afk: false,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns status-only presence when activity is omitted", () => {
|
it("returns status-only presence when activity is omitted", () => {
|
||||||
|
|
|
||||||
|
|
@ -212,14 +212,14 @@ describe("DiscordVoiceManager", () => {
|
||||||
|
|
||||||
const manager = createManager();
|
const manager = createManager();
|
||||||
|
|
||||||
await manager.join({ guildId: "g1", channelId: "c1" });
|
await manager.join({ guildId: "g1", channelId: "1001" });
|
||||||
await manager.join({ guildId: "g1", channelId: "c2" });
|
await manager.join({ guildId: "g1", channelId: "1002" });
|
||||||
|
|
||||||
const oldDisconnected = oldConnection.handlers.get("disconnected");
|
const oldDisconnected = oldConnection.handlers.get("disconnected");
|
||||||
expect(oldDisconnected).toBeTypeOf("function");
|
expect(oldDisconnected).toBeTypeOf("function");
|
||||||
await oldDisconnected?.();
|
await oldDisconnected?.();
|
||||||
|
|
||||||
expectConnectedStatus(manager, "c2");
|
expectConnectedStatus(manager, "1002");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the new session when an old destroyed handler fires", async () => {
|
it("keeps the new session when an old destroyed handler fires", async () => {
|
||||||
|
|
@ -229,14 +229,14 @@ describe("DiscordVoiceManager", () => {
|
||||||
|
|
||||||
const manager = createManager();
|
const manager = createManager();
|
||||||
|
|
||||||
await manager.join({ guildId: "g1", channelId: "c1" });
|
await manager.join({ guildId: "g1", channelId: "1001" });
|
||||||
await manager.join({ guildId: "g1", channelId: "c2" });
|
await manager.join({ guildId: "g1", channelId: "1002" });
|
||||||
|
|
||||||
const oldDestroyed = oldConnection.handlers.get("destroyed");
|
const oldDestroyed = oldConnection.handlers.get("destroyed");
|
||||||
expect(oldDestroyed).toBeTypeOf("function");
|
expect(oldDestroyed).toBeTypeOf("function");
|
||||||
oldDestroyed?.();
|
oldDestroyed?.();
|
||||||
|
|
||||||
expectConnectedStatus(manager, "c2");
|
expectConnectedStatus(manager, "1002");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("removes voice listeners on leave", async () => {
|
it("removes voice listeners on leave", async () => {
|
||||||
|
|
@ -244,7 +244,7 @@ describe("DiscordVoiceManager", () => {
|
||||||
joinVoiceChannelMock.mockReturnValueOnce(connection);
|
joinVoiceChannelMock.mockReturnValueOnce(connection);
|
||||||
const manager = createManager();
|
const manager = createManager();
|
||||||
|
|
||||||
await manager.join({ guildId: "g1", channelId: "c1" });
|
await manager.join({ guildId: "g1", channelId: "1001" });
|
||||||
await manager.leave({ guildId: "g1" });
|
await manager.leave({ guildId: "g1" });
|
||||||
|
|
||||||
const player = createAudioPlayerMock.mock.results[0]?.value;
|
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(joinVoiceChannelMock).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
|
@ -275,7 +275,7 @@ describe("DiscordVoiceManager", () => {
|
||||||
it("attempts rejoin after repeated decrypt failures", async () => {
|
it("attempts rejoin after repeated decrypt failures", async () => {
|
||||||
const manager = createManager();
|
const manager = createManager();
|
||||||
|
|
||||||
await manager.join({ guildId: "g1", channelId: "c1" });
|
await manager.join({ guildId: "g1", channelId: "1001" });
|
||||||
|
|
||||||
emitDecryptFailure(manager);
|
emitDecryptFailure(manager);
|
||||||
emitDecryptFailure(manager);
|
emitDecryptFailure(manager);
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,10 @@ import type { SessionScope } from "../config/sessions/types.js";
|
||||||
|
|
||||||
const agentCommand = vi.fn();
|
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 { runBootOnce } = await import("./boot.js");
|
||||||
const { resolveAgentIdFromSessionKey, resolveAgentMainSessionKey, resolveMainSessionKey } =
|
const { resolveAgentIdFromSessionKey, resolveAgentMainSessionKey, resolveMainSessionKey } =
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ vi.mock("../../config/sessions.js", async () => {
|
||||||
|
|
||||||
vi.mock("../../commands/agent.js", () => ({
|
vi.mock("../../commands/agent.js", () => ({
|
||||||
agentCommand: mocks.agentCommand,
|
agentCommand: mocks.agentCommand,
|
||||||
|
agentCommandFromIngress: mocks.agentCommand,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../config/config.js", async () => {
|
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 workspace = "/workspace/test-agent";
|
||||||
const candidate = path.resolve(workspace, "AGENTS.md");
|
const candidate = path.resolve(workspace, "AGENTS.md");
|
||||||
const target = path.resolve(workspace, "policies", "AGENTS.md");
|
const target = path.resolve(workspace, "policies", "AGENTS.md");
|
||||||
|
|
@ -626,12 +626,11 @@ describe("agents.files.get/set symlink safety", () => {
|
||||||
});
|
});
|
||||||
await setCall.promise;
|
await setCall.promise;
|
||||||
expect(setCall.respond).toHaveBeenCalledWith(
|
expect(setCall.respond).toHaveBeenCalledWith(
|
||||||
true,
|
false,
|
||||||
expect.objectContaining({
|
|
||||||
ok: true,
|
|
||||||
file: expect.objectContaining({ missing: false, content: "updated\n" }),
|
|
||||||
}),
|
|
||||||
undefined,
|
undefined,
|
||||||
|
expect.objectContaining({
|
||||||
|
message: expect.stringContaining('unsafe workspace file "AGENTS.md"'),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ const buildSessionLookup = (
|
||||||
legacyKey: undefined,
|
legacyKey: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const ingressAgentCommandMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||||
|
|
||||||
vi.mock("../infra/system-events.js", () => ({
|
vi.mock("../infra/system-events.js", () => ({
|
||||||
enqueueSystemEvent: vi.fn(),
|
enqueueSystemEvent: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
@ -31,7 +33,8 @@ vi.mock("../infra/heartbeat-wake.js", () => ({
|
||||||
requestHeartbeatNow: vi.fn(),
|
requestHeartbeatNow: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock("../commands/agent.js", () => ({
|
vi.mock("../commands/agent.js", () => ({
|
||||||
agentCommand: vi.fn(),
|
agentCommand: ingressAgentCommandMock,
|
||||||
|
agentCommandFromIngress: ingressAgentCommandMock,
|
||||||
}));
|
}));
|
||||||
vi.mock("../config/config.js", () => ({
|
vi.mock("../config/config.js", () => ({
|
||||||
loadConfig: vi.fn(() => ({ session: { mainKey: "agent:main:main" } })),
|
loadConfig: vi.fn(() => ({ session: { mainKey: "agent:main:main" } })),
|
||||||
|
|
|
||||||
|
|
@ -581,6 +581,7 @@ vi.mock("../channels/web/index.js", async () => {
|
||||||
});
|
});
|
||||||
vi.mock("../commands/agent.js", () => ({
|
vi.mock("../commands/agent.js", () => ({
|
||||||
agentCommand,
|
agentCommand,
|
||||||
|
agentCommandFromIngress: agentCommand,
|
||||||
}));
|
}));
|
||||||
vi.mock("../auto-reply/reply.js", () => ({
|
vi.mock("../auto-reply/reply.js", () => ({
|
||||||
getReplyFromConfig,
|
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 = {
|
const cfg: OpenClawConfig = {
|
||||||
commands: { native: false },
|
commands: { native: false },
|
||||||
};
|
};
|
||||||
|
|
@ -141,10 +141,7 @@ describe("registerTelegramNativeCommands", () => {
|
||||||
nativeSkillsEnabled: false,
|
nativeSkillsEnabled: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const registeredCommands = setMyCommands.mock.calls[0]?.[0] as Array<{
|
const registeredCommands = await waitForRegisteredCommands(setMyCommands);
|
||||||
command: string;
|
|
||||||
description: string;
|
|
||||||
}>;
|
|
||||||
expect(registeredCommands).toHaveLength(100);
|
expect(registeredCommands).toHaveLength(100);
|
||||||
expect(registeredCommands).toEqual(customCommands.slice(0, 100));
|
expect(registeredCommands).toEqual(customCommands.slice(0, 100));
|
||||||
expect(runtimeLog).toHaveBeenCalledWith(
|
expect(runtimeLog).toHaveBeenCalledWith(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue