@@ -3,8 +3,10 @@ import path from "node:path";
33import type { OpenClawConfig } from "../config/types.openclaw.js" ;
44import {
55 AVATAR_MAX_BYTES ,
6+ hasAvatarUriScheme ,
67 isAvatarDataUrl ,
78 isAvatarHttpUrl ,
9+ isWindowsAbsolutePath ,
810 isPathWithinRoot ,
911 isSupportedLocalAvatarExtension ,
1012} from "../shared/avatar-policy.js" ;
@@ -15,10 +17,18 @@ import { loadAgentIdentityFromWorkspace } from "./identity-file.js";
1517import { resolveAgentIdentity } from "./identity.js" ;
1618
1719export type AgentAvatarResolution =
18- | { kind : "none" ; reason : string }
19- | { kind : "local" ; filePath : string }
20- | { kind : "remote" ; url : string }
21- | { kind : "data" ; url : string } ;
20+ | { kind : "none" ; reason : string ; source ?: string }
21+ | { kind : "local" ; filePath : string ; source : string }
22+ | { kind : "remote" ; url : string ; source : string }
23+ | { kind : "data" ; url : string ; source : string } ;
24+
25+ type AgentAvatarPublicSourceInput = {
26+ kind : AgentAvatarResolution [ "kind" ] ;
27+ source ?: string | null ;
28+ } ;
29+
30+ const PUBLIC_AVATAR_SOURCE_MAX_CHARS = 256 ;
31+ const PUBLIC_DATA_AVATAR_HEADER_MAX_CHARS = 64 ;
2232
2333function resolveAvatarSource (
2434 cfg : OpenClawConfig ,
@@ -80,6 +90,42 @@ function resolveLocalAvatarPath(params: {
8090 return { ok : true , filePath : realPath } ;
8191}
8292
93+ function isSafeRelativeAvatarSource ( source : string ) : boolean {
94+ if (
95+ source . length > PUBLIC_AVATAR_SOURCE_MAX_CHARS ||
96+ source . startsWith ( "~" ) ||
97+ path . isAbsolute ( source ) ||
98+ isWindowsAbsolutePath ( source ) ||
99+ ( hasAvatarUriScheme ( source ) && ! isWindowsAbsolutePath ( source ) ) ||
100+ source . includes ( "\0" )
101+ ) {
102+ return false ;
103+ }
104+ const parts = source . replace ( / \\ / g, "/" ) . split ( "/" ) ;
105+ return parts . every ( ( part ) => part !== ".." ) ;
106+ }
107+
108+ export function resolvePublicAgentAvatarSource (
109+ resolved : AgentAvatarPublicSourceInput ,
110+ ) : string | undefined {
111+ const source = normalizeOptionalString ( resolved . source ) ?? null ;
112+ if ( ! source ) {
113+ return undefined ;
114+ }
115+ if ( isAvatarDataUrl ( source ) ) {
116+ const commaIndex = source . indexOf ( "," ) ;
117+ const header =
118+ commaIndex > 0
119+ ? source . slice ( 0 , Math . min ( commaIndex , PUBLIC_DATA_AVATAR_HEADER_MAX_CHARS ) )
120+ : source . slice ( 0 , PUBLIC_DATA_AVATAR_HEADER_MAX_CHARS ) ;
121+ return `${ header } ,...` ;
122+ }
123+ if ( isAvatarHttpUrl ( source ) ) {
124+ return "remote URL" ;
125+ }
126+ return isSafeRelativeAvatarSource ( source ) ? source : undefined ;
127+ }
128+
83129export function resolveAgentAvatar (
84130 cfg : OpenClawConfig ,
85131 agentId : string ,
@@ -90,15 +136,15 @@ export function resolveAgentAvatar(
90136 return { kind : "none" , reason : "missing" } ;
91137 }
92138 if ( isAvatarHttpUrl ( source ) ) {
93- return { kind : "remote" , url : source } ;
139+ return { kind : "remote" , url : source , source } ;
94140 }
95141 if ( isAvatarDataUrl ( source ) ) {
96- return { kind : "data" , url : source } ;
142+ return { kind : "data" , url : source , source } ;
97143 }
98144 const workspaceDir = resolveAgentWorkspaceDir ( cfg , agentId ) ;
99145 const resolved = resolveLocalAvatarPath ( { raw : source , workspaceDir } ) ;
100146 if ( ! resolved . ok ) {
101- return { kind : "none" , reason : resolved . reason } ;
147+ return { kind : "none" , reason : resolved . reason , source } ;
102148 }
103- return { kind : "local" , filePath : resolved . filePath } ;
149+ return { kind : "local" , filePath : resolved . filePath , source } ;
104150}
0 commit comments