Redact persisted secret-shaped payloads [AI] (#79006)

* fix: redact persisted secret-shaped payloads

* docs: add changelog entry for PR merge
This commit is contained in:
Pavan Kumar Gondhi
2026-05-11 12:52:32 +05:30
committed by GitHub
parent be8bf3585e
commit 17ceca86d6
33 changed files with 2775 additions and 218 deletions

View File

@@ -48,6 +48,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Redact persisted secret-shaped payloads [AI]. (#79006) Thanks @pgondhi987.
- OpenAI Codex: surface browser OAuth and device-code login failures instead of treating failed logins as empty successful auth results. Refs #80363.
- CLI agents: carry runtime-only current-turn sender/reply context into CLI model prompts while keeping prompt-build hook input and transcript text clean.
- fix(matrix): gate name-based allowlist resolution [AI]. (#79007) Thanks @pgondhi987.

View File

@@ -816,14 +816,23 @@ describe("ensureAuthProfileStore", () => {
const persisted = JSON.parse(
fs.readFileSync(path.join(agentDir, "auth-profiles.json"), "utf8"),
) as {
profiles: Record<string, unknown>;
profiles: Record<string, Record<string, unknown>>;
};
expectRecordFields(persisted.profiles["openai-codex:default"], {
const persistedProfile = persisted.profiles["openai-codex:default"];
expect(persistedProfile).toMatchObject({
type: "oauth",
provider: "openai-codex",
access: "access-token",
refresh: "refresh-token",
oauthRef: {
source: "openclaw-credentials",
provider: "openai-codex",
id: expect.any(String),
},
});
expect(persistedProfile).not.toHaveProperty("access");
expect(persistedProfile).not.toHaveProperty("refresh");
expect(persistedProfile).not.toHaveProperty("idToken");
expect(JSON.stringify(persisted)).not.toContain("access-token");
expect(JSON.stringify(persisted)).not.toContain("refresh-token");
} finally {
clearRuntimeAuthProfileStoreSnapshots();
restoreEnvValue("OPENCLAW_STATE_DIR", previousStateDir);

View File

@@ -145,6 +145,12 @@ function resolveExternalCliSyncProvider(params: {
return provider;
}
function hasInlineOAuthTokenMaterial(credential: OAuthCredential): boolean {
return [credential.access, credential.refresh, credential.idToken].some(
(value) => typeof value === "string" && value.trim().length > 0,
);
}
export function readExternalCliBootstrapCredential(params: {
profileId: string;
credential: OAuthCredential;
@@ -153,11 +159,7 @@ export function readExternalCliBootstrapCredential(params: {
if (!provider) {
return null;
}
// bootstrapOnly providers must not replace an existing local credential
// during runtime refresh. The oauth-manager only calls this hook when a
// local credential is already present, so returning null here keeps the
// locally stored refresh token canonical.
if (provider.bootstrapOnly) {
if (provider.bootstrapOnly && hasInlineOAuthTokenMaterial(params.credential)) {
return null;
}
return provider.readCredentials();
@@ -248,7 +250,11 @@ export function resolveExternalCliAuthProfiles(
});
continue;
}
if (providerConfig.bootstrapOnly && existingOAuth) {
if (
providerConfig.bootstrapOnly &&
existingOAuth &&
hasInlineOAuthTokenMaterial(existingOAuth)
) {
log.debug("kept local oauth over external cli bootstrap-only provider", {
profileId: providerConfig.profileId,
provider: providerConfig.provider,

View File

@@ -5,6 +5,7 @@ import {
overlayExternalOAuthProfiles,
shouldPersistExternalOAuthProfile,
} from "./external-auth.js";
import { readManagedExternalCliCredential } from "./external-cli-sync.js";
import type { AuthProfileStore, OAuthCredential } from "./types.js";
const resolveExternalAuthProfilesWithPluginsMock = vi.fn<
@@ -158,7 +159,7 @@ describe("auth external oauth helpers", () => {
expect(shouldPersist).toBe(true);
});
it("does not use Codex CLI OAuth as a runtime overlay source", () => {
it("keeps Codex CLI OAuth from replacing stored inline token material", () => {
readCodexCliCredentialsCachedMock.mockReturnValue(
createCredential({
access: "fresh-cli-access-token",
@@ -185,6 +186,44 @@ describe("auth external oauth helpers", () => {
expect(profile.accountId).toBe("acct-cli");
});
it("uses Codex CLI OAuth when the stored Codex profile has no inline token material", () => {
const cliCredential = createCredential({
access: "fresh-cli-access-token",
refresh: "fresh-cli-refresh-token",
expires: createUsableOAuthExpiry(),
accountId: "acct-cli",
});
const tokenlessCredential = {
type: "oauth",
provider: "openai-codex",
expires: Date.now() - 60_000,
accountId: "acct-cli",
} as OAuthCredential;
readCodexCliCredentialsCachedMock.mockReturnValue(cliCredential);
const overlaid = overlayExternalOAuthProfiles(
createStore({
"openai-codex:default": tokenlessCredential,
}),
);
expect(overlaid.profiles["openai-codex:default"]).toMatchObject({
access: "fresh-cli-access-token",
refresh: "fresh-cli-refresh-token",
accountId: "acct-cli",
});
expect(
readManagedExternalCliCredential({
profileId: "openai-codex:default",
credential: tokenlessCredential,
}),
).toMatchObject({
access: "fresh-cli-access-token",
refresh: "fresh-cli-refresh-token",
accountId: "acct-cli",
});
});
it("keeps healthy local oauth even when external cli has a fresher token", () => {
readCodexCliCredentialsCachedMock.mockReturnValue(
createCredential({

View File

@@ -29,18 +29,18 @@ const {
formatProviderAuthProfileApiKeyWithPluginMock,
} = getOAuthProviderRuntimeMocks();
function expectOAuthProfileFields(
store: AuthProfileStore,
profileId: string,
params: { access: string; accountId: string },
) {
const credential = store.profiles[profileId];
expect(credential?.type).toBe("oauth");
if (credential?.type !== "oauth") {
throw new Error(`Expected OAuth credential for ${profileId}`);
}
expect(credential.access).toBe(params.access);
expect(credential.accountId).toBe(params.accountId);
function expectPersistedOpenAICodexProfileWithoutInlineTokens(
credential: AuthProfileStore["profiles"][string],
metadata: Record<string, unknown> = {},
): void {
expect(credential).toMatchObject({
type: "oauth",
provider: "openai-codex",
...metadata,
});
expect(credential).not.toHaveProperty("access");
expect(credential).not.toHaveProperty("refresh");
expect(credential).not.toHaveProperty("idToken");
}
// Cross-account-leak defense-in-depth: each adopt site in oauth.ts calls the
@@ -138,10 +138,11 @@ describe("OAuth credential adoption is identity-gated", () => {
const subRaw = JSON.parse(
await fs.readFile(path.join(subAgentDir, "auth-profiles.json"), "utf8"),
) as AuthProfileStore;
expectOAuthProfileFields(subRaw, profileId, {
access: "sub-own-access",
expectPersistedOpenAICodexProfileWithoutInlineTokens(subRaw.profiles[profileId], {
accountId: "acct-sub",
expires: subExpiry,
});
expect(JSON.stringify(subRaw)).not.toContain("sub-own-access");
});
it("inside-the-lock main adoption refuses across accountId mismatch and proceeds to own refresh", async () => {
@@ -210,10 +211,11 @@ describe("OAuth credential adoption is identity-gated", () => {
const mainRaw = JSON.parse(
await fs.readFile(path.join(mainAgentDir, "auth-profiles.json"), "utf8"),
) as AuthProfileStore;
expectOAuthProfileFields(mainRaw, profileId, {
access: "main-foreign-access",
expectPersistedOpenAICodexProfileWithoutInlineTokens(mainRaw.profiles[profileId], {
accountId: "acct-other",
expires: freshExpiry,
});
expect(JSON.stringify(mainRaw)).not.toContain("main-foreign-access");
});
it("catch-block main-inherit refuses across accountId mismatch and surfaces the original error", async () => {
@@ -286,9 +288,9 @@ describe("OAuth credential adoption is identity-gated", () => {
const subRaw = JSON.parse(
await fs.readFile(path.join(subAgentDir, "auth-profiles.json"), "utf8"),
) as AuthProfileStore;
expectOAuthProfileFields(subRaw, profileId, {
access: "sub-stale",
expectPersistedOpenAICodexProfileWithoutInlineTokens(subRaw.profiles[profileId], {
accountId: "acct-sub",
});
expect(JSON.stringify(subRaw)).not.toContain("sub-stale");
});
});

View File

@@ -28,6 +28,20 @@ const {
formatProviderAuthProfileApiKeyWithPluginMock,
} = getOAuthProviderRuntimeMocks();
function expectPersistedOpenAICodexProfileWithoutInlineTokens(
credential: AuthProfileStore["profiles"][string],
metadata: Record<string, unknown> = {},
): void {
expect(credential).toMatchObject({
type: "oauth",
provider: "openai-codex",
...metadata,
});
expect(credential).not.toHaveProperty("access");
expect(credential).not.toHaveProperty("refresh");
expect(credential).not.toHaveProperty("idToken");
}
function requireOAuthCredential(store: AuthProfileStore, profileId: string): OAuthCredential {
const profile = store.profiles[profileId];
if (!profile || profile.type !== "oauth") {
@@ -36,7 +50,7 @@ function requireOAuthCredential(store: AuthProfileStore, profileId: string): OAu
return profile;
}
vi.mock("@earendil-works/pi-ai/oauth", () => ({
vi.mock("@mariozechner/pi-ai/oauth", () => ({
getOAuthProviders: () => [{ id: "anthropic" }, { id: "openai-codex" }],
getOAuthApiKey: vi.fn(async (provider: string, credentials: Record<string, OAuthCredential>) => {
const credential = credentials[provider];
@@ -85,7 +99,7 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
await removeOAuthTestTempRoot(tempRoot);
});
it("mirrors refreshed credentials into the main store so peers skip refresh", async () => {
it("mirrors refreshed Codex OAuth metadata into the main store without inline tokens", async () => {
const profileId = "openai-codex:default";
const provider = "openai-codex";
const accountId = "acct-shared";
@@ -116,15 +130,17 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
expect(result?.apiKey).toBe("sub-refreshed-access");
// Main store should now carry the refreshed credential, so a peer agent
// starting fresh will adopt rather than race.
// Main store should now carry refreshed metadata, so a peer agent
// starting fresh can resolve the runtime credential without token races.
const mainRaw = JSON.parse(
await fs.readFile(path.join(mainAgentDir, "auth-profiles.json"), "utf8"),
) as AuthProfileStore;
const mainCredential = requireOAuthCredential(mainRaw, profileId);
expect(mainCredential.access).toBe("sub-refreshed-access");
expect(mainCredential.refresh).toBe("sub-refreshed-refresh");
expect(mainCredential.expires).toBe(freshExpiry);
expectPersistedOpenAICodexProfileWithoutInlineTokens(mainRaw.profiles[profileId], {
expires: freshExpiry,
accountId,
});
expect(JSON.stringify(mainRaw)).not.toContain("sub-refreshed-access");
expect(JSON.stringify(mainRaw)).not.toContain("sub-refreshed-refresh");
});
it("does not mirror when refresh was performed from the main agent itself", async () => {
@@ -161,10 +177,11 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
const mainRaw = JSON.parse(
await fs.readFile(path.join(mainAgentDir, "auth-profiles.json"), "utf8"),
) as AuthProfileStore;
const mainCredential = requireOAuthCredential(mainRaw, profileId);
expect(mainCredential.access).toBe("main-refreshed-access");
expect(mainCredential.refresh).toBe("main-refreshed-refresh");
expect(mainCredential.expires).toBe(freshExpiry);
expectPersistedOpenAICodexProfileWithoutInlineTokens(mainRaw.profiles[profileId], {
expires: freshExpiry,
});
expect(JSON.stringify(mainRaw)).not.toContain("main-refreshed-access");
expect(JSON.stringify(mainRaw)).not.toContain("main-refreshed-refresh");
expect(refreshProviderOAuthCredentialWithPluginMock).toHaveBeenCalledTimes(1);
});
@@ -332,17 +349,22 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
const subRaw = JSON.parse(
await fs.readFile(path.join(subAgentDir, "auth-profiles.json"), "utf8"),
) as AuthProfileStore;
const subCredential = requireOAuthCredential(subRaw, profileId);
expect(subCredential.access).toBe("local-stale-access");
expect(subCredential.refresh).toBe("local-stale-refresh");
expectPersistedOpenAICodexProfileWithoutInlineTokens(subRaw.profiles[profileId], {
expires: now - 120_000,
accountId,
});
expect(JSON.stringify(subRaw)).not.toContain("local-stale-access");
expect(JSON.stringify(subRaw)).not.toContain("local-stale-refresh");
const mainRaw = JSON.parse(
await fs.readFile(path.join(mainAgentDir, "auth-profiles.json"), "utf8"),
) as AuthProfileStore;
const mainCredential = requireOAuthCredential(mainRaw, profileId);
expect(mainCredential.access).toBe("main-owner-refreshed-access");
expect(mainCredential.refresh).toBe("main-owner-refreshed-refresh");
expect(mainCredential.expires).toBe(freshExpiry);
expectPersistedOpenAICodexProfileWithoutInlineTokens(mainRaw.profiles[profileId], {
expires: freshExpiry,
accountId,
});
expect(JSON.stringify(mainRaw)).not.toContain("main-owner-refreshed-access");
expect(JSON.stringify(mainRaw)).not.toContain("main-owner-refreshed-refresh");
});
it("inherits main-agent credentials via the catch-block fallback when refresh throws after main becomes fresh", async () => {
@@ -410,7 +432,10 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
const subRaw = JSON.parse(
await fs.readFile(path.join(subAgentDir, "auth-profiles.json"), "utf8"),
) as AuthProfileStore;
expect(requireOAuthCredential(subRaw, profileId).access).toBe("cached-access-token");
expectPersistedOpenAICodexProfileWithoutInlineTokens(subRaw.profiles[profileId], {
accountId: "acct-shared",
});
expect(JSON.stringify(subRaw)).not.toContain("cached-access-token");
});
it("mirrors refreshed credentials produced by the plugin-refresh path", async () => {

View File

@@ -85,6 +85,20 @@ function mockRotatedOpenAICodexRefresh() {
});
}
function expectPersistedOpenAICodexProfileWithoutInlineTokens(
credential: AuthProfileStore["profiles"][string],
metadata: Record<string, unknown> = {},
): void {
expect(credential).toMatchObject({
type: "oauth",
provider: "openai-codex",
...metadata,
});
expect(credential).not.toHaveProperty("access");
expect(credential).not.toHaveProperty("refresh");
expect(credential).not.toHaveProperty("idToken");
}
function resolveOpenAICodexProfile(params: { profileId: string; agentDir: string }) {
return resolveApiKeyForProfile({
store: ensureAuthProfileStore(params.agentDir),
@@ -212,7 +226,7 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => {
expect(refreshProviderOAuthCredentialWithPluginMock).toHaveBeenCalledTimes(1);
});
it("persists plugin-refreshed openai-codex credentials before returning", async () => {
it("persists plugin-refreshed openai-codex metadata before returning", async () => {
const profileId = "openai-codex:default";
saveAuthProfileStore(
createExpiredOauthStore({
@@ -233,11 +247,11 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => {
});
const persisted = await readPersistedStore(agentDir);
const profile = requireOAuthProfile(persisted, profileId);
expect(profile.provider).toBe("openai-codex");
expect(profile.access).toBe("rotated-access-token");
expect(profile.refresh).toBe("rotated-refresh-token");
expect(profile.accountId).toBe("acct-rotated");
expectPersistedOpenAICodexProfileWithoutInlineTokens(persisted.profiles[profileId], {
accountId: "acct-rotated",
});
expect(JSON.stringify(persisted)).not.toContain("rotated-access-token");
expect(JSON.stringify(persisted)).not.toContain("rotated-refresh-token");
});
it("refreshes imported Codex credentials into the canonical auth store without writing back to .codex", async () => {
@@ -286,12 +300,17 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => {
email: undefined,
});
const persisted = await readPersistedStore(agentDir);
const profile = requireOAuthProfile(persisted, profileId);
expect(profile.provider).toBe("openai-codex");
expect(profile.access).toBe("rotated-cli-access-token");
expect(profile.refresh).toBe("rotated-cli-refresh-token");
expect(profile.accountId).toBe("acct-rotated");
expect(profile.access).not.toBe("expired-access-token");
expectPersistedOpenAICodexProfileWithoutInlineTokens(persisted.profiles[profileId], {
accountId: "acct-rotated",
});
expect(JSON.stringify(persisted)).not.toContain("rotated-cli-access-token");
expect(JSON.stringify(persisted)).not.toContain("rotated-cli-refresh-token");
expect(persisted.profiles[profileId]).not.toEqual(
expect.objectContaining({
provider: "openai-codex",
access: "expired-access-token",
}),
);
});
it("ignores mismatched fresh Codex CLI credentials when canonical local auth is bound to another account", async () => {
@@ -344,13 +363,18 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => {
});
const persisted = await readPersistedStore(agentDir);
const profile = requireOAuthProfile(persisted, profileId);
expect(profile.access).toBe("fresh-local-access-token");
expect(profile.refresh).toBe("fresh-local-refresh-token");
expect(profile.accountId).toBe("acct-local");
expect(profile.access).not.toBe("fresh-cli-access-token");
expect(profile.refresh).not.toBe("fresh-cli-refresh-token");
expect(profile.accountId).not.toBe("acct-external");
expectPersistedOpenAICodexProfileWithoutInlineTokens(persisted.profiles[profileId], {
accountId: "acct-local",
});
expect(JSON.stringify(persisted)).not.toContain("fresh-local-access-token");
expect(JSON.stringify(persisted)).not.toContain("fresh-local-refresh-token");
expect(persisted.profiles[profileId]).not.toEqual(
expect.objectContaining({
access: "fresh-cli-access-token",
refresh: "fresh-cli-refresh-token",
accountId: "acct-external",
}),
);
});
it("keeps the canonical refresh token when imported Codex CLI state is expired", async () => {
@@ -406,10 +430,14 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => {
});
const persisted = await readPersistedStore(agentDir);
const profile = requireOAuthProfile(persisted, profileId);
expect(profile.access).toBe("fresh-access-token");
expect(profile.refresh).toBe("fresh-refresh-token");
expect(profile.refresh).not.toBe("fresh-cli-refresh-token");
expectPersistedOpenAICodexProfileWithoutInlineTokens(persisted.profiles[profileId]);
expect(JSON.stringify(persisted)).not.toContain("fresh-access-token");
expect(JSON.stringify(persisted)).not.toContain("fresh-refresh-token");
expect(persisted.profiles[profileId]).not.toEqual(
expect.objectContaining({
refresh: "fresh-cli-refresh-token",
}),
);
});
it("adopts fresher stored credentials after refresh_token_reused", async () => {
@@ -514,9 +542,9 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => {
expect(getOAuthApiKeyMock).toHaveBeenCalledTimes(2);
const persisted = await readPersistedStore(agentDir);
const profile = requireOAuthProfile(persisted, profileId);
expect(profile.access).toBe("retried-access-token");
expect(profile.refresh).toBe("retried-refresh-token");
expectPersistedOpenAICodexProfileWithoutInlineTokens(persisted.profiles[profileId]);
expect(JSON.stringify(persisted)).not.toContain("retried-access-token");
expect(JSON.stringify(persisted)).not.toContain("retried-refresh-token");
});
it("keeps throwing for non-codex providers on the same refresh error", async () => {

View File

@@ -1,6 +1,11 @@
import { resolveOAuthPath } from "../../config/paths.js";
import { execFileSync } from "node:child_process";
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { resolveOAuthDir, resolveOAuthPath, resolveStateDir } from "../../config/paths.js";
import { coerceSecretRef } from "../../config/types.secrets.js";
import { loadJsonFile } from "../../infra/json-file.js";
import { loadJsonFile, saveJsonFile } from "../../infra/json-file.js";
import { normalizeProviderId } from "../provider-id.js";
import { AUTH_STORE_VERSION, log } from "./constants.js";
import {
@@ -22,6 +27,7 @@ import type {
AuthProfileSecretsStore,
AuthProfileStore,
OAuthCredential,
OAuthCredentialRef,
OAuthCredentials,
ProfileUsageStats,
} from "./types.js";
@@ -32,6 +38,40 @@ type CredentialRejectReason = "non_object" | "invalid_type" | "missing_provider"
type RejectedCredentialEntry = { key: string; reason: CredentialRejectReason };
const AUTH_PROFILE_TYPES = new Set<AuthProfileCredential["type"]>(["api_key", "oauth", "token"]);
const REDACTED_OAUTH_TOKEN_PROVIDER_IDS = new Set(["openai-codex"]);
const OAUTH_PROFILE_SECRET_REF_SOURCE = "openclaw-credentials" as const;
const OAUTH_PROFILE_SECRET_DIRNAME = "auth-profiles";
const OAUTH_PROFILE_SECRET_VERSION = 1;
const OAUTH_PROFILE_SECRET_ALGORITHM = "aes-256-gcm" as const;
const OAUTH_PROFILE_SECRET_KEY_ENV = "OPENCLAW_AUTH_PROFILE_SECRET_KEY";
const OAUTH_PROFILE_SECRET_KEYCHAIN_SERVICE = "OpenClaw Auth Profile Secrets";
const OAUTH_PROFILE_SECRET_KEYCHAIN_ACCOUNT = "oauth-profile-master-key";
const OAUTH_PROFILE_SECRET_KEY_FILE_NAME = "auth-profile-secret-key";
type OAuthProfileSecretMaterial = {
access?: string;
refresh?: string;
idToken?: string;
};
type OAuthProfileEncryptedSecretPayload = {
algorithm: typeof OAUTH_PROFILE_SECRET_ALGORITHM;
iv: string;
tag: string;
ciphertext: string;
};
type OAuthProfileSecretPayload = OAuthProfileSecretMaterial & {
version: typeof OAUTH_PROFILE_SECRET_VERSION;
profileId: string;
provider: string;
encrypted?: OAuthProfileEncryptedSecretPayload;
};
type LoadPersistedAuthProfileStoreOptions = {
rewriteInlineOAuthSecrets?: boolean;
repairOAuthSecretPayloads?: boolean;
};
function normalizeSecretBackedField(params: {
entry: Record<string, unknown>;
@@ -62,6 +102,424 @@ function normalizeRawCredentialEntry(raw: Record<string, unknown>): Partial<Auth
return entry as Partial<AuthProfileCredential>;
}
function shouldPersistOAuthWithoutInlineSecrets(
credential: AuthProfileCredential,
): credential is OAuthCredential {
return (
credential.type === "oauth" &&
REDACTED_OAUTH_TOKEN_PROVIDER_IDS.has(normalizeProviderId(credential.provider))
);
}
function resolveOAuthProfileSecretId(params: { agentDir?: string; profileId: string }): string {
return createHash("sha256")
.update(`${resolveAuthStorePath(params.agentDir)}\0${params.profileId}`)
.digest("hex")
.slice(0, 32);
}
function resolveOAuthProfileSecretPath(ref: OAuthCredentialRef): string {
return path.join(resolveOAuthDir(), OAUTH_PROFILE_SECRET_DIRNAME, `${ref.id}.json`);
}
function isOAuthProfileSecretRef(value: unknown): value is OAuthCredentialRef {
if (!value || typeof value !== "object") {
return false;
}
const record = value as Partial<OAuthCredentialRef>;
return (
record.source === OAUTH_PROFILE_SECRET_REF_SOURCE &&
record.provider === "openai-codex" &&
typeof record.id === "string" &&
/^[a-f0-9]{32}$/.test(record.id)
);
}
function resolveOAuthProfileSecretRef(params: {
agentDir?: string;
profileId: string;
}): OAuthCredentialRef {
return {
source: OAUTH_PROFILE_SECRET_REF_SOURCE,
provider: "openai-codex",
id: resolveOAuthProfileSecretId(params),
};
}
function hasInlineOAuthTokenMaterial(credential: OAuthCredential): boolean {
return [credential.access, credential.refresh, credential.idToken].some(
(value) => typeof value === "string" && value.trim().length > 0,
);
}
function normalizeOAuthProfileSecretMaterial(
credential: Partial<Pick<OAuthCredential, "access" | "refresh" | "idToken">>,
): OAuthProfileSecretMaterial | null {
const material: OAuthProfileSecretMaterial = {
...(typeof credential.access === "string" && credential.access.trim()
? { access: credential.access }
: {}),
...(typeof credential.refresh === "string" && credential.refresh.trim()
? { refresh: credential.refresh }
: {}),
...(typeof credential.idToken === "string" && credential.idToken.trim()
? { idToken: credential.idToken }
: {}),
};
return Object.keys(material).length > 0 ? material : null;
}
function buildOAuthProfileSecretAad(params: {
ref: OAuthCredentialRef;
profileId: string;
provider: string;
}): Buffer {
return Buffer.from(`${params.ref.id}\0${params.profileId}\0${params.provider}`, "utf8");
}
function readMacOAuthProfileSecretKey(): string | undefined {
if (process.platform !== "darwin") {
return undefined;
}
try {
return execFileSync(
"security",
[
"find-generic-password",
"-s",
OAUTH_PROFILE_SECRET_KEYCHAIN_SERVICE,
"-a",
OAUTH_PROFILE_SECRET_KEYCHAIN_ACCOUNT,
"-w",
],
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
).trim();
} catch {
return undefined;
}
}
function createMacOAuthProfileSecretKey(): string | undefined {
if (process.platform !== "darwin") {
return undefined;
}
const generated = randomBytes(32).toString("base64url");
try {
execFileSync(
"security",
[
"add-generic-password",
"-U",
"-s",
OAUTH_PROFILE_SECRET_KEYCHAIN_SERVICE,
"-a",
OAUTH_PROFILE_SECRET_KEYCHAIN_ACCOUNT,
"-w",
generated,
],
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
);
return generated;
} catch (err) {
log.warn("failed to create oauth profile secret keychain entry", { err });
return undefined;
}
}
function isPathInsideOrEqual(parentDir: string, candidatePath: string): boolean {
const relative = path.relative(path.resolve(parentDir), path.resolve(candidatePath));
return (
relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative))
);
}
function uniquePaths(paths: Array<string | undefined>): string[] {
return Array.from(new Set(paths.filter((entry): entry is string => Boolean(entry))));
}
function resolveFallbackOAuthProfileSecretKeyFileCandidates(): string[] {
if (process.platform === "win32") {
const home = process.env.USERPROFILE?.trim() || os.homedir();
const root =
process.env.APPDATA?.trim() || (home ? path.join(home, "AppData", "Roaming") : undefined);
return uniquePaths([
root ? path.join(root, "OpenClaw", OAUTH_PROFILE_SECRET_KEY_FILE_NAME) : undefined,
home
? path.join(home, ".openclaw-auth-profile-secrets", OAUTH_PROFILE_SECRET_KEY_FILE_NAME)
: undefined,
]);
}
if (process.platform === "darwin") {
const home = process.env.HOME?.trim() || os.homedir();
return uniquePaths([
home
? path.join(
home,
"Library",
"Application Support",
"OpenClaw",
OAUTH_PROFILE_SECRET_KEY_FILE_NAME,
)
: undefined,
home
? path.join(home, ".openclaw-auth-profile-secrets", OAUTH_PROFILE_SECRET_KEY_FILE_NAME)
: undefined,
]);
}
const home = process.env.HOME?.trim() || os.homedir();
const root =
process.env.XDG_CONFIG_HOME?.trim() || (home ? path.join(home, ".config") : undefined);
return uniquePaths([
root ? path.join(root, "openclaw", OAUTH_PROFILE_SECRET_KEY_FILE_NAME) : undefined,
home
? path.join(home, ".openclaw-auth-profile-secrets", OAUTH_PROFILE_SECRET_KEY_FILE_NAME)
: undefined,
]);
}
function resolveFallbackOAuthProfileSecretKeyFilePath(): string | undefined {
const stateDir = resolveStateDir();
return resolveFallbackOAuthProfileSecretKeyFileCandidates().find(
(candidate) => !isPathInsideOrEqual(stateDir, candidate),
);
}
function readFallbackOAuthProfileSecretKeyFile(): string | undefined {
const keyPath = resolveFallbackOAuthProfileSecretKeyFilePath();
if (!keyPath) {
return undefined;
}
return readFallbackOAuthProfileSecretKeyFileAtPath(keyPath);
}
function readFallbackOAuthProfileSecretKeyFileAtPath(keyPath: string): string | undefined {
try {
const value = fs.readFileSync(keyPath, "utf8").trim();
return value || undefined;
} catch {
return undefined;
}
}
function createFallbackOAuthProfileSecretKeyFile(): string | undefined {
const keyPath = resolveFallbackOAuthProfileSecretKeyFilePath();
if (!keyPath) {
return undefined;
}
const generated = randomBytes(32).toString("base64url");
let fd: number | undefined;
try {
fs.mkdirSync(path.dirname(keyPath), { recursive: true, mode: 0o700 });
fd = fs.openSync(keyPath, "wx", 0o600);
fs.writeFileSync(fd, `${generated}\n`, "utf8");
try {
fs.chmodSync(keyPath, 0o600);
} catch {
// Best effort only; some platforms ignore POSIX modes.
}
return generated;
} catch (err) {
if ((err as NodeJS.ErrnoException)?.code === "EEXIST") {
return readFallbackOAuthProfileSecretKeyFileAtPath(keyPath);
}
log.warn("failed to create oauth profile secret key file", { err });
return undefined;
} finally {
if (fd !== undefined) {
try {
fs.closeSync(fd);
} catch {
// Best effort only.
}
}
}
}
function shouldUseMacKeychainForOAuthProfileSecrets(): boolean {
return process.platform === "darwin" && process.env.VITEST !== "true";
}
function resolveOAuthProfileSecretKeySeed(options?: { create?: boolean }): string | undefined {
const externalKey = process.env[OAUTH_PROFILE_SECRET_KEY_ENV]?.trim();
if (externalKey) {
return externalKey;
}
if (process.env.NODE_ENV === "test" && process.env.VITEST === "true") {
return "openclaw-test-oauth-profile-secret-key";
}
if (shouldUseMacKeychainForOAuthProfileSecrets()) {
const keychainKey =
readMacOAuthProfileSecretKey() ??
(options?.create === true ? createMacOAuthProfileSecretKey() : undefined);
if (keychainKey) {
return keychainKey;
}
}
const fileKey =
readFallbackOAuthProfileSecretKeyFile() ??
(options?.create === true ? createFallbackOAuthProfileSecretKeyFile() : undefined);
if (fileKey) {
return fileKey;
}
return undefined;
}
function buildOAuthProfileSecretKey(options?: { create?: boolean }): Buffer | null {
const externalKey = resolveOAuthProfileSecretKeySeed(options);
if (!externalKey) {
return null;
}
return createHash("sha256").update(`openclaw:auth-profile-oauth:${externalKey}`).digest();
}
function encryptOAuthProfileSecretMaterial(params: {
ref: OAuthCredentialRef;
profileId: string;
provider: string;
material: OAuthProfileSecretMaterial;
}): OAuthProfileEncryptedSecretPayload {
const key = buildOAuthProfileSecretKey({ create: true });
if (!key) {
throw new Error("OAuth profile secret key source is required to persist OAuth profile secrets");
}
const iv = randomBytes(12);
const cipher = createCipheriv(OAUTH_PROFILE_SECRET_ALGORITHM, key, iv);
cipher.setAAD(
buildOAuthProfileSecretAad({
ref: params.ref,
profileId: params.profileId,
provider: params.provider,
}),
);
const ciphertext = Buffer.concat([
cipher.update(JSON.stringify(params.material), "utf8"),
cipher.final(),
]);
return {
algorithm: OAUTH_PROFILE_SECRET_ALGORITHM,
iv: iv.toString("base64url"),
tag: cipher.getAuthTag().toString("base64url"),
ciphertext: ciphertext.toString("base64url"),
};
}
function decryptOAuthProfileSecretMaterial(params: {
ref: OAuthCredentialRef;
profileId: string;
provider: string;
encrypted: OAuthProfileEncryptedSecretPayload;
}): OAuthProfileSecretMaterial | null {
if (params.encrypted.algorithm !== OAUTH_PROFILE_SECRET_ALGORITHM) {
return null;
}
const key = buildOAuthProfileSecretKey();
if (!key) {
return null;
}
try {
const decipher = createDecipheriv(
OAUTH_PROFILE_SECRET_ALGORITHM,
key,
Buffer.from(params.encrypted.iv, "base64url"),
);
decipher.setAAD(
buildOAuthProfileSecretAad({
ref: params.ref,
profileId: params.profileId,
provider: params.provider,
}),
);
decipher.setAuthTag(Buffer.from(params.encrypted.tag, "base64url"));
const plaintext = Buffer.concat([
decipher.update(Buffer.from(params.encrypted.ciphertext, "base64url")),
decipher.final(),
]).toString("utf8");
const raw = JSON.parse(plaintext) as unknown;
if (!raw || typeof raw !== "object") {
return null;
}
return normalizeOAuthProfileSecretMaterial(raw as OAuthProfileSecretMaterial);
} catch {
return null;
}
}
function writeOAuthProfileSecretMaterial(params: {
ref: OAuthCredentialRef;
profileId: string;
provider: string;
material: OAuthProfileSecretMaterial;
}): void {
const secretPath = resolveOAuthProfileSecretPath(params.ref);
fs.mkdirSync(path.dirname(secretPath), { recursive: true, mode: 0o700 });
const payload: OAuthProfileSecretPayload = {
version: OAUTH_PROFILE_SECRET_VERSION,
profileId: params.profileId,
provider: params.provider,
encrypted: encryptOAuthProfileSecretMaterial(params),
};
saveJsonFile(secretPath, payload);
try {
fs.chmodSync(secretPath, 0o600);
} catch {
// Best effort only; some platforms ignore POSIX modes.
}
}
function persistOAuthProfileSecrets(params: {
agentDir?: string;
profileId: string;
credential: OAuthCredential;
}): OAuthCredentialRef | undefined {
const expectedRef = resolveOAuthProfileSecretRef({
agentDir: params.agentDir,
profileId: params.profileId,
});
const existingRef = isOAuthProfileSecretRef(params.credential.oauthRef)
? params.credential.oauthRef
: undefined;
const targetRef = existingRef?.id === expectedRef.id ? existingRef : expectedRef;
if (!hasInlineOAuthTokenMaterial(params.credential)) {
return existingRef?.id === expectedRef.id ? existingRef : undefined;
}
const material = normalizeOAuthProfileSecretMaterial(params.credential);
if (!material) {
return existingRef?.id === expectedRef.id ? existingRef : undefined;
}
writeOAuthProfileSecretMaterial({
ref: targetRef,
profileId: params.profileId,
provider: params.credential.provider,
material,
});
return targetRef;
}
function omitInlineOAuthSecrets(params: {
agentDir?: string;
profileId: string;
credential: OAuthCredential;
}): AuthProfileCredential {
const oauthRef = persistOAuthProfileSecrets(params);
if (!oauthRef) {
return params.credential;
}
const sanitized = { ...params.credential } as Record<string, unknown>;
delete sanitized.access;
delete sanitized.refresh;
delete sanitized.idToken;
sanitized.oauthRef = oauthRef;
return sanitized as AuthProfileCredential;
}
function hasInlinePersistableOAuthSecrets(credential: AuthProfileCredential): boolean {
return (
shouldPersistOAuthWithoutInlineSecrets(credential) && hasInlineOAuthTokenMaterial(credential)
);
}
function parseCredentialEntry(
raw: unknown,
fallbackProvider?: string,
@@ -500,6 +958,7 @@ export function buildPersistedAuthProfileSecretsStore(
profileId: string;
credential: AuthProfileCredential;
}) => boolean,
options?: { agentDir?: string },
): AuthProfileSecretsStore {
const profiles = Object.fromEntries(
Object.entries(store.profiles).flatMap(([profileId, credential]) => {
@@ -516,6 +975,18 @@ export function buildPersistedAuthProfileSecretsStore(
delete sanitized.token;
return [[profileId, sanitized]];
}
if (shouldPersistOAuthWithoutInlineSecrets(credential)) {
return [
[
profileId,
omitInlineOAuthSecrets({
agentDir: options?.agentDir,
profileId,
credential,
}),
],
];
}
return [[profileId, credential]];
}),
) as AuthProfileSecretsStore["profiles"];
@@ -589,17 +1060,274 @@ export function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean {
return mutated;
}
export function loadPersistedAuthProfileStore(agentDir?: string): AuthProfileStore | null {
function coerceOAuthProfileEncryptedSecretPayload(
raw: unknown,
): OAuthProfileEncryptedSecretPayload | null {
if (!raw || typeof raw !== "object") {
return null;
}
const record = raw as Partial<OAuthProfileEncryptedSecretPayload>;
return record.algorithm === OAUTH_PROFILE_SECRET_ALGORITHM &&
typeof record.iv === "string" &&
typeof record.tag === "string" &&
typeof record.ciphertext === "string"
? {
algorithm: record.algorithm,
iv: record.iv,
tag: record.tag,
ciphertext: record.ciphertext,
}
: null;
}
function hasEncryptedOAuthProfileSecretPayload(raw: unknown): boolean {
return (
!!raw &&
typeof raw === "object" &&
coerceOAuthProfileEncryptedSecretPayload(
(raw as Partial<OAuthProfileSecretPayload>).encrypted,
) !== null
);
}
function coerceOAuthProfileSecretPayload(params: {
raw: unknown;
ref: OAuthCredentialRef;
profileId: string;
provider: string;
}): OAuthProfileSecretMaterial | null {
const { raw, ref, profileId, provider } = params;
if (!raw || typeof raw !== "object") {
return null;
}
const record = raw as Partial<OAuthProfileSecretPayload>;
if (
record.version !== OAUTH_PROFILE_SECRET_VERSION ||
record.profileId !== profileId ||
record.provider !== provider
) {
return null;
}
const encrypted = coerceOAuthProfileEncryptedSecretPayload(record.encrypted);
if (encrypted) {
return decryptOAuthProfileSecretMaterial({
ref,
profileId,
provider,
encrypted,
});
}
return normalizeOAuthProfileSecretMaterial(record);
}
function resolvePersistedOAuthSecrets(
credential: OAuthCredential,
profileId: string,
options?: { repairOAuthSecretPayloads?: boolean },
): OAuthCredential {
if (!isOAuthProfileSecretRef(credential.oauthRef)) {
return credential;
}
const secretPath = resolveOAuthProfileSecretPath(credential.oauthRef);
const raw = loadJsonFile(secretPath);
const secret = coerceOAuthProfileSecretPayload({
raw,
ref: credential.oauthRef,
profileId,
provider: credential.provider,
});
if (!secret) {
return credential;
}
if (options?.repairOAuthSecretPayloads === true && !hasEncryptedOAuthProfileSecretPayload(raw)) {
writeOAuthProfileSecretMaterial({
ref: credential.oauthRef,
profileId,
provider: credential.provider,
material: secret,
});
}
return {
...credential,
...(secret.access ? { access: secret.access } : {}),
...(secret.refresh ? { refresh: secret.refresh } : {}),
...(secret.idToken ? { idToken: secret.idToken } : {}),
} as OAuthCredential;
}
function resolvePersistedOAuthProfileSecrets(
store: AuthProfileStore,
options?: { repairOAuthSecretPayloads?: boolean },
): AuthProfileStore {
const profiles = Object.fromEntries(
Object.entries(store.profiles).map(([profileId, credential]) => [
profileId,
credential.type === "oauth"
? resolvePersistedOAuthSecrets(credential, profileId, options)
: credential,
]),
) as AuthProfileStore["profiles"];
return {
...store,
profiles,
};
}
function collectPersistedOAuthProfileSecretIds(
store: AuthProfileStore | AuthProfileSecretsStore,
): Set<string> {
const ids = new Set<string>();
for (const credential of Object.values(store.profiles)) {
if (credential.type === "oauth" && isOAuthProfileSecretRef(credential.oauthRef)) {
ids.add(credential.oauthRef.id);
}
}
return ids;
}
export function removeDetachedOAuthProfileSecrets(params: {
previousRaw: unknown;
nextStore: AuthProfileSecretsStore;
}): void {
const previousStore = coercePersistedAuthProfileStore(params.previousRaw);
if (!previousStore) {
return;
}
const previousIds = collectPersistedOAuthProfileSecretIds(previousStore);
if (previousIds.size === 0) {
return;
}
const nextIds = collectPersistedOAuthProfileSecretIds(params.nextStore);
for (const id of previousIds) {
if (nextIds.has(id)) {
continue;
}
fs.rmSync(
resolveOAuthProfileSecretPath({
source: OAUTH_PROFILE_SECRET_REF_SOURCE,
provider: "openai-codex",
id,
}),
{ force: true },
);
}
}
function buildPersistedAuthProfileFilePayload(params: {
store: AuthProfileStore;
raw: unknown;
agentDir?: string;
}): AuthProfileSecretsStore & Partial<AuthProfileStore> {
const payload = buildPersistedAuthProfileSecretsStore(params.store, undefined, {
agentDir: params.agentDir,
}) as AuthProfileSecretsStore & Partial<AuthProfileStore>;
const state = coerceAuthProfileState(params.raw);
return {
...payload,
...(state.order ? { order: state.order } : {}),
...(state.lastGood ? { lastGood: state.lastGood } : {}),
...(state.usageStats ? { usageStats: state.usageStats } : {}),
};
}
function resolveAuthStoreLockPathSync(authPath: string): string {
const resolved = path.resolve(authPath);
const dir = path.dirname(resolved);
fs.mkdirSync(dir, { recursive: true });
try {
return `${path.join(fs.realpathSync(dir), path.basename(resolved))}.lock`;
} catch {
return `${resolved}.lock`;
}
}
function withAuthStoreRewriteLockSync(authPath: string, fn: () => void): boolean {
const lockPath = resolveAuthStoreLockPathSync(authPath);
let fd: number | undefined;
try {
fd = fs.openSync(lockPath, "wx", 0o600);
fs.writeFileSync(
fd,
`${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }, null, 2)}\n`,
"utf8",
);
fn();
return true;
} catch (err) {
if ((err as NodeJS.ErrnoException)?.code === "EEXIST") {
return false;
}
throw err;
} finally {
if (fd !== undefined) {
try {
fs.closeSync(fd);
} catch {
// Best effort only.
}
try {
fs.rmSync(lockPath, { force: true });
} catch {
// Best effort only.
}
}
}
}
function rewritePersistedInlineOAuthSecrets(params: { authPath: string; agentDir?: string }): void {
withAuthStoreRewriteLockSync(params.authPath, () => {
const raw = loadJsonFile(params.authPath);
const store = coercePersistedAuthProfileStore(raw);
if (!store) {
return;
}
const merged = {
...store,
...mergeAuthProfileState(
coerceAuthProfileState(raw),
loadPersistedAuthProfileState(params.agentDir),
),
};
if (!Object.values(merged.profiles).some(hasInlinePersistableOAuthSecrets)) {
return;
}
saveJsonFile(
params.authPath,
buildPersistedAuthProfileFilePayload({ store: merged, raw, agentDir: params.agentDir }),
);
});
}
export function loadPersistedAuthProfileStore(
agentDir?: string,
options?: LoadPersistedAuthProfileStoreOptions,
): AuthProfileStore | null {
const authPath = resolveAuthStorePath(agentDir);
const raw = loadJsonFile(authPath);
const store = coercePersistedAuthProfileStore(raw);
if (!store) {
return null;
}
return {
const merged = {
...store,
...mergeAuthProfileState(coerceAuthProfileState(raw), loadPersistedAuthProfileState(agentDir)),
};
const canRepairPersistedSecrets =
options?.rewriteInlineOAuthSecrets === true && process.env.OPENCLAW_AUTH_STORE_READONLY !== "1";
if (
canRepairPersistedSecrets &&
Object.values(merged.profiles).some(hasInlinePersistableOAuthSecrets)
) {
try {
rewritePersistedInlineOAuthSecrets({ authPath, agentDir });
} catch (err) {
log.warn("failed to rewrite inline oauth auth profile secrets", { err, authPath });
}
}
return resolvePersistedOAuthProfileSecrets(merged, {
repairOAuthSecretPayloads:
options?.repairOAuthSecretPayloads === true || canRepairPersistedSecrets,
});
}
export function loadLegacyAuthProfileStore(agentDir?: string): LegacyAuthStore | null {

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@ import path from "node:path";
import { isDeepStrictEqual } from "node:util";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { withFileLock } from "../../infra/file-lock.js";
import { saveJsonFile } from "../../infra/json-file.js";
import { loadJsonFile, saveJsonFile } from "../../infra/json-file.js";
import { cloneAuthProfileStore } from "./clone.js";
import {
AUTH_STORE_LOCK_OPTIONS,
@@ -31,6 +31,7 @@ import {
loadPersistedAuthProfileStore,
mergeAuthProfileStores,
mergeOAuthFileIntoStore,
removeDetachedOAuthProfileSecrets,
} from "./persisted.js";
import {
clearRuntimeAuthProfileStoreSnapshots as clearRuntimeAuthProfileStoreSnapshotsImpl,
@@ -478,7 +479,9 @@ export async function updateAuthProfileStoreWithLock(params: {
}
export function loadAuthProfileStore(): AuthProfileStore {
const asStore = loadPersistedAuthProfileStore();
const asStore = loadPersistedAuthProfileStore(undefined, {
rewriteInlineOAuthSecrets: process.env.OPENCLAW_AUTH_STORE_READONLY !== "1",
});
if (asStore) {
return overlayExternalAuthProfiles(asStore);
}
@@ -515,7 +518,9 @@ function loadAuthProfileStoreForAgent(
return cached;
}
}
const asStore = loadPersistedAuthProfileStore(agentDir);
const asStore = loadPersistedAuthProfileStore(agentDir, {
rewriteInlineOAuthSecrets: !readOnly && process.env.OPENCLAW_AUTH_STORE_READONLY !== "1",
});
if (asStore) {
const synced = maybeSyncPersistedExternalCliAuthProfiles({
store: asStore,
@@ -745,8 +750,10 @@ export function saveAuthProfileStore(
const authPath = resolveAuthStorePath(agentDir);
const statePath = resolveAuthStatePath(agentDir);
const localStore = buildLocalAuthProfileStoreForSave({ store, agentDir, options });
const payload = buildPersistedAuthProfileSecretsStore(localStore);
const previousRaw = loadJsonFile(authPath);
const payload = buildPersistedAuthProfileSecretsStore(localStore, undefined, { agentDir });
saveJsonFile(authPath, payload);
removeDetachedOAuthProfileSecrets({ previousRaw, nextStore: payload });
savePersistedAuthProfileState(localStore, agentDir);
writeCachedAuthProfileStore({
authPath,

View File

@@ -16,6 +16,12 @@ export type OAuthCredentials = {
idToken?: string;
};
export type OAuthCredentialRef = {
source: "openclaw-credentials";
provider: "openai-codex";
id: string;
};
export type ApiKeyCredential = {
type: "api_key";
provider: string;
@@ -57,6 +63,7 @@ export type OAuthCredential = OAuthCredentials & {
copyToAgents?: boolean;
email?: string;
displayName?: string;
oauthRef?: OAuthCredentialRef;
};
export type AuthProfileCredential = ApiKeyCredential | TokenCredential | OAuthCredential;

View File

@@ -3,6 +3,7 @@ import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AUTH_STORE_VERSION } from "../agents/auth-profiles/constants.js";
import { saveAuthProfileStore } from "../agents/auth-profiles/store.js";
import { baseConfigSnapshot, createTestRuntime } from "./test-runtime-config-helpers.js";
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
@@ -137,6 +138,68 @@ describe("agents add command", () => {
}
});
it("copies portable Codex OAuth profiles without inline token material", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agents-add-oauth-copy-"));
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
process.env.OPENCLAW_STATE_DIR = root;
try {
const sourceAgentDir = path.join(root, "main", "agent");
const destAgentDir = path.join(root, "work", "agent");
const destAuthPath = path.join(destAgentDir, "auth-profiles.json");
await fs.mkdir(sourceAgentDir, { recursive: true });
saveAuthProfileStore(
{
version: AUTH_STORE_VERSION,
profiles: {
"openai-codex:default": {
type: "oauth",
provider: "openai-codex",
access: "codex-copy-access-token",
refresh: "codex-copy-refresh-token",
expires: Date.now() + 60_000,
copyToAgents: true,
},
},
},
sourceAgentDir,
);
const result = await __testing.copyPortableAuthProfiles({
sourceAgentDir,
destAuthPath,
});
expect(result).toEqual({ copied: 1, skipped: 0 });
const copiedRaw = await fs.readFile(destAuthPath, "utf8");
expect(copiedRaw).not.toContain("codex-copy-access-token");
expect(copiedRaw).not.toContain("codex-copy-refresh-token");
const copied = JSON.parse(copiedRaw) as {
profiles: Record<string, Record<string, unknown>>;
};
const credential = copied.profiles["openai-codex:default"];
expect(credential).toMatchObject({
type: "oauth",
provider: "openai-codex",
copyToAgents: true,
oauthRef: {
source: "openclaw-credentials",
provider: "openai-codex",
id: expect.any(String),
},
});
expect(credential).not.toHaveProperty("access");
expect(credential).not.toHaveProperty("refresh");
expect(credential).not.toHaveProperty("idToken");
} finally {
if (previousStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = previousStateDir;
}
await fs.rm(root, { recursive: true, force: true });
}
});
it("does not claim skipped OAuth profiles stay shared from a non-main source agent", () => {
expect(
__testing.formatSkippedOAuthProfilesMessage({

View File

@@ -10,7 +10,10 @@ import {
ensureAuthProfileStore,
} from "../agents/auth-profiles.js";
import { resolveAuthStorePath } from "../agents/auth-profiles/paths.js";
import { loadPersistedAuthProfileStore } from "../agents/auth-profiles/persisted.js";
import {
buildPersistedAuthProfileSecretsStore,
loadPersistedAuthProfileStore,
} from "../agents/auth-profiles/persisted.js";
import { formatCliCommand } from "../cli/command-format.js";
import { commitConfigWithPendingPluginInstalls } from "../cli/plugins-install-record-commit.js";
import { logConfigUpdated } from "../config/logging.js";
@@ -63,7 +66,12 @@ async function copyPortableAuthProfiles(params: {
return { copied: 0, skipped: portable.skippedProfileIds.length };
}
await fs.mkdir(path.dirname(params.destAuthPath), { recursive: true });
saveJsonFile(params.destAuthPath, portable.store);
saveJsonFile(
params.destAuthPath,
buildPersistedAuthProfileSecretsStore(portable.store, undefined, {
agentDir: path.dirname(params.destAuthPath),
}),
);
return {
copied: portable.copiedProfileIds.length,
skipped: portable.skippedProfileIds.length,
@@ -304,7 +312,10 @@ export async function agentsAddCommand(
});
if (shouldCopy) {
await fs.mkdir(path.dirname(destAuthPath), { recursive: true });
saveJsonFile(destAuthPath, portable.store);
saveJsonFile(
destAuthPath,
buildPersistedAuthProfileSecretsStore(portable.store, undefined, { agentDir }),
);
const skippedText =
portable.skippedProfileIds.length > 0
? ` ${formatSkippedOAuthProfilesMessage({

View File

@@ -1,16 +1,36 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ProgressReporter } from "../../cli/progress.js";
vi.mock("../../daemon/restart-logs.js", () => ({
resolveGatewayLogPaths: () => {
type GatewayLogPaths = {
logDir: string;
stdoutPath: string;
stderrPath: string;
};
const restartLogMocks = vi.hoisted(() => ({
resolveGatewayLogPaths: vi.fn<() => GatewayLogPaths>(() => {
throw new Error("skip log tail");
},
resolveGatewayRestartLogPath: () => "/tmp/gateway-restart.log",
}),
resolveGatewayRestartLogPath: vi.fn<() => string>(() => "/tmp/gateway-restart.log"),
}));
const gatewayMocks = vi.hoisted(() => ({
readFileTailLines: vi.fn<(filePath: string, maxLines: number) => Promise<string[]>>(
async () => [],
),
summarizeLogTail: vi.fn<(lines: string[], opts?: { maxLines?: number }) => string[]>(
(lines) => lines,
),
}));
vi.mock("../../daemon/restart-logs.js", () => ({
resolveGatewayLogPaths: restartLogMocks.resolveGatewayLogPaths,
resolveGatewayRestartLogPath: restartLogMocks.resolveGatewayRestartLogPath,
}));
vi.mock("./gateway.js", () => ({
readFileTailLines: vi.fn(async () => []),
summarizeLogTail: vi.fn(() => []),
readFileTailLines: gatewayMocks.readFileTailLines,
summarizeLogTail: gatewayMocks.summarizeLogTail,
}));
import { appendStatusAllDiagnosis } from "./diagnosis.js";
@@ -63,6 +83,15 @@ function createBaseParams(
}
describe("status-all diagnosis port checks", () => {
beforeEach(() => {
restartLogMocks.resolveGatewayLogPaths.mockImplementation(() => {
throw new Error("skip log tail");
});
restartLogMocks.resolveGatewayRestartLogPath.mockReturnValue("/tmp/gateway-restart.log");
gatewayMocks.readFileTailLines.mockResolvedValue([]);
gatewayMocks.summarizeLogTail.mockImplementation((lines: string[]) => lines);
});
it("labels OpenClaw Tailscale exposure separately from daemon state", async () => {
const params = createBaseParams([]);
params.tailscale.backendState = "Running";
@@ -145,4 +174,43 @@ describe("status-all diagnosis port checks", () => {
expect(output).not.toContain("Channel issues skipped (gateway unreachable)");
expect(output).not.toContain("Gateway health:");
});
it("does not read or display stale stderr tails on Darwin", async () => {
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", { value: "darwin" });
try {
restartLogMocks.resolveGatewayLogPaths.mockReturnValue({
logDir: "/tmp/openclaw/logs",
stdoutPath: "/tmp/openclaw/logs/gateway.log",
stderrPath: "/tmp/openclaw/logs/gateway.err.log",
});
restartLogMocks.resolveGatewayRestartLogPath.mockReturnValue(
"/tmp/openclaw/logs/gateway-restart.log",
);
gatewayMocks.readFileTailLines.mockImplementation(async (filePath: string) => {
if (filePath.endsWith("gateway.log")) {
return ["gateway stdout current"];
}
if (filePath.endsWith("gateway.err.log")) {
return ["failed to bind gateway socket stale"];
}
return [];
});
const params = createBaseParams([]);
await appendStatusAllDiagnosis(params);
const output = params.lines.join("\n");
expect(gatewayMocks.readFileTailLines).not.toHaveBeenCalledWith(
"/tmp/openclaw/logs/gateway.err.log",
40,
);
expect(output).toContain("# stdout: /tmp/openclaw/logs/gateway.log");
expect(output).toContain("gateway stdout current");
expect(output).not.toContain("# stderr:");
expect(output).not.toContain("failed to bind gateway socket stale");
} finally {
Object.defineProperty(process, "platform", { value: originalPlatform });
}
});
});

View File

@@ -226,17 +226,20 @@ export async function appendStatusAllDiagnosis(params: {
if (logPaths) {
params.progress.setLabel("Reading logs…");
const restartLogPath = resolveGatewayRestartLogPath(process.env);
const readStderr = process.platform !== "darwin";
const [stderrTail, stdoutTail, restartTail] = await Promise.all([
readFileTailLines(logPaths.stderrPath, 40).catch(() => []),
readStderr ? readFileTailLines(logPaths.stderrPath, 40).catch(() => []) : [],
readFileTailLines(logPaths.stdoutPath, 40).catch(() => []),
readFileTailLines(restartLogPath, 30).catch(() => []),
]);
if (stderrTail.length > 0 || stdoutTail.length > 0) {
lines.push("");
lines.push(muted(`Gateway logs (tail, summarized): ${logPaths.logDir}`));
lines.push(` ${muted(`# stderr: ${logPaths.stderrPath}`)}`);
for (const line of summarizeLogTail(stderrTail, { maxLines: 22 }).map(redactSecrets)) {
lines.push(` ${muted(line)}`);
if (readStderr) {
lines.push(` ${muted(`# stderr: ${logPaths.stderrPath}`)}`);
for (const line of summarizeLogTail(stderrTail, { maxLines: 22 }).map(redactSecrets)) {
lines.push(` ${muted(line)}`);
}
}
lines.push(` ${muted(`# stdout: ${logPaths.stdoutPath}`)}`);
for (const line of summarizeLogTail(stdoutTail, { maxLines: 22 }).map(redactSecrets)) {

View File

@@ -202,6 +202,36 @@ describe("config io audit helpers", () => {
expect(written.nextHash).toBe("next-hash");
});
it("redacts structured audit records before persistence", async () => {
const home = await suiteRootTracker.make("append-redacted");
const record = finalizeConfigWriteAuditRecord({
base: {
...createAuditRecordBase(path.join(home, ".openclaw", "openclaw.json")),
suspicious: [
"provider returned ya29.fake-access-token-with-enough-length",
"plugin returned AIzaSyD-very-real-looking-google-api-key-123",
],
},
result: "failed",
err: Object.assign(new Error("payload contained abcd-efgh-ijkl-mnop"), { code: "EFAIL" }),
});
await appendConfigAuditRecord({
fs,
env: {} as NodeJS.ProcessEnv,
homedir: () => home,
record,
});
const raw = fs.readFileSync(
path.join(home, ".openclaw", "logs", "config-audit.jsonl"),
"utf-8",
);
expect(raw).not.toContain("AIzaSyD-very-real-looking");
expect(raw).not.toContain("ya29.fake-access-token");
expect(raw).not.toContain("abcd-efgh-ijkl-mnop");
});
it("redacts argv values that follow known secret flag names", () => {
const argv = [
"node",

View File

@@ -1,5 +1,5 @@
import path from "node:path";
import { redactToolPayloadText } from "../logging/redact.js";
import { redactSecrets, redactToolPayloadText } from "../logging/redact.js";
import { resolveStateDir } from "./paths.js";
const CONFIG_AUDIT_ARGV_CAP = 8;
@@ -432,10 +432,10 @@ type ConfigAuditAppendParams = ConfigAuditAppendContext &
function resolveConfigAuditAppendRecord(params: ConfigAuditAppendParams): ConfigAuditRecord {
if ("record" in params) {
return params.record;
return redactSecrets(params.record);
}
const { fs: _fs, env: _env, homedir: _homedir, ...record } = params;
return record as ConfigAuditRecord;
return redactSecrets(record as ConfigAuditRecord);
}
export async function appendConfigAuditRecord(params: ConfigAuditAppendParams): Promise<void> {

View File

@@ -7,6 +7,7 @@ import {
type SessionWriteLockAcquireTimeoutConfig,
resolveSessionWriteLockAcquireTimeoutMs,
} from "../../agents/session-write-lock.js";
import { redactSecrets } from "../../logging/redact.js";
const TRANSCRIPT_APPEND_SCAN_CHUNK_BYTES = 64 * 1024;
const SESSION_MANAGER_APPEND_MAX_BYTES = 8 * 1024 * 1024;
@@ -290,7 +291,7 @@ async function appendSessionTranscriptMessageLocked(params: {
id: messageId,
...(shouldRawAppend ? {} : { parentId: leafInfo.leafId ?? null }),
timestamp: new Date(now).toISOString(),
message: params.message,
message: redactSecrets(params.message),
};
await fs.appendFile(params.transcriptPath, `${JSON.stringify(entry)}\n`, "utf-8");
return { messageId };

View File

@@ -423,6 +423,40 @@ describe("appendAssistantMessageToSessionTranscript", () => {
}
});
it("redacts structured message content before transcript persistence", async () => {
const sessionFile = resolveSessionTranscriptPathInDir(
"redacted-transcript-session",
fixture.sessionsDir(),
);
await appendSessionTranscriptMessage({
transcriptPath: sessionFile,
message: {
role: "user",
content: [
{
type: "text",
text: "standalone app password abcd-efgh-ijkl-mnop",
},
{
type: "text",
text: "tokens ya29.fake-access-token-with-enough-length",
},
],
toolInput: {
apiKey: "AIzaSyD-very-real-looking-google-api-key-123",
refresh: "1//0fake-refresh-token-with-enough-length",
},
},
});
const raw = fs.readFileSync(sessionFile, "utf-8");
expect(raw).not.toContain("ya29.fake-access-token");
expect(raw).not.toContain("abcd-efgh-ijkl-mnop");
expect(raw).not.toContain("AIzaSyD-very-real-looking");
expect(raw).not.toContain("1//0fake-refresh-token");
});
it("migrates small linear transcripts before appending", async () => {
const sessionFile = resolveSessionTranscriptPathInDir(
"small-linear-session",

View File

@@ -0,0 +1,35 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { readLastGatewayErrorLine } from "./diagnostics.js";
import { resolveGatewayLogPaths } from "./restart-logs.js";
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
function makeTempStateDir(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-daemon-diagnostics-"));
tempDirs.push(dir);
return dir;
}
describe("readLastGatewayErrorLine", () => {
it("ignores stale launchd stderr when stderr is suppressed", async () => {
const stateDir = makeTempStateDir();
const env = { OPENCLAW_STATE_DIR: stateDir };
const { logDir, stdoutPath, stderrPath } = resolveGatewayLogPaths(env);
fs.mkdirSync(logDir, { recursive: true });
fs.writeFileSync(stderrPath, "failed to bind gateway socket stale\n", "utf8");
fs.writeFileSync(stdoutPath, "gateway stdout current\n", "utf8");
await expect(readLastGatewayErrorLine(env, { platform: "darwin" })).resolves.toBe(
"gateway stdout current",
);
});
});

View File

@@ -24,9 +24,13 @@ async function readLastLogLine(filePath: string): Promise<string | null> {
}
}
export async function readLastGatewayErrorLine(env: NodeJS.ProcessEnv): Promise<string | null> {
export async function readLastGatewayErrorLine(
env: NodeJS.ProcessEnv,
options?: { platform?: NodeJS.Platform },
): Promise<string | null> {
const readStderr = (options?.platform ?? process.platform) !== "darwin";
const { stdoutPath, stderrPath } = resolveGatewayLogPaths(env);
const stderrRaw = await fs.readFile(stderrPath, "utf8").catch(() => "");
const stderrRaw = readStderr ? await fs.readFile(stderrPath, "utf8").catch(() => "") : "";
const stdoutRaw = await fs.readFile(stdoutPath, "utf8").catch(() => "");
const lines = [...stderrRaw.split(/\r?\n/), ...stdoutRaw.split(/\r?\n/)].map((line) =>
line.trim(),
@@ -40,5 +44,7 @@ export async function readLastGatewayErrorLine(env: NodeJS.ProcessEnv): Promise<
return line;
}
}
return (await readLastLogLine(stderrPath)) ?? (await readLastLogLine(stdoutPath));
return readStderr
? ((await readLastLogLine(stderrPath)) ?? (await readLastLogLine(stdoutPath)))
: await readLastLogLine(stdoutPath);
}

View File

@@ -614,6 +614,7 @@ describe("launchd install", () => {
const plist = state.files.get(plistPath) ?? "";
expect(plist).toContain("<key>StandardOutPath</key>");
expect(plist).toContain("<key>StandardErrorPath</key>");
expect(plist).toContain("<string>/dev/null</string>");
expect(plist).toContain("<key>KeepAlive</key>");
expect(plist).toContain("<string>node</string>");
const rewriteIndex = state.fileWrites.findIndex((write) => write.path === plistPath);

View File

@@ -41,6 +41,7 @@ const LAUNCH_AGENT_PRIVATE_DIR_MODE = 0o700;
const LAUNCH_AGENT_ENV_FILE_MODE = 0o600;
const LAUNCH_AGENT_ENV_WRAPPER_MODE = 0o700;
const LAUNCH_AGENT_ENV_DIR_NAME = "service-env";
const LAUNCH_AGENT_STDERR_PATH = "/dev/null";
function assertValidLaunchAgentLabel(label: string): string {
const trimmed = label.trim();
@@ -665,7 +666,7 @@ async function writeLaunchAgentPlist({
environment,
description,
}: Omit<GatewayServiceInstallArgs, "stdout">): Promise<{ plistPath: string; stdoutPath: string }> {
const { logDir, stdoutPath, stderrPath } = resolveGatewayLogPaths(env);
const { logDir, stdoutPath } = resolveGatewayLogPaths(env);
await ensureSecureDirectory(logDir);
const domain = resolveGuiDomain();
@@ -702,7 +703,7 @@ async function writeLaunchAgentPlist({
programArguments: prepared.programArguments,
workingDirectory,
stdoutPath,
stderrPath,
stderrPath: LAUNCH_AGENT_STDERR_PATH,
environment: prepared.inlineEnvironment,
});
await fs.writeFile(plistPath, plist, { encoding: "utf8", mode: LAUNCH_AGENT_PLIST_MODE });
@@ -774,7 +775,7 @@ async function rewriteLaunchAgentPlistForRestart({
return;
}
const { logDir, stdoutPath, stderrPath } = resolveGatewayLogPaths(env);
const { logDir, stdoutPath } = resolveGatewayLogPaths(env);
await ensureSecureDirectory(logDir);
const serviceDescription = resolveGatewayServiceDescription({
@@ -793,7 +794,7 @@ async function rewriteLaunchAgentPlistForRestart({
programArguments: prepared.programArguments,
workingDirectory: existing.workingDirectory,
stdoutPath,
stderrPath,
stderrPath: LAUNCH_AGENT_STDERR_PATH,
environment: prepared.inlineEnvironment,
});
await fs.writeFile(plistPath, plist, { encoding: "utf8", mode: LAUNCH_AGENT_PLIST_MODE });

View File

@@ -15,7 +15,7 @@ describe("buildPlatformRuntimeLogHints", () => {
}),
).toEqual([
"Launchd stdout (if installed): /tmp/openclaw-state/logs/gateway.log",
"Launchd stderr (if installed): /tmp/openclaw-state/logs/gateway.err.log",
"Launchd stderr (if installed): suppressed",
"Restart attempts: /tmp/openclaw-state/logs/gateway-restart.log",
]);
});

View File

@@ -17,7 +17,7 @@ export function buildPlatformRuntimeLogHints(params: {
const logs = resolveGatewayLogPaths(env);
return [
`Launchd stdout (if installed): ${toDarwinDisplayPath(logs.stdoutPath)}`,
`Launchd stderr (if installed): ${toDarwinDisplayPath(logs.stderrPath)}`,
"Launchd stderr (if installed): suppressed",
`Restart attempts: ${toDarwinDisplayPath(resolveGatewayRestartLogPath(env))}`,
];
}

View File

@@ -30,7 +30,7 @@ describe("buildPlatformRuntimeLogHints", () => {
}),
).toEqual([
"Launchd stdout (if installed): /tmp/openclaw-state/logs/gateway.log",
"Launchd stderr (if installed): /tmp/openclaw-state/logs/gateway.err.log",
"Launchd stderr (if installed): suppressed",
"Restart attempts: /tmp/openclaw-state/logs/gateway-restart.log",
]);
});

View File

@@ -281,8 +281,8 @@ function redactKnownPathPrefixesForSupport(
}
export function redactTextForSupport(value: string): string {
let redacted = redactSensitiveTextForSupport(value);
redacted = redactCommonCredentialTextForSupport(redacted);
let redacted = redactCommonCredentialTextForSupport(value);
redacted = redactSensitiveTextForSupport(redacted);
redacted = redactUrlSecretsForSupport(redacted);
redacted = redactServiceIdentifiersForSupport(redacted);
redacted = redactContactIdentifiersForSupport(redacted);

View File

@@ -9,6 +9,7 @@ import {
import { getChildLogger, getLogger, resetLogger, setLoggerOverride } from "../logging.js";
import { createSuiteLogPathTracker } from "./log-test-helpers.js";
import { __test__ as loggerTest } from "./logger.js";
import { createDiagnosticLogRecordCapture } from "./test-helpers/diagnostic-log-capture.js";
const secret = "sk-testsecret1234567890abcd";
const TRACE_ID = "4bf92f3577b34da6a3ce929d0e0e4736";
@@ -71,6 +72,58 @@ describe("file log redaction", () => {
expect(content).not.toContain(secret);
});
it("redacts sensitive structured fields before emitting diagnostic log records", async () => {
const logPath = logPathTracker.nextPath();
setLoggerOverride({ level: "info", file: logPath });
const capture = createDiagnosticLogRecordCapture();
try {
getLogger().info(
{
password: "hunter2",
token: "token-value-1234567890",
},
"credential diagnostic",
);
await capture.flush();
const serialized = JSON.stringify(capture.records);
expect(serialized).toContain("credential diagnostic");
expect(serialized).not.toContain("hunter2");
expect(serialized).not.toContain("token-value-1234567890");
expect(capture.records.at(-1)?.attributes?.password).toBe("***");
} finally {
capture.cleanup();
}
});
it("honors logging redaction opt-out for structured file log fields", () => {
const logPath = logPathTracker.nextPath();
const configPath = logPathTracker.nextPath();
fs.writeFileSync(
configPath,
JSON.stringify({
logging: {
redactSensitive: "off",
},
}),
);
process.env.OPENCLAW_CONFIG_PATH = configPath;
setLoggerOverride({ level: "info", file: logPath });
getLogger().info({
token: "token-value-1234567890",
access: "ya29.fake-access-token-with-enough-length",
password: "abcd-efgh-ijkl-mnop",
message: `Authorization: Bearer ${secret}`,
});
const content = fs.readFileSync(logPath, "utf8");
expect(content).toContain("token-value-1234567890");
expect(content).toContain("ya29.fake-access-token-with-enough-length");
expect(content).toContain("abcd-efgh-ijkl-mnop");
expect(content).toContain(secret);
});
it("uses logging.file from the active config path", () => {
const logPath = logPathTracker.nextPath();
const configPath = logPathTracker.nextPath();

View File

@@ -21,7 +21,7 @@ import {
import { readLoggingConfig, shouldSkipMutatingLoggingConfigRead } from "./config.js";
import { resolveEnvLogLevelOverride } from "./env-log-level.js";
import { type LogLevel, levelToMinLevel, normalizeLogLevel } from "./levels.js";
import { redactSensitiveText } from "./redact.js";
import { redactSecrets, redactSensitiveText } from "./redact.js";
import { loggingState } from "./state.js";
import { formatTimestamp } from "./timestamps.js";
import type { LoggerSettings } from "./types.js";
@@ -444,10 +444,20 @@ function buildDiagnosticLogRecord(logObj: TsLogRecord) {
};
}
function isLogRedactionDisabled(): boolean {
return readLoggingConfig()?.redactSensitive === "off";
}
function redactLogRecordForTransport<T extends LogObj>(record: T): T {
return isLogRedactionDisabled() ? record : redactSecrets(record);
}
function attachDiagnosticEventTransport(logger: TsLogger<LogObj>): void {
logger.attachTransport((logObj: LogObj) => {
try {
emitDiagnosticEvent(buildDiagnosticLogRecord(logObj as TsLogRecord));
emitDiagnosticEvent(
buildDiagnosticLogRecord(redactLogRecordForTransport(logObj) as TsLogRecord),
);
} catch {
// never block on logging failures
}
@@ -518,6 +528,8 @@ export function isFileLogLevelEnabled(level: LogLevel): boolean {
function buildLogger(settings: ResolvedSettings): TsLogger<LogObj> {
const logger = new TsLogger<LogObj>({
name: "openclaw",
// Custom structured redaction runs at each transport boundary; avoid tslog pre-masking divergent records.
maskValuesOfKeys: [],
minLevel: levelToMinLevel(settings.level),
type: "hidden", // no ansi formatting
});
@@ -552,9 +564,8 @@ function buildLogger(settings: ResolvedSettings): TsLogger<LogObj> {
const time = formatTimestamp(logObj.date ?? new Date(), { style: "long" });
const traceFields = buildTraceFileLogFields(logObj as TsLogRecord);
const structuredFields = buildStructuredFileLogFields(logObj as TsLogRecord);
const line = redactSensitiveText(
JSON.stringify({ ...logObj, time, ...structuredFields, ...traceFields }),
);
const record = { ...logObj, time, ...structuredFields, ...traceFields };
const line = redactSensitiveText(JSON.stringify(redactLogRecordForTransport(record)));
const payload = `${line}\n`;
const payloadBytes = Buffer.byteLength(payload, "utf8");
const nextBytes = currentFileBytes + payloadBytes;

View File

@@ -4,6 +4,7 @@ import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
getDefaultRedactPatterns,
redactSecrets,
redactSensitiveFieldValue,
redactSensitiveLines,
redactSensitiveText,
@@ -356,6 +357,38 @@ describe("redactSensitiveText", () => {
expect(output).toBe("r8_ABC…stuv");
});
it("masks OAuth and JWT token shapes", () => {
const input = [
"ya29.fake-access-token-with-enough-length",
"1//0fake-refresh-token-with-enough-length",
"eyJheaderabcd.eyJpayloadabcd.signatureabcd123456",
].join(" ");
const output = redactSensitiveText(input, {
mode: "tools",
patterns: defaults,
});
expect(output).not.toContain("ya29.fake-access-token");
expect(output).not.toContain("1//0fake-refresh-token");
expect(output).not.toContain("eyJheaderabcd.eyJpayloadabcd.signatureabcd123456");
});
it("masks app-specific password shapes only in secret contexts", () => {
const input = [
"password=abcd-efgh-ijkl-mnop",
"--password qrst-uvwx-yzab-cdef",
'{"password":"lmno-pqrs-tuvw-xyza"}',
"main-test-case-name",
].join(" ");
const output = redactSensitiveText(input, {
mode: "tools",
patterns: defaults,
});
expect(output).not.toContain("abcd-efgh-ijkl-mnop");
expect(output).not.toContain("qrst-uvwx-yzab-cdef");
expect(output).not.toContain("lmno-pqrs-tuvw-xyza");
expect(output).toContain("main-test-case-name");
});
it("skips redaction when mode is off", () => {
const input = "OPENAI_API_KEY=sk-1234567890abcdef";
const output = redactSensitiveText(input, {
@@ -406,6 +439,65 @@ describe("redactSensitiveText", () => {
});
});
describe("redactSecrets", () => {
it("redacts nested structured payloads before JSON persistence", () => {
const input = {
plugin: {
config: {
apiKey: "AIzaSyD-very-real-looking-google-api-key-123",
access: "ya29.fake-access-token-with-enough-length",
refresh: "1//0fake-refresh-token-with-enough-length",
password: "abcd-efgh-ijkl-mnop",
},
},
transcript: [
{
text: "jwt eyJheaderabcd.eyJpayloadabcd.signatureabcd123456 and main-test-case-name",
},
{
text: "standalone app password abcd-efgh-ijkl-mnop",
errorMessage: "failed with app password qrst-uvwx-yzab-cdef",
},
],
};
const output = redactSecrets(input);
const serialized = JSON.stringify(output);
expect(serialized).not.toContain("AIzaSyD-very-real-looking");
expect(serialized).not.toContain("ya29.fake-access-token");
expect(serialized).not.toContain("1//0fake-refresh-token");
expect(serialized).not.toContain("eyJheaderabcd.eyJpayloadabcd.signatureabcd123456");
expect(serialized).not.toContain("abcd-efgh-ijkl-mnop");
expect(serialized).not.toContain("qrst-uvwx-yzab-cdef");
expect(serialized).toContain("main-test-case-name");
});
it("preserves benign bare access and refresh fields", () => {
const output = redactSecrets({
permissions: {
access: "read",
refresh: "monthly",
},
oauth: {
access: "ya29.fake-access-token-with-enough-length",
refresh: "1//0fake-refresh-token-with-enough-length",
accessToken: "opaque-access-token-value",
refreshToken: "opaque-refresh-token-value",
},
});
expect(output.permissions).toEqual({
access: "read",
refresh: "monthly",
});
const serialized = JSON.stringify(output);
expect(serialized).not.toContain("ya29.fake-access-token");
expect(serialized).not.toContain("1//0fake-refresh-token");
expect(serialized).not.toContain("opaque-access-token-value");
expect(serialized).not.toContain("opaque-refresh-token-value");
});
});
describe("redactSensitiveLines", () => {
it("redacts matching content across all lines", () => {
const resolved = resolveRedactOptions({ mode: "tools", patterns: defaults });

View File

@@ -14,9 +14,24 @@ const PAYMENT_CREDENTIAL_ENV_KEYS = String.raw`CARD[_-]?NUMBER|CARD[_-]?CVC|CARD
const PAYMENT_CREDENTIAL_QUERY_KEYS = String.raw`card[-_]?number|card[-_]?cvc|card[-_]?cvv|cvc|cvv|security[-_]?code|payment[-_]?credential|shared[-_]?payment[-_]?token`;
const PAYMENT_CREDENTIAL_JSON_KEYS = String.raw`cardNumber|card_number|cardCvc|card_cvc|cardCvv|card_cvv|cvc|cvv|securityCode|security_code|paymentCredential|payment_credential|sharedPaymentToken|shared_payment_token`;
const STRUCTURED_SECRET_FIELD_RE = new RegExp(
String.raw`^(?:api[-_]?key|apiKey|token|secret|password|passwd|access[-_]?token|accessToken|refresh[-_]?token|refreshToken|auth[-_]?token|authToken|client[-_]?secret|clientSecret|app[-_]?secret|appSecret|${PAYMENT_CREDENTIAL_QUERY_KEYS}|${PAYMENT_CREDENTIAL_JSON_KEYS})$`,
String.raw`^(?:api[-_]?key|apiKey|token|secret|password|passwd|access[-_]?token|accessToken|refresh[-_]?token|refreshToken|id[-_]?token|idToken|client[-_]?secret|clientSecret|${PAYMENT_CREDENTIAL_QUERY_KEYS}|${PAYMENT_CREDENTIAL_JSON_KEYS})$`,
"i",
);
const STRUCTURED_APP_PASSWORD_FIELD_RE =
/^(?:apple|icloud|app[-_]?specific[-_]?password|appSpecificPassword|application[-_]?password|text|content|message|error|errorMessage|detail|details|reason)$/i;
const APP_SPECIFIC_PASSWORD_RE = /\b([a-z]{4}-[a-z]{4}-[a-z]{4}-[a-z]{4})\b/g;
const BENIGN_APP_PASSWORD_WORDS = new Set([
"case",
"claw",
"demo",
"file",
"main",
"name",
"open",
"path",
"slug",
"test",
]);
const DEFAULT_REDACT_PATTERNS: string[] = [
// ENV-style assignments. Keep this case-sensitive so diagnostics like
@@ -49,6 +64,9 @@ const DEFAULT_REDACT_PATTERNS: string[] = [
String.raw`\b(xapp-[A-Za-z0-9-]{10,})\b`,
String.raw`\b(gsk_[A-Za-z0-9_-]{10,})\b`,
String.raw`\b(AIza[0-9A-Za-z\-_]{20,})\b`,
String.raw`\b(ya29\.[0-9A-Za-z_\-./+=]{10,})\b`,
String.raw`\b(1//0[0-9A-Za-z_\-./+=]{10,})\b`,
String.raw`\b(eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,})\b`,
String.raw`\b(pplx-[A-Za-z0-9_-]{10,})\b`,
String.raw`\b(npm_[A-Za-z0-9]{10,})\b`,
// Additional access-key and token-style prefixes.
@@ -137,6 +155,16 @@ function redactText(text: string, patterns: RegExp[]): string {
return next;
}
function looksLikeAppSpecificPassword(candidate: string): boolean {
return candidate.split("-").every((part) => !BENIGN_APP_PASSWORD_WORDS.has(part.toLowerCase()));
}
function redactAppSpecificPasswords(text: string): string {
return replacePatternBounded(text, APP_SPECIFIC_PASSWORD_RE, (match: string, token: string) =>
looksLikeAppSpecificPassword(token) ? redactMatch(match, [token]) : match,
);
}
function resolveConfigRedaction(): RedactOptions {
const cfg = readLoggingConfig();
return {
@@ -182,6 +210,16 @@ export function redactToolDetail(detail: string): string {
return redactSensitiveText(detail, resolved);
}
function resolveToolPayloadRedaction(): RedactOptions {
const cfg = readLoggingConfig();
const userPatterns = cfg?.redactPatterns;
const patterns =
userPatterns && userPatterns.length > 0
? [...userPatterns, ...DEFAULT_REDACT_PATTERNS]
: undefined;
return { mode: "tools", patterns };
}
// Forces tools-mode regardless of `logging.redactSensitive` (which governs log
// output, not UI surfaces), and merges user `logging.redactPatterns` with the
// built-in defaults so both apply.
@@ -189,18 +227,22 @@ export function redactToolPayloadText(text: string): string {
if (!text) {
return text;
}
const cfg = readLoggingConfig();
const userPatterns = cfg?.redactPatterns;
const patterns =
userPatterns && userPatterns.length > 0
? [...userPatterns, ...DEFAULT_REDACT_PATTERNS]
: undefined;
return redactSensitiveText(text, { mode: "tools", patterns });
return redactSensitiveText(text, resolveToolPayloadRedaction());
}
export function redactSensitiveFieldValue(key: string, value: string): string {
const redacted = redactToolPayloadText(value);
if (redacted !== value) {
function redactSensitiveFieldValueWithOptions(
key: string,
value: string,
options: RedactOptions,
): string {
const redacted = redactSensitiveText(value, options);
const shouldRedactAppPassword = redacted !== value || STRUCTURED_APP_PASSWORD_FIELD_RE.test(key);
if (shouldRedactAppPassword) {
const appRedacted = redactAppSpecificPasswords(redacted);
if (appRedacted !== value) {
return appRedacted;
}
} else if (redacted !== value) {
return redacted;
}
if (STRUCTURED_SECRET_FIELD_RE.test(key)) {
@@ -209,6 +251,71 @@ export function redactSensitiveFieldValue(key: string, value: string): string {
return value;
}
export function redactSensitiveFieldValue(key: string, value: string): string {
return redactSensitiveFieldValueWithOptions(key, value, resolveToolPayloadRedaction());
}
function isPlainRedactableObject(value: object): value is Record<string, unknown> {
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
function redactStructuredSecretValue(
key: string,
value: unknown,
seen: WeakSet<object>,
options: RedactOptions,
): unknown {
if (typeof value === "string") {
return redactSensitiveFieldValueWithOptions(key, value, options);
}
if (value === null || value === undefined) {
return value;
}
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
return value;
}
if (Array.isArray(value)) {
if (seen.has(value)) {
return "[Circular]";
}
seen.add(value);
const out = value.map((entry) => redactStructuredSecretValue(key, entry, seen, options));
seen.delete(value);
return out;
}
if (typeof value === "object") {
if (seen.has(value)) {
return "[Circular]";
}
if (!isPlainRedactableObject(value)) {
return value;
}
seen.add(value);
const out: Record<string, unknown> = {};
for (const [nestedKey, nestedValue] of Object.entries(value)) {
out[nestedKey] = redactStructuredSecretValue(nestedKey, nestedValue, seen, options);
}
seen.delete(value);
return out;
}
return value;
}
export function redactSecrets<T>(value: T): T {
const options = resolveToolPayloadRedaction();
if (typeof value === "string") {
return redactSensitiveText(value, options) as T;
}
if (value === null || value === undefined) {
return value;
}
if (typeof value !== "object") {
return value;
}
return redactStructuredSecretValue("", value, new WeakSet<object>(), options) as T;
}
export function getDefaultRedactPatterns(): string[] {
return [...DEFAULT_REDACT_PATTERNS];
}

View File

@@ -80,6 +80,8 @@ describe("trajectory runtime", () => {
systemPrompt: "system prompt",
headers: [{ name: "Authorization", value: "Bearer sk-test-secret-token" }],
command: "curl -H 'Authorization: Bearer sk-other-secret-token'",
oauth: "ya29.fake-access-token-with-enough-length",
apple: "abcd-efgh-ijkl-mnop",
tools: toTrajectoryToolDefinitions([
{ name: "z-tool", parameters: { z: 1 } },
{ name: "a-tool", description: "alpha", parameters: { a: 1 } },
@@ -98,6 +100,8 @@ describe("trajectory runtime", () => {
]);
expect(JSON.stringify(parsed.data)).not.toContain("sk-test-secret-token");
expect(JSON.stringify(parsed.data)).not.toContain("sk-other-secret-token");
expect(JSON.stringify(parsed.data)).not.toContain("ya29.fake-access-token");
expect(JSON.stringify(parsed.data)).not.toContain("abcd-efgh-ijkl-mnop");
});
it("bounds large runtime event fields before serialization", () => {

View File

@@ -3,6 +3,7 @@ import path from "node:path";
import { sanitizeDiagnosticPayload } from "../agents/payload-redaction.js";
import { getQueuedFileWriter, type QueuedFileWriter } from "../agents/queued-file-writer.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { redactSecrets } from "../logging/redact.js";
import { parseBooleanValue } from "../utils/boolean.js";
import { safeJsonStringify } from "../utils/safe-json.js";
import {
@@ -201,7 +202,10 @@ function limitTrajectoryPayloadValue(
}
function sanitizeTrajectoryPayload(data: Record<string, unknown>): Record<string, unknown> {
return sanitizeDiagnosticPayload(limitTrajectoryPayloadValue(data)) as Record<string, unknown>;
return redactSecrets(sanitizeDiagnosticPayload(limitTrajectoryPayloadValue(data))) as Record<
string,
unknown
>;
}
export function toTrajectoryToolDefinitions(