mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-16 18:34:18 +00:00
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:
committed by
GitHub
parent
be8bf3585e
commit
17ceca86d6
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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",
|
||||
|
||||
35
src/daemon/diagnostics.test.ts
Normal file
35
src/daemon/diagnostics.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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))}`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user